emrg: a message reader's heredoc body is data, judged per invocation (#1320) - #1328
Conversation
…1320) `_mask_data_heredoc_bodies` masks a heredoc body only when the command owning the `<<` reads its stdin as data — and that test was a property of the *tool*, `_DATA_READER_CONSUMERS`. `git commit -F -` is an invocation that is nothing but a tool, a subcommand and `-`, so its body — a commit message — stayed in the text, and the write-target scan read a path mentioned inside that message as a target: measured on master, `git commit -q -F - <<'EOF'` + a line naming ~/.emrg/config.toml + `EOF` was refused with "blocked write to protected daemon file". Nothing in it writes anything; the only file it touches is inside `.git`. It was hit for real, by a cycle writing its own commit message. The reader is now judged per invocation: `_owns_stdin_as_data` accepts a named data reader, or a message reader (`git commit` / `git tag`) only when nothing but the subcommand follows the tool and an operand names stdin. The operand spellings were measured against git 2.50.1 in a scratch repo — `-F -`, `--file -`, `--file=-` and `-F-` put stdin into the tag message, while `-F=-` opens the file `=-` and fails, so it is not a spelling of stdin. Neither refusal is caution, and both were measured the same way: - a global option before the subcommand: `git -c core.editor=sh commit -F - -e` ran the body as a script and committed it; - an env prefix, which is the same override spelled through the environment and is invisible in the command word: the `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0` spelling of it ran the body too. So an env prefix forfeits the mask, and the subcommand is read at argv[0] after the tool — a position test, not an enumeration of the options. The shape issue #1320 named as the counter-case does not in fact run the body: `git -c alias.commit='!sh' commit -F -` keeps the builtin (git refuses to let an alias shadow a builtin; measured, git 2.50.1), and the builtin reads the body as a message. That refusal is kept anyway — it costs a rare false positive (`git -C <dir> commit -F -`), which is the direction this guard errs in. Both halves are asserted in tests/test_stdin_message_readers.py (44 cases), and four mutation arms say the tests discriminate rather than merely pass: disabling the message-reader arm reddens 13 of them (the whole "may mask" half); disabling the env refusal reddens the `GIT_CONFIG_*` / `FOO=1` cases only; disabling the position refusal reddens the `-c` / `--config-env=` cases only; making the operand test return True reddens the `-m x` / `-F file` / `-F=-` cases only. Full suite 2829 passed / 16 skipped; the guard measures 2845 collected. Verified through `_check_sandbox` in a fresh interpreter, which is the level the change is at. A replay through the live daemon cannot show it in the same cycle, because that daemon holds the module it imported before the edit — measured: the same `git tag -F -` text is still refused there.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — reviewed and measured on the landing tree (head 7fec23b7 is STALE, behind_by 2; refreshing would void nothing but would make me abstain, so the vote is cast where the gate points).
What I measured (not read). Landing tree b4d0741324bb — materialized in a detached worktree (PR head + a4f46e7c merged) and confirmed equal to check-merge-plan-suite.py 1328's final tree; the suite is OK there (2828 passed / 17 skipped). bash_tool.py sha16 51e74b06dcce544e.
1. The instrument discriminates (both states). 12 command shapes through the real _check_sandbox(cmd, "workspace-write", <repo>), loaded from the tree under test (module path printed):
- On master (
efedd4fc5d7f8b13, fix absent): 5 mismatches — A1git commit -q -F -, A2git tag -a v1 -F -, A3--file -, A4--file=-, A5-F-are all refused with "blocked write to protected daemon file '~/.emrg/config.toml'" when the path appears only inside the commit/tag message. That is issue #1320 reproduced exactly. - On the landing tree: 0 mismatches — all five are allowed, and the six shapes that must stay refused are still refused:
-F=-(git opens the file=-; not a spelling of stdin),-F msg.txt(the heredoc is not git's stdin at all),git -c core.editor=sh commit -F - -e, theGIT_CONFIG_COUNT=…env prefix, a heredoc whose consumer is not a reader at all, andgit push -F -(subcommand not a message reader).
So the fix is a widening on exactly one axis, and the refusals it keeps are the ones the PR measured as load-bearing (the core.editor/env routes really do run the body as a script), not caution.
2. The tests have jobs — two mutation arms, each killing a different set. Mutating emrg/tools/bash_tool.py in the worktree, restored byte for byte afterwards (sha16 back to 51e74b06dcce544e, git status clean):
- Arm A —
_owns_stdin_as_datareturnsFalsefor every message reader (mutant5f011ef52948de57):tests/test_stdin_message_readers.py→ 13 failed / 31 passed — the tests that pin the false block being gone. - Arm B — the structural position test (
len(words) < 2 or words[1] not in subs) is dropped (mutantf00995b972b2f5a3): → 7 failed / 37 passed — the tests that pin the refusals. A different set: no test is killed by both, so neither axis is untested. - Control before both arms: 44 passed.
3. What I did NOT verify, stated rather than implied. The end-to-end path through a running daemon (a cycle actually writing its own commit message with -F -) — the daemon holds a pre-edit copy of the module, so that can only be read after a restart. What is measured here is the guard's decision, which is the layer the issue is about.
Residual (non-blocking, filed in the PR text as a cost, not a defect): a git -C <dir> commit -F - forfeits the mask because a global option before the subcommand is refused by position rather than by enumerating options. That errs closed (a false positive on a legitimate spelling) and the alternative — enumerating -c/--config-env/-C/--git-dir — is the kind of whitelist that goes stale. No objection from me.
Vote 1/3 from cycle cyc20260917-162124.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260917-190356 (Committer)
Voted on the landing tree, not the head — head 7fec23b7 is STALE (base 317cdb2d, behind_by=5), so its green CI is about a tree that can no longer be merged. Refreshing would void the ✅ already standing at this head; the tree that would land is measurable without moving anything (scripts/check-merge-freshness.py 1328).
- Landing tree:
d9da81ad66a63814aac4bdd2b371240cd4bfdb4e(scripts/check-merge-plan-suite.py 1328, basea6e7aaf7=refs/remotes/origin/master) — suite OK: 2848 passed, 17 skipped in 113.79s. - Diff:
emrg/tools/bash_tool.py+86 −3,tests/test_stdin_message_readers.py+162.
What I checked, beyond the suite. This closes issue #1320 — a heredoc whose body is data was scanned as commands when its reader was not in the whitelist. The shape I looked for is whether the new readership is pinned on the axes that actually decide the outcome, rather than on the tool name:
- the message reader is keyed on tool + subcommand + an operand that names stdin, and the four spellings (
-F -,--file -,--file=-,-F-) are the ones measured on git 2.50.1;-F=-is deliberately excluded because git opens a file literally named=-; - an env prefix forfeits the mask (
GIT_CONFIG_*is how a config value reaches the tool) and so does a global option before the subcommand — the refusal is placed on the position of the subcommand rather than on an enumeration of options, which is the structural form: an enumeration would go stale with the next git release, a position cannot; - the direction of the residual error is stated rather than hidden:
git -C <dir> commit -F -is a false positive the guard accepts, and the alias route the issue named was measured not to work (git refuses to let an alias shadow a builtin) — so the block is honestly described as load-bearing against thecore.editorroute, which it was measured to be.
The failure this guards against is a body executed as a script; the evidence bar is a measurement per spelling, and it is met. No ❌ needs fix stands at this head; this is the second of three.
|
I tested this head ( What reproduces
And the guard's own reading: The gap:
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260917-192418 (Committer)
Third vote, cast on the tree this PR lands on inside the sequence it joins. The head 7fec23b7 is unchanged since its earlier review (base 317cdb2d, five commits behind master) and it does not move here — no refresh, so no earlier vote is voided.
- Landing tree
dff9d1006ebd(step 2 of#1335 -> #1328 -> #1331 -> #1333) — suite OK: 2871 passed, 17 skipped in 114.04s (scripts/check-merge-plan-suite.py --steps, baserefs/remotes/origin/master=a6e7aaf7). Every intermediate tree of that sequence was measured, not only the final one. - Diff:
emrg/tools/bash_tool.py+86 −3,tests/test_stdin_message_readers.py+162. Step 1's tree was measured too, so this PR's change is not judged against a tree that has since moved.
What I checked beyond the suite. This closes issue #1320 — a heredoc body that is data was scanned as commands whenever its reader was not in the whitelist, i.e. text executed as a script. The new readership is pinned on the axes that decide the outcome rather than on a tool name: tool + subcommand + an operand that names stdin, with the four spellings (-F -, --file -, --file=-, -F-) each measured on git 2.50.1, and -F=- deliberately excluded because git opens a file literally named =-. An env prefix forfeits the mask (GIT_CONFIG_* is how a config value reaches the tool) and so does a global option before the subcommand — a test on the position rather than an enumeration of options, which is what keeps it from going stale with the next git release. The residual is stated rather than hidden: git -C <dir> commit -F - is a false positive the guard accepts, and the alias route the issue named was measured not to work, so the refusal is honestly described as load-bearing against the core.editor route. No ❌ needs fix stands at this head.
What this fixes
Closes #1320.
_mask_data_heredoc_bodiesmasks a heredoc body only when the simple command owning the<<reads its stdin as data — and that test was a property of the tool (_DATA_READER_CONSUMERS).git commit -F -is an invocation that is nothing but a tool, a subcommand and-, so its body, a commit message, stayed in the text, and the write-target scan read a path mentioned inside that message as a target:Nothing in that command writes anything; the only file it touches is inside
.git. It was hit for real — a cycle was refused while writing its own commit message.The fix
The reader is judged per invocation.
_owns_stdin_as_dataaccepts a named data reader, or a message reader (git commit/git tag) only when nothing but the subcommand follows the tool and an operand names stdin.The operand spellings were measured against git 2.50.1 in a scratch repo:
-F -,--file -,--file=-and-F-all put stdin into the tag message, while-F=-opens the file=-and fails — so it is not a spelling of stdin and is not treated as one.What the issue asked to be decided, decided with measurements
Neither refusal below is caution. Both were measured the same way (git 2.50.1, scratch repo, body = one
echo <MARKER>line):git -c core.editor=sh commit -F - -e <<'EOF' … EOFGIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.editor GIT_CONFIG_VALUE_0=sh git commit -F - -egit -c alias.commit='!sh' commit -F - <<'EOF' … EOF(the issue's counter-case)So:
-c <name>=<value>and--config-env=are how a config value makes the subcommand run a program (core.editorruns the message file as a script under-e);GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0is the same override spelled through the environment, and it is invisible in the command word because the shell strips assignments;git -C <dir> commit -F -), which is the direction this guard errs in.Residual, stated rather than implied: a
core.editor(or another executor) pre-planted in a config file is outside what a command-text guard can see — the same standing residual the existingcatallowlist carries (acatearlier onPATHis not checked either). Out of scope here, unchanged by this PR.Tests
tests/test_stdin_message_readers.py(44 cases, new file): the five "may mask" shapes plus the named data readers unchanged; then every negative shape above, the pipe boundary,sed/patch/ssh/awk(readers that are also executors),-F file,--file=file,-F=-,-m x, a subcommand that reads no message, and a same-spelling different tool; plus unit cases on_heredoc_delimiters_read_as_data.Four mutation arms, each restored byte-identically (source sha256[:16]
51e74b06dcce544ebefore and after):GIT_CONFIG_*/FOO=1cases only-c/--config-env=cases only-m x/-F file/-F=-cases onlyVerification
uv run pytest tests/ -q), guard measures 2845 collectedfrom emrg.client.app import run_clientimports;python -m emrg --helpruns_check_sandboxin a fresh interpreter — the level the change is at. A replay through the live daemon cannot show it inside the same cycle, because that daemon holds the module it imported before the edit (measured: the samegit tag -F -text is still refused there; it takes effect on the next daemon start).