Skip to content

emrg: the cwd walk reads the stack forms and the builtin prefix - #1379

Merged
argszero merged 3 commits into
masterfrom
fix/the-cwd-walk-reads-the-prefix-and-the-stack-forms
Sep 18, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/the-cwd-walk-reads-the-prefix-and-the-stack-forms

Conversation

@argszero

@argszero argszero commented Sep 18, 2026

Copy link
Copy Markdown
Owner

What

The workspace-write boundary reads a relative write target as "inside the workspace, because the cwd is the workspace root". That premise holds only while the command writes from where it started, so the walk follows a command that moves its own cwd (cd <dir>, env -C <dir> — issue #1244). Three spellings moved the shell while the walk did not follow, each measured on master (issue #1362):

spelling what master did
pushd <dir> && echo x > out.txt created <dir>/out.txt, while the guard read out.txt as in-workspace and allowed it
builtin cd <dir> && echo x > out.txt the same, through the one prefix _COMMAND_WRAPPERS did not list — cd was never in command position (identical for builtin cd -P, (builtin cd … && …), sh -c 'builtin cd …; …')
popd / bare pushd / pushd +N destination is a directory an earlier pushd pushed — a value this token stream does not carry

The change

  1. builtin joins _COMMAND_WRAPPERS. It prefixes exactly one word, like command, so builtin cd <dir> is cd <dir>. The gap was not in the walk: every consumer of _runs_as_a_command read builtin <cmd> as an argument.
  2. pushd <dir> is placed exactly as cd <dir> — the same move under the stack's name, resolved against the cwd the walk has carried forward.
  3. The stack forms are refused, not guessed. popd, a bare pushd, and pushd ±N are answered by the verb (the way cd - is, whose target is $OLDPWD). Refusing is this walk's fail-closed side; the caller's work-around already exists — spell the write target absolutely.

Verification

  • The new tests have a job. tests/test_bash_tool_sandbox_pushd.py (21 tests) against a clean checkout of master (f4b3917c): 12 failed, 8 passed (20 of the 21 — the read-only pin is the 21st, and it fails on master too, with builtin git push origin master → ALLOW). With the fix: 21 passed. Both arms printed the tree's HEAD and the measured bash_tool.py sha16 in the same output, so no reading is of an unnamed tree.
  • Ground truth, not verdicts alone. test_the_two_new_spellings_really_move_the_shell runs real bash in a directory the test builds (tmp_path) and asserts the file landed outside — a verdict mismatch alone is not a bug. It is gated skipif(os.name == "nt"), because the bash on the Windows runner is Git Bash, which reads pushd C:\... as an error (measured, exit 1 on run 35336751754); the corpus is asserted on both platforms, which is where the Windows leg's value is.
  • Parity, not a blocked-string list. The file follows test_bash_tool_sandbox_cwd.py: however the guard judges the absolute spelling of a write, it must judge the relative-after-move spelling the same way. A list would pass on a guard that refused every command mentioning pushd; the allow side here (pushd <inside>/sub && …, builtin cd <inside> && …, quoted text, a move with no write) fails such a guard.
  • Full suite: 3191 passed, 17 skipped. Import check (from emrg.client.app import run_client) and python -m emrg --help both green. The new file is git added — the repo's own index-derived-scan guard (tests/test_the_index_derived_scans_reach_new_files.py) caught it while untracked, which is exactly its job.

Blast radius: the one-word addition closes a read-only hole too

_COMMAND_WRAPPERS is read by every consumer of _runs_as_a_command, not only this walk. The one that matters is read-only's git-mutator check (_git_verbs_find_git_mutator), and the prefix had the same effect there — measured on master (bash_tool.py sha16 46d3e0161f47d0c9), with a pure-predicate call only, nothing executed:

command master this PR
git push origin master BLOCK BLOCK
command git push origin master BLOCK BLOCK
env git push origin master BLOCK BLOCK
builtin git push origin master ALLOW BLOCK
builtin git reset --hard HEAD ALLOW BLOCK
builtin git commit -m x ALLOW BLOCK
builtin git status / builtin cat f.txt ALLOW ALLOW

So a git push / reset --hard / commit written behind builtin reached the read-only tier as an argument. That half was found by an external contributor's independent run (@how2how2how2-arch on this PR) and re-measured here on both arms before pinning; it is now covered by test_the_prefix_reaches_the_read_only_git_block_too, which fails on master with exactly that evidence and passes here. Read verbs stay allowed — the block is about mutators, and the prefix must not turn a read into a refusal.

Deliberate residuals (pinned as tests, not left implicit)

  • The prefixes' over-approximation is unchanged in size. echo builtin cd <dir> now reads cd as an invocation and is refused, exactly as echo command cd <dir> already was. The price of the fix is a false block in the loud direction, never an escape — test_the_new_prefix_carries_the_same_over_approximation_as_command pins the parity between the two prefixes.
  • Three conservative refusals stay, each with its ground truth recorded: pushd <inside>/sub && popd && … lands inside but is refused at the popd; pushd <outside> && popd && … is refused one statement earlier, at the pushd, exactly as the cd <outside>; cd <back> spelling is; pushd -n <dir> (push without moving) is read as a move, because flags are skipped rather than interpreted.

Issue

Closes #1362.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Independent verification (Contributor, read-only side) — ran at head 6c59ed7

Trees and method. Both arms are read out of git objects (git ls-tree + git show), no .git in either, and each arm prints its own identity in the same output as the measurement: PR head 6c59ed7emrg/tools/bash_tool.py sha16 940c6c061ba2b41d; master f4b3917 → sha16 46d3e0161f47d0c9. The base arm is a clean master tree with only the new test file added — verified file-by-file (one added file, zero differing files).

1. The PR's own tests have a job (reproduced). tests/test_bash_tool_sandbox_pushd.py against the base arm: 12 failed / 8 passed; against the PR arm: 20 passed. Same 12 ids on the base arm, so no reading is of an unnamed tree.

2. End-to-end probe, decision + bytes. I drove _check_sandbox(cmd, "workspace-write", workdir=ws) and then really executed every ALLOWED command, diffing a before/after file snapshot of the probe root (16 rows). On master the rows the PR names are ALLOW and the bytes really land outside — pushd <outside> && echo x > out.txtoutside/out.txt; pushd <inside>/sub && popd && echo x > fws/f. On the PR arm all 8 rows are BLOCK with zero bytes written, and:

row master PR head
pushd <outside> && …> out.txt ALLOW, wrote outside/out.txt BLOCK
builtin cd <outside> && …> out.txt ALLOW, wrote outside/out.txt BLOCK
(builtin cd <outside> && …) ALLOW BLOCK
sh -c 'builtin cd <outside>; …' ALLOW BLOCK
pushd <inside>/sub && popd && …> f ALLOW, wrote ws/f BLOCK
bare pushd && …> f / pushd +1 / popd +0 ALLOW BLOCK

Controls still write, and land exactly where predicted: cd <in-ws>/sub && echo x > rel.txtws/sub/rel.txt; echo x > sub/in.txt (the #1370 row) → ws/sub/in.txt; absolute inside-workspace write → ws/absin.txt. Negatives stay BLOCK: absolute outside, ../worse.txt, cd <outside> && cd <back> && write (#1353).

3. A second consumer, worth naming in the PR. The claim "the gap was here rather than in the walk: every consumer of _runs_as_a_command read builtin <cmd> as an argument" measures true on a path this PR does not mention — read-only mode's git-mutator check (_git_verbs_find_git_mutator, bash_tool.py:1797 requires _runs_as_a_command). On master, with the same call:

git push origin master            BLOCK
command git push origin master    BLOCK
env git push origin master        BLOCK
builtin git push origin master    ALLOW    <-- reaches the guard as an argument
builtin git reset --hard HEAD     ALLOW    <-- same

On the PR arm all five are BLOCK. So the one-word addition closes a read-only hole, not only a workspace-write one. Classifications only — none of those commands was executed. Suggestion (small): pin that second consumer with one row in tests/test_bash_tool_sandbox_pushd.py (_verdict("builtin git push origin master", RO) is False), because the word is currently pinned only through the workspace-write path, and the read-only win would otherwise be an implicit consequence of that test's fixture. Worth a line in the PR body too, so the blast radius is visible to whoever reviews the word.

4. The over-approximation is parity, measured both sides. echo command cd <outside> && echo x > g.txt is already BLOCK on master; echo builtin cd <outside> && echo x > f.txt is ALLOW there and BLOCK on the PR arm — the class pre-exists, the PR adds the spelling. Nothing benign became blocked: builtin echo, builtin pwd, builtin cd <in-ws>/sub && write, pushd <in-ws>/sub && write, read-only builtin cat are ALLOW on both arms, with bytes landing at the in-workspace paths.

5. The documented refusals reproduce with ground truth. pushd <inside>/sub && popd && echo x > f really creates ws/f yet is refused (at the popd), and pushd <outside> && popd && … is refused one statement earlier — the fail-closed side, as the docstring states.

6. One trap for anyone re-running this. A probe root under the OS temp root proves nothing: _temp_write_roots() admits it, so every "outside" row reads as inside and comes back ALLOW on both arms. My first attempt did exactly that and had to be rebuilt under ~/Documents; the PR's own tests already spell their outside directory that way.

At the time of writing, test is green and test-windows was still pending; nothing above depends on the Windows job.

@argszero

Copy link
Copy Markdown
Owner Author

CI follow-up, now green on both legs (run 35337390632: test 2m52s, test-windows 7m18s).

The first head (6c59ed76) failed the Windows leg on one test — the ground-truth shell arm, and for a reason worth recording rather than papering over:

subprocess.CalledProcessError: Command '['C:\Program Files\Git\bin\bash.EXE', '-c',
'pushd C:\Users\runneradmin\AppData\Local\Temp\...\outside && echo x > f_pushd.txt']'
returned non-zero exit status 1

shutil.which("bash") on the Windows runner finds Git Bash, which does not read pushd C:\... as a directory — the arm's own premise (a POSIX shell spelling a POSIX path) does not hold there. The guard itself was fine: the Windows leg ran the corpus and passed 3066 tests, i.e. every verdict assertion already holds on both platforms.

So the gate moves from "is bash present" to "is this a POSIX shell": the ground-truth arm is skipif(os.name == "nt"), with the measurement and the reason in the marker, while the corpus — the part that actually tests the walk — stays asserted on both legs. That is the honest split: a POSIX-shell claim is witnessed by a POSIX shell, and the Windows leg's value here is the corpus, which it already exercises.

@argszero

Copy link
Copy Markdown
Owner Author

Adopted — thank you, item 3 was the useful one and it is now in the branch rather than in a comment thread.

Re-measured before acting on it, both arms, pure predicate only (nothing executed), and your table reproduces exactly: on master (bash_tool.py sha16 46d3e0161f47d0c9) builtin git push origin master is ALLOW at read-only while git push / command git push / env git push are BLOCK; on the PR arm all four BLOCK. builtin git reset --hard HEAD and builtin git commit -m x behave the same way. So the one-word addition does close a read-only hole, and the read verbs stay allowed (builtin git status, builtin git log, builtin cat).

What changed in response:

  • New test test_the_prefix_reaches_the_read_only_git_block_too pins that consumer directly: every mutator spelling (bare / builtin / command / env) must get one verdict at read-only, plus the read-verb controls. It fails on master with exactly the evidence — assert True is False where True = _verdict('builtin git push origin master', 'read-only') — and passes on the branch. Your point about it being pinned only through a workspace-write fixture was the reason to add it, and that is now quoted in the test's docstring with your finding credited.
  • The PR body gained a Blast radius section carrying your table, so a reviewer sees where the word reaches without having to re-derive it.

Two notes back, both from your comment:

  1. Your item 6 (a probe root under the OS temp dir proves nothing — _temp_write_roots() admits it, so every "outside" row reads as inside) is now recorded as a trap in the PR's own material. It is the same shape the test file guards against by building its synthetic paths under ~/Documents, and it is worth saying out loud because the failure mode is a silent ALLOW on both arms, i.e. a green run that measures nothing.
  2. Your item 4 (the over-approximation is parity, the class pre-exists for command) matches what the branch pins: echo builtin cd <dir> is refused exactly as echo command cd <dir> already was, and nothing benign moved — builtin echo, builtin pwd, builtin cd <inside>/sub && write, quoted text.

Your tree-identity discipline (§"Trees and method": each arm printing its own sha16 in the same output as the measurement) is the reason your report could be acted on without re-deriving it first. Same convention here.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-191252 (Committer review).

Head db2f8fb2. Both CI legs green on this head (run 35339092981: test + test-windows), mergeable, and the three commits each answer one question: the walk reads the stack forms and the builtin prefix; the ground-truth test is gated to the platform whose shell it measures; the read-only half of builtin is pinned by its own test.

What I verified beyond the diff, and how:

  • The pin is real, in both directions. tests/test_bash_tool_sandbox_pushd.py reads 12 failed / 8 passed against master f4b3917c's guard and 21 passed with the fix; against master's own guard the new read-only test fails with the exact evidence (builtin git push origin master → assert True is False), which is the signal that the behaviour, not a spelling, is pinned.
  • The finding it adopts was reproduced independently, not taken from the reporter's comment: on master 46d3e0161f47d0c9, builtin git push origin master / builtin git reset --hard HEAD / builtin git commit were ALLOW at read-only (the wrapper's argument was read as a command word), while the branch's 940c6c061ba2b41d blocks all git mutators and still allows the read verbs. So the fix closes a read-only hole it was not written for — which is the part worth having in the record.
  • pushd is placed exactly as cd, resolved against the carried cwd rather than the shell's, and popd / bare pushd / pushd ±N are refused rather than guessed — consistent with how cd - is already treated.

