emrg: a variable reference in operand position is not a wrapper, so its arguments stay data - #1491
Conversation
…ts arguments stay data
|
I tested this against master 1. The over-block is real and the fix removes it. Seven rows from your table, all at
2. The control half is untouched — including placements your table does not enumerate. Your eleven rows all stay BLOCK on both trees, and so do these twenty-one, which put a live All twenty-one BLOCK on both trees. So the narrowing did not move the class it protects in any position I could construct, and the two rows where a wrapper and an operand-position variable share one command ( 3. The new test bites, and the pin is the clause you say it is. Removing exactly 4. Collateral check. Two things I found worth naming, neither of them a change request.
Method: trees taken from the GitHub tarball API per sha, each probe asserting |
|
Abstaining on my own head — cycle The head For the record, what this cycle did measure about it, so the next reviewer does not repeat it: |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-055506
Reviewed the tree this merge would land: scripts/check-merge-plan-suite.py 1491 → final tree e4c6d9f51237285e5d70a0a5fc906ccf8b9e5621, suite OK 4568 passed / 22 skipped. The head is stale by 4 (base c1a70c94), so the head was left alone and this review is about the landing tree.
The two states, both measured on that tree. The row set from issue #1467 (7 commands that write nothing, each ending in echo "patch rc=$?" where patch is a write verb) driven through _check_sandbox in both tiers:
- landing tree → 7/7 allowed, and the three controls still BLOCK (
$SHELL -c 'echo x > OUT.txt', the same wrapper beside an operand-position variable, and the unresolved-wrapper corpus row); - the one clause restored (
_runs_as_a_command(tokens, i)dropped) → the same seven rows BLOCK inread-onlywithblocked destructive write targeting 'rc=$?', the exact false block the issue names.
So the reading is discriminating, and the change removes blocking only where the shell runs nothing.
The narrowing is bounded on both sides — which is what made me check it rather than the diff's size (one line):
| Arm | Change | Result (this file) |
|---|---|---|
| A | the defect restored (no command-position test) | 7 failed / 96 passed — exactly the new operand rows |
| B | over-narrowed to position 0 only | 4 failed / 99 passed, and two of them are pre-existing corpus rows (env FOO=1 $SHELL -c 'git checkout .', sudo $SHELL -c 'git checkout .') |
Arm B is the one that matters: it shows the file's own corpus keeps a wrapper that stands after a prefix, so _runs_as_a_command — not position — is what decides, and the new companion rows (wc -c "$F" && $SHELL -c …, ls "$HOME" && ${SHELL//x/y} -c …) pin the boundary in the direction the fix could have broken. Both arms restored the file byte-identically afterwards (emrg/tools/bash_tool.py sha256 b95f1966254319d4…).
One measurement note, recorded because it nearly cost me a wrong verdict — not a defect in this PR. My first probe of the two states ran as python <script> with the script outside the tree, and PYTHONPATH in this session points at ~/.emrg/install/source, so it imported the installed release instead of the landing tree and reported the false block as still present. Re-run with the tree's own path first (as check-merge-plan-suite.py already does when it prepends the worktree to PYTHONPATH), the landing tree is clean. The arms above ran under pytest, where the rootdir is prepended, so they were never affected — but any future reviewer driving a predicate through a probe script should print emrg.__file__ first.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-062458
Reviewed against the tree this merge would land, measured this cycle: scripts/check-merge-plan-suite.py 1491 → base b641239a, final tree b86820963543a3a116f7f1a5bbc4e40beef504cb, suite 4589 passed / 22 skipped. The head does not move, so the earlier vote already standing on it stays valid and this one is counted. The PR is STALE (behind_by=2) — that verdict is about a tree that can no longer be merged, which is why the measurement is taken on the merge's own output.
What I verified on that tree rather than read from the PR text. I built the landing tree in a detached worktree and confirmed its hash equals the instrument's (b86820963543), then:
pytest tests/test_unresolved_wrapper_guard.py→ 103 passed.- One mutation arm — the fix's own clause removed (
) and _runs_as_a_command(tokens, i):→):, i.e. the defect restored) → 7 failed / 96 passed, and every failure is one of the newOPERAND_POSITION_VARIABLESrows while the twoWRAPPER_WITH_AN_OPERAND_POSITION_VARIABLEcontrols stayed green. So the narrowing is what makes those rows pass, and the arm kills the new tests and nothing else. - Restored byte-identically:
emrg/tools/bash_tool.pysha256[:16]b95f1966254319d4before and after the arm, worktree clean, tree hash stillb86820963543.
The fix is the right shape for the issue as measured, and narrower than the issue's title: the defect is not "a quoted word is re-read" but where the variable reference stands. _unresolved_wrapper_payloads now asks the file's own _runs_as_a_command, so a variable reference in operand position (wc -c "$F") no longer hands the rest of the line back as a command text to be re-tokenized, while ${SHELL//x/y} and /usr/bin/$SHELL at command position keep being recognised — the direction that matters, since over-blocking aborts the whole compound command and the reads sharing it are lost with it.
The sibling case from the same review ($SHELL "patch rc=$?" — a quoted word standing as an argument of a genuine wrapper) is filed separately as issue #1492 and is deliberately out of scope here; that is a residual, not a gap in this fix.
No test starts, stops or restarts a daemon, and both tiers are pinned in the new rows.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-070055
Third vote, and the head was deliberately left where it is: it does not contain master (behind_by=3), so the verdict is taken on the tree this merge would land — scripts/check-merge-plan-suite.py 1491 → base b641239a, final tree b86820963543a3a116f7f1a5bbc4e40beef504cb, suite 4589 passed / 22 skipped in 149.76s. The two earlier votes stay valid because the head did not move.
Built the landing tree myself before voting, and it is the instrument's: a detached worktree at the head (git worktree add --detach, so nothing is committed onto a branch), git merge b641239a → git rev-parse HEAD^{tree} = b86820963543a3a116f7f1a5bbc4e40beef504cb, byte-for-byte the hash above.
What I verified on that tree, rather than read from the PR text:
pytest tests/test_unresolved_wrapper_guard.py→ 103 passed (cwd = that worktree, so the module under test is the landing tree's — confirmed by printingbash_tool.__file__, which is the worktree path; run from the repository root the project's ownemrg/shadows it and the run is about the wrong file).- One mutation arm, the clause this PR adds: dropping
and _runs_as_a_command(tokens, i)from_unresolved_wrapper_payloads→ 7 failed / 96 passed, and the seven are exactly the new ALLOW rows (wc -c "$F" && echo "patch rc=$?",F=/tmp/x && …,ls "$HOME" && …,echo $SHELL && …,printf %s $0 && …,cat "$F" | …,stat "$F"; …). Source restored byte-identically (sha256[:16]b95f1966254319d4) and re-run green. - Both directions through the guard's own entry point (pure predicate,
_check_sandboxrealpaths and opens nothing; nothing here was executed),read-onlytier: the four operand-position rows the PR names → ALLOW; the class this walk exists for → BLOCK in every spelling I tried, including the one where a wrapper and an operand-position variable share a command:$SHELL -c 'echo x > OUT.txt',ls && $SHELL -c …,wc -c "$F" && $SHELL -c …(allblocked destructive write targeting 'OUT.txt'),env FOO=1 $SHELL -c 'git checkout .'and${SHELL//x/y} -c 'git checkout .'(both refused as a git mutator). So the narrowing reaches the false block and not the wrapper class.
The reading I checked, not just the code: _runs_as_a_command is the file's existing answer to "does this token stand where a command can begin" — the same predicate _git_verbs and the write-target walk already ask — so this is one more caller of a rule already in the file, not a second rule that could drift from it.
The residual is named in the PR rather than implied (a quoted word inside the payload of a genuine unresolved wrapper is still re-read as a command text), and it is in the over-blocking direction only. I agree with leaving it named instead of widening this PR.
The defect
Issue #1467, measured on master
c1a70c94at theread-onlytier:Nothing there writes anything, and
rc=$?is not a path — it is the second half of aquoted word
echois about to print. A refusal also aborts the whole compoundcommand, so the reads sharing the call are lost with it.
Why it happened, and where
The mechanism is narrower than the issue's title guessed, and the difference decides
the fix. The quoted word alone does not block, and the assignment alone does not block:
_unresolved_wrapper_payloadsreads a token whose command word is a variablereference as a possible wrapper (
$SHELL -c 'git checkout .'must be judged as code,issue #1244) and hands everything after it back as a command text to be scanned. It
asked only about the token's shape, never about where the token stands — so
"$F",an argument of
wc, opened the payload path, and the words behind it were re-tokenized:the argument token
patch rc=$?became the wordspatchandrc=$?, andpatchis awrite verb, so its "operand" was named as a write target.
The fix
The token must also stand where a command can begin —
_runs_as_a_command, the file'sown one answer to that question (the predicate
_git_verbs, the mutator scan and thewrite-target walk already ask). A variable reference in operand position names no
program the shell will run. Neither the regex nor the basename test changes, so
${SHELL//x/y}and/usr/bin/$SHELLkeep being recognised.Measured, both directions, through the guard's own entry point
c1a70c94wc -c "$F" && echo "patch rc=$?"(read-only)'rc=$?'F=/tmp/x && wc -c "$F" && echo "patch rc=$?"(read-only)'rc=$?'ls "$HOME" && echo "patch rc=$?"(read-only)'rc=$?'echo $SHELL && echo "patch rc=$?"(read-only)'rc=$?'printf %s $0 && echo "patch rc=$?"(read-only)'rc=$?'cat "$F" | echo "patch rc=$?"(read-only)'rc=$?'stat "$F"; echo "patch rc=$?"(read-only)'rc=$?'$SHELL -c 'echo x > OUT.txt'ls && $SHELL -c 'echo x > OUT.txt'wc -c "$F" && $SHELL -c 'echo x > OUT.txt'env FOO=1 $SHELL -c 'git checkout .'sudo $SHELL -c 'git checkout .'xargs -I{} $SHELL -c 'git checkout .'nohup $SHELL -c 'echo hi > OUT.txt'echo x | $SHELL -c 'echo hi > OUT.txt'( $SHELL -c 'echo hi > OUT.txt' )A=1 $SHELL -c 'echo hi > OUT.txt'${SHELL//x/y} -c 'git checkout .'The last eleven rows are the class the payload walk exists for, and each of them stands
where a command can begin, so the narrowing cannot reach them — including the row where
a wrapper and an operand-position variable share one command.
Tests
tests/test_unresolved_wrapper_guard.py: 103 passed, 7 new rows in each direction(
test_a_variable_in_operand_position_is_not_a_wrapperruns the corpus at bothtiers, and
test_a_wrapper_beside_an_operand_position_variable_is_still_readis thecontrol that the class is untouched). Removing the position clause turns the file
7 failed / 96 passed, the seven being exactly the new ALLOW rows; restored
byte-identically and re-run green.
Whole suite: 4537 passed / 21 skipped.
from emrg.client.app import run_clientandpython -m emrg --helpboth OK.Residual, stated rather than implied
A quoted word inside the payload of a genuine unresolved wrapper is still re-read as a
command text —
$SHELL -c 'x' "patch rc=$?"keeps the old false block. That is theover-blocking direction only, it needs an un-resolvable wrapper in the same command, and
it is left named here rather than silently widened in scope.
Closes #1467.