Skip to content

feat(gateway): guard repo side effects with workspace binding#17938

Open
nepenth wants to merge 14 commits into
NousResearch:mainfrom
nepenth:feat/gateway-workspace-side-effect-guard
Open

feat(gateway): guard repo side effects with workspace binding#17938
nepenth wants to merge 14 commits into
NousResearch:mainfrom
nepenth:feat/gateway-workspace-side-effect-guard

Conversation

@nepenth

@nepenth nepenth commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds workspace-binding guards for gateway-initiated repo side effects.
  • Blocks file writes/patches into git repos when a gateway session is unbound or bound to a different repo.
  • Blocks detected mutating git terminal commands outside the authoritative workspace binding while preserving read-only git commands and non-repo scratch writes.
  • Fails closed on unverifiable Git target-selection forms, including --git-dir, --work-tree, unsafe Git env vars, shell-expanded -C/cd paths, subshell cd, 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, and HERMES_WORKSPACE_REPO_PATH coexist 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

Test plan

Latest verification after the multi-invocation / alias hardening fix:

  • RED check before implementation: the new regression tests failed for non-repo git mutation short-circuiting and 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 passed
  • python -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 passed
  • python -m py_compile tools/workspace_safety.py tools/terminal_tool.py tools/file_tools.py gateway/session_context.py gateway/workspace_registry.py → passed
  • git diff --check → passed
  • Static added-line scan for obvious shell/secrets hazards → clean
  • Independent review after the final hardening pass → passed; no security concerns or logic errors.

Earlier 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

  • Credentials/secrets: no changes.
  • Shell/subprocess execution: adds a pre-execution guard for detected mutating git commands in gateway sessions.
  • Filesystem writes: adds a pre-write guard for file write and patch tools when targeting git repositories from gateway sessions.
  • Network/API behavior: no outbound network behavior changes.
  • Logging sensitive identifiers: no new logging of room IDs, tokens, private identifiers, workspace repo paths, or private repo URLs.
  • Workspace repo path/URL values are session-local operational metadata for safety checks. They may appear in the session prompt/env for the active agent, but should not be logged, quoted, screenshotted, or otherwise disclosed unless the user explicitly asks.

Explicit scope / non-goals

  • Guards file-tool writes/patches when the target path is inside a git repository.
  • Guards all detected mutating git invocations in a shell command and only returns success after scanning the full command.
  • Guards detected mutating git commands when the command targets an existing git repository.
  • Fails closed on common unverifiable git target-selection forms, including --git-dir, --work-tree, unsafe Git env vars, shell-expanded -C/cd paths, subshell cd, unsafe git runtime config, include.path, includeIf.*.path, alias.*, and unknown git subcommands that may resolve to aliases/extensions.
  • Allows quoted parentheses in normal git arguments such as conventional commit messages.
  • Allows non-repo scratch writes and preserves non-repo scratch git behavior while continuing to inspect later git invocations in the same command.
  • This is not a full shell sandbox. It handles simple 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.
  • The guard verifies side effects targeting existing git repositories. It allows non-repo scratch work, including commands run outside a git checkout, unless existing dangerous-command approval blocks them separately.
  • Does not prevent creating/cloning new repos from non-repo scratch directories.
  • Non-gateway sessions and non-repo paths remain unchanged.

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-only git status plus cd, 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 passed targeted 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 fetch is treated as a repo side effect and guarded.

  • Confirmed mutating git remote and git branch subcommands/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-tree git invocations and malformed shell segments containing git.

  • 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-expanded cd/git -C paths, and subshell cd ... && git ... chains.

  • Added fail-closed hardening for Git config redirection: git -c core.worktree=..., inline git -ccore.worktree=..., GIT_CONFIG_* env assignments, and git --config-env core.worktree=....

  • Added final fail-closed hardening for Git config includes: include.path and includeIf.*.path, because included config can set core.worktree or 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:

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery tool/file File tools (read, write, patch, search) tool/terminal Terminal execution and process management labels Apr 30, 2026
teknium1 and others added 7 commits April 30, 2026 10:31
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.
@nepenth nepenth force-pushed the feat/gateway-workspace-side-effect-guard branch from dd92ea1 to 67b8484 Compare April 30, 2026 14:45
@nepenth nepenth marked this pull request as ready for review April 30, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists tool/file File tools (read, write, patch, search) tool/terminal Terminal execution and process management type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants