Repository navigation
workspace-write does not know pushd: the cwd leaves the workspace and a relative write is still read as inside #1362
Description
Activity
how2how2how2-arch commented
on Sep 18, 2026 CollaboratorMore actionsRe-measured on master e3da533 — and the walk is defeated by a prefix as well as by a verb it does not know.
Method
Master's own
emrg/tools/bash_tool.pywas staged into/tmp(the workspace here is read-only),
_check_sandbox(cmd, "workspace-write", <ws>)was called directly, and the same command was then
run for real in/bin/bashfrom thatws. Each case got its own scratch directory, and the scratch
base was checked to sit outside the OS temp root (_temp_write_roots()→/private/var/folders/…/T),
so "the file landed outsidews" is an escape and not the tier's own temp allowance.What the walk knows, and what it does not
spelling guard file really landed cd <outside> && cat > fBLOCK outside (not run) env -C <outside> cat > fBLOCK in-ws command cd <outside> && cat > fBLOCK outside (not run) eval 'cd <outside>' && cat > fBLOCK outside (not run) \cd <outside> && cat > fBLOCK outside (not run) "cd" <outside> && cat > fBLOCK outside (not run) bash -c 'cd <outside> && cat > f'BLOCK outside (not run) cd - && cat > fBLOCK — builtin cd <outside> && cat > fALLOW outside — the escape happens builtin cd -P <outside> && cat > fALLOW outside (builtin cd <outside> && cat > f)ALLOW outside sh -c 'builtin cd <outside>; cat > f'ALLOW not run (nested payload, same reading) pushd <outside> && cat > fALLOW outside builtin pushd <outside> && cat > fALLOW outside builtin exec cd <outside> && cat > fBLOCK nothing ( builtin exec cdfails in the shell)builtin eval 'cd <outside>' && cat > fBLOCK outside (not run) Root cause is one word, in the shared vocabulary
builtinis not in_COMMAND_WRAPPERS(bash_tool.py:363— the set listscommand,exec,eval,
env,sudo, …). The walk's own loop (bash_tool.py:2371) readsif word not in ("cd", "env") or not _runs_as_a_command(tokens, i): continue
so the move is never asked about. Predicate, measured on the tokenizer's own output:
_split_command_tokens("builtin cd /x && cat > f") -> ['builtin','cd','/x','&&','cat','>','f'] _runs_as_a_command(..., idx of 'cd') = False <- builtin is not a wrapper _split_command_tokens("command cd /x && cat > f") -> ['command','cd','/x','&&','cat','>','f'] _runs_as_a_command(..., idx of 'cd') = TrueThat is why the sibling spellings above block and this one does not: they are the same verb, and only
the prefixed form leaves command position.builtinprefixes exactly one word, likecommand, so it
belongs in the same list — and the gap is in the shared vocabulary, i.e. every consumer of
_runs_as_a_commandreadsbuiltin <cmd>as an argument, not only this walk.Prototype, with the allow half measured
Adding
"builtin"to_COMMAND_WRAPPERSalone was applied to the/tmpcopy and the two halves re-run
(must-refuse: 10 rows, must-stay-allowed: 12 rows):builtin cd <outside> && cat > f ALLOW -> BLOCK closed builtin cd -P <outside> && cat > f ALLOW -> BLOCK closed (builtin cd <outside> && cat > f) ALLOW -> BLOCK closed sh -c 'builtin cd <outside>; cat > f' ALLOW -> BLOCK closed pushd <outside> && cat > f ALLOW -> ALLOW untouched by this patchNo row of the allow half moved:
echo x > out.txt,echo x > ./sub/out.txt,
echo x > sub/../out.txt,echo x > /dev/null,T=.emrg/tmp && echo x > "$T/f", the absolute-value
form,git status,cd <ws>/sub && cat > f,env -C <ws>/sub cat > f,builtin echo hi > …all stay
ALLOW. So the two halves of this issue are independent: this one is a word in the wrapper set, the
pushd/popdhalf is the verb list the walk reads — the patch above does not closepushd(measured
row above), and it is not meant to.One judgement call for whoever fixes it:
builtincan only prefix a shell builtin, sobuiltin cdis
deliberate rather than accidental — but the walk exists precisely for the spelling a command can
always use, and here the spelling that leaves the workspace is also the one that is not asked about.Status — cycle
cyc20260918-164110: the fix is written but cannot be committed, and here is exactly where it is.The hole is confirmed on master and the fix is complete in the working tree, on the local branch
fix/the-walk-reads-a-prefix-and-a-verb(created from master4f8639f2), as one uncommitted modification toemrg/tools/bash_tool.py(+67/−8, 123 diff lines):_cwd_left_workspacelearns the stack vocabulary:pushd/popdjoincd/env -C, sopushd <outside> && echo x > f.txtis placed instead of read as in-workspace;- the stack forms whose destination is an earlier
pushd's (popd, barepushd,pushd ±N,pushd -) are refused by their own token, the same fail-closed sidecd -already takes — the mirror cost is recorded in the docstring (a command that returns to where it started is refused too, and the work-around is to spell the target absolutely); _COMMAND_WRAPPERSgainsbuiltin, which was the one prefix that left the candidate out of command position: measured on master,builtin cd <outside> && echo x > f.txtcreated<outside>/f.txtwhile the guard allowed it — the same forbuiltin cd -P …,(builtin cd …)andsh -c 'builtin cd …; …'. That gap was in the shared rule, not in this walk: every consumer of_runs_as_a_commandreadbuiltin <cmd>as an argument.- a 420-shape sweep over the modified tree: holes (guard says inside, the write lands outside) 9 → 0, at the price of the false blocks 3 → 9, all in the "unprovable stack form" class the docstring names.
Why it is not a PR. The change is the only dirty entry in the worktree, so
TaskHandler._dirty_tree_would_lose_work_syncmeasures it as unique dirt and the cycle tier is forced toread-only(community issue #979): every git write verb and every workspace write is refused, including the ones that would commit it. The fix therefore cannot be pushed from inside a cycle until the host either commits the branch, authorises a one-shotEMRG_TASK_DIRTY_OVERRIDE=emrg-taskdaemon start, or discards the file.To make sure it is not lost, the diff was copied out of the workspace and verified byte-for-byte against
git diff(git diff | diff - <file>, 123 lines both sides):/Users/argszero/.emrg/evolution/.emrg/patches/pushd-popd-cwd-walk-20260918.patchRe-apply with
git applyon a clean master4f8639f2, or regenerate from the working tree while it is still there. Nothing else is needed from this issue's side until the change can be committed.Held fix, measured against master: it closes every escape in the sweep, and the fail-closed cost is now classified row by row.
The patch exists locally (+67/−8 in
emrg/tools/bash_tool.py) and its base file is byte-identical between the branch base4f8639f2and current master409281b6(git diff 4f8639f2 409281b6 -- emrg/tools/bash_tool.pyis empty), so it applies to master without a rebase. It is not pushed: this fix is the uncommitted working tree, so the daemon's loss criterion (scheduler._dirty_tree_would_lose_work_sync) downgrades every cycle on this repo toread-onlyandgit add/commit/pushare refused. Decision pending with the host.A/B, one process, both guards asked about the same command string (master's
bash_tool.pymaterialised fromgit show 4f8639f2:emrg/tools/bash_tool.pyand loaded as a second module), landing read back off disk in a scratch tree inside the repo:holes false blocks agree master 9 4 20 fixed 0 10 23 Holes closed (each verified to have written outside the declared workspace):
pushd <out> && …,pushd <out>; …,pushd <out> && cat > f,sh -c 'pushd <out>; …',builtin cd <out> && …,builtin cd -P <out> && …,(builtin cd <out> && …),sh -c 'builtin cd <out>; …',builtin pushd <out> && …— 9 → 0.The six added refusals, classified rather than counted:
- Genuinely unplaceable — the destination is the shell's own directory stack (3: bare
pushd,pushd ±N,popdwith no readable prior pushd). Their target is $OLDPWD / whatever an earlierpushdpushed, which the walk cannot name; refusing is the fail-closed side. Cost accepted. - A
popdthat returns to a directory the walk did read (2:pushd <sub> && popd && echo x > f.txt,pushd <out> && popd && echo x > f.txt). Both really land inside (the round trip is back to the start directory, in bash and in dash alike), and master allowed them correctly by not readingpushdat all. These are placeable in principle — the walk would have to carry the stack thepushdpushes — so this is the one part of the cost a later change could remove; today the refusal is conservative. - The prefix over-approximation master already had (1:
echo builtin cd <dir> && echo x > f.txt). Measured, not assumed:echo command cd <dir> && echo x > f.txtis refused on master too, andecho cd <dir> && …is allowed on both. So addingbuiltinto_COMMAND_WRAPPERSextends an existing loud-direction class rather than creating one. The fix's own comment claims exactly this; the control rows confirm it.
Method trap worth keeping: the scratch tree must not live under the OS temp root.
_temp_write_roots()is itself a trusted write zone, so a scratch there makes the "outside" directory trusted too and every escape row reads as allowed — a discarded run I made reportedholes=13against the real 9 (master) / 0 (fixed). The sweep lives under the repo for that reason.- Genuinely unplaceable — the destination is the shell's own directory stack (3: bare
Landed as PR #1379 (
fix/the-cwd-walk-reads-the-prefix-and-the-stack-forms), which closes this issue.Summary of what the PR does, in case the code is reviewed against this report:
builtinjoins_COMMAND_WRAPPERS, sobuiltin cd <dir>is read ascd <dir>— and the same forbuiltin cd -P, a grouped(builtin cd … && …), andsh -c 'builtin cd …; …'. The gap was not only in this walk: every consumer of_runs_as_a_commandreadbuiltin <cmd>as an argument.pushd <dir>is placed exactly ascd <dir>, resolved against the cwd the walk has carried forward.popd, a barepushd, andpushd ±Nare refused (answered by the verb) rather than guessed, because their destination is a directory an earlierpushdpushed — a value the token stream does not carry. That is the same readingcd -gets, and the work-around is the one the walk's contract already names: spell the write target absolutely.
Measured, so the refusal is not a claim:
tests/test_bash_tool_sandbox_pushd.py, 20 tests. Against a clean checkout of masterf4b3917cthe same file reads 12 failed, 8 passed; with the fix, 20 pass. One test runs realbashin a directory it builds and asserts the file really landed outside, because a verdict mismatch alone is not a bug.Two residuals are pinned as tests rather than left implicit: the prefixes' over-approximation is unchanged in size (
echo builtin cd <dir>is refused, exactly asecho command cd <dir>already was — a false block in the loud direction), and three shapes are refused although the shell would have written inside (pushd <inside>/sub && popd && …,pushd <outside> && popd && …,pushd -n <dir>), each with its ground truth recorded in the test that pins it.Full suite on the branch: 3190 passed / 17 skipped.
how2how2how2-arch commented
on Sep 18, 2026 CollaboratorMore actionsStill live on current master (
f4b3917) — measured now, after #1377 (a relative target joins onto the write site, not the directory the cd left) landed. That PR fixes thecd-then-climb family, so the walk's verb vocabulary is the remaining half, andpushdis still not in it:command ( workspace-write, declared ws, ws/sub present)verdict where the file really landed cd <outside> && cat > fBLOCK not run pushd <outside> && cat > fALLOW outside the workspace builtin cd <outside> && cat > fALLOW outside the workspace Both ALLOW rows were run for real in
/bin/shfrom the workspace and the file appears outside it, so this is not a refusal-shape question: the write happens and the guard reads it as inside. Both spellings reach the same walk (_cwd_left_workspace), which compares the command word against("cd", "env")and consults_runs_as_a_command;pushdis a different verb andbuiltinis not in_COMMAND_WRAPPERS, so neither is ever asked about.Since
#1377changed how the destination is treated but not which commands are considered moves, the two spellings in this issue are untouched by it — worth re-checking only if the fix here is written as "extend the verb list", in which case the write-site base from#1377is already in place and the same rows can be pinned against it (apushdinto a workspace subdirectory followed by a relative climb back inside is the shape that would catch a partial fix).how2how2how2-arch commented
on Sep 18, 2026 CollaboratorMore actionsFollow-up to the verification above: a PR now closes all three spellings on this issue — 🧪 #1379, "the cwd walk reads the stack forms and the builtin prefix" (head
6c59ed7).I re-ran my probe set against that head (trees staged from git objects, PR head sha16
940c6c061ba2b41dvs masterf4b3917sha1646d3e0161f47d0c9), decision + real byte placement:pushd <outside> && echo x > out.txt,builtin cd <outside> && echo x > out.txt, the grouped andsh -cspellings, barepushd,pushd +1,popd +0, andpushd <inside>/sub && popdall move from ALLOW-with-bytes-outside on master to BLOCK with zero bytes written. The controls still write where expected, so the fix is not a blanket refusal.The remaining half is not this PR's subject and is now tracked separately: the walk refuses the forms that name a stack entry, which is the fail-closed side, but
D=../outside && cd "$D" && cat > fandfor d in <dir>; do cd "$d" && cat > f; doneare still ALLOW on both trees (issue #1357). Details of the #1379 measurement are in the PR thread.Follow-up on this issue's second half, filed and fixed rather than left implicit: #1381 → PR #1382.
While reviewing #1379 (which reads the stack forms and the
builtinprefix in the walk that asks whether a move leaves the workspace) I measured the mirror walk,_cwd_at_write_site, which asks which directory the shell writes from. It readscdonly, so the same command got two answers depending on the spelling. Measured in/bin/sh,ws/subinside the declared workspace, verdict and real landing place read in one run:command guard where the file really landed cd <ws>/sub && echo x > ../gt-out.txtALLOW <ws>/gt-out.txt— insidepushd <ws>/sub && echo x > ../gt-out.txtBLOCK <ws>/gt-out.txt— insideThe refusal resolved the target to
<ws>/../gt-out.txtand named the workspace's parent — the issue #1370 defect class, reached through the third spelling. It is fail-closed and it predates #1379 (master answers the same way, readingpushdin neither walk), so it did not block that PR; #1382 reads both verbs in one helper (_move_statement), withpushd's flags answering "no placeable destination" (-npushes without moving,±Nindexes the stack) and--read before them because it ends option parsing.This is also the shape @how2how2how2-arch predicted in their 2026-09-18T10:53:32 comment — "a
pushdinto a workspace subdirectory followed by a relative climb back inside is the shape that would catch a partial fix". It did.- added a commit that references this issue
on Sep 18, 2026
What the rule is
workspace-writereads a relative write target as "inside the workspace, because the cwd is the workspace root". That premise only holds while the command writes from where it started, so_cwd_left_workspace(emrg/tools/bash_tool.py) walks the token stream for a command that moves the shell out first (issue #1244, and itsenv -Csibling).What it does not know:
pushd/popdThe walk's vocabulary is
cdandenv -C. A shell that haspushdgives the same move a third spelling, and it is not refused.Measured on this host (macOS), with two directories
AandB, both inside the workspace, so nothing here is a claim about a path the guard cannot place:So
pushdmoves the shell for every later relative target, exactly ascddoes — and the leaf of that same command is a relative write the guard reads as in-workspace:Both readings allow it. With a declared workspace that is the #1244 defect reached by another spelling: the write lands outside the workspace while the guard reports it inside.
Platform, measured rather than assumed
pushd/bin/shon macOS (bash 3.2 in POSIX mode)/bin/zsh/bin/dash(the usual Linux/bin/sh)pushd: not foundcmd.exe(Windows leg)pushd/popd(builtins)So the hole is live on macOS (where this host runs) and plausibly on Windows, while a dash-based Linux runner fails the command instead. That asymmetry is a reason to pin it with a platform table rather than to a runner's behaviour.
Acceptance
pushd— andpopd, which moves the shell back, andpushdwith no argument (swaps the top two) — are handled by_cwd_left_workspace, or refused outright: refusing is the fail-closed side and this walk's existing bias.pushd <outside> && cat > frefused;pushd <inside> && cat > fstill allowed (an over-refusal here would break ordinary work in the workspace).pushdhandling and the refusal test must redden._cwd_left_workspaceis updated, in the style of the limits already recorded there — apushdwhose destination the walk cannot place belongs in the same list.Not a regression from #1361 or #1359: both readings in the measurement above already allow it, on master, before either landed. Found while reviewing #1361 with a wider corpus than its own tests carry (
pushd, a subshell,env -C, a nestedsh -c, an absolute target) — every other row agreed with the declared reading there.