Skip to content

emrg: a message reader's heredoc body is data, judged per invocation (#1320) - #1328

Merged
argszero merged 1 commit into
masterfrom
fix/heredoc-message-reader
Sep 17, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/heredoc-message-reader

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this fixes

Closes #1320.

_mask_data_heredoc_bodies masks 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:

git commit -q -F - <<'EOF'
echo x > ~/.emrg/config.toml
EOF
-> BLOCKED, "blocked write to protected daemon file '~/.emrg/config.toml'"

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_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- 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):

shape result
git -c core.editor=sh commit -F - -e <<'EOF' … EOF the body ran as a script (marker printed, commit made)
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.editor GIT_CONFIG_VALUE_0=sh git commit -F - -e the body ran as a script
git -c alias.commit='!sh' commit -F - <<'EOF' … EOF (the issue's counter-case) the builtin ran; the body stayed a message

So:

  • a global option before the subcommand forfeits the mask-c <name>=<value> and --config-env= are how a config value makes the subcommand run a program (core.editor runs the message file as a script under -e);
  • an env prefix forfeits the maskGIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 is the same override spelled through the environment, and it is invisible in the command word because the shell strips assignments;
  • the subcommand is read at argv[0] after the tool: a position test, not an enumeration of the options that can precede it (the emrg: 修复 p12 私钥校验单复数匹配 — identities imported #461 class);
  • git itself refuses to let an alias shadow a builtin, so the alias spelling in the issue's counter-case does not run the body on git 2.50.1. The option-position refusal is kept anyway; it costs a rare false positive (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 existing cat allowlist carries (a cat earlier on PATH is 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] 51e74b06dcce544e before and after):

arm reddened
message-reader arm returns False 13 (the whole "may mask" half)
env-prefix refusal removed the GIT_CONFIG_* / FOO=1 cases only
option-position refusal removed the -c / --config-env= cases only
operand test returns True the -m x / -F file / -F=- cases only

Verification

  • full suite: 2829 passed, 16 skipped (uv run pytest tests/ -q), guard measures 2845 collected
  • from emrg.client.app import run_client imports; python -m emrg --help runs
  • verified through _check_sandbox in 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 same git tag -F - text is still refused there; it takes effect on the next daemon start).

…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 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 — 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 — A1 git commit -q -F -, A2 git 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, the GIT_CONFIG_COUNT=… env prefix, a heredoc whose consumer is not a reader at all, and git 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_data returns False for every message reader (mutant 5f011ef52948de57): tests/test_stdin_message_readers.py13 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 (mutant f00995b972b2f5a3): → 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 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 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, base a6e7aaf7 = 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 the core.editor route, 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.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this head (7fec23b7) — the per-invocation axis is the right one and every positive claim reproduces on git 2.50.1. But the mask still accepts an invocation whose body really runs, reachable in two allowed steps: the editor can be named by config instead of by an option, and -e is on the masked side.

What reproduces

tests/test_stdin_message_readers.py: 44 passed. Independently, against git 2.50.1 (this machine's version, same as yours):

tag spelling result
git tag --file - v1 (stdin) rc 0, tag created, subject = the stdin body
git tag --file=- v2 rc 0, same
git tag -F- v3 rc 0, same
git tag -F=- v4 rc 128 — fatal: could not open or read '=-'

And the guard's own reading: -F -, --file -, --file=-, -F- all mask the body; -F=-, git commit -F msg.txt, sh -c do not; git -c … and git -C /tmp … forfeit (your documented false positive), and so does the GIT_CONFIG_* env prefix. Read-only blocks all of these by the verb (blocked git mutating command 'git commit'), so what the mask decides is the workspace-write verdict — which is the tier every cycle runs in.

The gap: -e keeps the mask, and the editor can be set beforehand

_owns_stdin_as_data reads the editor route as a position rule — the global option must not sit between tool and subcommand. The same effect is reachable with no option at all, because the editor is also decided by repository config:

step 1   git config core.editor sh        -> allowed (workspace-write, ordinary local config)
step 2   git commit -F - -e <<EOF
         echo TOOL_BODY_EXECUTED > /tmp/r2475-tool-body-ran.txt
         EOF

Both through the real BashTool.execute at sandbox: workspace-write, in a scratch repo: error = False for both, and after step 2 the marker file exists with TOOL_BODY_EXECUTED in it — the body's line ran. Step 1 is a plain allowed command (local config inside the workspace), so nothing in the pair looks like a bypass.

Guard-level version of the same measurement: git commit -F - -e <<EOF … EOF is masked (delims=['EOF'], body not visible) and allowed at workspace-write, while your refused form git -c core.editor=sh commit -F - -e <<EOF is unmasked and blocked. The difference is only where the editor came from; the body ends up as a script in both.

The docstring's own sentence covers this — "Nothing else may be on the line, because a global option before the subcommand and an env prefix can both decide what the subcommand does" — but the code checks only words[1] (position) and then searches words[2:] for the operand, so -e, --edit and --amend are accepted: measured, git commit -F - --edit and git commit -F - --amend are both masked.

Suggested direction (not a prescription)

Forfeit the mask when the invocation asks for an editor, in the same place the env prefix forfeits — -e / --edit anywhere in words[2:]:

  • it is the signal that the body stops being a message and becomes a file handed to a program;
  • it cannot be answered by config, because the flag is in the command text and the config is not;
  • it costs no legitimate use I can see: the same commit without -e still masks and still works (git tag -F - v1 keeps the tag-name operand and stays masked, measured).

A test in this file's idiom would be the behavioural version rather than the shape one — a fixture repo where git config core.editor sh has been run, then the two-step pair, asserting the body's marker does not appear. That also pins the property against a future re-spelling of the operand.

If instead you decide the two-step is out of scope for this PR, it is worth a sentence in the docstring, since the current wording claims a boundary the code does not enforce on that axis.

@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 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, base refs/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.

@argszero
argszero merged commit f9c874d into master Sep 17, 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: a heredoc whose body is data is scanned as commands when its reader is not in the whitelist

2 participants