Skip to content

security: scrub the public IPs + client hostnames this PUBLIC repo was carrying, and gate both classes - #350

Merged
ZacxDev merged 3 commits into
mainfrom
scrub-public-ips
Aug 6, 2026
Merged

security: scrub the public IPs + client hostnames this PUBLIC repo was carrying, and gate both classes#350
ZacxDev merged 3 commits into
mainfrom
scrub-public-ips

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🔴 Read this first: history is NOT rewritten, and nothing here is unpublished

This PR scrubs HEAD only. Every value below is still in git history, still in every
existing clone and fork, and possibly still in GitHub's search index and whatever crawled it.
A filter-repo/BFG rewrite was deliberately not attempted — it breaks every clone and fork
and does not unpublish what is already public.

Treat every value listed here as disclosed. If any of them matters, the remedy is
renumbering or firewalling the endpoint, not this PR. What this PR buys is that the next
one cannot land.


Round 3 — the two 🟡 from the delta re-audit

Both were the same failure in two shapes: a bar button that dies without saying anything.

🟡 F1 — bar-url died with a traceback instead of its own loud error

load() caught only OSError, and UnicodeDecodeError is not one. MEASURED: a single
\xff byte in urls.env escaped as an unhandled traceback, exit 1 — contradicting the
module's own 🔴 contract ("FAILS LOUD… exit 3 with a message naming the file and the key").

Why this is worse than it looks: from a bar click stderr goes nowhere. A traceback is not
loud, it is invisible — so one stray byte in a hand-maintained file restored exactly the
silent-dead-button failure this component exists to prevent.

Fixed with except (OSError, UnicodeDecodeError), plus five parametrised cases (three at the
API, two through the CLI asserting exit 3, key and path named, and no Traceback).

One of my first fixtures was vacuous: b"\x00\x01\x02\x03" is valid UTF-8, so that
parameter never reached the bug. Replaced with bare continuation bytes (\x80\x81\x82), and
all five parameters are now confirmed red at base.

🟡 F2 — the seam guard never reached the toast half

It asserted only that the literal _bar_url_action("civitai_grafana") appears in
bar-status-poll. Nothing exercised what that function returns, and test_bar_status.py
(122 tests) asserts nothing about the civitai action. The audit measured both mutants
surviving: "--openn " + name + "_typo"150 passed / 0 failed, and repointing at
scripts/bar-urls135 passed / 0 failed. Both make the toast completely inert, and
the toast is the half with no other coverage. A structural check type-checks past a wrong
argument.

Four behavioural cases now: the returned string must split to
[<…/bar-url>, "--open", "civitai_grafana"]; the target must exist and be executable;
running it must print the URL, exit 0 and hand that URL to xdg-open; and an unset key must
exit 3 naming the key and the file. Both audit-measured mutants now die.

Two things found while writing that:

  • 🔴 The first version of the end-to-end case ran the REAL xdg-open and launched a browser
    tab on the developer's desktop.
    xdg-open is stubbed now — a test suite must not touch the
    user's session. (Caught because the assertion failed on "Opening in existing browser session." appearing in stdout.)
  • 🟡 The toast and the button resolve DIFFERENT COPIES of bar-url. The toast builds its
    path from $DEVRC_DIR (the git checkout); the bar block's left-click uses
    ${scriptsDir} (the nix-deployed copy under ~/.config/i3status-rust/scripts). Only the
    second is updated by home-manager switch; the first needs a git pull on that host. So a
    host whose base clone is stale has a working button and a dead toast — the same class this
    round is closing. Pinned by an invariant guard (…resolves_bar_url_from_the_repo_root_handle,
    green at base, labelled as such) so the asymmetry is visible in the suite. Not changed
    here
    — it is a behaviour change to a live bar component and belongs in its own PR.

Also: two smuggling forms the gap list did not list