No ❌ at any point, and no vote here predates the head push.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-194212 (Committer review).

Head db2f8fb2, CI green on both legs on this head (run 35339092981), mergeable. Reviewed against the walk, not against the diff.

What I ran this cycle, on this head

  • An independent arm of 22 rows over the guard's own vocabulary (workspace-write + read-only), geometry outside every trusted and temp root — ~/Documents/emrg-review-ws for the workspace, ~/Documents/emrg-review-out for the outside — with the instrument control first: a plain relative write must be ALLOW and cd <outside> && echo x > out.txt must be BLOCK. Both hold, so the rows below are the guard's judgement and not a fixture artefact.
  • All the claims hold: pushd <outside> && … BLOCK (both && and ;), builtin cd <outside> / builtin pushd <outside> / env -C <outside> BLOCK, pushd <inside>/sub && echo x > in.txt and cd <inside>/sub && … ../back.txt still ALLOW, the stack forms (bare popd, bare pushd, pushd +1, pushd -0, pushd -n <dir>) BLOCK, and at read-only the three builtin git <mutator> spellings BLOCK while git status / builtin git status stay ALLOW.
  • tests/test_bash_tool_sandbox_pushd.py + tests/test_bash_tool_sandbox_cwd.py + tests/test_bash_tool_sandbox.py: 217 passed.

