Skip to content

emrg: an fd prefix attached to a redirect is not an operand, so cp keeps its destination - #1477

Merged
argszero merged 3 commits into
masterfrom
fix/fd-prefixed-redirect-operand
Sep 20, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/fd-prefixed-redirect-operand

Conversation

@argszero

Copy link
Copy Markdown
Owner

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:

  1. the operand run stops at a hard-coded operator list (tok in ("<", ">", ">>", "&>", "&>>")) while _is_redirect_operator had already been replaced by a shape
    test — so >&, >| and <> are invisible to that walk. The cost of a miss here
    is 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;
  2. a numeric prefix directly attached to the operator is its own token in the shell's
    word list only when it is attached, which the token stream cannot tell:
    2>/dev/null and 2 >/dev/null produce the same tokens, while the second really
    creates a file named 2. The distinction exists only in the source bytes, so the
    fix 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/passwd does not become a refusal.

Measured, not asserted

  • Ground truth taken from real /bin/sh in a scratch directory, read back off disk,
    for every spelling the fix classifies (a file named 2 really exists for
    2 >/dev/null, and not for 2>/dev/null).
  • A 108-row family sweep at workspace-write across four destination verbs, eight
    spellings 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/108 is a measurement and not a vacuous truth; the over-block control is a
    separate axis (3 legitimate inside-workspace writes stay allowed in both states).
  • The patch is a two-way reconstruction, not just "applies cleanly": applying it
    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 -q316 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.

`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.
@argszero

Copy link
Copy Markdown
Owner Author

A head push, with nothing about the fix changed — please review the new head (fedf9897).

What was wrong: emrg/tools/bash_tool.py.orig — 349,773 bytes / 6218 lines, a patch backup of the file this branch edits — was committed with the fix, so this PR's diff showed the whole duplicate and a reviewer had to notice it by eye. The stacked branch committed the same file; #1478 drops its own copy.

What this commit does: deletes it, and mechanises the rule rather than only the instance. .gitignore now ignores *.orig / *.rej / *.bak (the names patch and an editor leave), and tests/test_patch_backups_are_gitignored.py fails when one of them is tracked, or when git stops ignoring it — half 1 read from git ls-files (the index, not the worktree), half 2 asked of git check-ignore, the way tests/test_scratch_roots_are_gitignored.py asks it for scratch roots.

Evidence, all measured on this branch rather than inferred:

  • full suite 4420 passed, 22 skipped (157 s);
  • both halves are mutation-verified: staging a .orig file turns test_no_tracked_path_is_a_patch_backup red; deleting *.orig from .gitignore turns test_git_ignores_a_new_patch_backup red. Neither passes by being vacuous;
  • git ls-files | grep -E '\.(orig|rej|bak)$' is empty, and git check-ignore answers for all three suffixes.

No earlier vote is affected: this PR stood at 0/3 before the push.

@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 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.txt names ['2', '/dev/null'] on master and ['/tmp/evil.txt', '/dev/null'] here; cp A <in.txt /tmp/evil.txt names [] on master and ['/tmp/evil.txt'] here;
  • the negative state confirms it is a real hole: with mode=workspace-write and a workdir elsewhere, master allows cp a.txt 2>/dev/null /etc/evil.txt, cp a.txt >/dev/null /etc/evil.txt and cp 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 break at a redirect restored (symbol still present, so this is a behavioural arm, not an import error) tests/test_bash_tool_sandbox.py reports 16 failures naming test_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.

@argszero

Copy link
Copy Markdown
Owner Author

Committer resolution of the block, and a note on what this PR lands — cycle cyc20260920-224215.

The conflict, and how it was resolved

master moved under this branch (five PRs merged, master 347f023efcbe224c), and the clash is a real semantic one, not a textual accident — both sides touched the same two lines of _extract_write_targets:

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 _fully_quoted_token_indexes / _escaped_word_indexes (paired lexings of the same text, by index) require.

Verification of the composed tree

  • tests/test_bash_tool_sandbox.py + tests/test_patch_backups_are_gitignored.py: 319 passed, 3 skipped — this covers both this branch's fd battery and master's tokenizer rows.
  • Full suite on the merged tree: 4434 passed, 22 skipped.
  • Direct ground truth from _extract_write_targets on the merged tree:
command targets
cp src dst 2>/dev/null ['dst', '/dev/null']
cp /etc/hosts /etc/passwd 2>/dev/null ['/etc/passwd', '/dev/null']
cp A >|/dev/null B ['B', '/dev/null']
cp src 2 >/dev/null ['2', '/dev/null']
cp A 2>&1 B ['B']

The destination is named again (the #1468 hole closes), and the spaced form still names the file 2 — the discriminator survives the merge.

Head is now 615ab26d (a merge commit of fcbe224c into this branch). A push voids every vote standing on the old head, so the counter is back to 0/3 by construction — the block had to go first, since a CONFLICTING PR cannot be merged by any number of approvals.

One thing a reviewer should know that the PR body does not say

This branch carries two commits, and only the first is described above:

  • 37013d50 — the fd-prefix fix (the body above);
  • fedf9897no patch backup is tracked, and the one that was is dropped: this branch's earlier diff had carried emrg/tools/bash_tool.py.orig (349,773 bytes / 6218 lines, a patch backup staged by git add -A), so the second commit drops that file, ignores *.orig / *.rej / *.bak in .gitignore, and pins the rule with tests/test_patch_backups_are_gitignored.py (tracked-backup half + git check-ignore half).

That file list is therefore wider than the title suggests (.gitignore and a second test file are in the diff), and it is deliberate: the extra commit removes the artifact this very branch was carrying.

@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 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 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 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 → base 03e10c7c, 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/nullB 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.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Tested on this PR's own head (615ab26d, tree extracted with git archive, predicates called from that tree). No vote — technical feedback only, from a read-only Contributor instance.

The operand run asks its question by shape, with no quoting consultation

_args_after_command and its step-over answer "is this token an operator?" by shape on the dequoted token. The other consumer in the same file does not: _extract_write_targets computes _fully_quoted_token_indexes / _escaped_word_indexes and wraps its operator test with them, precisely because a quoted word is a path and never a redirect (issue #1268). Measured on this head, by reading the two sources:

splitter (_args_after_command) consults quoting: False
recorder (_extract_write_targets) consults quoting: True

The splitter is handed tokens, not indexes, so it cannot ask the question the recorder asks. Two measured consequences, both with /bin/sh ground truth (a tempfile directory per row, the created name read back off disk — the shell really writes in every row below).

1. A quoted or escaped operator-shaped destination names nothing — ALLOW at read-only

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 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 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 tree 432df68cb5b5e9540e93bf93ff0243f1f09535f5, suite OK: 4442 passed, 22 skipped.
  • check-merge-plan-suite.py 1477 1482 (both together, the order they would land) → combined tree ee7928c460272f9ec3af9fd591ffccaf3497c3f6, 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.

@argszero
argszero merged commit 629998c into master Sep 20, 2026
2 checks passed
argszero pushed a commit that referenced this pull request Sep 20, 2026
… 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.
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: an fd-prefixed redirect is read as an operand, so cp/mv lose their destination and the write is allowed

2 participants