diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md new file mode 100644 index 00000000000..8dd65e97303 --- /dev/null +++ b/docs/design/daemon-git-worktree-guard.md @@ -0,0 +1,269 @@ +# Daemon Git worktree guard + +## Context + +A daemon ACP session is owned by one bound workspace. The model shell tool +already rejects an explicit `directory` outside its effective workspace, but a +Git command can relocate itself with `-C`, `--work-tree`, or `--git-dir` while +the shell process still starts inside the workspace. This can let a daemon +agent mutate another checkout or worktree after the direct directory form was +rejected. + +## Scope + +The guard applies only to model tool execution through the managed daemon ACP +path. It does not change CLI or TUI shell validation, Git safety classification, +permission rules, confirmation behavior, or direct user shell execution. + +The daemon enables its managed tool guard for every ACP child. The host owns +the session's effective working directory and adds it to the validated guard +request before applying the built-in policy. An optional external tool guard +remains an additional policy and receives the same request only after the +built-in policy allows it. + +## Policy + +The built-in guard inspects the tools that hand the host a shell command line: +`run_shell_command` and `monitor`, which spawns its `command` through the same +shell and carries the same `directory` argument. Command splitting +reuses core `splitCommands`; containment reuses core `realpathNearestExisting` +and `isWithinRoot`. It recognizes Git invocations whose repository location is +changed by literal forms of: + +- `git -C ` and `git -C` +- `git --work-tree ` and `git --work-tree=` +- `git --git-dir ` and `git --git-dir=` +- leading `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, or `GIT_INDEX_FILE` + assignments +- the same assignments made through `export`/`declare`/`typeset`/`readonly`/`local` + (or plain assignments under `set -a`), which stay in the environment of + every later command in the same chain rather than only their own run. A + name-only `export GIT_DIR` exports the value an earlier shell-local + assignment left in that name, and an unresolvable assignment (`+=`, a + dynamic value, `set -o $OPT`) is recorded as an unresolved relocation +- directory-shifting wrapper flags `env -C`/`--chdir` and `sudo -D`/`--chdir` +- `cd`, `pushd`, or `popd` builtins earlier in the same command chain, whose + targets become the containment basis for later Git invocations in that chain + +Wrapper prefixes are unwrapped before Git detection: leading env assignments, +`command`, `builtin`, `env` (with its value-taking flags), `sudo` (with its +value-taking +flags), `nohup`, `exec`, `timeout `, `sh|bash|dash|zsh|ksh -c` +payloads (analyzed recursively, keeping the outermost run's entry cwd as the +containment basis so a preceding `cd` cannot disappear inside the wrapper), +`eval` payloads (analyzed recursively, with cwd changes propagated because +`eval` runs in the current shell), path-qualified Git binaries by basename, +and leading shell keywords and reserved words (`{`, `}`, `!`, `if`, `then`, +`else`, `elif`, `fi`, `for`, `do`, `done`, `while`, `until`, `in`, `case`, +`esac`, `time`, `coproc`), which can lead a split segment without changing +what executes. `cd` option words (`-L`, `-P`, `-e`, `-@`, `-q`, `-s`, `--`) are +skipped when locating the directory operand — `pushd`/`popd` treat any +leading `-`/`+` word as unresolvable instead, so containment is evaluated +against the directory the shell actually enters. A segment whose program token +cannot be classified — including one the daemon cannot read at all (`$CMD`) — +fails closed when the segment still references Git and +carries a relocation marker (token-level or inside a quoted payload, where a +`cd`/`pushd` counts as one because `su -c 'cd && git reset --hard'` +relocates just as effectively as `-C`), a +recorded relocation, an unresolved prefix, or a tracked working directory that +is unknown or already outside the boundary — `cd && nice git reset +--hard` is denied on that last clause. The Git word is matched +case-insensitively, because the program-word classification lowercases and a +case-insensitive filesystem runs `GIT` and `git` alike. A `-c` payload that is +dynamic +(`sh -c "$CMD"`) or fused +into the flag token (`bash -c'cmd'`, read from the same token) is analyzed +after extraction; `env -S` payloads follow the same rules in both their spaced +and fused (`env -S'cmd'`) forms; an undecidable payload is denied rather than +allowed. + +Command substitutions (`$(…)` and backticks) execute before the command they +are embedded in, so their bodies are extracted from the raw segment and +analyzed as nested commands against the current tracked directory; their own +`cd` changes stay inside the substitution. `$((…))` is arithmetic and is +stepped over, though a substitution nested inside it is still analyzed. An +unterminated substitution is denied as unparseable. + +A sub-agent pinned to a worktree (`working_dir`, or `isolation`, which +rebinds the child Config's cwd surfaces) executes there while still reporting +the parent session id, so the session's own directory is not where the +command runs. The child reports that directory alongside the request; it is +untrusted, so the daemon accepts it only where it can verify it from state it +owns — inside the session's effective working directory, or inside the +worktree tree that session owns (`GitWorktreeService.getWorktreesDir()`). Anywhere else the scope cannot be established and the call fails +closed. When an owned worktree is accepted it becomes the boundary, so an +isolated sub-agent is contained to its own worktree instead of to its +parent's checkout. + +Relative targets resolve from the command's effective starting directory: +`arguments.directory` when present, otherwise the session's current effective +working directory. A model-supplied `directory` is itself canonicalized and +checked against the effective working directory before it is trusted as the +containment basis. The bridge supplies the current directory from trusted +session state. The current effective +working directory is the allowed execution boundary so a session moved through +the controlled daemon `/cd` flow can operate in its selected worktree without +being mistaken for an escape from the original storage owner. Git applies `-C` +during option parsing and resolves relative `--git-dir`/`--work-tree` against +the post-`-C` cwd, so relative targets resolve against the final cwd of the +`-C` chain regardless of argv order. + +A statically resolved Git relocation is denied when both of the following +hold: + +1. its target is outside the session's effective working directory after + canonical path resolution; +2. its Git subcommand is mutating or cannot be classified as read-only. + +Relocated commands whose subcommand is in a small verified read-only set +(`rev-parse`, `cat-file`) remain allowed. `diff`, +`log`, `show`, and `blame` are excluded from that set: `--output` writes +files, and textconv-style drivers execute programs configured by the target +repository. `grep` takes the same `--textconv` path, `status` and `ls-files` both run the +target repository's `core.fsmonitor` (`ls-files` executes the hook even +though it writes no index), and +`describe --dirty`/`--broken` rewrite the target index whenever its stat +cache is stale — a plain `describe` does not, but the flag is one token +away — so none of them is read-only here. A `--output`, `--textconv`, or `--filters` flag +demotes an invocation wherever it appears: the first writes a file, and the +other two run the target repository's configured drivers even for an +allowlisted subcommand (`git -C cat-file --textconv --path=f HEAD:f` +executes its `diff..textconv` command). Commands with no recognized +relocation retain existing behavior. +Dynamic relocation targets (`$` expansions, backticks, leading `~`, globs) +and command-executing `-c`/`--config-env` assignments are denied regardless of +the subcommand — the check runs before the read-only allowance because even +`status` executes a target-repo-configured `core.fsmonitor` — because the +daemon cannot prove that the target remains inside the effective working +directory. The command-executing keys are `alias.*`, `core.askPass`, +`core.editor`, `core.fsmonitor`, `core.pager`, `core.sshCommand`, +`credential.helper`, `diff..command`, `diff..textconv`, +`difftool.*`, `filter.*`, `gpg.program`, `merge..driver`, +`mergetool.*`, `pager.*`, `sequence.editor`, and +`uploadpack.packObjectsHook`, `core.hooksPath` and `gpg..program`, +matched case-insensitively because Git config keys are; any value starting +with `!` counts too. The check runs before the read-only allowance and +independently of relocation, so such a `-c` is denied even in the session's +own repository. + +`GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_CONFIG`, +`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM` and `SHELLOPTS` name no repository +the containment check can resolve but do move where git writes or which +config it reads (measured: `GIT_OBJECT_DIRECTORY=/.git/objects git +add` writes the blob there), so they mark the invocation unresolved. So do +`PATH`/`GIT_EXEC_PATH`, which decide which `git` binary runs at all. + +Git global options that consume the next argv entry (`--namespace`, +`--super-prefix`, `--shallow-file`, `--attr-source`) are modelled as such: +leaving one out would make its value look like the subcommand, ending option +parsing and hiding every relocation after it. + +`--git-dir` is evaluated by the repository git operates on, with +canonicalization before basename handling: a target whose canonical form ends +in `.git` uses its parent; a `.git` gitfile is followed through its `gitdir:` +redirect; a per-worktree administrative directory +(`/.git/worktrees/`) is resolved through its `gitdir` file to the +linked worktree checkout. Unresolvable indirections fail closed. + +## Failure semantics + +Malformed managed guard requests, stale session or prompt ownership, missing +trusted effective working directory, policy exceptions, and malformed +external-provider responses fail closed before execution. Unparseable commands, dangling +relocation options, relocation targets that do not fully exist at decision +time (a missing target can still become an outward symlink before git runs), +and unreadable Git indirections are denied for mutating or unclassifiable +subcommands. A built-in denial is final and is not sent to the optional +provider. Denial reasons are length-clamped and control-character-stripped so +they always satisfy the guard result validation. + +The managed guard plumbing is active for every daemon ACP child because the +built-in policy needs it. The child-side v1 restrictions (`/fork` and +agent-backed workspace memory remember/dream) key on the external provider +being attached, not on the plumbing's mere presence: under the built-in guard +alone, hidden-agent tool calls traverse the same managed guard and are +inspected by the same daemon-side policy. Subagent reasoning loops, cron +turns, background notifications, and resumed background agents run without an +invocation context by design; their shell calls fall back to the +scheduler-owned session identity and are validated by session ownership +alone, because the built-in policy needs the effective working directory, +not a live prompt. Consulting the external provider always requires a prompt +binding, so a prompt-less request with a provider attached fails closed. +Without a provider the child also resolves every non-shell tool call locally +(the built-in policy allows them structurally) instead of paying a +child-daemon-child round trip per call; `run_shell_command` and `monitor` +always make the round trip. With a provider attached every prompt-bound call +still makes it. + +## Limitations + +The guard is a containment control against mis-targeted Git invocations +expressed in the literal forms above. It is not a sandbox against a +prompt-injected agent: script-file contents are not read, variable values are +not tracked across commands, and program words outside the unwrapped set are +handled by failing closed on Git-shaped runs rather than by modelling their +execution semantics. + +### Why this cannot be made complete here + +The guard decides by reading command **text** before a shell interprets it, +and that gap is structural rather than a list of unfixed cases. Seven rounds +of adversarial review on this change bear it out: each round closed the +reported bypasses and each following round found more, several of them in the +rules added by the round before. The parser is now several times the size of +the policy it protects, and the shell's semantics — quoting modes, expansion +order, subshell boundaries, deferred bodies, environment attributes — remain +larger than any token scan of them. + +So the promise here is deliberately bounded: + +- **Reliable** against Git relocation written in the literal forms this + document lists. That is the case the control exists for: an agent that + mis-targets a sibling checkout, a stale `-C`, a `cd` that outlived its + purpose. +- **Best-effort, not a boundary**, against shell text written to defeat it. + Constructions that hide the relocation from a static reader — variable + indirection, generated payloads, exotic quoting, program words the daemon + cannot model — may pass. New ones will keep being found. + +Treating it as more than that would be the actual risk: an operator who +believes the daemon cannot mutate a sibling worktree will grant it broader +trust than the mechanism earns. + +Closing the gap properly means moving the decision off the text. The +enforcement point, not the parser, is what would converge — deciding where a +command may write when it runs (a restricted working directory, a mount or +namespace view, or interception at the Git invocation rather than the shell +line) instead of predicting it beforehand. That is a separate change with its +own design; this one should not grow into it by accretion. + +## Non-goals + +- No changes to core `ShellTool`, `ShellToolInvocation`, shell AST parsing, + `PermissionManager`, or `evaluatePermissionFlow`. `CoreToolScheduler` and + `speculation.ts` gain one additive field — the scheduler-owned `sessionId` + on the guard context — and no behavior change: hosts that ignore it see + exactly the previous flow. +- No new confirmation flow or linked-worktree exception. +- No restriction on direct user-entered daemon shell commands. +- No general shell interpreter or environment-variable analysis: script files + run by `bash script.sh` or `source` are not read, and variable values are + not tracked across commands. +- No resolution of the `sh` implementation: only `bash` imports `export -f` + functions, but `sh` is bash on macOS and dash elsewhere. The basename cannot + say which, so the guard never replays an exported shadow for `sh -c` — + importing it on a dash-backed `sh` would recreate the escape. It fails + closed, over-denying the bash-backed case (a false positive, not a bypass). + `env -i`/`-`/`--ignore-environment` likewise drop the exported functions + before a bash child starts, so they are not imported into that payload. +- No revocation of a recorded relocation: `unset GIT_DIR` and `env -u GIT_DIR` + later in the same chain do not clear an exported GIT\_\* relocation, so such a + chain can be denied even though the real shell would run it inside the + session (a fail-closed false positive, not a bypass). +- No heredoc body analysis: `splitCommands` has no heredoc state, so a + heredoc body is scanned as ordinary command lines. Usually that only + over-denies (Git-shaped text the shell merely writes to a file), but the + direction is not guaranteed — a body can also shift the parse — so treat it + as unanalyzed rather than as fail-closed. +- No attempt to correlate a denial with a previous tool call. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9835f423235..118af25ecf2 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -442,43 +442,43 @@ operator diagnostic snapshot documented below. -| Tag | Advertised when … | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | -| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | -| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | -| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected. | -| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | -| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | -| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | -| `workspace_settings` | the daemon was created with settings persistence available. | -| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | -| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | -| `session_shell_command` | session shell execution is explicitly enabled. | -| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | -| `session_generation` | session generation helpers are available. | -| `workspace_generation` | workspace-scoped generation helpers are available. | -| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | -| `workspace_reload` | workspace reload support is available in the embedded route configuration. | -| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | -| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | -| `channel_control` | daemon-managed channel worker runtime control is wired. | -| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | -| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | -| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | -| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | -| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | -| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | -| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | -| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | -| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | -| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | -| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | -| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | -| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | -| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | -| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | -| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | +| Tag | Advertised when … | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | +| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | +| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to the managed tools that carry a shell command line (`run_shell_command` and `monitor`), so the absence of this tag does not mean no pre-execution denials. | +| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | +| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | +| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | +| `workspace_settings` | the daemon was created with settings persistence available. | +| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | +| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | +| `session_shell_command` | session shell execution is explicitly enabled. | +| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | +| `session_generation` | session generation helpers are available. | +| `workspace_generation` | workspace-scoped generation helpers are available. | +| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | +| `workspace_reload` | workspace reload support is available in the embedded route configuration. | +| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | +| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | +| `channel_control` | daemon-managed channel worker runtime control is wired. | +| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | +| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | +| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | +| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | +| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | +| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | +| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | +| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | +| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | +| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | +| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | +| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | +| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | +| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | +| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | +| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 76ab3a84e38..d0edb697b5c 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -434,6 +434,71 @@ Notes: > matched case-sensitively by yargs `choices` (`--memory-project-scope Workspace` is rejected). Use lowercase values when copying between the two. +### Built-in daemon Git relocation guard + +Every managed daemon ACP session applies a built-in pre-execution guard for +model shell commands, independent of `--external-tool-guard-mode` and without +any capability advertisement. The daemon owns the bound workspace and the +session's current effective working directory; both are supplied from trusted +session state and never accepted from the ACP child. + +The guard inspects the tools that run a shell command line — `run_shell_command` +and `monitor` — and denies a mutating Git +command before execution when its repository location resolves outside the +session's effective working directory. Relocation is recognized for literal +forms of `git -C `, `git --git-dir[=]`, +`git --work-tree[=]`, leading +`GIT_DIR`/`GIT_WORK_TREE`/`GIT_COMMON_DIR`/`GIT_INDEX_FILE` assignments (also +when made through `export`/`declare`/`readonly`, which keep them in the +environment of every later command in the chain), +directory-shifting wrapper flags (`env -C`, `sudo -D`), and `cd`, `pushd`, or +`popd` builtins earlier in the same command chain. Common wrapper prefixes +(`sh -c`, `bash -c`, `eval`, `sudo`, `nohup`, `timeout`, `exec`, `command`, +`builtin`, +`env`, path-qualified `git` binaries, and `{ …; }` / `! …` shell syntax) are +unwrapped so the same policy applies to the inner Git invocation, and `$(…)` +or backtick substitution bodies are analyzed as commands of their own. + +A sub-agent pinned to its own worktree is contained to that worktree rather +than to the session's directory; a shell call whose execution directory the +daemon cannot place is denied. + +Relative targets resolve from the command's effective starting directory +(`arguments.directory` when present, otherwise the session's current effective +working directory) after canonical path resolution, including `.git` gitfile +redirects, symlinks, and per-worktree administrative directories. A relocated +target that cannot be fully resolved before execution — a dynamic target +(`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an +unreadable indirection — is denied for mutating or unclassifiable subcommands. +A relocated target that cannot be resolved is denied whatever the subcommand +is — including the read-only ones. Relocated commands whose subcommand is one +of a small verified read-only set (`rev-parse`, `cat-file`) remain allowed +once the target resolves, unless the command carries command-executing `-c` +config, or +it carries a `--output`, `--textconv`, or `--filters` flag: those write a file +or run the target repository's configured drivers. Commands with no recognized +relocation keep their existing behavior. +Denials are final and are reported to the model as +`Daemon shell guard denied a mutating Git command…` for a resolved, dynamic, +or unresolvable repository location, and as +`Daemon shell guard denied a shell command…` when the command could not be +parsed, its payload could not be resolved, or an unrecognized program may run +a relocated Git command. + +The guard is reliable against Git relocation written in the literal forms +above — the mis-targeted command this control exists for — and is +**best-effort, not a boundary**, against shell text written to defeat it: +constructions that hide the relocation from a static reader may pass, and new +ones will keep being found. Do not grant a daemon broader trust on the +strength of it. It does not interpret script files, +track environment variable values across commands, or analyze heredoc bodies +(Git-shaped text inside a heredoc can be denied even though the shell never +executes it). `/fork` and agent-backed workspace memory remember/dream remain +available under the built-in guard; they are only restricted while the +external provider mode below is active. An optional external tool guard +remains an additional policy and receives the same request only after the +built-in policy allows it. + ### Required external Tool Guard This opt-in is for managed ACP deployments that need an external allow/deny diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 82eda5ac943..17a672eaa44 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -250,10 +250,14 @@ describe('BridgeClient — managed external tool guard', () => { }); const entry: { sessionId: string; + workspaceCwd: string; + effectiveCwd: string; promptActive: boolean; activePromptId?: string; } = { sessionId: 'session-1', + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', promptActive: true, activePromptId: 'prompt-1', }; @@ -278,9 +282,117 @@ describe('BridgeClient — managed external tool guard', () => { toolCallId: 'call-1', toolName: 'write_file', arguments: { path: 'README.md' }, + effectiveCwd: '/workspace/worktree', }); }); + it('ignores a forged effective directory in the child payload', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + const entry: { + sessionId: string; + workspaceCwd: string; + effectiveCwd: string; + promptActive: boolean; + activePromptId?: string; + } = { + sessionId: 'session-1', + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', + promptActive: true, + activePromptId: 'prompt-1', + }; + const client = makeClient(undefined, { + resolveEntry: (sessionId) => + sessionId === entry.sessionId ? entry : undefined, + handler, + }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'write_file', + arguments: { path: 'README.md' }, + effectiveCwd: '/forged/effective', + }), + ).resolves.toEqual({ allowed: true }); + expect(handler).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'write_file', + arguments: { path: 'README.md' }, + effectiveCwd: '/workspace/worktree', + }); + }); + + it('accepts a prompt-less shell check validated by session ownership', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + const entry: { + sessionId: string; + workspaceCwd: string; + effectiveCwd: string; + promptActive: boolean; + activePromptId?: string; + } = { + sessionId: 'session-1', + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', + promptActive: false, + }; + const client = makeClient(undefined, { + resolveEntry: (sessionId) => + sessionId === entry.sessionId ? entry : undefined, + handler, + }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + }), + ).resolves.toEqual({ allowed: true }); + expect(handler).toHaveBeenCalledWith({ + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + effectiveCwd: '/workspace/worktree', + }); + }); + + it('rejects an empty prompt id in a guard request', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + const client = makeClient(undefined, { + resolveEntry: () => ({ + sessionId: 'session-1', + promptActive: true, + activePromptId: 'prompt-1', + }), + handler, + }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + promptId: '', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: {}, + }), + ).rejects.toThrow('Invalid external tool guard request'); + expect(handler).not.toHaveBeenCalled(); + }); + it('rejects a stale prompt without contacting the host', async () => { const handler = vi.fn().mockResolvedValue({ allowed: true, diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 39f1b9e8442..492b0a30b3c 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -603,6 +603,8 @@ function sliceLineRange( */ export interface BridgeClientSessionEntry { sessionId: string; + workspaceCwd: string; + effectiveCwd: string; events: EventBus; artifacts: SessionArtifactStore; recordingDegraded: boolean; @@ -1297,8 +1299,8 @@ export class BridgeClient implements Client { if ( typeof sessionId !== 'string' || sessionId.length === 0 || - typeof promptId !== 'string' || - promptId.length === 0 || + (promptId !== undefined && + (typeof promptId !== 'string' || promptId.length === 0)) || typeof toolCallId !== 'string' || toolCallId.length === 0 || typeof toolName !== 'string' || @@ -1310,6 +1312,11 @@ export class BridgeClient implements Client { 'Invalid external tool guard request', ); } + // Context-less shell checks (subagents, cron turns, resumed background + // agents) carry no prompt binding; they are validated by session + // ownership alone. The host handler decides whether its policy can run + // without a live prompt. + const promptScoped = promptId !== undefined; if (!this.ownsSession(sessionId)) { throw RequestError.invalidParams( undefined, @@ -1317,25 +1324,37 @@ export class BridgeClient implements Client { ); } const entry = this.resolveEntry(sessionId); - if (!entry || !entry.promptActive || entry.activePromptId !== promptId) { + if ( + !entry || + (promptScoped && + (!entry.promptActive || entry.activePromptId !== promptId)) + ) { throw RequestError.invalidParams( undefined, 'External tool guard prompt is not the active prompt', ); } + const invocationCwd = params['invocationCwd']; const decision: unknown = await this.externalToolGuard({ sessionId: entry.sessionId, - promptId: entry.activePromptId, + ...(promptScoped ? { promptId } : {}), toolCallId, toolName, arguments: args, + effectiveCwd: entry.effectiveCwd, + // Forwarded verbatim and explicitly untrusted: the host policy decides + // whether it can establish this scope from state it owns. + ...(typeof invocationCwd === 'string' && invocationCwd.length > 0 + ? { invocationCwd } + : {}), }); const currentEntry = this.resolveEntry(sessionId); if ( !this.ownsSession(sessionId) || currentEntry !== entry || - !currentEntry.promptActive || - currentEntry.activePromptId !== promptId + (promptScoped && + (!currentEntry.promptActive || + currentEntry.activePromptId !== promptId)) ) { throw RequestError.invalidParams( undefined, diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index af4235f6859..1a39ba01abe 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -73,10 +73,25 @@ export type BridgeSessionLifecycle = ( */ export interface ExternalToolGuardPrepareRequest { readonly sessionId: string; - readonly promptId: string; + /** + * Runtime-owned active-prompt binding. Absent for context-less shell + * checks: subagent reasoning loops, cron turns, background notifications, + * and resumed background agents run without an invocation context by + * design. A host policy that requires a live prompt must fail closed when + * the binding is missing. + */ + readonly promptId?: string; readonly toolCallId: string; readonly toolName: string; readonly arguments: Readonly>; + /** Daemon-owned current session working directory. */ + readonly effectiveCwd?: string; + /** + * Directory the child will actually run the tool in, when it differs from + * the session's own. Untrusted: the host validates it against state it + * owns before using it as a containment basis. + */ + readonly invocationCwd?: string; } export type ExternalToolGuardPrepareResult = diff --git a/packages/acp-bridge/src/externalToolGuard.ts b/packages/acp-bridge/src/externalToolGuard.ts index 9e65d3420f5..2199e272937 100644 --- a/packages/acp-bridge/src/externalToolGuard.ts +++ b/packages/acp-bridge/src/externalToolGuard.ts @@ -12,6 +12,18 @@ export const PRIVATE_EXTERNAL_TOOL_GUARD_ENV = 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD'; +/** + * Private, non-secret marker passed from `qwen serve` to its ACP child only + * when a real external tool guard provider is attached. Without it the child + * still installs the managed guard plumbing (the daemon's built-in policy + * needs it), but resolves every non-shell tool locally and keeps `/fork` and + * agent-backed workspace memory available: those features are only disabled + * for the external provider's v1 contract, which cannot observe hidden-agent + * execution. + */ +export const PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV = + 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER'; + /** * ACP initialize-response metadata proving that the child consumed the * private activation marker and installed the required executor callback. @@ -26,6 +38,26 @@ export const EXTERNAL_TOOL_GUARD_READY_META_KEY = */ export const EXTERNAL_TOOL_GUARD_REQUIRED_VALUE = 'required-v1'; +/** + * The provider-attached marker value `qwen serve` passes to the child when a + * real external tool guard provider is configured. + */ +export const EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE = 'attached-v1'; + +/** + * Tools whose arguments carry a shell command line the host runs on the + * session's behalf. The daemon's built-in policy inspects exactly these, and + * the ACP child resolves every other tool locally when no external provider + * is attached. Pinned to `ToolNames.SHELL`/`ToolNames.MONITOR` in + * `@qwen-code/qwen-code-core`, which this package deliberately does not + * depend on; `daemon-git-worktree-guard.test.ts` asserts the values still + * match so a rename cannot silently unhook a tool from the guard. + */ +export const SHELL_EXECUTING_TOOL_NAMES: ReadonlySet = new Set([ + 'monitor', + 'run_shell_command', +]); + /** Daemon-local bearer token for the loopback external Tool Guard provider. */ export const EXTERNAL_TOOL_GUARD_TOKEN_ENV = 'QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f0798521d5d..206adc44dc3 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -261,6 +261,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ WORKFLOW: 'workflow', CREATE_SUB_SESSION: 'create_sub_session', SEND_MESSAGE: 'send_message', + SHELL: 'run_shell_command', + MONITOR: 'monitor', }, FORK_SUBAGENT_TYPE: 'fork', IMAGE_CAPABILITY: Object.freeze({ @@ -8482,7 +8484,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('rejects agent-backed workspace memory operations when the managed guard is required', async () => { + it('rejects agent-backed workspace memory operations when an external guard provider is attached', async () => { Object.assign(mockConfig, { isManagedMemoryAvailable: vi.fn().mockReturnValue(true), getProjectRoot: vi.fn().mockReturnValue('/workspace'), @@ -8495,6 +8497,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { privateParentCapability: 'expected-capability', externalToolGuardRequired: true, + externalToolGuardProviderAttached: true, }, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); @@ -8537,6 +8540,46 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('keeps agent-backed workspace memory available under the built-in guard alone', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + + await expect( + agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, + {}, + ), + ).resolves.toEqual({ available: true }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('launches fork agents with neutral history text', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -8614,7 +8657,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('rejects /fork before starting a nested agent when the managed guard is required', async () => { + it('rejects /fork before starting a nested agent when an external guard provider is attached', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); const execute = vi.fn(); @@ -8640,6 +8683,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { privateParentCapability: 'expected-capability', externalToolGuardRequired: true, + externalToolGuardProviderAttached: true, }, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); @@ -8684,6 +8728,62 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('allows /fork past the guard gate under the built-in guard alone', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const execute = vi.fn().mockResolvedValue({ llmContent: 'ok' }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getHistoryShallow: vi + .fn() + .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), + addHistory: vi.fn(), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn(() => ({ build })), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + sessionId, + directive: 'review this branch', + }), + ).resolves.toMatchObject({ launched: true }); + expect(build).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('allows cancelling paused agent tasks', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -18740,12 +18840,59 @@ describe('createManagedExternalToolGuard', () => { }); }); - it('fails closed without a managed invocation context', async () => { + it('routes context-less shell calls with the scheduler session identity', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + sessionId: 'session-9', + }), + ).resolves.toEqual({ allowed: true }); + + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-9', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + }, + ); + }); + + it('fails closed when neither invocation context nor session id exists', async () => { const extMethod = vi.fn(); const guard = createManagedExternalToolGuard({ extMethod, } as unknown as AgentSideConnection); + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + }), + ).rejects.toThrow('requires a session identity'); + expect(extMethod).not.toHaveBeenCalled(); + }); + + it('fails closed without a managed invocation context', async () => { + const extMethod = vi.fn(); + const guard = createManagedExternalToolGuard( + { + extMethod, + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); + await expect( guard({ callId: 'call-1', @@ -18757,47 +18904,161 @@ describe('createManagedExternalToolGuard', () => { expect(extMethod).not.toHaveBeenCalled(); }); - it.each([ - ToolNames.AGENT, - ToolNames.WORKFLOW, - ToolNames.CREATE_SUB_SESSION, - ToolNames.SEND_MESSAGE, - ])( - 'rejects unsupported nested executor %s without contacting the provider', - async (toolName) => { - const extMethod = vi.fn(); - const guard = createManagedExternalToolGuard({ + it('forwards nested executors to the daemon host guard when a provider is attached', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard( + { extMethod, - } as unknown as AgentSideConnection); + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); - await expect( - guard({ - callId: 'call-1', - toolName, - args: {}, - signal: new AbortController().signal, - invocationContext: { - version: 1, - sessionId: 'session-1', - promptId: 'prompt-1', - }, - }), - ).resolves.toEqual({ - allowed: false, - reason: - 'Managed external tool guard v1 does not support nested or delegated agent execution.', - }); - expect(extMethod).not.toHaveBeenCalled(); - }, - ); + await expect( + guard({ + callId: 'call-1', + toolName: ToolNames.AGENT, + args: {}, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledOnce(); + }); - it('stops waiting when the tool invocation is cancelled', async () => { - const extMethod = vi.fn( - () => new Promise>(() => {}), + it('resolves non-shell tools locally when only the built-in guard is attached', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: ToolNames.AGENT, + args: {}, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + await expect( + guard({ + callId: 'call-2', + toolName: 'write_file', + args: {}, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).not.toHaveBeenCalled(); + }); + + it('still routes shell commands to the daemon without an external provider', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledOnce(); + }); + + // The only link between a worktree-pinned sub-agent's real execution + // directory and the daemon's containment check. + it('forwards the invocation directory to the daemon', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'git status' }, + signal: new AbortController().signal, + sessionId: 'session-1', + cwd: '/work/agent-worktree', + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'git status' }, + invocationCwd: '/work/agent-worktree', + }, ); + }); + + // `monitor` spawns its `command` through the same shell, so the built-in + // daemon policy has to see it too. + it('routes monitor commands to the daemon without an external provider', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); const guard = createManagedExternalToolGuard({ extMethod, } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: ToolNames.MONITOR, + args: { command: 'npm run build' }, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: ToolNames.MONITOR, + arguments: { command: 'npm run build' }, + }, + ); + }); + + it('stops waiting when the tool invocation is cancelled', async () => { + const extMethod = vi.fn( + () => new Promise>(() => {}), + ); + const guard = createManagedExternalToolGuard( + { + extMethod, + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); const controller = new AbortController(); const pending = guard({ callId: 'call-1', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2f10407e7e0..48384b2500f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -314,6 +314,8 @@ import { EXTERNAL_TOOL_GUARD_TOKEN_ENV, isValidExternalToolGuardDenialReason, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, + SHELL_EXECUTING_TOOL_NAMES, } from '@qwen-code/acp-bridge/externalToolGuard'; import { parseSessionSource, @@ -2847,30 +2849,42 @@ export async function deliverClientMcpMessage( */ export function createManagedExternalToolGuard( connection: AgentSideConnection, + options: { externalProviderAttached: boolean } = { + externalProviderAttached: false, + }, ): ToolInvocationGuard { return async (context) => { + // With only the daemon's built-in policy attached there is no external + // provider to consult: every non-shell tool is structurally allowed, + // so resolve locally instead of paying a serialized child-daemon-child + // round trip on every tool call. The shell-executing tools still go to + // the daemon because they are the only ones the built-in policy inspects. + if ( + !options.externalProviderAttached && + !SHELL_EXECUTING_TOOL_NAMES.has(context.toolName) + ) { + return { allowed: true }; + } const invocation = context.invocationContext; - if (!invocation) { + if (!invocation && options.externalProviderAttached) { throw new Error( 'Managed external tool guard requires a runtime invocation context.', ); } + // Subagent reasoning loops, cron turns, background notifications, and + // resumed background agents run without an invocation context by design. + // Under the built-in policy alone the daemon only needs the session + // identity, so fall back to the scheduler-owned session id and skip the + // prompt binding instead of denying every shell call those paths make. + const sessionId = invocation?.sessionId ?? context.sessionId; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error( + 'Managed external tool guard requires a session identity.', + ); + } if (context.signal.aborted) { throw new DOMException('Tool invocation aborted', 'AbortError'); } - if ( - context.toolName === ToolNames.AGENT || - context.toolName === ToolNames.WORKFLOW || - context.toolName === ToolNames.CREATE_SUB_SESSION || - context.toolName === ToolNames.SEND_MESSAGE - ) { - return { - allowed: false, - reason: - 'Managed external tool guard v1 does not support nested or delegated agent execution.', - }; - } - let rejectOnAbort: ((error: Error) => void) | undefined; const aborted = new Promise((_resolve, reject) => { rejectOnAbort = reject; @@ -2888,11 +2902,16 @@ export function createManagedExternalToolGuard( connection.extMethod( SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { - sessionId: invocation.sessionId, - promptId: invocation.promptId, + sessionId, + ...(invocation ? { promptId: invocation.promptId } : {}), toolCallId: context.callId, toolName: context.toolName, arguments: context.args, + // A sub-agent pinned to a worktree executes here, not in the + // session's own directory; the host validates this before use. + ...(typeof context.cwd === 'string' && context.cwd.length > 0 + ? { invocationCwd: context.cwd } + : {}), }, ), aborted, @@ -3045,6 +3064,7 @@ export async function runAcpAgent( options?: { privateParentCapability?: string; externalToolGuardRequired?: boolean; + externalToolGuardProviderAttached?: boolean; }, ) { // Freeze the restart-required writer protocol before the first await. @@ -3060,8 +3080,11 @@ export async function runAcpAgent( : options.privateParentCapability; delete process.env[PRIVATE_ACP_CAPABILITY_ENV]; delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_ENV]; + delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]; delete process.env[EXTERNAL_TOOL_GUARD_TOKEN_ENV]; const externalToolGuardRequired = options?.externalToolGuardRequired === true; + const externalToolGuardProviderAttached = + options?.externalToolGuardProviderAttached === true; if (externalToolGuardRequired && privateParentCapability === undefined) { throw new Error( 'Required external tool guard is available only to a private managed ACP parent.', @@ -3189,7 +3212,9 @@ export async function runAcpAgent( connection = new AgentSideConnection((conn) => { acpConnection = conn; const managedToolInvocationGuard = externalToolGuardRequired - ? createManagedExternalToolGuard(conn) + ? createManagedExternalToolGuard(conn, { + externalProviderAttached: externalToolGuardProviderAttached, + }) : undefined; agentInstance = new QwenAgent( config, @@ -3199,6 +3224,7 @@ export async function runAcpAgent( privateParentCapability, sessionWriterLeaseEnabledAtStartup, managedToolInvocationGuard, + externalToolGuardProviderAttached, ); return agentInstance; }, stream); @@ -3714,7 +3740,10 @@ class QwenAgent implements Agent { } private rejectUnsupportedGuardedHiddenAgent(operation: string): void { - if (this.managedToolInvocationGuard) { + if ( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) { throw RequestError.invalidParams( undefined, `Managed external tool guard v1 does not support ${operation}.`, @@ -4474,6 +4503,7 @@ class QwenAgent implements Agent { private readonly expectedPrivateParentCapability?: string, private readonly sessionWriterLeaseEnabledAtStartup = false, private readonly managedToolInvocationGuard?: ToolInvocationGuard, + private readonly externalToolGuardProviderAttached = false, ) { // Pool kill switch via env var so operators can A/B compare or // roll back without rebuilding. `run-qwen-serve.ts` sets this when @@ -8452,8 +8482,10 @@ class QwenAgent implements Agent { case SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability: return { available: - !this.managedToolInvocationGuard && - this.config.isManagedMemoryAvailable(), + !( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) && this.config.isManagedMemoryAvailable(), }; case SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember: { this.rejectUnsupportedGuardedHiddenAgent( @@ -10292,7 +10324,10 @@ class QwenAgent implements Agent { return { sessionId, answer: result.text || null }; } case SERVE_CONTROL_EXT_METHODS.sessionForkAgent: { - if (this.managedToolInvocationGuard) { + if ( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) { throw RequestError.invalidParams( undefined, 'Managed external tool guard v1 does not support /fork.', diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index fdede0c2365..daedcb141f3 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -17669,6 +17669,10 @@ describe('Session', () => { toolName: 'read_file', args: { path: '/normalized/final.txt' }, signal: expect.any(AbortSignal), + // The daemon policy falls back to the session and needs to know + // where the tool will run. + sessionId: 'test-session-id', + cwd: process.cwd(), }); expect(executeSpy).not.toHaveBeenCalled(); expect( @@ -17733,6 +17737,10 @@ describe('Session', () => { toolName: 'read_file', args: { path: '/normalized/final.txt' }, signal: expect.any(AbortSignal), + // The daemon policy falls back to the session and needs to know + // where the tool will run. + sessionId: 'test-session-id', + cwd: process.cwd(), }); expect(executeSpy).toHaveBeenCalledOnce(); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bc2ab1d8bd9..a799b365e22 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -9307,6 +9307,13 @@ export class Session implements SessionContext { toolName: policyToolName, args: invocation.params as Record, signal: activeToolAbortSignal, + // Same identity and execution scope `CoreToolScheduler` + // supplies. This is the path daemon ACP sessions actually + // take, so without them a host policy that falls back to the + // session — or reasons about where the tool runs — sees + // neither on every call made here. + sessionId: this.config.getSessionId(), + cwd: this.config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5b5cc17f21d..72409fdd80a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -22,9 +22,11 @@ import { uiTelemetryService, } from '@qwen-code/qwen-code-core'; import { + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, EXTERNAL_TOOL_GUARD_TOKEN_ENV, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import dns from 'node:dns'; import fs from 'node:fs'; @@ -367,6 +369,12 @@ export async function main() { ? EXTERNAL_TOOL_GUARD_REQUIRED_VALUE : undefined; delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_ENV]; + const privateExternalToolGuardProvider = + process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV] === + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE + ? EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE + : undefined; + delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]; if (process.argv.includes('--bare')) { process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1'; @@ -393,6 +401,12 @@ export async function main() { ...(privateExternalToolGuard ? { [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: privateExternalToolGuard, + ...(privateExternalToolGuardProvider + ? { + [PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]: + privateExternalToolGuardProvider, + } + : {}), } : {}), } @@ -1072,6 +1086,11 @@ export async function main() { isAcpMode && privateAcpParentCapability !== undefined && privateExternalToolGuard === EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, + externalToolGuardProviderAttached: + isAcpMode && + privateAcpParentCapability !== undefined && + privateExternalToolGuardProvider === + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, }); } finally { // Clean up child processes even when ACP setup or shutdown fails. diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts new file mode 100644 index 00000000000..50080dfd38f --- /dev/null +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -0,0 +1,2284 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { GitWorktreeService, ToolNames } from '@qwen-code/qwen-code-core'; +import type { ExternalToolGuardPrepareRequest } from '@qwen-code/acp-bridge/bridgeOptions'; +import { SHELL_EXECUTING_TOOL_NAMES } from '@qwen-code/acp-bridge/externalToolGuard'; +import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; + +const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-')); +const effectiveCwd = path.join(temporaryRoot, 'workspace', 'worktree'); +const insideNested = path.join(effectiveCwd, 'nested'); +const outsideRepo = path.join(temporaryRoot, 'outside', 'repo'); +// A second outside checkout whose path contains no Git word, so a test using +// it cannot pass because `\bgit\b` happened to match inside the path. +const plainOutsidePath = path.join(temporaryRoot, 'elsewhere', 'checkout'); +mkdirSync(path.join(outsideRepo, '.git'), { recursive: true }); +mkdirSync(insideNested, { recursive: true }); + +function request( + command: string, + extraArguments: Record = {}, +): ExternalToolGuardPrepareRequest { + return { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command, ...extraArguments }, + effectiveCwd, + } as ExternalToolGuardPrepareRequest; +} + +afterAll(async () => { + await rm(temporaryRoot, { recursive: true, force: true }); +}); + +describe('createDaemonToolGuard', () => { + it.each([ + () => `git -C ${outsideRepo} reset --hard`, + () => `git -C${outsideRepo} checkout -- .`, + () => + `git --work-tree=${outsideRepo} --git-dir=${path.join(outsideRepo, '.git')} clean -fd`, + () => `git --git-dir ${path.join(outsideRepo, '.git')} commit -m x`, + () => `git --namespace foo -C ${outsideRepo} reset --hard`, + () => `git --super-prefix=foo --work-tree=${outsideRepo} clean -fd`, + // `grep` runs the target repo's diff..textconv programs and + // `status` refreshes the target index + runs its core.fsmonitor. + () => `git -C ${outsideRepo} grep --textconv pattern`, + () => `git -C ${outsideRepo} status --porcelain`, + ])('denies relocated mutating Git command %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('allows relocated read-only Git commands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`git -C ${outsideRepo} rev-parse HEAD`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + `git -C ${outsideRepo} diff`, + `git -C ${outsideRepo} log -p`, + `git -C ${outsideRepo} show --output=${path.join(outsideRepo, 'out.txt')} HEAD`, + ])( + 'denies relocated Git subcommands that can execute target-repo config or write files', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + `git -C ${outsideRepo} branch -D topic`, + `git -C ${outsideRepo} remote add origin example.invalid/repo`, + ])( + 'denies relocated Git subcommands that can mutate state', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('denies dynamic repository relocation for mutating commands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C "$OTHER_WORKTREE" reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }); + + it.each([ + 'git -C `echo /outside/repo` reset --hard', + 'git -C ~/repos/other-checkout reset --hard', + "git $'-C' /outside/repo reset --hard", + "$'git' -C /outside/repo reset --hard", + 'git $(echo -C) /outside/repo reset --hard', + 'git -C /outside/repo* reset --hard', + ])('denies shell-expansion relocation forms %#', async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }); + + it.each([ + // A trailing comment must not hide the relocation from the guard. + () => `git -C ${outsideRepo} reset --hard # note`, + // Git treats an empty `-C` as a no-op and applies the next relocation. + () => `git -C "" -C ${outsideRepo} reset --hard`, + ])('denies relocations masked by token edge cases %#', async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it.each(['git -C', 'git --git-dir', 'git --work-tree='])( + 'fails closed on a dangling relocation option', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('allows mutating Git commands inside the effective working directory', async () => { + const guard = createDaemonToolGuard(); + + await expect(guard(request('git -C nested reset --hard'))).resolves.toEqual( + { allowed: true }, + ); + }); + + it('resolves relative targets from the explicit shell directory', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C .. reset --hard', { directory: insideNested })), + ).resolves.toEqual({ allowed: true }); + await expect( + guard( + request( + `git -C ${path.relative(insideNested, outsideRepo)} reset --hard`, + { + directory: insideNested, + }, + ), + ), + ).resolves.toMatchObject({ allowed: false }); + }); + + it.each([ + `pwd && git -C ${outsideRepo} reset --hard; true`, + `X=1 git -C ${outsideRepo} reset --hard`, + `env X=1 git -C ${outsideRepo} reset --hard`, + `command git -C ${outsideRepo} reset --hard`, + `pwd\ngit -C ${outsideRepo} reset --hard`, + ])( + 'denies a relocated mutation inside shell command forms', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `sh -c 'git -C ${outsideRepo} reset --hard'`, + () => `bash -c "git -C ${outsideRepo} reset --hard"`, + () => `bash -lc 'git -C ${outsideRepo} reset --hard'`, + () => `eval 'git -C ${outsideRepo} reset --hard'`, + () => `sudo git -C ${outsideRepo} reset --hard`, + () => `nohup git -C ${outsideRepo} reset --hard`, + () => `timeout 5 git -C ${outsideRepo} reset --hard`, + () => `exec git -C ${outsideRepo} reset --hard`, + () => `/usr/bin/git -C ${outsideRepo} reset --hard`, + () => `./bin/git -C ${outsideRepo} reset --hard`, + () => `{ git -C ${outsideRepo} reset --hard; }`, + () => `! git -C ${outsideRepo} reset --hard`, + () => `env -S 'git -C ${outsideRepo} reset --hard'`, + ])( + 'denies a relocated mutation through wrapper invocations %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `cd ${outsideRepo} && git reset --hard`, + () => `pushd ${outsideRepo} && git reset --hard`, + () => `(cd ${outsideRepo} && git reset --hard)`, + () => `eval 'cd ${outsideRepo}' && git reset --hard`, + () => 'cd && git reset --hard', + () => 'cd - && git reset --hard', + () => 'popd && git reset --hard', + ])( + 'denies mutations after a cwd-shifting builtin %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `if true; then git -C ${outsideRepo} reset --hard; fi`, + () => `if true; then cd ${outsideRepo} && git reset --hard; fi`, + () => `for i in 1; do git -C ${outsideRepo} reset --hard; done`, + () => `while true; do git -C ${outsideRepo} reset --hard; break; done`, + () => `until false; do git -C ${outsideRepo} reset --hard; done`, + () => + `if false; then pwd; elif true; then git -C ${outsideRepo} reset --hard; fi`, + () => `time git -C ${outsideRepo} reset --hard`, + () => `coproc git -C ${outsideRepo} reset --hard`, + ])( + 'denies relocated mutations hidden behind shell keywords %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([() => 'bash -c "$CMD"', () => 'sh -c "$CMD" arg'])( + 'fails closed on undecidable shell payloads %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('could not be resolved'), + }); + }, + ); + + // A substitution body runs before the command it is embedded in, so it is + // analysed on its own instead of being folded into an opaque token. + it.each([ + () => `echo $(git -C ${outsideRepo} reset --hard)`, + () => `echo "$(cd ${outsideRepo} && git reset --hard)"`, + () => `FOO=$(cd ${outsideRepo} && git reset --hard)`, + () => `echo \`cd ${outsideRepo} && git reset --hard\``, + () => `echo \${x:-$(git -C ${outsideRepo} reset --hard)}`, + () => `echo $(( $(git -C ${outsideRepo} reset --hard) + 1 ))`, + () => `sh -c "$(echo git -C ${outsideRepo} reset --hard)"`, + () => `eval "$(echo git -C ${outsideRepo} reset --hard)"`, + ])( + 'denies a relocated mutation inside a command substitution %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('allows command substitutions that stay inside the boundary', async () => { + const guard = createDaemonToolGuard(); + + await expect(guard(request('echo $(date)'))).resolves.toEqual({ + allowed: true, + }); + await expect(guard(request('echo $(git rev-parse HEAD)'))).resolves.toEqual( + { allowed: true }, + ); + await expect( + guard(request('echo $(cd nested && git commit -m x)')), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on an unterminated command substitution', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`echo $(git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it.each([ + () => `bash -c'git -C ${outsideRepo} reset --hard'`, + () => `bash -lc'git -C ${outsideRepo} reset --hard'`, + ])( + 'denies relocated mutations fused into the -c flag token %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `cd ${outsideRepo} && sh -c 'git reset --hard'`, + () => `cd ${outsideRepo} && bash -c 'git clean -fd'`, + () => `cd ${outsideRepo} && eval 'git reset --hard'`, + () => `cd ${outsideRepo}; sh -c 'git reset --hard'`, + () => `cd ${outsideRepo} && sh -c 'cd nested && git reset --hard'`, + ])( + 'keeps the entry cwd as the containment basis inside shell wrappers %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('still allows wrapper payloads that stay inside the entry cwd', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`cd ${effectiveCwd} && sh -c 'git reset --hard'`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + () => `git -c core.fsmonitor=/tmp/evil.sh -C ${outsideRepo} status`, + () => `git -c alias.x='!evil' -C ${outsideRepo} status`, + ])( + 'inspects command-executing -c config even for read-only subcommands %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }, + ); + + it.each([ + () => `git --exec-path -C ${outsideRepo} reset --hard`, + () => `git --list-cmds -C ${outsideRepo} reset --hard`, + ])( + 'does not let --exec-path/--list-cmds swallow the relocation token %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('denies a model-supplied directory outside the effective working directory', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git reset --hard', { directory: outsideRepo })), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + await expect( + guard( + request('git reset --hard', { + directory: path.relative(effectiveCwd, outsideRepo), + }), + ), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard(request('git reset --hard', { directory: insideNested })), + ).resolves.toEqual({ allowed: true }); + }); + + it('keeps subshell cwd shifts from leaking into later commands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`sh -c 'cd ${outsideRepo}'; git reset --hard`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`cd ${effectiveCwd} && git reset --hard`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + () => `git -C \\ +${outsideRepo} reset --hard`, + () => `g\\ +it -C ${outsideRepo} reset --hard`, + ])( + 'joins backslash continuations before parsing %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it.each([ + () => `GIT_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `GIT_WORK_TREE=${outsideRepo} git reset --hard`, + () => `GIT_COMMON_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `env GIT_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `env -C ${outsideRepo} git reset --hard`, + () => `env --chdir=${outsideRepo} git reset --hard`, + () => `env -u GIT_DIR git -C ${outsideRepo} reset --hard`, + () => `sudo -D ${outsideRepo} git reset --hard`, + ])( + 'denies repository relocation through environment forms %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + `git --git-dir=../evil/.git -C .. branch X`, + `git -C .. --git-dir=../evil/.git branch X`, + `git --work-tree=../evil -C .. reset --hard`, + ])( + 'resolves relative git-dir and work-tree against the final -C cwd', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + `git -c alias.pwn='!git -C ${outsideRepo} branch pwned' pwn`, + 'git -c core.editor=evil-command commit', + 'git --config-env core.pager=evil-command log --follow', + 'git -c filter.evil.clean=evil-command add file', + // Command-executing config families git runs directly. + "git -c trailer.sign.command='evil-command' interpret-trailers", + "git -c man.foo.cmd='evil-command' help -m git", + "git -c sendemail.sendmailcmd='evil-command' send-email", + ])( + 'denies mutating subcommands with command-valued -c config', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }, + ); + + it('allows harmless -c config on mutations inside the boundary', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -c user.name=Qwen commit --allow-empty')), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on commands that cannot be parsed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C ${UNBALANCED reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('could not be parsed'), + }); + }); + + it('follows chained -C targets using Git semantics', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`git -C nested -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard( + request( + `git -C ${outsideRepo} -C ${path.relative(outsideRepo, effectiveCwd)} reset --hard`, + ), + ), + ).resolves.toEqual({ allowed: true }); + }); + + it('checks work-tree and git-dir targets independently', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard( + request( + `git --work-tree=${effectiveCwd} --git-dir=${path.join(outsideRepo, '.git')} reset --hard`, + ), + ), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('resolves a missing target through its nearest existing symlink ancestor', async () => { + const localEffectiveCwd = path.join(temporaryRoot, 'sym-cwd'); + const localOutsideRepo = path.join(temporaryRoot, 'sym-outside'); + const linkedOutsideRepo = path.join(localEffectiveCwd, 'linked-outside'); + await Promise.all([ + mkdir(localEffectiveCwd, { recursive: true }), + mkdir(localOutsideRepo, { recursive: true }), + ]); + await symlink(localOutsideRepo, linkedOutsideRepo); + + const guard = createDaemonToolGuard(); + await expect( + guard({ + ...request('git -C linked-outside/missing reset --hard'), + effectiveCwd: localEffectiveCwd, + }), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('denies relocated mutations whose target does not exist at decision time', async () => { + const guard = createDaemonToolGuard(); + + // A target that is missing now cannot be proven safe: it may exist as an + // outward symlink by the time git runs. + await expect( + guard(request('git -C not-created-yet reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('unresolvable repository location'), + }); + }); + + // A path the command itself re-points defeats any containment proved before + // it runs — `bait` is still the original directory when the guard looks. + it.each([ + () => `ln -s ${outsideRepo} link && git -C link reset --hard`, + () => + `rm -rf nested && ln -s ${outsideRepo} nested && git -C nested reset --hard`, + () => `mv ${outsideRepo} nested && git -C nested reset --hard`, + ])( + 'denies a relocation after the command relinks a path %#', + async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // Only an invocation that resolves a path is affected: renaming files and + // then staging them is everyday work, not a relocation. + it('leaves path-free Git alone after a rename', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'cp a b && git commit -m x', + 'mv old new && git add -A', + 'mv old new && git add -A && git commit -m x', + 'ln -s a b && git status', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + it('follows gitfile redirects before the containment check', async () => { + // Per-test fixture: the redirect file persists for the rest of the run + // and would change how later tests resolve targets under a shared basis. + const localEffectiveCwd = path.join(temporaryRoot, 'gitfile-cwd'); + const localNested = path.join(localEffectiveCwd, 'nested'); + await mkdir(localNested, { recursive: true }); + await writeFile( + path.join(localNested, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + const localRequest = ( + command: string, + ): ExternalToolGuardPrepareRequest => ({ + ...request(command), + effectiveCwd: localEffectiveCwd, + }); + + const guard = createDaemonToolGuard(); + await expect( + guard(localRequest('git --git-dir=nested/.git branch -D topic')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + await expect( + guard(localRequest(`GIT_DIR=nested/.git sh -c 'git reset --hard'`)), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('canonicalizes a symlink named .git before stripping the basename', async () => { + const localEffectiveCwd = path.join(temporaryRoot, 'symgit-cwd'); + const localNestedD = path.join(localEffectiveCwd, 'nested', 'd'); + await mkdir(localNestedD, { recursive: true }); + await symlink( + path.join(outsideRepo, '.git'), + path.join(localNestedD, '.git'), + ); + const localRequest = ( + command: string, + ): ExternalToolGuardPrepareRequest => ({ + ...request(command), + effectiveCwd: localEffectiveCwd, + }); + + const guard = createDaemonToolGuard(); + await expect( + guard(localRequest('git --git-dir=nested/d/.git branch -D topic')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('resolves per-worktree admin directories to the linked worktree', async () => { + const adminDir = path.join(effectiveCwd, '.git', 'worktrees', 'wt1'); + const outsideCheckout = path.join(temporaryRoot, 'outside-checkout'); + await Promise.all([ + mkdir(adminDir, { recursive: true }), + mkdir(outsideCheckout, { recursive: true }), + ]); + await writeFile( + path.join(outsideCheckout, '.git'), + `gitdir: ${adminDir}\n`, + ); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(outsideCheckout, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=.git/worktrees/wt1 reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideCheckout), + }); + }); + + it('allows per-worktree admin directories whose checkout stays inside', async () => { + const adminDir = path.join(effectiveCwd, '.git', 'worktrees', 'wt2'); + const insideCheckout = path.join(effectiveCwd, 'wt2-checkout'); + await Promise.all([ + mkdir(adminDir, { recursive: true }), + mkdir(insideCheckout, { recursive: true }), + ]); + await writeFile(path.join(insideCheckout, '.git'), `gitdir: ${adminDir}\n`); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(insideCheckout, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=.git/worktrees/wt2 reset --hard')), + ).resolves.toEqual({ allowed: true }); + }); + + it('clamps long paths and strips control characters in denial reasons', async () => { + const guard = createDaemonToolGuard(); + const longTarget = path.join(outsideRepo, 'x'.repeat(200), 'y'.repeat(200)); + + const longDenial = await guard( + request(`git -C ${longTarget} reset --hard`), + ); + expect(longDenial).toMatchObject({ allowed: false }); + const longReason = (longDenial as { reason: string }).reason; + expect(longReason.length).toBeLessThanOrEqual(500); + expect(longReason).toContain('…'); + + const tabTarget = path.join(temporaryRoot, 'tab\tdir'); + await mkdir(path.join(tabTarget, '.git'), { recursive: true }); + const controlDenial = await guard( + request(`git -C '${tabTarget}' reset --hard`), + ); + expect(controlDenial).toMatchObject({ allowed: false }); + const controlReason = (controlDenial as { reason: string }).reason; + expect(controlReason.length).toBeLessThanOrEqual(500); + // eslint-disable-next-line no-control-regex -- asserting control chars are stripped + expect(controlReason).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); + }); + + it('denies dynamic relocations even for read-only subcommands', async () => { + const guard = createDaemonToolGuard(); + + // `status` would run the target repository's core.fsmonitor, so the + // unresolved/dangerous-config check precedes the read-only allowance. + await expect( + guard(request('git -C "$OTHER_WORKTREE" rev-parse')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }); + + it.each([ + () => `echo git -C ${outsideRepo} reset --hard`, + () => `nice git -C ${outsideRepo} reset --hard`, + () => `nice -n 5 git -C ${outsideRepo} reset --hard`, + () => `stdbuf -o0 git -C ${outsideRepo} reset --hard`, + () => `setsid git -C ${outsideRepo} reset --hard`, + () => `flock /tmp/daemon-guard-lock git -C ${outsideRepo} reset --hard`, + () => `xargs -I{} git -C ${outsideRepo} reset --hard`, + () => `su -c 'git -C ${outsideRepo} reset --hard'`, + () => `find . -exec git -C ${outsideRepo} reset --hard ;`, + // `PATH=`/`GIT_EXEC_PATH=` inside an unrecognized wrapper choose which git + // binary runs — the direct forms are denied, so the wrapper must be too. + () => `find . -exec sh -c 'PATH=/tmp/evil git reset --hard' ';'`, + () => `find . -exec sh -c 'GIT_EXEC_PATH=/tmp/evil git reset --hard' ';'`, + // A relocation assignment glued to a shell delimiter inside a quoted + // payload must still register as a marker, matching the `cd`/`pushd` arm. + () => `su -c 'true;GIT_DIR=${outsideRepo}/.git git reset --hard'`, + () => `su -c 'x && GIT_WORK_TREE=${outsideRepo} git reset --hard'`, + ])( + 'fails closed when an unrecognized program may run a relocated Git command %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('unrecognized program'), + }); + }, + ); + + it('allows commands that mention Git without a relocation marker', async () => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(`echo 'git status'`))).resolves.toEqual({ + allowed: true, + }); + await expect(guard(request(`grep -rn 'git reset' src`))).resolves.toEqual({ + allowed: true, + }); + }); + + // An unrecognized program word hides what runs, so a git mention only + // survives while the shell is provably still inside the boundary. + it.each([ + () => `cd ${outsideRepo} && nice git reset --hard`, + () => `cd ${outsideRepo} && ionice -c3 git reset --hard`, + () => `cd ${outsideRepo} && echo x | xargs -I{} git reset --hard`, + () => `cd ${outsideRepo} && find . -maxdepth 0 -exec git reset --hard ;`, + () => `cd ${outsideRepo} && stdbuf -o0 git reset --hard`, + () => 'cd - && nice git reset --hard', + ])( + 'denies an unrecognized program running Git after a cwd shift %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('allows an unrecognized program running Git inside the boundary', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('cd nested && nice git status')), + ).resolves.toEqual({ allowed: true }); + await expect(guard(request('nice git status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // `export`/`declare -x`/`set -a` put a GIT_* relocation in the environment + // of every later command, so it outlives the run that declared it. + it.each([ + () => `export GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `export GIT_WORK_TREE=${outsideRepo} ; git reset --hard`, + () => `export GIT_DIR=${path.join(outsideRepo, '.git')} && git commit -m x`, + () => `declare -x GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `typeset -x GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `readonly GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `set -a && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `set -o allexport; GIT_WORK_TREE=${outsideRepo}; git reset --hard`, + () => `export GIT_WORK_TREE=${outsideRepo} && sh -c 'git reset --hard'`, + () => `export GIT_WORK_TREE=$OTHER && git reset --hard`, + ])( + 'denies a mutation after an exported Git relocation %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves unexported and unrelated assignments alone', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('export FOO=bar && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`export GIT_WORK_TREE=${insideNested} && git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + // Without `export` (or `set -a`) the assignment stays shell-local and + // never reaches the git process. + await expect( + guard(request(`GIT_WORK_TREE=${outsideRepo}; echo done`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + () => `builtin cd ${outsideRepo} && git reset --hard`, + () => `builtin cd -P ${outsideRepo} && git reset --hard`, + ])('denies a mutation after `builtin cd` %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // `cd -P ` must not resolve containment against `/-P`: that + // basis is inside the boundary whenever such a directory exists. + it.each([ + () => `cd -P ${outsideRepo} && git reset --hard`, + () => `cd -L ${outsideRepo} && git reset --hard`, + () => `cd -eP ${outsideRepo} && git reset --hard`, + () => `cd -- ${outsideRepo} && git reset --hard`, + ])( + 'denies a mutation after an option-carrying cd %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps an option-carrying cd inside the boundary allowed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('cd -P nested && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request('cd -- nested && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + + // The relocated read-only allowance covers subcommands that neither write + // files nor run target-repository programs — flags can revoke both. + it.each([ + () => `git -C ${outsideRepo} cat-file --textconv --path=f.txt HEAD:f.txt`, + () => `git -C ${outsideRepo} cat-file --filters --path=f.txt HEAD:f.txt`, + () => `git -C ${outsideRepo} rev-parse --output=${outsideRepo}/o.txt HEAD`, + () => + `git -C ${outsideRepo} cat-file --output ${outsideRepo}/o.txt -p HEAD`, + ])( + 'denies a relocated read-only subcommand carrying a disqualifying flag %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it('still allows the plain relocated read-only subcommands', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + `git -C ${outsideRepo} cat-file -p HEAD:f.txt`, + `git -C ${outsideRepo} rev-parse HEAD`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // `ls-files` executes the target repository's core.fsmonitor hook — the + // same property that excluded `status` (measured on git 2.47.3). + it.each([ + () => `git -C ${outsideRepo} ls-files`, + () => `git -C ${outsideRepo} ls-files --others`, + ])('denies a relocated ls-files %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + // `describe --dirty`/`--broken` rewrite the target repository's index + // whenever its stat cache is stale (measured on git 2.47.3: a plain + // `describe`/`--tags`/`--always` leaves .git/index untouched). The whole + // subcommand stays out of the read-only set because the flag is one token + // away from any describe a model writes. + it.each([ + () => `git -C ${outsideRepo} describe`, + () => `git -C ${outsideRepo} describe --tags`, + () => `git -C ${outsideRepo} describe --dirty`, + () => `git -C ${outsideRepo} describe --always --dirty`, + () => `git -C ${outsideRepo} describe --broken`, + ])('denies a relocated describe %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + // `monitor` runs its `command` through the same shell as the shell tool. + it('applies the built-in policy to the monitor tool', async () => { + const guard = createDaemonToolGuard(); + const monitorCall = (command: string) => + ({ + ...request(command), + toolName: ToolNames.MONITOR, + }) as ExternalToolGuardPrepareRequest; + + await expect( + guard(monitorCall(`git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + await expect(guard(monitorCall('git status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // A quoted payload can relocate through `cd` instead of a Git flag, and an + // unrecognized program word hides which of them runs. + it.each([ + () => `su -c 'cd ${outsideRepo} && git reset --hard'`, + () => `xargs -I{} sh -c 'cd ${outsideRepo} && git reset --hard'`, + // `executableBaseName` lowercases, so an uppercase program word resolves + // to the same binary on a case-insensitive filesystem. + () => `cd ${outsideRepo} && nice GIT reset --hard`, + ])( + 'denies a relocated mutation concealed in an unrecognized program %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // A program word the daemon cannot read is as opaque as an unrecognized one. + it.each([ + () => `cd ${outsideRepo} && $CMD git reset --hard`, + () => `cd ${outsideRepo} && command $CMD git reset --hard`, + ])( + 'denies a dynamic program running Git after a cwd shift %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + // `export NAME` with no `=` exports an earlier shell-local assignment. + () => + `GIT_WORK_TREE=${outsideRepo}; export GIT_WORK_TREE; git reset --hard`, + () => + `GIT_DIR=${path.join(outsideRepo, '.git')}\nexport GIT_DIR\ngit commit -m x`, + // `eval` runs in the current shell, so its exports outlive the payload. + () => `eval 'export GIT_WORK_TREE=${outsideRepo}' && git reset --hard`, + () => `eval 'set -a' && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + // `set -o $OPT` can request allexport without naming it. + () => `set -o $OPT && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + // `+=` appends to an unknown previous value. + () => + `GIT_WORK_TREE+=${outsideRepo} && export GIT_WORK_TREE && git reset --hard`, + ])( + 'denies a mutation after a deferred or unresolvable export %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps shell-local assignments shell-local', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`GIT_WORK_TREE=${outsideRepo}; echo done`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request('FOO=bar; export FOO; git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + + // Config keys are case-insensitive and several beyond the alias set run a + // program of the target repository's choosing. + it.each([ + () => `git -c core.sshCommand='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c CORE.SSHCOMMAND='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c diff.d.textconv='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c merge.d.driver='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c sequence.editor='touch /tmp/x' -C ${outsideRepo} rev-parse`, + ])( + 'denies relocated commands carrying command-executing config %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // An unmodelled value-taking global option makes its value look like the + // subcommand, which ends option parsing and hides the relocation after it. + it.each([ + () => `git --shallow-file /tmp/shallow -C ${outsideRepo} reset --hard`, + () => `git --attr-source HEAD -C ${outsideRepo} reset --hard`, + ])( + 'parses relocations after value-taking global options %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it.each([ + () => 'env -S "$CMD"', + () => `env -S'git -C ${outsideRepo} reset --hard'`, + () => `env -iS'git -C ${outsideRepo} reset --hard'`, + ])('handles env -S payload forms %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Git discovers its repository by walking up from the working directory, so + // an in-boundary directory can still hand it an outside repository. + it('denies a relocation into a directory whose .git redirects outside', async () => { + const decoy = path.join(effectiveCwd, 'gitfile-decoy'); + await mkdir(decoy, { recursive: true }); + await writeFile( + path.join(decoy, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git -C gitfile-decoy reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard(request('cd gitfile-decoy && git commit -m x')), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('keeps a linked-worktree session working when its own .git points outside', async () => { + const linkedRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-wt-')); + const session = path.join(linkedRoot, 'checkout'); + const adminDir = path.join(linkedRoot, 'main', '.git', 'worktrees', 'live'); + await Promise.all([ + mkdir(path.join(session, 'nested'), { recursive: true }), + mkdir(adminDir, { recursive: true }), + ]); + await writeFile(path.join(session, '.git'), `gitdir: ${adminDir}\n`); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(session, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + const call = { + ...request('cd nested && git commit -m x'), + effectiveCwd: session, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toEqual({ allowed: true }); + await rm(linkedRoot, { recursive: true, force: true }); + }); + + it('resolves cd -P through symlinks before applying ..', async () => { + await symlink(outsideRepo, path.join(effectiveCwd, 'outward-link'), 'dir'); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('cd -P outward-link/.. && git reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + // The default (logical) form really does stay inside: bash resolves + // `link/..` against the logical path, so allowing it matches the shell. + await expect( + guard(request('cd outward-link/.. && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + + // `git -C` reaches the kernel as a chdir, which resolves each component's + // symlinks — unlike bash's default (logical) `cd`. + it('resolves git -C physically through symlinks', async () => { + const outward = path.join(effectiveCwd, 'physical-link'); + await symlink(path.join(outsideRepo, 'sub'), outward, 'dir'); + await mkdir(path.join(outsideRepo, 'sub'), { recursive: true }); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git -C physical-link/.. reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + await expect(guard(request('git -C nested/.. status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // A here-string carries its payload in the command line itself. + it.each([ + () => `sh <<< 'git -C ${outsideRepo} reset --hard'`, + () => `bash -s <<< 'cd ${outsideRepo} && git reset --hard'`, + ])('denies a payload delivered by here-string %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps ordinary redirects allowed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C nested status > out.txt 2> err.txt')), + ).resolves.toEqual({ allowed: true }); + }); + + // Brace expansion happens after this parse, so the tokens git receives are + // not the tokens the guard saw. + it.each([ + () => `git {-C,${outsideRepo}} reset --hard`, + () => `git -C{,${outsideRepo}} reset --hard`, + ])('denies a relocation hidden in a brace expansion %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // A redirection operand is scanned for markers but is never argv, so an + // `eval` payload must not absorb it. + it.each([ + () => `eval > /dev/null 'cd ${outsideRepo} && git reset --hard'`, + () => `eval 2> /dev/null 'cd ${outsideRepo} && git reset --hard'`, + ])('keeps redirections out of an eval payload %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // `cd` glued to a control operator is still a relocation. + it.each([ + () => `su -c 'true;cd ${outsideRepo} && git reset --hard'`, + () => `su -c 'true&&cd ${outsideRepo} && git reset --hard'`, + ])('treats an operator-glued cd as a marker %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Letters after `c` in a bundle are more flags; the payload is a later argv + // entry, and `-o`/`-O` among them consumes one first. + it.each([ + () => `bash -cx 'cd ${outsideRepo} && git reset --hard'`, + () => `sh -co ignoreeof 'cd ${outsideRepo} && git reset --hard'`, + () => `bash -c'cd ${outsideRepo} && git reset --hard'`, + ])('reads the -c payload from the right argv entry %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it.each([ + // `sudo -R ` moves the filesystem root out from under every path. + () => `sudo -R ${outsideRepo} git reset --hard`, + () => `sudo --chroot=${outsideRepo} git reset --hard`, + // A command that chooses its own `git` binary defeats the classification. + () => `PATH=/tmp/evilbin git commit -m x`, + () => `GIT_EXEC_PATH=/tmp/evil git commit -m x`, + ])( + 'fails closed when the run redefines its own context %#', + async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // Values assigned earlier in the same command are visible to the guard. + it.each([ + () => `X='git reset --hard'; cd ${outsideRepo}; $X`, + () => `X=git; Y='-C ${outsideRepo} reset --hard'; $X $Y`, + () => + `eval 'GIT_WORK_TREE=${outsideRepo}'; export GIT_WORK_TREE; git reset --hard`, + () => `GIT_WORK_TREE=${outsideRepo}; export $NAME; git reset --hard`, + ])('resolves a relocation through shell variables %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('scopes a parenthesized subshell the way the shell does', async () => { + const guard = createDaemonToolGuard(); + + // The subshell's cwd dies with its parentheses... + await expect( + guard(request(`(cd ${outsideRepo}); git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + // ...but a Git command inside them is still judged against it. + await expect( + guard(request(`(cd ${outsideRepo} && git reset --hard)`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('keeps env value flags in their attached forms decidable', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'env --unset=GIT_DIR git commit -m x', + 'env -uGIT_DIR git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // A relinked `.git` redirects repository discovery for every later command, + // relocated or not; a relinked directory only affects a run that resolves + // that very path. + it.each([ + () => `ln -s ${path.join(outsideRepo, '.git')} .git && git status`, + () => `ln -s ${path.join(outsideRepo, '.git')} .git && git commit -m x`, + () => `env ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `X=1 ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `nice ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `cp -s ${outsideRepo} bait && git -C bait reset --hard`, + ])('denies Git after the command relinks its path %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // A dynamic program word may be `ln`, and an ordinary target it re-points + // is just as invalidating as a `.git` one. + it.each([ + () => + `rm -rf src && X=ln; $X -s ${outsideRepo} src && git -C src reset --hard`, + () => `X=ln; $X -s ${outsideRepo} nested && git -C nested reset --hard`, + ])('denies Git after a dynamic relinker re-points its path %#', async (b) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Git discovers its repository by walking up even with no relocation. + it('denies a planted gitfile at the session root', async () => { + const decoyRoot = path.join(temporaryRoot, 'decoy-session'); + await mkdir(decoyRoot, { recursive: true }); + await writeFile( + path.join(decoyRoot, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + const call = { + ...request('git commit -m x'), + effectiveCwd: decoyRoot, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toMatchObject({ allowed: false }); + }); + + it('leaves a session bound below its repository alone', async () => { + // The repository's `.git` lives ABOVE the boundary, so the walk stops at + // the boundary and finds nothing — the ordinary monorepo-subdir session. + const repoRoot = path.join(temporaryRoot, 'mono'); + const session = path.join(repoRoot, 'packages', 'app'); + await mkdir(path.join(repoRoot, '.git'), { recursive: true }); + await mkdir(session, { recursive: true }); + + const guard = createDaemonToolGuard(); + const call = { + ...request('git commit -m x'), + effectiveCwd: session, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toEqual({ allowed: true }); + }); + + it.each([ + // Redirects and their `N>` prefixes are never the `-c` payload. + () => `sh -c > /dev/null 'git -C ${outsideRepo} reset --hard'`, + // These env keys move where git writes or which config it reads. + () => `GIT_OBJECT_DIRECTORY=${outsideRepo}/.git/objects git commit -m x`, + () => `GIT_CONFIG_GLOBAL=${outsideRepo}/evil.cfg git commit -m x`, + () => `GIT_ALTERNATE_OBJECT_DIRECTORIES=${outsideRepo} git commit -m x`, + // `$'…'` escapes with a backslash, so the scanner must not lose phase. + () => `echo $'a\\'b' $(git -C ${outsideRepo} reset --hard)`, + // `+=` builds the value the shell will expand. + () => `X=git; X+=' -C ${outsideRepo}'; X+=' reset --hard'; $X`, + ])('closes the round-4 shell and environment gaps %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps a subshell from leaking its environment outward', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`(export GIT_WORK_TREE=${outsideRepo}); git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`(export GIT_WORK_TREE=${outsideRepo}; git reset --hard)`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it("leaves a program's own -C flag alone", async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'grep -C 5 git CHANGELOG.md', + 'tar -C nested -cf out.tar .', + 'diff -C 3 a.txt b.txt # git', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + // …while an unrecognized wrapper's -C is still git's. + await expect( + guard(request(`xargs -I{} git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + // Relink state crosses scopes in both directions: the symlink a nested + // evaluation creates is just as real, and a parent's relink still misleads + // a nested run. + it.each([ + () => + `sh -c 'rm -rf src && ln -s ${outsideRepo} src' && git -C src reset --hard`, + () => `eval 'ln -s ${outsideRepo} src' && git -C src reset --hard`, + () => `echo $(ln -s ${outsideRepo} src) && git -C src reset --hard`, + () => `ln -s ${outsideRepo} src && sh -c 'git -C src reset --hard'`, + () => `X=ln; $X -s ${path.join(outsideRepo, '.git')} .git && git add -A`, + () => `ln -s ${path.join(outsideRepo, '.git')} .git && nice git add -A`, + ])('carries relink state across scopes %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it.each([ + // `<(…)` opens a paren the tokenizer must count, or its `)` pops the + // enclosing subshell early and the preceding `cd` is lost. + () => `(cd ${outsideRepo}; <(true); git reset --hard)`, + // `eval` runs in this shell, so it sees the shell-local assignment. + () => + `GIT_DIR=${outsideRepo}/meta; eval 'export GIT_DIR'; git reset --hard`, + // Any unreadable word in a shell's argv can be the `-c`. + () => `A='-c'; bash $A "$P"`, + () => `bash $A "$P"`, + ])('fails closed on the round-5 scope gaps %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // A sub-agent pinned to a worktree executes there while reporting the + // parent session id, so the session's own directory is not the boundary. + describe('reported execution directory', () => { + const call = ( + command: string, + invocationCwd?: string, + ): ExternalToolGuardPrepareRequest => + ({ + ...request(command), + ...(invocationCwd === undefined ? {} : { invocationCwd }), + }) as ExternalToolGuardPrepareRequest; + + it('accepts a directory inside the session', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(call('git commit -m x', insideNested)), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on a directory the daemon cannot place', async () => { + const guard = createDaemonToolGuard(); + + // The session id owns no worktree here, so this scope is unverifiable. + await expect( + guard(call('git commit -m x', outsideRepo)), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('execution directory'), + }); + }); + + it('contains a sub-agent to an in-project agent worktree', async () => { + // `AgentTool` with `isolation: 'worktree'` provisions under + // `/.qwen/worktrees/`, i.e. inside the session — being + // inside is not enough to leave the boundary alone. + const agentWorktree = path.join( + effectiveCwd, + '.qwen', + 'worktrees', + 'agent-abc1234', + ); + const sibling = path.join( + effectiveCwd, + '.qwen', + 'worktrees', + 'agent-def5678', + ); + await mkdir(path.join(agentWorktree, 'src'), { recursive: true }); + await mkdir(sibling, { recursive: true }); + await writeFile( + path.join(agentWorktree, '.git'), + `gitdir: ${path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234')}\n`, + ); + await mkdir( + path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234'), + { recursive: true }, + ); + await writeFile( + path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234', 'gitdir'), + `${path.join(agentWorktree, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + // Work inside its own worktree is allowed... + await expect( + guard(call('cd src && git commit -m x', agentWorktree)), + ).resolves.toEqual({ allowed: true }); + // ...reaching into a sibling agent's worktree is not, even though both + // sit inside the session's directory. + await expect( + guard(call(`git -C ${sibling} reset --hard`, agentWorktree)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('contains a sub-agent to the worktree it reports', async () => { + // A session id unique to this test: `getWorktreesDir` resolves under the + // user's global Qwen dir, so a shared id would have this test create and + // delete real directories belonging to someone's session. + const isolatedSessionId = `daemon-guard-${process.pid}-worktree`; + const owned = GitWorktreeService.getWorktreesDir(isolatedSessionId); + const agentWorktree = path.join(owned, 'agent-a'); + await mkdir(path.join(agentWorktree, 'src'), { recursive: true }); + + const guard = createDaemonToolGuard(); + const inWorktree = (command: string): ExternalToolGuardPrepareRequest => + ({ + ...call(command, agentWorktree), + sessionId: isolatedSessionId, + }) as ExternalToolGuardPrepareRequest; + try { + // Its own worktree is the boundary: work inside it is allowed... + await expect( + guard(inWorktree('cd src && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + // ...while reaching back into the parent checkout is not. + await expect( + guard(inWorktree(`git -C ${effectiveCwd} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + } finally { + await rm(GitWorktreeService.getSessionDir(isolatedSessionId), { + recursive: true, + force: true, + }); + } + }); + }); + + it.each([ + // Env vars git executes as programs, and its config-injection channels. + () => `GIT_SSH_COMMAND='touch /tmp/x' git fetch`, + () => `GIT_EDITOR='touch /tmp/x' git commit`, + () => `GIT_ASKPASS='touch /tmp/x' git fetch`, + () => `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager git status`, + // Config keys git runs through a shell. + () => `git -c diff.external='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c core.gitProxy='touch /tmp/x' -C ${outsideRepo} rev-parse`, + // An unrecognized wrapper does not launder that config. + () => `nice git -c alias.pwn='!cd ${outsideRepo} && git reset --hard' pwn`, + // `-execdir` runs git with the cwd of each directory it visits. + () => `find ${outsideRepo} -execdir git reset --hard ;`, + // An archive decides where it writes, so the extraction directory is + // what became untrustworthy. + () => `tar -xf evil.tar && git -C nested reset --hard`, + // A body defined earlier runs where the later bare word appears. + () => `alias g='git reset --hard'; cd ${outsideRepo}; g`, + () => `f() { git reset --hard; }; cd ${outsideRepo}; f`, + // A decoy `> g` redirect whose target equals the function name must not + // truncate the prefix-assignment scan of the `GIT_DIR=` on the call. + () => `g() { git reset --hard; }; > g GIT_DIR=${outsideRepo}/.git g`, + ])('closes the round-6 escapes %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Each of these was denied by a rule that was too broad. + it('keeps ordinary commands out of the round-6 rules', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // `SHELLOPTS` is bash's own options state, not a git redirection. + 'SHELLOPTS=errexit git status', + 'env --ignore-environment git status', + 'env --null git status', + // `curl -C -` resumes a download; it is not `git -C`. + 'curl -C - -o pkg.tgz https://git.example.com/pkg.tgz', + "env -iS 'git status'", + // A `cd` target the guard already knows the value of. + `d=${insideNested}; cd $d; git status`, + // `set +a` turns allexport back off. + `set -a; set +a; GIT_WORK_TREE=${outsideRepo}; echo done`, + // Definitions used inside the boundary stay allowed. + 'f() { git status; }; cd nested; f', + "alias g='git status'; cd nested; g", + 'tar -xf a.tar && git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // Round-7. These are the reviewers' exact payloads: the earlier "denies as + // written" replies were checked with counter-probes that tripped a + // different rule (a `.git` in the path matching the Git-word marker, or a + // literal `-C`), leaving the reported mechanism untouched. `outsideRepo` + // would do that again, so these use a path with no Git word in it. + describe('round-7 exact payloads', () => { + const plainOutside = path.join(temporaryRoot, 'elsewhere', 'checkout'); + const spacedOutside = path.join(temporaryRoot, 'boundary with space'); + + it.each([ + // `$'…'` is not ANSI-C quoting inside double quotes, so the + // substitution in it is live. + () => `echo "$'$(GIT_DIR=${plainOutside}/.git git reset --hard HEAD~1)'"`, + // The export attribute sticks to the name, so a LATER assignment to it + // reaches the git subprocess. + () => `export GIT_DIR; GIT_DIR=${plainOutside}; git reset --hard`, + () => + `export GIT_WORK_TREE; GIT_WORK_TREE=${plainOutside}; git reset --hard`, + // Both sides of a pipe run in subshells: the parent stays outside. + () => `cd ${plainOutside}; echo x | cd ${effectiveCwd}; git commit -m x`, + // A bare digit before a spaced redirect is a real argv word. + () => `eval git -C 2 > x reset --hard`, + // `-o` before `c` in a bundle does not cancel the `c`. + () => `bash -oc errexit "$P"`, + () => `bash -Oc extglob "$P"`, + () => `bash -oc errexit 'git -C ${plainOutside} reset --hard'`, + // Re-joining argv must not lose the quoting that made a path one word. + () => `env -S 'git -C' '${spacedOutside}' reset --hard`, + ])('denies the reported payload verbatim %#', async (build) => { + await mkdir(path.join(plainOutside, '.git'), { recursive: true }); + await mkdir(path.join(spacedOutside, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves the equivalent in-boundary shapes alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'echo x | cat; git commit -m x', + `cd nested; echo x | cd ${effectiveCwd}; git commit -m x`, + "env -S 'git status'", + "bash -oc errexit 'git status'", + 'export GIT_DIR; echo done', + ]) { + await expect(guard(request(command))).resolves.toEqual({ + allowed: true, + }); + } + }); + }); + + // Defects the round-7 patch itself introduced. Each was reproduced before + // the fix; the path deliberately carries no Git word. + it.each([ + // The fd digit of `2>…` belongs to the redirection, never to argv. + () => `sh -c 2> /dev/null 'git -C ${plainOutsidePath} reset --hard'`, + // Each `o`/`O` after `c` consumes one entry, not "one if any". + () => `bash -coo x y 'git -C ${plainOutsidePath} reset --hard'`, + // The last payload rebuild that still joined without re-quoting. + () => `env --split-string='git -C' '${plainOutsidePath}' reset --hard`, + // A lone `&` backgrounds into a subshell, so its `cd` does not stick. + () => `cd ${plainOutsidePath}; cd ${effectiveCwd} & git reset --hard`, + // `>|` is the clobber redirect, not a pipe. + () => `cd ${plainOutsidePath} >| /tmp/f && git -C . push`, + // The export attribute is shell state and crosses `eval`. + () => + `export GIT_DIR; eval 'GIT_DIR=${plainOutsidePath}'; git reset --hard`, + // A deferred body is keyed on the program word, not on run[0]. + () => `alias g='git reset --hard'; cd ${plainOutsidePath}; X=1 g`, + ])('closes a defect the round-7 patch introduced %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves backgrounded and redirected in-boundary work alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'sleep 1 & git commit -m x', + 'git status > out.txt 2> err.txt', + "bash -coo x y 'git status'", + "env --split-string='git status'", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // Common shell forms an agent may emit — not adversarial exotica. Fixed + // even under the guard's "reliable against literal forms" promise. + it.each([ + // `&>` / `&>>` is a redirect operator, not a background separator. + () => `cd ${plainOutsidePath} &> /dev/null; git reset --hard`, + () => `cd ${plainOutsidePath} &>> /dev/null; git reset --hard`, + // The `function NAME { … }` keyword form, `()` optional. + () => `function g { git reset --hard; }; cd ${plainOutsidePath}; g`, + () => `function g() { git reset --hard; }; cd ${plainOutsidePath}; g`, + // `include.path`/`includeIf.*.path` pull in a config file the guard + // cannot read; it can carry a worktree redirect or executable config. + () => `git -c include.path=/tmp/evil reset --hard`, + () => `git -c includeIf.gitdir:/x.path=/tmp/evil commit -m x`, + ])( + 'denies a common-form relocation the parser used to miss %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves the in-boundary equivalents of those forms alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'git status &> /dev/null', + 'git commit -m x &>> log.txt', + 'function g { git status; }; cd nested; g', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // Round-9 Criticals reproduced before fixing (Git-word-free path). + it.each([ + // Config/env channels git executes as programs. + () => `git -c imap.tunnel='touch /tmp/x' -C ${plainOutsidePath} fetch`, + () => + `git -c instaweb.httpd='touch /tmp/x' -C ${plainOutsidePath} rev-parse`, + () => + `GIT_DIFFTOOL_EXTCMD='touch /tmp/x' git -C ${plainOutsidePath} difftool`, + // `GIT_DIR=… set -a` persists (special builtin) and exports. + () => `GIT_DIR=${plainOutsidePath}/.git set -a; git reset --hard`, + // Definition recognition behind a redirect / keyword prefix. + () => `2>/dev/null alias g='git reset --hard'; cd ${plainOutsidePath}; g`, + () => + `if true; then alias g='git reset --hard'; fi; cd ${plainOutsidePath}; g`, + // Every pair of a multi-alias statement is a definition. + () => `alias a=x g='git reset --hard'; cd ${plainOutsidePath}; g`, + // A heredoc body must not launder a tracked cwd. + () => + `cd ${plainOutsidePath}; cat < `f() { true; git -C ${plainOutsidePath} reset --hard; }; f`, + ])('denies the round-9 critical form %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps the round-9 in-boundary equivalents alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // A backgrounded `cd` does not move the shell that runs git. + 'cd nested & git commit -m x', + // Extract-then-commit is ordinary work, not a relocation. + 'tar -xf a.tar && git commit -m x', + 'cat < `alias gg='git'; gg -C ${plainOutsidePath} reset --hard`, + () => `alias gg='git -C'; gg ${plainOutsidePath} reset --hard`, + ])('denies a relocation passed to an alias %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves an alias used inside the boundary alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + "alias gg='git'; gg status", + "alias gg='git commit'; gg -m x", + "alias gg='git status'; cd nested; gg", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // A function/alias runs in the current shell, so a `cd` or export in its + // body survives the call and a later path-free Git mutation is judged + // against where the body left the shell. + it.each([ + () => `f() { cd ${plainOutsidePath}; }; f; git reset --hard`, + () => + `f() { export GIT_DIR=${plainOutsidePath}/.git; }; f; git reset --hard`, + () => `alias gg='cd ${plainOutsidePath}'; gg; git reset --hard`, + ])('carries a body cwd/export out to the caller %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps an in-boundary body cwd shift allowed', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'f() { cd nested; }; f; git status', + 'f() { echo hi; }; f; git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // A body run in the current shell inherits the caller's `set -a`, so a + // plain assignment there is exported to the following git. + it.each([ + () => + `set -a; f() { GIT_WORK_TREE=${plainOutsidePath}; }; f; git reset --hard`, + () => `set -a; GIT_WORK_TREE=${plainOutsidePath}; git reset --hard`, + () => `set -a; eval 'GIT_WORK_TREE=${plainOutsidePath}'; git reset --hard`, + ])( + 'carries the caller allexport into a same-shell body %#', + async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves an unexported body assignment alone', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // No `export`, no `set -a`: bash does not put it in git's environment. + for (const command of [ + `GIT_WORK_TREE=${plainOutsidePath}; git status`, + `f() { GIT_WORK_TREE=${plainOutsidePath}; }; f; git status`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + it.each([ + // A command substitution inherits the caller's `set -a`. + () => `set -a; echo $(GIT_WORK_TREE=${plainOutsidePath}; git reset --hard)`, + // A nested function defined in the caller is visible to the body it runs. + () => + `inner() { cd ${plainOutsidePath}; }; outer() { inner; }; outer; git reset --hard`, + ])( + 'shares option and definition state with a same-shell body %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('lets a same-shell body turn allexport back off', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // `set +a` in the body persists, so the later assignment is unexported. + for (const command of [ + `set -a; f() { set +a; }; f; GIT_WORK_TREE=${plainOutsidePath}; git status`, + `set -a; eval 'set +a'; GIT_WORK_TREE=${plainOutsidePath}; git status`, + // A substitution's own changes die with it. + `echo $(GIT_WORK_TREE=${plainOutsidePath}; git status)`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + it.each([ + // A function/alias shadows the git program or a builtin; bash resolves it + // before either, so the recorded body must run first. + () => `git() { cd ${plainOutsidePath}; command git status; }; git`, + () => + `cd() { command cd ${plainOutsidePath}; }; cd nested; git reset --hard`, + // A pipeline redefinition runs in a subshell and does not persist. + () => + `f() { cd ${plainOutsidePath}; }; f() { :; } | cat; f; git reset --hard`, + // `export -f` makes a function visible inside a `bash -c` subprocess. + () => + `f() { cd ${plainOutsidePath}; }; export -f f; bash -c "f; git reset --hard"`, + ])('resolves a shadowing/exported function correctly %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('does not import an unexported function into a subprocess', async () => { + const guard = createDaemonToolGuard(); + + // Without `export -f`, `bash -c` does not see `f`, so this is an ordinary + // (path-free) git run inside the boundary. + await expect( + guard(request(`f() { cd ${plainOutsidePath}; }; bash -c 'git status'`)), + ).resolves.toEqual({ allowed: true }); + // `command git` explicitly bypasses a shadowing function. + await expect(guard(request('command git status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // Gaps in the function-model work of the preceding commits. + it.each([ + // `export -f` state must reach a nested same-shell body too. + () => + `f() { cd ${plainOutsidePath}; }; export -f f; g() { bash -c "f; git reset --hard"; }; g`, + // A prefix assignment on a function/alias invocation reaches its git. + () => `gg() { git status; }; GIT_WORK_TREE=${plainOutsidePath} gg`, + () => `alias gg='git status'; GIT_WORK_TREE=${plainOutsidePath} gg`, + ])('propagates invocation state into a recorded body %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('rolls back a pipe subshell fully', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // The pipe-side export/assignment dies with the subshell. + await expect( + guard( + request( + `export GIT_WORK_TREE; GIT_WORK_TREE=${plainOutsidePath} | cat; git status`, + ), + ), + ).resolves.toEqual({ allowed: true }); + }); + + // Reachable escapes via a redirection on a `cd` or inside a git run. + it.each([ + () => `cd ${plainOutsidePath} >&2; git reset --hard`, + () => `git 2>/dev/null -C ${plainOutsidePath} reset --hard`, + () => `git -C ${plainOutsidePath} 2>/dev/null reset --hard`, + ])('denies a relocation around a redirection %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves an ordinary redirection alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'cd nested >&2; git status', + 'git status 2>/dev/null', + 'git -C nested reset --hard 2>&1', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + it.each([ + // A redirection before an alias/function invocation must not hide it. + () => `alias gg='git -C ${plainOutsidePath} reset --hard'; 2>/dev/null gg`, + () => `gg() { git -C ${plainOutsidePath} reset --hard; }; 2>/dev/null gg`, + // Only the segment `&` follows is backgrounded; the next runs foreground. + () => `true & cd ${plainOutsidePath}; git reset --hard`, + ])('denies a relocation past a redirect or background %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps foreground/background boundaries correct', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // The backgrounded `cd` is a subshell; the foreground git stays inside. + `cd ${outsideRepo} & git status`, + 'true & cd nested; git status', + "2>/dev/null alias gg='git status'; gg", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // A harmless recorded body must not mask a relocation the real interpreter + // would run: only bash imports `export -f`, and removals retract a shadow. + it.each([ + // dash does not import the exported function, so the real git relocates. + () => + `git() { :; }; export -f git; dash -c "git -C ${plainOutsidePath} reset --hard"`, + // `unset -f`/`unalias` remove the shadow, exposing the real git. + () => `git() { :; }; unset -f git; git -C ${plainOutsidePath} reset --hard`, + () => + `alias git='echo hi'; unalias git; git -C ${plainOutsidePath} reset --hard`, + // `sh` resolves to dash on most daemons but to bash on macOS, so the + // guard never replays an exported shadow for it: importing on a + // dash-backed `sh` would recreate the escape. It fails closed — the + // deliberate, safe trade-off is over-denying the bash-backed case. + () => + `git() { :; }; export -f git; sh -c "git -C ${plainOutsidePath} reset --hard"`, + // `env -i`/`-`/`--ignore-environment` wipe the exported function before + // bash starts, so even bash resolves the real git. + () => + `git() { :; }; export -f git; env -i bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env - bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env --ignore-environment bash -c "git -C ${plainOutsidePath} reset --hard"`, + ])( + 'does not let a stale/incompatible shadow mask a relocation %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // Removal builtins must retract a shadow only the way the real shell does: + // mis-modelling one drops a live relocating function and allows the command. + it.each([ + // `unset` has no `-a` option: the command errors, the function survives. + () => `pwn() { git -C ${plainOutsidePath} reset --hard; }; unset -a; pwn`, + // `unalias -a` clears aliases, never functions. + () => `pwn() { git -C ${plainOutsidePath} reset --hard; }; unalias -a; pwn`, + // A function shadowing `unset` runs `:` instead of the builtin, so the + // removal never happens and the shadow stays live. + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; unset() { :; }; unset -f pwn; pwn`, + // A `-c` subprocess is a separate process: its `unset -f` cannot retract + // the parent's exported function. + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; export -f pwn; bash -c "unset -f pwn"; bash -c pwn`, + // A command substitution inherits the exported function too. + () => + `evil() { git -C ${plainOutsidePath} reset --hard; }; export -f evil; echo $(bash -c 'evil')`, + // A bare `unset NAME` removes a same-name variable first; Bash keeps the + // function, so the model must not delete it (it tracks no variables). + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; pwn=1; unset pwn; pwn`, + // `env -u BASH_FUNC_git%%` (separated, attached, and `--unset=` forms) + // strips the exported function from the child, which then runs the real + // git — the guard must not replay the harmless imported body. + () => + `git() { :; }; export -f git; env -u 'BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env -u'BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env --unset='BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + // A bare `unset git` with no same-name variable removes the function in + // bash; the harmless body must not mask the relocating call arguments. + () => `git() { :; }; unset git; git -C ${plainOutsidePath} reset --hard`, + // Fused `export -nf` un-exports the function bash's option parser accepts. + () => + `git() { :; }; export -f git; export -nf git; bash -c 'git -C ${plainOutsidePath} reset --hard'`, + // A `command`/`builtin` prefix still runs the real removal builtin. + () => + `git() { :; }; command unset -f git; git -C ${plainOutsidePath} reset --hard`, + // A leading redirection is stripped from argv, so it must not hide the + // `command unset` that removes the shadow. + () => + `git() { :; }; 2>/dev/null command unset -f git; git -C ${plainOutsidePath} reset --hard`, + // `enable -n unset` disables the builtin, so the removal is a no-op and + // the relocating function survives. + () => + `g() { git -C ${plainOutsidePath} reset --hard; }; enable -n unset; unset -f g; g`, + // A removal inside a `( … )` subshell does not reach the parent shell. + () => + `git() { command git -C ${plainOutsidePath} reset --hard "$@"; }; ( unset -f git ); git`, + // A function shadowing `unset` runs its relocating body even when the + // argument names only untracked state — the builtin never runs. + () => `unset() { git -C ${plainOutsidePath} reset --hard; }; unset other`, + // `unset A` drops the tracked variable, so `cd $A` is a bare `cd` to $HOME + // in bash; the guard must not keep expanding the stale in-bounds value. + () => `A=nested; unset A; cd $A; git reset --hard`, + // A function shadowing the `command`/`builtin` prefix word runs its own + // relocating body — the prefix is not a guaranteed bypass to the builtin. + () => + `command() { git -C ${plainOutsidePath} reset --hard; }; command unset other`, + () => + `builtin() { git -C ${plainOutsidePath} reset --hard; }; builtin unset other`, + ])( + 'does not let a mis-modelled removal drop a live relocating shadow %#', + async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps a live compatible shadow modelled', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + for (const command of [ + // bash imports the function, which really shadows git and drops args. + `git() { :; }; export -f git; bash -c "git -C ${plainOutsidePath} reset --hard"`, + // The alias is still in effect (no removal). + `alias git='echo hi'; git -C ${plainOutsidePath} reset --hard`, + // Removing a different name leaves the git shadow intact. + `git() { :; }; unset -f other; git -C ${plainOutsidePath} reset --hard`, + // `env -i` clears the function, but a read-only relocation is still fine. + `git() { :; }; export -f git; env -i bash -c "git -C ${plainOutsidePath} rev-parse HEAD"`, + // `env` without a clearing flag keeps the bash-imported shadow live. + `git() { :; }; export -f git; env FOO=bar bash -c "git -C ${plainOutsidePath} reset --hard"`, + // `env -u` of an unrelated key leaves the exported function in place. + `git() { :; }; export -f git; env -u FOO bash -c "git -C ${plainOutsidePath} reset --hard"`, + // `env -u BASH_FUNC_other%%` strips a different function, not git. + `git() { :; }; export -f git; env -u 'BASH_FUNC_other%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // The shell-executing set pins ToolNames literals in acp-bridge, which + // cannot import core; a rename must fail here. + it('matches the ToolNames constants for shell-executing tools', () => { + expect(SHELL_EXECUTING_TOOL_NAMES).toEqual( + new Set([ToolNames.SHELL, ToolNames.MONITOR]), + ); + }); + + it('short-circuits the external provider after a built-in denial', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + + await expect( + guard(request(`git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + expect(externalGuard).not.toHaveBeenCalled(); + }); + + it('forwards allowed calls to the external provider unchanged', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + const call = request('pwd'); + + await expect(guard(call)).resolves.toEqual({ allowed: true }); + expect(externalGuard).toHaveBeenCalledWith(call); + }); + + it('returns an external provider denial for an otherwise allowed call', async () => { + const providerDenial = { + allowed: false, + reason: 'Provider policy denied this invocation.', + }; + const externalGuard = vi.fn().mockResolvedValue(providerDenial); + const guard = createDaemonToolGuard(externalGuard); + + await expect(guard(request('pwd'))).resolves.toEqual(providerDenial); + expect(externalGuard).toHaveBeenCalledOnce(); + }); + + it.each([ + ToolNames.AGENT, + ToolNames.WORKFLOW, + ToolNames.CREATE_SUB_SESSION, + ToolNames.SEND_MESSAGE, + ])( + 'preserves external-provider nested executor restrictions only when configured (%s)', + async (toolName) => { + const call = { + ...request('pwd'), + toolName, + arguments: {}, + }; + + await expect(createDaemonToolGuard()(call)).resolves.toEqual({ + allowed: true, + }); + await expect( + createDaemonToolGuard(vi.fn().mockResolvedValue({ allowed: true }))( + call, + ), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('nested or delegated'), + }); + }, + ); + + // The unsupported-tool set intentionally pins ToolNames string literals so + // this module keeps its import footprint; a rename must fail here. + it('matches the ToolNames constants for nested executor tools', () => { + const unsupported = new Set([ + 'agent', + 'workflow', + 'create_sub_session', + 'send_message', + ]); + expect(unsupported).toEqual( + new Set([ + ToolNames.AGENT, + ToolNames.WORKFLOW, + ToolNames.CREATE_SUB_SESSION, + ToolNames.SEND_MESSAGE, + ]), + ); + }); + + it('fails closed without the trusted effective working directory', async () => { + const guard = createDaemonToolGuard(); + const call = request('pwd') as unknown as Record; + delete call['effectiveCwd']; + + await expect( + guard(call as unknown as ExternalToolGuardPrepareRequest), + ).rejects.toThrow('trusted workspace context'); + }); + + it('applies the built-in policy to prompt-less shell checks', async () => { + const guard = createDaemonToolGuard(); + + const allowed = request('pwd') as unknown as Record; + delete allowed['promptId']; + await expect( + guard(allowed as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toEqual({ allowed: true }); + + const denied = request( + `git -C ${outsideRepo} reset --hard`, + ) as unknown as Record; + delete denied['promptId']; + await expect( + guard(denied as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('refuses to consult the external provider without a prompt binding', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + const call = request('pwd') as unknown as Record; + delete call['promptId']; + + await expect( + guard(call as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('without an active prompt binding'), + }); + expect(externalGuard).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts new file mode 100644 index 00000000000..39ce8229028 --- /dev/null +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -0,0 +1,2910 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { realpath, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { parse } from 'shell-quote'; +import { + GitWorktreeService, + isWithinRoot, + realpathNearestExistingAsync, + splitCommands, +} from '@qwen-code/qwen-code-core'; +import { + EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS, + SHELL_EXECUTING_TOOL_NAMES as SHELL_EXECUTING_TOOLS, +} from '@qwen-code/acp-bridge/externalToolGuard'; +import type { + ExternalToolGuardHandler, + ExternalToolGuardPrepareRequest, + ExternalToolGuardPrepareResult, +} from '@qwen-code/acp-bridge/bridgeOptions'; + +// Git subcommands allowed even when relocated outside the session working +// directory. Limited to subcommands verified to neither write files nor +// execute programs configured by the target repository on the managed +// (non-tty) output path. `diff`/`log`/`show`/`blame` are excluded: `--output` +// writes files and textconv drivers run commands from target-repository +// config. `grep` takes the same `--textconv` path; `status` and `ls-files` +// both run the target repository's core.fsmonitor (measured on git 2.47.3 — +// `ls-files` executes the hook even though it writes no index); and +// `describe --dirty`/`--broken` rewrite the target index whenever its stat +// cache is stale (a plain `describe` does not, but the flag is one token +// away), so none of them is read-only here. +const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set(['cat-file', 'rev-parse']); + +// Flags that break the invariant above wherever they appear: `--output` +// writes a file, and `--textconv`/`--filters` run the *target* repository's +// configured drivers (`git -C cat-file --textconv --path=f HEAD:f` +// executes its `diff..textconv` command). A subcommand from the set +// above carrying one of these is treated as any other relocated command. +const RELOCATED_READ_ONLY_DISQUALIFYING_FLAGS = new Set([ + '--filters', + '--output', + '--textconv', +]); + +// Git global options whose next argv entry is consumed as a value. +// `--exec-path` and `--list-cmds` are deliberately absent: real git only +// accepts their `=` form (a bare `--exec-path` prints and exits), so +// modelling them as value-taking would swallow the token that follows them. +// An unmodelled value-taking option is not merely ignored: its value is read +// as the subcommand, which ends option parsing and hides every relocation +// after it (`git --shallow-file

