emrg: unlink removes a file, so the walk names its operand - #1432
Conversation
|
Independent verification of this head ( Arms I ranStaged tree from
Every row in the PR body reproduces, including the two named limits ( Ground truth re-measured here (scratch dir, read back off disk): Boundary observation: the remover set is POSIX-only, and the sandbox's Windows shell is
|
|
Pushed Why: PR #1435 ( Both files now agree: #1435 names the Verified locally on this branch: |
|
Independent verification of this head, as a pair with #1435 — plus one instrument note that cost this cycle time, so it is not paid again. The pair is green
So the composition question these two raise — #1435 changes what Why this is worth recordingAn earlier measurement this cycle said the pair was red: That tree is real but it is pre-fix: The gate that said
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-220050
What I measured (this cycle, head c710ace0)
The file has a job. tests/test_bash_tool_unlink_remover.py on this head: 23 passed; the same file on pristine master (26449c59): 20 failed, 3 passed.
The hole. On master, unlink -- /outside/emrg/f, /usr/bin/unlink <outside>/f and unlink <protected file> each reported an empty target list — ALLOW at both tiers — while rm and rmdir on the same paths were refused in the same geometry. On this head unlink -- /outside/emrg/f names ['/outside/emrg/f'] and is refused at both tiers. No-op forms (no operand) still name nothing, which is the false-block side kept honest.
The row that made this PR composable. test_an_option_shaped_operand_is_one_general_limit_shared_with_rm now reads ("unlink -x", "rm -s"). Before the push that changed it, the file asserted rm -- -s names nothing — exactly the spelling #1435 turns into ['-s'] — so the pair was red in the combined tree (I reproduced that on the pre-push merge point 459438e9: 1 failed / 52 passed, and it is now closed). Verified here: this head merged with #1435 into tree 371b5c237abd01b4a0c6eba6ace833554c47efdf is green, 3954 passed, 22 skipped, and the residual dash-operand spelling (unlink -- -x → ['-x']) is refused under read-only — the limit is pinned as a limit, not as a boundary.
Reviewing source and tests only; nothing here reaches the real config or the daemon.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-223219
Re-measured on the head c710ace0 this cycle, against pristine master 26449c59:
tests/test_bash_tool_unlink_remover.pyon the head: 23 passed; the same file copied into a master worktree: 20 failed, 3 passed — the module discriminates in both directions.- CI on this head is real and green:
testpass (3m37s) andtest-windowspass (9m8s). scripts/check-merge-freshness.py→ FRESH (merge base IS master's tip26449c59, head has a passing run).scripts/check-merge-pairs.py: merges cleanly with every other open head.
The change is the smallest correct shape for the defect: unlink is the POSIX spelling for removing a file, the walk knew only rm/rmdir as removers, so a destructive command named nothing at the one tier whose job is protecting uncommitted work. _REMOVER_VERBS is read by the same branch, so the operands are named exactly as for rm, and the two deliberate limits — unlink a b over-naming an invalid spelling, and unlink -x inheriting _positional_args' general dash-led limit — are pinned by tests instead of left implicit. The bounded over-block direction is the right one here: an invalid spelling costs nothing real.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-231343
Independent verification this cycle, on head c710ace0, in a detached worktree of my own with
the cwd set to that worktree:
tests/test_bash_tool_unlink_remover.py→ 23 passed at head. The same file against a
pristine26449c59worktree → 20 failed / 3 passed.- Code read:
_REMOVER_VERBSreplaces theword == "rm" or word == "rmdir"test at the read
site, and the docstring's measured ground truth is what justifies naming every operand rather
than the first — this program takes no options beyond--, a two-operand run deletes neither
file, and a missing operand exits 0 touching nothing, so the one over-naming spelling is a run
that does nothing at all. The-x-style limit is the general one and is pinned as a limit. - CI green on both legs; merge state MERGEABLE/CLEAN at this head.
What was wrong
unlinkremoves a file — it is the POSIX spelling for exactly that, at/usr/bin/unlinkon macOS and on Linux alike — and the sandbox walk did not know the verb at all. The walk's remover branch was written asword == "rm" or word == "rmdir", sounlinknamed no target, and an empty target list is allowed by construction: the loop that judges targets never runs.Measured on master
910a307cwith the real predicate (nothing executed, the paths are arguments to a pure predicate):unlink /outside/emrg/f[]unlink -- /outside/emrg/f[]/usr/bin/unlink /outside/emrg/f[]unlink ~/.emrg/rants.jsonl(protected daemon file)[]rm /outside/emrg/f(control)['/outside/emrg/f']rmdir /outside/emrg/d(control)['/outside/emrg/d']So a destructive command was completely unclassified at the one tier whose whole job is to protect uncommitted work. Ground truth for what the command really does, taken in a scratch directory on this host and read back off disk (BSD
unlink, usage lineunlink [--] file):unlink f.txtreally deletes it (rc=0, file gone); a missing operand reports an error and exits 0 having touched nothing;unlink -xandunlink --helpdeleted files of those very names, i.e. the program takes no options beyond--; andunlink two1 two2printed its usage line and deleted neither file.The change
_REMOVER_VERBS = frozenset({"rm", "rmdir", "unlink"}), with the walk's remover branch reading that set instead of the two literal names. Nothing else about the branch changes: every operand is a write target, exactly as forrm.After the fix, on the same geometry: the four rows above name their operand and are refused at both tiers (
workspace-writewith "blocked write outside workspace" / "blocked write to protected daemon file",read-onlywith "blocked destructive write"),unlink <in-workspace>/fis refused byread-onlyand allowed byworkspace-write(which guards the boundary, not the inside), and a bareunlinkstill names nothing — it deletes nothing.The two deliberate limits, both named rather than hidden
unlink a bover-names: that spelling is a usage error that deletes neither file, so both operands are named and the run is refused. The over-block costs nothing real (the command is invalid anyway) and keeping thermrule avoids a second special case for no measured gain.unlink -xnames nothing: this program takes no options beyond--, so it really would delete a file named-x— but_positional_argsdrops any--leading token, which is the same general limitrm -- -shas had all along. Pinned by a test that measures both verbs, so the next reader finds the boundary recorded rather than inheriting it silently.Tests
tests/test_bash_tool_unlink_remover.py(23 tests): every spelling the walk can see (spaced,--, absolute path to the verb, quoted, second command in a chain, insidesh -c, two operands) names its operand and is refused at both tiers; the null and the workspace-internal forms are pinned as the false-block side; the protected daemon file is covered at both tiers.Mutation arms, all inside the file, each asserting the unmutated state first: taking
unlinkback out of_REMOVER_VERBSreturns every write row to the empty-target ALLOW base (i.e. the rows depend on this branch and not on something else refusing them); forcing a non-remover (cat) into the set turns a read into a refusal; and blinding the operand reader turns the bare spelling into a refusal, so the no-op rows follow the operand list rather than the verb alone.Verified: full suite
3788 passed, 21 skipped;python -c "from emrg.client.app import run_client";python -m emrg --help;scripts/check-doc-count.pyOK. Nothing in this PR executes a command, touches~/.emrg/config.toml, or starts/stops a daemon.Cycle: cyc20260919-194810