* feat(daemon): guard cross-worktree Git mutations
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): keep Git guard off serve fast path
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): close daemon Git guard parser bypasses
Rebuild the daemon-side Git relocation guard parser so the runtime-verified
bypasses from review are closed: comment/glob tokens, backslash
continuations, shell wrapper and path-qualified invocations, cwd-shifting
builtins, env-var relocations, gitfile/symlink/worktree-admin indirection,
-C vs relative git-dir ordering, --output and textconv-capable read-only
subcommands, command-valued -c config, and dynamic expansion forms all fail
closed for mutations outside the session working directory. Command
splitting, canonicalization, and containment now reuse the core helpers.
Key the child-side v1 restrictions (/fork, agent-backed workspace memory)
and per-call daemon round trips on a real external provider being attached
instead of on guard plumbing presence: under the built-in guard alone,
hidden-agent tool calls traverse the same daemon-side policy, so those
features stay available and non-shell tools resolve locally.
Denial reasons are length-clamped and control-character-stripped so they
always satisfy the guard result validation.
* fix(serve): keep daemon Git guard out of serve fast-path closure
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): close re-reviewed daemon Git guard bypasses and subagent regression
Address the re-review at b1b7606: keep the outermost entry cwd as the
containment basis inside shell wrappers, fail closed on unrecognized
programs that still reference a relocated Git command, skip leading
shell keywords, deny undecidable and fused `-c` payloads, inspect
command-executing `-c` config before the read-only allowance, drop
`grep`/`status` from the relocated read-only set, validate the
model-supplied `directory` against the effective working directory,
and stop modelling `--exec-path`/`--list-cmds` as value-taking.
Context-less shell paths (subagents, cron turns, background
notifications, resumed background agents) previously failed closed
under the now-unconditional managed guard: fall back to the
scheduler-owned session id and validate those requests by session
ownership, while external-provider consultation still requires a
prompt binding. Move the guard's canonicalization off the daemon
event loop with a promise-based realpathNearestExisting, drop the
unread workspaceCwd request field, and restore the top-level guard
import in run-qwen-serve.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): restore lazy daemon Git guard import
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): close daemon Git guard shell front-end bypasses
All six forms below were reproduced against the real guard and confirmed to
really escape the boundary with a real shell and git 2.47.3 (the outside
worktree was reset, or the textconv marker file was created outside).
- `cat-file --textconv`/`--filters` run programs configured by the *target*
repository, so the relocated read-only allowance no longer applies when a
`--textconv`, `--filters`, or `--output` flag is present, wherever it
appears in the invocation.
- `export GIT_WORK_TREE=<outside> && git reset --hard` hid the relocation in
its own segment. `export`/`declare`/`typeset`/`readonly` operands (and plain
assignments after `set -a`) are now recorded as exported relocations that
apply to every later command in the chain, including wrapper payloads and
substitution bodies.
- `builtin cd <outside>` masked cwd tracking; `builtin` now takes the same
prefix-skipping path as `command`.
- `cd -P <outside>` consumed the flag as the directory operand, so containment
was evaluated against `<cwd>/-P` — inside the boundary whenever such a
directory exists. `cd`/`pushd` option words are skipped when locating the
operand.
- `cd <outside> && nice git reset --hard` passed because an unrecognized
program word was only checked for relocation markers. It is now also denied
when the tracked working directory is unknown or already outside the
boundary, while the same command inside the boundary stays allowed.
- `echo $(git -C <outside> reset --hard)` was folded into an opaque token.
`$(…)` and backtick bodies are extracted from the raw segment and analysed
as nested commands; `$((…))` is stepped over as arithmetic and an
unterminated substitution is denied.
The guard also covers the `monitor` tool, which spawns its `command` through
the same shell with the same `directory` argument and was previously
short-circuited to allow by the child before the daemon ever saw it. The
shell-executing tool set is shared through acp-bridge so the child and the
daemon policy cannot drift apart.
* fix(serve): restore the shell-wrapper analysis return shape
The unrecognized-shell-wrapper branch of `analyzeRun` still returned the
pre-`assignmentsOnly` `other` shape, so `tsc --build` failed on the union.
Every CI job that installs dependencies runs that build, so all three went
red on it.
* fix(serve): close the remaining daemon Git guard front-end gaps
Round-4 review findings, each reproduced against the real guard before the
fix and pinned by a regression test with an in-boundary control.
- An unrecognized program word only failed closed on Git-flag markers, so a
`cd` inside its quoted payload slipped through (`su -c 'cd <outside> && git
reset --hard'`, the same through `xargs … sh -c`). `cd`/`pushd` now count as
relocation markers in that scan.
- The Git word was matched case-sensitively while program classification
lowercases, so `nice GIT reset --hard` escaped on a case-insensitive
filesystem.
- A program word the daemon cannot read at all (`cd <outside> && $CMD git
reset --hard`, also behind `command`) skipped the containment check that
unrecognized program words get.
- `export GIT_DIR` with no `=` exported an earlier shell-local assignment
invisibly; shell-local GIT_* assignments are now tracked and promoted on a
name-only export. `+=` appends and `set -o $OPT` are recorded as unresolved.
- `eval` propagated only its cwd, so `eval 'export GIT_WORK_TREE=<outside>'`
lost the export it performed in the current shell.
- `--shallow-file` and `--attr-source` were not modelled as value-taking, so
their value was read as the subcommand — which ends option parsing and hid
every relocation after it.
- The command-executing config set missed `core.sshCommand`,
`diff.<driver>.textconv`/`command`, `merge.<driver>.driver`,
`sequence.editor`, `gpg.program`, `pager.*`, `core.askPass` and
`uploadpack.packObjectsHook`, and matched case-sensitively although Git
config keys are not.
- `env -S` accepted a dynamic payload as literal text and ignored the fused
`env -S'cmd'` form.
Docs: the Non-goals list claimed `CoreToolScheduler` was untouched although
this PR adds the scheduler-owned `sessionId` to the guard context; the
protocol capability row and the user guide still described the guard as
`run_shell_command`-only and mis-stated the denial-message prefixes; and the
relocation-never-revoked over-approximation (`unset GIT_DIR` does not clear a
recorded relocation) is now stated in Limitations.
* fix(serve): resolve the repository Git discovers, not just the directory
Two more escapes from the round-4 review, both reproduced with a real shell
and git 2.47.3 before the fix.
- A `.git` gitfile inside the boundary redirects Git to an outside
repository: `git -C <inside>/decoy commit` moved the outside repo's HEAD
while the directory itself passed containment. A `cwd` target now resolves
the first `.git` between it and the boundary through the same
`resolveGitDirRepository` path `--git-dir` targets use, which keeps a
linked-worktree session working because its own gitfile resolves back to
that worktree's checkout — pinned by a test that runs a session whose `.git`
points at an outside admin directory.
- `cd -P <symlink>/..` lands the shell in the parent of the symlink's real
target, which a lexical resolve places back inside the boundary. A `-P` cd
whose target contains `..` now drops the tracked directory. The default
logical form is unchanged and still allowed, because bash resolves it
against the logical path and really does stay inside.
* fix(serve): match chdir, brace-expansion and here-string semantics
Three more escapes from the round-4 review, each reproduced against the real
guard and then confirmed with a real shell and git 2.47.3.
- `git -C <symlink>/.. reset --hard` escaped: git's `-C` reaches the kernel as
a chdir, which resolves each component's symlinks, while the guard collapsed
`..` lexically and landed back inside the boundary. `-C`, `env -C`,
`sudo -D` and `cd -P` now resolve physically, component by component; bash's
default `cd` stays lexical because that is what the shell itself does.
- `git {-C,<outside>} reset --hard` escaped: brace expansion happens after
this parse, so the tokens git receives were never the tokens the guard saw.
A brace-expansion token now marks the invocation unresolved.
- `sh <<< 'git -C <outside> reset --hard'` escaped: the tokenizer dropped
redirect operands, and a here-string carries its whole payload in the
command line. Redirect operands stay in the run, so the here-string is
scanned like any other token; ordinary `>`/`2>` targets are inert text and
a regression test keeps them allowed.
Checked and not reproduced, so left alone: `describe --dirty` did not rewrite
the target index, `GIT_OBJECT_DIRECTORY=<outside>` did not write objects
there, and `bash -o allexport -c '…'` cannot export into the parent shell
because the payload runs in a subprocess.
* fix(serve): treat relocated git describe as a target-repo write
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* docs(serve): state what git describe actually rewrites
Keeping `describe` out of the relocated read-only set is right, but the
reason as written does not match git 2.47.3. Measured against a real
repository with a stale stat cache (mtime-only touch), `describe --dirty`,
`--broken` and `--always --dirty` rewrite the target repository's
`.git/index`, while a plain `describe`, `--tags` and `--always` leave it
untouched. The subcommand still belongs outside the set — the flag is one
token away from any describe a model writes — so only the comments and the
design doc change.
* test(serve): cover the provider-attached marker with a real handshake
The only assertion on the provider marker was the negative case, so a break
in the attached path — the marker comparison or the conditional child-env
spread — would have gone unnoticed. This drives a loopback provider through
the real `/v1/handshake` and asserts the child env carries the attached
marker alongside the plumbing one. Verified load-bearing: forcing the marker
to `undefined` fails it.
* fix(serve): close the round-3 shell and repository-discovery gaps
Every payload below was reproduced against the real guard first; the two
that turn on git's own behaviour were measured with git 2.47.3.
Repository discovery
- `ls-files` executes the target repository's `core.fsmonitor` — the exact
property that removed `status` — so it leaves the relocated read-only set.
Measured: `git -C <outside> ls-files` runs the hook; `rev-parse` and
`cat-file` remain side-effect free.
- The discovery check was tied to a *cwd* relocation, so a `--work-tree`-only
or bare relocation skipped it. Git discovers its repository from the cwd
whenever no `--git-dir` names one, so the check now runs on that basis, and
an unrecognized program (`cd sub && nice git branch`) gets it too. A linked
worktree whose own gitfile points at an outside admin directory stays
allowed — pinned from both sides.
Shell front-end
- `eval > /dev/null '…'` swallowed the redirection into its payload and lost
the command. Redirect operands (and an `N>` descriptor prefix) are now
flagged: still scanned for markers, never joined into argv.
- `cd` glued to a control operator (`true;cd <outside>`) was not a marker.
- Letters after `c` in a short bundle are more flags, not a fused payload:
`bash -cx 'cd <outside> && …'` and `sh -co ignoreeof '…'` took their real
payload from a later argv entry the guard never read.
- `( … )` is now scoped like a subshell: `(cd <outside>); git commit` is
allowed again, while `(cd <outside> && git reset --hard)` still denies.
- `sudo -R <rootfs>`/`--chroot=` and a `PATH=`/`GIT_EXEC_PATH=` assignment
make every path the daemon resolves meaningless, so they fail closed.
- `env --unset=NAME`, `-uNAME` and `--split-string=` in their attached forms
no longer read as unrecognized options, which was denying decidable
commands.
- Values assigned earlier in the same command are substituted before the
dynamic-program check (`X=git; Y='-C <outside> …'; $X $Y`), `eval` carries
its shell locals back out, and `export $NAME` fails closed.
- A command that relinks a path (`ln`, `mv`) invalidates containment proved
afterwards: `ln -s <outside> bait && git -C bait reset --hard` is checked
while `bait` is still the original directory.
- `resolvePhysicalPath` treated `\` as a separator on POSIX, where it is an
ordinary filename character.
- `gpg.<format>.program` and `core.hooksPath` join the command-executing
config keys.
* fix(serve): scope the relink invalidation to path-resolving Git runs
The rule I added a commit ago denied every Git run that followed an `ln` or
`mv` in the same command, which takes out `mv old new && git add -A` — as
ordinary as it gets. The invalidation only makes sense for an invocation that
resolves a path, so it now requires a relocation target or a shifted cwd:
`ln -s <outside> bait && git -C bait reset --hard` still denies, while
staging renamed files does not.
* fix(serve): rebuild the relink defense and close the round-4 gaps
Reproduced against the real guard first; the two environment claims were
measured with git 2.47.3.
Fixing my own two previous commits
- The relink invalidation was both too wide and too narrow. It now records
which paths a run may have re-pointed instead of setting one flag: a
relinked `.git` invalidates repository discovery for every later command
(`ln -s <outside>/.git .git && git status` mutated the outside repo), while
a relinked directory only affects a run that resolves that very path, so
`mv old new && git add -A` stays allowed. The scan no longer keys on
`run[0]`, which `env ln …`, `X=1 ln …` and `nice ln …` walked straight past,
and `cp -s` joins `ln`/`mv`.
- Flagging redirect operands (the here-string fix) left `consumeShellWrapper`
reading one as the `-c` payload: `sh -c > /dev/null 'git -C <outside> …'`
dropped the real payload from analysis.
- Leaving a subshell rolled back only the tracked cwd, so exports and
shell locals made inside `( … )` kept denying later commands.
- Shell-local reconstruction was last-assignment-wins, losing `X+=` appends.
Repository discovery
- A non-relocated Git command never reached discovery, so a planted `.git`
gitfile at the session root redirected plain `git commit` outside. Discovery
now runs for those too; a session bound below its repository is unaffected
because the walk stops at the boundary, and a linked worktree still resolves
back to its own checkout — both pinned.
Environment and parsing
- `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_CONFIG*`
and `SHELLOPTS` mark the invocation unresolved. Measured:
`GIT_OBJECT_DIRECTORY=<outside>/.git/objects git add` writes the blob there,
and `GIT_CONFIG_GLOBAL=<outside>/cfg` makes git read that config.
- `$'…'` is ANSI-C quoting where a backslash escapes, so `$'a\'b'` no longer
leaves the substitution scanner a quote out of phase — which had hidden a
following `$(git -C <outside> …)` from every analysis pass.
Over-denial
- A program with its own `-C` (`grep -C 5 git`, `tar -C dir`) no longer reads
as a Git relocation.
* fix(serve): carry relink and shell state across nested scopes
Round-5 findings, all reproduced against the real guard. Four of the five
are in the machinery I added over the last two commits.
- Relink state was local to one `evaluateCommandWithCwd` call, so a symlink
created inside `sh -c '…'`, `eval '…'` or a `$(…)` body was invisible to the
parent, and a relink made in the parent was invisible to a nested Git run.
It is now shared by reference in both directions.
- Nothing consulted it for an unrecognized or dynamic program word, so
`… && nice git add -A` after a relinked `.git` was allowed, and
`X=ln; $X -s <outside>/.git .git` recorded nothing at all. A dynamic program
word may itself be `ln`, so its operands are recorded too.
- `<(…)` opens a paren that shell-quote reports without one, while its `)`
still arrives — so `(cd <outside>; <(true); git reset --hard)` popped the
subshell early and lost the `cd`. My round-4 triage called this one "not
reproduced" because the probe used the top-level shape, which survives on
the `Math.max(0, …)` clamp; the nested shape does not.
- `eval` ran with an empty shell-local map, so
`GIT_DIR=<outside>/meta; eval 'export GIT_DIR'` promoted invisibly. Locals
now flow into `eval` and into substitution subshells (by copy, since their
own assignments die with them); a `sh -c` subprocess still gets none.
- Any unreadable word in a shell wrapper's argv can be the `-c` that carries
the command, so `bash $A "$P"` is undecidable rather than absent.
* fix(daemon): evaluate the guard against the directory the tool runs in
A sub-agent pinned to a worktree — `working_dir`, or `isolation`, which sets
`ov.targetDir` on the child Config — executes at `config.getTargetDir()` while
still reporting the parent session id. The guard context carried only that
session id, so the daemon evaluated every such call against the parent
session's `effectiveCwd`: a plain `git commit` from an isolated sub-agent was
judged in-boundary and allowed while running somewhere else entirely, and a
relative `-C` resolved against a directory the command would never be in.
The invocation now carries the directory it will run in, from
`config.getTargetDir()` through the child guard to the daemon. It is
explicitly 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 — worktrees live under
`GitWorktreeService.getWorktreesDir(<session id>)`, and the session id is
already validated by `ownsSession`. 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 — reaching back into the parent is now denied, which is the escape
this was reported for.
* fix(daemon): reach the real guard path and close the round-6 escapes
The headline finding is that the previous round's fix never reached the code
it was written for. `Session.runTool` — the path daemon ACP sessions actually
execute tools through — built the guard context without `sessionId` and
without `cwd`, so both the session fallback this PR added and the execution
directory added last round were unreachable there. Both are now supplied,
exactly as `CoreToolScheduler` does.
Escapes, each reproduced against the real guard first:
- `GIT_SSH_COMMAND`, `GIT_EDITOR`, `GIT_SEQUENCE_EDITOR`, `GIT_ASKPASS`,
`GIT_PAGER`, `GIT_EXTERNAL_DIFF`, `GIT_SSH` are programs git executes, and
`GIT_CONFIG_PARAMETERS`/`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_<n>` are its
environment config channel — none were modelled.
- `diff.external`, `core.gitProxy`, `interactive.diffFilter`,
`credential.<url>.helper`, `remote.<name>.uploadpack`/`receivepack`/`proxy`,
`tar.<format>.command`, `browser.<tool>.cmd`, `web.browser`,
`help.browser`, `gc.recentObjectsHook` and `ssh.variant` join the
command-executing config keys.
- An unrecognized wrapper laundered that config: `nice git -c alias.pwn='!…'`
never reached the git analysis. It is checked there too now.
- `find <outside> -execdir git reset --hard` relocates through the program's
own flag, leaving no marker.
- An archive decides where it writes, so `tar`/`unzip`/`cpio`/`rsync` make
their extraction directory suspect rather than their operands.
- `alias g='git reset --hard'; cd <outside>; g` and the function-definition
form both defer a body to wherever the bare word is later used.
Over-denials, all introduced by earlier rounds of this PR:
- `SHELLOPTS=errexit git status` (SHELLOPTS is bash's own options state — the
rationale I gave for listing it was simply wrong, and it never reproduced
as an escape), `env --ignore-environment`/`--null`/`--debug`,
`curl -C - …`, `env -iS 'cmd'`, `d=<inside>; cd $d; git status`, and
`set +a` turning allexport back off.
Also: the sub-agent worktree test created and recursively deleted a directory
under the user's global Qwen dir, keyed on a session id a real session could
own. It now uses a process-unique id and cleans up in `finally`.
* fix(daemon): contain a sub-agent to an in-project agent worktree
`AgentTool` with `isolation: 'worktree'` provisions under
`<projectRoot>/.qwen/worktrees/`, which is inside the session — so the
acceptance rule's "inside the effective working directory" branch left the
boundary alone and one sub-agent could still reach into a sibling's worktree,
the very thing this PR is named for.
A reported directory that is a checkout root in its own right now becomes the
boundary wherever it lives, not only under the session-owned worktree tree.
An ordinary subdirectory resolves to the session's own repository and changes
nothing, which is what keeps `cd packages/cli && git commit` working.
Also drops the unread `entryCwd` parameter from `evaluateUnrecognizedRun`,
whose signature implied containment behaviour that function never had, and
covers the child-side `invocationCwd` forwarding with a direct test — it is
the only link between a pinned sub-agent's real execution directory and the
daemon's check.
* test(acp): assert the session identity and cwd the guard now receives
Adding `sessionId`/`cwd` to the guard context in `Session.runTool` changed
the shape these two assertions pin, and I ran the guard, acpAgent and serve
suites but not this one — CI caught what I should have.
* fix(daemon): record ordinary targets a dynamic relinker re-points
The dynamic-program branch resolved the operands of an unreadable program
word but only used them to raise the `.git` flag, so an ordinary target was
never added to the relink set: `X=ln; $X -s <outside> src && git -C src reset
--hard` validated `src` against what it pointed at before the same command
replaced it.
Ordinary operands are now recorded alongside the `.git` case. Verified
load-bearing: dropping the new line fails the added regression test and
nothing else.
* fix(daemon): close the round-7 escapes, verified with the reported payloads
The reviewers were right that my previous round's "denies as written" replies
were built on non-equivalent counter-probes: a `.git` inside the path, or a
literal `-C`, tripped an unrelated marker rule while the reported mechanism
went untouched. Re-run with each payload verbatim — against a path with no
Git word in it — five of them were allowed. All are fixed, and the new tests
use those exact payloads.
- `$'…'` is ANSI-C quoting only OUTSIDE double quotes, so the substitution in
`echo "$'$(GIT_DIR=<outside>/.git git reset --hard HEAD~1)'"` is live. Both
scanners now gate the skip on `!single && !double`.
- The export attribute sticks to the name: after `export GIT_DIR`, a LATER
assignment to it reaches the git subprocess. Name-only exports of a
relocation key are recorded so those assignments count as exported.
- Both sides of a pipe run in subshells, so a pipe-side `cd` must not move
the shell. Segments that are pipeline components restore the directory they
started with; the top-level separators are read with the same quoting rules
`splitCommands` uses, and any disagreement falls back to treating every
segment of a piped command as a component.
- A bare digit before a *spaced* redirect is a real argv word, not a file
descriptor, and nothing in the token stream distinguishes it — so it is
marked ambiguous and a payload built from it fails closed instead of
silently dropping the word.
- `-o`/`-O` before `c` in a short bundle does not cancel the `c`: bash still
executes it, taking the command from a later argv entry. The bundle is now
parsed for how many entries the value flags consume on either side.
- Rebuilding a command line out of separate argv words re-quotes anything
that would otherwise split, so a path with a space stays one word and a
`-C` value cannot shrink. `eval` keeps the verbatim join it needs, since it
re-parses its argument as shell text.
* fix(daemon): repair the round-7 patch and bound what this guard promises
Two halves.
First, the round-7 patch introduced seven defects of its own, six of them
reproduced here before fixing:
- the fd digit of `2>…` was eligible as a `-c` payload, because
`nextArgvIndex` skipped only redirect-flagged tokens;
- `o`/`O` letters after `c` in a bundle were counted by presence rather than
per letter, shifting the extracted payload left;
- `env --split-string=` still rebuilt its payload with the verbatim join
while both sibling branches had moved to the re-quoting one;
- the separator scan recorded no lone `&` and mistook `>|` for a pipe, and
its disagreement fallback scoped nothing instead of everything;
- the export-attribute set neither crossed `eval` nor rolled back with a
subshell;
- deferred alias and function bodies were keyed on `run[0]` rather than on
the program word, so any prefix hid them.
Second, and more important than any single rule: the docs now bound what
this control claims. It is reliable against Git relocation written in the
literal forms the design doc lists — the mis-targeted command it exists for
— and best-effort, not a boundary, against shell text written to defeat it.
Seven rounds of adversarial review support that framing rather than
contradict it: each round closed the reported bypasses and the next found
more, several inside the rules the previous round added. The gap is
structural — the guard reads command text before a shell interprets it — so
the honest fix is to move the decision off the text, deciding where a command
may write when it runs rather than predicting it beforehand. That is a
separate change with its own design, and this one should not grow into it by
accretion. Saying so plainly is itself a safety property: an operator who
believes the daemon cannot reach a sibling worktree would grant it more trust
than the mechanism earns.
* fix(daemon): close the round-9 critical forms an agent may actually emit
Scoped to the Critical findings that reproduced against the real guard with
the reviewer's payloads verbatim (Git-word-free path). Common shell forms,
not adversarial exotica; the parser-edge tail stays under the bounded promise
this PR now documents.
- `&>`/`&>>` is a redirect operator, no longer read as a background `&`.
- `function NAME { … }` (the keyword form, `()` optional) is recognised as a
definition.
- `git -c include.path=`/`includeIf.<cond>.path=` pull in a config file the
guard cannot read; it can carry a `core.worktree` redirect or executable
config, so it is treated as dangerous config and fails closed.
- `imap.tunnel`, `instaweb.httpd` join the command-executing config keys, and
`GIT_DIFFTOOL_EXTCMD` the executed-env keys.
- `GIT_DIR=… set -a` persists (a prefix assignment on the special builtin
`set`) and exports; that leading assignment is now carried, not dropped.
- alias/function recognition starts at the program word — past a leading
redirect (`2>/dev/null alias …`), keyword (`if …; then alias …`) or
assignment — and records every pair of a multi-alias statement.
- a heredoc body is stdin data, not commands: it is stripped before command
splitting so a body `cd` cannot launder the tracked directory.
- a function body that `splitCommands` cuts across segments is now captured
whole and replayed, so a `-C <outside>` inside it is seen, not just the
name.
Two round-9 Criticals are deliberately not "fixed" here: `cd <outside> &
git …` runs git in the parent shell at the in-boundary cwd, so allowing it is
correct; and an archive that plants a `.git` for a later path-less discovery
is a TOCTOU (the unpack happens after the decision), left to the same
limitation as the symlink race rather than denying every `tar && git commit`.
* fix(daemon): replay an alias with the args its invocation appends
`alias gg='git'; gg -C <outside> reset --hard` ran `git -C <outside> reset
--hard`, but the guard replayed only the recorded body (`git`) and dropped
the appended argv, so the relocation was invisible and the command was
allowed. An alias now replays as `body + trailing args`, so the invocation's
own `-C <outside>` is seen. A function is unchanged: its args arrive through
`$@` inside the body, which the recorded body already carries.
Verified with in-boundary controls (`alias gg='git'; gg status`,
`alias gg='git commit'; gg -m x`) staying allowed.
* fix(daemon): carry a function/alias body's cwd and exports to the caller
A shell function and an alias both run in the current shell, so a `cd` or an
export inside the recorded body survives the call. The replay discarded
`nested.cwdAfter` and the exported state, so `f() { cd <outside>; }; f; git
reset --hard` kept the old in-boundary tracked cwd and the path-free git
mutation was judged inside while the real shell had moved outside. The nested
cwd, exports and shell-locals now propagate back, exactly as an `eval`
payload already does. Distinct from the earlier case where git appeared in
the body itself.
Verified: `f() { cd nested; }; f; git status` and `f() { echo hi; }; f; git
commit` stay allowed.
* fix(daemon): inherit the caller's allexport into a same-shell body
A body run in the current shell — `eval`, an alias, or a function — inherits
the enclosing `set -a`, so a plain `GIT_WORK_TREE=<outside>` assignment there
is exported to the following git. The nested evaluation initialized
`allExport` to false instead of the caller's value, so with allexport on the
assignment was treated as shell-local, no relocation was recorded, and the
path-free mutation was allowed. `allExport` now flows into the same-shell
scopes (and back out). An unexported assignment stays shell-local and is
still ignored.
* fix(daemon): complete the same-shell state model for bodies and substitutions
Three related gaps, all in the shell-state sharing this PR has been building:
- A command substitution inherits the enclosing `set -a` but did not carry
it in, so `set -a; echo $(GIT_WORK_TREE=<outside>; git reset --hard)` was
allowed. The substitution scope now inherits allexport (by copy — its own
changes still die with the subshell).
- A same-shell body could turn allexport on but not off: the merge-back only
handled the truthy result, so `set -a; f() { set +a; }; f;
GIT_WORK_TREE=<outside>; git status` denied even though bash leaves the
later assignment unexported. Both the function and eval merges now
propagate the boolean in both directions.
- Recorded function/alias definitions were local to each evaluator, so a
body could not see a function the caller had already defined:
`inner() { cd <outside>; }; outer() { inner; }; outer; git reset --hard`
ran `inner` as an opaque command and lost the cwd. The definition tables
are now shared by reference with same-shell bodies (`eval`, function/alias
replay) and copied for substitution subshells.
* fix(daemon): resolve shadowing and exported functions; isolate pipe subshells
Four related function-model findings, all reproduced first:
- A recorded function shadows the git program or a builtin, and bash resolves
it before either — `git() { cd <outside>; command git status; }; git` and
`cd() { command cd <outside>; }; cd nested; git reset --hard` were allowed
because `analyzeRun` classified `git`/`cd` before the body lookup. Recorded
bodies are now resolved before program/builtin dispatch, via a shared
`invokeDefinedBody`; `command`/`builtin` name a different program word and
bypass it as bash does.
- A function/alias redefinition in a pipeline component runs in a subshell and
must not persist, but sharing `definedBodies` (previous commit) let it leak:
`f() { cd <outside>; }; f() { :; } | cat; f` was modelled as a no-op. Pipe
and background components no longer record a definition into the parent, and
their cwd/allexport are already rolled back.
- `export -f f` makes a function visible inside a `bash -c` subprocess, unlike
an ordinary function. Those names are tracked and the subprocess payload is
seeded with only the exported subset; an unexported function stays invisible
to `bash -c`.
* fix(daemon): close the interlocking gaps in my function-model work
Four gaps in the recorded-body machinery the last commits built, all
reproduced first:
- `invokeDefinedBody` did not carry `exportedFunctions` into the replayed
body, so a `export -f`'d function invoked from another function's body was
invisible to its `bash -c`.
- A prefix assignment on the invocation (`GIT_WORK_TREE=<outside> gg`) was
dropped, because the defined-body gate skips `analyzeRun`; the run's leading
assignments are now applied to the body as ambient relocations.
- The pipe-component rollback restored cwd/allexport/definitions but leaked
the subshell's exports, export attributes and shell-locals into the parent;
all of them now roll back.
* fix(daemon): deny relocations disguised by a redirection
Two reachable escapes with ordinary (non-adversarial) commands:
- `cd <outside> >&2; git reset --hard` — a stderr redirect on the `cd`, whose
`&` was read as a background separator so the tracked cwd was rewound while
the real shell had moved outside. `>&`/`<&` file-descriptor redirects are no
longer treated as backgrounding.
- `git 2>/dev/null -C <outside> reset --hard` — the redirect operand among the
git args ended `readGitInvocation`'s option parsing before the `-C`, so the
relocation was invisible. It now skips redirect/fd-flagged tokens. Ordinary
trailing redirects (`git status 2>/dev/null`) stay allowed.
* fix(daemon): deny relocation hidden by a leading redirect or a background &
Two more reachable escapes with ordinary commands, triaged out of the R8
batch (the rest of which is Windows paths, docs wording, test coverage or
adversarial parser edges under the documented best-effort promise):
- `2>/dev/null gg` where `gg` is a recorded alias/function ran the body in
bash, but `readProgramWord` returned the fd token instead of the program
word, so the invocation was not resolved. It now skips redirect/fd operands.
- `true & cd <outside>; git reset --hard` — only the segment a `&` follows is
backgrounded (a subshell); the segment after it runs in the foreground, so
its `cd` persists. The pipe-component test now treats a segment as a
subshell only when it precedes `&`, while both sides of a `|` still are.
* fix(daemon): don't let a harmless or removed shadow mask a relocation
Two escapes where the guard replayed a recorded body while the real
interpreter ran a relocating external git, both reproduced first:
- `export -f` functions were seeded into every subprocess shell, but only
bash imports them. `git() { :; }; export -f git; dash -c "git -C <outside>
reset --hard"` was allowed because the guard replayed the harmless `:` for
dash, while real dash resolves the external git and relocates. Exported
functions are now seeded only for a bash child.
- `definedBodies`/`gitShapedNames`/`exportedFunctions` only ever gained
entries, so a removed shadow still replayed. `unset -f`/`unalias` now drop
the function/alias (and `-a` clears all), and `export -n -f` clears the
export attribute — `git() { :; }; unset -f git; git -C <outside> reset
--hard` and the `unalias git` form now deny, while a live compatible shadow
(bash-imported function, an alias still in effect) stays modelled.
* fix(daemon): drop exported functions when env clears the child environment
Two follow-ups to the per-interpreter shadow modelling:
- The `unalias`/`unset -f` removal branch compared `removalProgram` against
`'unalias'` after `isFunctions` had already narrowed it to `'unset'`, which
`tsc --build` rejects as a no-overlap comparison (TS2367). `isFunctions`
already covers every `unalias` case, so drop the redundant term.
- `env -i` / `-` / `--ignore-environment` start the child from an empty
environment, so a bash `-c` payload no longer inherits the parent's
`export -f` functions. The env wrapper now records that the environment was
cleared and the bash payload stops importing exported functions when it was,
so `git() { :; }; export -f git; env -i bash -c "git -C <outside> reset
--hard"` denies while `env -i bash -c "... rev-parse HEAD"` and an
un-cleared `env FOO=bar bash -c` stay allowed. Regressions added.
* test(daemon): pin the sh-wrapper fail-closed contract and document it
`sh` is bash on macOS and dash elsewhere, so its `export -f` import behaviour
cannot be decided from the basename. The guard already treats `sh` as
non-importing — it never replays an exported shadow for `sh -c`, because doing
so on a dash-backed `sh` would recreate the relocation escape. Pin that
fail-closed contract with a regression (`export -f git; sh -c "git -C
<outside> reset --hard"` denies) so a future change that widens the bash gate
to include `sh` breaks a test, and record the deliberate over-denial in the
design doc's non-goals.
* fix(daemon): model shell-definition removal the way the real shell does
The removal-builtin handling added earlier was too broad and dropped live
relocating shadows, and the exported-function set was shared into subprocess
scopes by reference. Each escape below was reproduced against the guard first.
- `unset` has no `-a` option and `unalias -a` clears only aliases, yet both
were treated as "clear every definition", so `pwn(){ git -C <outside> reset
--hard; }; unset -a; pwn` (and the `unalias -a` form) wiped the function and
ran it unrecognized. Removal is now kind-aware: `unalias` touches only
aliases, `unset -f`/bare `unset` only functions.
- A function shadowing `unset`/`unalias`/`export` runs instead of the builtin,
so the removal never happens; the branch now fires only when the name is not
itself a recorded shadow, and otherwise falls through to replay the shadow.
- The bash `-c` subprocess and command-substitution scopes received the
parent's `exportedFunctions` set by reference (or, for `$( )`, not at all),
so a child `unset -f` retracted the parent's export and a substitution saw
none. Both now take a copy.
Adds regressions for each and keeps the existing shadow/removal cases green.
* fix(daemon): bare unset keeps the function and env -u strips exported functions
Two more escapes doudouOUC reproduced in the removal model, both verified
against the guard first.
- A bare `unset NAME` unsets a same-name variable first and removes the
function only when none exists. This evaluator tracks no ordinary variables,
so it cannot tell the two apart; treating every bare `unset NAME` as a
function removal dropped a live relocating shadow
(`pwn(){ git -C <outside> …; }; pwn=1; unset pwn; pwn`). Only `unset -f`
now removes a function; a bare `unset` leaves it, the safe over-deny choice.
- A bash `export -f foo` travels as a `BASH_FUNC_foo%%` environment entry, so
`env -u BASH_FUNC_foo%%` (and the `--unset=` / attached forms) strips it
before `bash -c` and the child runs the real program. The env wrapper now
records unset keys in PrefixState and the payload seeding drops functions
whose `BASH_FUNC_*` entry was removed, so a stripped harmless `git` shadow no
longer masks the real relocation.
Adds regressions for both; keeps `unset -f`, unrelated `env -u`, and live
shadows behaving as before.
* fix(daemon): fail closed when a removal builtin could retract a tracked shadow
Modelling exactly which definition an `unset`/`unalias`/`export -n` removes is
general shell semantics this guard does not attempt: a bare `unset NAME` drops
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. Every attempt to model
these precisely left a live relocating shadow reachable through a form it did
not cover.
Collapse the whole removal path to one rule: when a removal references a name
tracked as a shadow (a defined body, a git-shaped name, or an exported
function) — or clears all while any shadow exists — fail closed. This denies
the previously-allowed `git(){ :; }; unset git; git -C <outside> …`,
`export -nf`, `command unset -f`, `enable -n unset; unset -f`, and
`( unset -f git ); git` forms, while a removal of an untracked name and every
live-shadow replay behave exactly as before. Removes the earlier kind-aware
bookkeeping the same escapes kept slipping through.
* fix(daemon): skip leading redirections before the removal-builtin prefix scan
The `command`/`builtin` strip in the shadow-removal guard started at raw token
zero, but bash strips redirections from argv. A leading `2>/dev/null` before
`command unset -f <tracked-function>` left the scan looking at the redirect
operand, so `command` was never consumed, `readProgramWord` returned `command`
rather than `unset`, the removal went unrecorded, and the stale harmless
function masked the later external Git relocation.
Skip redirect/fd operands before and between the `command`/`builtin` prefixes,
the same normalization `readProgramWord` applies. Adds the leading-redirection
variant to the command-prefix regression.
* fix(daemon): replay a shadowed removal builtin and drop unset variables
Two more escapes doudouOUC reproduced in the fail-closed removal rule.
- The rule's early `continue` fired even when `unset`/`unalias`/`export` was
itself a recorded function and the operands named only untracked state, so a
shadowing `unset(){ git -C <outside> …; }; unset other` was classified as a
harmless builtin removal and never reached the shadow dispatch that replays
the relocating body. The branch now runs only when the program is not a
shadowed function (a `command`/`builtin` prefix still forces the builtin).
- The removal never dropped tracked variables, so `A=nested; unset A; cd $A`
kept expanding the stale in-bounds value while bash's `unset A` leaves `$A`
empty and `cd $A` lands at $HOME. `unset NAME`/`unset -v NAME` now deletes
the shell-local, turning the later `$A` into an unresolved reference the cd
fails closed on. `unset -f` is functions-only and leaves variables intact.
Adds regressions for both.
* fix(daemon): honor shadowed command/builtin prefixes and PATH-based relocation
Two escapes surfaced by the round-11 review, both reproduced against the guard.
- The shadow-removal prefix scan trusted a literal `command`/`builtin` word to
force the real builtin, but bash resolves a function of that name first. A
`command(){ git -C <outside> …; }; command unset other` therefore
early-continued as a harmless builtin removal and never replayed the
relocating body. The prefix loop now stops when the prefix word is itself a
recorded shadow, leaving it for the normal shadow dispatch.
- The unrecognized-program marker scans covered GIT_DIR/GIT_WORK_TREE-family
assignments but not GIT_PROGRAM_ENV_KEYS (`PATH`/`GIT_EXEC_PATH`), which
decide which git binary runs. The direct `PATH=/evil git …` was denied while
`find … -exec sh -c 'PATH=/evil git …'` slipped through. Both marker scans
now include those keys, and they remain gated on a co-present git word so an
ordinary `PATH=… make` is unaffected.
Adds regressions for the shadowed prefixes and the wrapped PATH/GIT_EXEC_PATH
forms.
* fix(daemon): catch delimiter-glued relocations and redirect-decoy prefix drops
Two escapes surfaced by the round-12 review, both reproduced against the guard.
- The env-assignment arm of both text marker patterns required `(^|\s)` before
the key, while the sibling `cd`/`pushd` arm already allowed `;&|(){}`
boundaries. A relocation glued to a delimiter inside a quoted wrapper payload
(`su -c 'true;GIT_DIR=<outside> git reset --hard'`) therefore evaded the
unrecognized-program backstop. Both arms now share the same boundary class.
- `invokeDefinedBody` located the invoked name with a raw `findIndex` that
also matched redirect operands, so a decoy `> g` whose target equals the
function name truncated the prefix-assignment scan to empty and dropped the
call's `GIT_DIR=` relocation. The lookup now skips redirect/fd operands like
`readProgramWord` does.
Adds regressions for the delimiter-glued and redirect-decoy forms.
* fix(daemon): deny trailer/man/sendemail command-executing config keys
The dangerous-config model already denies `git -c <key>=<command>` for the
command-executing config families, but omitted three documented ones:
`trailer.<token>.command`, `man.<tool>.cmd`, and
`sendemail.(sendmailcmd|tocmd|cccmd)`. `git -c trailer.sign.command='…'
interpret-trailers` (and the man/sendemail forms) ran the configured shell
command while the guard allowed it. Adds the three patterns and regressions.
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
16 KiB
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 <path>andgit -C<path>git --work-tree <path>andgit --work-tree=<path>git --git-dir <path>andgit --git-dir=<path>- leading
GIT_DIR,GIT_WORK_TREE,GIT_COMMON_DIR, orGIT_INDEX_FILEassignments - the same assignments made through
export/declare/typeset/readonly/local(or plain assignments underset -a), which stay in the environment of every later command in the same chain rather than only their own run. A name-onlyexport GIT_DIRexports 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/--chdirandsudo -D/--chdir cd,pushd, orpopdbuiltins 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 <duration>, 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 <outside> && 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 <outside> && 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(<session id>)). 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:
- its target is outside the session's effective working directory after canonical path resolution;
- 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 <outside> cat-file --textconv --path=f HEAD:f
executes its diff.<driver>.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.<driver>.command, diff.<driver>.textconv,
difftool.*, filter.*, gpg.program, merge.<driver>.driver,
mergetool.*, pager.*, sequence.editor, and
uploadpack.packObjectsHook, core.hooksPath and gpg.<format>.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=<outside>/.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
(<repo>/.git/worktrees/<name>) 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, acdthat 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, orevaluatePermissionFlow.CoreToolSchedulerandspeculation.tsgain one additive field — the scheduler-ownedsessionIdon 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.shorsourceare not read, and variable values are not tracked across commands. - No resolution of the
shimplementation: onlybashimportsexport -ffunctions, butshis bash on macOS and dash elsewhere. The basename cannot say which, so the guard never replays an exported shadow forsh -c— importing it on a dash-backedshwould recreate the escape. It fails closed, over-denying the bash-backed case (a false positive, not a bypass).env -i/-/--ignore-environmentlikewise 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_DIRandenv -u GIT_DIRlater 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:
splitCommandshas 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.