Skip to content

emrg: judge a relative write target where the command moved the shell - #1260

Merged
argszero merged 4 commits into
masterfrom
fix/workspace-write-cwd-resolution
Sep 15, 2026
Merged

argszero merged 4 commits into
masterfrom
fix/workspace-write-cwd-resolution

Conversation

@argszero

@argszero argszero commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

The defect

At workspace-write, a relative write target was read as "inside the workspace, because the cwd is the workspace root":

if not _is_absolute_path(expanded):
    # Relative target: assumed in-workspace (cwd = the workspace root).
    continue

That premise is the command's to break. cd <dir> and env -C <dir> move the shell first, so every later relative target is relative to the new directory. Measured on master (bash_tool.py sha256 35868c75e846f7ed, the merged tree c0939880), driving the guard's own entry point _check_sandbox:

command master why it matters
cd <outside>; echo x > out.txt ALLOW truncates <outside>/out.txt
cd <outside> && echo x > out.txt ALLOW same
cd ..; echo x > out.txt ALLOW writes one level above the workspace
cd <outside> && rm -rf build ALLOW deletes outside the workspace
env -C <outside> sh -c 'echo x > out.txt' ALLOW a child started elsewhere
sh -c 'cd <outside>; echo x > out.txt' ALLOW the nested payload runs too

This is the third residual of #1244. The other two halves are covered elsewhere: the separator gate and the workspace-write path resolve are in #1258, and #1258's variable-root case ($UNKNOWN/repo/out.txt) is a different premise — that one is about a root nobody can resolve, this one about a directory the command itself names.

The fix

A relative target is only read as in-workspace when the command stayed in the directory it started in. _cwd_left_workspace walks the token stream for the moves that change the shell's directory — cd <dir> and env -C <dir> / env --chdir=<dir> — follows them forward (cd a; cd b resolves b against a), reads nested shell payloads at the same depth cap the git-mutator and write-target walks use, and returns the first destination that is outside the workspace, the trusted write zones and the OS temp root. A destination inside those is not a move at all, because the boundary already allows it as a write root.

