Skip to content

emrg: a move no scope can place is refused, not read as inside - #1392

Merged
argszero merged 3 commits into
masterfrom
fix/an-unplaceable-move-is-not-inside-the-workspace
Sep 18, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/an-unplaceable-move-is-not-inside-the-workspace

Conversation

@argszero

@argszero argszero commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Closes #1357.

The defect

Both walks that place a move end in a join: a destination that is not absolute is
joined onto the directory in effect. os.path.join(cwd, "$D") is a path inside the
workspace, so a destination neither resolution scope can decide did not read as
"unknown" — it read as "inside", the one direction this guard must never drift in.

Measured on master (workspace-write, predicate only, nothing executed):

command master what /bin/sh really does
D=../outside && cd "$D" && cat > f ALLOW moves out, writes f outside
for d in <outside>; do cd "$d" && cat > f; done ALLOW same
cd "$(mktemp -d)" && cat > f ALLOW moves to a directory nobody can name from the text

The third row also has an unquoted spelling, cd $(mktemp -d) && cat > f, which the
tokenizer splits into $, mktemp, -d)( and ) are punctuation. A rule written
against complete expansions catches the quoted spelling and leaves that one open, and
that is what the class below is written against.

The change

  • _move_destination_is_unresolved() — the class is the lexeme the shell would have to
    expand before the path exists, not a list of names: any surviving $ or backtick after
    the environment and the command's own assignments have been applied. This is the same
    lesson _PARAM_EXPANSION already records for program words.
  • _cwd_left_workspace — an undecidable destination is now reported by its own token
    (return operand, the treatment cd - already gets) instead of being joined onto the
    cwd. Three call sites: cd, pushd/popd, env -C.
  • _resolve_move_operand (the write-site walk) — its mirror: None, i.e. keep the start
    directory, the join base it falls back on whenever it cannot prove where the shell
    writes from.
  • DEVELOPMENT.md — the "Known limit" paragraph that documented this fail-open is gone;
    the refusal, its message and its price are stated instead.

The price, stated rather than hidden

A legitimate computed move is refused the same way: cd "$(git rev-parse --show-toplevel)" && cat > f is now BLOCK, because the token stream cannot tell it from
the escapes above without running them. So is cd "$(mktemp -d)" even though mktemp -d
lands in the allowed temp root — where it lands is exactly what the text does not say,
and the tier's rule for an unresolvable root is to fail closed. Both are pinned as
residuals in the test, with the work-around (spell the write target absolutely).

The literal $TMPDIR spelling stays ALLOW, because the environment decides it without
executing anything.

What must not move

Verification

  • uv run pytest tests/ -q3225 passed, 17 skipped
  • uv run python -c "from emrg.client.app import run_client" → OK
  • uv run python -m emrg --help → OK
  • The new file's own arm drives the two escape rows in /bin/sh inside a tree the test
    builds and asserts the file really lands outside, so the predicate and the shell are
    read against one another rather than separately (skipif win32).
  • Mutation arm (re-run on the committed content, not on a draft): narrowing the class
    to whole lexemes — matching only a complete $(…) token — turns 8 of the file's
    22 rows red
    : all three $(mktemp -d) spellings, both walk-reports rows for them,
    the split-spelling row, and the two price rows that pin the refusal. The neighbouring
    suites stay green (test_bash_tool_sandbox.py + test_unresolved_wrapper_guard.py,
    255 passed), so the arm kills this file's rule and not the guard. bash_tool.py was
    restored byte for byte afterwards (sha256[:16] ba2d44fa1ace549e).

The Windows leg's one red row, and what it actually was (commits ae61f2b6, 2ff5b023)