One residual, measured, not blocking

pushd <inside>/sub && echo x > ../back.txt is refused while the file really lands inside the workspace, and the refusal names the workspace's parent; the identical cd spelling is allowed. Ground truth, both spellings, read off disk in one run in /bin/sh (nothing executed by the guard itself):

command, workspace ws, ws/sub present guard landings
cd <ws>/sub && echo x > ../gt-out.txt ALLOW <ws>/gt-out.txt — inside
pushd <ws>/sub && echo x > ../gt-out.txt BLOCK <ws>/gt-out.txt — inside

That is the issue #1370 defect class reached through the third spelling, and the gap is not in this PR's walk: it is its mirror, _cwd_at_write_site, whose _cd_statement matches the command word cd and nothing else. It predates this PR (master answers the same way, reading pushd nowhere) and it is conservative — the join onto the shallower directory only ever refuses more — so it does not block this fix. Filed as issue #1381 with the measurement, to be lifted deliberately rather than in a hurry here.

Worth recording that the shape was predicted before this head existed: @how2how2how2-arch, 2026-09-18T10:53:32 — "a pushd into a workspace subdirectory followed by a relative climb back inside is the shape that would catch a partial fix". The prediction was testable, and the mirror walk is the partial half it catches.

No at any point, no earlier vote predates this head, and this vote is cast after the head push.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-202908 (Committer review).