-C reset --hard`). +const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ + '--attr-source', + '--namespace', + '--shallow-file', + '--super-prefix', +]); + +// `-c`/`--config-env` keys whose values git executes through a shell. A +// relocated mutation can be embedded in such a value with no relocation in +// the outer argv, so these mark a mutating invocation unresolved. Git config +// keys are case-insensitive, so these are matched against a lowercased key. +const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ + /^alias\./, + /^core\.(askpass|editor|fsmonitor|pager|sshcommand)$/, + /^credential\.helper$/, + /^diff\..+\.(command|textconv)$/, + /^difftool\./, + /^filter\./, + /^core\.hookspath$/, + /^gpg\.(.+\.)?program$/, + /^merge\..+\.driver$/, + /^mergetool\./, + /^pager\./, + /^sequence\.editor$/, + /^uploadpack\.packobjectshook$/, + /^browser\..+\.cmd$/, + /^core\.gitproxy$/, + /^credential\..+\.helper$/, + /^diff\.external$/, + /^gc\.recentobjectshook$/, + /^help\.browser$/, + /^interactive\.difffilter$/, + /^remote\..+\.(proxy|receivepack|uploadpack)$/, + /^ssh\.variant$/, + /^tar\..+\.command$/, + /^trailer\..+\.command$/, + /^man\..+\.cmd$/, + /^sendemail\.(sendmailcmd|tocmd|cccmd)$/, + /^web\.browser$/, + // Pulls in a config file the guard cannot read: it can carry a + // `core.worktree` redirect or any command-executing key, so it is + // undecidable and fails closed. + /^include\.path$/, + /^includeif\..+\.path$/, + /^imap\.tunnel$/, + /^instaweb\.httpd$/, +]; + +// Environment assignments that redirect git's repository selection (mirrors +// core shell.ts GIT_ENV_SHIFTS_REPO). +const GIT_DIR_ENV_KEYS = new Set(['GIT_COMMON_DIR', 'GIT_DIR']); +const GIT_WORK_TREE_ENV_KEYS = new Set(['GIT_INDEX_FILE', 'GIT_WORK_TREE']); +// Keys that redirect where git writes or which config it reads without +// naming a repository the containment check can resolve. Measured on git +// 2.47.3: `GIT_OBJECT_DIRECTORY=/.git/objects git add` writes the +// blob there, and `GIT_CONFIG_GLOBAL=/cfg` makes git read that +// file — enough to point `core.hooksPath` outside. +const GIT_UNRESOLVABLE_ENV_KEYS = new Set([ + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_ASKPASS', + 'GIT_CONFIG', + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_PARAMETERS', + 'GIT_CONFIG_SYSTEM', + 'GIT_EDITOR', + 'GIT_DIFFTOOL_EXTCMD', + 'GIT_EXTERNAL_DIFF', + 'GIT_OBJECT_DIRECTORY', + 'GIT_PAGER', + 'GIT_SEQUENCE_EDITOR', + 'GIT_SSH', + 'GIT_SSH_COMMAND', +]); + +// `GIT_CONFIG_KEY_`/`GIT_CONFIG_VALUE_` are the numbered half of git's +// environment config channel — equivalent to `-c =`. +const GIT_NUMBERED_CONFIG_ENV_PATTERN = /^GIT_CONFIG_(KEY|VALUE)_\d+$/; + +const SHELL_WRAPPER_PROGRAMS = new Set(['bash', 'dash', 'ksh', 'sh', 'zsh']); +const SHELL_WRAPPER_VALUE_FLAGS = new Set(['-o', '-O']); + +// `-c` bundled inside short flags (`bash -lc 'cmd'`) still consumes the next +// argv entry as the payload. `-o`/`-O` take values, so either one earlier in +// the bundle consumes the rest of it and `-c` is not present. +function shellBundleRequestsCommand(flag: string): boolean { + if (!flag.startsWith('-') || flag.startsWith('--')) return false; + return flag.slice(1).includes('c'); +} + +/** How many argv entries the value-taking flags before `c` consume. */ +function shellBundleValueFlagsBeforeCommand(flag: string): number { + let consumed = 0; + for (const character of flag.slice(1)) { + if (character === 'c') return consumed; + if (character === 'o' || character === 'O') consumed++; + } + return consumed; +} + +const ENV_CHDIR_FLAGS = new Set(['-C', '--chdir']); +const ENV_VALUE_FLAGS = new Set(['-S', '--split-string', '-u', '--unset']); +const ENV_KNOWN_FLAG_ONLY = new Set([ + '-', + '-0', + '-i', + '-v', + '--null', + '--ignore-environment', + '--debug', +]); + +// The subset of the flag-only options that start the child from an empty +// environment. `-` is GNU env's shorthand for `-i`. Bundled forms (`-iv`) are +// not exact members and already fail closed as unrecognized options. +const ENV_CLEARS_ENVIRONMENT = new Set(['-', '-i', '--ignore-environment']); + +// Union of core shell-utils/shell.ts value-taking sudo options. +const SUDO_VALUE_FLAGS = new Set([ + '-C', + '-D', + '-T', + '-g', + '-h', + '-p', + '-r', + '-t', + '-u', + '--chdir', + '--close-from', + '--command-timeout', + '--group', + '--host', + '--prompt', + '--role', + '--type', + '--user', +]); +const SUDO_CHDIR_FLAGS = new Set(['-D', '--chdir']); +// `sudo -R ` runs the command under a different filesystem root, so +// no path the daemon resolves means what git will see. +const SUDO_CHROOT_FLAGS = new Set(['-R', '--chroot']); + +const TIMEOUT_VALUE_FLAGS = new Set(['-k', '-s', '--kill-after', '--signal']); + +// Programs that can point an existing in-boundary path at somewhere else. +// Running one earlier in the same command invalidates any containment the +// guard proves afterwards: `ln -s bait && git -C bait reset --hard` +// is checked while `bait` is still the original directory. +const PATH_RELINKING_PROGRAMS = new Set(['cp', 'ln', 'mv']); + +// Archive extractors do not name the paths they write: the archive decides. +// Everything under their extraction directory is therefore suspect, which is +// the directory itself rather than any operand. +const PATH_EXTRACTING_PROGRAMS = new Set(['cpio', 'rsync', 'tar', 'unzip']); + +// Programs whose own `-C` means something else entirely (`grep -C 5`, +// `tar -C dir`), so it must not read as a git relocation marker. +const PROGRAMS_WITH_OWN_C_FLAG = new Set([ + 'cmake', + 'curl', + 'cpio', + 'diff', + 'grep', + 'install', + 'make', + 'patch', + 'rsync', + 'tar', + 'unzip', +]); + +// Pinned to ToolNames.AGENT/WORKFLOW/CREATE_SUB_SESSION/SEND_MESSAGE in +// @qwen-code/qwen-code-core. The literals keep this module free of a core +// barrel import for this one set; daemon-git-worktree-guard.test.ts asserts +// the values match so a rename cannot silently desync this set. +const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ + 'agent', + 'workflow', + 'create_sub_session', + 'send_message', +]); + +const DYNAMIC_RELOCATION_DENIAL = + 'Daemon shell guard denied a mutating Git command with a dynamic repository location.'; +const UNPARSEABLE_COMMAND_DENIAL = + 'Daemon shell guard denied a shell command that could not be parsed before execution.'; +const UNRESOLVED_TARGET_DENIAL_PREFIX = + 'Daemon shell guard denied a mutating Git command with an unresolvable repository location: '; +const OUTSIDE_TARGET_DENIAL_PREFIX = + 'Daemon shell guard denied a mutating Git command outside the session working directory: '; +const UNDECIDABLE_PAYLOAD_DENIAL = + 'Daemon shell guard denied a shell command whose payload could not be resolved before execution.'; +const UNRECOGNIZED_PROGRAM_DENIAL = + 'Daemon shell guard denied a shell command that may run a relocated Git command through an unrecognized program.'; +const SHADOW_REMOVAL_DENIAL = + 'Daemon shell guard denied a shell command that removes a tracked shell definition in a way it cannot model.'; +const PROMPTLESS_PROVIDER_DENIAL = + 'Managed external tool guard cannot consult an external provider without an active prompt binding.'; + +const MAX_PAYLOAD_RECURSION_DEPTH = 3; + +interface TrustedDaemonToolGuardRequest + extends ExternalToolGuardPrepareRequest { + readonly effectiveCwd: string; +} + +const UNVERIFIABLE_SCOPE_DENIAL_PREFIX = + 'Daemon shell guard could not establish the execution directory of this call: '; + +interface GuardToken { + readonly text: string; + readonly dynamic: boolean; + // Operand of a redirection (`> out`, `<<< payload`). It is scanned for + // relocation markers — a here-string carries a whole command — but it is + // never argv, so payload joins (`eval …`, `env -S …`) must skip it. + readonly redirect?: boolean; + // A bare digit before a redirection: a file descriptor or an argv word, + // indistinguishable here. + readonly ambiguousFd?: boolean; +} + +interface GitEnvRelocation { + readonly target: string; + readonly kind: 'cwd' | 'git-dir' | 'work-tree'; +} + +interface PrefixState { + readonly relocations: GitEnvRelocation[]; + unresolved: boolean; + // `env -i` / `--ignore-environment` wipe the inherited environment, so a + // later shell child receives none of the parent's `export -f` functions. + clearsEnvironment?: boolean; + // Environment names removed by `env -u` / `--unset`. A bash `export -f foo` + // travels as a `BASH_FUNC_foo%%` entry, so unsetting it strips the function + // from the child even though the environment is otherwise intact. + unsetEnvKeys?: Set; +} + +// Bash exports a function `foo` as a `BASH_FUNC_foo%%` (4.3+) or +// `BASH_FUNC_foo()` (older) environment entry; an `env -u` of that entry drops +// the function from the child even though `-u` names an ordinary key. +function envUnsetRemovesFunction(name: string, state: PrefixState): boolean { + const keys = state.unsetEnvKeys; + return ( + keys !== undefined && + (keys.has(`BASH_FUNC_${name}%%`) || keys.has(`BASH_FUNC_${name}()`)) + ); +} + +type GuardDenial = { allowed: false; reason: string }; + +function sanitizeDenialPath(value: string, prefix: string): string { + // Mirror containsUnsafeExternalToolGuardControlCharacter: control + // characters would convert a clean denial into an invalid guard result. + const stripped = [...value] + .filter((character) => { + const code = character.charCodeAt(0); + return ( + code >= 0x20 && + !(code >= 0x7f && code <= 0x9f) && + code !== 0x2028 && + code !== 0x2029 + ); + }) + .join(''); + const budget = EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS - prefix.length; + if (stripped.length <= budget) return stripped; + return `${stripped.slice(0, Math.max(1, budget - 1))}…`; +} + +function denyDynamicRelocation(): GuardDenial { + return { allowed: false, reason: DYNAMIC_RELOCATION_DENIAL }; +} + +function denyTarget(prefix: string, target: string): GuardDenial { + return { + allowed: false, + reason: `${prefix}${sanitizeDenialPath(target, prefix)}`, + }; +} + +// `{a,b}` is expanded by the shell after this parse, so `git {-C,} +// reset --hard` reaches git as a relocation the token scan never saw. +const BRACE_EXPANSION_PATTERN = /\{[^{}]*,[^{}]*\}/; + +function isDynamicPathValue(token: GuardToken | undefined): boolean { + return ( + token === undefined || + token.dynamic || + token.text.includes('`') || + token.text.startsWith('~') || + BRACE_EXPANSION_PATTERN.test(token.text) + ); +} + +// `+=` appends to whatever the variable already holds, so the resulting value +// cannot be resolved from this token alone; it is reported like any other +// assignment and `recordEnvAssignment` marks it unresolved. +function leadingEnvAssignmentKey(token: string): string | null { + const match = /^([A-Za-z_][A-Za-z0-9_]*)\+?=/.exec(token); + return match ? match[1]! : null; +} + +function isAppendAssignment(token: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*\+=/.test(token); +} + +function executableBaseName(token: GuardToken): string { + const base = token.text.split(/[\\/]/).pop() ?? token.text; + return base.toLowerCase().replace(/\.exe$/i, ''); +} + +// `(` opens a subshell; `<(`/`>(` open a process substitution. All three are +// closed by a `)` that arrives on its own, so all three must raise the depth +// or that `)` pops a scope that was never opened. +const SUBSHELL_OPENING_OPERATORS: ReadonlySet = new Set([ + '(', + '<(', + '>(', +]); + +const REDIRECT_OPERATORS = new Set([ + '<', + '>', + '>>', + '<<', + '<<<', + '<>', + '>&', + '<&', + '>|', + '&>', + '&>>', +]); + +interface GuardRun { + readonly tokens: GuardToken[]; + // `( … )` nesting level. A subshell's `cd` does not outlive its parentheses. + readonly depth: number; +} + +interface TokenizedSegment { + readonly runs: GuardRun[]; + // `splitCommands` cuts on `&&`/`;` without regard for parentheses, so the + // paren nesting has to be carried from one segment to the next. + readonly endDepth: number; +} + +function tokenizeSegment( + segment: string, + startDepth: number, +): TokenizedSegment | null { + let parsed: ReturnType; + try { + parsed = parse(segment, (key) => `$${key}`); + } catch { + return null; + } + let depth = startDepth; + const runs: GuardRun[] = [{ tokens: [], depth }]; + let redirectOperand = false; + for (let index = 0; index < parsed.length; index++) { + const token = parsed[index]; + if (typeof token === 'string') { + const isRedirectOperand = redirectOperand; + redirectOperand = false; + // A `$(...)` substitution arrives as a string ending in `$` followed + // by an `(` operator. Consume the whole body as one opaque dynamic + // token so the assignment/flag it belongs to keeps its place instead + // of being severed into a separate run. + if (token.endsWith('$')) { + const next = parsed[index + 1]; + if ( + next !== null && + typeof next === 'object' && + 'op' in next && + next.op === '(' + ) { + let depth = 0; + index++; + for (; index < parsed.length; index++) { + const inner = parsed[index]; + if (inner !== null && typeof inner === 'object' && 'op' in inner) { + if (inner.op === '(') depth++; + else if (inner.op === ')') { + depth--; + if (depth === 0) break; + } + } + } + runs.at(-1)!.tokens.push({ + text: token, + dynamic: true, + ...(isRedirectOperand ? { redirect: true } : {}), + }); + continue; + } + } + runs.at(-1)!.tokens.push({ + text: token, + dynamic: token.includes('$') || token.includes('`'), + ...(isRedirectOperand ? { redirect: true } : {}), + }); + continue; + } + if (token === null || typeof token !== 'object') return null; + if ('comment' in token) break; + if (!('op' in token)) return null; + const op = token.op; + if (op === 'glob') { + // Glob expansion is resolved by the shell at runtime; the daemon + // cannot evaluate it statically. + const pattern = + 'pattern' in token && typeof token.pattern === 'string' + ? token.pattern + : ''; + runs.at(-1)!.tokens.push({ text: pattern, dynamic: true }); + continue; + } + if (SUBSHELL_OPENING_OPERATORS.has(op)) { + depth++; + runs.push({ tokens: [], depth }); + continue; + } + if (op === ')') { + depth = Math.max(0, depth - 1); + runs.push({ tokens: [], depth }); + continue; + } + if (REDIRECT_OPERATORS.has(op)) { + // The operand stays in the run — a here-string (`sh <<< 'git -C … reset + // --hard'`) carries an executable payload — but it is flagged so no + // payload join mistakes it for argv. An `N>` file descriptor prefix is + // part of the redirection too, never a word of the command. + const tokens = runs.at(-1)!.tokens; + const previous = tokens.at(-1); + if (previous && !previous.redirect && /^\d+$/.test(previous.text)) { + // `2>file` makes it a file descriptor, `git -C 2 > file` makes it a + // real argv word, and the token stream cannot tell them apart — so + // it is marked ambiguous and the analysis fails closed. + tokens[tokens.length - 1] = { ...previous, ambiguousFd: true }; + } + redirectOperand = true; + continue; + } + runs.push({ tokens: [], depth }); + } + return { runs: runs.filter((run) => run.tokens.length > 0), endDepth: depth }; +} + +/** + * Substitute `$NAME`/`${NAME}` from assignments made earlier in this same + * command. `X=git; Y='-C reset --hard'; $X $Y` is a relocation the + * literal token scan cannot see, but the values are right there. + */ +function expandShellLocals( + token: GuardToken, + shellLocals: ReadonlyMap, +): GuardToken { + if (!token.dynamic || shellLocals.size === 0) return token; + let resolved = true; + const text = token.text.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, + (match, name: string) => { + const local = shellLocals.get(name); + if (local === undefined || local.dynamic) { + resolved = false; + return match; + } + return local.text.slice(local.text.indexOf('=') + 1); + }, + ); + if (text === token.text) return token; + return { text, dynamic: !resolved }; +} + +// Rebuilding a payload from tokens loses the quoting that made a value one +// argv word, so a path with a space would re-parse as several words and the +// `-C` value would silently shrink. Re-quote anything that would split. +function quoteForRejoin(text: string): string { + if (text.length === 0) return "''"; + if (!/[\s"'`$\\|&;<>()*?[\]{}!#~]/.test(text)) return text; + return `'${text.replaceAll("'", `'\\''`)}'`; +} + +// `eval` concatenates its arguments and re-parses the result as shell text, +// so its payload must be joined verbatim. +function joinTokenTexts(tokens: GuardToken[]): string { + return tokens + .filter((token) => !token.redirect) + .map((token) => token.text) + .join(' '); +} + +// Rebuilding a command line out of separate argv words is the opposite case: +// a value that was one word only because it was quoted has to stay one word, +// or a path with a space re-parses as several and a `-C` value silently +// shrinks. +function joinArgvTexts(tokens: GuardToken[]): string { + return tokens + .filter((token) => !token.redirect) + .map((token) => quoteForRejoin(token.text)) + .join(' '); +} + +function hasGitRelocationMarker(tokens: GuardToken[]): boolean { + return tokens.some((token) => { + if (token.text === '-C' || token.text.startsWith('-C')) return true; + if (/^--(?:git-dir|work-tree)(?:=|$)/.test(token.text)) return true; + const key = leadingEnvAssignmentKey(token.text); + return ( + key !== null && + (GIT_DIR_ENV_KEYS.has(key) || + GIT_WORK_TREE_ENV_KEYS.has(key) || + // `PATH=`/`GIT_EXEC_PATH=` choose which git binary runs — a relocation + // the direct path already denies, so the wrapper backstop must too. + GIT_PROGRAM_ENV_KEYS.has(key)) + ); + }); +} + +// A static token scan cannot prove what an unrecognized program executes. +// When the run still references git and carries a relocation marker — +// possibly inside a quoted payload such as `su -c 'git -C ...'` — fail +// closed instead of letting the program word short-circuit the analysis. +// Case-insensitive because `executableBaseName` lowercases too, so on a +// case-insensitive filesystem `nice GIT …` runs the same binary. +const GIT_WORD_PATTERN = /\bgit\b/i; +// A `cd`/`pushd` inside such a payload relocates the git that follows it just +// as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). +const TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN = + /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|[\s;&|(){}])(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; +const TEXT_RELOCATION_MARKER_PATTERN = + /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|[\s;&|(){}])(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; + +// Assignments that decide WHICH git binary the run executes. The guard +// classifies the program word `git` and then reasons about paths; if the +// binary itself is chosen by the command, that reasoning proves nothing. +const GIT_PROGRAM_ENV_KEYS = new Set(['GIT_EXEC_PATH', 'PATH']); + +function recordEnvAssignment(token: GuardToken, state: PrefixState): void { + const key = leadingEnvAssignmentKey(token.text); + if (key === null) return; + if ( + GIT_PROGRAM_ENV_KEYS.has(key) || + GIT_UNRESOLVABLE_ENV_KEYS.has(key) || + GIT_NUMBERED_CONFIG_ENV_PATTERN.test(key) + ) { + state.unresolved = true; + return; + } + if (!GIT_DIR_ENV_KEYS.has(key) && !GIT_WORK_TREE_ENV_KEYS.has(key)) return; + const value = token.text.slice(token.text.indexOf('=') + 1); + if ( + token.dynamic || + isAppendAssignment(token.text) || + isDynamicPathValue({ text: value, dynamic: false }) + ) { + state.unresolved = true; + return; + } + state.relocations.push({ + target: value, + // GIT_COMMON_DIR and GIT_INDEX_FILE cannot be mapped onto a repository + // root the way `--git-dir` targets are; checking the concrete path they + // name is the conservative approximation. + kind: GIT_WORK_TREE_ENV_KEYS.has(key) ? 'work-tree' : 'git-dir', + }); +} + +function attachedChdirValue( + flag: string, + set: ReadonlySet, +): string | undefined { + for (const candidate of set) { + if (candidate.startsWith('--') && flag.startsWith(`${candidate}=`)) { + return flag.slice(candidate.length + 1); + } + if ( + candidate.length === 2 && + flag.startsWith(candidate) && + flag.length > candidate.length + ) { + return flag.slice(candidate.length); + } + } + return undefined; +} + +function recordChdirValue( + value: GuardToken | undefined, + state: PrefixState, +): void { + if (isDynamicPathValue(value)) { + state.unresolved = true; + return; + } + state.relocations.push({ target: value!.text, kind: 'cwd' }); +} + +interface WrapperScan { + next: number; + payload?: string; + undecidable?: boolean; +} + +function consumeEnvWrapper( + run: GuardToken[], + start: number, + state: PrefixState, +): WrapperScan { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.dynamic) { + state.unresolved = true; + index++; + continue; + } + if (token.text === '--') { + index++; + break; + } + if (ENV_KNOWN_FLAG_ONLY.has(token.text)) { + if (ENV_CLEARS_ENVIRONMENT.has(token.text)) { + state.clearsEnvironment = true; + } + index++; + continue; + } + if (ENV_CHDIR_FLAGS.has(token.text)) { + recordChdirValue(run[index + 1], state); + index += 2; + continue; + } + const attached = attachedChdirValue(token.text, ENV_CHDIR_FLAGS); + if (attached !== undefined) { + recordChdirValue({ text: attached, dynamic: false }, state); + index++; + continue; + } + if (token.text === '-S' || token.text === '--split-string') { + const payloadToken = run[index + 1]; + if (payloadToken === undefined) return { next: run.length }; + // Mirrors the `-c` payload rule: a payload the daemon cannot read is + // undecidable, not absent. + if (payloadToken.dynamic) return { next: run.length, undecidable: true }; + const rest = joinArgvTexts(run.slice(index + 2)); + return { + next: run.length, + payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, + }; + } + // `env -S'cmd'` / `env -iS'cmd'`: the payload is fused into the flag + // token after the `S`, exactly as `sh -c'cmd'` fuses its own. + if (/^-[A-Za-z]*S/.test(token.text) && !token.text.startsWith('--')) { + const fused = token.text.slice(token.text.indexOf('S') + 1); + if (fused.length > 0) { + const rest = joinArgvTexts(run.slice(index + 1)); + return { + next: run.length, + payload: rest ? `${fused} ${rest}` : fused, + }; + } + // `env -iS 'cmd'`: the bundle ends at `S`, so the payload is the next + // argv entry — the same rule `sh -lc 'cmd'` follows. + const payloadToken = run[index + 1]; + if (payloadToken === undefined) return { next: run.length }; + if (payloadToken.dynamic) return { next: run.length, undecidable: true }; + const rest = joinArgvTexts(run.slice(index + 2)); + return { + next: run.length, + payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, + }; + } + if (ENV_VALUE_FLAGS.has(token.text)) { + // Only `-u`/`--unset` reach here (`-S`/`--split-string` returned above); + // remember the removed key so a stripped `BASH_FUNC_*` is honoured. + const removed = run[index + 1]; + if (removed !== undefined && !removed.dynamic) { + (state.unsetEnvKeys ??= new Set()).add(removed.text); + } + index += 2; + continue; + } + // `env -uNAME` / `--unset=NAME` carry their value in the same token, so + // they consume nothing further and must not look unrecognized. + if ( + /^-u./.test(token.text) || + token.text.startsWith('--unset=') || + token.text.startsWith('--split-string=') + ) { + if (token.text.startsWith('--split-string=')) { + const fused = token.text.slice('--split-string='.length); + const rest = joinArgvTexts(run.slice(index + 1)); + return { next: run.length, payload: rest ? `${fused} ${rest}` : fused }; + } + const removedName = token.text.startsWith('--unset=') + ? token.text.slice('--unset='.length) + : token.text.slice(2); + (state.unsetEnvKeys ??= new Set()).add(removedName); + index++; + continue; + } + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + index++; + continue; + } + if (token.text.startsWith('-')) { + // Unrecognized env option before the program: fail closed rather than + // guess whether it consumes the next token. + state.unresolved = true; + index++; + continue; + } + break; + } + return { next: index }; +} + +function consumeSudoWrapper( + run: GuardToken[], + start: number, + state: PrefixState, +): WrapperScan { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.dynamic) { + state.unresolved = true; + index++; + continue; + } + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + index++; + continue; + } + if (!token.text.startsWith('-')) break; + if (SUDO_CHDIR_FLAGS.has(token.text)) { + recordChdirValue(run[index + 1], state); + index += 2; + continue; + } + if ( + SUDO_CHROOT_FLAGS.has(token.text) || + token.text.startsWith('--chroot=') || + /^-R./.test(token.text) + ) { + state.unresolved = true; + index += SUDO_CHROOT_FLAGS.has(token.text) ? 2 : 1; + continue; + } + const attached = attachedChdirValue(token.text, SUDO_CHDIR_FLAGS); + if (attached !== undefined) { + recordChdirValue({ text: attached, dynamic: false }, state); + index++; + continue; + } + if (SUDO_VALUE_FLAGS.has(token.text)) { + index += 2; + continue; + } + index++; + } + return { next: index }; +} + +function consumeTimeoutWrapper(run: GuardToken[], start: number): number { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (!token.text.startsWith('-')) break; + if (TIMEOUT_VALUE_FLAGS.has(token.text) && !token.text.includes('=')) { + index += 2; + continue; + } + index++; + } + // The duration operand. + if (index < run.length) index++; + return index; +} + +type ShellWrapperScan = + | { kind: 'none' } + | { kind: 'static'; payload: string } + | { kind: 'dynamic' }; + +// The next real argv entry: a redirection between the flag and its payload +// (`sh -c > /dev/null 'cmd'`) is not the payload. +function nextArgvIndex(run: GuardToken[], from: number): number { + let index = from; + while ( + index < run.length && + (run[index]!.redirect || run[index]!.ambiguousFd) + ) { + index++; + } + return index; +} + +function consumeShellWrapper( + run: GuardToken[], + start: number, +): ShellWrapperScan { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.text === '-c') { + const payloadToken = run[nextArgvIndex(run, index + 1)]; + if (payloadToken === undefined) { + // `sh -c` with no payload executes nothing. + return { kind: 'static', payload: '' }; + } + if (payloadToken.dynamic) return { kind: 'dynamic' }; + return { kind: 'static', payload: payloadToken.text }; + } + if (token.dynamic) { + // `bash -c$CMD`, `bash $A "$P"`: any unreadable word in a shell's argv + // can be the `-c` that carries the command, so the wrapper as a whole + // is undecidable rather than absent. + return { kind: 'dynamic' }; + } + if (shellBundleRequestsCommand(token.text)) { + const remainder = token.text.slice(token.text.indexOf('c') + 1); + // A remainder of nothing but letters is more short options (`-cx`, + // `-co`), which POSIX shells parse as flags and then take the payload + // from a later argv entry — `-o`/`-O` among them consumes one first. + // Anything else is a payload fused into the token (`bash -c'cmd'`); a + // pure-letter remainder cannot hide a relocation either way. + if (remainder.length > 0 && !/^[A-Za-z]+$/.test(remainder)) { + return { kind: 'static', payload: remainder }; + } + let payloadIndex = nextArgvIndex(run, index + 1); + // `-o`/`-O` on either side of the `c` each consume one argv entry + // before the command string. + let toSkip = + shellBundleValueFlagsBeforeCommand(token.text) + + (remainder.match(/[oO]/g)?.length ?? 0); + while (toSkip-- > 0) { + payloadIndex = nextArgvIndex(run, payloadIndex + 1); + } + const payloadToken = run[payloadIndex]; + if (payloadToken === undefined) { + return { kind: 'static', payload: '' }; + } + if (payloadToken.dynamic) return { kind: 'dynamic' }; + return { kind: 'static', payload: payloadToken.text }; + } + if (token.text.startsWith('+')) { + index++; + continue; + } + if (!token.text.startsWith('-')) return { kind: 'none' }; + if (token.text === '--') return { kind: 'none' }; + index += SHELL_WRAPPER_VALUE_FLAGS.has(token.text) ? 2 : 1; + } + return { kind: 'none' }; +} + +type RunAnalysis = + | { kind: 'git'; tokens: GuardToken[]; state: PrefixState } + | { + kind: 'payload'; + payload: string; + state: PrefixState; + propagatesCwd: boolean; + // Only bash imports functions marked with `export -f`; dash/sh/zsh/ksh + // resolve the external program instead. + importsExportedFunctions?: boolean; + } + | { + kind: 'cd'; + variant: 'cd' | 'popd' | 'pushd'; + target?: GuardToken; + physical?: boolean; + } + | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } + | { kind: 'export'; state: PrefixState; operands: GuardToken[] } + | { kind: 'all-export'; state: PrefixState } + | { kind: 'all-export-off'; state: PrefixState } + | { kind: 'undecidable' } + | { kind: 'other'; state: PrefixState; assignmentsOnly: boolean }; + +// Shell keywords that can lead a split segment without changing what +// executes: `if true; then git ...` arrives as a `then git ...` segment +// because the split happens on `;`/`&&`. Skipping them keeps the real +// program under analysis; bare terminators (`fi`, `done`, ...) leave an +// empty run that classifies as safe. +const LEADING_SHELL_KEYWORDS = new Set([ + '{', + '}', + '!', + 'if', + 'then', + 'else', + 'elif', + 'fi', + 'for', + 'do', + 'done', + 'while', + 'until', + 'in', + 'case', + 'esac', + 'time', + 'coproc', +]); + +// Builtins that declare variables. `export`/`declare -x`/`typeset -x` put the +// assignment in the environment of every later command in this shell, so a +// GIT_* relocation declared here outlives its own run. `readonly`/`local` are +// treated the same way: over-approximating an assignment as exported can only +// deny, never allow. +const EXPORT_BUILTINS = new Set([ + 'declare', + 'export', + 'local', + 'readonly', + 'typeset', +]); + +// `cd`/`pushd` options that precede the directory operand. Consuming one as +// the target would resolve containment against `/-P` instead of the +// directory the shell actually enters. +const CHDIR_OPTION_PATTERN = /^-[LPe@qs]+$/; + +function findChdirTarget( + run: GuardToken[], + start: number, + variant: 'cd' | 'popd' | 'pushd', +): GuardToken | undefined { + let index = start; + while (index < run.length) { + const token = run[index]!; + if (token.text === '--') return run[index + 1]; + if (variant === 'cd' && CHDIR_OPTION_PATTERN.test(token.text)) { + index++; + continue; + } + // `cd -` (previous directory), `pushd +N`/`-N` (stack rotation) and any + // unrecognized option land somewhere unresolvable: report no target so + // the caller drops the tracked cwd. + if (/^[-+]/.test(token.text)) return undefined; + return token; + } + return undefined; +} + +// `set -a` / `set -o allexport` puts every later assignment in the +// environment, so plain `GIT_DIR=…` runs stop being shell-local. +function requestsAllExport(run: GuardToken[], start: number): boolean { + for (let index = start; index < run.length; index++) { + const token = run[index]!; + const text = token.text; + // `set -o $OPT` can request allexport without naming it. + if (token.dynamic) return true; + if (text === '-o' || text === '--') { + if (run[index + 1]?.text === 'allexport') return true; + continue; + } + if (text === 'allexport' || text === '--allexport') return true; + if (/^-[a-zA-Z]*a/.test(text)) return true; + } + return false; +} + +// `set +a` / `set +o allexport` turn it back off. +function disablesAllExport(run: GuardToken[], start: number): boolean { + for (let index = start; index < run.length; index++) { + const text = run[index]!.text; + if (text === '+o' && run[index + 1]?.text === 'allexport') return true; + if (/^\+[a-zA-Z]*a/.test(text)) return true; + } + return false; +} + +/** + * `alias name='body'` and `name() { body; }` both defer a command: the body + * runs where the *later* bare word appears, not where it was written. + */ +/** + * The word that actually names the program, i.e. the first token that is not + * a leading assignment or a shell keyword. `X=1 g` runs `g`. + */ +function readProgramWord(run: GuardToken[]): string | undefined { + for (const token of run) { + if (token.redirect || token.ambiguousFd) continue; + if (leadingEnvAssignmentKey(token.text) !== null) continue; + if (LEADING_SHELL_KEYWORDS.has(token.text)) continue; + return token.text; + } + return undefined; +} + +// The tokens from the program word onward — past leading keywords, +// assignments and redirect/fd operands — so a definition or a call is +// recognised even behind `if …; then`, `X=1`, or `2>/dev/null`. +function runFromProgramWord(run: GuardToken[]): GuardToken[] { + let index = 0; + while (index < run.length) { + const token = run[index]!; + if ( + token.redirect || + token.ambiguousFd || + leadingEnvAssignmentKey(token.text) !== null || + LEADING_SHELL_KEYWORDS.has(token.text) + ) { + index++; + continue; + } + break; + } + return run.slice(index); +} + +/** `f()` / `f ()` — the header of a function definition, if this is one. */ +function readFunctionName(run: GuardToken[]): string | undefined { + const body = runFromProgramWord(run); + if (body.length === 0) return undefined; + const first = body[0]!.text; + if (first.endsWith('()') && first.length > 2) return first.slice(0, -2); + if (body[1]?.text === '()') return first; + return undefined; +} + +// R6-5: a single `alias a=1 b=2` statement defines every pair, not just the +// first. Returns all of them. +function readAliasDefinitions( + run: GuardToken[], +): Array<{ name: string; body: string }> { + const body = runFromProgramWord(run); + if (body.length === 0 || executableBaseName(body[0]!) !== 'alias') return []; + const definitions: Array<{ name: string; body: string }> = []; + for (const token of body.slice(1)) { + const separator = token.text.indexOf('='); + if (separator <= 0) continue; + definitions.push({ + name: token.text.slice(0, separator), + body: token.text.slice(separator + 1), + }); + } + return definitions; +} + +function readDefinition( + run: GuardToken[], +): { name: string; body: string } | undefined { + const body = runFromProgramWord(run); + if (body.length === 0) return undefined; + // shell-quote yields `f()` (or `f` `()`), then the braced body tokens. + const first = body[0]!.text; + const name = first.endsWith('()') + ? first.slice(0, -2) + : body[1]?.text === '()' + ? first + : undefined; + if (!name) return undefined; + const bodyTokens = body + .slice(first.endsWith('()') ? 1 : 2) + .filter((token) => token.text !== '{' && token.text !== '}'); + if (bodyTokens.length === 0) return undefined; + return { name, body: joinArgvTexts(bodyTokens) }; +} + +function analyzeRun(run: GuardToken[]): RunAnalysis { + const state: PrefixState = { relocations: [], unresolved: false }; + let index = 0; + let assignments = 0; + while (index < run.length && LEADING_SHELL_KEYWORDS.has(run[index]!.text)) { + index++; + } + while (index < run.length) { + const token = run[index]!; + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + assignments++; + index++; + continue; + } + if (token.dynamic) { + return { kind: 'dynamic-program', rest: run.slice(index), state }; + } + const program = executableBaseName(token); + // `command git …` and `builtin cd …` run the following word with the + // function/alias lookup suppressed; neither changes what executes. + if (program === 'command' || program === 'builtin') { + index++; + while (index < run.length && run[index]!.text.startsWith('-')) index++; + continue; + } + if (EXPORT_BUILTINS.has(program)) { + const operands = run.slice(index + 1); + for (const operand of operands) recordEnvAssignment(operand, state); + return { kind: 'export', state, operands }; + } + if (program === 'set') { + if (requestsAllExport(run, index + 1)) { + return { kind: 'all-export', state }; + } + if (disablesAllExport(run, index + 1)) { + return { kind: 'all-export-off', state }; + } + } + if (program === 'env') { + const scan = consumeEnvWrapper(run, index, state); + if (scan.undecidable) return { kind: 'undecidable' }; + if (scan.payload !== undefined) { + return { + kind: 'payload', + payload: scan.payload, + state, + propagatesCwd: false, + }; + } + index = scan.next; + continue; + } + if (program === 'sudo') { + index = consumeSudoWrapper(run, index, state).next; + continue; + } + if (program === 'timeout') { + index = consumeTimeoutWrapper(run, index); + continue; + } + if (program === 'eval') { + const payloadTokens = run.slice(index + 1); + if ( + payloadTokens.some( + (payloadToken) => payloadToken.dynamic || payloadToken.ambiguousFd, + ) + ) { + return { kind: 'undecidable' }; + } + return { + kind: 'payload', + payload: joinTokenTexts(payloadTokens), + state, + // `eval` runs in the current shell, so a `cd` inside the payload + // relocates subsequent commands in this run's scope. + propagatesCwd: true, + }; + } + if (SHELL_WRAPPER_PROGRAMS.has(program)) { + const scan = consumeShellWrapper(run, index); + if (scan.kind === 'none') { + return { kind: 'other', state, assignmentsOnly: false }; + } + if (scan.kind === 'dynamic') return { kind: 'undecidable' }; + return { + kind: 'payload', + payload: scan.payload, + state, + propagatesCwd: false, + // Only bash imports `export -f` functions, and only when it inherits + // the environment carrying them — `env -i bash -c` wipes them first. + importsExportedFunctions: + program === 'bash' && !state.clearsEnvironment, + }; + } + if (program === 'nohup' || program === 'exec') { + index++; + continue; + } + if (program === 'git') { + return { kind: 'git', tokens: run.slice(index), state }; + } + if (program === 'cd' || program === 'pushd' || program === 'popd') { + return { + kind: 'cd', + variant: program, + target: findChdirTarget(run, index + 1, program), + // `cd -P` resolves each component through its symlinks before + // applying `..`, which a lexical resolve cannot reproduce. + physical: run + .slice(index + 1) + .some((token) => /^-[A-Za-z]*P/.test(token.text)), + }; + } + return { kind: 'other', state, assignmentsOnly: false }; + } + return { kind: 'other', state, assignmentsOnly: assignments > 0 }; +} + +interface GitInvocation { + readonly cwdTargets: GuardToken[]; + readonly gitDirTargets: GuardToken[]; + readonly workTreeTargets: GuardToken[]; + readonly subcommand?: string; + readonly unresolved: boolean; + readonly dangerousConfig: boolean; + readonly hasDisqualifyingFlag: boolean; +} + +function readGitInvocation(tokens: GuardToken[]): GitInvocation { + const cwdTargets: GuardToken[] = []; + const gitDirTargets: GuardToken[] = []; + const workTreeTargets: GuardToken[] = []; + let subcommand: string | undefined; + let unresolved = false; + let dangerousConfig = false; + + const recordConfigAssignment = (value: string): void => { + const separator = value.indexOf('='); + const key = ( + separator >= 0 ? value.slice(0, separator) : value + ).toLowerCase(); + const assignment = separator >= 0 ? value.slice(separator + 1) : ''; + if ( + GIT_COMMAND_CONFIG_KEY_PATTERNS.some((pattern) => pattern.test(key)) || + assignment.trimStart().startsWith('!') + ) { + dangerousConfig = true; + } + }; + const pushRelocation = ( + kind: 'cwd' | 'git-dir' | 'work-tree', + value: GuardToken | undefined, + emptyIsNoop: boolean, + ): boolean => { + if (value === undefined) { + unresolved = true; + return false; + } + if (value.text === '' && !value.dynamic) { + if (emptyIsNoop) return true; + unresolved = true; + return false; + } + if (isDynamicPathValue(value)) { + unresolved = true; + return true; + } + if (kind === 'cwd') cwdTargets.push(value); + else if (kind === 'git-dir') gitDirTargets.push(value); + else workTreeTargets.push(value); + return true; + }; + + let index = 1; + while (index < tokens.length) { + const token = tokens[index]!; + if (token.redirect || token.ambiguousFd) { + // A redirection operand among the args (`git 2>/dev/null -C

…`) is + // not part of argv and must not terminate option parsing. + index++; + continue; + } + if (token.dynamic || BRACE_EXPANSION_PATTERN.test(token.text)) { + unresolved = true; + index++; + continue; + } + if (token.text === '-C') { + // Git treats an empty `-C` value as a no-op chdir. + if (!pushRelocation('cwd', tokens[index + 1], true)) break; + index += 2; + continue; + } + if (token.text === '--git-dir' || token.text === '--work-tree') { + const kind = token.text === '--git-dir' ? 'git-dir' : 'work-tree'; + if (!pushRelocation(kind, tokens[index + 1], false)) break; + index += 2; + continue; + } + if (token.text.length > 2 && token.text.startsWith('-C')) { + if ( + !pushRelocation( + 'cwd', + { text: token.text.slice(2), dynamic: false }, + false, + ) + ) { + break; + } + index++; + continue; + } + if ( + token.text.startsWith('--git-dir=') || + token.text.startsWith('--work-tree=') + ) { + const kind = token.text.startsWith('--git-dir=') + ? 'git-dir' + : 'work-tree'; + const value = token.text.slice(token.text.indexOf('=') + 1); + if (!pushRelocation(kind, { text: value, dynamic: false }, false)) { + break; + } + index++; + continue; + } + if (token.text === '-c' || token.text === '--config-env') { + const value = tokens[index + 1]; + if (value === undefined) break; + if (value.dynamic) dangerousConfig = true; + else recordConfigAssignment(value.text); + index += 2; + continue; + } + if (token.text.startsWith('--config-env=')) { + recordConfigAssignment(token.text.slice('--config-env='.length)); + index++; + continue; + } + if ( + token.text.length > 2 && + token.text.startsWith('-c') && + !token.text.startsWith('--') + ) { + recordConfigAssignment(token.text.slice(2)); + index++; + continue; + } + if (GIT_GLOBAL_OPTIONS_WITH_VALUES.has(token.text)) { + if (tokens[index + 1] === undefined) break; + index += 2; + continue; + } + if (token.text.startsWith('-')) { + index++; + continue; + } + subcommand = token.text; + break; + } + + const hasDisqualifyingFlag = tokens.some((token) => { + const separator = token.text.indexOf('='); + const flag = separator >= 0 ? token.text.slice(0, separator) : token.text; + return RELOCATED_READ_ONLY_DISQUALIFYING_FLAGS.has(flag); + }); + return { + cwdTargets, + gitDirTargets, + workTreeTargets, + subcommand, + unresolved, + dangerousConfig, + hasDisqualifyingFlag, + }; +} + +/** + * Resolve a `--git-dir`/`GIT_DIR` target to the repository git operates on, + * following git's own indirections: a `.git` gitfile redirect (`gitdir:` + * line) and per-worktree administrative directories (their `gitdir` file + * points at the linked worktree checkout). Canonicalization happens BEFORE + * any basename handling so a symlink named `.git` resolves to its real + * target. Throws when an indirection cannot be resolved. + */ +async function resolveGitDirRepository( + canonicalGitDir: string, +): Promise { + let current = canonicalGitDir; + for (let depth = 0; depth < 3; depth++) { + const stats = await stat(current); + if (stats.isFile()) { + const [firstLine] = (await readFile(current, 'utf8')).split(/\r?\n/); + const match = /^gitdir:\s*(.+)$/.exec(firstLine ?? ''); + if (!match) throw new Error('unrecognized gitfile'); + current = path.resolve(path.dirname(current), match[1]!.trim()); + continue; + } + if (path.basename(current) === '.git') { + return path.dirname(current); + } + if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(current)) { + const worktreeGitPointer = ( + await readFile(path.join(current, 'gitdir'), 'utf8') + ).trim(); + if (!worktreeGitPointer) throw new Error('empty worktree gitdir file'); + return path.dirname(path.resolve(current, worktreeGitPointer)); + } + return current; + } + throw new Error('gitdir indirection too deep'); +} + +/** + * Resolve a directory change the way `chdir(2)` does — following each + * component's symlinks before applying the next one. `git -C` and `cd -P` use + * it, so `-C /..` lands in the parent of the symlink's real target, + * while a lexical `path.resolve` would collapse it back to the starting + * directory. Bash's default `cd` is logical and keeps the lexical behavior. + */ +async function resolvePhysicalPath( + base: string, + target: string, +): Promise { + let current = path.isAbsolute(target) ? path.parse(target).root : base; + const separators = path.sep === '\\' ? /[\\/]+/ : /\/+/; + for (const segment of target.split(separators)) { + if (segment === '' || segment === '.') continue; + if (segment === '..') { + current = path.dirname(await realpathNearestExistingAsync(current)); + continue; + } + current = await realpathNearestExistingAsync(path.join(current, segment)); + } + return current; +} + +/** + * Git discovers its repository by walking up from the working directory, so a + * directory that is itself inside the boundary can still hand git an outside + * repository through a `.git` gitfile (`gitdir: /.git`). Resolve the + * first `.git` between the target and the boundary the same way `--git-dir` + * targets are resolved — which keeps a linked worktree working, because its + * own gitfile resolves back to that worktree's checkout. Returns undefined + * when nothing is discovered inside the boundary; throws when an indirection + * cannot be read. + */ +async function resolveDiscoveredRepository( + startDirectory: string, + boundary: string, +): Promise { + let current = startDirectory; + for (let depth = 0; depth < 64; depth++) { + const candidate = path.join(current, '.git'); + let exists = true; + try { + await stat(candidate); + } catch { + exists = false; + } + if (exists) { + return resolveGitDirRepository( + await realpathNearestExistingAsync(candidate), + ); + } + if (current === boundary) return undefined; + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } + return undefined; +} + +interface GuardEvaluationContext { + readonly canonicalEffectiveCwd: string; + readonly ambientRelocations: readonly GitEnvRelocation[]; + readonly ambientUnresolved: boolean; +} + +async function evaluateGitInvocation( + invocation: GitInvocation, + state: PrefixState, + basisCwd: string | undefined, + entryCwd: string | undefined, + context: GuardEvaluationContext, +): Promise { + // Command-executing `-c` config and unresolvable relocations are checked + // BEFORE the read-only allowance: `git status` still runs the target + // repository's core.fsmonitor, so a read-only subcommand does not make an + // undecidable invocation safe. + if ( + invocation.unresolved || + invocation.dangerousConfig || + state.unresolved || + context.ambientUnresolved + ) { + return denyDynamicRelocation(); + } + if ( + RELOCATED_READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') && + !invocation.hasDisqualifyingFlag + ) { + return undefined; + } + + const cwdRelocations: GitEnvRelocation[] = []; + const repositoryRelocations: GitEnvRelocation[] = []; + for (const relocation of [ + ...context.ambientRelocations, + ...state.relocations, + ]) { + if (relocation.kind === 'cwd') cwdRelocations.push(relocation); + else repositoryRelocations.push(relocation); + } + for (const target of invocation.cwdTargets) { + cwdRelocations.push({ target: target.text, kind: 'cwd' }); + } + for (const target of invocation.gitDirTargets) { + repositoryRelocations.push({ target: target.text, kind: 'git-dir' }); + } + for (const target of invocation.workTreeTargets) { + repositoryRelocations.push({ target: target.text, kind: 'work-tree' }); + } + + // Ambient relocations recorded here are git-level relocations from an + // enclosing wrapper (e.g. `GIT_DIR=… sh -c '…'`); they make the payload + // invocation relocated even when the payload itself carries no flags. + const relocated = + basisCwd === undefined || + basisCwd !== entryCwd || + cwdRelocations.length > 0 || + repositoryRelocations.length > 0; + if (!relocated) { + // Even with no relocation git still discovers its repository by walking + // up from here, and a planted `.git` gitfile can point that walk outside. + // A session bound to a subdirectory of a repository is unaffected: its + // `.git` lives above the boundary and the walk stops at the boundary. + return basisCwd === undefined + ? undefined + : denyOutsideDiscoveredRepository(basisCwd, context); + } + + // `-C`, `env -C` and `sudo -D` all reach the kernel as a chdir, so each + // component resolves through its symlinks before the next one applies. + let gitCwd = basisCwd; + for (const relocation of cwdRelocations) { + if (gitCwd === undefined && !path.isAbsolute(relocation.target)) break; + gitCwd = await resolvePhysicalPath(gitCwd ?? '', relocation.target); + } + if (gitCwd === undefined) { + return denyDynamicRelocation(); + } + + // Git applies `-C` during option parsing and resolves relative + // `--git-dir`/`--work-tree` against the post-`-C` cwd, so every relative + // target resolves against the final cwd regardless of argv order. + const checkedTargets: Array<{ + target: string; + kind: 'cwd' | 'git-dir' | 'work-tree'; + }> = []; + for (const relocation of repositoryRelocations) { + checkedTargets.push({ + target: path.isAbsolute(relocation.target) + ? relocation.target + : path.resolve(gitCwd, relocation.target), + kind: relocation.kind, + }); + } + if ( + basisCwd === undefined || + basisCwd !== entryCwd || + cwdRelocations.length > 0 + ) { + checkedTargets.push({ target: gitCwd, kind: 'cwd' }); + } + + for (const { target, kind } of checkedTargets) { + const canonicalTarget = await realpathNearestExistingAsync(target); + let repositoryTarget: string; + if (kind === 'git-dir') { + try { + repositoryTarget = await resolveGitDirRepository(canonicalTarget); + } catch { + // Missing or unreadable indirection: containment cannot be proven + // before execution. + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalTarget); + } + } else { + try { + // A target that does not fully exist at decision time may still be + // created as an outward symlink before git runs. + await realpath(canonicalTarget); + repositoryTarget = canonicalTarget; + } catch { + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalTarget); + } + } + repositoryTarget = await realpathNearestExistingAsync(repositoryTarget); + if (!isWithinRoot(repositoryTarget, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, repositoryTarget); + } + } + // Unless a `--git-dir`/`GIT_DIR` names the repository outright, git finds it + // by walking up from its working directory — which can hand it a repository + // outside the boundary even when the directory itself is inside. + if ( + repositoryRelocations.every((relocation) => relocation.kind !== 'git-dir') + ) { + const denial = await denyOutsideDiscoveredRepository(gitCwd, context); + if (denial) return denial; + } + return undefined; +} + +async function denyOutsideDiscoveredRepository( + startDirectory: string, + context: GuardEvaluationContext, +): Promise { + const canonicalStart = await realpathNearestExistingAsync(startDirectory); + let discovered: string | undefined; + try { + discovered = await resolveDiscoveredRepository( + canonicalStart, + context.canonicalEffectiveCwd, + ); + } catch { + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalStart); + } + if (discovered === undefined) return undefined; + const canonicalDiscovered = await realpathNearestExistingAsync(discovered); + if (!isWithinRoot(canonicalDiscovered, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalDiscovered); + } + return undefined; +} + +/** + * Extract the bodies of `$(…)` and backtick command substitutions from one + * segment. They execute before the command they are embedded in, so a + * relocated mutation hidden inside one (`echo $(git -C reset + * --hard)`) has to be analysed rather than folded into an opaque token. + * Returns null when a substitution is left unterminated. + */ +// `$'…'` is ANSI-C quoting: unlike a plain single-quoted string, a backslash +// escapes inside it, so `$'a\'b'` does not end at the middle quote. Treating +// it as a plain quote leaves the scanner one quote out of phase and a later +// `$(…)` invisible. Returns the index just past the closing quote. +function skipAnsiCQuote(segment: string, start: number): number { + let index = start + 2; + while (index < segment.length && segment[index] !== "'") { + if (segment[index] === '\\') index++; + index++; + } + return index + 1; +} + +function extractCommandSubstitutions(segment: string): string[] | null { + const bodies: string[] = []; + let single = false; + let double = false; + let index = 0; + while (index < segment.length) { + const character = segment[index]!; + if (!single && character === '\\' && index + 1 < segment.length) { + index += 2; + continue; + } + if (!single && !double && character === '$' && segment[index + 1] === "'") { + index = skipAnsiCQuote(segment, index); + continue; + } + if (!single && character === '$' && segment[index + 1] === '(') { + // `$((…))` is arithmetic, not a command. Stepping over the opening + // punctuation keeps any real substitution nested inside it visible. + if (segment[index + 2] === '(') { + index += 3; + continue; + } + const end = findSubstitutionEnd(segment, index + 2); + if (end === -1) return null; + bodies.push(segment.slice(index + 2, end)); + index = end + 1; + continue; + } + if (!single && character === '`') { + let end = index + 1; + while (end < segment.length && segment[end] !== '`') { + if (segment[end] === '\\') end++; + end++; + } + if (end >= segment.length) return null; + bodies.push(segment.slice(index + 1, end)); + index = end + 1; + continue; + } + if (character === "'" && !double) single = !single; + else if (character === '"' && !single) double = !double; + index++; + } + return bodies; +} + +/** Index of the `)` closing a `$(` body opened at `start`, or -1. */ +function findSubstitutionEnd(segment: string, start: number): number { + let single = false; + let double = false; + let depth = 0; + for (let index = start; index < segment.length; index++) { + const character = segment[index]!; + if (!single && character === '\\') { + index++; + continue; + } + if (!single && !double && character === '$' && segment[index + 1] === "'") { + index = skipAnsiCQuote(segment, index) - 1; + continue; + } + if (character === "'" && !double) { + single = !single; + continue; + } + if (character === '"' && !single) { + double = !double; + continue; + } + if (single || double) continue; + if (character === '(') depth++; + else if (character === ')') { + if (depth === 0) return index; + depth--; + } + } + return -1; +} + +/** + * A run whose program word the daemon does not recognize can still run git: + * `nice git reset --hard`, `xargs git …`, `find -exec git …`. The static scan + * cannot prove what it executes, so deny whenever the run mentions git and the + * repository it would act on is not provably the session's own — either + * because a relocation is in play or because the shell has been moved out of + * the boundary by an earlier `cd`. + */ +async function evaluateUnrecognizedRun( + run: GuardToken[], + state: PrefixState, + basisCwd: string | undefined, + context: GuardEvaluationContext, + relink?: RelinkState, +): Promise { + if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return undefined; + // A relinked `.git` redirects discovery for whatever git this run executes, + // exactly as it would for a recognized one. + if (relink?.gitDir) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + // `nice git -c alias.pwn='!…' pwn`: the wrapper hides the invocation from + // the git analysis, but the config it carries executes just the same. + const gitIndex = run.findIndex( + (token) => executableBaseName(token) === 'git', + ); + if (gitIndex >= 0 && readGitInvocation(run.slice(gitIndex)).dangerousConfig) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + // `grep -C 5 git CHANGELOG.md` carries a `-C` that has nothing to do with + // git, so the program's own flag vocabulary decides whether it is a marker. + const ownsCFlag = + run.length > 0 && PROGRAMS_WITH_OWN_C_FLAG.has(executableBaseName(run[0]!)); + if ( + (!ownsCFlag && hasGitRelocationMarker(run)) || + run.some((token) => + (ownsCFlag + ? TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN + : TEXT_RELOCATION_MARKER_PATTERN + ).test(token.text), + ) || + state.relocations.length > 0 || + state.unresolved || + context.ambientRelocations.length > 0 || + context.ambientUnresolved + ) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + if (basisCwd === undefined) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + const canonicalBasis = await realpathNearestExistingAsync(basisCwd); + if (!isWithinRoot(canonicalBasis, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalBasis); + } + // Same discovery rule as a recognized git run: being in an in-boundary + // directory says nothing about which repository git finds from it. + return denyOutsideDiscoveredRepository(canonicalBasis, context); +} + +/** + * State that outlives the scope it was created in. A relink performed inside + * `sh -c '…'` still changes the real filesystem, and a relink performed in + * the parent still misleads a nested run — so this is shared by reference in + * both directions rather than merged after the fact. + */ +interface RelinkState { + readonly targets: string[]; + gitDir: boolean; +} + +interface EvaluationScope { + readonly relink: RelinkState; + // Shell variables the nested command can see: `eval` and subshells inherit + // them, a `sh -c` subprocess does not. + readonly locals?: Map; + // Names carrying the export attribute, shared with `eval` for the same + // reason its locals are. + readonly exportedNames?: Set; + // `set -a` state from the enclosing shell — a body run in the current shell + // (`eval`, alias, function) inherits it, so a plain assignment there is + // exported just as the real shell would. + readonly allExport?: boolean; + // Alias/function bodies and their Git-shaped names, shared with a + // same-shell body so `outer() { inner; }` can see `inner`. + readonly definedBodies?: Map; + readonly gitShapedNames?: Set; + // Names carried by `export -f`, which a child shell (`bash -c`) imports. + readonly exportedFunctions?: Set; +} + +/** + * The top-level separators `splitCommands` cut on, in order — mirroring its + * quote and substitution rules. `separators[i]` follows segment `i`. Both + * sides of a `|` run in subshells, so a `cd` there must not move the shell. + */ +/** + * A heredoc body is stdin data delivered to the command, not shell commands, + * yet `splitCommands` has no heredoc state and would parse each body line as + * its own segment — letting a body `cd` launder the tracked directory. Strip + * `<<[-]WORD … WORD` bodies (quoted or not) before splitting. This is + * best-effort: only the first heredoc on a line is handled, which is the + * shape a model emits, and anything unrecognised is left untouched. + */ +function stripHeredocBodies(command: string): string { + const lines = command.split('\n'); + const out: string[] = []; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!; + out.push(line); + const match = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line); + if (!match) continue; + const delimiter = match[2]!; + const stripTabs = line.includes('<<-'); + // Consume the body up to the delimiter line, dropping it from the output. + while (index + 1 < lines.length) { + index++; + const body = lines[index]!; + const trimmed = stripTabs ? body.replace(/^\t+/, '') : body; + if (trimmed === delimiter) break; + } + } + return out.join('\n'); +} + +function readTopLevelSeparators(command: string): string[] { + const separators: string[] = []; + let single = false; + let double = false; + let backtick = false; + let substitution = 0; + const quoteStack: Array<[boolean, boolean]> = []; + for (let index = 0; index < command.length; index++) { + const character = command[index]!; + const next = command[index + 1]; + if (!single && character === '\\' && index + 1 < command.length) { + index++; + continue; + } + if (!single && character === '`') { + backtick = !backtick; + continue; + } + if (!single && !backtick && character === '$' && next === '(') { + quoteStack.push([single, double]); + single = false; + double = false; + substitution++; + index++; + continue; + } + if ( + !backtick && + substitution > 0 && + character === ')' && + !single && + !double + ) { + const enclosing = quoteStack.pop(); + single = enclosing?.[0] ?? false; + double = enclosing?.[1] ?? false; + substitution--; + continue; + } + if (!backtick && character === "'" && !double) { + single = !single; + continue; + } + if (!backtick && character === '"' && !single) { + double = !double; + continue; + } + if (single || double || backtick || substitution > 0) continue; + if (character === '&' && next === '&') { + separators.push('&&'); + index++; + } else if (character === '&' && next === '>') { + // `&>` / `&>>` redirects stdout+stderr; the `&` is not a separator. + index += command[index + 2] === '>' ? 2 : 1; + } else if ( + character === '&' && + (command[index - 1] === '>' || command[index - 1] === '<') + ) { + // `>&2` / `<&fd` — the `&` is part of a file-descriptor redirect. + } else if (character === '&') { + // A lone `&` backgrounds the command in its own subshell. + separators.push('&'); + } else if (character === '|' && next === '|') { + separators.push('||'); + index++; + } else if (character === '|') { + // `>|` is the clobber redirect, not a pipe. + separators.push(command[index - 1] === '>' ? '>|' : '|'); + } else if (character === ';') { + separators.push(';'); + } else if (character === '\n') { + separators.push('\n'); + } + } + return separators; +} + +interface CommandEvaluation { + readonly denial?: GuardDenial; + readonly cwdAfter: string | undefined; + // Environment state the payload leaves behind. Only a construct that runs + // in the current shell (`eval`) propagates it back to the caller. + readonly exportedAfter?: PrefixState; + readonly allExportAfter?: boolean; + readonly shellLocalsAfter?: ReadonlyMap; +} + +async function evaluateCommandWithCwd( + command: string, + startCwd: string | undefined, + entryCwd: string | undefined, + context: GuardEvaluationContext, + depth: number, + scope: EvaluationScope = { relink: { targets: [], gitDir: false } }, +): Promise { + let trackedCwd = startCwd; + // Assignments this command exported into the environment of everything that + // runs after them, and whether `set -a` made plain assignments exported. + const exported: PrefixState = { relocations: [], unresolved: false }; + let allExport = scope.allExport ?? false; + // GIT_* assignments made without `export`. They stay shell-local until a + // name-only `export GIT_DIR` promotes them into the environment. + const shellLocals = scope.locals ?? new Map(); + // `alias g='git …'` and `f() { git …; }` both make a later bare word run a + // body defined earlier; without them that word is an opaque `other` run. + const definedBodies = + scope.definedBodies ?? new Map(); + // Names carrying the export attribute from a name-only `export KEY`; a + // later assignment to one of them reaches the git subprocess. + const exportedNames = scope.exportedNames ?? new Set(); + // Function bodies that `splitCommands` cut across segments cannot be + // replayed verbatim, so the name is recorded as Git-shaped instead and the + // later bare word answers to the unrecognized-program containment rule. + const gitShapedNames = scope.gitShapedNames ?? new Set(); + const exportedFunctions = scope.exportedFunctions ?? new Set(); + let insideDefinition: string | undefined; + let definitionBody = ''; + // Paths a run in this command may have re-pointed. Any containment the + // guard proves for one of them afterwards is proved against the old target. + // Shared with every nested evaluation, in both directions. + const relinkedTargets = scope.relink.targets; + // Exported relocations reach every later command, including the ones nested + // inside a wrapper payload or a substitution body. + const activeContext = (): GuardEvaluationContext => + exported.relocations.length > 0 || exported.unresolved + ? { + canonicalEffectiveCwd: context.canonicalEffectiveCwd, + ambientRelocations: [ + ...context.ambientRelocations, + ...exported.relocations, + ], + ambientUnresolved: context.ambientUnresolved || exported.unresolved, + } + : context; + let subshellDepth = 0; + interface ShellStateSnapshot { + readonly cwd: string | undefined; + readonly relocations: GitEnvRelocation[]; + readonly unresolved: boolean; + readonly allExport: boolean; + readonly locals: Array<[string, GuardToken]>; + } + const snapshotShellState = (): ShellStateSnapshot => ({ + cwd: trackedCwd, + relocations: [...exported.relocations], + unresolved: exported.unresolved, + allExport, + locals: [...shellLocals], + }); + const restoreShellState = ( + snapshot: ShellStateSnapshot | undefined, + ): void => { + if (snapshot === undefined) return; + trackedCwd = snapshot.cwd; + exported.relocations.length = 0; + exported.relocations.push(...snapshot.relocations); + exported.unresolved = snapshot.unresolved; + allExport = snapshot.allExport; + shellLocals.clear(); + for (const [key, token] of snapshot.locals) shellLocals.set(key, token); + }; + const subshellCwds: ShellStateSnapshot[] = []; + // Replay a recorded alias/function body in the current shell, propagating + // its cwd and shell state back — an alias keeps the invocation's trailing + // argv, a function receives args through `$@`. + const invokeDefinedBody = async ( + programToken: string, + run: GuardToken[], + ): Promise => { + const defined = definedBodies.get(programToken)!; + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) return denyDynamicRelocation(); + let replay = defined.body; + // Skip redirect/fd operands the way `readProgramWord` does, so a decoy + // `> name` before the call does not truncate the prefix-assignment scan and + // drop the call's leading `VAR=val` relocations. + const programIndex = run.findIndex( + (token) => + !token.redirect && !token.ambiguousFd && token.text === programToken, + ); + if (defined.alias) { + const args = joinArgvTexts(run.slice(programIndex + 1)); + if (args.length > 0) replay = `${replay} ${args}`; + } + // `VAR=val name` puts the assignment in the call's environment, so the + // body's git sees it — record the leading assignments as ambient. + const prefix: PrefixState = { relocations: [], unresolved: false }; + for (const token of run.slice(0, programIndex)) { + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, prefix); + } + } + const base = activeContext(); + const bodyContext: GuardEvaluationContext = + prefix.relocations.length > 0 || prefix.unresolved + ? { + canonicalEffectiveCwd: base.canonicalEffectiveCwd, + ambientRelocations: [ + ...base.ambientRelocations, + ...prefix.relocations, + ], + ambientUnresolved: base.ambientUnresolved || prefix.unresolved, + } + : base; + const nested = await evaluateCommandWithCwd( + replay, + trackedCwd, + entryCwd, + bodyContext, + depth + 1, + { + relink: scope.relink, + locals: shellLocals, + exportedNames, + allExport, + definedBodies, + gitShapedNames, + exportedFunctions, + }, + ); + if (nested.denial) return nested.denial; + trackedCwd = nested.cwdAfter; + if (nested.exportedAfter) { + exported.relocations.push(...nested.exportedAfter.relocations); + if (nested.exportedAfter.unresolved) exported.unresolved = true; + } + if (nested.allExportAfter !== undefined) allExport = nested.allExportAfter; + for (const [key, token] of nested.shellLocalsAfter ?? []) { + shellLocals.set(key, token); + } + return undefined; + }; + + const segments = splitCommands(stripHeredocBodies(command)); + const separators = readTopLevelSeparators(stripHeredocBodies(command)); + // On any disagreement with `splitCommands`, treat every segment of a piped + // command as a pipeline component rather than guessing. + const separatorsMatch = separators.length === segments.length - 1; + const isPipeComponent = (index: number): boolean => + separatorsMatch + ? // Both sides of a pipe run in subshells; for `&` only the segment it + // follows (the backgrounded one) does — the next segment is + // foreground. + separators[index - 1] === '|' || + separators[index] === '|' || + separators[index] === '&' + : // Structural disagreement with `splitCommands`: scope every segment + // rather than guess which ones ran in a subshell. + separators.some((separator) => separator === '|' || separator === '&'); + for (const [segmentIndex, segment] of segments.entries()) { + const pipeComponent = isPipeComponent(segmentIndex); + const cwdBeforeSegment = trackedCwd; + const definedBodiesBefore = pipeComponent + ? new Map(definedBodies) + : undefined; + const gitShapedNamesBefore = pipeComponent + ? new Set(gitShapedNames) + : undefined; + const exportedNamesBefore = pipeComponent + ? new Set(exportedNames) + : undefined; + const exportedFunctionsBefore = pipeComponent + ? new Set(exportedFunctions) + : undefined; + const shellLocalsBefore = pipeComponent ? new Map(shellLocals) : undefined; + const exportedBefore = pipeComponent + ? { + relocations: [...exported.relocations], + unresolved: exported.unresolved, + } + : undefined; + const allExportBefore = allExport; + const substitutions = extractCommandSubstitutions(segment); + const tokenized = + substitutions === null ? null : tokenizeSegment(segment, subshellDepth); + const runs = tokenized?.runs ?? null; + if (runs === null) { + return { + denial: { allowed: false, reason: UNPARSEABLE_COMMAND_DENIAL }, + cwdAfter: trackedCwd, + }; + } + // `name() { … }` — shell-quote reports the parentheses as operators, so + // the header is recognised on the raw segment. The body runs wherever the + // name is later used, which is what the recorded shape stands in for. + const functionHeader = + /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)/.exec(segment) ?? + // The `function NAME` keyword form, with the `()` optional. + /^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(segment); + if (functionHeader) { + insideDefinition = functionHeader[1]!; + // Start the body at the first `{`; the header before it is not code. + const braceAt = segment.indexOf('{'); + definitionBody = braceAt >= 0 ? segment.slice(braceAt + 1) : ''; + } else if (insideDefinition !== undefined) { + definitionBody += `\n${segment}`; + } + if (insideDefinition !== undefined) { + if (segment.includes('}')) { + // Record the whole body so a later call replays it — a `-C ` + // or a `cd` inside it is then seen, not just the name. + const closeAt = definitionBody.lastIndexOf('}'); + const body = ( + closeAt >= 0 ? definitionBody.slice(0, closeAt) : definitionBody + ).trim(); + if (body.length > 0 && !pipeComponent) { + definedBodies.set(insideDefinition, { body, alias: false }); + } + insideDefinition = undefined; + } + continue; + } + + // A substitution body executes before the command it is embedded in, in a + // subshell of the current directory, so its cwd changes do not escape it. + for (const body of substitutions!) { + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + const nested = await evaluateCommandWithCwd( + body, + trackedCwd, + entryCwd, + activeContext(), + depth + 1, + // A substitution runs in a subshell: it inherits the variables, the + // option state and the definitions, but its own changes die with it, + // so it gets copies and nothing is merged back. + { + relink: scope.relink, + locals: new Map(shellLocals), + exportedNames: new Set(exportedNames), + allExport, + definedBodies: new Map(definedBodies), + gitShapedNames: new Set(gitShapedNames), + // A subshell inherits `export -f` functions too; copy so its own + // definitions and removals die with it. + exportedFunctions: new Set(exportedFunctions), + }, + ); + if (nested.denial) { + return { denial: nested.denial, cwdAfter: trackedCwd }; + } + } + for (const { tokens: run, depth: runDepth } of runs) { + while (runDepth > subshellDepth) { + subshellCwds.push(snapshotShellState()); + subshellDepth++; + } + while (runDepth < subshellDepth) { + // Leaving `( … )`: everything the subshell changed dies with it — + // its cwd, its exports and its shell-local variables. + restoreShellState(subshellCwds.pop()); + subshellDepth--; + } + // A removal builtin (`unset`/`unalias`/`export -n`) retracts a shadow, + // but deciding exactly which name it drops is general shell semantics + // this guard does not model: `unset NAME` removes a same-name variable + // before the function, `enable -n unset` turns the builtin into a no-op, + // a `command`/`builtin` prefix or a `( … )` subshell changes what runs, + // and fused flag clusters (`-nf`) hide the mode. Whenever a removal + // could retract a name we track as a shadow, fail closed rather than + // trust a now-doubtful replay of the harmless body. + // Bash strips redirections from argv, so skip redirect/fd operands the + // same way `readProgramWord` does before (and between) `command`/ + // `builtin` prefixes — otherwise a leading `2>/dev/null` hides the + // `command unset` that really removes the shadow. + let removalStart = 0; + const skipRedirectOperands = (): void => { + while ( + removalStart < run.length && + (run[removalStart]!.redirect || run[removalStart]!.ambiguousFd) + ) { + removalStart++; + } + }; + skipRedirectOperands(); + let hasCommandPrefix = false; + while ( + removalStart < run.length && + (run[removalStart]!.text === 'command' || + run[removalStart]!.text === 'builtin') + ) { + // Bash resolves a function before the `command`/`builtin` builtin, so + // a shadowed prefix word runs its own body — leave it for the shadow + // dispatch rather than treating it as a bypass to the real builtin. + if (definedBodies.has(run[removalStart]!.text)) break; + hasCommandPrefix = true; + removalStart++; + while ( + removalStart < run.length && + (run[removalStart]!.text.startsWith('-') || + run[removalStart]!.redirect || + run[removalStart]!.ambiguousFd) + ) { + removalStart++; + } + } + const removalTokens = run.slice(removalStart); + const removalProgram = readProgramWord(removalTokens); + // A function shadowing `unset`/`unalias`/`export` runs its body instead + // of the builtin (unless `command`/`builtin` bypassed the lookup), so + // let the normal shadow dispatch replay it rather than treating the run + // as a builtin removal that changes nothing. + const shadowedBuiltin = + !hasCommandPrefix && + removalProgram !== undefined && + definedBodies.has(removalProgram); + const isRemoval = + !shadowedBuiltin && + (removalProgram === 'unset' || + removalProgram === 'unalias' || + (removalProgram === 'export' && + removalTokens.some((token) => /^-[A-Za-z]*n/.test(token.text)))); + if (isRemoval) { + const clearsAll = removalTokens.some((token) => + /^-[A-Za-z]*a/.test(token.text), + ); + const touchesShadow = (name: string): boolean => + definedBodies.has(name) || + gitShapedNames.has(name) || + exportedFunctions.has(name); + const anyShadow = + definedBodies.size > 0 || + gitShapedNames.size > 0 || + exportedFunctions.size > 0; + const retractsShadow = + (clearsAll && anyShadow) || + removalTokens + .slice(1) + .some( + (token) => + !token.text.startsWith('-') && + (token.dynamic || touchesShadow(token.text)), + ); + if (retractsShadow) { + return { + denial: { allowed: false, reason: SHADOW_REMOVAL_DENIAL }, + cwdAfter: trackedCwd, + }; + } + // `unset NAME` / `unset -v NAME` drops a tracked variable, so a later + // `$NAME` must stop expanding to its stale value — bash leaves it empty + // (an unresolved reference the guard then fails closed on). `unset -f` + // is functions-only and leaves variables intact. + if ( + removalProgram === 'unset' && + !removalTokens.some((token) => token.text === '-f') + ) { + for (const token of removalTokens.slice(1)) { + if (!token.text.startsWith('-')) shellLocals.delete(token.text); + } + } + // Any other removal that names only untracked state is a genuine no-op. + continue; + } + // A recorded function shadows a builtin or the git program, and bash + // resolves it before either. `command`/`builtin` name a different + // program word, so they bypass this naturally. + const invoked = readProgramWord(run); + if ( + invoked !== undefined && + definedBodies.has(invoked) && + readFunctionName(run) === undefined && + readAliasDefinitions(run).length === 0 + ) { + const denial = await invokeDefinedBody(invoked, run); + if (denial) return { denial, cwdAfter: trackedCwd }; + continue; + } + const analysis = analyzeRun(run); + switch (analysis.kind) { + case 'cd': { + const target = + analysis.target === undefined + ? undefined + : expandShellLocals(analysis.target, shellLocals); + if (analysis.variant === 'popd' || target === undefined) { + // `popd`, bare `cd` ($HOME), and dir-stack rotations land the + // shell somewhere the daemon cannot resolve statically. + trackedCwd = undefined; + break; + } + if (isDynamicPathValue(target)) { + trackedCwd = undefined; + break; + } + if (analysis.physical) { + // `cd -P` resolves each component through its symlinks, so + // `link/..` is the parent of the symlink's real target rather + // than the directory the link sits in. + trackedCwd = + trackedCwd === undefined && !path.isAbsolute(target.text) + ? undefined + : await resolvePhysicalPath(trackedCwd ?? '', target.text); + break; + } + if (path.isAbsolute(target.text)) { + trackedCwd = target.text; + break; + } + trackedCwd = + trackedCwd === undefined + ? undefined + : path.resolve(trackedCwd, target.text); + break; + } + case 'payload': { + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + const inherited = activeContext(); + const ambient: GuardEvaluationContext = { + canonicalEffectiveCwd: inherited.canonicalEffectiveCwd, + ambientRelocations: [ + ...inherited.ambientRelocations, + ...analysis.state.relocations, + ], + ambientUnresolved: + inherited.ambientUnresolved || analysis.state.unresolved, + }; + // The payload keeps the outermost run's entry cwd as its + // containment basis: re-basing it to the tracked cwd would let a + // preceding `cd` disappear inside the wrapper. + const nested = await evaluateCommandWithCwd( + analysis.payload, + trackedCwd, + entryCwd, + ambient, + depth + 1, + { + relink: scope.relink, + // `eval` runs in this very shell, so it sees these variables + // and the export attributes; a `sh -c` subprocess inherits only + // exported ones. + ...(analysis.propagatesCwd + ? { + locals: shellLocals, + exportedNames, + allExport, + definedBodies, + gitShapedNames, + exportedFunctions, + } + : analysis.importsExportedFunctions + ? { + // Only bash imports `export -f` functions. A `-c` + // subprocess is a separate process: copy the set so a + // child `unset -f` cannot retract the parent's exports, + // and drop any function whose `BASH_FUNC_*` entry an + // `env -u` stripped before the child started. + definedBodies: new Map( + [...definedBodies].filter( + ([name]) => + exportedFunctions.has(name) && + !envUnsetRemovesFunction(name, analysis.state), + ), + ), + exportedFunctions: new Set( + [...exportedFunctions].filter( + (name) => + !envUnsetRemovesFunction(name, analysis.state), + ), + ), + } + : {}), + }, + ); + if (nested.denial) { + return { denial: nested.denial, cwdAfter: trackedCwd }; + } + if (analysis.propagatesCwd) { + // `eval` runs in the current shell, so everything it changed — + // the cwd, exported relocations and `set -a` — outlives it. + trackedCwd = nested.cwdAfter; + if (nested.exportedAfter) { + exported.relocations.push(...nested.exportedAfter.relocations); + if (nested.exportedAfter.unresolved) exported.unresolved = true; + } + if (nested.allExportAfter !== undefined) { + allExport = nested.allExportAfter; + } + for (const [key, token] of nested.shellLocalsAfter ?? []) { + shellLocals.set(key, token); + } + } + break; + } + case 'git': { + const invocation = readGitInvocation(analysis.tokens); + // A path this command relinked defeats a containment check made + // afterwards. A relinked `.git` redirects discovery for every later + // command; otherwise only a run that resolves one of those very + // paths is affected, so `mv old new && git add -A` stays allowed. + const resolvedRelocations = [ + ...invocation.cwdTargets, + ...invocation.gitDirTargets, + ...invocation.workTreeTargets, + ].map((target) => + trackedCwd === undefined + ? target.text + : path.resolve(trackedCwd, target.text), + ); + if (trackedCwd !== undefined && trackedCwd !== entryCwd) { + resolvedRelocations.push(trackedCwd); + } + if ( + scope.relink.gitDir || + resolvedRelocations.some((target) => + relinkedTargets.some( + (relinked) => + target === relinked || + isWithinRoot(target, relinked) || + isWithinRoot(relinked, target), + ), + ) + ) { + return { + denial: denyDynamicRelocation(), + cwdAfter: trackedCwd, + }; + } + const denial = await evaluateGitInvocation( + invocation, + analysis.state, + trackedCwd, + entryCwd, + activeContext(), + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + case 'dynamic-program': { + const inherited = activeContext(); + const expanded = analysis.rest.map((token) => + expandShellLocals(token, shellLocals), + ); + // The program word is unreadable, so it may be `ln`: record its + // operands as possibly re-pointed. A `.git` among them redirects + // discovery for everything after it; an ordinary one still has to + // be recorded, or a later `git -C ` is validated against + // what the path pointed at before the command replaced it. + for (const operand of expanded) { + if (operand.text.startsWith('-') || operand.dynamic) continue; + if (trackedCwd === undefined) { + scope.relink.gitDir = true; + continue; + } + const resolved = path.resolve(trackedCwd, operand.text); + if (path.basename(resolved) === '.git') scope.relink.gitDir = true; + else relinkedTargets.push(resolved); + } + if ( + analysis.state.unresolved || + analysis.state.relocations.length > 0 || + inherited.ambientUnresolved || + inherited.ambientRelocations.length > 0 || + hasGitRelocationMarker(expanded) || + expanded.some((token) => + TEXT_RELOCATION_MARKER_PATTERN.test(token.text), + ) + ) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + // A program word the daemon cannot read is at least as opaque as an + // unrecognized one, so it answers to the same containment rule — + // and when the shell has already left the boundary, an unreadable + // program word is undecidable rather than harmless. + if ( + trackedCwd === undefined || + !isWithinRoot( + await realpathNearestExistingAsync(trackedCwd), + inherited.canonicalEffectiveCwd, + ) + ) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + const denial = await evaluateUnrecognizedRun( + expanded, + analysis.state, + trackedCwd, + inherited, + scope.relink, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + case 'undecidable': + return { + denial: { allowed: false, reason: UNDECIDABLE_PAYLOAD_DENIAL }, + cwdAfter: trackedCwd, + }; + case 'export': { + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; + if (analysis.operands.some((op) => op.text === '-f')) { + for (const op of analysis.operands) { + if (!op.text.startsWith('-') && !op.dynamic) { + exportedFunctions.add(op.text); + } + } + } + // `export GIT_DIR` with no `=` exports whatever an earlier + // shell-local assignment left in that name — and, because the + // export *attribute* sticks to the name, whatever a later one puts + // there as well. + for (const operand of analysis.operands) { + if (leadingEnvAssignmentKey(operand.text) !== null) continue; + if (operand.dynamic) { + // `export $NAME` can promote any assignment made earlier. + exported.unresolved = true; + continue; + } + const pending = shellLocals.get(operand.text); + if (pending) recordEnvAssignment(pending, exported); + if ( + GIT_DIR_ENV_KEYS.has(operand.text) || + GIT_WORK_TREE_ENV_KEYS.has(operand.text) || + GIT_UNRESOLVABLE_ENV_KEYS.has(operand.text) || + GIT_PROGRAM_ENV_KEYS.has(operand.text) + ) { + exportedNames.add(operand.text); + } + } + const denial = await evaluateUnrecognizedRun( + run, + analysis.state, + trackedCwd, + activeContext(), + scope.relink, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + case 'all-export': + allExport = true; + // A leading `GIT_DIR=… set -a` still made that assignment; it is + // shell-local for now, promoted the moment allexport is on. + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; + break; + case 'all-export-off': + allExport = false; + break; + case 'other': { + // `alias name=body …` / `name() { body }` — record, don't execute. + const aliasDefinitions = readAliasDefinitions(run); + if (aliasDefinitions.length > 0) { + for (const definition of aliasDefinitions) { + definedBodies.set(definition.name, { + body: definition.body, + alias: true, + }); + } + break; + } + const definition = readDefinition(run); + if (definition) { + definedBodies.set(definition.name, { + body: definition.body, + alias: false, + }); + break; + } + const programToken = readProgramWord(run); + const definitionName = readFunctionName(run); + if (definitionName) { + if (run.some((token) => GIT_WORD_PATTERN.test(token.text))) { + gitShapedNames.add(definitionName); + } + break; + } + if (programToken !== undefined && gitShapedNames.has(programToken)) { + const denial = await evaluateUnrecognizedRun( + [ + { text: programToken, dynamic: false }, + { text: 'git', dynamic: false }, + ], + analysis.state, + trackedCwd, + activeContext(), + scope.relink, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + if (programToken !== undefined && definedBodies.has(programToken)) { + const denial = await invokeDefinedBody(programToken, run); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + if ( + run.some((t) => PATH_EXTRACTING_PROGRAMS.has(executableBaseName(t))) + ) { + // An archive can place a symlink anywhere below the extraction + // directory, so a later relocation resolving into it is suspect. + // A path-less run that merely discovers a repository from an + // extracted `.git` is a TOCTOU (the archive is unpacked after + // this decision) and is left to the same limitation as the + // symlink race rather than denying every `tar && git commit`. + if (trackedCwd !== undefined) relinkedTargets.push(trackedCwd); + } + if ( + run.some((t) => PATH_RELINKING_PROGRAMS.has(executableBaseName(t))) + ) { + // Wrappers and leading assignments (`env ln …`, `X=1 ln …`) keep + // the relinking program out of run[0], so scan the whole run. + for (const operand of run) { + if (operand.text.startsWith('-')) continue; + if (PATH_RELINKING_PROGRAMS.has(executableBaseName(operand))) { + continue; + } + if (operand.dynamic || trackedCwd === undefined) { + scope.relink.gitDir = true; + continue; + } + const resolved = path.resolve(trackedCwd, operand.text); + relinkedTargets.push(resolved); + if (path.basename(resolved) === '.git') + scope.relink.gitDir = true; + } + } + if (analysis.assignmentsOnly) { + if (allExport) { + // `set -a` turned this shell-local assignment into an exported + // one straight away. + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; + } else { + for (const token of run) { + const key = leadingEnvAssignmentKey(token.text); + if (key === null) continue; + if (exportedNames.has(key)) { + recordEnvAssignment(token, exported); + continue; + } + const previous = shellLocals.get(key); + if (!isAppendAssignment(token.text) || previous === undefined) { + shellLocals.set(key, token); + continue; + } + // `X+=…` appends: keep the accumulated value so a later `$X` + // expands to what the shell would run. + shellLocals.set(key, { + text: + previous.text + + token.text.slice(token.text.indexOf('=') + 1), + dynamic: previous.dynamic || token.dynamic, + }); + } + } + } + const denial = await evaluateUnrecognizedRun( + run, + analysis.state, + trackedCwd, + activeContext(), + scope.relink, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + default: { + const exhaustive: never = analysis; + void exhaustive; + break; + } + } + } + // The tokenizer's closing depth is authoritative for what this segment + // did with parentheses: `(cd )` opens and closes within it, so + // the subshell's cwd must not survive into the next segment. + while (tokenized!.endDepth < subshellDepth) { + restoreShellState(subshellCwds.pop()); + subshellDepth--; + } + while (tokenized!.endDepth > subshellDepth) { + subshellCwds.push(snapshotShellState()); + subshellDepth++; + } + // Both sides of a pipe run in their own subshell, so whatever this + // segment did to the shell's directory dies with it. + if (pipeComponent) { + // A subshell keeps nothing: its cwd, option state, definitions, + // exports and variables all die with it. + trackedCwd = cwdBeforeSegment; + allExport = allExportBefore; + definedBodies.clear(); + for (const [k, v] of definedBodiesBefore!) definedBodies.set(k, v); + gitShapedNames.clear(); + for (const k of gitShapedNamesBefore!) gitShapedNames.add(k); + exportedNames.clear(); + for (const k of exportedNamesBefore!) exportedNames.add(k); + exportedFunctions.clear(); + for (const k of exportedFunctionsBefore!) exportedFunctions.add(k); + shellLocals.clear(); + for (const [k, v] of shellLocalsBefore!) shellLocals.set(k, v); + exported.relocations.length = 0; + exported.relocations.push(...exportedBefore!.relocations); + exported.unresolved = exportedBefore!.unresolved; + } + } + return { + cwdAfter: trackedCwd, + exportedAfter: exported, + allExportAfter: allExport, + shellLocalsAfter: shellLocals, + }; +} + +async function evaluateBuiltInGuard( + request: TrustedDaemonToolGuardRequest, +): Promise { + if (!SHELL_EXECUTING_TOOLS.has(request.toolName)) return { allowed: true }; + const command = request.arguments['command']; + if (typeof command !== 'string') return { allowed: true }; + + const sessionCwd = await realpathNearestExistingAsync(request.effectiveCwd); + + // A sub-agent pinned to a worktree (`working_dir`, or `isolation`, which + // rebinds the child Config's cwd surfaces) executes there while reporting + // the parent session id, so the session's own directory is not where the + // command runs. The child reports that directory; it is untrusted, so it is + // only accepted where the daemon can verify it from state it owns: inside + // the session's effective working directory, or inside the worktree tree + // this very session owns (`GitWorktreeService.getWorktreesDir(sessionId)`). + // Anywhere else the scope cannot be established and the call fails closed — + // and the accepted directory becomes the boundary, so an isolated sub-agent + // is contained to its own worktree rather than to its parent's checkout. + let canonicalEffectiveCwd = sessionCwd; + const reportedCwd = request.invocationCwd; + if (typeof reportedCwd === 'string' && reportedCwd.length > 0) { + const canonicalReported = await realpathNearestExistingAsync(reportedCwd); + if (isWithinRoot(canonicalReported, sessionCwd)) { + // `AgentTool` with `isolation: 'worktree'` provisions under + // `/.qwen/worktrees/`, which is inside the session — so + // "inside" is not enough to leave the boundary alone. A reported + // directory that is a checkout root in its own right is the sub-agent's + // worktree, and containing it there is what stops one sub-agent from + // reaching into a sibling's. An ordinary subdirectory resolves to the + // session's own repository and changes nothing. + if (canonicalReported !== sessionCwd) { + let discovered: string | undefined; + try { + discovered = await resolveDiscoveredRepository( + canonicalReported, + sessionCwd, + ); + } catch { + return denyTarget( + UNVERIFIABLE_SCOPE_DENIAL_PREFIX, + canonicalReported, + ); + } + if ( + discovered !== undefined && + (await realpathNearestExistingAsync(discovered)) === canonicalReported + ) { + canonicalEffectiveCwd = canonicalReported; + } + } + } else { + const ownedWorktrees = await realpathNearestExistingAsync( + GitWorktreeService.getWorktreesDir(request.sessionId), + ); + if (!isWithinRoot(canonicalReported, ownedWorktrees)) { + return denyTarget(UNVERIFIABLE_SCOPE_DENIAL_PREFIX, canonicalReported); + } + canonicalEffectiveCwd = canonicalReported; + } + } + + // A model-supplied `directory` becomes the containment basis, so it must + // itself stay inside the effective working directory before it is trusted. + let startDirectory = canonicalEffectiveCwd; + const startDirectoryValue = request.arguments['directory']; + if (typeof startDirectoryValue === 'string') { + startDirectory = await realpathNearestExistingAsync( + path.resolve(canonicalEffectiveCwd, startDirectoryValue), + ); + if (!isWithinRoot(startDirectory, canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, startDirectory); + } + } + + const { denial } = await evaluateCommandWithCwd( + command, + startDirectory, + startDirectory, + { + canonicalEffectiveCwd, + ambientRelocations: [], + ambientUnresolved: false, + }, + 0, + ); + return denial ?? { allowed: true }; +} + +export function createDaemonToolGuard( + externalGuard?: ExternalToolGuardHandler, +): ExternalToolGuardHandler { + return async (request) => { + const trusted = request as TrustedDaemonToolGuardRequest; + if (typeof trusted.effectiveCwd !== 'string') { + throw new Error('Daemon tool guard requires trusted workspace context.'); + } + const builtInDecision = await evaluateBuiltInGuard(trusted); + if (!builtInDecision.allowed || !externalGuard) return builtInDecision; + if (trusted.promptId === undefined) { + // Context-less shell checks carry only the built-in policy; the + // external provider is contracted to a live prompt. + return { allowed: false, reason: PROMPTLESS_PROVIDER_DENIAL }; + } + if (EXTERNAL_GUARD_UNSUPPORTED_TOOLS.has(request.toolName)) { + return { + allowed: false, + reason: + 'Managed external tool guard v1 does not support nested or delegated agent execution.', + }; + } + return externalGuard(request); + }; +} diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 094332540e9..460a2f78b12 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3877,11 +3877,41 @@ describe('runQwenServe runtime startup failures', () => { try { await handle.runtimeReady; const bridgeOptions = createBridge.mock.calls[0]?.[0] as - | { childEnvOverrides?: Record } + | { + childEnvOverrides?: Record; + externalToolGuard?: unknown; + } | undefined; expect(bridgeOptions?.childEnvOverrides).toMatchObject({ QWEN_SERVE_CDP_TUNNEL_OVER_WS: '1', + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD: 'required-v1', }); + // No external provider is configured in this test: the child must see + // the guard plumbing marker but NOT the provider-attached marker. + expect(bridgeOptions?.childEnvOverrides).toHaveProperty( + 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER', + undefined, + ); + expect(createBridge.mock.calls.length).toBeGreaterThan(0); + for (const call of createBridge.mock.calls) { + const options = call[0] as { externalToolGuard?: unknown }; + expect(options.externalToolGuard).toEqual(expect.any(Function)); + } + const daemonGuard = bridgeOptions?.externalToolGuard as ( + request: Record, + ) => Promise<{ allowed: boolean; reason?: string }>; + await expect( + daemonGuard({ + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { + command: `git -C ${path.join(os.tmpdir(), 'outside-repo')} reset --hard`, + }, + effectiveCwd: tmpDir, + }), + ).resolves.toMatchObject({ allowed: false }); } finally { if (originalClientMcpOverWs === undefined) { delete process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS']; @@ -3897,6 +3927,79 @@ describe('runQwenServe runtime startup failures', () => { } }); + // The negative side of the provider marker is asserted above. This is the + // attached side, driven by a real handshake against a loopback provider so + // the marker, the composed guard and the child env are all exercised. + it('forwards the provider-attached marker when a real provider handshakes', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-guard-provider-')), + ); + const provider = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + protocolVersion: number; + nonce?: string; + }; + response.statusCode = 200; + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + protocolVersion: body.protocolVersion, + nonce: body.nonce, + capabilities: { prepare: true }, + }), + ); + }); + }); + await new Promise((resolve) => + provider.listen(0, '127.0.0.1', resolve), + ); + const { port } = provider.address() as import('node:net').AddressInfo; + const bridge = makeRuntimeBridge(); + const createBridge = vi + .spyOn(acpBridge, 'createAcpSessionBridge') + .mockReturnValue( + bridge as ReturnType, + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + externalToolGuard: { + mode: 'required', + endpoint: `http://127.0.0.1:${port}`, + token: 'guard-token', + }, + } as Parameters[0], + { resolveOnListen: true }, + ); + + try { + await handle.runtimeReady; + const bridgeOptions = createBridge.mock.calls[0]?.[0] as + | { + childEnvOverrides?: Record; + externalToolGuard?: unknown; + } + | undefined; + expect(bridgeOptions?.childEnvOverrides).toMatchObject({ + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD: 'required-v1', + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER: 'attached-v1', + }); + expect(bridgeOptions?.externalToolGuard).toEqual(expect.any(Function)); + } finally { + await handle.close(); + await new Promise((resolve) => provider.close(() => resolve())); + } + }); + it.each([ [ 'defaults every runtime to workspace project-memory scope', diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index dad1fbe4830..855cd4a4eec 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -101,9 +101,11 @@ import { SERVE_CAPABILITY_REGISTRY, } from './capabilities.js'; import { + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, EXTERNAL_TOOL_GUARD_TOKEN_ENV, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import { CAPABILITIES_SCHEMA_VERSION, @@ -2924,6 +2926,13 @@ async function runQwenServeImpl( 'qwen serve: required external tool guard handshake succeeded.', ); } + // Keep the guard's core helper imports out of the serve fast-path bundle. + const { createDaemonToolGuard } = await import( + './daemon-git-worktree-guard.js' + ); + const daemonToolGuardHandler = createDaemonToolGuard( + externalToolGuardHandler, + ); const childEnvOverrides: Record = { QWEN_SERVE_MCP_CLIENT_BUDGET: opts.mcpClientBudget !== undefined @@ -2931,8 +2940,9 @@ async function runQwenServeImpl( : undefined, QWEN_SERVE_MCP_BUDGET_MODE: opts.mcpBudgetMode, QWEN_SERVE_CDP_TUNNEL_OVER_WS: opts.cdpTunnelOverWs ? '1' : undefined, - [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: externalToolGuardHandler - ? EXTERNAL_TOOL_GUARD_REQUIRED_VALUE + [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, + [PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]: externalToolGuardHandler + ? EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE : undefined, }; @@ -4136,9 +4146,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: daemonTelemetry, ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), @@ -4540,9 +4548,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory: secondaryChannelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: createRuntimeBridgeTelemetry(secondaryWorkspaceHash), ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), @@ -5094,9 +5100,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory: wsChannelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: createRuntimeBridgeTelemetry(wsHash), ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 7b42b2ac737..b26417ea4ba 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -9912,6 +9912,8 @@ describe('CoreToolScheduler Plan shell routing', () => { toolName: ToolNames.SHELL, args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), + sessionId: 'plan-shell-session', + cwd: '/workspace', }); expect(execute).not.toHaveBeenCalled(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; @@ -9948,6 +9950,8 @@ describe('CoreToolScheduler Plan shell routing', () => { toolName: ToolNames.SHELL, args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), + sessionId: 'plan-shell-session', + cwd: '/workspace', }); expect(execute).toHaveBeenCalledOnce(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 1d2702cc47e..377e231abed 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -4479,6 +4479,8 @@ export class CoreToolScheduler { toolName: canonicalName, args: invocation.params as Record, signal, + sessionId: this.config.getSessionId(), + cwd: this.config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/core/src/core/tool-invocation-guard.ts b/packages/core/src/core/tool-invocation-guard.ts index 81edf416be9..4c64659fd9c 100644 --- a/packages/core/src/core/tool-invocation-guard.ts +++ b/packages/core/src/core/tool-invocation-guard.ts @@ -17,6 +17,21 @@ export interface ToolInvocationGuardContext { * have one; a host that requires it must fail closed when it is absent. */ invocationContext?: Readonly; + /** + * Owning session id from the scheduler's session config. Present even when + * {@link invocationContext} is absent (subagents, cron turns, and resumed + * background agents run without one); a host whose policy only needs + * session scope may fall back to it instead of failing closed. + */ + sessionId?: string; + /** + * The directory the invocation will actually execute in — the scheduler's + * `config.getTargetDir()`. A sub-agent pinned to a worktree (`working_dir`, + * or `isolation`, which rebinds the child Config's cwd surfaces) runs there + * while still reporting the parent's {@link sessionId}, so a host that + * reasons about paths cannot assume the session's own directory. + */ + cwd?: string; } export type ToolInvocationGuardDecision = diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index 9209ed2a3a3..e2da6b366a3 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -61,6 +61,8 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), + getTargetDir: vi.fn().mockReturnValue('/spec/cwd'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -101,6 +103,8 @@ describe('startSpeculation', () => { toolName: 'read_file', args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), + sessionId: 'spec-session', + cwd: '/spec/cwd', }); expect(execute).not.toHaveBeenCalled(); @@ -125,6 +129,8 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), + getTargetDir: vi.fn().mockReturnValue('/spec/cwd'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -165,6 +171,8 @@ describe('startSpeculation', () => { toolName: 'read_file', args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), + sessionId: 'spec-session', + cwd: '/spec/cwd', }); expect(execute).toHaveBeenCalledOnce(); diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index e5ed0f2d0a4..a07562ee34e 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -350,6 +350,8 @@ async function runSpeculativeLoop( toolName: canonicalToolName(name), args: invocation.params as Record, signal: state.abortController!.signal, + sessionId: config.getSessionId(), + cwd: config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index d403934d3f7..fe08b133765 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -29,6 +29,7 @@ import { expandHomeDir, getProjectHash, realpathNearestExisting, + realpathNearestExistingAsync, _resetValidatePathCacheForTest, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -872,6 +873,46 @@ describe('realpathNearestExisting', () => { ); }); +describe('realpathNearestExistingAsync', () => { + let root: string; + + beforeAll(() => { + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'realpath-nearest-async-')), + ); + fs.mkdirSync(path.join(root, 'real'), { recursive: true }); + fs.writeFileSync(path.join(root, 'real', 'file.txt'), 'x', 'utf8'); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('matches the sync variant across the canonicalization cases', async () => { + const cases = [ + path.join(root, 'real', 'file.txt'), + path.join(root, 'real', 'a', 'b.txt'), + path.resolve(path.sep, 'no', 'such', 'ancestor', 'x'), + ]; + for (const target of cases) { + await expect(realpathNearestExistingAsync(target)).resolves.toBe( + realpathNearestExisting(target), + ); + } + }); + + it.skipIf(process.platform === 'win32')( + 'follows a dangling symlink to its non-existent target', + async () => { + const link = path.join(root, 'dangling-async'); + fs.symlinkSync(path.join(root, 'real', 'absent.txt'), link); + await expect(realpathNearestExistingAsync(link)).resolves.toBe( + path.join(root, 'real', 'absent.txt'), + ); + }, + ); +}); + describe('shortenPath', () => { const sep = path.sep; const sepForRegex = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index a90c9adb9ed..9d3f69aee23 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -486,6 +486,69 @@ export function realpathNearestExisting(inputPath: string): string { } } +async function resolveLeafSymlinkAsync(inputPath: string): Promise { + const maxHops = 40; // POSIX SYMLOOP_MAX + let current = path.resolve(inputPath); + for (let i = 0; i < maxHops; i++) { + let stat: fs.Stats; + try { + stat = await fs.promises.lstat(current); + } catch { + return current; // missing or unreadable — nothing left to follow + } + if (!stat.isSymbolicLink()) { + return current; + } + const target = await fs.promises.readlink(current); + if (path.isAbsolute(target)) { + current = target; + } else { + let parent: string; + try { + parent = await fs.promises.realpath(path.dirname(current)); + } catch { + parent = path.dirname(current); + } + current = path.resolve(parent, target); + } + } + return current; // chain too deep — caller still range-checks the result +} + +/** + * Promise-based {@link realpathNearestExisting} for callers on a shared event + * loop (the daemon guard evaluates shell calls for every workspace/session). + */ +export async function realpathNearestExistingAsync( + inputPath: string, +): Promise { + const resolved = await resolveLeafSymlinkAsync(inputPath); + const missingSegments: string[] = []; + let current = resolved; + + for (;;) { + let exists = true; + try { + await fs.promises.access(current); + } catch { + exists = false; + } + if (exists) break; + const parent = path.dirname(current); + if (parent === current) { + return resolved; + } + missingSegments.unshift(path.basename(current)); + current = parent; + } + + try { + return path.join(await fs.promises.realpath(current), ...missingSegments); + } catch { + return resolved; + } +} + /** * Resolves a path with tilde (~) expansion and relative path resolution. * Handles tilde expansion for home directory and resolves relative paths