feat(gateway): guard repo side effects with workspace binding#17938
Open
nepenth wants to merge 14 commits into
Open
feat(gateway): guard repo side effects with workspace binding#17938nepenth wants to merge 14 commits into
nepenth wants to merge 14 commits into
Conversation
This was referenced Apr 30, 2026
CI Tests workflow has been red on main for 40+ consecutive runs. This commit recovers every failure visible in run 25130722163 (most recent completed run prior to this PR). Root causes, by group: Test-mock drift after product landed (fix: update mocks) - test_mcp_structured_content / test_mcp_dynamic_discovery (6 tests): product added _rpc_lock (#02ae15222) and _schedule_tools_refresh (#1350d12b0) without updating sibling test files. Install a real asyncio.Lock inside the fake run-loop and patch at _schedule_tools_refresh. - test_session.py: renamed normalize_whatsapp_identifier → canonical_ whatsapp_identifier upstream; keep a local alias so the legacy tests keep working. - test_run_progress_topics Slack DM test: PR NousResearch#8006 made Slack default tool_progress=off; explicitly set it to 'all' in the test fixture so the progress-callback path still runs. Also read tool_progress_callback at call time rather than freezing it in FakeAgent.__init__ — production assigns it AFTER construction. - test_tui_gateway_server session-create/close race: session.create now defers _start_agent_build behind a 50ms timer — wait for the build thread to enter _make_agent before closing, otherwise the orphan- cleanup path never runs. - test_protocol session.resume: product get_messages_as_conversation now takes include_ancestors kwarg; accept **_kwargs in the test stub. - test_copilot_acp_client redaction: redactor is OFF by default (snapshots HERMES_REDACT_SECRETS at import); patch agent.redact._REDACT_ENABLED=True for the duration of the test. - test_minimax_provider: after NousResearch#17171, dots in non-Anthropic model names stay dots even with preserve_dots=False. Assert the new invariant rather than the old 'broken for MiniMax' behavior. - test_update_autostash: updater now scans `ps -A` for dashboard PIDs; the test's catch-all subprocess.run stub needed stdout/stderr fields. - test_accretion_caps: read_timestamps dict is populated lazily when os.path.getmtime succeeds. Use .get("read_timestamps", {}) to tolerate CI filesystems where the stat races file creation. Change-detector tests (fix: rewrite as structural invariants) - test_credential_sources_registry_has_expected_steps: was a frozen set comparison that broke when minimax-oauth was added. Rewrite as an invariant check (every step has description, no dupes, core steps present) per AGENTS.md 'don't write change-detector tests'. xdist ordering / test pollution (fix: reset state, use module-local patches) - test_setup vercel: sibling test saved VERCEL_PROJECT_ID='project' to os.environ via save_env_value() and never cleared it. monkeypatch.delenv the VERCEL_* vars in the link-file test. - test_clipboard TestIsWsl: GitHub Actions is on Azure VMs whose real /proc/version often contains 'microsoft'. Patching builtins.open with mock_open didn't reliably intercept hermes_constants.is_wsl's call in xdist workers that had already cached _wsl_detected=True from an earlier test. Patch hermes_constants.open directly and add teardown_method to reset the cache after each test. Pytest-asyncio cancellation hangs (fix: bound product await with timeout) - test_session_split_brain_11016 (3 params) + test_gateway_shutdown cancel-inflight: under pytest-asyncio 1.3.0, 'await task' and 'asyncio.gather(cancelled_tasks)' can stall for 30s when the cancelled task's finally block awaits typing-task cleanup. Bound both with asyncio.wait_for(..., timeout=5.0) and asyncio.shield — the stragglers are released from adapter tracking and allowed to finish unwinding in the background. This is also a legitimate hardening: a wedged finally shouldn't stall the caller's dispatch or a gateway shutdown. Orphan UI config (fix: merge tiny tab into messaging category) - test_web_server test_no_single_field_categories: the telegram.reactions config field lived in its own 'telegram' schema category with no siblings. Fold it under 'discord' via _CATEGORY_MERGE so the dashboard doesn't render an orphan single-field tab. Local verification: 38/38 originally-failing tests pass; 4044/4044 gateway tests pass; 684/684 targeted subset (all 16 touched test files) passes.
dd92ea1 to
67b8484
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
--git-dir,--work-tree, unsafe Git env vars, shell-expanded-C/cdpaths, subshellcd, unsafe Git runtime config,include.path,includeIf.*.path,alias.*, and unknown git subcommands that may resolve to aliases/extensions, while allowing quoted parentheses in normal commit messages.Scope / stacking and rebase note
This branch is stacked after #17711 so the final branch contains both normalized channel identity contextvars and workspace binding contextvars. The latest rebase/integration pass verified that
HERMES_SESSION_CHANNEL_IDENTITY,HERMES_WORKSPACE_SLUG, andHERMES_WORKSPACE_REPO_PATHcoexist in the session env path.Until prerequisite context/registry work merges, this PR diff includes the earlier channel identity/workspace registry commits as well as the side-effect guard commits. After #17711 lands, this branch should be rebased again so the final diff excludes unrelated channel-identity commits.
Related reconnaissance
workspace registry,workspace binding, and this fork branch head.Test plan
Latest verification after the multi-invocation / alias hardening fix:
git -c alias.*shell alias injection before the guard changes.python -m pytest tests/tools/test_workspace_safety.py::test_second_git_invocation_wrong_repo_is_blocked tests/tools/test_workspace_safety.py::test_allowed_first_git_then_cd_wrong_repo_git_is_blocked tests/tools/test_workspace_safety.py::test_non_repo_git_mutation_does_not_short_circuit_later_wrong_repo tests/tools/test_workspace_safety.py::test_git_config_alias_shell_fails_closed -q -o 'addopts='→ 4 passedpython -m pytest tests/tools/test_workspace_safety.py::test_unknown_git_subcommand_alias_fails_closed_from_bound_repo -q -o 'addopts='→ failed before the unknown-subcommand fail-closed patch, then passed after implementation.python -m pytest tests/tools/test_workspace_safety.py tests/gateway/test_workspace_registry.py -q -o 'addopts='→ 41 passedpython -m py_compile tools/workspace_safety.py tools/terminal_tool.py tools/file_tools.py gateway/session_context.py gateway/workspace_registry.py→ passedgit diff --check→ passedEarlier verification on this PR line also covered the #17711 session-env coexistence path (
tests/gateway/test_session_env.py) and the Git runtime-config include hardening (include.path/includeIf.*.path).Security / platform notes
Explicit scope / non-goals
--git-dir,--work-tree, unsafe Git env vars, shell-expanded-C/cdpaths, subshellcd, unsafe git runtime config,include.path,includeIf.*.path,alias.*, and unknown git subcommands that may resolve to aliases/extensions.cd/git -C/ env-prefix forms and fails closed on common unverifiable git-targeting constructs. It does not attempt to parse or sandbox arbitrary shell programs that themselves write files.Review feedback remediation (2026-04-30)
Final external-review blocker remediation (2026-04-30)
Fixed the blocker where an allowed earlier mutating git invocation could stop evaluation before later invocations in the same shell command. The guard now continues across all detected git invocations and only returns success after the loop.
Added regressions for a wrong-repo second invocation after
git add, after read-onlygit statuspluscd, and after a non-repo scratch mutation.Blocked
alias.*runtime Git config assignments so command-local shell aliases cannot redirect mutations to another repo.Added fail-closed handling for unknown git subcommands because they may resolve to repo/global aliases or extensions whose target repo cannot be verified from cwd alone.
Updated the test plan from stale counts to the fresh
41 passedtargeted workspace-safety/registry run.Confirmed and retained guard coverage for
git -C <path>so the git invocation target repo, not only terminal cwd, is checked.Confirmed
git fetchis treated as a repo side effect and guarded.Confirmed mutating
git remoteandgit branchsubcommands/flags are classified as guarded mutations while read-only forms remain allowed.Confirmed simple
cd <path> && git ...handling so common wrong-repo command chains resolve against the changed cwd.Tightened fail-closed behavior for explicit
--git-dir/--work-treegit invocations and malformed shell segments containinggit.Added second-review hardening for absolute git binaries such as
/usr/bin/git, unsafe Git env vars (GIT_DIR,GIT_WORK_TREE, related repo redirection vars), shell-expandedcd/git -Cpaths, and subshellcd ... && git ...chains.Added fail-closed hardening for Git config redirection:
git -c core.worktree=..., inlinegit -ccore.worktree=...,GIT_CONFIG_*env assignments, andgit --config-env core.worktree=....Added final fail-closed hardening for Git config includes:
include.pathandincludeIf.*.path, because included config can setcore.worktreeor other target-selection config after parsing.Fixed the quoted-parentheses false positive so conventional commit scopes like
fix(matrix): ...are allowed while unquoted subshell/grouping syntax still fails closed.Added regression coverage for all of the above plus the feat(gateway): expose normalized channel identity #17711 coexistence path for channel identity and workspace contextvars.
Relationship to adjacent PRs
This is the enforcement layer. It uses workspace bindings derived from gateway source/channel context to guard repo side effects.
Related: