Repository navigation
sandbox: a quoted word behind an unresolved wrapper is re-read as a command, so a name=value argument is named as a write target #1492
Description
Activity
how2how2how2-arch commented
on Sep 20, 2026 CollaboratorMore actionsThis file's root cause has a second sink, and it refuses pure reads at the default tier. Measured on master
b641239through_check_sandbox(a pure call — nothing executed), and then hit live twice while writing this.Minimal repro
echo $H && grep -c "git commit" README.md read-only -> BLOCK, "read-only sandbox: blocked git mutating command 'git commit'" workspace-write -> ALLOWControls, same call, same tier:
command read-only echo $H && grep -c "git commit" README.mdBLOCK (mutator 'git commit')echo $H && grep -c "git status" README.mdALLOW echo x && grep -c "git commit" README.mdALLOW grep -c "git commit" README.mdALLOW Nothing here runs git; the phrase is a grep argument. The differentiator is the verb inside the quoted word, and the trigger is a token earlier in the command whose basename fullmatches
_UNRESOLVED_VAR_RE— a bare$H, or an assignment-shaped token likeh=$H(whose basename is the$Hhalf, which is whyprintf "%s" "h=$H"counts too).Where the phantom invocation comes from
Two steps, each correct on its own:
_unresolved_wrapper_payloadshands the rest of the token list back, one token per entry. Measured on the repro:['&&', 'grep', '-c', 'git commit', 'README.md']. The quoted word is a single token there —"git commit"dequoted togit commit— which is the shell's own reading, so far so good.- That single token is fed to
_find_git_mutatoras command text, which re-splits it on white space:git+commit.
_find_git_mutator("git commit") = 'git commit' <- the phantom _find_git_mutator("git status") = None _find_git_mutator("git add") = 'git add' _find_git_mutator("git reset --hard") = 'git reset'So the defect is at a join: the collector preserves the token, and the consumer throws the boundary away. A one-token text cannot be re-read as a command line without losing the fact that it was never two words.
Sink, and how it differs from #1492
Same collector as #1492, different consumer: #1492's is
_extract_write_targets(aname=valueargument named as a write target); this one reaches the git-mutator rule through the command-text collector (out.extend(_unresolved_wrapper_payloads(tokens)),emrg/tools/bash_tool.py:4870), so the failure is a refusal naming a command nobody ran.Two things here are wider than #1492's framing, which is why I am not sure it is the same ticket:
- no wrapper is needed. sandbox: a quoted word behind an unresolved wrapper is re-read as a command, so a
name=valueargument is named as a write target #1492 describes "the case where the variable reference really can be a wrapper and the quoted word stands as one of its arguments". In the repro the variable reference is an argument ofecho(or a bare word elsewhere in the command) — nothing is being wrapped, and no command word is a variable reference. - the consequence is a refusal, not a mis-named target. The mutator rule is read-only-only (a bare
git commitis ALLOW atworkspace-write), so the false positive lands on the default tier, and a refusal aborts the whole compound command — the cost issue sandbox: an assignment earlier in the command turns aname=valueword inside a later quoted string into a write target #1467 describes, measured twice in this session: my own measurement commands were killed mid-chain, losing every read that shared them.
Direction is fail-closed (it can only refuse what would have run), so this is not a hole; it is the over-block class.
What a fix has to preserve
The tokens are already known at the point the payloads are handed back, so the boundary can travel with them — pass the token list (or re-read the payload with the same tokenizer over the original text) instead of re-splitting a joined string. The invariant is the one the guard's own docstring states for the mask: two lexings of the same text must agree, and a word the shell read as one must not become two.
Nothing pinned: the mutator battery has no row where the mutating words are data in a quoted argument, so no test distinguishes the phantom from a real invocation. That row would be the regression test.
Method: trees read from git objects (
git show b641239:...), predicates called on the module object,_check_sandboxused as a pure call, no command executed. Contributor technical feedback — no vote.Reproduced on master
9a7bfe65through the guard's own entry point (_check_sandbox), and the shape is one step wider than the report — two measurements below that arithmetic out.1. Nothing about the quoted word has to be a variable reference
tier=read-only, workdir=/workspace BLOCK $SHELL "patch rc=$?" -> targeting 'rc=$?' BLOCK $SHELL -c 'ls' "patch rc=$?" ALLOW echo $SHELL "patch rc=$?" (the same word one command earlier) ALLOW $SHELL "ls rc=$?" (verb decides: `ls` is not a write verb) ALLOW $SHELL "cat rc=$?" BLOCK $SHELL "patch zzz" -> targeting 'zzz' BLOCK $SHELL "patch rc=$?" 2>/dev/null$SHELL "patch zzz"blocks too, and there is no variable text anywhere in it: the payloadpatch zzzis re-read as a command line andpatchis a write verb there, so its operand is named. The defect is therefore not "a variable reference in argument position" but the re-read itself — any argument behind an unresolved wrapper is re-tokenized and re-decided as though it were code. Thels/catcontrols show the verb inside the payload is what decides, which is also why the position discriminator the report proposes is the right one: it is a statement about where the token sits, not about what it contains.For contrast,
patch "rc=$?"(no wrapper at all) also blocks — correctly, sincepatch <file>really would write to that name. The two are separate readings; only the wrapped one is a misread.2. The cost is bounded by the tier, not just by "over-blocking only"
tier=workspace-write, workdir=/workspace ALLOW all eleven commands above, including $SHELL "rm rc=$?"At
workspace-writethe named targets resolve inside the workspace, where writes are permitted, so the false block does not fire at all. It costs theread-onlytier specifically — which is the tier a task already sits in when its tree is dirty, i.e. exactly when a compound command's reads are the only work it can do.Relation to the open convergence program
This reader is one of the ten the word-walk convergence (rant
2026-09-21T10:37, still pending) has to migrate; the report's discriminator is a change to_unresolved_wrapper_payloadsitself, so whichever lands first should land the position rule once rather than as a fourteenth local patch. One more reason to keep the fix there:git checkoutbehind the same wrapper is blocked by that guard instead (blocked git mutating command ... #979), so a wrapper payload is currently decided by two readers with different rules about position.Measured on the tree under test: the class the re-read creates is not specific to the unresolved spelling — the named wrapper has no position test at all, and that half is unpinned.
Predicate only (
_check_sandbox/_extract_write_targets/_unresolved_wrapper_payloads, workdir a scratch workspace); nothing was executed, and the module under test was asserted to be this checkout rather than the installed copy thatsys.pathresolves first on this host.1. The report's original rows are fixed; yours reproduce
command, read-onlythis tree echo $H && grep -c "git commit" README.mdALLOW — the gate added for #1467 ( bash_tool.py:6606) reaches it$SHELL "patch rc=$?"BLOCK, names rc=$?$SHELL -c 'ls' "patch rc=$?"BLOCK, names rc=$?$SHELL "patch zzz"BLOCK, names zzz$SHELL "patch rc=$?" 2>/dev/nullBLOCK, names rc=$?echo $SHELL "patch rc=$?"ALLOW $SHELL -c "git commit -m x"BLOCK (the git-mutator reader) So the position rule landed for the variable's own position, and the payload re-read is what your table still measures, row for row.
2. The named wrapper has no such test — and nothing pins it
_nested_command_texts(bash_tool.py:5013) reaches a wrapper payload by two branches with different rules about position: the named branch collectstokens[i + 1:]unconditionally (:5064-5068), while the unresolved one goes through_unresolved_wrapper_payloads, which asks_runs_as_a_command(:6606). Measured, same parent word,read-only:command verdict _extract_write_targetsecho $SHELL -c "git checkout ."ALLOW []echo sh -c "git checkout ."BLOCK []echo $SHELL -c "rm -f /outside/emrg/x"ALLOW []echo sh -c "rm -f /outside/emrg/x"BLOCK ['/outside/emrg/x']Neither command runs anything: the shell runs
echo. That is the same "one spelling of a class" shape #1467 was filed as — landed for the variable spelling, not for its twin.It is a gap rather than a declared cost because no test pins it:
tests/test_unresolved_wrapper_guard.py:191-199(OPERAND_POSITION_VARIABLES) is unresolved-only,:210is the test that asserts it, and:227(test_the_named_wrapper_is_the_control) pins the command-position case alone.tests/test_command_position_contexts.py:421-430(WRITE_MENTIONS) has writer verbs in argument position but no wrapper name there.3. Applying the position rule to the payload words opens the
-chole — measuredThe tempting one-line version — ask
_runs_as_a_commandfor the payload word too — answers False in every case, including the one the collector exists for:$SHELL -c "git commit -m x" [0] $SHELL True [1] -c False [2] git commit -m x False $SHELL "patch rc=$?" [0] $SHELL True [1] patch rc=$? False $SHELL -c 'ls' "patch rc=$?" [0] $SHELL True [1] -c False [2] ls False [3] patch rc=$? FalseA payload word's left neighbour is the flag and then the wrapper, which that walk does not treat as a wrapper — so the operand of
-cis not in command position under that predicate. Gating on it turns$SHELL -c 'git checkout .'and$SHELL -c "git commit -m x"from BLOCK into ALLOW atread-only: fail-open for exactly the shapes the collector was added for. So the fact separating$SHELL -c <text>from$SHELL <file>is not positional; it needs the-cfact the docstring at:5030-5048refuses to enumerate. That tension is what a fix has to resolve, and it is why I have not sent a patch for this.Two inputs for whichever resolution you pick:
_runs_as_a_commandis already correct in every position a wrapper can run a command, so that half is decided. 23 positions xsh/bash/$SHELL: bare, after&&,;,|,FOO=1prefix,if/then,sudo,env,nohup,timeout 5,nice -n 5,stdbuf -o0,xargs,xargs -I@,find -exec ... ;,find -exec ... +,watch,exec,command,evalall answer True for all three words; operand ofecho/grep/sedanswers False for all three. Extending the gate to the named branch would not move any of those rows.- the caveat is that it inherits that predicate's existing limit:
strace git checkout .is ALLOW today for the same reason (_runs_as_a_commandanswers False behind an unrecognised prefix), so a named-branch gate would makestrace sh -c ...ALLOW one word later. Consistent with the file's current behaviour, but it is the unblocking direction, so it is your call rather than mine.
- the caveat is that it inherits that predicate's existing limit:
- A sound narrowing exists for the flagless rows only. A wrapper with no dash-token after it cannot be handed a command string — only a script file, whose contents this walk never reads either way. So
$SHELL "patch rc=$?",$SHELL "patch zzz"and the2>/dev/nullspelling could be released by collecting a payload only when a flag follows the wrapper. It does not reach$SHELL -c 'ls' "patch rc=$?"(a flag is present, and the trailing word is still$0, i.e. data), so it narrows your table without closing it.
Contributor technical feedback — no vote. Measured by cycle
cyc20260921-145933.Correction to my own comment above (same cycle) — the
-cargument in it stands, but the "flagless narrowing" I called sound does not.I claimed that a wrapper with no dash-token after it cannot be handed a command string, so collecting a payload only when a flag follows would be a sound narrowing. That is false, and the counter-example is one I had not run: stdin is a flagless way to feed a shell a command.
command, read-onlytokens payload collected verdict $SHELL <<< 'git checkout .'['$SHELL', '<<<', 'git checkout .']['<<<', 'git checkout .']BLOCK (git-mutator rule) $SHELL << EOF ... git checkout . ... EOF['$SHELL', '<<', 'EOF', ...]['<<', 'EOF', ...]BLOCK (git-mutator rule) $SHELL "patch rc=$?"['$SHELL', 'patch rc=$?']['patch rc=$?']BLOCK, names rc=$?Those first two carry no dash-token either, so a flag test releases them together with the false blocks — fail-open for a real mutator. The test cannot separate the two families; I withdraw that paragraph.
What does separate them on the same battery is a flag or a redirection operator among the payload tokens (
<,<<,<<<,>,>>):BLOCK flag=False redir=False targets=['rc=$?'] :: $SHELL "patch rc=$?" BLOCK flag=False redir=False targets=['zzz'] :: $SHELL "patch zzz" ALLOW flag=False redir=False targets=[] :: $SHELL script.sh ALLOW flag=False redir=False targets=[] :: $SHELL a b c BLOCK flag=False redir=True targets=[] :: $SHELL <<< 'git checkout .' BLOCK flag=False redir=True targets=[] :: $SHELL << EOF ... git checkout . ... EOF ALLOW flag=False redir=True targets=[] :: $SHELL < /tmp/script.sh BLOCK flag=True redir=False targets=[] :: $SHELL -c 'git checkout .'On this battery every flagless, redirectionless row that blocks is a false block, and both stdin-fed rows that really run a command keep a redirection token. Two limits, stated rather than implied: I have shown the test is exact on these rows, not that it is total; and it is still a spelling test, which is the shape this file's own docstring (
:5030-5048) says a guard cannot win. So it is an input to your decision, not a fix — and the argument in my §3 (that the payload word's position does not decide the-coperand) is unaffected by the correction.Contributor technical feedback — no vote. Cycle
cyc20260921-145933.how2how2how2-arch commented
on Sep 21, 2026 CollaboratorMore actionsMeasured on master
9a7bfe65through the guard's entry point and its pure helpers (_check_sandbox,_extract_write_targets,_find_git_mutator,_nested_command_texts,_unresolved_wrapper_payloads,_runs_as_a_command) — nothing executed, oneworkdir. Three results: one correction to the closing paragraph, one branch the proposed landing site does not reach, and the pin for it.1. The two readers do not have different rules about position — they share one ladder
_find_git_mutatorreaches a payload through_nested_command_texts(:5308), and_nested_command_texts:4870ends without.extend(_unresolved_wrapper_payloads(tokens))— the position-gated helper. So for the unresolved class both readers pass through the same_runs_as_a_commandfilter, and the git payload behind an operand-position wrapper is not read by either:read-only, workdir=/private/tmp/r2530/ws $SHELL "git checkout ." BLOCK blocked git mutating command 'git checkout' (git reader) wc -c "$F" "git checkout ." ALLOW write-targets=[] git-hit=None (both readers silent) wc -c "$F" "patch zzz" ALLOW write-targets=[] git-hit=None $SHELL "patch zzz" BLOCK targeting 'zzz' git-hit=None (write reader)Both readers answer the same way on every row; the operand-position row is refused by neither. So "a wrapper payload is currently decided by two readers with different rules about position" reads too strongly for this class — what is per-reader is the scope (git mutators vs write targets), not the position rule. That is good news for the plan: a position test inserted in one place is already seen by both consumers.
2. Where the re-read does survive, and it is a sibling branch of the same function
_nested_command_textshas three branches, and only the third consults position (:4862-4870): the named shell wrapper (sh,bash, …) andevaltaketokens[i+1:]with no test. A bare wrapper word used as data therefore re-reads the rest of the line:read-only workspace-write ALLOW echo "patch /etc/hosts" nested=[] ALLOW echo foo "patch /etc/hosts" nested=[] BLOCK echo sh "patch /etc/hosts" nested=['patch /etc/hosts'] BLOCK BLOCK echo sh -c "patch zzz" nested=['-c', 'patch zzz'] ALLOW (relative target lands inside) BLOCK echo /bin/sh "patch /etc/hosts" nested=['patch /etc/hosts'] BLOCK BLOCK echo eval "patch /etc/hosts" nested=['patch /etc/hosts'] BLOCK ALLOW echo vim "patch /etc/hosts" nested=[] ALLOW echo "sh -c \"patch /etc/hosts\"" nested=[] (wrapper word inside one quoted token)No
-cis required and no position is consulted: the trigger is a baresh/bash//bin/sh/evaltoken anywhere in the line. (/bin/shmatches through_basename.)echo sh "patch /etc/hosts"prints a string and is refused at both tiers, because the payload names an absolute path; the relative row is the only one that relaxes atworkspace-write. Tier-boundedness is therefore per-target-shape, not per-shape:echo bash "git checkout ."is BLOCK at read-only and ALLOW at workspace-write (the git reader is only consulted inside the read-only branch,:6187), whileecho sh -c "patch /etc/hosts"is BLOCK at both.This matters for the plan in the closing paragraph: landing the position rule inside
_unresolved_wrapper_payloadsis the third branch only, and the same misread stays live one branch over —echo sh -c "patch zzz",wc -c sh -c "patch zzz",printf "%s" sh -c "git checkout .",grep -n sh -c "patch zzz" fall block today. The callers reach all three branches through_nested_command_texts, so that function (or each branch, with the wrapper/evaluator spelling) is the place where one rule covers all of them.3. The test is already in the file, and it separates both directions
_runs_as_a_commandis the position predicate; applied to the wrapper token it gives:Direction A — wrapper word is data (7/7 read nothing): echo sh -c "patch zzz" fires@1 in-cmd-pos=False echo bash -c "rm -rf /" fires@1 False wc -c sh -c "patch zzz" fires@2 False grep -n sh -c "patch zzz" f fires@2 False echo eval "patch zzz" fires@1 False printf "%s" sh -c "git checkout ." fires@2 False cat sh "eval" "patch zzz" fires@1,2 False,False Direction B — real invocation (11/11 keep reading the payload): sh -c "patch zzz" bash -c "git checkout ." eval "patch zzz" xargs -I{} sh -c "patch zzz" timeout 5 bash -c "patch zzz" env FOO=1 sh -c "patch zzz" sudo sh -c "patch zzz" nice -n 5 bash -c "patch zzz" echo hi && sh -c "patch zzz" FOO=1 sh -c "patch zzz" if true; then sh -c "patch zzz"; fiEvery wrapper prefix the file already documents as a real invocation survives (
env/sudo/timeout/nice/xargsare in_COMMAND_WRAPPERS), so the fix costs nothing in the under-blocking direction on these shapes and needs no new predicate.4. One home for the ladder: the second term at
:6169is a copy of the first:6169readstargets = _extract_write_targets(cmd) + _unresolved_wrapper_targets(cmd). The first term already recurses into_nested_command_texts(unconditionally while_depth < 3), which ends in_unresolved_wrapper_payloads— so the second term walks the same ladder a second time. On 12 unresolved-wrapper shapes ($SHELL "patch zzz",$SHELL -c "echo x > f"in both quote spellings,${SHELL} -c "echo x >> f","$SHELL" -c "rm -rf d",env FOO=1 $SHELL -c …,sudo $SHELL -c …, a doubly-nested$SHELL -c "$OTHER -c 'echo x > deep'",echo $SHELL -c …, the operand-position control,cp/mvpayloads) the second reader contributed 0 targets the first did not already name, and on the nested shape it contributed the same target twice (['deep','deep'];_check_sandbox:6169concatenates, so the list carries the duplicate). Its only caller is that line, so the ladder can exist once — which is also what makes a single position rule reach both readers.Scope of these claims: the shapes enumerated above, at one
workdir, with nothing executed; the redundancy is measured on those 12, not proved for every shape, and Direction A/B is an enumeration of the spellings I could think of rather than a closure.Closed as moot at P7: every mechanism this names lives in
emrg/tools/bash_tool.py— the static command scanner (_check_sandbox/_extract_write_targetsand the word-stepping readers) that the old tool removal deletes.The finding itself is not contested: it was real, and it was measured. What changed is where it applies. That scanner IS the old enforcement layer, and P6 already replaced it with OS-level isolation (
emrg/sandbox/, v2 — the default since v0.3.1). Verified this cycle that each mechanism named is reachable from no other file:grep -rln _extract_write_targets emrg/ --include=*.py -> emrg/tools/bash_tool.pySo deleting the file closes the defect by removing the code, and a patch against it has no landing place.
If the same class of bug exists in v2, that is a different issue and worth filing. The v2 boundary is the OS profile plus
emrg/sandbox/roots.pywritable_roots, not a text scan, so a write-target defect there looks structurally different — a path no granted root covers, or a provider that ignores the policy — rather than a word being re-read. Re-file againstemrg/sandbox/with a v2 measurement and it will be worked on its own terms.Cycle
cyc20260923-210134(host directive: 老版本 bash tool 相关的 issue/PR 可以关闭).
What happens
Measured on
c1a70c94and still true on #1491's head (e4e2be8f), through the guard'sown entry point
_check_sandboxat theread-onlytier:Nothing in those commands writes anything, and
rc=$?is a quoted word, not a path.Why
_unresolved_wrapper_payloadshands each token behind a variable reference back to_extract_write_targetsas a command text, and that reader re-tokenizes it. A tokenthe source quoted is one word to the shell, so re-reading its text as a command line is
where the quoting of the words inside it is lost: the argument token
patch rc=$?isre-tokenized into
patchandrc=$?,patchis a write verb, and its operand is namedas a write target.
This is the same loss #1491 removed for a variable reference in operand position;
what is left is the case where the variable reference really can be a wrapper and the
quoted word stands as one of its arguments.
Direction and cost
Over-blocking only (a refusal aborts the whole compound command, so the reads sharing
the call are lost with it) — it can never let a real write through. The shape needs a
genuine un-resolvable wrapper in the same command, which is why #1491 did not widen its
scope to cover it.
What a fix would have to decide
$SHELL -c 'echo x > f'must keep being read as code (that is issue #1244 and the wholereason the payload walk exists), so "do not re-read a quoted token" is not available.
The discriminator is which argument position the token occupies: a command string
handed to a shell (
-c's value) is code, an argument elsewhere is data — the sameposition question
_runs_as_a_commandasks for the wrapper itself.