π΄ DO-NOT-MERGE-YET: airvpn killswitch β close the fail-open, stop root-sourcing the site config, fix the apply/doc order - #355
Open
ZacxDev wants to merge 4 commits into
Open
Conversation
β¦onfig, and fix the apply/doc order π΄ DO NOT MERGE until the `bar` skill's mandatory re-test protocol has been run on a physically reachable host. See the PR body. This is the killswitch half of the public-IP scrub, split out of #350 because `scripts/airvpn-updown` is a LIVE fail-closed killswitch on the workbench's uplink and a change to it is a runtime change, not a docs change. The lighthouse IP moves out of tracked source (this repo is PUBLIC) into a root-owned 0600 /etc/airvpn-updown.env, and the four audit findings against the first version of that move are fixed: A-2 FAIL-OPEN (π΄). A malformed value -- a partially-written env file is enough -- was interpolated into the nft ruleset, making it a SYNTAX ERROR. `up` does `nft add table` + `nft flush table` BEFORE loading, so re-arming with a bad value DELETED a working killswitch, installed nothing, and exited 0. The blanket fallback carried the same value, so it failed identically. Now: * the value is validated (`valid_ip`) and an unparseable one is UNSET, loudly; * `arm_failclosed` carries NO operator-supplied text at all -- the fallback for "the primary did not parse" must not contain what broke the parse. A-4 ROOT-SOURCED CONFIG (π΄). The file was `.`-sourced as root with no owner/mode check, so a stray `LAN_SUBNET=`/`IFACE=`/`NEBULA_USER=` line rewrote the killswitch's own constants and any other shell in it simply ran. Now the file is PARSED, not sourced: owner must be root or self, no group/other write, and only `NEBULA_LIGHTHOUSE` is read. A-3 APPLY GAP (π‘). `apply-airvpn-host.sh` installed the new helper but never created, chmod'd or checked the env file, so a fresh-host apply shipped a silently degraded killswitch. It now provisions it -- BEFORE installing the helper that reads it -- and refuses rather than proceeding, mirroring how the wg conf is handled at step 1. `claude/skills/bar/airvpn.md` had the same ordering defect (the requirement was a trailing sentence AFTER the install command) and is renumbered into dependency order. Also fixed while testing this: `log()` was defined BELOW its first callers, so every rejection reason above was `log: command not found`. The guards failed safe and said nothing -- which is most of their value gone. New: `airvpn-updown check-env`, a read-only action that prints the resolved value without touching a rule or route, and `scripts/tests/test_airvpn_updown_env.py` (42 tests) built on it. 10/10 mutants killed, each by the test that owns it. Deletes the `scripts/airvpn-updown` PENDING_SCRUB entry in the same commit, which is what the ratchet in #350 requires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
F1. `test_airvpn_updown_env.py` used the real lighthouse IP's first three octets as the "partially-written file" fixture -- byte-identical to the prefix of the literal this PR deletes. Merging is what publishes it, and it narrows the address from "somewhere in Hetzner" to a /24. Neither gate can catch this: a 3-octet prefix is not an address, so `public_ip_scan.is_reportable()` is False by construction. TEST-NET-3 now, with a comment saying every literal in this file must be TEST-NET. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦ake the protocol safe to follow
Round 2 of the adversarial audit. The core was confirmed sound (A-4 proven closed
by side effect, A-3 ordering real, the log() fix real); these are the findings
that were not.
F3 π΄ THE A-2 GUARD WAS SILENT FOR THE SHAPE A-2 IS NAMED FOR.
`done < "$path"` with a plain `while IFS= read -r line` DROPS a final line that
has no trailing newline -- and a file whose write was cut short is exactly a
file with no final newline. `NEBULA_LIGHTHOUSE=<ip>` with no `\n` resolved to
UNSET with NO log line, falling into the "key simply absent, no noise" branch,
while the PR body claimed "a partially-written env file was enough". Mutant X4
survived all 44 tests. Fixed with `|| [[ -n "$line" ]]`, plus coverage that
kills X4. CRLF shares the root cause and was MEASURED to resolve correctly
(the rtrim already eats `\r`); both tails are now pinned.
F4 π΄ THE LOAD IS NOW ONE ATOMIC TRANSACTION.
`nft add table` + `nft flush table` + `nft -f -` is three transactions, the
first two of which destroy the working killswitch before the third is known to
parse. Validating the env value shut one door into that state, not the only
one: the fallback still interpolates `$NEBULA_USER`, and
`id -un nebula-mesh 2>/dev/null || echo nebula-mesh` emits the name EVEN WHEN
THE ACCOUNT DOES NOT EXIST, so nft must resolve it via getpwnam -- on a fresh
host, or after a rename, BOTH rulesets fail and the uplink is left flushed and
empty at exit 0. Now one `nft -f` carrying `table {}` + `delete table` + the
definition, so a failed parse changes nothing. Mutant Y6 survived all 44 tests.
G6 the arm line no longer lies. It was unconditional and printed `armed` even
when the primary load AND the fallback had failed -- the line carrying this
PR's new `lighthouse=` signal was the one that could not be trusted. It now
ASKS THE KERNEL (`nft list table`) and reports ARMED(primary) /
ARMED(fallback) / NOT-ARMED. Mutant Y5 survived.
G1 `valid_ip` accepted values the ruleset cannot express: `::`, `:::::`,
`0::0::0`, real IPv6, and leading-zero octets. The consumers are `ip daddr`
(v4 family) and `ip route replace <v> via <v4 gw>`, so every one of those
passed validation and then failed the nft LOAD -> flush -> fallback, which has
no `meta mark` accept, so wg's own packets are dropped and THE TUNNEL DIES.
The previous `test_a_valid_ipv6_resolves` explicitly blessed that; it is
reversed. Leading zeros are octal to getent and decimal to nft, so they are
rejected rather than normalised, via `10#` rather than by a bash arithmetic
crash.
G4 a FIFO at $SITE_ENV blocked FOREVER -- at top level, i.e. inside wg-quick's
PostUp as root, with the interface UP and the killswitch NOT yet armed. An
indefinite fail-OPEN window and a NEW failure mode. Gated on `-f`.
G3 `check-env` could not distinguish absent from unreadable, so running the one
diagnostic the protocol tells you to trust WITHOUT sudo on a correct
0600 root:root host reported plain UNSET. It now reports `state=`.
G9 two guards were SPELLED, the trap their own docstrings claimed to avoid:
- the read-only check forbade a token list; mutants X2 (`echo x >/tmp/canary`)
and X3 (`touch`) both walked past it. Replaced by three complementary
properties -- no writes into a sandbox (behavioural), no command outside a
read-only set (behavioural, via a narrowed PATH), and no output redirection
to a path (syntactic, exhaustive over the redirection class). X2 survived my
FIRST replacement too; the syntactic property is what kills it.
- the fallback check grepped for the literal `NEBULA_LIGHTHOUSE`; mutant Y1
(alias it outside the function, interpolate the alias inside) restored the
hazard with the token gone. Now the RENDERED ruleset text is asserted, via a
fully-stubbed harness -- nft/ip/wg/logger are stubs, nothing real is touched.
F2 + G8 + G7 the operator protocol would have blackholed the host, and lives in
the durable artifact now, not just the PR body.
- It never said the tunnel must ALREADY BE UP. Re-arming with it down yields a
chain with no `meta mark` and no endpoint accept, ending in `drop`.
- It omitted the install step, so `check-env` would have hit the OLD deployed
script and printed usage.
- It never observed the /32 route. `ip route get <lighthouse>` is added as the
ONLY step that sees A-1; the off-LAN ssh check cannot substitute, because
with a healthy tunnel the traffic arrives either way.
- `claude/skills/bar/airvpn.md` re-test protocol + Debug/bail are rewritten,
and six tests pin them so the durable doc cannot drift back.
Also rewrites the fallback comment: it reached the right conclusion through the
exact firewall-vs-routing conflation this PR criticises elsewhere. The audit's
own finding is stronger -- `meta skuid` PRECEDES the lighthouse accept, so that
accept was DOMINATED and always a no-op. Only the /32 route ever mattered.
83 tests (was 44). 44 red at base 2c768d0, 83 green at HEAD. 24/24 mutants
killed, including all eight the audit named as survivors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦e two unvalidated addresses Round 3 of the audit. Rebased onto current main now that #350 has merged, so the stacked-parent hazard is gone and branch CI shows the truth. P1 (π΄-equivalent) -- STEP 11 DID NOT DO WHAT THE PROTOCOL SAID. `ssh zach@10.42.0.30` is the WORKBENCH'S OWN nebula address, and step 1 puts the operator on the LAN. Followed literally it is a SELF-SSH; from another LAN host it is a same-LAN hop. Neither exercises the nebula direct-punch path that the section's own opening line says a LAN-only test cannot reach -- the path that locked the host out in #118. The step carrying the entire "off-LAN" claim never said which host to run it FROM, so it passed unconditionally. It now names the source host, carries a still-on-the-LAN guard, defines PASS / FAIL / INVALID, and says the protocol CANNOT BE COMPLETED without an off-LAN host rather than letting the self-ssh be substituted. π‘1 -- ARMED(none), a FOURTH state my own F4 made reachable. Before the atomic load, "both loads failed" implied the table was already flushed, so NOT-ARMED was the only outcome. After F4 the PREVIOUS table survives, so `ks_present` is true with no ruleset of ours installed -- reported as `ARMED(none)` while the line above it said "uplink is NOT filtered". The surviving table was built for a different endpoint/fwmark, so it can read as armed with a dead tunnel. Now `STALE(previous)`, with both contradictory lines fixed, four states pinned pairwise distinct, and the skill's "three states" list corrected to four. π‘3 (M12) -- the FALLBACK path's atomicity was untested. The atomicity test drove the harness with no failure injected, so it only ever saw the primary path; open-coding the old add+flush+load in `arm_failclosed` survived all 83 tests. Both callers are pinned now, every rendered load must be self-contained, and a reachability control proves the fallback really produced a second load. π‘4 -- `$EP` and `$GW` reached the ruleset with NO validation, while `valid_ip` -- hardened for exactly this -- was applied only to the lighthouse. A gateway-less default route yields the literal `eth0` from the awk; a peer with no endpoint makes wg print `(none)`; an IPv6 endpoint survives the sed. Each failed the PRIMARY load, dropping into the blanket fallback, which has no `meta mark` accept -- the state that kills the tunnel. nft also tries to DNS-RESOLVE these tokens, as root, inside wg-quick's PostUp. Both producers validate now. π‘2 / π‘5 -- two comments that were false. "A failure here changes NOTHING" is true of the helper and false of the composed path (four lines later the fallback replaces what survived). And the `\r` comment still carried the theory I retracted last round -- measured false, rtrim strips it -- corrected in the test but not in the script. π’ M5 -- the `10#` radix pin was UNREACHABLE: the leading-zero check fired first, so deleting `10#` changed nothing and the comment credited it anyway. Rather than ship an untestable guard with corrected prose, the two checks are REORDERED: radix-pinned range check first, leading-zero second. Identical verdict for every input, and `10#` is now load-bearing -- a mutant deleting it dies on "value too great for base". π’ P3 / P4 / P5 -- the precondition tested EXISTENCE not up-ness (an interface left by a half-failed `wg-quick up` printed TUNNEL-UP); the install step never said to confirm the branch, which is how the deployed helper was stale in the first place; and `ARMED(fallback)` was called merely "degraded" with no instruction to bail. Folds in the auditor's REAL X11 and X15, which my reconstruction had missed: the apply-script fixture now asserts exact bytes including the trailing newline (`.strip()` erased the byte F3 is about), and the prereq-ordering test anchors on the π΄ block's own text rather than a substring Procedure step 1 also contains. Also hardens the FIFO fixture that leaked 30 blocked processes into the operator's session, the oldest for 77 minutes: the cleanup opens the write end non-blocking and unlinks, on the green path too. 94 tests (was 85). 12 red at base 4fa6f4f, 94 green at HEAD. 32/32 mutants killed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
force-pushed
the
airvpn-killswitch-env
branch
from
August 6, 2026 19:29
51c799c to
ed17f4f
Compare
ZacxDev
added a commit
that referenced
this pull request
Aug 13, 2026
β¦m a merged branch (#447) * docs(rules): two multi-agent lessons that never landed, recovered from a merged branch `zach/rules-multiagent-lessons` merged as #313, so it read as fully absorbed. It scores 1% when measured by added-lines-present-in-main -- which means nothing for RULES.md, a file that is continuously reworded. Measured by ARCHIVE ANCHOR instead, 20 of its 22 lessons are in main and two are in neither RULES.md nor RULES-ARCHIVE.md: - cross-repo-worktree: `isolation: "worktree"` builds the worktree from the dispatching session's CURRENT repo, not the repo the task names. Cross-repo dispatch silently gives every agent a worktree of the wrong repo, with no error. - sibling-agent-kill: under a parallel fan-out, confirming `/proc/<pid>/cmdline` is not sufficient before a kill -- a sibling agent running the same suite matches it legitimately. Filter by `/proc/<pid>/cwd` to your own worktree too. Found while diffing the 23 preserved worktrees for keep-or-drop (verdict table in claudedocs/handoff-subsystem-store.md, landed in #446). Every other worktree was absorbed or is deliberately open (#355); this was the single real loss, and the "superseded intermediate" guess would have dropped it. RULES.md 31,726 -> 32,727 bytes, against a 35,200 ceiling with 900 required headroom -- fits with 1,573 to spare, so no eviction was needed. Numbers read from scripts/tests/test_rules_size.py, which owns them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): restore the measurements the first round paraphrased away Audit of #447 found the two archive sections were REWRITES, not recoveries. They dropped exactly what an archive exists to carry: `Measured 2026-08-02`, the repo names (`civit/civitai` dispatched from `civit/cli`), the kill pattern (`chrome-headless-shell|vitest|steam-run`, system-wide, ~15 PIDs), and n=2. Both sections are now ea82146's text verbatim, plus a remedy paragraph and the cross-links. Two invented sentences that read like evidence are gone -- "the loop can run several times before anyone asks" is contradicted by the n=2 measurement (one agent refused, one self-recovered; there was no loop). Also restores the SILENT failure mode the paraphrase lost. cross-repo-worktree has two, and the first round taught only the loud one: an agent may instead self-recover by quietly building its own worktree in the right repo, leaving the dispatcher's model of where the work happened wrong. A rule recovered and then narrowed is worse than the rule it replaced. Corrections to the claims themselves, both measured on the workbench today and neither present in the 2026-08-02 write-up: - `/proc/<pid>/cwd` is not sufficient as stated. This harness nests agent worktrees at `<dispatching-repo>/.claude/worktrees/agent-<id>`, so a prefix check against the repo root matches every sibling and discriminates nothing -- compare the EXACT path. And agents that share the base clone (the read-only ones, which the adjacent rule tells you not to give a worktree) have identical cwds, so the discriminator does not exist for them at all -- which is what the perpetrator of the original incident was. - The inference "confirming `cmdline` is not sufficient" was never measured: the auditor pattern-matched system-wide rather than running resolve-then-confirm. The widening is sound but it is reasoning, and is now labelled as such. - cross-repo-worktree is an 11-day-old harness measurement re-landed without re-verification. Labelled, with the structural corroboration that does hold today: the Agent tool schema exposes no target-repo parameter. Reconciles the new bullet with the "Mandatory" isolation rule directly above it -- having the agent make its own worktree still satisfies that mandate, so it is not an exception. Adds the missing `worktree-not-session` TOC entry (pre-existing gap) and moves `sibling-agent-kill` to the git/worktree TOC cluster where its section actually lives. RULES.md 32,727 -> 33,289 against the 34,300 budget; 1,011 spare. All 5 tests in scripts/tests/test_rules_size.py pass, including test_every_archive_pointer_resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): the cwd caveat I added was wrong about its own incident Delta re-audit of ff9589b caught an analytical error the previous fix round introduced -- not a restoration defect; the recovered text is faithful. Caveat (b) said the discriminator "does not exist" for base-clone-sharing agents "-- and the perpetrator above was an auditor", which reads as: cwd filtering would not have saved the recorded incident. That is false, and caveat (a) two sentences earlier already implied so. The retained ea82146 text says the victim died "in another worktree of the same repo". Perpetrator in the base clone, victim in a worktree -> the two cwds DIFFER -> exact-path comparison spares the victim. The prefix check is what kills it, which is exactly what (a) diagnoses. So (b)'s real scope is narrower than I stated: cwd fails only between two agents that BOTH sit in the base clone. Corrected, along with the premise it leaned on -- RULES.md:94 says read-only agents "don't need one", which is permissive, and I had paraphrased it as a prohibition. The consequence was worse than the overstatement. RULES.md:104 read "there, don't kill by pattern at all", aimed at read-only agents in the base clone -- i.e. at precisely the actor in the incident, whose stated need was to clear ITS OWN hung run. A prohibition with no substitute, for the one case the archive entry exists to describe. Replaced with a filter that is deterministic and strictly stronger than cwd in every case including that one: kill only your own descendants, walking PPid in /proc/<pid>/status back to your own pid, or track the pid you launched. Verified PPid is populated on this host. The same over-reach was published in the PR comment; a follow-up comment corrects it. RULES.md 33,289 -> 33,356 against the 34,300 budget; 944 spare. All 5 tests in scripts/tests/test_rules_size.py pass. Those tests were negative-controlled during the re-audit -- forcing the ceiling under the current size, the headroom above it, a missing file and a dangling anchor each turned the matching test red with its own message -- so the 5/5 is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): the descendant filter I offered as a fix returns EMPTY for the case it was for Round-3 delta audit found a π΄ in fe315e4: I replaced a fail-safe prohibition with a procedure that does not reach its targets. Verified both decisive claims myself rather than taking the audit's word. - MEASURED: nix-daemon workers are children of nix-daemon (PID 18717, PPid 1). A `nix build` an agent launches does its real work in a process that is NOT the agent's descendant, so a PPid walk returns the empty set for exactly the hung-run case sibling-agent-kill is about. Same for anything that daemonizes or re-parents -- including Chrome's zygote, and the original incident's pattern was `chrome-headless-shell`. A walk is also per-tool-call: each Bash call is a fresh `zsh -c`, so an earlier call's strays are not descendants of the current one. - MEASURED: RULES.md:14 mandates killing an orphan "by resolved PID". An orphan is PPid 1 by construction, so it is never your descendant -- line 104's "kill only your own descendants" contradicted it outright. So "strictly stronger in every case" was wrong twice over. cwd and descendant are INCOMPARABLE filters, not ordered. cwd is now primary (it demonstrably separates the recorded pair); the PPid walk only NARROWS a cwd-filtered set and can never build one; orphan hunts are explicitly scoped out to the deploy rule. Restores a fail-safe, which is what the previous round actually removed: if no filter leaves a confident set, kill nothing and hand it to the operator. An empty descendant set cannot distinguish "no strays" from "the walk cannot see them" -- the archive's own empty-result trap, wearing a procedure. Also strikes "the perpetrator sat in the base clone" (RULES-ARCHIVE.md:659). The original never places the perpetrator; that was my inference stated as fact, three sentences above the same paragraph's "it is reasoning, not an observation". The victim-side fact -- the victim was in another worktree -- carries the argument alone. My control last round ("verified PPid is populated") validated that the FIELD exists, not that the procedure reaches the targets. PPid is always populated on Linux. That is the instrument-validation rule's negative control missing: the check needed was whether a hung run from an earlier tool call is reachable by the walk. It is not. ea82146's recovered bodies remain byte-identical (cmp-verified). RULES.md 33,356 -> 33,596 against the 34,300 budget; 704 spare. All 5 tests in scripts/tests/test_rules_size.py pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): evict my own error trail from the core to the archive Review with Zach: the sibling-agent-kill bullet had accreted a REFUTATION of an idea no reader has had yet -- descendant/PPid filtering -- which exists in the core only because I proposed that fix in fe315e4 and had to walk it back in 0679fe0. A reader arriving fresh needs the imperative, not my error trail. The core now carries the three actionable facts: filter by the EXACT worktree path (a prefix matches every sibling, because agent worktrees nest under the base repo); a box-wide pattern reaches a sibling agent's processes, with the incident; and if no filter leaves a confident set, kill nothing and hand it over. Everything evicted is preserved in the archive entry, where it costs nothing until recalled -- that the two filters are incomparable rather than ordered, the measured nix-daemon/PPid-1 re-parenting that makes a descendant walk return empty, the per-tool-call shell, and the scoping of orphan hunts to the deploy rule. Verified `nix-daemon`, `PPid` and `incomparable` now appear in RULES-ARCHIVE.md and NOT in RULES.md. RULES.md 33,596 -> 33,145 against the 34,300 budget; spare goes 704 -> 1,155. All 5 tests in scripts/tests/test_rules_size.py pass. Not addressed here, deliberately: the size module's own comment says the answer to the next real rule is consolidation, not another bump, and this PR takes the worktree-related bullets from four to five. Zach's call was to land the recovery and consolidate separately rather than entangle a refactor with a salvage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): the trim cut one clause too many, and it was the one carrying scope Round-4 delta audit found a π΄ in 37dbbe8 -- a regression from the eviction I proposed. Verified both findings against the files and live /proc before acting. 1. SCOPE LOSS (blocking). Trimming the bullet dropped every mention of the case cwd CANNOT answer. `grep "base clone"` on bullet 104 returned 0. The core then read as an unqualified "confirm /proc/<pid>/cwd is your OWN worktree -- the EXACT path", framing exact-match as THE fix for the sibling problem. Two read-only agents in the base clone -- a configuration RULES.md:94 explicitly permits -- both match, and the auditor kills the sibling. That is the 2026-08-02 incident verbatim, re-opened. The retained fail-safe does not catch it: "if no filter leaves a set you are confident in" never fires, because the reader IS confident -- the exact-path check matched and nothing left in the core says a match can still be a sibling. This is precisely what scripts/tests/test_rules_size.py:128-133 forbids in bold: trimming a "not just X" qualifier to buy bytes reintroduces the failure mode. The qualifier lived in the archive, which is not auto-loaded. Restored, ~150 B against 1,155 spare. Also restores the π΄ marker the trim dropped from the fail-safe (:142 lists the priority marker under what stays). 2. UNMEASURED CLAIM INSIDE A "MEASURED" PARAGRAPH (again). The archive asserted a descendant walk misses "anything that daemonizes or re-parents -- including Chrome's zygote". Measured on this host: all Brave --type=zygote processes have real parents (2869800/2869802 <- 2869781; 2869804 <- 2869802); only chrome_crashpad sits at PPid 1. The zygote does NOT re-parent. Struck, and the entry now says so explicitly, because the wrong version was in a paragraph headed "Measured 2026-08-13" and in 0679fe0's commit message under "MEASURED:". The conclusion is unaffected -- the nix-daemon measurement and the per-tool-call mechanism each independently establish the empty walk, and both reproduce. It is the per-tool-call shell, not zygote re-parenting, that actually orphans a chrome-headless-shell from an earlier call; the entry now says that instead. Fourth recurrence in this PR of one error class: stating an inference as an observation. Logged in the entry itself rather than only in a commit message. 3. The archive's `Supports:` line still quoted core text 37dbbe8 had deleted. Requoted. No test covers archive->core quote fidelity (test_every_archive_pointer _resolves only checks core->anchor), so this one is eyes-only. RULES.md 33,145 -> 33,300 against the 34,300 budget; 1,000 spare. All 5 tests in scripts/tests/test_rules_size.py pass, negative-controlled by the auditor on all five plus a positive control on the pointer test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): the nix-daemon premise was measured; the "so" that followed it was not Round-5 delta audit was CLEAN on 37dbbe8..fa1c54f -- first clean round in five -- but found this pre-existing π‘, introduced back in 0679fe0 and untouched since. Verified live before acting. The paragraph said a `nix build`'s real work runs in a nix-daemon child of PID 1, "so a PPid walk from the agent returns the EMPTY SET". The premise is true; the conclusion does not follow, and measurement refutes it. Sampled while this very gate ran: nix 368350 <- 366150(zsh) <- 328364(.claude-wrapped) <- the CLIENT is a descendant nix-daemon 368389 <- 18717 <- 1 <- the BUILDER is not So the walk returns something -- the client stub -- it just never reaches the process doing the work. Killing what it returns does not stop the build. Emptiness DOES hold, but via the other ground the paragraph already gave: the per-tool-call shell. Measured seven sequential Bash calls, seven distinct shell pids, and an actual orphaned stray from an earlier call (`nix 2085612 <- 1`). The entry now separates the two grounds and states which one yields which conclusion, and records the retraction inline -- because the wrong version sat inside a paragraph headed "Measured 2026-08-13", which is the whole failure mode being catalogued. Fifth instance in this PR of one class: stating a conclusion as measured when only its premise was. The imperative is unchanged and is now better supported than by the reason originally given: use a descendant walk to NARROW a cwd-filtered set, never to build one. Archive-only; RULES.md untouched at 33,300 B, 1,000 spare. All 5 tests in scripts/tests/test_rules_size.py pass. ea82146's recovered body still cmp-identical at 885 bytes. π΄ Committed from a dedicated worktree at fa1c54f, NOT the base clone: another session checked ~/workspace/devrc out onto main and fast-forwarded it to b2fb680 mid-session. The gate I had just launched "on fa1c54f" resolved its derivation from that working tree and therefore measured MAIN -- collected=9429 against 9267 on every prior run, which was the tell I read past. That result is retracted; this commit's gate runs against the real branch tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 13, 2026
β¦ were (#454) The canonical handoff on main still read "DIFFED AND DECIDED -- NOT EXECUTED ... the drops have NOT been run -- 24 worktrees were still present". All 22 drops executed 2026-08-13; the count is 10. A /resume off this doc would have acted on a picture that stopped being true hours earlier -- the stale-memory hazard the rules exist for, in the file a session reads FIRST. Records what the sweep actually did, because the safety properties are the part worth carrying forward, not the count: - the removal script re-verified each worktree's HEAD and cleanliness AT the moment of removal rather than trusting the survey that motivated it. That earned its keep -- five worktrees from concurrent sessions had appeared in the interval and were left alone because they were not in the adjudicated set. - six drops were blocked by an untracked one-line `use opencode` .envrc. Each was checked to hold ONLY that, the file was removed, and the PLAIN (non---force) remove was retried, so any genuine surprise would still have blocked. - the residue is #355 (deliberately kept, DO-NOT-MERGE), this base clone, and worktrees created after the survey. Corrects the tag-preservation note from an instruction into a record, and adds the lesson that note was missing: A LOCAL TAG IS NOT A BACKUP. The 8 preserved/* tags sat local-only for the first hour after the drops, so the reversibility this section promised did not exist -- one disk failure and 22 "superseded" calls become unfalsifiable. They are now on origin, verified by reading them back: `git ls-remote --tags origin 'preserved/*'` returns 8. ls-remote is the check; `git tag -l` answers a different question. Also carries forward the #447 finding worth having in the handoff: four audit rounds, four real defects, and every defect after round 1 was in a caveat the FIX rounds added, never in the recovered material -- ea82146's bodies have been cmp-identical since ff9589b. Recurring error class, four times: an inference stated as an observation inside a paragraph headed "Measured". The doc now names the stopping rule that follows from that pattern. 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.
π΄ DO NOT MERGE β this needs YOU, on a host you can physically reach
scripts/airvpn-updownis a LIVE fail-closed killswitch on the workbench's uplink. Everyclaim below is from unit tests,
bash -n,nft -cand reading the code. Thebarskill'smandatory re-test protocol has NOT been run and I cannot run it.
The protocol lives in
claude/skills/bar/airvpn.md(11 steps, pinned by nine tests). Read itthere, not here.
π΄ Round 3 fixed the step that the entire banner exists for. Step 11 said
ssh zach@10.42.0.30β the workbench's own nebula address β while step 1 puts you on theLAN. Followed literally that is a self-ssh; from another LAN host it is a same-LAN hop.
Neither touches the nebula direct-punch path that the section's opening line says a LAN-only
test cannot reach, and that locked the host out in #118. The step carrying the whole "off-LAN"
claim never said which host to run it from, so it passed unconditionally. It now names the
source host, carries a still-on-the-LAN guard, defines PASS / FAIL / INVALID, and says the
protocol cannot be completed without an off-LAN host rather than letting the self-ssh stand
in for one.
Now based on
main#350 has merged, so this is rebased onto current
mainand retargeted tomain. Thestacked-parent hazard is gone, and branch CI now shows the truth rather than the pre-existing
breakage #356/#357 fixed. It still deletes the
scripts/airvpn-updownPENDING_SCRUBentry#350 added β required, because that ratchet fails in both directions.
Round 3 β what the delta audit found
The audit confirmed a lot: F1's scrub verified across 17 encodings over all 702 tracked files;
F3 over 19 byte-level fixtures including every CR shape; F4's atomicity confirmed, and the
missing-table footgun confirmed real and confirmed guarded (a bare
delete tableon a missingtable aborts the file with rc=1;
table X {}+delete+ definition gives rc=0); G1, G6, G4,G3 correct and distinct; X2/X3/Y1 all killed at their own assertions. These are the findings
that were not sound.
π΄ P1 β step 11 (above)
π‘ 1 β
ARMED(none): a FOURTH state my own F4 made reachableBefore the atomic load, "both loads failed" implied the table had already been flushed, so
NOT-ARMEDwas the only reachable outcome. After F4 the previous table survives, soks_presentis true withKS_RULESET=noneβ and the arm line readARMED(none)while theline immediately above it said
FALLBACK β¦ uplink is NOT filtered, which is now false in thatcorner. Worse than meaningless: the surviving ruleset was built for a different endpoint and
fwmark, so it can read as armed while the tunnel is dead. No test covered it.
Now
STALE(previous), loud, with both contradictory lines fixed. The four states are pinnedpairwise distinct across every corner of (load outcome Γ kernel answer), and the skill's
"three states" list is now a four-row table with a bail column.
π‘ 3 β M12: the fallback path's atomicity was untested
test_the_load_is_ONE_atomic_transactiondrove the harness with no failure injected, so itonly ever inspected the primary path. Open-coding the old
add+flush+loadinsidearm_failclosedsurvived all 83 tests β the "second caller open-codes the old sequence"shape, de-atomising precisely the path that runs when things are already going wrong. Both
callers are pinned now, every rendered load must be self-contained, and a reachability
control proves the fallback actually produced a second load.
π‘ 4 β
$EPand$GWreached the ruleset with no validationvalid_ipβ hardened for exactly this β was applied to the lighthouse only. Measured withnft -c: a gateway-less default route (default dev eth0) makes the awk yield the literaleth0; a peer with no endpoint makeswg showprint(none); an IPv6 endpoint survives thesed. Each fails the primary load β blanket fallback β nometa markaccept β thetunnel dies. nft also tries to DNS-resolve these tokens, as root, inside wg-quick's
PostUp. Both producers validate now, with a positive control that valid values still land
(a validator that rejects everything is not a validator).
π‘ 2 and π‘ 5 β two comments that were false
"A failure here changes NOTHING" is true of the helper and false of the composed path:
four lines later
arm_failclosedreplaces whatever survived with the blanket fallback. And the\rcomment still carried the theory I retracted last round β measured false, the rtrimstrips it β corrected in the test docstring but not in the script.
π’ M5 β the
10#radix pin was unreachable, and the comment credited it anywayThe leading-zero check fired first, so deleting
10#made zero behavioural difference.Rather than ship an untestable guard with corrected prose, the two checks are reordered:
radix-pinned range check first, leading-zero second. Identical verdict for every input, and
10#is now load-bearing β a mutant deleting it dies onvalue too great for base.π’ P3 / P4 / P5
The precondition tested existence, not up-ness (
ip link show airvpnsucceeds for aninterface left by a half-failed
wg-quick up, and prints TUNNEL-UP) β it now requires the linkUP and a non-zero wg handshake. The install step never said to confirm the branch,
which is exactly how the deployed helper was found stale. And
ARMED(fallback)was calledmerely "degraded" with no instruction to bail β step 6 now says BAIL NOW for both
ARMED(fallback)andSTALE(previous), so you do not walk into step 10'scurland get aconfusing failure instead of a diagnosed one.
The auditor's real X11 and X15, which my reconstruction had missed
I had guessed these from their IDs and said so. Both were the G11-shaped defect and both are
now folded in:
printf 'NEBULA_LIGHTHOUSE=%s\n'β%ssurvived because the fixture asserted.strip()-ed content, erasing the very byte F3 is about. Now exact bytes.doc.index("/etc/airvpn-updown.env")is satisfied by Procedure step 1's own command. Nowanchored on the π΄ block's own text, plus an assertion that it still precedes
## Procedures.The 30 leaked processes
Thank you for killing those. That was my FIFO fixture:
subprocess.runkills its direct childon timeout, but a shell blocked in
open()on a writer-less FIFO can outlive it, and the X11gmutant (which removes the
-fgate) makes that reachable. The fixture now opens the write endnon-blocking and unlinks β on the green path too, because a cleanup that only fires on
failure is a cleanup that is never exercised.
π΄ A-1, restated (unchanged, and still REASONED not measured)
meta skuidis a firewall ACCEPT; the droppedip route replace <lighthouse> via <gw>is aROUTING bypass, and the first does not restore the second. With
suppress_prefixlength 0, onlya more-specific main-table route beats the tunnel default, so with the
/32gone the lighthouseroutes into the tunnel. Your audit strengthened this:
meta skuidprecedes thelighthouse
accept, so that accept was dominated β a no-op β in both rulesets. Only the/32ever mattered.Scope: WORKBENCH-ONLY. REASONED, NOT MEASURED β I have not observed a routing table with
the
/32absent. Step 7 (ip route get <lighthouse>) is how you measure it.What is measured
94 tests (was 85). Red/green: 12 failed / 82 passed at
4fa6f4f(10 distinct testfunctions), 94 passed at HEAD. Both tiers agree.
π΄ Four of this round's guards are INVARIANT guards, green at base, and I am labelling them
rather than counting them as regression coverage β their value is mutation-proof, not
base-red:
β¦load_is_ONE_atomic_transaction(both callers)β¦LEADING_ZERO_octet_is_REJECTEDMutation battery β 32 mutants, 32 killed, each at its own assertion. Two survived my first
attempt and are worth naming, because both were my own harness being weaker than it looked:
EPis only computed inside theif [[ -n "$GW" && -n "$PHYS" ]]branch β with the harness's default no-opip, my endpointtest never reached
endpoint_ipat all. It needed a real default route to become reachable,and now carries a control asserting the gateway landed.
A or Bβ the mutant only brokeA. Nowand.Authoritative gate, re-baselined against current
main(60e6d9d) β no longer attributinganything to a pre-existing red gate, since main is green:
origin/main(60e6d9d)RESULT: PASSRESULT: PASSNode tier: 1024/1024, PASS.
bash -nclean on both scripts. The local (non-sandbox) tiershows 26 failures on both
mainand this branch β identical set, environment-dependent,unchanged by this PR.
Correction accepted: my
test_browser_agent"ceiling crossing" diagnosis was wrong. Yourmeasurement (171.04s on main, 171.99s on the merged tree, 481 passed both) does not reproduce
it; the 291.68s run accompanied two real failures from the stale base, so the wall time was
inflated by an assertion timeout, not a budget crossing. Wall-time decomposition was the right
instrument and I drew the wrong conclusion from it.
What is NOT verified
nft -c's semantic pass (unprivileged: syntax only).uppath against a real kernel. The harness stubs every external command;nothing here arms, disarms, routes or loads a ruleset.
derivation β not the packet-level behaviour of the chain. Nine behaviour mutants survived
your earlier audit (inverting the uplink guard, never calling
arm_failclosed, dropping the/32route) and this round does not close them. That is the gap the operator protocol existsto cover, which is why P1 mattered.
π€ Generated with Claude Code