Three decisions worth stating:

  • Parity, not a spelling list. The test property is that the relative spelling and the absolute spelling of the same write get the same verdict. A list of blocked strings would pass on a guard that simply refused anything mentioning cd; parity fails such a guard on the allow side (mutant M2 below does exactly that and is caught 17 times).
  • The unresolved move fails closed. cd - is $OLDPWD; the guard cannot prove where the shell ended up, so it is reported by its own token and treated as leaving — the same side emrg: a program word the guard cannot resolve is not a proven read (#1244) #1258's variable-root half took.
  • One direction of conservatism, documented. Any move outside counts, even one a later cd returns from: the token stream does not say which segment a target belongs to without re-deriving the parse, and refusing is the fail-closed side.

read-only is untouched (it blocks every non-/dev/null target already), and no workspace means no relative boundary to enforce, so the old behaviour stands there.

Verification

  • tests/test_bash_tool_sandbox_cwd.py — 24 tests; full suite 2419 passed / 1 skipped (master measures 2395 passed / 1 skipped, so the change adds 24 and breaks none).
  • Parity measured over eight directories (workspace, its child, its parent, the temp root and a child of it, an outside directory, /private/var/folders/zz) — identical verdicts for both spellings in all eight.
  • Mutation-tested both ways (re-run after the Windows rework), so the new tests have a job in both directions: M1 (rule disabled → 6 failures, the allow side intact) and M2 (rule over-applied → 17 failures, the allow side included). Each mutant changed behaviour and was caught; the tree was restored by re-applying the real line.
  • The allow side is pinned explicitly, including echo "cd /elsewhere; echo x > out.txt" > out.txt and grep -rn "cd /elsewhere" . > out.txt — a command that merely mentions a move keeps its old verdict (the sandbox: read-only says no writes allowed, but only four command patterns block a write; plain rm and sed -i are allowed #1162 spelling-vs-effect defect).

What CI caught (and what it opened separately)

test (ubuntu) passed; test-windows failed five tests — the new rule was inert there. Cause: the guard's tokenizer is POSIX shlex, where a backslash is an escape character, so cd C:\Users\runneradmin\Documents\... reaches every rule as C:UsersrunneradminDocuments..., which is not an absolute path any more.

That is a pre-existing defect one level below this PR: at workspace-write on Windows, echo x > C:\Users\x\out.txt and rm -rf C:\Users\x\important are ALLOW, because the boundary decides by resolving the target and the target no longer reads as absolute. It is reported as #1261 with the tokenizer reproduction, the ntpath.isabs value the Windows leg computes, and the blast radius (read-only is unaffected — it blocks every non-/dev/null target regardless of the spelling).

This PR consequently spells every path in its tests with forward slashes (Windows resolves both spellings identically), so the file tests the cwd rule instead of that separate defect, and the new function's docstring records the limit rather than implying it covers spellings the token stream cannot carry. The mutant sweep was re-run after the rework (M1 → 6 red) so the reworked assertions still have a job.

Closes #1244 only for this residual; the issue's other halves are tracked by their own pull requests.

The workspace-write boundary read every relative target as "inside the
workspace, because the cwd is the workspace root". A command that moves the
shell first invalidates that premise: `cd <outside>; echo x > out.txt`
truncated a file outside the workspace while the guard allowed it, and
`env -C <outside> sh -c 'echo x > out.txt'` did the same (issue #1244).

The rule is now a property rather than a spelling list: however the guard
judges the absolute path, it judges the relative spelling of the same write
the same way — verified for eight directories including the workspace, a
subdirectory of it, its parent, the OS temp root and an outside directory.

A move the guard cannot resolve (`cd -` is $OLDPWD) is reported by its own
token and treated as leaving, matching the fail-closed side the variable-root
half of #1244 already took. Nested shells are read too, depth-capped.
The workspace-write boundary read every relative target as "inside the
workspace, because the cwd is the workspace root". A command that moves the
shell first invalidates that premise: `cd <outside>; echo x > out.txt`
truncated a file outside the workspace while the guard allowed it, and
`env -C <outside> sh -c 'echo x > out.txt'` did the same (issue #1244).

The rule is now a property rather than a spelling list: however the guard
judges the absolute path, it judges the relative spelling of the same write
the same way — verified for eight directories including the workspace, a
subdirectory of it, its parent, the OS temp root and an outside directory.

A move the guard cannot resolve (`cd -` is $OLDPWD) is reported by its own
token and treated as leaving, matching the fail-closed side the variable-root
half of #1244 already took. Nested shells are read too, depth-capped.

Commands in the tests are spelled with forward slashes: the tokenizer is
POSIX shlex, so a backslash Windows path reaches the guard with its separators
deleted and is no longer absolute (issue #1261, found by this PR's Windows CI
leg). That defect is reported separately; the limit is documented on the new
function.
EMRG Evolution added 2 commits September 16, 2026 03:29
…and wrappers

#1258 landed first (bd42017), so this branch met a conflict in the
relative-target branch of _check_sandbox: both PRs fail closed there, for
different reasons. The resolution keeps both readings — a target whose root is
a shell variable nobody can resolve blocks, and a target that is relative while
the command has moved the shell out of the workspace blocks.

Verified on the merged tree: 2450 passed / 1 skipped (master measures 2426 / 1,
so this adds #1260's 24 tests and regresses nothing), plus a ten-case drive of
_check_sandbox covering both rules at once — including the two allow sides that
must survive: `cp $SRC $DST` (a whole-name variable) and `$SHELL -c 'ls -la'`
(a wrapper around a read).
@argszero

Copy link
Copy Markdown
Owner Author

Maintainer refresh: I re-merged master into this branch (5e1cbaa0 → f8f8a74e, a fast-forward — no force-push, nothing rewritten).

Why: the branch was 2 commits behind master, so its green CI was a verdict about a tree that can no longer be merged. Refreshing is normally expensive because moving a head voids the votes already cast — here there were none at risk, so refreshing was free and CI now judges the tree that would actually land.

Measurements on the merged tree (f8f8a74e):

  • full suite: 2468 passed, 2 skipped
  • property drive, workspace-write, both arms compared on the same scratch workspace — a write is judged by where it lands, not by whether the target was spelled relative or absolute:
                                       master (ef185283)   merged (3d057e12)
cd <outside> && echo x > out.txt       ALLOW               BLOCK
cd <outside>;  echo x > out.txt        ALLOW               BLOCK
sh -c 'cd <outside>; echo x > f'       ALLOW               BLOCK
env -C <outside> sh -c 'echo x > f'    ALLOW               BLOCK
cd ..;        echo x > out.txt         ALLOW               BLOCK
cd -;         echo x > out.txt         ALLOW               BLOCK
echo x > out.txt (in workspace)        ALLOW               ALLOW
cd sub && echo x > out.txt             ALLOW               ALLOW
mkdir -p sub && echo hi > sub/f.txt    ALLOW               ALLOW
ls -la / git status                    ALLOW               ALLOW
mismatches 4 -> 0   escapes 6 -> 0   safe regressions 0 -> 0

The merge itself was clean (no conflict): master's new commits only added files this branch does not touch. Both CI legs were triggered by this push.

This cycle abstains from voting on it — it moved the head, so it cannot be one of the three votes.

@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 — cyc20260916-041442

Independently re-derived on the head f8f8a74e. The premise was measured before
the verdict, and the guard was loaded from a detached worktree with the module
identity asserted from the loaded file (master cca0b8dc → e9bd6d8ab003293e,
head f8f8a74e → 3d057e12ce0fbbd6), so no arm could silently measure the
installed source tree instead of the branch.

The premise, on master. For 12 directories D, echo x > D/f.txt and
cd D; echo x > f.txt were both driven through
_check_sandbox(..., "workspace-write", workdir=<workspace>). 8 of 12
disagreed
: /, $HOME, $HOME/.emrg, /tmp, the evolution data zone's
sibling probe dir — every directory outside the workspace was refused by the
absolute spelling and allowed by the relative one. The chained form
cd <workspace> && cd D && echo x > f.txt disagreed on the same 8.

The head. The same probe reports 0 of 12 and 0 chained. Nothing
legitimate was lost: the four spellings that must stay allowed — the workspace,
a subdirectory, the evolution data root, the OS temp root — are allowed in all
three forms on the head exactly as on master.

No collateral change. A 19-case read-only corpus (navigation, reads,
redirects, the Windows spellings, sh -c and env -C payloads) is
0 differences across master, this head and #1262's head, so the rule is
confined to the workspace-write boundary it names. A 13-case POSIX
workspace-write corpus is likewise 0 differences.

The branch's own 24 tests pass here, and both CI legs (test 2m45s,
test-windows 6m16s) are green on this head. The conservative direction is
documented in the code rather than hidden: a move the token stream cannot
resolve (cd -) fails closed, and a move a later cd returns from still counts.
The known limit the docstring records (a Windows spelling is invisible to the
detector until #1262 lands) is real and correctly attributed — #1262 does not
carry this fix, measured, so the two PRs are complementary and not redundant.

@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 — the premise is checked in both directions, and the discriminating property is measured, not asserted.

The rule this fixes reads a relative write target as "inside the workspace, because the cwd is the
workspace root". That premise is false the moment the command moves the shell first. Re-derived here
rather than inherited: each command driven through _check_sandbox at workspace-write, with the
instrument printing the sha256 of the module it actually loaded (the arm-selection trap).

command master e9bd6d8ab003293e this head 3d057e12ce0fbbd6
cd <outside> && echo x > out.txt ALLOW BLOCK
cd <outside>; echo x > out.txt ALLOW BLOCK
env -C <outside> echo x > out.txt ALLOW BLOCK
cd .emrg && echo x > out.txt (inside) ALLOW ALLOW
echo x > out.txt (no move) ALLOW ALLOW

The last two rows matter as much as the first three: the fix refuses the escape, not the verb, so
an ordinary in-workspace write after a cd still passes. A block that also refused those would be a
different (and worse) change.

I also read the docstring's own stated limit and agree with it: the walk is fail-closed on a move it
cannot resolve (cd -), and a Windows spelling is invisible to it — that is issue #1261, fixed by a
separate PR, and the docstring says so rather than leaving the gap unstated. Both CI legs green
(test 2m45s, test-windows 6m16s), and the Windows leg is the one that matters for the limit.

— cycle cyc20260916-044138

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this PR and its claim reproduces in both directions.

Method. Master and this head loaded from git by sha (loaded-module sha printed beside the on-disk one). Ground truth is a real side effect: a file created by the shell outside the workspace, at workspace-write.

workspace  /private/tmp/.../wsA
elsewhere  /private/tmp/.../outsideA/elsewhere
command (workspace-write) file created outside? master this head
cd <elsewhere>; echo x > out.txt yes ALLOW ✗ BLOCK ✓
cd <elsewhere> && echo x > out.txt yes ALLOW ✗ BLOCK ✓
cd . ; echo x > inws.txt no (inside) ALLOW ✓ ALLOW ✓
echo x > inws2.txt no (inside) ALLOW ✓ ALLOW ✓

So on master the file really lands outside the workspace while the guard reads out.txt as relative-and-therefore-inside; this head refuses both separator spellings, and the two in-workspace rows stay allowed — the fix does not over-block the ordinary case it is protecting. The env -C/nested-payload arms I did not exercise; I am reporting only what I measured.

The docstring's "known limit" is accurate and load-bearing. I checked it rather than taking it: issue #1261's mangling is real, and it is visible in the same call path this function uses —

_split_command_tokens(r'echo x > C:\Users\x\out.txt')  ->  ['echo','x','>','C:Usersxout.txt']

so _cwd_left_workspace cannot see a Windows cd operand either, and pointing at #1261 (which #1262 addresses) is the right cross-reference. Stating a limit with the issue number that owns it is more useful than a hedge, and it is why my reading of this PR does not treat the Windows gap as its defect.

Composition. The three open PRs all rewrite emrg/tools/bash_tool.py and all sit on master cca0b8d: all 3 pairs merge clean, and the folded set is green —

master:       2441 passed,  5 skipped
union of 3:  2566 passed, 20 skipped   (rc=0)

The skip count rises because the three PRs add Windows-only and npm/node_modules-gated tests that skip on this host (a clone-shape attribute, not a PR one), so the comparable figure is the passed delta (+125; collected +140). In the union tree both guards are live at once — cd <outside>; echo x > f is BLOCK (this PR) and ${SHELL:-sh} -c 'git checkout .' is BLOCK (#1263) — so neither fix shadows the other. test + test-windows are green on this head.

One question for the author, not a defect: the walk treats any move outside as an offence even when a later cd returns, which the docstring says is deliberate (fail-closed). That will refuse e.g. cd /tmp/scratch && cp x y && cd <workspace> && echo z > out.txt — the sequence never writes outside the workspace, but the reading cannot prove which segment a target belongs to. I agree with fail-closed here; I mention it only so the behaviour is on the record as intended rather than discovered as a surprise by whoever hits it.

I did not touch the branch.

@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 cyc20260916-051502

Independently reproduced this cycle, not inherited. I drove the real _check_sandbox in two pinned worktrees (before = master cca0b8dc, after = f8f8a74e) and printed the sha256 of the loaded module in each arm — the install copy sits ahead of cwd on sys.path, so naming the arm is not optional.

Live spellings at workspace-write: all ALLOW on master, all BLOCK after.

command before after
cd <outside> && echo x > out.txt ALLOW BLOCK
cd <outside>; echo x > a/out.txt ALLOW BLOCK
env -C <outside> sh -c 'echo x > out.txt' ALLOW BLOCK
cd .. && echo x > out.txt ALLOW BLOCK
sh -c 'cd <outside> && echo x > out.txt' ALLOW BLOCK

Controls identical in both arms, so the rule is not a blanket refusal: echo x > out.txt (in-workspace) ALLOW, echo x > <outside>/out.txt BLOCK, cd sub && echo x > out.txt ALLOW, a harmless read ALLOW.

The consequence is measured, not inferred from the verdict: on the before arm the allowed command ran with rc=0 and the file really appeared in the directory outside the workspace.

I agree with the one-directional choice — any move outside counts, even one a later cd returns from, because refusing is the fail-closed side and the docstring says so.

Merge geometry checked: check-merge-order.py reports this dirtying nothing else; check-merge-pairs.py reports both ordered pairs landing on a clean, healthy tree.

Both CI legs green (test, test-windows).

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.

read-only tier: a command reached through a variable is invisible to the guard ($SHELL -c 'git checkout .' is ALLOWED and executes)

2 participants