client_host_scan.py's "what this does not catch" list read as exhaustive, and the auditor
found two forms missing from it. Both are now fixed, not documented away:

  • A subdomain written as a REGEX LITERAL WITH ESCAPED DOTS was invisible. MEASURED:
    /^metrics-internal\.<client>$/0 hits; unescaped → 1. A hostname matcher in JS/Python
    source is one of the likeliest places a real client host gets written down — and this repo's
    own scrub found a regex literal among its occurrences.
  • A subdomain whose left neighbour is _ was invisible. _LABEL forbade _, and the
    lookbehind then refused to back off to the valid suffix, so _grpc.metrics.<client>0
    hits
    despite the embedded host. Realistic shapes: markdown italics, a DNS SRV/TXT record.

🔴 False-positive re-measure, because widening a scanner can lose matches as easily as
gain them and a loss is invisible — the gate just goes quieter. Both the old and the new
pattern were pointed at six domains that are heavily present here:

probe domain old new added lost
example.test 253 255 +2 0
zacx.dev 125 125 0 0
example.com 79 79 0 0
homelab.lan 35 35 0 0
workbench.lan 18 18 0 0
github.com 9 9 0 0
total 519 521 +2 0

Both additions are genuine escaped-dot regex literals in tracked test files
(frame_eval_cdp.test.mjs:181, player_buttons.test.mjs:199). Zero false positives, zero
losses. Hits are reported in one canonical form (backslashes stripped) so an allowlist cannot
need an entry per escaping style; a dedicated case re-runs the shrinking half.

The gap list now says plainly that it is not exhaustive — that is the actual lesson — and
gained the encoding, separator-substitution ([.], ${VAR} template seams) and
non-UTF-8-file gaps it was also missing.


1. The inventory (swept before anything was changed)

Scan of every tracked file, classified with Python ipaddress. IPv4 + IPv6.

PUBLIC / routable — the actual problem

value where what it is action
the Hetzner prod gateway / nebula lighthouse scripts/opencode/agent/k8s.md, claude/skills/mailbox/SKILL.md ×2, claude/skills/mailbox/reference/build-dns-forwarding.md, nix/system/apply-nebula-443.sh ×4, nix/system/apply-mullvad-enable.sh ×4, scripts/airvpn-updown, scripts/claude-hooks/tests/test_guard_core.py ×2 the named target, from #276 SCRUBBED except two files — see §6
the operator's home residential IP claude/skills/bar/airvpn.md, claudedocs/handoff-airvpn-host-tunnel-2026-07-21.md, nix/system/apply-mullvad-enable.sh ×4 geolocates a residence — arguably worse than the named target SCRUBBED
an AirVPN CA exit, quoted as an example claudedocs/handoff-airvpn-host-tunnel-2026-07-21.md SCRUBBED
two retired Mullvad server endpoints nix/system/apply-mullvad-enable.sh ×6 SCRUBBED (env vars)
the parking IP vcap.me resolves to claudedocs/handoff-browser-bridge-2026-07-31.md, scripts/browser-bridge/tests/fixtures/oopif-rig/README.md third-party; the exact value was never load-bearing SCRUBBEDgetent hosts vcap.me

PUBLIC but deliberately kept — pinned per file, not repo-wide

Public resolvers, the conventional dummies, a ClickHouse version string, a Chrome major in
a User-Agent fixture, and 128.0.0.0 (half of the WireGuard split-default pair). 21
(path, value) entries, each with a written reason. Plus ~255 AirVPN endpoints derived from
the committed catalogue — valid only inside the catalogue and the menu test (§4).

RFC1918 / nebula overlay — reported, deliberately NOT churned

234 occurrences, 29 distinct: the LAN 192.168.50.0/24, the nebula 10.42/16 overlay, CNI
ranges, loopback ×293, link-local, multicast, CGNAT. Unroutable from the internet and
load-bearing in ~90 files.

Documentation-reserved — already safe

TEST-NET-1/2/3 and 2001:db8::, 17 occurrences; the gate allowlists these by construction.


