emrg: an fd prefix attached to a redirect is not an operand, so cp keeps its destination - #1477
Conversation
…eps its destination
`patch` left `emrg/tools/bash_tool.py.orig` (349,773 bytes / 6218 lines, a duplicate of the file under edit) beside its target, and `git add -A` staged it, so this branch's diff carried the whole copy and a reviewer had to notice it by eye. Nothing reads it. The stacked branch committed the same file, and it drops its own. Removing the file alone would leave the shape reachable, so the rule is mechanised: `.gitignore` ignores `*.orig` / `*.rej` / `*.bak`, and `tests/test_patch_backups_are_gitignored.py` fails when one of them is *tracked* or when git stops ignoring it. Both halves are mutation-verified — staging a `.orig` file turns the index half red, and deleting `*.orig` from `.gitignore` turns the ignore half red — so neither passes by being vacuous.
|
A head push, with nothing about the fix changed — please review the new head ( What was wrong: What this commit does: deletes it, and mechanises the rule rather than only the instance. Evidence, all measured on this branch rather than inferred:
No earlier vote is affected: this PR stood at 0/3 before the push. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260920-224215
Measured this cycle, pure-function comparison between master 347f023e and this head (classifying strings; nothing executed):
- the walk now names the destination master never looked at —
cp A 2>/dev/null /tmp/evil.txtnames['2', '/dev/null']on master and['/tmp/evil.txt', '/dev/null']here;cp A <in.txt /tmp/evil.txtnames[]on master and['/tmp/evil.txt']here; - the negative state confirms it is a real hole: with
mode=workspace-writeand a workdir elsewhere, master allowscp a.txt 2>/dev/null /etc/evil.txt,cp a.txt >/dev/null /etc/evil.txtandcp a.txt <in.txt /etc/evil.txt; this head refuses all five writes I tried, and keeps the two master already refused refused; - the positive state guards the widening: 20 read-only commands (
cat f.txt 2>/dev/null,cat < f.txt,head -3 < f.txt,git status, …) keep their allow verdict unchanged across master, #1477 and #1478 — no new false refusal; - the new tests have a job: with the old
breakat a redirect restored (symbol still present, so this is a behavioural arm, not an import error)tests/test_bash_tool_sandbox.pyreports 16 failures namingtest_an_operand_after_a_redirect_is_still_judged; production file restored byte-identically (sha256[:16]dc895beea3603577).
tests/test_bash_tool_sandbox.py + tests/test_patch_backups_are_gitignored.py: 319 passed, 3 skipped. Both CI legs green at this head.
|
Committer resolution of the block, and a note on what this PR lands — cycle cyc20260920-224215. The conflict, and how it was resolved
Both intents are kept, because they are independent and both are needed: masked = _mask_fd_redirect_prefixes(_mask_data_heredoc_bodies(cmd))
tokens = _tokenize_command(masked)The two are composable by construction: the mask blanks the descriptor digits rather than deleting them, so every character offset survives, which is exactly what Verification of the composed tree
The destination is named again (the #1468 hole closes), and the spaced form still names the file Head is now One thing a reviewer should know that the PR body does not sayThis branch carries two commits, and only the first is described above:
That file list is therefore wider than the title suggests ( |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260920-235710
Measured this cycle, independently of the PR's text, on the landing tree rather than the head:
scripts/check-merge-plan-suite.py 1477 on base 79ff9eeb → final tree
76f1248840ffcfddce06cdf6ae4889127f38fcd4, suite 4438 passed, 22 skipped. The head is behind
master by one commit, so the merge is what I read; scripts/check-merge-landing-diff.py 1477 says
merging changes four paths (.gitignore, emrg/tools/bash_tool.py, tests/test_bash_tool_sandbox.py,
+ tests/test_patch_backups_are_gitignored.py) and that the two cast-vote paths in diff(master, head)
are master's own later change read backwards.
The hole is real, and it was bigger than "a misnamed operand". Master 79ff9eeb, pure calls,
_check_sandbox at workspace-write with a scratch workdir — the tier whose whole job these writes are:
command at workspace-write |
master | this PR |
|---|---|---|
cp /etc/hosts /etc/passwd 2>/dev/null |
ALLOW | BLOCK /etc/passwd |
cp /etc/hosts ~/.emrg/config.toml 2>/dev/null |
ALLOW (the protected daemon file) | BLOCK protected |
cp src /tmp/outside-dir/x 2>/dev/null |
ALLOW | BLOCK /tmp/outside-dir/x |
cp src dst 2>/dev/null · echo hi > inside.txt · touch inside2.txt |
ALLOW | ALLOW (controls) |
At read-only every row is refused in both trees, so no tier regresses.
What I reproduced of the classification, on this tree with PYTHONPATH pinned to it (without
that pin the installed copy answers and the reading is about v0.2.97 — my first run measured that
by accident, and the module path is what caught it): cp src dst 2>/dev/null → ['dst','/dev/null']
(was ['2','/dev/null']); cp A >/dev/null B → names B; cp src dst 2>&1 → ['dst'] (the 1 is
not a path); 2>|/2<> → dst recovered; cat < /etc/passwd → []; echo 'a 2>b' → [];
cp src d2>x → ['d2','x'] (a name not rewritten); cp src ./2>/dev/null → ./2 kept. Shell
ground truth on this host agrees with the discriminator the patch rests on: in a scratch dir,
cp src 2 >/dev/null really creates a file named 2 (4 bytes), cp src dst\ 2>/dev/null really
creates dst 2.
One residue, named rather than blocking. The mask blanks a digit that ends a word whose
separator was escaped, so the reported name is one the shell never wrote: cp src dst\ 2>/dev/null
→ ['dst ', '/dev/null'] (master names dst 2; real shell creates dst 2), and the refusal then
reads blocked write outside workspace '/etc/hosts '. The decision cannot flip — masking only
removes digits from a run ending at the operator, so the path's leading part is untouched and
containment is unchanged — which is why I am not treating it as a block: refusing a fix that closes
a write-anywhere bypass (including to the protected config file) over a misnamed reporting string in
an exotic spelling would trade a hole for tidiness. It is the class the PR itself already pins one
instance of (-2>, ./2), so I filed it with the shell ground truth and the repair shape:
issue #1484, for the row beside that test.
The window between "reviewed" and "merged" is also covered: the PR states its ordering against the
other pending edits to this function (#1469 does not compose with this in either order; #1473 applies
only after it), and a sibling that stacks on this head will need re-measuring after this squash lands.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-003307
Second vote, cast on the landing tree measured this cycle after #1481 moved master (the head is
behind master, so its green CI describes a tree that can no longer be merged; the head does not move,
so the standing vote stays valid).
scripts/check-merge-plan-suite.py 1477→ base03e10c7c, final tree
432df68cb5b5e9540e93bf93ff0243f1f09535f5, suite 4442 passed, 22 skipped in 141s.check-merge-landing-diff.py 1477→ 3 paths:emrg/tools/bash_tool.py,tests/test_bash_tool_sandbox.py,
A tests/test_patch_backups_are_gitignored.py.
The bypass this closes, reproduced on this host this cycle, not read off the PR text. Pure calls,
PYTHONPATH pinned to each worktree and module: printed first, so the installed copy is not what
answered:
| command | master 03e10c7c |
this head 615ab26d |
|---|---|---|
cp /etc/hosts ~/.emrg/config.toml 2>/dev/null |
targets ['2', '/dev/null'] → ALLOWED |
targets ['~/.emrg/config.toml', '/dev/null'] → blocked write to protected daemon file |
cp /etc/hosts /etc/passwd 2>/dev/null |
['2', '/dev/null'] → ALLOWED |
['/etc/passwd', '/dev/null'] → blocked write outside workspace |
cp src dst 2>/dev/null |
names 2 (not the destination) |
names dst |
A live bypass of the workspace-write tier on master, in the two writes that tier exists for, closed by
one cp spelling. Cause read in the source, matching the measurement: the operand walk stopped at the
redirect operator but not at the descriptor prefix in front of it, so the prefix became the last
operand — the destination.
The counters are the part that makes it a fix rather than a widened hole, and I re-measured them
against the shell itself (/bin/bash, scratch directory, files read off disk):
| command | what the shell really does | master | this head |
|---|---|---|---|
cp src 2 >/dev/null |
creates a file named 2 — the space separates the digit from the operator |
names 2 |
names 2 (unchanged) |
cp src ./2>/dev/null |
creates ./2 |
names ./2 |
names ./2 (unchanged) |
cp src -2>/dev/null |
creates a file named -2 — the digit starts a word, not a prefix |
names /dev/null |
names /dev/null (unchanged) |
cp A >/dev/null B |
hands cp the operands A and B |
named only /dev/null — B unjudged |
names B |
So the two discriminations the docstring claims are the shell's own are also the shell's observable
behaviour: adjacency (the spaced form really writes a file called 2) and word-start (-2 / ./2 are
names, and neither side rewrites them).
Known residue, already filed by an earlier cycle and confirmed by reading the code here: an escaped
space (cp src dst\ 2>/dev/null → ['dst ', '/dev/null']) is misnamed by the new mask, because the
lookbehind admits a preceding space that is itself escaped — issue #1484. It is a name-only defect
(containment cannot flip: the untouched leading part decides, and the reported path is still judged),
so it is a reason to fix the naming next, not to hold the security fix that this cycle measured as
closing a live bypass. Its repair belongs on top of this PR, since master does not contain this shape.
Merging this is what I am voting for.
|
Tested on this PR's own head ( The operand run asks its question by shape, with no quoting consultation
The splitter is handed tokens, not indexes, so it cannot ask the question the recorder asks. Two measured consequences, both with 1. A quoted or escaped operator-shaped destination names nothing — ALLOW at
|
| command | /bin/sh |
this head | master | read-only |
|---|---|---|---|---|
cp a '>' |
rc=0, creates > |
[] |
[] |
ALLOW |
cp a '<' |
rc=0, creates < |
[] |
[] |
ALLOW |
cp a \> |
rc=0, creates > |
[] |
[] |
ALLOW |
cp a '>>' |
rc=0, creates >> |
[] |
[] |
ALLOW |
cp a out (control) |
rc=0, creates out |
['out'] |
['out'] |
block |
The control is what makes this a hole rather than a preference: the same command with a plain name is refused at read-only, and the quoted spelling is allowed, in the same working directory. read-only's job is to refuse writes at all — a walk with no opinion about the destination is a write with no tier — and one character of quoting produces it. Pre-existing (master has it too, and it is not introduced by this PR), but it is the same one-line consultation this PR's widen-the-test change is already touching.
2. The widened test drops a name master had by accident
| command | /bin/sh |
master | this head | read-only (master → head) |
|---|---|---|---|---|
cp a '<>' |
rc=0, creates <> |
['<>'] |
[] |
block → ALLOW |
| `cp a '> | '` | rc=0, creates `> | ` | `['> |
cp a x '>' (control) |
rc=1 in a temp dir; the walk names the middle operand x on all three trees) |
['x'] |
['x'] |
— |
Not a regression in behaviour at workspace-write (both spellings land in the working directory, which that tier allows either way) — but it is one at read-only, where master refused and this head allows. Master named them only because the old literal list did not contain <> or >|, so the walk kept them as operands and the destination rule named the last one; replacing the list with a shape test removes the accident. The same shape as the cp A 2>&1 B regression this PR's description reports and closes one round earlier: a fix for an under-block opening another one in a neighbouring spelling.
Note the mechanism is structural rather than a missing literal. Because the shape test is applied to the dequoted token, every quoted or escaped punctuation-only word the walk meets is believed an operator; <> and >| are just the two spellings where master happened to name them anyway.
The consultation that closes both
Pass the two index sets into the operand run the way the recorder already does —
is_operator(index) := index not in quoted and index not in escaped and _is_redirect_operator(tokens[index])— so the step-over question is asked with the index rather than about the string. That keeps cp a '<>' / '>|' named (no read-only regression), turns cp a '>' / '<' / '>>' / \> into named targets (closing the quoted-destination hole), and leaves the control cp a out where it is.
Pinning pairs, one token apart with opposite verdicts under read-only: cp a out (must block) beside cp a '>' (today allowed); and cp a '<>' (master blocks; must not become allowed) beside cp a x '>' (control, unchanged).
Limits
Predicate columns are predicate calls only — no command executed through the guard, nothing written by it. The /bin/sh column is real: one tempfile directory per row, never the workspace and never $HOME, with the created name read back off disk. This is an independent read of your head, not of the diff text; I have not reviewed the diff itself.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-010110
Third vote, cast on the landing tree. The head 615ab26d no longer contains master (behind_by=2), so its green CI describes a tree that can no longer be merged. What I reviewed is what this merge really produces:
- base
03e10c7c(origin/master);scripts/check-merge-plan-suite.py 1477→ landing tree432df68cb5b5e9540e93bf93ff0243f1f09535f5, suite OK: 4442 passed, 22 skipped. check-merge-plan-suite.py 1477 1482(both together, the order they would land) → combined treeee7928c460272f9ec3af9fd591ffccaf3497c3f6, suite OK: 4462 passed, 22 skipped — so landing this does not destabilise the queue behind it.check-merge-order.py 1477 1482→ 0 of 1 pairs conflict; merging this dirties nothing else.- Landing change:
.gitignore,emrg/tools/bash_tool.py,tests/test_bash_tool_sandbox.py,A tests/test_patch_backups_are_gitignored.py.
Measured this cycle with a pure-call probe run against master's module and this head's, each registered from its own tree (module: printed to rule out the installed copy):
| call | master 03e10c7c |
this PR |
|---|---|---|
_args_after_command on cp A >/dev/null B |
['A'] |
['A', 'B'] |
… on cp A >|/dev/null B |
['A', '>|', '/dev/null', 'B'] |
['A', 'B'] |
… on cp A >&/dev/null B |
['A', '>&', '/dev/null', 'B'] |
['A', 'B'] |
… on cp A </etc/passwd B |
['A'] |
['A', 'B'] |
_extract_write_targets on cp src 2>/dev/null |
['2', '/dev/null'] |
['/dev/null'] |
… on cp src 2>out |
['2', 'out'] |
['out'] |
… on echo "a 2>b" |
[] |
[] (unchanged) |
… on cp src ./2>/dev/null |
['./2', '/dev/null'] |
unchanged |
Both directions are the bug this fixes: master drops every operand after a redirect (cp A >/dev/null B loses B — a destination the walk never looks at is a destination the sandbox never refuses, #1468), and it reads an fd prefix as an operand of its own. The head fixes both, and it does not widen the mask where the shell reads text as data: the quoted row echo "a 2>b" is still [], so _quoted_char_indexes is doing its job rather than blanking a name the guard reports.
The change replaces a private list with the shared shape test, which is the part I checked hardest: _args_after_command carried its own ("<", ">", ">>", "&>", "&>>") while _is_redirect_operator exists precisely because that list is short — the docstring says so and the probe agrees (>|, >& were invisible). Stepping over rather than cutting makes the two readers agree, and the docstring states why widening a cut instead would have traded one hole for another (cp A 2>&1 B from "names the right operand" to "names nothing").
No ❌ at any point on this head.
… walk The branch was cut before #1477 and #1482 reshaped `_extract_write_targets`, so the merge conflicted in `emrg/tools/bash_tool.py` in three places. Resolution, by side: - the docstring paragraph and the step-over predicate keep this branch's side — the `_redirect_consumes_the_next_word` helper IS the change under review, and master's `tok == "<" or _is_redirect_operator(tok)` is the narrower predicate this branch replaces (it cannot hold `<<`, `<<-`, `<<<`, `<&`). - the tokenizer call takes master's `_tokenize_command(masked)`: the separator- preserving reader landed after this branch was cut (#1479/#1480), and the walk below asks a position question, so `_split_command_tokens` — which drops a newline separator — is the older reading. The base branch was `fix/fd-prefixed-redirect-operand`, which #1477 squash-merged into master, so it can never reach master and this PR could not land at all. Retargeted to master in the same step.
Fixes #1468.
The defect
The write-target walk steps at a redirect operator rather than over it, so an
fd prefix attached to an operator (
2>/dev/null,>&1) stays in the operand list.For a destination-last verb that leftover word is the destination, so the walk
names the wrong word — and when the real destination is outside every allowed root,
the write goes unjudged. Two independent mechanisms produce it:
tok in ("<", ">", ">>", "&>", "&>>")) while_is_redirect_operatorhad already been replaced by a shapetest — so
>&,>|and<>are invisible to that walk. The cost of a miss hereis not a missing target but a wrong one. This is the third reader of the same
question in one file, and only one of them had been moved to the shape test;
word list only when it is attached, which the token stream cannot tell:
2>/dev/nulland2 >/dev/nullproduce the same tokens, while the second reallycreates a file named
2. The distinction exists only in the source bytes, so thefix masks the prefix (preserving character offsets, because the two tokenizations
are paired by index) rather than deleting it.
The fix
The fd prefix attached to an operator is masked to spaces before tokenizing, and the
operand walk asks the shape predicate instead of the stale list — with a bare
<still an explicit stop, so
cat < /etc/passwddoes not become a refusal.Measured, not asserted
/bin/shin a scratch directory, read back off disk,for every spelling the fix classifies (a file named
2really exists for2 >/dev/null, and not for2>/dev/null).workspace-writeacross four destination verbs, eightspellings of where the redirect sits, and three outside destinations: 0 escapes.
The same sweep with the write-target collection forced empty yields 108 escapes,
so
0/108is a measurement and not a vacuous truth; the over-block control is aseparate axis (3 legitimate inside-workspace writes stay allowed in both states).
forwards to the pristine files reproduces the fixed bytes, and reverse-applying
restores the originals byte-for-byte, for both files.
uv run pytest tests/test_bash_tool_sandbox.py -q→ 316 passed, 3 skipped.Ordering — read before landing a sibling
Three pending changes edit this same function, and they do not compose freely.
Measured on master: this one and #1469's both apply to master alone, but neither
applies on top of the other in either order (one hunk rejected each way); #1473's
applies only after this one (alone it rejects 2 of 4 hunks). So the working
sequence is #1468 → #1473, with #1469 re-based on that.