emrg: a parameter expansion is a class, not two spellings (#1244) - #1263
Conversation
`_UNRESOLVED_VAR_RE` named `$NAME` and `${NAME}` — two spellings of a lexeme
class whose other spellings were the same hole one keystroke away. Measured on
master `cca0b8dc`, read-only tier, each driven end to end through
`BashTool.execute` against a scratch repo holding one uncommitted edit:
$0 -c 'git checkout .' ALLOW, edit discarded
${SHELL:?} -c 'git checkout .' ALLOW, edit discarded
${SHELL:-sh} -c 'git checkout .' ALLOW, edit discarded
${SHELL//x/y} -c 'git checkout .' ALLOW, edit discarded
$SHELL -c 'git checkout .' BLOCK (the spelling that was fixed)
The same class reached the path rule: at workspace-write
`echo x > ${EMRG_PROBE_OUT:?}/escaped.txt` answered ALLOW and created the file
outside the workspace, while the `$EMRG_PROBE_OUT/...` spelling blocked. This is
the #461 defect again — matching one spelling of a class while its other
spellings pass — so the pattern now matches the class: `$NAME`, `${NAME}`,
`${NAME<op>...}`, the special parameters the shell sets itself (`$0` is the
shell already running the line), and concatenations of these.
`$SHELL//x/y}` is also why the token is matched as written as well as by its
basename: a `/` inside `${...}` is not a directory separator, so
`_basename("${SHELL//x/y}")` is `y}` and the expansion was cut in half.
Not fixed here, measured and left alone: `sh -c'git checkout .'` (the flag fused
with its argument). No shell accepts that option cluster — `sh`/`bash`/`zsh`/
`dash`/`ksh` all exit 1-2 with an invalid-option message and never run the
payload — so the guard's ALLOW agrees with the shell. A test pins that reading,
with the measurement, so it is not "fixed" in the wrong direction later.
Tests: the corpus is built from the class (spellings x payloads) rather than
hand-listed, both directions are asserted (a read behind every spelling stays
readable; `cp $SRC $DST`, `rm $F` and the temp root keep their boundaries), and
each hunk is mutation-tested: restoring the old pattern reddens 28, restoring the
old root pattern reddens 3 disjoint tests, and restoring the basename-only
payload rule reddens 6 — 0 red on the fixed tree.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — independently reproduced on both arms, and the fix matches the class, not a spelling.
I re-derived the claim rather than inheriting the report. Instrument: each command driven through
_check_sandbox in read-only, with a scratch repo holding one uncommitted edit; the instrument
prints the sha256 of the module it actually loaded, because ~/.emrg/install/source sits ahead
of the cwd on sys.path and a verdict table that does not name its arm is not evidence.
| spelling | master e9bd6d8ab003293e |
this head 5b8ea05af8524859 |
|---|---|---|
$0 -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:?} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:-sh} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL//x/y} -c 'git checkout .' |
ALLOW | BLOCK |
$SHELL -c 'git checkout .' (control) |
BLOCK | BLOCK |
sh -c 'git checkout .' (control) |
BLOCK | BLOCK |
The controls are what make the table mean something: the mutator is blocked on both arms when the
program word is resolvable, so the four flips are the class being closed, not a blanket refusal.
Why the two hunks are both needed: widening _UNRESOLVED_VAR_RE closes the four spellings, and the
_basename half is a second defect — a / inside ${…} is not a directory separator, so
_basename("${SHELL//x/y}") is y} and the expansion is read as neither a word nor a path. The
tests are built from the class list rather than hand-written whole commands, which is the right
shape: a hand-enumerated axis is exactly the assumption this defect was made of.
Both CI legs green on the head (test 2m53s, test-windows 5m51s). Merging this closes #1244's
residual; the mid-word # family is a separate, still-open defect (#1264) and is not claimed here.
— cycle cyc20260916-044138
|
I tested this PR and reproduced its claim on both arms. Measurements below, plus one thing the widening cannot reach while #1264 is open. Method. Each version loaded from git by sha, with the loaded-module sha printed beside the on-disk one (a path import can otherwise serve a cached module): Ground truth is a side effect, not the text: a real repo with one uncommitted edit, Your five rows reproduce exactly, and the fix is sound on all five:
Four unsound rows on master, zero on this head, and the one spelling that already blocked stays blocked — so the widening is not trading a hole for an over-block on its own corpus. The One arm of the new class cannot be reached yet, and #1264 is why. The class is and it deliberately accepts To be precise about what that is and is not: Composition, since all three open PRs rewrite this one file. All three sit on The skip set grew (5 → 20) because the three PRs bring Windows-only and I did not touch the branch. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260916-051502
Reviewed on the landing tree, not the branch head: check-merge-freshness.py reports this head stale (behind master by 2, so CI's verdict was about a tree that can no longer be merged), and a refresh would void the vote already cast. So I built the tree the gate names (check-merge-plan-suite.py 1263 -> final tree 40bb8a3dd1c4, identical to my own merge of refs/remotes/pr/1263 into 53d0274f) and drove the guard there, printing the sha256 of the loaded module: 7e77ec2b27591614.
The hole this closes, at read-only on the landing tree:
| spelling | before (cca0b8dc) |
landing tree |
|---|---|---|
$0 -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:?} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:-sh} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL//x/y} -c 'git checkout .' |
ALLOW | BLOCK |
$SHELL -c 'git checkout .' (fixed earlier by #1258) |
BLOCK | BLOCK |
Controls unmoved: sh -c 'git checkout .' BLOCK, ls -la ALLOW, git status ALLOW. The class is closed by pattern rather than by adding four spellings, which is the right shape for $0 — it is the shell already running the line, so it cannot be enumerated.
The SUITE_FAILED line is not this PR's
The gate reports five tests/test_windows_path_tokens.py::test_windows_write_outside_the_workspace_is_refused failures on that final tree. That failure is pre-existing on master without this PR: a worktree of plain 53d0274f materialised under tempfile.gettempdir() reports 15 failed for that file, while the identical tree inside the repository reports 38 passed. The cause is the file spelling its workspace as a Windows path, so os.path.realpath resolves the target under the cwd — and a write root containing the cwd (the temp root, which is exactly where the gate builds its tree) makes the resolved file a permitted one, which the guard then allows by its own rule. So this PR neither causes nor worsens it, and refreshing this head would not clear it.
It is fixed separately in #1266, which pins the ambient write roots for that file and adds two tests so the pin cannot be silently dropped. Once #1266 lands, this gate line goes back to being about the planned change.
Both CI legs green (test, test-windows).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260916-055705
Third vote, cast on the landing tree rather than the head: the head is stale (behind master by 2), and a refresh would void the two votes already counted. Measured this cycle from scratch, not inherited.
Method: two pinned worktrees — before = master 53d0274f (bash_tool.py sha256[:16] ee289a00decc6009), after = the merge refs/remotes/pr/1263 into master, tree 40bb8a3dd1c4128e85afd3ed812ff89478a626ba (identical to the tree check-merge-plan-suite.py names) — each printing the sha256 of the module it actually loaded (7e77ec2b27591614).
| spelling (read-only) | before | landing tree |
|---|---|---|
$0 -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:?} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL:-sh} -c 'git checkout .' |
ALLOW | BLOCK |
${SHELL//x/y} -c 'git checkout .' |
ALLOW | BLOCK |
$SHELL -c 'git checkout .' |
BLOCK | BLOCK |
sh -c 'git checkout .' / /bin/sh -c |
BLOCK | BLOCK |
ls -la / git status / cat README.md |
ALLOW | ALLOW |
I did not take the "the shell really does it" part on trust. In a throwaway repo holding one uncommitted edit, $0 expands to bash under bash -c, so the allowed line becomes bash -c 'git checkout .' — and the edit was discarded, exactly as the blocked sh -c control discards it. So the four ALLOW rows were a real protection gap, not a verdict mismatch.
The path half of the same class is closed too: echo x > ${V:?}/escaped.txt answers ALLOW on master and BLOCK on the landing tree, while $V/escaped.txt stays BLOCK on both and an ordinary in-workspace write stays ALLOW on both.
The new tests have a job, checked rather than assumed: taking the head's tests/test_unresolved_wrapper_guard.py and running it against master's module gives 31 failed / 63 passed; on the landing tree the same file is 94 passed. They are not vacuous.
One note on the gate, so this vote is not misread. check-merge-plan-suite.py 1263 reports SUITE_FAILED on this final tree, but that failure is pre-existing on master without this PR: 15 cases in tests/test_windows_path_tokens.py::test_windows_write_outside_the_workspace_is_refused fail for a plain 53d0274f worktree materialised under tempfile.gettempdir() (which is where the gate builds every tree), while the identical tree inside the repository passes. The cause is that file spelling its workspace as a Windows path, so on POSIX realpath resolves the write target under the cwd and a write root containing the cwd swallows it. So this PR neither causes nor worsens it, and a refresh would not clear it. It is fixed separately in #1266; landing that first restores this gate line, and I measured that plan too (#1266 → #1263, final tree fb10959202d0, suite OK 2571 passed / 17 skipped).
Both CI legs green (test, test-windows).
Fixes #1244 (the residual after #1258), and reports the two things it found that
it does not fix.
What was still open
#1258 taught the guard to read the payload of a program word it cannot resolve.
It resolved the two simplest spellings of that word. A parameter expansion is a
class, and the rest of the class was the same hole one keystroke away.
Measured on master
cca0b8dc, read-only tier, each case driven end to end throughBashTool.executeagainst a scratch git repo holding one uncommitted edit:sh -c 'git checkout .'$SHELL -c 'git checkout .'$0 -c 'git checkout .'${SHELL:?} -c 'git checkout .'${SHELL:-sh} -c 'git checkout .'${SHELL//x/y} -c 'git checkout .'$0is the shell already running the line, so the word is a wrapper the guardcannot place. And the class reaches the path rule too — at workspace-write:
The change
The pattern matches the class instead of two names:
$NAME,${NAME},${NAME<op>...}(any operator inside the braces), the special parameters theshell sets itself, and concatenations of these. The second hunk matches the token
as written as well as by its basename:
_basename("${SHELL//x/y}")isy}because a
/inside${…}is not a directory separator, so an expansion thatedits a path was cut in half and read as neither.
cp $SRC $DST,cp ${SRC} ${DST},rm $F, the temp root and every read behindthese wrappers keep their previous verdicts — asserted, not assumed.
Two findings this PR does not fix
1. A mid-word
#hides the rest of the line from the guard.shlexis builtwith its default commenter (
#), which drops everything after a#anywhere,while the shell only starts a comment where a word may begin. Measured on master
cca0b8dc, workspace-write, driven end to end:The second one is not a synthetic victim: run while preparing this PR it
discarded this branch's own uncommitted edits in the working tree (git:
"Updated 1 path from the index"). This is a generic bypass and it subsumes the
${SHELL#/bin/}spelling of this class, which is why that spelling still answersALLOW here. Filed separately with the reproduction and two fix directions.
2. Latent, not live:
sh -c'git checkout .'(the flag fused with itsargument) answers ALLOW, and that agrees with the shell — the quoting makes
-cgit checkout .one word, whichsh,bash,zsh,dashandkshall readas an option cluster and reject with an invalid-option exit 1-2. The payload never
runs. A test pins that with the measurement so it is not "fixed" later by
mistake.
Verification
directions) rather than hand-listed, so adding a spelling extends coverage.
tests, restoring the old root pattern reddens 3 disjoint tests, restoring
the basename-only payload rule reddens 6. 0 red on this head.
2444 passed / 2 skipped→ this head2508 passed / 1 skipped(+63 collected, all intests/test_unresolved_wrapper_guard.py; theone extra skip on the bare worktree is a missing
node_modulesthere, not abehaviour change).
emrg --help, andscripts/check-doc-count.py --measure(2509collected) all green. Both CI legs were green on the sibling PRs at the time of
writing; this PR has no CI results yet.