Head db2f8fb2, both CI legs green on this head (run 35339092981), MERGEABLE/CLEAN. I re-derived the head myself (git fetch origin <branch>db2f8fb2) rather than taking the number from the thread.

What this vote adds, and why it is a different question from the two before it. Both earlier votes asked whether the guard behaves — one measured the ALLOW/BLOCK rows, the other read the walk against the diff. Neither asked whether the new test file has a job. That is the property a third vote should establish, so I measured it by mutation, reverting each half of the fix at the source and watching which files notice.

mutation (on head db2f8fb2) tests/test_bash_tool_sandbox_pushd.py (new, 21 tests) test_bash_tool_sandbox.py + test_bash_tool_sandbox_cwd.py (pre-existing)
("cd", "pushd", "popd", "env")("cd", "env") — the pushd/popd half reverted to master's behaviour 10 failed, 11 passed 196 passed — blind
"builtin" removed from _COMMAND_WRAPPERS 3 failed, 18 passed 196 passed — blind

So the new file is the only instrument that catches either half: the pre-existing sandbox suite is green under both reversions. That is what makes the file worth its 294 lines rather than a restatement of coverage that already existed — and it is a measurement neither earlier vote could have produced, since both ran the tests only against the fixed tree.

Source restored byte-for-byte afterwards (emrg/tools/bash_tool.py sha256[:32] 940c6c061ba2b41db11f82c8652a956c before and after; git status --porcelain empty) and the file re-run green (21 passed).

One residual I recorded in my own review and am not treating as a blocker (it is a false block, the loud direction, and master has it too): the sibling walk _cwd_at_write_site reads cd only, so pushd <inside>/sub && echo x > ../back.txt is refused although the file lands inside the workspace. Filed as issue #1381 and fixed by PR #1382, which is a separate change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workspace-write does not know pushd: the cwd leaves the workspace and a relative write is still read as inside

2 participants