2. The scrub — by kind, not by blanket deletion

  • Prose / agent context → the handle the repo already uses ($KC_PROD, mx-in.zacx.dev).
  • One-shot apply scriptsrequired env vars (${VAR:?…}), so they fail loudly with a
    named variable instead of silently mis-editing /etc/nixos/configuration.nix.

3. Hostnames — reported in the first revision, GATED now

scripts/testlib/client_host_scan.py flags a SUBDOMAIN of a CLIENT registrable domain.

🔴 Measured, not assumed. A flag-every-dotted-FQDN scan over this repo returns 7,470
distinct tokens
, almost all of them code. Tightening to URL position plus a curated real-TLD
list still leaves ~160 registrable domains — every documentation link anyone ever pasted.
That gate would be permanently red, and RULES.md is explicit that a permanently-red gate is
worse than none. The apex is not a finding (it is prose about a client, ~100 times over);
the topology is — grafana-new.*, auth.*, sish.*, review-<hash>.*.

Red at the base ref: restoring every scrubbed file to origin/main content →
75 occurrences, 11 distinct client subdomains, 22 files. After the scrub: green. Six of
those hosts were not in the audit's inventory — they surfaced only because the gate went
looking.

The two the operator ruled on. Test-load-bearing → fictional *.example.test (58
occurrences across 14 files; the inventory named 8, and two were escaped forms a plain
string replace does not reach — found by running the node suite). Functionally
load-bearing → the client Grafana host moved to ~/.config/bar/urls.env behind
scripts/bar-url, which fails loud (exit 3) rather than doing nothing.

🔴 The button is still unverified end-to-end. bar-url resolves the real value on this
host and an unset key exits 3, but nobody has clicked the actual bar button — that needs a
home-manager switch and the operator's live session.


4. 🔴 The gate — and the mutant that survived the first round

The audit's M7 — add a routable IP to the catalogue and paste it into a skill doc —
passed 12/12, because both exemption sources were keyed on the value alone.

gate version tree result
pre-fix pre-fix, no mutant 12 passed (clean baseline)
pre-fix M7 planted 12 passed — SURVIVED
fixed M7 planted 1 failed, 14 passed — re-confirmed this round

ALLOWLIST is keyed on (relpath, value); the catalogue exemption applies only inside
CATALOGUE_FILES, with the menu test's mirrored endpoints pinned at 2 distinct.

Harness bugs caught by controls, across three rounds — the reason none of the zeros here
are taken at face value: an absolute-path skip-dir test that skipped the whole repo; a hex-only
IPv6 boundary with 11 false positives; a case-sensitive hostname regex; a right boundary
that reported x.<domain>.evil.test; and this round, a vacuous UTF-8 fixture and a test
that opened a browser tab.

🔴 What the gates do NOT catch — in both scanners' docstrings

IP side: leading-zero octets, decimal-integer and hex-dotted forms, a quad glued to
alphanumerics, base64, split-across-lines, fullwidth dots, IPv6 %zone, and a value in a
filename. None present today; the leading-zero gap is shared with
guard_core._public_ips (both delegate to ipaddress), so the seam stays honest.

Hostname side: see the rewritten list — and note it now says explicitly that it is not
exhaustive
, because reading it as exhaustive is precisely what let two forms through.


5. Test accounting

nix build .#checks.x86_64-linux.pytests, counted, not exit-coded.

