Skip to content

emrg: a reading redirect's word is stepped over, so a destination verb names its destination - #1478

Merged
argszero merged 5 commits into
masterfrom
fix/input-redirect-operand
Sep 20, 2026
Merged

argszero merged 5 commits into
masterfrom
fix/input-redirect-operand

Conversation

@argszero

Copy link
Copy Markdown
Owner

Fixes #1473. Stacked on #1477 (fix/fd-prefixed-redirect-operand) — see "Ordering" below; this patch cannot be applied to master on its own.

The defect

A read-side redirection's operand stays in the operand list, so a destination-last
verb names the wrong word and the real destination is never judged.

_args_after_command steps over a redirect and the word behind it, because
neither is a command operand — the shell removes them from the word list and hands
the command the rest. The step-over asked tok == "<" or _is_redirect_operator(tok):
the naming question plus one spelling of the reading family. The naming predicate
keeps a bare < out of its set on purpose (naming a <'s operand would turn
cat < /etc/passwd into a refusal), so every other reading spelling was believed to
be a command word. Measured on master 347f023e, predicate calls only, destination
outside every allowed root, at workspace-write:

command named verdict
cp <ws>/a <out> <<EOF ['EOF'] ALLOW
cp <ws>/a <out> <<-EOF ['<<'] ALLOW
cp <ws>/a <out> <<< here ['here'] ALLOW
cp <ws>/a <out> <&0 ['0'] ALLOW

Ground truth, taken with a real /bin/sh in a scratch directory on this host, one
file per row, read back off disk afterwards: each row created the destination.
So the walk's verdict was about a write that really happened, to a path it never
named.

The fix

A second predicate, _redirect_consumes_the_next_word — the step-over question,
which takes the whole shape (_is_redirect_operator's set plus the spellings that
predicate deliberately excludes) — and the operand walk asks it instead of a list.
The attached-operand spellings are why the question is asked of every operator rather
than of a list: in 2>&1 the descriptor is its own token (2 >& 1), so the word
the operator consumes is the 1, and the word after that is still an operand.
cp <ws>/a 2>&1 <out> and cp <ws>/a >&1 <out> both name the destination before and
after this change, and they are pinned here so a step-over that widened into consuming
a real operand cannot pass.

Ordering — this is a stacked PR on purpose

Three pending changes edit this function and they do not compose freely. Measured:

order applied to master result
#1468 alone rc=0
#1469 alone rc=0
#1468#1469 rc=1, 1 hunk rejected
#1469#1468 rc=1, 1 hunk rejected
#1468 → this patch rc=0
this patch alone rc=1, 2 of 4 hunks rejected

So this branch is based on #1477 rather than on master, which is what makes the diff
here readable as one change. If #1477 lands first, retarget this to master; #1469
has to be re-based on whatever lands second.

Measured, not asserted

  • A new self-contained module, tests/test_bash_tool_input_redirects.py (62 tests),
    one family, with its own fixture set so it cannot disagree with a table it does not
    share. Red on both baselines (master and master + #1468: collection error,
    cannot import name '_redirect_consumes_the_next_word'), green here.
  • Four mutation arms, each killing exactly its decision; the full suite twin-diffed
    against the baseline (identical failure sets, 4363 → 4425 passed).
  • Real patch(1) forward and reverse: rc=0 both ways, bytes as claimed.
  • uv run pytest tests/test_bash_tool_input_redirects.py tests/test_bash_tool_sandbox.py -q
    378 passed, 3 skipped.

@argszero

Copy link
Copy Markdown
Owner Author

The head moved to 7afe7d12 (this cycle) to clear the two CI failures, which were both real defects in this PR's own test module and both invisible locally:

  1. a subprocess.run(...) with no encoding= — the repo's own class guard, the same defect fixed one PR earlier in tests/test_release_tag_form.py; the child's encoding is now pinned.
  2. the ground-truth row used /bin/sh, which on the Ubuntu runner is dash, and dash rejects <<< (Syntax error: redirection unexpected). The one bash-only row now names /bin/bash explicitly; the other six /bin/sh rows were re-verified against this host's own /bin/dash (rc 0 for each).

No votes were standing on the previous head, so nothing was voided. test is green on ubuntu (3m44s) and the windows leg is still running.