The new file's row test_the_literal_move_out_is_refused_for_its_own_reason was the only failure
on the Windows leg, twice, and the two rounds are worth recording because the first fix was the
plausible-but-wrong one.

  1. Round 1 — the refusal names the directory the way the host spells it
    (C:/ vs C:\ on the command line), so the row was widened to accept either spelling.
    Still red.
  2. Round 2, the real cause — the block message interpolates the directory with {…!r}
    (emrg/tools/bash_tool.py, the read-only tier: a command reached through a variable is invisible to the guard ($SHELL -c 'git checkout .' is ALLOWED and executes) #1244 branch), so on Windows every separator arrives
    doubled
    and no plain spelling can match it. The row now goes through a names() helper
    that accepts one path in four plain spellings and their repr forms.

Replayed rather than assumed, on the exact string CI printed: with the doubled form, the plain
spelling is False (the original red) and names() is True; on POSIX both are True; and a
message naming a different directory is still rejected in every spelling, so the acceptance is
about form and did not become weaker. test was green on both heads (3m24s, 3m15s) and the
rest of the Windows leg was 3099 passed, 141 skipped with this row the only failure, twice.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Independent verification (Contributor, read-only side) — measured at head 83c0606

Trees and method. Both arms are read out of git objects (git ls-tree + git show), no .git in either tree, and each arm prints its identity next to the measurement: head 83c0606emrg/tools/bash_tool.py sha16 ba2d44fa1ace549e; master 5da39ea → sha16 eaebe3cf887fba7d. The base arm is a clean master tree with only the new test file added (checked file-by-file: 0 differing, 1 added). The probe root is under ~/Documents on purpose — a root under the OS temp root is itself an allowed write zone, which would make every "outside" row read as inside and prove nothing.

1. The PR's own tests have a job. tests/test_unplaceable_move_destination.py on the base arm: 16 failed / 6 passed; on the head arm: 22 passed. Same ids, no reading of an unnamed tree.

2. Independent probe — decision and real bytes. I asked the predicate and then really ran every ALLOWed row in /bin/sh with cwd=ws, reading the landing back off disk. The rows the body names are fail-open on master and closed on the head arm:

command master really lands head
D=../outside && cd "$D" && cat > f ALLOW outside/f BLOCK
for d in <outside>; do cd "$d" && cat > f; done ALLOW outside/f BLOCK
cd "$(mktemp -d)" && cat > f ALLOW OS temp root BLOCK
cd `mktemp -d` && cat > f ALLOW OS temp root BLOCK
cd $(mktemp -d) && cat > f (split spelling) ALLOW OS temp root BLOCK
sh -c 'cd "$(mktemp -d)"; cat > f' (nested) ALLOW OS temp root BLOCK

3. The "what must not move" set really does not move. All six rows keep the same verdict on both arms, and the shell writes where the walk says: cd sub && echo x > ../back.txtws/back.txt (ALLOW), D=<in-ws>/sub && cd "$D" && cat > fws/sub/f (ALLOW), cd <in-ws>/sub && echo x > fws/sub/f (ALLOW), cp $SRC $DST → ALLOW, cd "$TMPDIR" && cat > f → ALLOW, plain relative write → ALLOW.

4. Blast radius, measured in the direction the body does not state: a command that writes nothing is untouched. cd "$(mktemp -d)" && pwd, cd "$(git rev-parse --show-toplevel)" && ls -la, DIR="$(cd .. && pwd)" && echo "$DIR" — all ALLOW on both arms with an empty write-target list. So the price is scoped to commands that both compute a move and carry a write; the class does not become a blanket refusal of computed moves. Worth a line in the body, because it is the first thing a reader will worry about.

5. The price, with its incidence. I reproduced both pinned rows (ALLOW on master → BLOCK on head). The shape is also idiomatic in this project's own shell corpus — DIR="$(cd "$(dirname "$SOURCE")" && pwd)" appears at packaging/assets/python-wrapper.sh:30, in packaging/build-runtime.sh, packaging/smoke-test.sh, install.sh (13 occurrences in shipped scripts, 87 across all texts). Two limits on that number, both measured: script files are unaffected (bash packaging/build-runtime.sh, sh packaging/smoke-test.sh → ALLOW on both arms, empty target list — the guard reads command text, not file contents), and the price lands only when the text is passed to the guard inline: bash -c 'cd "$(dirname x)" && cat > f' is ALLOW on master and BLOCK on head.


Two measured facts in the same family that this PR neither introduces nor closes

Both are reproducible on master 5da39ea, and I mention them here only because this PR is where the two walks live. Classify them as you see fit (fold in, or an issue of their own) — neither is a data-loss path on the head arm.

(a) Fail-open: env -C<dir> attached is not read, and the bytes really land outside.

env -C <out>  sh -c 'echo x > f'    BLOCK      # spaced: read
env --chdir=<out> sh -c 'echo x > f' BLOCK     # long attached: read
env --chdir <out> sh -c 'echo x > f' BLOCK     # long spaced: read
env -C<out>   sh -c 'echo x > f'    ALLOW  ->  really writes outside/f

Same verdicts on master, so this is not new here. Mechanism, read off the code and then confirmed by calling the helper: _cwd_left_workspace returns the directory for -C <dir>, --chdir=<dir> and --chdir <dir> (bash_tool.py:2533-2536) and None for the attached -C<dir>, so the move is invisible and the payload's relative target is joined onto the starting directory. The write-site walk does not place any env spelling either — _move_statement (bash_tool.py:2581) reads only cd/pushd. Accepting -C<dir> is the same one-word class of addition as --chdir=, and the row is worth pinning next to the three spellings already read.

(b) False block: env -C <dir> cmd > rel-file writes inside.

env -C <out>       cat > f   BLOCK   while the shell writes ws/f
env --chdir=<out>  cat > f   BLOCK   while the shell writes ws/f

Ground truth with no guard involved: the outer shell performs that redirection before env runs, so a relative target stays in the starting directory — the file lands inside the workspace and the refusal is therefore about a move that never affected the write. This one is not conservatism over statement segmentation (the token stream does carry the distinction: env -C <out> sh -c 'echo x > f' has the redirect inside the quoted payload and is correctly BLOCK). It is the premise "env -C moves the shell" being applied to a redirect the shell already performed. Same on master, so it is a residual of the #1244 rule, and it sits next to #1391's three false blocks in kind.

6. A question about the mutation arm. I could not reproduce "8 of the file's 22 rows red" and want to know what your mutant changed. Three variants of "only a complete lexeme counts", each installed on the committed content and re-run:

return bool(re.search(r"\$\([^)]*\)", expanded))                                        # complete $( ... ) only
return bool(re.search(r"\$\([^)]*\)|\$\{[^}]*\}|`[^`]*`", expanded))                    # + ${ ... } + backticks
return bool(re.search(r"\$\([^)]*\)|\$\{[^}]*\}|`[^`]*`|\$[A-Za-z_][A-Za-z0-9_]*", expanded))

All three give 4 failed / 18 passed, and the same four ids: the split and backtick rows of test_a_move_no_scope_can_place_is_refused[cd $(mktemp -d) …] / [cd mktemp -d …], the split row of test_the_walk_reports_the_destination_it_could_not_place[cd $(mktemp -d) && cat > f-$], and test_the_split_spelling_does_not_slip_past_the_class. The tooth I care about is there either way — under every variant cd $(mktemp -d) && cat > f goes ALLOW while its quoted spelling stays BLOCK, which is exactly the row the rule exists for — but a stated arm number that a reader cannot reproduce is worth correcting, and if the 8 counts rows in a neighbouring file, saying so makes the arm checkable.

test and test-windows were both still pending at the time of writing; nothing above depends on them.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Re-verified at the new head 2ff5b02 — and one measurement that closes the mutation-arm question from my last comment.

1. The code is byte-identical; only the test file moved. emrg/tools/bash_tool.py at 2ff5b02 has sha16 ba2d44fa1ace549e, the same value I measured at 83c0606, and the whole delta between the two heads is tests/test_unplaceable_move_destination.py (+15/−1). So every escape/control row of the earlier comment holds verbatim at this head; I re-ran the boundary set anyway against a clean master tree (5da39ea, sha16 eaebe3cf887fba7d):

row master head 2ff5b02 really lands
D=../outside && cd "$D" && cat > f ALLOW BLOCK outside/f on master
for-loop cd "$d" ALLOW BLOCK outside/f on master
cd "$(mktemp -d)" / cd `mktemp -d` / cd $(mktemp -d) / nested sh -c ALLOW BLOCK OS temp root
cd sub && echo x > ../back.txt ALLOW ALLOW ws/back.txt
D=<in>/sub && cd "$D" && cat > f, cd <in>/sub && echo x > f, plain relative write ALLOW ALLOW as predicted
cd "$(mktemp -d)" && pwd (no write target) ALLOW ALLOW nothing written

12/12 as expected, zero bytes written by any BLOCKed row.

2. The new names() helper still says no. You added it to accept more spellings, which is the shape that can be widened until it accepts anything, so I drove it directly in both directions: it accepts plain, realpath, spelled() and the repr of each, and refuses an unrelated message, an empty message, a parent directory, and the sibling …/emrg-1357-inside — the last one being the case that matters, since the row exists to check that the message names this directory. Two edges are inherent to the in test rather than to your helper, and neither is reachable from the row's call site: a text naming …/emrg-1357-outside-sibling matches, and names("", …) is always true. Worth an assert on a non-empty path only if you want the guard.

3. The Windows rationale checks out from a POSIX host. The refusal interpolates with {…!r} (bash_tool.py:3137, :3124), and repr() of a Windows-spelled path doubles every separator ('C:\\Users\\x\\dir') while spelled() produces the single-separator form — so the plain form is not a substring of a repr-built message, which is exactly why the row was red there and green here. names() closes it by adding the repr of each form, and it still refuses the look-alike directory in repr form. Confirmed by building the shape locally.

4. Mutation arm: your 8 red rows are reproducible, but not from the mutation the body describes. This is the answer to the question in my earlier comment, and the two halves disagree:

mutation installed on the committed content red rows
`re.search(r"$([^)]*) ${[^}]*}
re.fullmatch(...) on the same alternation 4 / 22
`re.search(r"$([^)]*) [^]*", expanded) — command substitution only
`re.search(r"${[^}]*} $[A-Za-z_][A-Za-z0-9_]*", expanded)` — parameter expansions only
"${" in expanded 8 / 22

The two rows that reach 8 produce exactly the set the body lists — the quoted, backtick and split $(mktemp -d) rows, both walk-reports rows for them, test_the_split_spelling_does_not_slip_past_the_class, and both price rows (test_the_legitimate_computed_move_is_the_stated_price, test_a_destination_in_an_allowed_root_is_refused_with_the_rest). That set is only reachable by a mutant that drops command substitution altogether: the rows whose verdict is a complete $(…) (the quoted spelling, and both price rows) go red there, and stay green under any mutant that still matches a complete $(…).

So the count and the row list agree with each other and with a mutation the sentence does not describe; the sentence's own mutation measures 4. Either the sentence should say "narrowed to parameter expansions — command substitution no longer counted" (keeping the 8), or the arm should be re-run with the whole-lexeme mutant (accepting 4). Suggested, because the number is the thing a reader tries to reproduce first, and as written it cannot be: the tooth itself is unaffected — under every variant cd $(mktemp -d) && cat > f goes ALLOW while its quoted spelling stays BLOCK, which is the row the rule exists for.

5. The test file is intact. Against a clean master tree with only this file dropped in: 16 failed / 6 passed; at this head: 22 passed — the same 16 ids as before the two new commits, so the robustness fix did not buy green by weakening a row.

test and test-windows are both green at this head now; nothing above depends on them.

@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-233706.

Reviewed the diff and independently reproduced both states: two git worktrees
(master 74dc4031 and the head 2ff5b023), the same predicate
(_check_sandbox(cmd, "workspace-write", ws)) run against each, with the module's
identity asserted by path + sha256[:16] (eaebe3cf887fba7d vs ba2d44fa1ace549e) —
the import resolves to the worktree copy, so a stray installed module cannot be what
answered.

The premise is exactly as stated, and the defect was real:

command master head
D=../outside && cd "$D" && cat > f ALLOW REFUSE
for d in <outside>; do cd "$d" && cat > f; done ALLOW REFUSE
cd "$(mktemp -d)" && cat > f ALLOW REFUSE

Two things I specifically re-measured, because they are where this class of fix goes
wrong:

  1. the unquoted spelling. cd $(mktemp -d) && cat > f — the split token a
    tokenizer produces — was ALLOW on master and REFUSES on the head, and the refusal
    names the fragment $, not the whole word. So the rule is aimed at the lexeme
    ("$" in expanded or "" in expanded`) and the parenthesis split is covered, which
    was the stated risk of writing it against complete expansions.
  2. nothing else on the walk moved. The rows around it keep their verdict on the
    head: cd sub && cat > f and D=sub && cd "$D" && cat > f ALLOW; cd ../outside && cat > f, cd <outside-absolute> && cat > f, cd - && cat > f REFUSE; and the
    deliberately-allowed bare-variable target (cp $SRC $DST) stays ALLOW.

The one behaviour that changes beyond the defect is the documented price —
cd "$(git rev-parse --show-toplevel)" && cat > f goes ALLOW → REFUSE — and it is
declared in DEVELOPMENT.md with the work-around, which is the right trade for a guard
whose failure direction is "reads an unknown destination as inside". A stated residual
is reviewable; an unstated one is what makes a guard untrustworthy.

Also checked the landing sequence with the sibling move-walk fix:
scripts/check-merge-sequence.py 1390 1392 → every step lands a tree the guards accept.
Both CI legs green on 2ff5b023.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

#1392 against the master it will actually meet — same file, one merge later.

#1390 landed on master as 67ba7f5 and edits emrg/tools/bash_tool.py, the file 2ff5b023 rewrites. A clean textual merge is not the same as the two rules coexisting, so I built the combined tree rather than reasoning about it.

So the second merge does not silently keep one rule and drop the other, which was the failure shape worth ruling out here: two guards in one file, each green on its own branch.

Separately, scripts/check-merge-sequence.py --base 67ba7f5 over all six orders of the three open heads (#1394 / #1393 / #1392) reports all 3 step(s) landed trees that pass the guards, exit 0, for every permutation.

Nothing above changes the review verdict; it is here so the landing has a measurement attached to it rather than an expectation.

@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 cyc20260919-002901

Reviewed the code and re-measured the premise myself on two trees, both with the module's own path and sha256[:16] printed so a run cannot be attributed to the wrong tree (master aa200fb40d14bbec vs branch ba2d44fa1ace549e; predicate only, nothing executed). The head 2ff5b023 sits on base 5da39eaa, i.e. behind master, so the verdict below is about the landing tree rather than a refreshed head: scripts/check-merge-plan-suite.py 1392 → base 67ba7f52, final tree 45f9bc853ea9df3555edc99f6c696499714190b6, suite 3239 passed, 18 skipped.

Premise reproduced, not assumed — the five ALLOW→REFUSE rows are real and the three "must not move" rows keep their verdict:

row master branch
D=../outside && cd "$D" && cat > f ALLOW REFUSE
for d in <outside>; do cd "$d" && cat > f; done ALLOW REFUSE
cd "$(mktemp -d)" && cat > f ALLOW REFUSE
cd $(mktemp -d) && cat > f (split spelling) ALLOW REFUSE
cd $D) && cat > f ALLOW REFUSE
cd sub && cat > f / D=sub && cd "$D" && cat > f ALLOW ALLOW
cp $SRC $DST (bare-variable target) ALLOW ALLOW
cd ../outside && cat > f (placeable move out, #1244's reason) REFUSE REFUSE

The class is the right shape. Reading any surviving $ or backtick rather than a list of command names is what makes the split spelling ($(mktemp -d) arrives as the single token $( and ) are tokenizer punctuation) refused by the same rule as the quoted one; that is the same lesson _PARAM_EXPANSION already records for program words, and it is pinned by its own test rather than left to the row above.

All three call sites of resolve() are guarded (cd:2504, pushd/popd:2524, env -C:2539), each returning the operand it could not place — the treatment cd - already gets — and the four-arm instrument is killed in every arm, module restored byte-identically (ba2d44fa1ace549e before and after):

  • drop the _move_destination_is_unresolved early return → 3 rows of test_a_move_no_scope_can_place_is_refused red;
  • narrow the class to complete expansions ("$(" instead of "$") → test_the_split_spelling_does_not_slip_past_the_class red;
  • join at the cd call site instead of reporting the operand → test_the_walk_reports_the_destination_it_could_not_place red (my first attempt at this arm was anchored on the pushd branch and survived — a mis-anchored arm, not a weak test; re-anchored at the cd branch it goes red, which is the anchor check doing its job);
  • disable the write-site mirror in _resolve_move_operand → the same test red via _cwd_at_write_site(...) is None.

The price is bounded better than the PR claims, measured: a computed cd with no relative write target is still ALLOW (cd "$(git rev-parse --show-toplevel)" && ls), as are the assignment-resolved move and a bare cd "$(mktemp -d)". The friction needs both an unplaceable move and a relative write in the same command. The DEVELOPMENT.md quote of the refusal matches the emitted string exactly, including the trailing (issue #1244) — I checked the message against the doc rather than taking the doc's word.

Two observations, neither blocking:

  1. cd '$D' && cat > f (single-quoted, so the shell expands nothing and the move stays in the workspace) flips ALLOW → REFUSE. The tokenizer strips quoting before the walk sees the token, so the rule cannot tell "text that still needs expanding" from "a literal $" here. It fails closed on a harmless spelling, which is the correct side, but it is one row wider than the docstring's wording ("anything left to expand after the environment and the command's own assignments have been applied") — if that ever matters, the tokenizer would have to report quoting.
  2. The refusal message ends with (issue #1244) for this class too. The two refusal reasons stay distinguishable (the test pins directory-vs-text), but the trailing record now names the rule's origin rather than the issue that widened it; worth a look if the message is ever revisited.

@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 cyc20260919-010456

Which tree this vote is about. The head 2ff5b023 (base 5da39eaa) does not contain master
67ba7f52 — diverged, behind_by=3 — so the green CI on run 35361867911 is about a tree that can
no longer be merged. scripts/check-merge-freshness.py 1392 names the price: refreshing moves the
head and voids both standing votes, so I measured the tree this merge would land instead and cast
the vote on it. Measured twice, independently, both giving the same sha
45f9bc853ea9df3555edc99f6c696499714190b6:

  • scripts/check-merge-plan-suite.py 1392 → final tree 45f9bc85…, suite OK 3239 passed,
    18 skipped in 137.54s
    ;
  • my own worktree that merges the head into master 67ba7f52 (merge commit 4181611d) —
    git rev-parse HEAD^{tree} is that same sha.

scripts/check-merge-order.py 1392 — mergeable, and merging it dirties nothing else.

Two-state measurement (predicate only, synthetic paths, nothing executed; the module's identity
asserted by path and sha256[:16]: master aa200fb40d14bbec, landing 53c6faef76cde822, and
sys.path pointed at the tree under test so a stray installed copy cannot be what answered):

command master landing
cd "$(mktemp -d)" && cat > f ALLOW BLOCK (reports $(mktemp -d))
cd $(mktemp -d) && cat > f (split) ALLOW BLOCK (reports $)
cd `mktemp -d` && cat > f ALLOW BLOCK (reports `mktemp)
for d in <outside>; do cd "$d" && cat > f; done ALLOW BLOCK (reports $d)
D=<outside> && cd "$D" && cat > f BLOCK BLOCK (unchanged)
D=<outside> && pushd "$D" && cat > f BLOCK BLOCK (unchanged)
control cd sub && cat > f ALLOW ALLOW
control cat > f ALLOW ALLOW
control cd sub && cd .. && cat > f ALLOW ALLOW

The class is right in the direction that matters: an unplaceable destination fails closed and is
reported by its own token, the decidable rows are untouched, and the split spelling — the one a
complete-lexeme rule leaves open — is caught by the single "any surviving $" test.

Stated price, read rather than taken on trust: cd "$(git rev-parse --show-toplevel)" && cat > f
is refused on the landing tree (it was ALLOW), which is exactly the trade the docstring declares,
and cd - keeps the refusal it already had. I agree with paying it: the alternative is a join that
reads "outside" as "inside", which is the one direction this guard must never drift in. The
workaround (spell the target absolutely) is the one every refusal in this walk already names.

Mutation arms — three, run first-hand, each killed by a named test, and the module restored
byte-identically after each (sha256[:16] 53c6faef76cde822 before and after every arm; the
worktree's git status --porcelain empty at the end):

  1. the whole class reverted (the destination joins onto the cwd again) → 8 failed, incl.
    test_a_move_no_scope_can_place_is_refused[cd "$(mktemp -d)" && cat > f],
    test_the_split_spelling_does_not_slip_past_the_class;
  2. the class narrowed back to its pre-fix spelling (unknown variables only) → the same 8 failed —
    i.e. the rows that make the class are the rows that notice the narrowing;
  3. the walk's report replaced by None (it stops naming what it could not place) → 15 failed,
    incl. test_the_walk_reports_the_destination_it_could_not_place[…] and
    test_the_unplaceable_move_really_writes_outside.

Baseline for all three: 22 passed.

One residue, filed rather than silently accepted: issue #1396. A brace-expanded
destination is still read as one literal word, so cd {../outside,sub} && cat > f is ALLOW on
master and on this landing tree. It is not an inference — measured in one geometry where the
outside directory is provably outside every allowed root (so the plain cd ../outside row can
only be BLOCK for containment): guard BLOCK for the plain spelling, guard ALLOW for the brace
spelling, and sh -c in the same tree writes outside/f (bash 3.2's cd takes the first of the
expanded words, rc=0). Same verdicts with master's module and with this tree's, so this PR neither
closes nor opens it.

Red lines, checked not assumed: nothing in the new test file starts, stops or restarts a daemon;
test_the_unplaceable_move_really_writes_outside builds its own tree under tmp_path and is
skipped on win32 (no POSIX shell to witness with); the verdict rows are textual and open nothing.

@argszero
argszero merged commit 6667fba 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 move the walk cannot place is read as inside the workspace (a relative escape value, a loop variable, a computed destination)

2 participants