🔴 This branch is 3 commits behind main, and #356 unbroke the gate in that window. So the
number that matters is the merged tree, not this branch in isolation (RULES.md → "gate on
the merged tree"). Integration branch off origin/main (93f11e1) with this branch merged in:

origin/main (3d04ad5) merged tree
collected 6256 6311 (+55)
passed 6255 6310 (+55)
skipped 1 1
failed 0 0

RESULT: PASS — fully green, 0 failures, 0 new skips. Node tier on the merged tree:
1024/1024.

For completeness, this branch alone still reports 28 failures — 27 /usr/bin/env sandbox
failures plus the keylog :date case. Those are the four causes #356 fixed on main; they
are absent from the merged tree, which is the tree that will exist after merge. Attributed by
cause, not by count.

The scratch integration branch was deleted after measuring; airvpn-killswitch-env was not
touched this round (still at 51c799c).


6. 🔴 Two things you need to know before merging

(a) The killswitch is #355, stacked on this branch

scripts/airvpn-updown is a LIVE fail-closed killswitch; moving its lighthouse IP to an env
file is a runtime change that must pass the bar skill's re-test protocol on a physically
reachable host. This branch therefore still carries the literal, tracked as PENDING_SCRUB at
exactly 1 hit, pinned by count and never by value. #355 deletes that entry in the same
commit that removes the literal.

(b) 🔴 MERGE-ORDER COUPLING

PENDING_SCRUB fails in both directions — red if a pending leak grows, red if it is fixed
while the entry remains. Two landmines:

  1. scripts/airvpn-updown (1 hit) — merge security: scrub the public IPs + client hostnames this PUBLIC repo was carrying, and gate both classes #350🔴 DO-NOT-MERGE-YET: airvpn killswitch — close the fail-open, stop root-sourcing the site config, fix the apply/doc order #355 in that order and it is green
    throughout. 🔴 Do not gh pr merge --delete-branch this PR while 🔴 DO-NOT-MERGE-YET: airvpn killswitch — close the fail-open, stop root-sourcing the site config, fix the apply/doc order #355 is open: deleting
    a stacked parent's branch auto-closes the child and GitHub refuses to reopen it.
  2. scripts/claude-hooks/tests/test_guard_core.py (2 hits) — pinned at exactly 2, and the
    operator has uncommitted WIP in that file. The by-count design survives that WIP (the
    occurrences move, the count stays 2). But if that file is scrubbed in a different branch,
    the MERGED TREE goes red on test_pending_scrub_counts_are_exact while both branches are
    green alone.
    Whoever scrubs it must delete the entry in the same commit — a two-token
    change (both occurrences → the 1.2.3.4 dummy already used in that file).

Tracked follow-ups (not in scope this round)

  • PENDING_SCRUB exempts a path wholesale, so a value swap at constant count stays green —
    a documented trade.
  • The seam does not fail on caller growth (a third surface opening the dashboard).
  • SECRETS.md does not list the new ~/.config/bar/urls.env.
  • bar-url has no scheme allowlist and no -- terminator.
  • Both scanners silently skip non-UTF-8 tracked files (the one such file was inspected with
    strings — clean). Now at least stated in the hostname scanner's gap list.
  • The toast/button two-copies asymmetry described under F2.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits August 6, 2026 11:03
…e the class

`scripts/opencode/agent/k8s.md:38` documented a real production cluster's public
IP in a table, from #276. CLAUDE.md already forbids that class ("this repo is
PUBLIC"), but nothing enforced it — so a repo-wide sweep found more:

  * the Hetzner gateway / nebula lighthouse IP, in 6 files
  * the operator's HOME residential IP, in 3 files
  * two retired Mullvad server endpoints and a third-party parking IP

Scrubbed by kind, never by blanket deletion:
  * prose/agent-context -> the handle the repo already uses ($KC_PROD, the
    mx-in.zacx.dev hostname, or a named <placeholder>)
  * one-shot apply scripts -> required env vars, so they fail loudly rather
    than silently mis-editing /etc/nixos/configuration.nix
  * scripts/airvpn-updown (a LIVE killswitch) -> /etc/airvpn-updown.env, an
    optional root-owned site file. Absent, it still arms; only the lighthouse's
    direct bypass route + nft accept are omitted, and the arm line then reads
    `lighthouse=UNSET`. Documented in the `bar` skill's airvpn.md.

The gate: scripts/tests/test_no_public_ips.py over
scripts/testlib/public_ip_scan.py. It DELEGATES the IPv4 predicate to
scripts/claude-hooks/guard_core.py rather than growing a second copy, with a
seam test pinning that the commit hook and the file scan agree.

Two harness bugs the positive controls caught, both of which would have shipped
a scanner reporting a confident clean zero over nothing:
  * the skip-dir test ran against ABSOLUTE path parts, so every file in an agent
    worktree (.../.claude/worktrees/<id>/...) was skipped — the entire repo
  * a hex-only IPv6 boundary chopped fake globally-routable addresses out of
    C++-style `::` scope operators — 11 false positives

Measured: 1 finding on a planted IPv4 and 1 on a planted IPv6, both naming
scripts/opencode/agent/k8s.md:38; 0 under test, 12 passed.

🔴 This guards HEAD only. Git history still carries every value, and a history
rewrite would not unpublish what is already cloned, forked or indexed. Treat the
scrubbed values as disclosed and rotate/renumber if that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h out, and gate client hostnames

Addresses the adversarial audit of this PR. Three changes, in order of weight.

1. 🔴 B-1 — THE SURVIVING MUTANT. The gate's exemptions were keyed on the VALUE
   alone and applied repo-wide, so the audit's M7 (add a routable IP to
   scripts/data/airvpn-servers.json AND paste it into a skill doc) passed 12/12.
   Both exemption sources are now keyed on (relpath, value):
     * ALLOWLIST becomes 21 explicit (path, value) pins, each with its reason —
       the ClickHouse VERSION string in the headroom proposal in particular
       parses as a genuinely routable address in 25/8, and was pre-approving a
       whole /8 in every file;
     * the derived AirVPN-catalogue exemption applies only inside CATALOGUE_FILES,
       and the count of endpoints the menu TEST may mirror is pinned at 2 distinct.
   Two new controls reproduce M7 as units, so re-keying on the bare value again
   goes red. Stale-pin accounting now also catches a pin whose value MOVED file.

