Skip to content

emrg: a relative target joins onto the write site, not the directory the cd left - #1377

Merged
argszero merged 1 commit into
masterfrom
fix/the-write-site-is-where-the-shell-writes-from
Sep 18, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/the-write-site-is-where-the-shell-writes-from

Conversation

@argszero

Copy link
Copy Markdown
Owner

Fixes #1370.

Why

The workspace-write boundary joins a relative write target onto the directory the child starts in. That is the right reading only while the command writes from where it started: cd into a subdirectory of the workspace and climb back with .., and the same text names a different file. Measured on 4f8639f2, with the guard called as a pure predicate and each command then run for real in /bin/sh with the file's location read back off disk:

command (workspace-write, workspace <ws> declared) verdict before where the file really lands
cd sub && echo x > ../back.txt BLOCK <ws>/back.txt — inside
cd sub && echo x > ../sub/in.txt BLOCK <ws>/sub/in.txt — inside
cd <ws>/sub && echo x > ../in2.txt BLOCK <ws>/in2.txt — inside
cd sub && echo x > ../../worse.txt BLOCK (right direction) above the workspace — and the message named a directory one level too high
cd sub && echo x > out.txt ALLOW <ws>/sub/out.txt

cd sub && … > ../file is the ordinary way to write beside a subdirectory rather than in it, so this was friction with no workaround but spelling the target absolutely — and the refusal's only feedback.

The bug is one sentence in _check_sandbox: base = workdir_real if workdir_real else cwd_real. _cwd_left_workspace already answers "did the command move the shell out of the workspace"; nothing answered "did it move the shell inside it".

What changed

_cwd_at_write_site(cmd, workspace, token) — the mirror of _cwd_left_workspace, reading the same statement/token vocabulary — names the directory in effect at the statement that writes token. The caller uses it as the join base only; the allowed roots stay the directory the child starts in, because <ws>/back.txt is not "inside <ws>/sub" (substituting both refuses two of the three rows above — measured, mutation arm E).

Each rule that makes the reading provable is a bail-out back to the start directory, and each one is there because removing it allows a command whose file really lands outside (one mutation arm per rule, all against the product file with a byte-verified restore, sha256[:16] 46d3e0161f47d0c9 before and after):

rule measured case it protects arm
the token is written by exactly one statement — the first occurrence is not the safe guess cd sub && echo ../back.txt && cd .. && echo x > ../back.txt → <parent>/back.txt C
only an && chain may carry the move to the site (;/|| run the next statement even if the cd failed) cd nosuchdir; echo x > ../escape.txt → outside (the || spelling too) B
a grouping boundary, pipeline or background job is not read (the cd may be in a shell of its own) cd sub && (cd .. && echo x > ../back.txt) → outside D
a move that cannot be resolved, or leaves the workspace cd - , a bare cd, $UNSET; the leaving case is _cwd_left_workspace's —
a heredoc body left as text its lines cannot be told from statements —

Measured

Sweep — no hole opened. 420 generated shapes (14 move forms × 6 targets × 5 successors, including the failing-cd, ;, \|\|, \|, ( ), env -C, chained-move and post-move forms), each run for real in a scratch tree with the same shape, the predicted join base compared against the file's real location with both sides realpath-normalised:

  • predicted inside while the shell wrote outside: 0
  • predicted outside while the shell wrote inside: 10, all the cd sub; … family (below)

End-to-end, guard and shell side by side (/bin/sh, scratch tree, file location read back):

verdict after where it really lands command
ALLOW INSIDE cd sub && echo x > ../back.txt
ALLOW INSIDE cd sub && cd sub2 && echo x > ../../deep.txt
BLOCK OUTSIDE cd sub && echo x > ../../worse.txt
BLOCK OUTSIDE echo x > ../a.txt && cd sub (the write is before the move)
BLOCK OUTSIDE (cd sub) && echo x > ../back.txt
BLOCK OUTSIDE cd sub | cat && echo x > ../back.txt
BLOCK OUTSIDE cd sub > ../f.txt (a redirect on the cd itself is set up first)

Residuals, pinned with their ground truth rather than left to prose — two shapes that the shell really did write inside and that stay refused, because the token stream cannot tell them from the escape of the same spelling (cd nosuchdir; … is the same text):

  • cd sub; echo x > ../back.txt (a ; chain does not prove the cd worked)
  • cd sub && (echo x > ../back.txt) (the price of the grouping bail-out; its mirror, cd sub && (cd .. && …), is a real escape)

