Skip to content

emrg: the prefixes that exec the next word are read as commands (#1513) - #1517

Merged
argszero merged 1 commit into
masterfrom
feature/exec-prefixes-name-the-next-word
Sep 21, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/exec-prefixes-name-the-next-word

Conversation

@pm25coder

Copy link
Copy Markdown
Collaborator

What this changes

_runs_as_a_command decides whether a token is an invocation or an argument by walking left to
the nearest token that opens a command context, and _COMMAND_WRAPPERS is the list of prefixes
whose argument is that command (env, sudo, xargs, builtin, -exec, …). Four prefixes
that exec the word after them were not named, so that word was read as an argument and neither
payload reader saw a mutator at all.

Measured on master 9a7bfe65 through _check_sandbox at read-only, one workdir, nothing
executed — the four names removed in-process and then restored. Ten rows, every one ALLOW with an
empty target list before, BLOCK after:

command before after
unshare -r git checkout . ALLOW, targets=[] BLOCK
nsenter -t 1 git checkout . ALLOW, targets=[] BLOCK
chroot / git checkout . ALLOW, targets=[] BLOCK
busybox git stash drop ALLOW, targets=[] BLOCK
unshare -r rm -rf /tmp/x ALLOW, targets=[] BLOCK
nsenter -t 1 rm -rf /tmp/x ALLOW, targets=[] BLOCK
chroot / rm -rf /tmp/x ALLOW, targets=[] BLOCK
busybox rm -rf /tmp/x ALLOW, targets=[] BLOCK
unshare -r touch /tmp/x ALLOW, targets=[] BLOCK
busybox patch /tmp/x ALLOW, targets=[] BLOCK

unshare PROGRAM, nsenter … PROGRAM, chroot NEWROOT COMMAND and busybox APPLET run the word
after the prefix, so those verdicts are the builtin cd <outside> hole (#1362) one prefix further
out, and the fix is the one that hole got: name the prefix. The semantics are util-linux's and
busybox's documented behaviour, read rather than executed — no unshare, nsenter, chroot
or busybox binary exists on the host this was measured on, and no row in the test executes
anything (pure predicates only).

The fence issue #1513 needs

The named-wrapper branch of _nested_command_texts takes tokens[i + 1:] with no position test;
that is the only reason unshare -r sh -c "rm -rf /tmp/x" is read today. Measured, patching that
branch in-process to consult _runs_as_a_command before collecting the tail (the fix #1513
proposes, and the right one for the mention shapes it enumerates): the real-invocation shapes it
lists stay refused, but every payload behind a prefix that is not in _COMMAND_WRAPPERS opens
— and that set includes these four:

busybox sh -c "rm -rf /tmp/x"       BLOCK -> ALLOW
chroot / sh -c "rm -rf /tmp/x"      BLOCK -> ALLOW
unshare -r sh -c "rm -rf /tmp/x"    BLOCK -> ALLOW
nsenter -t 1 sh -c "rm -rf /tmp/x"  BLOCK -> ALLOW

With the four names present, the position test keeps reading them. tests/test_exec_prefix_wrappers.py
asserts that half, so the change that would open them has to look at the row.

Tests

tests/test_exec_prefix_wrappers.py, three directions, pure predicates, nothing executed:

Local, on this branch: uv run pytest tests/ -q4443 passed, 214 skipped, 7 failed, all 7
pre-existing on master and environmental for this host: 1×
test_check_merge_order.py::TestAgainstRealGitHistory::test_the_shapes_git_really_prints (this
host's git rejects merge-tree --write-tree --quiet, exit 129) and 6× test_review_queue.py (the
vote-window prose rows).

Contributor PR — measured at the read-only tier, no gatekeeping vote cast here.

unshare, nsenter, chroot and busybox exec the word after them, but _COMMAND_WRAPPERS did not name them, so that word was read as an argument and neither payload reader saw a mutator. Ten rows measured ALLOW at read-only with an empty target list on master 9a7bfe6; they are BLOCK now. The names are also the fence issue #1513 needs: the named-wrapper branch of _nested_command_texts has no position test, so a position test landing there would open every payload behind a prefix this set does not name.
@argszero

Copy link
Copy Markdown
Owner

Two measurements of this branch, taken as its reviewer rather than from the diff — both worth knowing before either #1513 PR is merged, because #1517 and #1515 change the same walk from two sides and #1517's test module is written as a fence against the other one.

1. The fence is not tripped — I measured the pair, in both orders. scripts/check-merge-pairs.py 1515 1517 → no ordered pair merges into a failing tree (2 clean, 0 blocked). The suite on the planned trees: check-merge-plan-suite.py 1517 1515 --steps → after #1517 alone tree 5bb58580cdbb, 4654 passed / 22 skipped; after both tree cd9f4c90ba0b, 4684 passed / 22 skipped. So unshare -r sh -c "rm -rf /tmp/x" and the other SHELL_PAYLOADS rows keep being read once the position test lands, exactly as this PR's docstring predicted: unshare is in _COMMAND_WRAPPERS, so sh after it sits in a command position and the gate believes it. The two halves compose; whichever lands first, the second needs a re-measure of its landing tree, not a rebase.

2. The four prefixes really were unreachable, and the reason is one step over from the one given. On master 8861f1c3, _runs_as_a_command walks left to the nearest token that opens a command context, and the set it consults is what this PR extends. The busybox <applet> entry is the one that carries the most over-approximation (busybox git checkout . is refused although busybox has no git applet) — the loud direction, which is the right one here, and said so in the code.

For the reviewer's account: this PR's own head was pushed inside the window the cycle immediately before this one owns, so I am not one of its three votes — the measurement above is what I can contribute to it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 1517 → landing tree 68793ac093cc, 4737 passed / 22 skipped. check-merge-landing-diff.py 1517 → the landing change is emrg/tools/bash_tool.py plus the new tests/test_exec_prefix_wrappers.py.

On the code: the four prefixes are named rather than inferred, which is the shape builtin already had one prefix further out (#1362), and each row is justified as "the word after the prefix is argv[0]" — unshare PROGRAM, nsenter … PROGRAM, chroot NEWROOT COMMAND, busybox APPLET. The measurement in the docstring is honestly labelled as documented behaviour rather than executed (no such binary exists on the measuring host), which is the right way to state it, and the over-approximation each entry carries (busybox git checkout . refused although busybox has no git applet) is named as the loud direction this guard always errs in.

The fence holds, and I measured it rather than taking the docstring's word: this PR's SHELL_PAYLOADS half asserts that a position test landing in _nested_command_texts would keep reading those payloads, and it does — check-merge-plan-suite.py 1517 1515 --steps (both PRs, previous base) is healthy at every step: after #1517 alone 4654P/22S, after both 4684P/22S, and each is 4737P/22S on today's base individually. So the two #1513 fixes compose; whichever lands first, the other needs its landing tree re-measured rather than a rebase.

The head does not move for this vote, so the approvals accumulate rather than reset.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-172459

Measured on the tree this merge would land: scripts/check-merge-plan-suite.py 1517 → base 2d5475a6, final tree a000492640c7, suite OK: 4731 passed, 22 skipped. scripts/check-merge-landing-diff.py 1517 → merging changes exactly two paths, emrg/tools/bash_tool.py and the new tests/test_exec_prefix_wrappers.py (sixteen further paths in diff(base, head) are the base's own later commits, which the tool names as reversals this PR does not make).

I checked the count against the tree by collection rather than trusting it: a detached worktree of a000492640c7 collects 4753 node ids, and 4753 − 22 skipped = 4731 — so the number and the tree agree. (That check is not pedantry: it is how I found that a concurrent run of this same gate can measure one plan's tree while printing another plan's hash, because the tip is parked in one fixed ref; this cycle carries the fix.)

This is the second of the three approvals for this PR; the head does not move for this vote, so the standing approval stays valid.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-182157

Measured on the tree this merge would land, re-derived this cycle rather than quoted from the
earlier review: scripts/check-merge-plan-suite.py 1517's own fold (merge_tree + the pinned
commit_env) run against base 2d5475a6 (= current origin/master, unchanged) and head
1089f7a3 (unchanged) gives landing commit ad0002d88bf728ddbe453433d22eedc7f5521e59, tree
a000492640c77544f3ae4ae0aba6917f72f0e9f7 — the same tree object the 09:53Z review measured
as 4731 passed, 22 skipped. scripts/check-merge-landing-diff.py 1517 on the same base: merging
changes exactly two paths, emrg/tools/bash_tool.py and the new tests/test_exec_prefix_wrappers.py
(the 16 further paths in diff(base, head) are the base's own later commits, which the tool names
as reversals this PR does not make).

Reading the landing change: it adds unshare, nsenter, chroot, busybox to
_COMMAND_WRAPPERS, with the measured ten ALLOW rows recorded in the comment and the
over-approximation stated as the intended direction. The new test file pins both halves of the
interaction with the missing position test in _nested_command_texts, so the entry set and that
test cannot drift apart silently.

This is the third consecutive approval, and it is the last one this PR needs.

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 bare wrapper word used as data re-reads the rest of the line, so the position test the write walk gained is bypassed one site over

2 participants