2. The killswitch change is NOT in this PR any more. scripts/airvpn-updown is a
   LIVE fail-closed killswitch on the workbench uplink; moving its lighthouse IP
   to an env file is a runtime change that has to pass the `bar` skill's re-test
   protocol on a physically reachable host. It is split into its own PR (stacked
   on this branch) and this branch tracks the remaining literal as PENDING_SCRUB,
   pinned by count and deleted by that PR in the same commit.

3. Client HOSTNAMES are now gated too — the other half of CLAUDE.md's "never
   commit ... a real third-party hostname used as an example".
     * scripts/testlib/client_host_scan.py + scripts/tests/test_no_client_hostnames.py
       flag a SUBDOMAIN of a client registrable domain. Measured, not guessed: a
       flag-every-FQDN scan returns 7,470 distinct tokens here and a URL-position
       one still needs ~160 allowlist entries -- a permanently-red gate. The apex
       is prose about a client and is deliberately not a finding; the topology
       (grafana-new.*, auth.*, sish.*, review-<hash>.*) is.
     * That gate is RED at the base ref: 75 occurrences, 11 distinct hosts,
       22 files. All scrubbed here.
     * The browser-bridge OOPIF fixture host and 9 others were test/doc-load-bearing
       -> fictional *.example.test hosts. Node suite 1024/1024.
     * The client Grafana host is FUNCTIONALLY load-bearing (a bar left-click and
       its toast open it), so the VALUE moved out of tracked source rather than
       the feature out of the bar: scripts/bar-url resolves it at click time from
       ~/.config/bar/urls.env, the same 0600 pattern as media.env/airvpn.env. It
       fails LOUD (exit 3, naming key and file) rather than silently doing
       nothing, and a seam test pins that BOTH surfaces route through it.

Also documents, in the scanner's docstring, the nine evasion forms the audit
enumerated that this gate does not catch. None is present in the repo, and the
leading-zero gap is shared with guard_core._public_ips (both delegate to
`ipaddress`), so the seam stays honest -- but green must not read as complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ZacxDev ZacxDev changed the title security: scrub the public IPs this PUBLIC repo was carrying, and gate the class security: scrub the public IPs + client hostnames this PUBLIC repo was carrying, and gate both classes Aug 6, 2026
…he toast half of the seam real