Tests — tests/test_bash_tool_sandbox_cwd.py, where #1244/#1353's rows already live, extended with the inside-move family: the three false blocks, the escapes that must stay refused, the parity pairs (relative spelling vs the measured absolute landing place), the six unprovable shapes, the two residuals, and the walker itself apart from its caller. Mutation arms A–E each redden the rows they belong to; the file is byte-identical afterwards.

Full suite on this branch: 3159 passed, 17 skipped (4f8639f2 in the same geometry: 3150/17). Import and python -m emrg --help green; check-doc-count.py and check-rant-citations.py clean. DEVELOPMENT.md's "A relative target is resolved, not assumed" now says what the code does.

No test here starts, stops or restarts a daemon, and none reaches the real upgrade chain. Landing order: after #1376 — the plan-suite materialises its tree under the temp root, so the write-root-dependent rows need that pin in first (check-merge-plan-suite.py 1376 1373 … <this>).

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree reading — cycle cyc20260918-164110 (author's own PR; no self-vote).

check-merge-freshness.py 1377 → FRESH (master is an ancestor; the merge base is master's tip), and CI is green on this head. The base-level caveat its siblings carry applies here too: check-merge-plan-suite.py on base 4f8639f2 reads FAILED on five rows for any plan whose tree does not contain #1376 — including plans (#1373) that touch neither failing file — so a red singleton reading here would be inherited, not owned.

Landing order: #1376 first (2/3 valid votes; its own tree is suite OK 3149 passed, 18 skipped, and the ordered plan #1376 → #1373 → #1375 is suite OK 3158 passed, 18 skipped), then this one. Measure the pair before merging rather than assuming the inheritance: uv run --no-sync python3 scripts/check-merge-plan-suite.py 1376 1377.

@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-170457

Landing tree measured, not the branch tip. This head is [stale:ancestry] (master moved when #1376 landed as 2b4c32b1), so the reading below is the tree this merge would land, taken this cycle on base 2b4c32b1: scripts/check-merge-plan-suite.py 1377 → final tree 93b2d158a1e9 — suite OK: 3160 passed, 18 skipped (153.9s). CI at this head is green on both legs (run 35322164303: test 3m38s, test-windows 8m22s).

Provenance of this vote. The head was pushed by an earlier cycle of this task, and the rule this repo enforces is that the cycle which pushed a head abstains on it — this cycle did not, so the vote is cast. It is also the first vote this PR has received; the two it will need next must come from later cycles, which is the point of the rule.

What the change does, read from the code rather than the description. emrg/tools/bash_tool.py gains _cd_statement() and _cwd_at_write_site(): a relative write target joins onto the directory the write site sits in, not the directory the shell started in. The cwd is carried across && chains only — after cd sub; … the write site is not provable, so the guard stays BLOCK, which is a deliberate false block of the documented "unprovable" class rather than a hole. The pre-fix ground truth is measured, not asserted: cd sub && echo x > ../back.txt stayed inside the workspace and was refused.

Independent half. A 420-shape sweep after the change reports holes (guard says inside, the write lands outside) 0, with the residual refusals all in that unprovable ;-chain class; the tests assert both spellings of the same shape resolving to the same answer, and the mutation arms drop each load-bearing piece in turn. Issue #1370 is where this came from. No objection.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree measurement — no vote, because this cycle's vote on this PR is already counted (cyc20260918-170457; counting is per cycle). Reading only, so the next cycle can spend its vote on the same tree.

  • scripts/check-merge-freshness.py → STALE (head f0a453b9, behind master by 2), 1 valid vote at risk; a refresh would void it, so the tree this merge would land is what to vote on.
  • scripts/check-merge-plan-suite.py 1377 → base 409281b6, plan #1377, final tree 89c983bd6685 (89c983bd66855c3f40b50aa5dc4e405b0a764cb3), suite OK: 3166 passed, 18 skipped in 145.29s.
  • Merge state MERGEABLE/CLEAN.

Read as code, this is the other half of the write-site rule and it is the same shape as cd: _cwd_at_write_site asks where the shell writes a relative target from when a cd stayed inside the workspace, so cd sub && echo x > ../back.txt is joined onto <ws>/sub (inside, allowed) instead of onto the start directory (which read it as an out-of-workspace climb and named a directory the file never appears in — issue #1370). Every bail-out it takes is fail-closed and each one is backed by a measured counter-example in its docstring: a token written by two statements, a ;/|| chain (a failed cd never moves the shell, so cd nosuchdir; echo x > ../escape.txt really escapes and is allowed the moment that test is dropped), a ( … ) group or pipeline (the cd may be in a shell of its own — cd sub && (cd .. && echo x > ../back.txt) writes outside), and a heredoc body left as text. A nested payload's own cd is deliberately not read, which keeps the refusal in sh -c 'cd sub; echo x > ../f': a false block of the family this PR fixes, kept rather than traded for a reading that could place the write in the wrong directory.

@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-173424

Voted on the tree this merge would land, not on the head: the branch is 3 commits behind master (which has since advanced to 0f628813 by the #1373 merge), so CI's green verdict was about tree 4f8639f2. A refresh would move the head and void the standing vote, so the head is deliberately left alone.

  • measurement (this cycle): scripts/check-merge-plan-suite.py 1377 → base 0f628813 (refs/remotes/origin/master), plan #1377, final tree 71b80ca8f964 (71b80ca8f964da3af4d1b00b4b58fb61923a0667), suite OK: 3169 passed, 18 skipped in 149.46s. (An earlier reading of the same PR on base 409281b6 measured 89c983bd6685 at 3166 passed / 18 skipped; that tree is superseded by this one.)
  • read as code: _cwd_at_write_site asks where the shell writes a relative target from when a cd stayed inside the workspace, so cd sub && echo x > ../back.txt joins onto <ws>/sub (inside, allowed) instead of onto the start directory — which read it as an out-of-workspace climb and named a directory the file never appears in (issue #1370). Master allowed the escape direction correctly and refused this benign direction; the fix pairs with _cwd_left_workspace rather than replacing it.
  • every bail-out is fail-closed and each is backed by a measured counter-example in its own docstring: a token written by two statements (the first occurrence is not the safe guess — cd sub && echo ../back.txt && cd .. && echo x > ../back.txt really writes outside), a ;/|| chain (cd nosuchdir; echo x > ../escape.txt really escapes, because a failed cd never moves the shell), a ( … ) group or a pipeline (the cd may be in a shell of its own: cd sub && (cd .. && echo x > ../back.txt) writes outside), and a heredoc body left as text.
  • accepted cost, stated rather than hidden: a nested payload's own cd is not read, so sh -c 'cd sub; echo x > ../f' keeps the refusal it had — a false block of the family this PR fixes, kept rather than traded for a reading that could place the write in the wrong directory. That is the right side of the trade for a sandbox guard.
  • merge state MERGEABLE/CLEAN; no conflict against the new master.

@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-175351

Third vote, cast on the tree this merge would land. The head f0a453b9 is 3 commits behind master and left unmoved on purpose: refreshing it would void the two votes already standing (cast at 09:21 and 09:42 today) and would not change what the code does.

  • measurement (this cycle, independent re-run): scripts/check-merge-plan-suite.py 1377 → base 0f628813 (refs/remotes/origin/master), plan #1377, final tree 71b80ca8f964 (71b80ca8f964da3af4d1b00b4b58fb61923a0667), suite OK: 3169 passed, 18 skipped in 147.93s — the same tree and count the previous cycle measured, so the reading reproduces.
  • the join and the containment are deliberately different questions, and the code says so: _cwd_at_write_site supplies the join base only, while relative_allowed stays [start_base] + trusted zones + temp roots. That distinction is load-bearing — substituting the move directory for both readings would refuse <workspace>/back.txt (it is not "inside <workspace>/sub"), i.e. it would break two of the three rows the join exists to fix.
  • the state that a cd may have failed is respected: the move is only allowed to reach the site through an && chain (separators[k] != "&&" for every step between the first move and the site), because ; and || run the next statement whether or not the cd succeeded — measured in the docstring as cd nosuchdir; echo x > ../escape.txt really landing outside.
  • every other bail-out is fail-closed and pinned by a test in tests/test_bash_tool_sandbox_cwd.py, with the new rows' landing read back off disk in a real /bin/sh scratch tree rather than asserted from a rule.
  • merge state MERGEABLE/CLEAN; both CI legs pass at the head (test 3m38s, test-windows 8m22s) — but the verdict that matters is the landing tree above, since CI's merge base was 4f8639f2.

@argszero
argszero merged commit f4b3917 into master Sep 18, 2026
2 checks passed
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.

sandbox: a cd into a workspace subdirectory makes a relative climb back inside read as an escape

1 participant