verify: the checklist becomes executable — make verify, numbered scripts, CI (#589) - #592
Merged
Merged
Conversation
…ripts, CI (#589) VERIFY.md earned its exemption from the no-`docs/` rule by being executable — "every line is a command with its expected output". Nothing executed it. The only thing that ever checked whether a line was still true was a human reading it, and the commit count says how often that happened. A command plus the output it produced once is a copy of state: stale the moment the code moves, and the staleness found by whoever finally walks it. The exemption moves to `scripts/`, which is executable in the sense VERIFY.md claimed. RUNNER. `make verify` walks `scripts/NNNN-slug.*` in numeric order. Each check prints what it asserted and exits non-zero when the assertion fails; the expected output lives in the assertions, not in prose beside them. EXIT 79 IS SKIP, not the conventional 77, because snug's own exitPolicy IS 77 (internal/cli/main.go). A script ending on an uncaptured `snug` refusal would otherwise report SKIP — a check that cannot fail. 79 is outside sysexits' 64-78 range entirely, so it cannot be one of snug's. A guard test refuses `exit 77` in a check rather than a comment saying not to. SNUG_VERIFY_FLOOR is the same guard as SNUG_HOSTLESS_FLOOR and SNUG_ENGINE_FLOOR: a minimum number of checks that must actually PASS, so a run in which everything SKIPped cannot be green. SNUG_REQUIRE_SANDBOX=1 turns every SKIP into a failure, as it does for the Go suite. NNNN IS AN ALLOCATION SEQUENCE, not a VERIFY.md section number. Sections carry suffixes (9a, 9c-bis, 9c-quater, 23b) no numeric prefix can express, and one section is frequently several independent checks. Allocated on creation, never reused, so a reference to 0011 means one thing forever. FOUR CHECKS, migrating VERIFY §1 and §27: 0010 `snug doctor`'s exit code agrees with the rows it printed — ❌ fatal,⚠️ not — and `snug fix subuid` is safe from an errexit init_hook 0011 the user-namespace row is MEASURED, not inferred from an exit code: on a fabricated host where namespace creation is blocked it says ❌ and exits 69 (issue #98) 0020 the five inherited kernel knobs are disclosed and never refused, and the drop-in is a function of the table rather than of this boot — 4 applied, 5 written (issue #526) 0021 a knob this kernel does not HAVE is never reported as one the host failed to set, and never gets a line in the drop-in (issue #526) PAYLOADS move to `scripts/payloads/`. A payload is a program run INSIDE a sandbox; it has no pass/fail of its own, so the runner does not walk it. That is what `http-door-server.py` and `pid-nesting.py` always were, and keeping them beside the checks would have meant the runner parsing names to tell two kinds apart. test/integration/scriptsstillrun_test.go and README.md follow the path. WHERE A NEW CHECK GOES, the rule that stops this becoming a chore list: - CI can run it on every push -> test/integration - CI cannot: per-machine answer, state CI structurally lacks, or a human's sudo -> scripts/NNNN-slug.sh - a program run INSIDE a sandbox -> scripts/payloads/ - a Go test already asserts it -> nowhere; delete the prose Never a new VERIFY.md section. A section is MIGRATED WHEN TOUCHED, never edited in place. CLAUDE.md, .claude/design/INDEX.md and VERIFY.md's own banner carry the rule; scripts/README.md is the index. THE DIRECTORY IS GRADED BY test/guard/scripts_test.go, because `make verify` cannot grade it: a number used twice, a file that is not executable and so is never run, a check missing from the index, a script spelling SKIP as 77, or a floor above the number of checks that exist are all invisible to a green `make verify` and visible to `make gate`. CI runs `make verify` on the integration-sandbox leg only — the signals leg is the same runner and the same host, so running the checks twice would measure one host twice. SNUG_REQUIRE_SANDBOX is deliberately NOT exported to that step: a SKIP there means "this host cannot be made weak in the specific way this check needs", and the floor is what stops the step passing having run nothing. No count of VERIFY.md's remaining sections is written down anywhere, here included. `wc -l VERIFY.md` and `grep -c '^```bash' VERIFY.md` recompute it, and the sections they count are in VERIFY.md, which is where a reader already looks. A count in prose is the chore-list shape two retired trackers died of. Open for the maintainer, both on the ticket: SNUG_VERIFY_FLOOR is 1 in CI, which is a reasoned minimum rather than a measurement — raise it to the count the first green run reports, since a floor is a MINIMUM OVER RUNS and a lucky reading builds one that can never be cleared. And VERIFY §13 needs two live GitHub accounts and two loaded ssh-agent keys, so a script for it would SKIP everywhere; it has never been walked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ther reason SKIPs
CI red on the first run of `make verify` (run 35505315762): 2 passed, 2 failed.
Both checks fabricate their host inside `unshare -Urm`, and `snug doctor` in a
NESTED user namespace is a per-host answer. On the GitHub Actions ubuntu runner
(bubblewrap 0.9.0) it refuses:
❌ bwrap cannot start a sandbox here, and the reason is not one this probe recognises
💬 bwrap said: bwrap: Can't mount proc on /newroot/proc: Operation not permitted
0020 read that ❌ as its own property failing — "doctor refused a host missing
four knobs; it must warn, never fail" — when the refusal had nothing to do with
the knobs. 0021 never got that far: `report=$(yamaless "$SNUG doctor 2>&1")`
under `set -e` ends the script with doctor's own status, so the runner reported
`exited 69`, an exit nobody wrote.
CONTROL. Both now ask doctor the same question in the same nested namespace
with NOTHING fabricated in it. A ❌ there belongs to the host, so the check
SKIPs and prints the row it refused on. A clean control is what makes the ❌
each check refuses attributable to the fabrication — without one, "warn, never
fail" is graded against a host that fails for an unrelated reason, which is a
check that cannot PASS rather than one that cannot fail. 0021 also captures
doctor's exit instead of inheriting it.
Verified both directions: `make verify` here is still 4 passed, 0 skipped, and
with SNUG pointed at a stub that prints a ❌ and exits 69, both exit 79 and name
the row.
The CI comment carries the measurement rather than the guess it had.
SNUG_VERIFY_FLOOR stays 1: a floor is a minimum over runs, and one green run is
not one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…N costs bwrap its /proc The control added in dd1c4dd asked whether doctor is clean in a nested user namespace with nothing bound. CI run 35505557677 answered yes and 0020 still failed, which refutes the diagnosis in that commit: the nested namespace is not what doctor refuses. Binding a file over /proc/sys/kernel/<knob> gives the mount namespace's /proc a submount, and a kernel enforcing mount_too_revealing() then refuses a NEW procfs instance inside the user namespace — bwrap's own `--proc /proc`. So the fabrication, not the namespace, is what costs doctor the probe: ❌ bwrap cannot start a sandbox here, and the reason is not one this probe recognises 💬 bwrap said: bwrap: Can't mount proc on /newroot/proc: Operation not permitted Second control, AFTER the knob rows are graded rather than before: a ❌ that names no knob is the fabrication's own cost on this kernel, so the check SKIPs and prints it. A ❌ that DOES name a knob is the property failing and stays a FAIL — which is what keeps this a control rather than a way of never failing. Both directions run, with SNUG pointed at stubs: a ❌ naming no knob gives exit 79 after the four⚠️ rows and the ✅ row are asserted; the same ❌ rewritten to name kernel.kptr_restrict gives `FAIL: doctor refused a host missing four knobs` and exit 1. `make verify` here is still 4 passed, 0 skipped. The first control stays. It is cheap and it answers a different question — is the namespace usable at all — which is the one 0011 depends on too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…converted into A checklist in one directory with its executable form in another is two places a reader has to know about, and the migration rule points at both of them. One directory now holds the whole thing: `make verify` walks scripts/NNNN-slug.sh, scripts/payloads/ holds the programs run inside a sandbox, scripts/README.md is the index, and scripts/VERIFY.md is what is left of the by-hand half. `git mv`, so the history follows. Every reference repointed rather than left to rot: README.md's two links and its three prose citations, .claude/design/INDEX §1 and its repository-tree listing, CLAUDE.md's exemption paragraph, scripts/README.md's own rows, internal/profile/docexamples_test.go's extraction path, and internal/cli/designcitation_test.go's notADesignDoc map — which is the test that would have caught a citation left behind. Inside the file: the three relative links now resolve from scripts/, and a new line says the commands are still run from the REPOSITORY ROOT. The file moved; the working directory every `./bin/snug` in it assumes did not. Checked, not assumed: every relative markdown link in the tree resolves, and no reference to a root VERIFY.md survives anywhere. `make gate` and `make verify` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
43 new named tests, four new checks in scripts/, and five checks that were
already there deleted because the rule says they belong in Go.
WHAT THE SECTIONS BECAME. Each of the five agents walked VERIFY.md sections
by RUNNING them, not reading them, and each converted section landed as a
test, a script, or nothing at all where a Go test already asserted it.
Bugs the walk found, all four by running:
- `snug profile show` printed `mtu` as a bare capability row with no
consequence sentence beside it, alone among the rows showCapabilities
emits. internal/cli/config.go now uses capRows and says what an MTU
changes and what it does not: segmentation, never reach.
- `@tmp-shared` resolved to "unknown profile" with no suggestion, though it
and the `{host_tmpdir}` variable behind it were removed on purpose. It is
in retiredProfiles now and names the replacement spelling —
`rw = ["/path/on/host:/tmp"]` — because a retired name that says only
"unknown" sends the reader looking for a typo.
- strings.Count(out, "CREATE: 201") matched inside "VOLCREATE: 201".
- A 404-was-assumed-where-403-is-correct: dockerproxy's ownership gate fails
closed, so the refusal is 403.
FOUR NEW CHECKS, each needing state CI structurally lacks:
0012 five wrong-host conditions each get doctor's own headline and fix,
not another's (two arms need sudo)
0032 the trust dialog is pre-answered and a hostile repo's hooks and
.mcp.json servers are reinterpreted away, with the same fixture
firing on the bare host as the control
0033 an unnamed plugin's hooks.json is not even read inside (issue #68)
0034 the staged credential carries exactly five fields, never a refresh
token, and a live turn on it still authenticates (issue #58)
FIVE CHECKS DELETED, graded against scripts/README.md's own rule — "a check
CI can run on every push belongs in test/integration, not here":
0010 its header said "SKIP: never. Every assertion here holds on any host
that built the binary", which is the definition of a check CI can run
0011 passed on the ubuntu runner (run 35505849557)
0021 passed there too, and internal/cli/hostsysctl_test.go already
asserted every one of its properties — the script was a second copy
0030 justified itself with "CI has no store directory holding a real
host's accumulated pre-label generations", then built one with
mktemp -d
0031 its one per-machine assertion (does this host's /usr carry an editor)
it already reported rather than asserted; the rest is env.claude.txt
They are now TestDoctorsExitCodeFollowsTheRowsItPrinted and
TestFixSubuidIsSafeToCallFromAnErrexitInitHook;
TestDoctorSaysNoOnAHostWhereUserNamespacesDoNotWork;
hostsysctl_test.go's existing pair; TestEngineGCSeesEveryGenerationOfStoreName;
TestClaudeInheritsAPagerAndNeitherEditorVariable.
0011's fabrication survives the move: the Go test still builds the weak host
with `unshare --user --map-root-user` and max_user_namespaces=0, still runs
the nested-unshare positive control, and still grades the whole binary on the
row AND the exit code. doctoruserns_test.go's comment says that condition is
"not something the unit suite can arrange everywhere" — true, and the
integration suite is where it can.
0030 keeps both older store-name generations by renaming
buildEngineStoreFixture's output onto them: bare 64-hex WITH a store.json
(ReadBreadcrumb's `"sha256_"+key == want` arm, so attributed) and bare 16-hex
with none. Mutation-checked — spell the 16-hex name `0123456789abcdefgg` and
the test fails with "1 store(s)", so the count is not vacuous.
CI'S `make verify` STEP IS REMOVED, and that follows from the rule rather
than weakening it. What is left in scripts/ is by construction what CI cannot
run, so a step walking it could only ever SKIP and SNUG_VERIFY_FLOOR would
have to be 0 to let it pass — a step that cannot fail, which is the shape
every floor in that file exists to refuse. Measured before removal (run
35505849557): 3 passed, 1 skipped, and all three that passed are the migrated
ones. test/guard/scripts_test.go keeps grading the directory on every push:
duplicate numbers, a non-executable file, a check missing from the index,
SKIP spelled 77 — none of which running the checks would catch.
SNUG_VERIFY_FLOOR 4 -> 1, and it is now a different kind of number from
SNUG_ENGINE_FLOOR: a promise about the maintainer's own host and nothing
else. `make verify` here: 5 passed, 0 skipped, 0 failed.
Numbers 0010, 0011, 0021, 0030 and 0031 stay retired. An allocation sequence
that reuses a number after a check leaves the directory makes a reference in
an old commit message mean two things.
THE WRITABLE SURFACE IS NINE PATHS, NOT EIGHT, and every document that
counted it by hand was wrong. TestTheWritableSurfaceIsExactlyTheseNinePaths
enumerates the rw rows of /proc/self/mounts inside an ordinary default
sandbox; {home}/.local is in the surface and is nobody's grant. @home names
only its two children two levels down, and InstallAnchors
(internal/policy/anchor.go, issue #553) puts an empty writable tmpfs at every
ancestor whose deepest cover is itself a tmpfs — so a list derived from
base.toml will always be one short. The target is rooted directly under /tmp
so the fixture's own depth adds no anchor row; measured both ways before the
assertion was written.
.claude/agents/sandbox-policy.md was wrong twice in the same bullet: eight,
and /dev listed as writable. /dev's root is remounted read-only by bwrap.go's
KindDev arm (issue #281), so /dev/shm is the one writable path on that
superblock. internal/profile/file_test.go named CLAUDE.md as one of four
documents quoting @home's tmpfs list; `grep -in "writable surface" CLAUDE.md`
is empty. Two documents quote it, and the doc comment and failure message now
say which.
make gate green. SNUG_REQUIRE_SANDBOX=1 make integration green. make verify
5 passed, 0 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erb it caught first TestTheThreeParkedDescriptorsOnALiveStage failed in the Tumbleweed engine job (run 35530416099), reporting all three of fd 66, 67 and 68 "not open in the live stage at all" while its fd 3 control passed. It passes locally and on the ubuntu sandbox leg. CAUSE, measured rather than guessed. __stage-setup and __stage-serve are the same process — the first re-execs into the second — so isStageProcess's prefix match on "__stage" answers yes from the instant execve replaces the image. The Go runtime has not run MainSetup at that instant, so reserveParkingFDs has not yet dup3'd anything onto 66, 67 or 68. fd 3 is inherited across the re-exec, so the positive control is satisfied by exactly the process that cannot satisfy the assertion. Sampled every `snug __stage*` process eight @net runs produced, recording whether 66/67/68 were open: 2621 __stage-serve parked=true 9 __stage-setup parked=false 2 __stage-setup parked=true The parked=false case reads: pid 1934318 __stage-setup parked=false fds=[0 1 10 11 12 13 2 3 4 5 6 7 8 9] which is the CI failure exactly: fd 3 present, no 66/67/68. Rare enough to pass on an idle host, common enough to fail on a loaded one. FIX: isServingStage, which matches "__stage-serve" exactly, and this test uses it. That is where the parkings are an invariant rather than a step that may not have happened yet — serve.go's first instants are requireFD on fdNetSock and fdNetlinkSock and setCloexec on fdNetnsN, so a serving stage missing one has already failed. isStageProcess is unchanged and still right for the ten other call sites, which want P1 in either phase. Checked all of them: every one that goes on to read the stage's descriptor table first waits for bwrap or the payload to appear, so the stage is past serve by then — TestTheStageClosesTheSandboxsDescriptorsAtThe Fork additionally has a sawNetns control that would fire in the setup window. The fd 3 control stays, and its comment now says what it does NOT cover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vyskocilm
added a commit
that referenced
this pull request
Sep 20, 2026
…orced The file is gone. What a human still has to run by hand is the last section of scripts/README.md; everything else is a Go test or a numbered script, and the prose that explained it now sits on the code that enforces it. WHAT THE MOVE MEASURED. Eleven sections were handed to the packages that own their subject. EIGHT needed nothing added — the reasoning was already in explain.go, engine/gc.go's ReclaimableStoreKey, policy/graft.go's JudgeEngineToolchain, envresolve.go's seqKey, engine.go's writeEngineHome and writeContainersConf, stage/fds.go's fdPremainSlack, and engine.go's run-directory split, in several cases almost word for word. The checklist was not holding knowledge the code lacked. It was a second copy, and only the copy nobody ran drifted. Three additions landed where the code was genuinely silent: - CheckEngineBindSource now carries the kernel fact the anchored-source rule rests on: rename re-points one dentry in the writable parent and every mount nested underneath follows ITS OWN dentry rather than a path string, so a real mount several components below a swappable name anchors nothing above it. - writeContainersConf now says why base_hosts_file is the stronger guarantee: the compat API — the only surface the proxy exposes — synthesizes /etc/hosts rather than copying it, so the key makes that structural instead of resting on being set correctly here. - TestParseEngineGCArgsRefusesAKeyShapedLikeNeitherGeneration pins the malformed-store-key refusal, which no test held. ONE GAP OF THREE WAS REAL. §9a's bounded-tmpfs claim was already asserted by TestEveryTmpfsInTheArgvCarriesASize at the argv level, on the same default selection; TestDefaultSelectionEmitsOnlyBoundedTmpfs adds the Resolve-boundary view, not first coverage. §12's host-side netns comparison already existed as TestSandboxNetnsIsTheStagesPinnedNetns. Only §21 was a hole: nothing asserted the staged @net arm's bwrap sits in the HOST pid namespace, in either direction. TestStagedArmsBwrapIsNotNested does, disambiguating the stage's direct bwrap child from bwrap's own inner self-fork so it cannot pass wrongly. TWO MORE STALE CLAIMS, making ten across both rounds: - §6g-bis said "no test drives it" of what the claude binary does with the regenerated plugin manifest. False since #592: scripts/0033-an-unnamed-plugins-hook-does-not-fire.sh:102 asserts exactly it, with :79 as the host control. - §9f described an engine-side bind of the generated /etc/resolv.conf. That mount is gone: internal/stage/inengine.go:419 reads "NO /etc/resolv.conf BIND HERE, and the absence is deliberate (issue #126)". The sandbox-level bwrap bind is a different mount, so the two code comments never conflicted — only the checklist did. HISTORY STRIPPED from scripts/README.md: the roll call of retired check numbers and the was/is-now table, which said the same thing twice and whose own lead-in admitted it. The rule they illustrated — allocated on creation, never reused, including by a check that has left — stays, because that one is live. REFERENCES. Zero mentions of the deleted file anywhere in the tree, by an unfiltered `grep -rI`. designcitation_test.go caught fifteen dangling citations the moment the file went, which is the test earning its keep; its VERIFY.md entry is removed. docexamples_test.go harvests scripts/README.md now, where the four surviving profile examples live, so the count against floor 8 stays six plus four. Earlier greps in this session used --include filters and missed base.toml and pid-nesting.py; the unfiltered sweep is what found them. Surviving headings carry no numbers. Nothing cites them by number any more, and a numbering with gaps is one more thing to go stale. make gate green. make verify: 5 passed, 0 skipped, 0 failed (floor 1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vyskocilm
added a commit
that referenced
this pull request
Sep 20, 2026
…orced (closes #589) (#593) * verify: the migrated sections leave VERIFY.md, and the rule says so (#589) VERIFY.md was 8047 lines in 57 top-level sections. Deleted the 6758 lines a Go test or a numbered script already asserts; 1289 remain in 31. The rule permitted the duplication. "A section is migrated when it is touched" never said migrated means DELETED, so the previous change wrote 43 tests BESIDE the prose they came from and the file grew. CLAUDE.md and scripts/README.md now say deleted-in-the-same-change; CLAUDE.md names no checklist at all, and the migration rule lives in one place instead of two. Every surviving section states, in its first paragraph, which tests cover the rest of its subject and which claim of its own has no owner. A section that cannot write that sentence does not belong there. What is left needs two logged-in GitHub accounts, a live `gh`, `sudo truncate -s 0 /etc/subuid`, a real engine host, or a measurement of one machine. Grading 57 sections against the suite found 8 claims the suite CONTRADICTS. Deleted, not corrected — the test already states the truth: - §3 "the writable surface is eight paths". Nine. TestTheWritableSurfaceIsExactlyTheseNinePaths; {home}/.local is an InstallAnchors anchor. The previous change had already dropped VERIFY.md from TestHomeTmpfsListIsPinnedToTheDocumentsQuotingIt, four documents to two. - §9c "403 for both spellings of --network=host". sandbox_test.go:3567 wants `host network: 200` and `host ns: 200` — since #401's containers.conf pin both are this sandbox's own network. `private network: 403` refuses now. - §9c-quater's depth table. enginebindergonomics_test.go: "SINCE ISSUE #553, EVERY ROW BELOW ACCEPTS". - §11c-ter "five keys and no sixth". Six — initstate.go:60, Owner for #489. - §12's SIGINT transcript: payload trap fires, snug exits 7. TestGroupDeliveredSIGINTTearsDownTheWholeSandboxWithoutForwarding measured exit 130 and no handler, 5 for 5. - §22 "foreground podman run refuses at containers/create". TestPlainLibpodRunReachesTheEngine: 2xx. Refusal moved to attach in #459 phase 2 — which §23 stated correctly one section away. - §25 lists 14 /dev entries. sandbox_test.go:763 has 15, incl. console. - §6l "a lowercase name refused by the grammar". checkEnvName's charset is [A-Za-z_][A-Za-z0-9_]*; envtypes.go:2177 accepts a-z. Two dangling script references the previous change created, gone with the prose that carried them: §1 pointed at scripts/0010 and 0011, §27 at scripts/0021-a-missing-knob-is-not-an-unset-one.sh. All three were retired into Go tests; `ls scripts/0021*` is No such file. Three citations named the wrong thing, repointed rather than preserved: - net_test.go:24 "only the behavioural check in VERIFY.md §7 can catch an upstream default changing". TestHostLoopbackIsUnreachable already was it. - claudesettings_test.go:145 and CLAUDE-SETTINGS.md:1039 cited §6b for the plugin channel (#68). §6b was the PID-1 /proc/1/environ check; that subject lived in §6m. The script that covers it now is 0033. - scripts/README.md and VERIFY.md's own header both called §16 "genuinely not executable". blastradius_test.go asserts its checks 1-3 in two packages. docexamples_test.go harvests 4 profile examples out of VERIFY.md now, not 18; README's 6 keeps the total at 10 against floor 8. Its comment said "five apiece" and now says the measured six and four. Its extraction-rule comment cited §6j and §6h as live cases; both are tests now, so it describes the shape instead of pointing at prose that moved. Checked, not assumed: all 43 test names and every file path the surviving prose cites resolve; fences balanced; no header without a body. make gate green. SNUG_REQUIRE_SANDBOX=1 make integration green (67.670s). make verify: 5 passed, 0 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * verify: VERIFY.md is deleted, and its reasoning moves where it is enforced The file is gone. What a human still has to run by hand is the last section of scripts/README.md; everything else is a Go test or a numbered script, and the prose that explained it now sits on the code that enforces it. WHAT THE MOVE MEASURED. Eleven sections were handed to the packages that own their subject. EIGHT needed nothing added — the reasoning was already in explain.go, engine/gc.go's ReclaimableStoreKey, policy/graft.go's JudgeEngineToolchain, envresolve.go's seqKey, engine.go's writeEngineHome and writeContainersConf, stage/fds.go's fdPremainSlack, and engine.go's run-directory split, in several cases almost word for word. The checklist was not holding knowledge the code lacked. It was a second copy, and only the copy nobody ran drifted. Three additions landed where the code was genuinely silent: - CheckEngineBindSource now carries the kernel fact the anchored-source rule rests on: rename re-points one dentry in the writable parent and every mount nested underneath follows ITS OWN dentry rather than a path string, so a real mount several components below a swappable name anchors nothing above it. - writeContainersConf now says why base_hosts_file is the stronger guarantee: the compat API — the only surface the proxy exposes — synthesizes /etc/hosts rather than copying it, so the key makes that structural instead of resting on being set correctly here. - TestParseEngineGCArgsRefusesAKeyShapedLikeNeitherGeneration pins the malformed-store-key refusal, which no test held. ONE GAP OF THREE WAS REAL. §9a's bounded-tmpfs claim was already asserted by TestEveryTmpfsInTheArgvCarriesASize at the argv level, on the same default selection; TestDefaultSelectionEmitsOnlyBoundedTmpfs adds the Resolve-boundary view, not first coverage. §12's host-side netns comparison already existed as TestSandboxNetnsIsTheStagesPinnedNetns. Only §21 was a hole: nothing asserted the staged @net arm's bwrap sits in the HOST pid namespace, in either direction. TestStagedArmsBwrapIsNotNested does, disambiguating the stage's direct bwrap child from bwrap's own inner self-fork so it cannot pass wrongly. TWO MORE STALE CLAIMS, making ten across both rounds: - §6g-bis said "no test drives it" of what the claude binary does with the regenerated plugin manifest. False since #592: scripts/0033-an-unnamed-plugins-hook-does-not-fire.sh:102 asserts exactly it, with :79 as the host control. - §9f described an engine-side bind of the generated /etc/resolv.conf. That mount is gone: internal/stage/inengine.go:419 reads "NO /etc/resolv.conf BIND HERE, and the absence is deliberate (issue #126)". The sandbox-level bwrap bind is a different mount, so the two code comments never conflicted — only the checklist did. HISTORY STRIPPED from scripts/README.md: the roll call of retired check numbers and the was/is-now table, which said the same thing twice and whose own lead-in admitted it. The rule they illustrated — allocated on creation, never reused, including by a check that has left — stays, because that one is live. REFERENCES. Zero mentions of the deleted file anywhere in the tree, by an unfiltered `grep -rI`. designcitation_test.go caught fifteen dangling citations the moment the file went, which is the test earning its keep; its VERIFY.md entry is removed. docexamples_test.go harvests scripts/README.md now, where the four surviving profile examples live, so the count against floor 8 stays six plus four. Earlier greps in this session used --include filters and missed base.toml and pid-nesting.py; the unfiltered sweep is what found them. Surviving headings carry no numbers. Nothing cites them by number any more, and a numbering with gaps is one more thing to go stale. make gate green. make verify: 5 passed, 0 skipped, 0 failed (floor 1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vyskocilm
added a commit
that referenced
this pull request
Sep 21, 2026
… makes (redteam round on 9da8978) Red-team round found no escape and no boundary crossing. It found two things. THE RELAY REACHED THE PAYLOAD BUT NOT ITS FOREGROUND CHILD. Fixed here, not filed. trap "echo TRAPPED; exit 7" TERM; sleep 30 kill -TERM <snug>, no tty before trap never fired, run burned the whole 1.018s, then swept after trap fired at 11-12ms, 3/3 A POSIX shell defers a trap until its foreground child returns, so signalling the shell and not the `sleep` leaves the handler unrun inside the budget. A terminal does not have this problem: it signals the whole foreground group. WHY MY OWN MEASUREMENTS MISSED IT, which is the part worth keeping. Every payload in the previous commit used `sleep 0.05` in a loop — short enough that the shell returns and reaches the trap inside the budget. The 4/4 was true and proved less than it looked: the exact error this branch accuses #595's own reproduction of making. TestAHandlerBehindALongBlockingChildStillRuns is the missing case, and it FAILS against the old relay (measured, by disabling the group path). THE FIX names the sandbox's process group. Safe because bwrap's init calls setsid() and LEADS that group, so the group id is the init's own pid — already pidfd-pinned, so it cannot be recycled. MEASURED, both arms: snug pid=1076120 pgrp=1076120 bwrap pid=1076136 pgrp=1076120 bwrap pid=1076144 pgrp=1076144 <- the init leads this group sleep pid=1076149 pgrp=1076144 Both conditions are CHECKED, never assumed: the init must lead its own group, and that group must not be snug's. Where either fails there is no --new-session, which is exactly the case where the terminal already delivered to everyone. Falls back to per-child pidfd signalling there. EXIT-CODE SUBSTITUTION, the round's other finding, is KEPT and now stated. A payload that traps and exits 0 makes a signalled run report 0, so CI gating on $? alone can be shown success for a run it force-stopped. The payload already picks the code on every ordinary exit and nothing crosses the boundary; what is new is that "the operator killed me" can hide behind a zero. Named in grace's comment and pinned by TestASignalledRunCanReportThePayloadsOwnZero. TestGroupDeliveredSIGINTTearsDownTheWholeSandboxWithoutForwarding FLIPPED, and its payload is why: `trap 'echo caught-sigint; exit 7' INT; sleep 30` is the blocking-child shape, so the group relay makes the trap fire and snug exit 7. That is what the stage commit's own by-hand transcript expected before #105's guard flipped it as a side effect and #592 froze the stale version into a test. Renamed TestAGroupDeliveredSIGINTLetsThePayloadAnswerItFirst; the header keeps the old name, the old assertions and the false #13 justification verbatim, so the next reader sees how it got pinned. "Nothing survives" is deliberately NOT asserted there — orphan_test.go owns it across every signal and offset. make gate, make verify (6 passed, 0 skipped, 0 failed) and make integration (sandbox 555.2s, signals 68.3s) all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vyskocilm
added a commit
that referenced
this pull request
Sep 21, 2026
… the only author of the run's death (closes #595) (#596) * signals: a signal to the tree is the payload's to answer, and snug is the only author of the run's death (closes #595) #595 asked whether a payload's own handler should get to finish, and framed it as tension with #13. Both halves of the framing are wrong, measured. #595's OWN REPRODUCTION DOES NOT REPRODUCE. `trap 'echo yes > f' INT` is 10/10 on main with #101's nesting confirmed on. It measures whether the handler STARTED. Split apart (write h-start, do N work, write h-done): handler enters 8/8 and finishes 0/8 past ~1.5ms of shell work, on BOTH arms — staged @net, which has no intermediate namespace, measures the same. So #101 is not the cause. #13 IS A STARTUP LEAK. SIGKILL of snug 1.0s into a running payload leaves nothing: survived 0/4 offline, 0/4 staged, heartbeat frozen at 0.4s and 1.4s after snug died, no Go code involved. teardown.go's own table: 0 leaks at 86-94ms, 8/8 at 110-160ms, ~206ms payload start. A grace gated on a named init cannot touch it. THE REAL QUESTION is who answers a signal delivered to the tree. It was decided by the scheduler and differed by topology, by host and by launcher. FOUR PARTS. internal/sigseal (new), called from both exec verbs before bwrap. execve resets CAUGHT handlers but preserves IGNORED ones and the thread's mask, and both leaked: `bash -c 'snug -p @net … &'` gave its payload SigIgn 0x2 — SIGINT ignored, so `trap … INT` was a silent no-op — while the offline arm did not, and BOTH arms leaked a blocked SigBlk 0x200 so `trap … USR1` never fired anywhere. The asymmetry was an accident of ordering: Go installs no handler for a signal already SIG_IGN at startup and os/exec only resets what it handles, so the offline arm escaped because armTeardown precedes cmd.Start() while the stage is forked before the guard. Reset is arch-independent (Notify over the SIG_IGN, execve clears it) — no rt_sigaction, no cgo. STAGE STOPS BEING AN AUTHOR OF THE RUN'S DEATH. MainServe catches and drops INT/TERM/HUP/QUIT (Notify, never Ignore — SIG_IGN would inherit into every child), and runOneSandbox forks bwrap into CLONE_NEWPID|CLONE_NEWNS so bwrap is pid 1 there and ignores what it has no handler for. No CLONE_NEWUSER: the stage already holds CAP_SYS_ADMIN in U, so it costs no nesting level. Measured before: SIGINT to the stage alone ended the run (snug exit 69), SIGINT to the outer bwrap alone ended it (255), while the offline arm's nested bwrap ignored the same signal. So the staged tree had three authors where the offline tree has one. internal/nestproc is the shared procfs step, one author for both verbs. info.InitPID is translated once — bwrap now reports pid 2 of the new namespace. THE GRACE. On INT/TERM/HUP, once the init is named, the payload gets 1s to handle it and exit; its OWN exit code is then reported instead of 128+signal; a second signal cuts the wait. Relay only where the terminal could not have delivered (ttydelivery.go: --new-session, no ctty, or snug not the foreground group) — on a shared session the tty already delivered and relaying would make one keypress read as the two that mean force-quit to compose, npm, pytest, vite. Budget is snug's number: no flag, no profile key. pasta gets Setpgid so a group ^C cannot kill the network out from under a payload still inside its window. MEASURED, real pty, handler doing `sleep 0.5`, 4 trials/cell. Every cell was 0/4 before: ^C, offline 4/4 finished ^C, staged @net 4/4 kill -INT snug, no tty, both 4/4 (reached the payload on NO host before) kill -INT snug, snug fg tty job 0/4 deliberate residual, stated Negatives: payload ignoring the signal exits 130 at 1.01-1.06s with 0 leftovers; second ^C cuts at 0.27s; payload sees exactly one SIGINT per keypress; $? is the payload's own 7; SIGKILL at steady state still leaves nothing. TWO HAZARDS FIXED IN REVIEW. The relay targets the init's CHILDREN with a CATCHABLE signal, so a reaped-and-recycled init pid would have snug SIGTERMing a stranger's children — the init is pidfd-pinned for the run, which stops the kernel reusing the number. And the payload is COUNTED even where it is not relayed to, or a terminal-delivered signal spent the whole budget on a container run whose payload was still PARKED on --block-fd. #594's CONTAINER STOP NOW RUNS ON THE SIGNALLED PATH, because a payload exiting inside its grace exits NORMALLY and st.Wait returns an ordinary status. Holding ^C on a container run can cost both budgets, ~2s. Two tests encoded the old guarantee and are renamed and re-conditioned to assert the surviving negative (payload ignores -> no stop). A TEST PINNED THE OLD BEHAVIOUR WITH A FALSE REASON. TestGroupDeliveredSIGINTTearsDownTheWholeSandboxWithoutForwarding said "this is the correct, safety-motivated behaviour to pin — a future change that made snug wait for the payload's own handler would reopen that window", citing #13. Refuted above. History: the stage commit's own by-hand transcript (2026-08-13) expected `trap … exit 7` to fire and snug to exit 7; #105's guard (2026-08-17) flipped it as a side effect; #592 (2026-09-20) turned the stale transcript into a test with the wrong reason. Nobody decided the current behaviour. The transcript's expectation is restored and now asserted. PROSE CORRECTED WHERE IT WAS MEASURABLY FALSE: exec.go's unconditional "Ctrl-C reaches the payload" (it depends on --new-session, which policy.NewSession() emits on two independent conditions); INDEX.md's "~40 ms before bwrap arms --die-with-parent" (teardown.go already says in capitals that number is wrong) and its "Ctrl-C reaches every stage" (reaching the stage is what killed it); dryrun.go's claim that confirmTeardown SIGKILLs the stage on a catchable signal (on a pty the stage died of the tty's own SIGINT first); stage.Wait's "check dmesg for an OOM kill" (the stage no longer exits on its own). TESTS: internal/sigseal (with its unsealed arm as the positive control), test/integration/payloadgrace_test.go (5 tests; 4 of them fail against a build with grace() neutered — the two upper bounds say in their own comments that they cannot stand alone), the pidfd-pin regression, and scripts/0040 for the real-pty ^C path CI structurally cannot run. Performance attributed rather than assumed: A/B on the nesting alone, same two engine tests, -count=1, 17.941s disabled vs 17.748s enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * sigseal: lock the test child's thread before it blocks a signal CI run 35636253285 failed the UNSEALED control: `plain: SigBlk SIGUSR1 bit = 0x0, want 0x200`. Passed locally every time. A signal mask is per-THREAD and execve preserves the mask of the thread that CALLS it. The test child set the mask and then exec'd without locking, so the Go scheduler was free to move the goroutine in between and the child reported a thread that had never blocked anything. One scheduler loses that race, another wins it. The bug is in the CONTROL, not in the thing under test: Seal() has locked first since it was written, and that is why the sealed arm passed on CI while the unsealed one did not. It is still the worse half to get wrong — a control that silently reports "nothing was blocked" is a control that cannot fail, which is the exact shape this test exists to keep out of the product. DIAGNOSED BOTH WAYS rather than assumed, because the failure did not reproduce locally on its own. Removing the lock and adding goroutine churn between the mask and the exec reproduces CI's line exactly (`SigBlk SIGUSR1 bit = 0x0, want 0x200`, GOMAXPROCS=8). Putting the lock back with the SAME churn passes 10/10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * signals: relay to the sandbox's process group, the gesture a terminal makes (redteam round on 9da8978) Red-team round found no escape and no boundary crossing. It found two things. THE RELAY REACHED THE PAYLOAD BUT NOT ITS FOREGROUND CHILD. Fixed here, not filed. trap "echo TRAPPED; exit 7" TERM; sleep 30 kill -TERM <snug>, no tty before trap never fired, run burned the whole 1.018s, then swept after trap fired at 11-12ms, 3/3 A POSIX shell defers a trap until its foreground child returns, so signalling the shell and not the `sleep` leaves the handler unrun inside the budget. A terminal does not have this problem: it signals the whole foreground group. WHY MY OWN MEASUREMENTS MISSED IT, which is the part worth keeping. Every payload in the previous commit used `sleep 0.05` in a loop — short enough that the shell returns and reaches the trap inside the budget. The 4/4 was true and proved less than it looked: the exact error this branch accuses #595's own reproduction of making. TestAHandlerBehindALongBlockingChildStillRuns is the missing case, and it FAILS against the old relay (measured, by disabling the group path). THE FIX names the sandbox's process group. Safe because bwrap's init calls setsid() and LEADS that group, so the group id is the init's own pid — already pidfd-pinned, so it cannot be recycled. MEASURED, both arms: snug pid=1076120 pgrp=1076120 bwrap pid=1076136 pgrp=1076120 bwrap pid=1076144 pgrp=1076144 <- the init leads this group sleep pid=1076149 pgrp=1076144 Both conditions are CHECKED, never assumed: the init must lead its own group, and that group must not be snug's. Where either fails there is no --new-session, which is exactly the case where the terminal already delivered to everyone. Falls back to per-child pidfd signalling there. EXIT-CODE SUBSTITUTION, the round's other finding, is KEPT and now stated. A payload that traps and exits 0 makes a signalled run report 0, so CI gating on $? alone can be shown success for a run it force-stopped. The payload already picks the code on every ordinary exit and nothing crosses the boundary; what is new is that "the operator killed me" can hide behind a zero. Named in grace's comment and pinned by TestASignalledRunCanReportThePayloadsOwnZero. TestGroupDeliveredSIGINTTearsDownTheWholeSandboxWithoutForwarding FLIPPED, and its payload is why: `trap 'echo caught-sigint; exit 7' INT; sleep 30` is the blocking-child shape, so the group relay makes the trap fire and snug exit 7. That is what the stage commit's own by-hand transcript expected before #105's guard flipped it as a side effect and #592 froze the stale version into a test. Renamed TestAGroupDeliveredSIGINTLetsThePayloadAnswerItFirst; the header keeps the old name, the old assertions and the false #13 justification verbatim, so the next reader sees how it got pinned. "Nothing survives" is deliberately NOT asserted there — orphan_test.go owns it across every signal and offset. make gate, make verify (6 passed, 0 skipped, 0 failed) and make integration (sandbox 555.2s, signals 68.3s) all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * sigseal: the test's reporter was a shell, and dash clears the inherited mask TestSealClearsWhatExecveWouldOtherwiseCarry/plain failed on CI and passed locally: `SigBlk SIGUSR1 bit = 0x0, want 0x200`. The unsealed CONTROL, so the arm whose whole job is to prove the test can fail. ubuntu-latest's /bin/sh is dash, and dash CLEARS THE INHERITED SIGNAL MASK AT STARTUP. The child exec'd `/bin/sh -c 'grep -E "^Sig(Blk|Ign):" /proc/self/status'`, so the program asked to report the mask rewrote it first. Measured in one ubuntu:24.04 container, same binary, same blocked SIGUSR1: dash -c 'grep …' SigBlk 0000000000000000 bash -c 'grep …' SigBlk 0000000000000200 grep, no shell SigBlk 0000000000000200 cat SigBlk 0000000000000200 SigIgn 0x2 survived all four — a disposition is not a mask, which is why the failure looked selective. The reporter is now `cat /proc/self/status`, parsed in Go. Control in both directions, same container: the 52008b5 binary fails there with CI's line byte for byte (sigseal_test.go:120, same hex), this one passes. 1e5866e's diagnosis of the same failure was WRONG. It read it as a per-thread race and added runtime.LockOSThread() to the child; the failure returned unchanged at 52008b5 with the lock in place. The lock stays — Seal() has always locked for that reason and the test should match it — but its comment claimed the race was the cause, and that claim is deleted rather than left in code, where prose says what is. The product is unaffected in either reading: a shell clearing MORE than Seal() does cannot make a payload inherit anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses #589. Decisions 1-3 answered in the change.
Why
VERIFY.mdearned its exemption from the no-docs/rule by being executable — "every line is a command with its expected output". Nothing executed it. The only thing that ever checked whether a line was still true was a human reading it. A command plus the output it produced once is a copy of state: stale the moment the code moves, and the staleness found by whoever finally walks it.The exemption moves to
scripts/, which is executable in the senseVERIFY.mdclaimed — and totest/integration, which is where most of it turned out to belong.The runner
make verifywalksscripts/NNNN-slug.*in numeric order. Each check prints what it asserted and exits non-zero when the assertion fails. Expected output lives in the assertions, not in prose beside them.Exit 79 is SKIP, not the conventional 77, because snug's own
exitPolicyIS 77 (internal/cli/main.go). A script ending on an uncapturedsnugrefusal would otherwise report SKIP — a check that cannot fail. 79 is outside sysexits' 64–78 range entirely, so it cannot be one of snug's. A guard test refusesexit 77in a check, rather than a comment saying not to.NNNNis an allocation sequence, not a VERIFY.md section number (decision 3's numbering). Sections carry suffixes (9a,9c-bis,23b) no numeric prefix can express, and one section is frequently several independent checks. Allocated on creation, never reused — including after a check leaves this directory.0010,0011,0021,0030and0031are retired numbers: they were checks here, they are Go tests now, and a reference to0011in an old commit message has to keep meaning one thing.Where a new check goes — decision 2
Never a new
VERIFY.mdsection. A section is migrated when it is touched, never edited in place — decision 3.What the sections became
43 new named Go tests, four new checks in
scripts/, five checks deleted.Each of five agents walked sections by RUNNING them, not reading them. Four bugs found that way:
snug profile showprintedmtuas a bare capability row with no consequence sentence beside it, alone among the rowsshowCapabilitiesemits.capRowsnow, saying what an MTU changes and what it does not: segmentation, never reach.@tmp-sharedresolved to "unknown profile" with no suggestion, though it and the{host_tmpdir}variable behind it were removed on purpose. InretiredProfilesnow, naming the replacement —rw = ["/path/on/host:/tmp"]. A retired name that says only "unknown" sends the reader looking for a typo.strings.Count(out, "CREATE: 201")matched inside"VOLCREATE: 201".dockerproxy's ownership gate fails closed.The five checks that stay, each needing state CI structurally lacks (
0020landed earlier in this branch; the other four are new)0012-doctor-names-each-wrong-host.shapparmor_restrict_unprivileged_userns=1, a container masking/proc, no/dev/net/tun, runc-without-crun under disabled cgroups, no/etc/subuidline (two arms needsudo)0020-a-weak-host-warns-and-still-runs.sh0032-claude-opens-with-no-dialog-and-no-hook.sh.claude/settings.jsonhooks and.mcp.jsonservers are reinterpreted away before Claude Code reads either — same fixture firing on the bare host as the control0033-an-unnamed-plugins-hook-does-not-fire.shhooks.jsonis not even read inside when@claude's allowlist does not name it, though its tree stays bound read-only (#68)0034-projected-credential-still-authenticates.sh~/.claude/.credentials.jsoncarries exactly five fields, never a refresh token, and a live turn on it still authenticates (#58)Five checks deleted, graded against the rule above
0010SKIP: never. Every assertion here holds on any host that built the binary— the definition of a check CI can runTestDoctorsExitCodeFollowsTheRowsItPrinted,TestFixSubuidIsSafeToCallFromAnErrexitInitHook0011TestDoctorSaysNoOnAHostWhereUserNamespacesDoNotWork0021internal/cli/hostsysctl_test.goalready asserted every one of its propertiesTestAnAbsentKnobIsReportedAsAbsentAndIsNeverFixedandTestTheThreeWaysAKnobHasNoValueAreToldApartwere already there, so the script was a second copy0030mktemp -dTestEngineGCSeesEveryGenerationOfStoreName0031/usrcarry an editor — it already reported rather than assertedTestClaudeInheritsAPagerAndNeitherEditorVariable0011's fabrication survives the move. The Go test still builds the weak host withunshare --user --map-root-userandmax_user_namespaces=0, still runs the nested-unsharepositive control, and still grades the whole binary on the ❌ row AND the exit code.doctoruserns_test.go's own comment says that condition is "not something the unit suite can arrange everywhere" — true, and the integration suite is where it can.0030keeps both older store-name generations by renamingbuildEngineStoreFixture's output onto them: bare 64-hex WITH astore.json(ReadBreadcrumb's"sha256_"+key == wantarm, so attributed) and bare 16-hex with none. Mutation-checked — spell the 16-hex name0123456789abcdefggand the test fails with1 store(s), so the count is not vacuous.CI no longer runs
make verifyThat follows from the rule rather than weakening it. What is left in
scripts/is by construction what CI cannot run, so a step walking it could only ever SKIP, andSNUG_VERIFY_FLOORwould have to be 0 to let it pass — a step that cannot fail, which is the shape every floor in that file exists to refuse.Measured before removal (run 35505849557):
3 passed, 1 skipped. All three that passed are the migrated ones; the one that skipped,0020, skipped on its own control.test/guard/scripts_test.gokeeps grading the DIRECTORY on every push — duplicate numbers, a file that is not executable, a check missing from the index, SKIP spelled 77, a floor above the number of checks that exist. None of those is visible to a greenmake verify.SNUG_VERIFY_FLOOR4 → 1, and it is now a different kind of number fromSNUG_ENGINE_FLOOR: a promise about the maintainer's own host and nothing else.The writable surface is nine paths, not eight
Every document that counted it by hand was wrong.
TestTheWritableSurfaceIsExactlyTheseNinePathsenumerates therwrows of/proc/self/mountsinside an ordinary default sandbox:{home}/.localis nobody's grant.@homenames only its two children two levels down;InstallAnchors(internal/policy/anchor.go, #553) puts an empty writable tmpfs at every ancestor whose deepest cover is itself a tmpfs, so a list derived frombase.tomlwill always be one short. The target is rooted directly under/tmpso the fixture's own depth adds no anchor row of its own — measured both ways before the assertion was written..claude/agents/sandbox-policy.mdwas wrong twice in one bullet: eight, and/devlisted as writable./dev's root is remounted read-only bybwrap.go'sKindDevarm (#281), so/dev/shmis the one writable path on that superblock.internal/profile/file_test.gonamedCLAUDE.mdas one of four documents quoting@home's tmpfs list;grep -in "writable surface" CLAUDE.mdis empty. Two documents quote it, and the doc comment and the failure message now say which.Decision 1 — VERIFY.md moves into
scripts/git mv VERIFY.md scripts/VERIFY.md. A checklist in one directory with its executable form in another is two places a reader has to know about. One directory now holds the whole thing: the numbered checks,payloads/,README.mdas the index, andVERIFY.mdas what is left of the by-hand half. The three relative links inside resolve fromscripts/, and a new line says the commands are still run from the REPOSITORY ROOT — the file moved, the working directory every./bin/snugin it assumes did not.Every reference repointed rather than left to rot, including
internal/cli/designcitation_test.go'snotADesignDocmap — the test that would have caught one left behind. Checked, not assumed: every relative markdown link in the tree resolves.Round 1 was red, and the first diagnosis was wrong
0020FAILed and0021exited 69 on the ubuntu runner. Cause: the fabrication binds over/proc/sys/kernel/<knob>, which gives/proca submount, and Linux'smount_too_revealing()then refuses a new procfs instance inside a userns — which is bwrap's own--proc /proc:The first fix guessed the nested namespace was the cause. CI refuted it: with a control asking doctor the same question with nothing bound, the control came back CLEAN and
0020still failed. The fabrication is the cause, not the namespace.So
0020has a second control, placed AFTER the knob rows are graded: a ❌ naming no knob is the fabrication's own cost on this kernel and the check SKIPs printing it; a ❌ that DOES name a knob is the property failing and stays a FAIL. That last half is what keeps it a control rather than a way of never failing. Both directions run against stubs.Verification
make gategreen.SNUG_REQUIRE_SANDBOX=1 make integrationgreen.make verifyon this host:5 passed, 0 skipped, 0 failed (floor 1).🤖 Generated with Claude Code