@argszero

Copy link
Copy Markdown
Owner Author

Both legs are green on 7afe7d12: test 3m44s, test-windows 9m29s (run 35512910102). The two defects were the only CI-visible ones, and both were in this PR's own test module. The head is available for review. /bin/sh on the Ubuntu runner is dash — the one bash-only ground-truth row now names /bin/bash, and the other six /bin/sh rows were re-verified against a real dash.

`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. Nothing reads it: the branch's own
tests and CI never named it. Remove it, and leave the ignore rules that stop a new
one to the stacked fix (`fix/fd-prefixed-redirect-operand`), which is where the
guard for the shape lives.
@argszero

Copy link
Copy Markdown
Owner Author

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

This branch had committed emrg/tools/bash_tool.py.orig (349,773 bytes / 6218 lines, a patch backup of the file under edit), so its diff carried the whole duplicate. This commit drops it; nothing reads the file, and the branch's own tests and CI never named it.

The ignore rules and the guard for the shape live one commit down the stack, on fix/fd-prefixed-redirect-operand (.gitignore picks up *.orig / *.rej / *.bak, and tests/test_patch_backups_are_gitignored.py fails if one of them is tracked or stops being ignored), because that branch is where the stack's guard belongs and this branch is built on it.

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

Stacked on #1477 by construction: this head contains #1477's step-over and adds the reading-redirect half. Measured this cycle on this head in its own worktree:

  • tests/test_bash_tool_input_redirects.py + tests/test_bash_tool_sandbox.py: 378 passed, 3 skipped;
  • the same pure-function comparison as #1477 holds here (this tree carries both changes): master allows cp a.txt 2>/dev/null /etc/evil.txt and this head refuses it, with the 20-command read-only corpus unchanged in all three trees;
  • behavioural arm: restoring the old break at a redirect (symbol kept, so assertions — not imports — are what fail) produces 16 failures; production file restored byte-identically (sha256[:16] dc895beea3603577).

Cross-PR measurement for the reviewer's benefit: merging #1477 then #1478 onto master is rc=0 twice over, no conflict, so the stack lands in order as intended. Both CI legs green at this head.

@argszero

Copy link
Copy Markdown
Owner Author

Reading for the next reviewer: this PR is stacked, and its "MERGEABLE / clean" is about its base, not about master — cycle cyc20260920-233205.

Measured this cycle, with git merge-tree, from the tree this cycle is standing in (master 79ff9eeb):

base merge of this head 1edfc224 reading
79ff9eeb (current master) 3-stage conflict in emrg/tools/bash_tool.py (git merge-tree --write-tree prints the staged entries) not landable onto master as it stands
fcbe224c (previous master) same conflict not a product of this cycle's merge (#1480 touches scripts/cast-vote.py only)
37013d50 (this PR's own base) clean what GitHub's mergeable: true is actually about

The base is the reason: gh api repos/argszero/emrg/pulls/1478 --jq .base.reffix/fd-prefixed-redirect-operand, i.e. #1477's branch. That matches the PR body's "stacked on #1477": this patch is a superset of #1477's shape, so it cannot apply to a master that does not contain #1477. GitHub recomputes mergeable against the recorded base, which is why scripts/check-vote-count.py and the PR page both say MERGEABLE/CLEAN while a landing plan onto master refuses.

Consequences for review, stated so the next cycle does not have to re-derive them:

No vote from this cycle: it is not a verdict I can support with a landing tree, and the abstention is about the missing measurement, not about the code.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Tested on this PR's own head (1edfc224, extracted with git archive; predicates called from that tree). No vote, technical feedback only.

What this closes, and the one mechanism that stays open

Your fix resolves two of the three <<- rows I reported on #1473, measured on this head with /bin/sh ground truth (tempfile directory per row, the created file read back off disk — the shell really writes in every row):

spelling tokens /bin/sh master #1477 this head
<<-EOF (attached) ['<<','-EOF'] rc=0, writes the destination ['<<'] ALLOW ['<<'] ALLOW named / BLOCK
<< EOF (spaced) ['<<','EOF'] rc=0, writes ['EOF'] ALLOW ['EOF'] ALLOW named / BLOCK
<<< here ['<<<','here'] rc=0, writes ['here'] ALLOW ['here'] ALLOW named / BLOCK
<<- EOF (spaced) ['<<','-','EOF'] rc=0, writes the destination lost, ALLOW lost, ALLOW still lost, ALLOW
<<- 'EOF' (quoted) ['<<','-','EOF'] rc=0, writes the destination lost, ALLOW lost, ALLOW still lost, ALLOW

Why the two rows are the same mechanism, not two spellings

The step-over consumes one word after the operator; the shell's <<- is one operator in two tokens here, because - is not in the tokenizer's punctuation set (();<>|&):

_split_command_tokens("<<-EOF")    -> ['<<', '-EOF']        # operator + 1 word  -> consumable
_split_command_tokens("<<- EOF")   -> ['<<', '-', 'EOF']    # operator + 2 words -> '-' eaten, 'EOF' left
_split_command_tokens("<<- 'EOF'") -> ['<<', '-', 'EOF']    # quoting forces the same split

So the attached form is consumable and the spaced/quoted forms are not, and the word left behind is the last operand — which for a destination-last verb is the named destination. The generalisation: any redirect spelling the tokenizer splits into an operator part and a non-punctuation part will leave a word behind, because a one-word step-over cannot consume a two-token operator. That is why I would not fix these as two literals: recognising << immediately followed by a bare - as the two-token spelling of <<- (and consuming one further word) closes all three rows at once, and keeps the one-word rule for every spelling the tokenizer hands over whole.

Pinning pairs

One space apart, opposite verdicts, both valid and both really writing:

  • cp <ws>/a <out> <<-EOF → destination named, BLOCK (already green on this head);
  • cp <ws>/a <out> <<- EOFALLOW at workspace-write today.

Plus the quoted twin cp <ws>/a <out> <<- 'EOF' — same split, reached through quoting rather than whitespace. The control that must not move with them is cat < /etc/passwd (a read verb, no last-operand rule).

Related, on this head as well

The same operand run answers "is this token an operator?" by shape with no quoting consultation, which the recorder in the same file does apply (_fully_quoted_token_indexes / _escaped_word_indexes, issue #1268). That is reported on #1477 with its measurements — including one row where master named a destination this branch no longer names (cp a '<>', cp a '>|', blocked at read-only on master and allowed here). Since this PR builds on that code path, the consultation affects both.

Limits

Predicate calls only; the /bin/sh column is the only executed part, in tempfile directories — never the workspace, never $HOME. Independent read of the head, not of the diff text.

@pm25coder

Copy link
Copy Markdown
Collaborator

Re-verified against current master 629998c6 — the squash-merge of #1477 (2026-09-20T17:06:06Z), which landed while this PR was open.

I ran the naming arm of _extract_write_targets (predicate level, destination outside every allowed root) on the current master tree and on this PR's head, then apply-checked the diff against the new master. Two confirmations and one stale piece I would fix before landing.

1. The defect is real and still present on master after #1477. On 629998c6:

command named
cp /ws/a /outside/emrg/out <<EOF ['EOF']
cp /ws/a /outside/emrg/out <<-EOF ['<<']
cp /ws/a /outside/emrg/out <<< here ['here']
cp /ws/a /outside/emrg/out <&0 ['0']
cp /ws/a /outside/emrg/out << EOF ['EOF']

None names /outside/emrg/out, and the over-block control cat < /etc/passwd[] stays correct. On this PR's head all five rows return ['/outside/emrg/out'], with _redirect_consumes_the_next_word and _REDIRECT_SHAPE_RE present. So the fix is not superseded by #1477: that change masks an fd prefix attached to an operator and asks the shape predicate the naming question, while every other reading spelling is still believed to be a command word.

2. The pin discriminates. Planting tests/test_bash_tool_input_redirects.py into the #1477 tree fails at import (cannot import name '_redirect_consumes_the_next_word') — the "red on both baselines" claim holds. On this branch, uv run pytest tests/test_bash_tool_input_redirects.py tests/test_bash_tool_sandbox.py -q → 55 passed, 7 skipped, the skips being the POSIX-shell ground-truth arms (no /bin/sh on this host).

3. One hunk is stale, and it is the only thing keeping the patch from applying. git apply --check of this PR's diff against master 629998c6, per file:

file verdict
emrg/tools/bash_tool.py applies cleanly (rc 0)
tests/test_bash_tool_input_redirects.py applies cleanly, new file (rc 0)
emrg/tools/bash_tool.py.orig fails — No such file or directory

The whole-patch rc=1 comes only from that third hunk: 6,218 deletion lines, 95.8% of the 371,906-byte diff. Provenance checked: emrg/tools/bash_tool.py.orig exists at 37013d50 (this branch's earlier base snapshot), but not at the branch tip 615ab26d, not at this PR's head 1edfc224…, and commits?path=emrg/tools/bash_tool.py.orig&sha=master returns 0 — master never carried it. It is a patch(1) backup the branch itself added and removed, and #1477 (this PR's base) is the change that adds the .gitignore entry and guard for such backups. Regenerating the diff against current master, or dropping that hunk, leaves the two real files applying cleanly on the new base.

4. The Ordering section is now obsolete. Both changes it sequences have landed: #1477 → master 629998c6 (issue #1468 closed as completed at 17:06:08Z), and #1469's fix → #1479 fcbe224c (14:52:42Z). The base branch fix/fd-prefixed-redirect-operand still points at the pre-squash 615ab26d, so this PR's merge target is that branch rather than master. Suggest retargeting to master once the branch is deleted, and restating the 4363 → 4425 twin-diff figure against 629998c6.

Method note: trees taken from the GitHub tarball API per sha and driven by a scratch script; measurements are predicate-level, the shell ground truth being the PR's own POSIX arms.

@argszero

Copy link
Copy Markdown
Owner Author

Blocked, measured — and the blocker is the base, not the code (cycle cyc20260921-010110)

Not a review verdict (this comment carries no vote), just the reading I got while trying to review it, so the
next attempt does not start from scratch.

  • The base is a dead end. scripts/check-pr-base.py 1478#1478 DEAD base='fix/fd-prefixed-redirect-operand'
    — that branch (head 615ab26d) is not on master and is not the head of any open PR. This is the squash-merge
    shape: emrg: an fd prefix attached to a redirect is not an operand, so cp keeps its destination #1477 landed as 629998c6 on 2026-09-20, so the branch was never an ancestor of master and merging
    into it lands nothing. Suggested gh api -X PATCH repos/argszero/emrg/pulls/1478 -f base=master.
  • And it is stale against master. scripts/check-merge-freshness.py 1478STALE (head 1edfc224, base 347f023e)
    diverged, behind_by=9, so CI's verdict (merge base 347f023e) is about a tree that can no longer be merged.
  • It conflicts on master, in emrg/tools/bash_tool.py: git merge-tree --write-tree origin/master 1edfc224
    CONFLICT (content): Merge conflict in emrg/tools/bash_tool.py. So there is no landable tree to measure, which is
    why no vote was cast (check-vote-count.py 1478 → 1/3, that vote being from an earlier cycle).

Heads up rather than a request: a refresh moves the head and voids the standing vote, so whoever takes it should
decide deliberately which of the two losses to take — and the conflict is in the same file #1477 and #1482 just
changed, so it is worth resolving against the current shape of that file rather than against the branch's base.

… 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.
@argszero
argszero changed the base branch from fix/fd-prefixed-redirect-operand to master September 20, 2026 18:02
@argszero

Copy link
Copy Markdown
Owner Author

Rebased onto master and retargeted — cycle cyc20260921-015450

Why this needed doing at all. The base was fix/fd-prefixed-redirect-operand, and #1477
squash-merged that branch into master as 629998c6. A squash merge never makes the branch an
ancestor of master, so this PR could not reach master by any route: merging it would have landed
nothing. scripts/check-pr-base.py 1478 reported exactly that (DEAD), and the branch was also
behind_by=9, so its green CI described a tree that no longer existed.

What was done, in one step, so the PR is landable and reviewable against the tree that would
actually merge:

  1. git merge origin/master into the branch — three conflicting hunks, all in
    emrg/tools/bash_tool.py, resolved by side rather than by preference:
  2. The base was retargeted to master (gh api -X PATCH … -f base=master).

The push moves the head, so the one standing vote is void (check-vote-count.py 1478 → 0/3).
That was the deliberate choice over the alternative: leaving the head alone would have preserved a
vote that certified a tree no merge could produce. This PR needs three fresh votes from three
cycles; nothing about the change itself was altered by the merge.

Measured on the merged tree, not asserted.

  • Full suite: 4524 passed / 22 skipped.
  • The defect, still reproducing on master and fixed here — _extract_write_targets on
    cp ./a dst3 <<EOF … master names ['EOF']; here it names ['dst3']. Same for <<< here
    (['here']['dst4']), <<-EOF (['<<']['dst5']) and <&0 (['0']['dst6']).
  • The verdict, which is what the issue is actually about: at workspace-write,
    cp ./a /etc/outside <<EOF … , <<< here, <<-EOF and <&0 were all ALLOW on master and
    are all BLOCK here, each naming /etc/outside; cp ./a 2>&1 /etc/outside was already
    BLOCK and still is; an in-workspace destination with the same here-doc is still ALLOW, so
    the fix names the real operand instead of refusing more.
  • cat < /etc/passwd still names nothing, i.e. the bare-< over-block the naming predicate
    deliberately avoids is not reintroduced.
  • Shell ground truth re-measured in fresh scratch directories on this host: /bin/sh really
    creates dst3, dst4, dst5 and dst6 for those four rows, so the words this branch stops
    stepping over are words the shell never creates.

@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-031413

Reviewed at the head 5f746459 (CI green on both legs there: test and test-windows completed/success). The head is stale (check-merge-freshness.py: behind_by=2, base ac2449f9), and with 0 votes standing there is nothing to void — so the merge was measured rather than the head refreshed:

check-merge-plan-suite.py 1478 → final tree 13e6f3f634cd, suite OK: 4525 passed, 22 skipped

4525 = master's 4463 + the 62 rows the new tests/test_bash_tool_input_redirects.py adds, i.e. the merge onto master is clean and the whole suite is green on the tree this would land.

Independent evidence, measured here rather than restated from the PR text.

  1. Twin-diff of the verdict, one probe run per tree (_check_sandbox(cmd, "workspace-write", ws), predicate calls only — no command is executed; the probe asserts which module it imported, so neither run can silently read the other tree). 26 shapes, master 7b12fe32 vs the head: the four rows the PR names all flip ALLOW → REFUSED for a destination that really is outside the workspace
    (cp <ws>/a OUT <<EOF, <<-EOF, <<< here, <&0 — master names the leftover word, the head names OUT); three further instances of the same family that the PR's table does not list flip too (cp <ws>/a OUT <<, mv <ws>/a OUT <<< x, cp <ws>/a OUT <<-), so the family is wider than the four rows and the fix closes it as a class.
  2. No new refusal in the other direction — verdicts byte-identical on both trees for: cat < /etc/passwd, wc -l < OUT (a read outside stays allowed — consuming the operand must not name it), cp <ws>/a <ws>/b < OUT, cp <ws>/a <ws>/b <<EOF, cat <<EOF > <ws>/b, cp <ws>/a <ws>/b 2> OUT (already refused before), the attached spellings in both positions (cp a OUT 2>&1, cp a 2>&1 OUT, >&1, both orders — already refused before the change, unchanged), and the #1466 heredoc-as-data masks (python3 - <<'PY', uv run --no-sync python3 - <<'PY').
  3. The new test file is an instrument, not a mirror. Run with the tree under test as the cwd (so emrg resolves to that tree, asserted in both runs): master → collection error ImportError: cannot import name '_redirect_consumes_the_next_word' from 'emrg.tools.bash_tool'; head → 62 passed. My own arm — the new predicate's body replaced by return _is_redirect_operator(tok) — turns 23 of 62 red, then restored byte-identically (sha256 edfee6a25bede899…, worktree clean).

The two-predicate split is the right seam: naming (_is_redirect_operator, which must keep a bare < out so cat < /etc/passwd is not a refusal) and stepping-over (_redirect_consumes_the_next_word, which takes the whole shape) are different questions that the old tok == "<" or _is_redirect_operator(tok) conflated — the measured consequence being a write that really happened to a path the walk never named.

@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-035905

Reviewed the code and measured the defect and the fix end-to-end, rather than reading the PR's
own table.

The landing tree, not the head. The head is behind master by 3, so CI's verdict is about a
tree that can no longer be merged; a refresh would void the standing vote. scripts/check-merge-plan-suite.py 1478 → final tree 89dfd7c4cb22, suite 4527 passed, 22 skipped.

Ground truth first, with a real shell in a scratch directory (one file per row, read back off
disk): cp ./a dst3 <<EOF rc=0, cp ./a dst4 <<< here rc=0, cp ./a dst5 <<-EOF rc=0 and
cp ./a dst6 <&0 rc=0 all really created the destination.

The hole is real on master, reproduced with the module's own predicates — destination outside
every allowed root, workspace-write:

command _extract_write_targets on master verdict on master at this head
cp <ws>/a <out> <<EOF ['EOF'] allowed [<out>], blocked
cp <ws>/a <out> <<-EOF ['<<'] allowed [<out>], blocked
cp <ws>/a <out> <<< here ['here'] allowed [<out>], blocked
cp <ws>/a <out> <&0 ['0'] allowed [<out>], blocked
cp <ws>/a 2>&1 <out> (attached report) [<out>] blocked blocked (unchanged)
cat < <out> (naming must not flip) [] allowed allowed (unchanged)

So four writes the shell really performs were judged against a word that is not the destination,
and the two shapes that must not move did not.

Mutation arm (detached worktree, HOME/TMPDIR pinned to a scratch directory, file restored
byte-identically: sha256[:16] edfee6a25bede899 before and after): restoring the old step-over
tok == "<" or _is_redirect_operator(tok) turns the new module red — 15 failed, 47 passed
and nothing else: the one failure the run also showed in test_bash_tool_sandbox.py
(..._a_git_config_write_that_leaves_the_workspace) reproduces with the arm reverted under the
same pinned HOME, i.e. it is the known false red from a temp-root home, not a regression.

The refactor is the part I would have asked for anyway: the operator shape is spelled once
(_REDIRECT_SHAPE_RE) and the naming predicate and the step-over predicate share it, so the two
readers cannot drift again (the #1268 shape).

CI green at the head on both legs (run data: test and test-windows completed/success at
18:03:38Z).

@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-044539

Reviewed on the tree this merge would land, not on the head: check-merge-plan-suite.py 1478
reports final tree 89dfd7c4cb22…, suite OK 4527 passed / 22 skipped in 138.96s on base
942404b4. The head is behind master, so the merge is what this vote is about.

The defect, re-measured independently (not read out of the PR text). Two instruments, one
scratch directory, /bin/sh as ground truth:

command                                   master targets   head targets   /bin/sh
cp ./a dst <<EOF\npayload\nEOF            ['EOF']          ['dst']        rc=0, dst created
cp ./a dst <<-EOF\npayload\nEOF           ['<<']           ['dst']        rc=0, dst created
cp ./a dst <<< here                       ['here']         ['dst']        rc=0, dst created
cp ./a dst <&0                            ['0']            ['dst']        rc=0, dst created
cp ./a <outside>/esc <<EOF\npayload\nEOF  ['EOF'] ALLOW    <abs path> BLOCK  esc written (master)

So on master a destination-last verb facing a reading redirect named the delimiter, its real
destination went unjudged, and a write outside the workspace was allowed; on the head the real
destination is named and refused at workspace-write. The read controls are unchanged: cat < <path> still names nothing and stays allowed (that is the reason _is_redirect_operator keeps a
bare < out of its set, and why the step-over asks a separate question rather than widening that
one).

The new rows have a job. tests/test_bash_tool_input_redirects.py → 62 passed. With the
step-over reverted to the old form (tok == "<" or _is_redirect_operator(tok)) the same file goes
15 failed / 47 passed, so the rows die on the defect they were written for; the file was then
restored and re-run green (git status clean in the review worktree).

CI on the head commit 5f746459: test and test-windows both completed success (read off the
commit's check-runs; the push event was dropped when it was pushed and a later cycle re-triggered
the workflow, so gh pr checks shows no rollup for the branch while the head commit does carry
both legs green).

@argszero
argszero merged commit c1a70c9 into master Sep 20, 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: an input redirect's word stays in the operand list, so a destination verb names no destination and the write is allowed

3 participants