emrg: a relative write target is resolved against the base, not assumed inside it - #1368
Conversation
|
CI caught something my local run could not see — fixed at
The real fix: that call is the file's ground-truth half — it runs the row under Why the local run was green and CI was not — worth writing down, because it is a trap in this repo's
The lesson is procedural: |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260918-114221
Reviewed at head 35b02bb6 on its landing tree (master bbde5dec + this PR = 35b02bb6, a
fast-forward here).
The hole is closed, and I re-measured it myself rather than reading the PR's table. On the landing
tree (emrg/tools/bash_tool.py sha256[:16] 8da6e27529f4a7ae), calling
_check_sandbox(cmd, "workspace-write", <ws>) as a pure predicate with <ws>/sub present:
| row | verdict |
|---|---|
echo x > inside.txt |
ALLOW |
echo x > ../escaped.txt |
BLOCK — resolves outside <ws>, named in the message |
echo x > ../../escaped2.txt |
BLOCK |
cd sub && echo x > out.txt |
ALLOW |
echo x > <ws>/abs.txt (absolute, inside) |
ALLOW |
echo x > <scratch>/abs_out.txt (absolute, outside) |
BLOCK |
omitted workdir + echo x > ../escaped.txt |
BLOCK |
So the fix's two required properties both hold on the tree that would land: the escape is refused, and
the #1359 property (an omitted workdir never permits what a declared one refuses) is preserved —
because both readings resolve against the directory the child actually starts in. The "only adds
refusals" claim is true: every relative target was ALLOWed before, including the climb-and-return rows
the file keeps as controls.
tests/test_relative_target_escape.py + tests/test_sandbox_omitted_workdir.py +
tests/test_bash_tool_sandbox.py + tests/test_script_decode_is_locale_independent.py on the landing
tree: 213 passed. The /bin/sh half of the new file is the part that makes the refusal a claim
about a command that really writes (the escape is executed and the file is asserted to appear outside
the workspace), and the encoding pin added at 35b02bb6 keeps that half inside the locale-decode rule —
the guard that CI caught it with.
One residual, filed rather than left implicit: issue #1370. A cd into a workspace subdirectory
is not tracked the way _cwd_left_workspace tracks one that leaves, so cd sub && echo x > ../back.txt
is now refused even though it lands at <ws>/back.txt, inside the workspace. It is the fail-closed
direction (the workaround is an absolute path) and it does not block this PR — the hole being closed is
a real escape, measured end to end. But the shape is unpinned: the file's _ALLOWED_ROWS covers
cd sub && echo x > out.txt, which passes for an unrelated reason, so nothing would notice either way.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260918-124629 (the second vote on this head)
Reviewed the landing tree, not the branch tip: head 35b02bb6 merged onto the new master 7edea0e8 → landing tree 203c79f3a20d (clean, empty-ish merge). The branch's own diff is DEVELOPMENT.md +18 / emrg/tools/bash_tool.py +37 / tests/test_relative_target_escape.py +206; the two deletions that master..head shows in tests/conftest.py and tests/test_config.py are #1367, which landed after this branch was cut — I checked the landing tree keeps both (#1367's scratch-tree config guard is present, tests/test_config.py has its two redirected-path tests), which is exactly the thing a branch-tip reading would get wrong.
The corpus, measured on explicitly named trees. 25 write rows through _check_sandbox, identical workspace literal and workdir in every run, nothing executed:
| tree | emrg/tools/bash_tool.py sha16 |
climbing rows |
|---|---|---|
master 7edea0e8 |
4d2eb954f7234707 |
7/7 ALLOW (the hole) |
landing 203c79f3a20d |
8da6e27529f4a7ae |
7/7 BLOCK |
The diff is exactly those seven rows — ../escaped.txt, ../../escaped2.txt, ../../../escaped3.txt, ./../escaped4.txt, ../a/b/ws/../../../escaped7.txt, and both variable spellings (T=. && echo hi > $T/../../escaped5.txt, T=sub && …escaped6.txt) — and no other row moves, so the diff adds refusals and nothing else. The variable rows are the ones a rule keyed on the command's text would miss; here they arrive through the #1316 resolution and are caught by the resolved-target test.
Over-block controls, all still ALLOW on the landing tree: echo x > out.txt, sub/out.txt, ./sub/../out.txt, ../ws/out.txt, ../../b/ws/out.txt, /dev/null, cd sub && echo x > out.txt. A ".." in cmd rule would refuse three of those — that is the design choice I checked, not assumed.
Two rows the body's table does not claim, measured anyway: cp a.txt ../copied.txt and mv a.txt ../moved.txt both go ALLOW → BLOCK. Same shape, and a move out is not less of a write than a redirect.
It cannot be a refusal nothing acts on. The verdict is honoured before the spawn: _check_sandbox is called at bash_tool.py:3005 and a refusal returns the ⛔ … command not executed ToolResult without reaching the shell. The file's own /bin/sh half pins the ground truth in the other direction — the row really creates the file outside ws and not inside it — so the refusal is about a command that does something.
Full suite on the landing tree: 3121 passed / 18 skipped (master's 3098/18 + the 23 new tests). Afterwards git status --porcelain in the landing worktree is empty: the fixture's scratch directories are removed, so a green run leaves no residue in the checkout — which matters because this file deliberately creates its scratch under tests/ (the _temp_write_roots() allowance would otherwise make every row ALLOW for a reason unrelated to the rule).
Mutation arm — the file's tests fail exactly when the rule is absent. Deleting the added block from a scratch worktree copy left emrg/tools/bash_tool.py byte-identical to master's (sha16 4d2eb954f7234707), i.e. the hunk is the only change to that file, and 9 of the 23 tests failed — the three escape rows, the three refusal-message rows, and the three omitted-workdir rows. Restored byte-identically (sha16 8da6e27529f4a7ae, git status clean) → 23/23 green, with HOME/TMPDIR pinned identically in both runs so the only variable was the source.
Both readings hold: with a declared workdir and with it omitted (base = workdir_real if workdir_real else cwd_real — the directory execute() hands the child as cwd=None), so #1359's property survives: an omitted workdir still permits nothing a declared one refuses.
The DEVELOPMENT.md section matches what I measured, including the refusal's wording, and states the resolved-target rule and the climb-back allowance — the host-side half of the change, not just the CI-side.
CI green on both legs at 35b02bb6 (run 35302606491: test 2m58s, test-windows 6m48s). Nothing here starts, stops or restarts a daemon, and no test opens the host's config file.
One note for the record, because it is this PR's own subject: my first pass at the corpus read backwards, because git checkout master inside a worktree where master was already checked out failed silently behind a && chain and both runs measured the same tree. Re-run with the two trees named explicitly (the table above), the direction is unambiguous. A verdict about a tree you did not mean to measure is the defect class, not just a review accident.
|
Verified against this head ( The rule closes the issue's table
The two "Only adds refusals" holds on a 33-row corpus. Comparing master against this head over relative targets, in-workspace climbs, variable-resolved values, The file's own tests pass when driven in isolation (its module installed under The #1370 defect is confirmed, and it is three rows rather than oneMeasured on this head, each run for real:
So it is not only the refusal that is wrong: the reason names a path the shell never writes. Its own test file pins no row of this family — The fix direction, prototyped and measuredA sibling walk that carries the inside move too ( One measured trap inside that direction, because I walked into it first: if the allowed root is changed to the write site as well, Whichever spelling is chosen, the write-site rows deserve a row set of their own next to |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260918-131753 (the third vote on this head)
Landing tree measured, not the branch tip — and re-measured after the master moved while this cycle was reviewing: 7edea0e8 → 203c79f3a20d (3121 passed), and, since #1366 landed as a82d0e4a in between, git merge-tree --write-tree a82d0e4a 35b02bb6 → 85cedc783ba364511f72312db73b41d5e193eadf. The numbers below are the second one. (GitHub's merge ref ab30f80d is again based on bbde5dec, one master behind, so the advertised CI run measured a tree without #1367 — the tree that lands is the one I ran.)
Full suite on that tree: 3127 passed / 18 skipped (a82d0e4a's 3104/18 + the file's 23 rows — the arithmetic closes), git status clean in the worktree afterwards, so the file's scratch directories are removed and a green run leaves no residue.
The rule, read in the landing tree. The workspace-write branch used to continue on any relative target — an assumption about where the write lands, which a literal .. breaks without moving anything. It now resolves the target against base = workdir_real if workdir_real else cwd_real (the directory the child actually starts in: the declared workspace, or this process's cwd when none was declared — what execute() hands the child as cwd=None), and holds the result to the same three tests the absolute branch already applies. Two details I checked rather than assumed:
- It only adds refusals. Every path through the new block was a
continuebefore, so the diff cannot permit anything the previous tree refused. That matters more than the number of new blocks, and it is what keeps the #1359 property (an omitted workdir never permits what a declared one refuses) true: both readings now resolve against the base the child runs in and both require the result to stay under it. - It is not a spelling rule.
'..' in cmdwould have refused../ws/out.txtandsub/../out.txt, which stay ALLOW — the file'sTestTheClimbBackInsideIsStillAllowedrows are the over-block control, and they are the reason I agree with the resolved-target formulation rather than a text match.
Mutation arm — the hunk is what the tests are about. Inserting a bare continue before the block (i.e. reverting #1353 exactly) reddens 9 of the 23 rows: the three escape rows, the three refusal-message rows, the three omitted-workdir rows; the corpus/positive control is green both before and after, and tests/test_sandbox_omitted_workdir.py stays 12/12 (it does not need editing, as the body says). Restored from a byte snapshot — emrg/tools/bash_tool.py sha16 8da6e27529f4a7ae identical before and after, git status empty.
The /bin/sh half of the new file is what makes the refusal a claim about a command that really writes (the escape executes, and the file is asserted to appear outside the workspace and not inside it), and the scratch lives under tests/ on purpose — a tmp_path workspace would land the climb inside an allowed zone and every row would ALLOW for a reason unrelated to the rule.
DEVELOPMENT.md states the resolved-target rule and the refusal's wording, so the host-side reading matches the CI-side one. CI green on both legs at 35b02bb6 (run 35302606491: test 2m58s, test-windows 6m48s). The one residual I know of was filed rather than left implicit — issue #1370 (cd into a subdirectory is not tracked, so cd sub && echo x > ../back.txt is refused although it lands inside), fail-closed, with an absolute-path workaround, and not a reason to hold this PR.
Closes #1353.
What
_check_sandbox's workspace-write branch read a relative target as "inside the workspace" andcontinued. That assumption is about where the write lands, and a literal..breaks it without moving anything:A relative target is now resolved against the directory the command actually runs in — the declared workspace, or this process's cwd when the caller declared none (what
execute()hands the child ascwd=None) — and the resolved path is then held to the same rules the absolute branch already applies: no protected daemon file, not~/.emrgitself, and inside the base, the trusted data root or the OS temp root. The refusal names all three facts:Both spellings in the issue are covered, because the rule reads the resolved target and not the command's text:
echo x > ../escaped.txtT=. && echo hi > $T/../../escaped.txt(the #1316 resolution fills the variable in)cp a.txt ../copied.txtecho x > ../../escaped2.txtWhy a spelling rule would have been wrong
".." in cmdwould also refuse targets that climb out and come back —echo x > ../ws/out.txt, andecho x > sub/../out.txtin the common case. Those stay allowed here, andtests/test_relative_target_escape.py::TestTheClimbBackInsideIsStillAllowedpins them; the second mutation arm below is exactly that spelling rule, and it reddens those rows.Only adds refusals
Every relative target was allowed before this change, so the diff cannot permit anything new — including in the omitted-workdir reading, whose property (issue #1359: an omitted workdir never permits what a declared one refuses) is preserved because both readings now resolve against the base the child runs in and both require the result to stay under it.
tests/test_sandbox_omitted_workdir.pyis unchanged and green, and does not need editing.Verification
python -c "from emrg.client.app import run_client"→ ok;python -m emrg --help→ ok.emrg/tools/bash_tool.pysha168da6e27529f4a7aebefore and after):TestTheEscapeIsRealrows;'..' in expanded) → 6 failed: the climb-and-return rows and the plainsub/../out.txtcontrols.TestTheEscapeIsRealruns each row for real with/bin/shin a scratch workspace and asserts the file is created outside the workspace and is not created inside it (skipped on Windows, where/bin/shdoes not exist). Without that half, "the guard refuses this" could be a claim about a command that writes nothing.tests/, not undertmp_path: the workspace-write allowance coverstempfile.gettempdir(), so a climb out of atmp_pathworkspace lands in an allowed zone and the rows could not discriminate — the same traptests/test_sandbox_omitted_workdir.pydocuments for its own rows.DEVELOPMENT.mdgains the rule beside the existing refusal examples (A relative target is resolved, not assumed).Not in this PR
check_workspace_write(the write/edit tools) has the same relative-to-the-daemon-cwd reading, deliberately, and its call sites pass the original path for exactly that reason. Whether it should resolve against the injectedworkspaceinstead is a separate decision with its own false-positive surface (the tools resolve against the process cwd, which need not equal the injected workspace), so it is left alone here rather than half-changed.