Both 🟡 from the delta re-audit. Same failure in two shapes: a bar button that
dies without saying anything.

F1 -- `scripts/bar-url` `load()` caught only `OSError`, and `UnicodeDecodeError`
  is not one. MEASURED: a single `\xff` byte in `urls.env` escaped as an
  unhandled traceback with exit 1, contradicting this module's own 🔴 contract
  ("FAILS LOUD ... exit 3 with a message naming the file and the key"). From a
  bar click stderr goes NOWHERE, so a traceback is not loud, it is invisible --
  one stray byte in a hand-maintained file restored exactly the
  silent-dead-button failure this component exists to prevent. Now
  `except (OSError, UnicodeDecodeError)`, with five parametrised cases (three at
  the API, two through the CLI asserting exit 3 and no traceback).

F2 -- the seam guard never reached the toast half. It asserted only that the
  literal `_bar_url_action("civitai_grafana")` APPEARS in `bar-status-poll`;
  nothing exercised what that function RETURNS, and `test_bar_status.py`
  (122 tests) asserts nothing about the civitai action. The audit measured both
  mutants surviving: `"--openn " + name + "_typo"` -> 150 passed / 0 failed, and
  repointing at `scripts/bar-urls` -> 135 passed / 0 failed. Both make the toast
  completely inert, and the toast is the half with no other coverage.

  Now four behavioural cases on the returned string: it must split to
  `[<.../bar-url>, "--open", "civitai_grafana"]`; the target must exist and be
  executable; running it must print the URL, exit 0 and hand that URL to
  xdg-open; and an unset key must exit 3 naming the key and the file. Both
  audit-measured mutants now die.

  Two things found while writing that:
    * the first version of the end-to-end case ran the REAL `xdg-open` and
      launched a browser tab on the developer's desktop. `xdg-open` is stubbed
      now -- a suite must not touch the user's session.
    * the toast resolves `bar-url` out of the GIT CHECKOUT (`$DEVRC_DIR`) while
      the bar block's left-click uses the NIX-DEPLOYED copy under
      `~/.config/i3status-rust/scripts`. Two copies of one script, and only one
      is updated by `home-manager switch`. Pinned by an invariant guard so the
      asymmetry is visible in the suite; NOT changed here, because it is a
      behaviour change to a live bar component.

Also closes two smuggling forms the auditor found that were NOT in
`client_host_scan.py`'s documented gap list, which read as exhaustive:
  * a subdomain written as a REGEX LITERAL WITH ESCAPED DOTS was invisible
    (MEASURED: escaped 0 hits, unescaped 1). A hostname matcher in JS/Python
    source is one of the likeliest places a real client host gets written down,
    and this repo's own scrub found a regex literal among its occurrences.
  * a subdomain whose left neighbour is `_` was invisible: `_LABEL` forbade `_`,
    and the lookbehind then refused to back off to the valid suffix. Realistic
    shapes: markdown italics, a DNS SRV/TXT record.

  FALSE-POSITIVE re-measure, since widening can LOSE matches as easily as gain
  them and a loss is invisible: both patterns pointed at six domains that ARE
  heavily present here (example.test, zacx.dev, example.com, homelab.lan,
  workbench.lan, github.com), 519 baseline hits -> **+2, -0**. Both additions
  are genuine escaped-dot regex literals in tracked test files. Hits are
  reported in one canonical form (backslashes stripped) so an allowlist cannot
  need an entry per escaping style.

  The gap list is rewritten to say plainly that it is NOT exhaustive -- that is
  the actual lesson -- and gains the encoding, separator-substitution and
  non-UTF-8-file gaps it was also missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ZacxDev
ZacxDev merged commit 60e6d9d into main Aug 6, 2026
ZacxDev added a commit that referenced this pull request Aug 6, 2026
…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>
ZacxDev added a commit that referenced this pull request Aug 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant