feat(nebula): advertise a nebula relay β a read-only check, and the sudo apply beside it - #1272
feat(nebula): advertise a nebula relay β a read-only check, and the sudo apply beside it#1272ZacxDev wants to merge 7 commits into
Conversation
/etc/nixos/configuration.nix on workbench renders
`relay: {am_relay: false, relays: [], use_relays: true}` β an empty `relays:`
means no peer can relay TO workbench, so laptop->workbench is direct
hole-punch only. That is the blocker behind clawgate #497.
Adds an idempotent apply script in the existing nix/system/apply-*.sh style:
backs up the config, asserts exactly one match of the target block before
patching, rebuilds, then verifies.
The verification parses the rendered YAML's relay.relays rather than grepping
for the relay IP. The obvious grep is a false positive: lighthouse.hosts lists
the same two mesh addresses, so `grep -E '^\s+- 10\.42\.0\.2$'` MATCHES the
pre-change config and would certify the fix while relays: was still empty.
The script runs the pre-change file through the check on every run as a
negative control, so a PASS is only printed beside a fired control.
Both relay targets were confirmed `am_relay: true` in the LIVE
nebula-lighthouse-config on the homelab and production clusters β nebula
requires that of anything listed in a peer's relays:.
Not yet applied: sudo on workbench is password-gated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
Audit of #1272 found three blockers in the apply script, all downstream of it being a stateful text-patcher that edits /etc/nixos and rebuilds: - It committed the production lighthouse's public IP into a PUBLIC repo, turning scripts/tests/test_no_public_ips.py red and adding a fourth site to a ratchet built only to shrink. apply-nebula-443.sh:4 already states the convention this violated. - Its verdict could not tell "already correct" from "broken": on a second run RENDERED_BEFORE is the patched config, so the negative control read as not-firing and a correctly-configured host got a hard FAIL. - Its `"relays = [" in s` idempotency guard is a whole-file substring, so on a host where apply-travel-prep.sh already inserted a relay list it silently skipped and reported success. That is the laptop's live state. Also: `import yaml` is not satisfiable from the python3 on the PATH that `sudo bash β¦` actually gets here, so the script could not run as documented. Rather than fix six findings on that design, this drops the auto-patching. The config edit is four lines the operator applies and can read in full; what is worth shipping is the check, which is the part that was subtle. check-nebula-relays.sh is read-only, needs no sudo, and: - parses relay.relays with the STDLIB only (no PyYAML dependency); - never greps the rendered YAML for the relay address -- lighthouse.hosts carries the same mesh addresses, so that pattern matches a config whose relays: is empty. The trap is documented in the header and asserted in the self-test; - gates its own verdict on a --self-test that must read BOTH a relays: [] and a populated fixture correctly, so a PASS is never printed by a parser that has not been shown able to say either answer; - prints the exact nix snippet to add when the check fails. Verified on the workbench node: live run FAIL rc1 (relays is empty today), shimmed PASS rc0, unit-missing rc2, unresolvable -config rc2, drop-in override picks the last ExecStart rc0, wrong-expected-relay rc1, self-test rc0. Two parser mutants (drop block-style items; report a relay for []) each fail the self-test rc1 and take the live run to rc2. The public-IP test was negative-controlled: green here, and red at the old file:line once that file is staged again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
Audit round 1 β findings and what the fix round didFull adversarial audit at Findings F2, F3, F4, F6 and F7 are closed by deletion: the apply script no longer exists, so there is no second-run verdict, no substring idempotency guard, no timestamped backup, no host assumption and no half-patched-config failure mode. Not fixed, recorded as open: F8 (relayed workbench traffic bills Hetzner egress). This is inherent to advertising the production lighthouse as a relay and is a decision for the operator, not a defect in the check. The verifier's own evidence β seven exit-code cases and two parser mutants β is in the PR body and the commit message. The relay is still not applied; that needs the operator's password. |
β¦ findings designed out
check-nebula-relays.sh tells you the relay is missing; this applies it. It is a
second attempt at the script the audit rejected, built so each of that round's
findings is structurally absent rather than patched over:
- F1 (public IP in a PUBLIC repo): no public-IP literal. Both defaults are mesh
addresses; the relay's public underlay appears nowhere.
- F2 (could not tell "already correct" from "broken"): the pre-state comes from
the VERIFIER, not from the file, so a stale edit that was never switched does
not read as satisfied. Already-satisfied prints SATISFIED and exits 0 without
touching the config or taking a backup.
- F3 (whole-file substring guard silently half-applying): the anchor is the pair
` settings = {` + ` punchy = {` and must match EXACTLY once β the bare
`settings = {` line occurs twice in the live file, the pair once. A config that
already carries `relays = [` fails that check and aborts naming the line and
telling the operator to merge by hand; it never merges lists itself.
- F4 (same-second re-run clobbering the pristine backup): backup name carries
timestamp AND pid, an existing path is refused rather than overwritten, and no
backup is taken on any path that does not write.
- F5 (PyYAML/python3 unavailable on the sudo PATH): the patch is awk; python3 is
needed only by the verifier and is checked in preflight with the exact
`sudo env "PATH=$PATH"` remedy. python3 is NOT in /run/current-system/sw/bin
here, so this is a real failure mode, not a hypothetical.
- F6 (no host assertion; `hostname` is `nixos` on more than one machine): the
guard is this host's own nebula mesh address, which is unique by construction.
- F7 (no failure atomicity): the temp copy is syntax-checked with
`nix-instantiate --parse` and line-counted BEFORE the backup is taken and
before anything is moved into place; from the move onward an EXIT trap restores
the backup, and it distinguishes "never switched" from "already switched, the
running system still needs a rebuild to revert".
Verification is delegated to check-nebula-relays.sh rather than reimplemented.
Exercised end-to-end against a copy of the live configuration.nix with shimmed
id/ip/systemctl/nixos-rebuild (no sudo, nothing real touched): happy path rc0 and
the 4 lines land in the right block; re-run when satisfied rc0, config unchanged,
0 new backups; wrong host rc1 before any write; config already carrying a relays
list rc1 with the merge-by-hand message; rebuild failure rc1 with the config
restored and "never switched"; verifier failure after a successful switch rc1
with the config restored and the "already switched" warning. Two mutants β insert
invalid Nix, insert 5 lines β each abort with THAT gate's own error, before the
backup, config untouched, and the unmutated script goes green on the same fixture.
The awk `n != 1` guard is defence in depth behind the preflight anchor count and
is not independently reachable; it is not claimed as tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
β¦rse as "no relay"
Round 2 of the audit found three defects, all in the verifier, all verified
before fixing:
- π‘-1 the file CLAIMED "a PASS means the running service loaded the new value"
while the code read only `systemctl cat` -- the unit file. Measured: that
exits 0 for an INACTIVE unit, so a config that built but never took effect
read as applied. A comment is a claim, and this one was false. Now the config
comes from /proc/<MainPID>/cmdline, the unit is required to be active, and a
disagreement between the unit file and the running process is its own reported
outcome ("a rebuild has landed but nebula has not restarted onto it").
- π‘-2 a one-line flow mapping parsed to NOTHING and was reported as
"relays is EMPTY" -- the exact opposite of the truth -- and a python traceback
inside the command substitution did the same, because `set -e` does not abort
there and `read` succeeds on the empty here-string. Both are now exit 2,
"cannot determine", which is a different claim from "the list is empty".
- π‘-3 the -config extraction matched only `-config X`. Measured against the
other spellings Go's flag package accepts: with a drop-in using `--config` or
`-config=`, the old sed silently fell back to the BASE unit's config and
reported on the wrong file. One extractor now feeds from both the process
cmdline and the unit's last ExecStart, so the two cannot drift.
- π’-1 the header claimed the generator never emits flow style. It does:
`relays: []` -- the exact state this script exists to detect -- IS flow style.
A maintainer trusting that comment could have deleted the branch that sees it.
- π’-2 --self-test failure now exits 2, not 1; 1 means "relay absent", a
different claim, and a CI job could not tell them apart.
- π’-4 the remediation block now says relayed traffic egresses the relay in both
directions, so a billed host puts every relayed byte on that bill (F8, which
was declared open rather than fixed).
Exit codes are now 0 advertised / 1 not advertised / 2 cannot determine.
Self-test grows from 3 controls to 8: the empty flow list, a block sequence, a
populated flow list, a one-line flow mapping, a terminator case, an absent
relay key that must be rc 2 rather than a false "empty", all four -config
spellings plus absent, and the naive-grep trap. Round 2's surviving mutants M1
and M3 are killed, plus a new one for the flow-mapping branch; positive control
green. M5 (terminator `break` -> `in_block = False`) is NOT claimed as killed:
measured, it differs only on a config with a duplicate top-level `relay:` key,
which a JSON-derived generator cannot emit. It is an equivalent mutant and
adding a fixture for an impossible input would be manufacturing coverage.
apply-nebula-relay.sh: since the verifier now returns 2 when the running process
has not picked up a new config, the post-switch check retries ONCE after
restarting the unit before rolling back -- `nixos-rebuild switch` normally
restarts a changed unit, and when it has not, restarting it is the completion of
the change we just made rather than an escalation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
Audit round 2 β findings and what the fix round didDelta audit of Field report from the first real run. The operator ran π΄ It also exposed a design defect not in either audit round: this machine has a pending 26.05 β 26.11 release jump, so Separately and pre-existing: nothing has activated on that host since it booted 30 days ago ( |
β¦ and cover both scripts Round 3 of the audit found eight issues in the root-privileged half. All eight are fixed, and both scripts now have repo-gate coverage they had none of. F-A β the rollback was not a rollback. `nixos-rebuild switch` registers the built system into /nix/var/nix/profiles/system and installs the bootloader BEFORE activation, so a failed activation still moved the machine while the trap printed "the system was never switched, so nothing is running the change". Measured on this host: generation 387 held `relays: - 10.42.0.2` after a reported-clean rollback. The order is now test -> verify -> switch -> verify; `nixos-rebuild test` activates without registering a generation or touching the bootloader, so a failure there really does persist nothing. The trap tracks five states and distinguishes never-activated, test-failed, activated-not-persisted, switch-attempted (profile MAY have moved, with the command to check) and persisted. It never claims more than it knows. F-B β the anchor was tied to nothing. Reproduced: NEBULA_NET=travel reported success naming nebula@travel.service while the four lines landed in the mesh block. The services.nebula.networks.<NET> block is now located by brace depth first and the anchor is only accepted inside it; every ambiguity aborts. F-C β root wrote to /tmp/nebula-relay-pre.$$. /tmp is 1777 and `>` follows symlinks. Now mktemp -d (0700), cleaned by the trap; the patch temp is a mktemp sibling of $CFG so the mv stays atomic. F-D β a missing backup skipped the rollback silently. There is an else now, cp's status is checked, and failure prints ROLLBACK FAILED with the manual fix. F-E β `[ -f "$CFG" ]` accepted a symlink and mv destroyed it. Symlinks are REFUSED, not followed: writing through a caller-chosen path as root is the same hazard as the /tmp one. Compared via readlink -f, so a symlinked directory component is caught too. F-F β a fixture comment claimed coverage it did not provide. Replacing the relay block's terminator with `if False:` left --self-test green. Fixture 5 keeps an honest comment; new fixture 5b kills the mutant with its own message, and says plainly that pkgs.formats.yaml cannot emit that shape, so it pins the parser's structure and not a reachable production config. F-G β the egress-cost warning never reached the operator. The verifier's rc-1 output is now printed in full before proceeding. F-I β the rc-2 retry was wider than its comment. check-nebula-relays.sh now emits a machine-readable `REASON: <token>` on every rc-2 path and the single restart fires only on unit-process-disagree. Also: unused `indent` and `local spellings` dropped; the preflight tool list names every binary the script execs; both scripts warn that applying a relay restarts the mesh the operator may be connected over. F-H β coverage. scripts/tests/test_nebula_relay_apply.py (29 tests, in the existing hermetic scripts/tests target) drives the real scripts against a fixture configuration.nix via NEBULA_CFG with id/ip/systemctl/nixos-rebuild/ nix-instantiate shimmed on PATH. The verifier under test is the real one and it reads the running process, so the rig spawns a real child carrying `-config <rendered.yml>` and the rebuild shim re-renders that yml from the .nix just patched. Nothing can reach a real rebuild, the profile, or /etc/nixos. scripts/tests/mutants-nebula-relay.sh is the battery, in the same shape as the other mutants-*.sh: three controls, applied-verified mutations, and each mutant naming the test that must kill it AND the message it must die with. 20/20 in the battery. Two results are recorded rather than rounded up: M-X-1 (the +4-lines guard) is a DECLARED SURVIVOR -- unreachable behind the awk `n != 1` guard, kept as defence in depth, not counted as coverage; and M-FB-2 is killed by that awk guard rather than by the assertion naming the wrong block, so the patch pass's range guard is defence in depth behind the scoped anchor count. Nothing was applied for real: /etc/nixos/configuration.nix is unmodified, nixos-rebuild was never run in any form, and the profile was not touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
β¦d-rolled shebang
The repo's own gate caught this before the sandbox did:
`test_runtime_shebangs.py::test_no_test_writes_a_usr_bin_env_shebang_at_runtime`
went red on two sites in the new harness. A test that writes a stub at RUNTIME
and then execs it cannot use `#!/usr/bin/env bash` β /usr/bin/env exists on this
NixOS host and does NOT exist in the nix build sandbox, which is the
authoritative tier, so the defect is structurally invisible here.
`testlib.mockbin.write_exec` owns that decision for the whole repo, so the shims
go through it. Consequence: the bodies are now POSIX sh, which required two real
changes rather than a shebang swap:
* the `ip` shim used `${@: -1}` (bash-only) to take the last argument; it now
walks "$@".
* the `nixos-rebuild` shim had a dead `cat β¦/rebuild_{test,switch}_rc` line
using brace expansion; removed (the case below already sets rc).
* the `id` shim no longer execs the real `id` through /usr/bin/env for
unexpected args β it fails loudly instead, which is the right answer for a
shim anyway.
Controlled two ways, because "it parses" and "it behaves" are different claims:
`dash -n` on all five generated shims, and the rig re-run end to end with the
shims written under dash (happy / deferred-restart / unreadable-config /
wrong-host all identical to the bash-backed /bin/sh run). This host's /bin/sh is
bash-interactive, so without that second control a bashism would have stayed
invisible until the sandbox.
Mutation battery re-run after the change: 20/20, same one declared survivor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
β¦ exists The patch temp is a mktemp SIBLING of $CFG rather than a file in the scratch dir, because the last step is `mv "$TMP" "$CFG"` and that is only atomic within one filesystem. Nothing asserted that it gets cleaned up, and only one path fails while it still exists: `nix-instantiate --parse` rejecting the patched file, which happens after the temp is written and before the backup is taken. test_an_invalid_nix_result_aborts_before_the_backup_and_leaves_no_temp pins all four claims on that path β the abort, its own message, no temp sibling left, and NO backup taken (the backup must not precede the parse check). The existing scratch-on-failure test now also asserts the sibling is gone. Two mutants added, both killed with this test's own message: M-X-3 drops `rm -f "$TMP"` from the cleanup -> "temp sibling leaked" M-X-4 turns the parse check into `|| true` -> "not valid Nix" Battery: 22/22, one declared survivor (unchanged). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
What
relay.relaysin a nebula config is the list of mesh IPs peers may use to relay packets TO this host β not the list of relays this host uses to reach others. That is whyuse_relays: trueon its own changes nothing. Workbench rendersrelays: [], so no peer can relay to it and a peer that cannot hole-punch (CGNAT, symmetric NAT, off-LAN) simply never connects. That is the blocker behind clawgate #497.This PR ships two scripts:
nix/system/check-nebula-relays.shnix/system/apply-nebula-relay.sh/etc/nixos/configuration.nix, thennixos-rebuild testβ verify βswitchβ verifyπ΄ The second one is a root-privileged, system-mutating script. An earlier version of this body said the PR "ships only the check β¦ does NOT edit that file and does NOT rebuild β¦ no sudo". That was true at
405ecfd4and false frome894fe10onward β the apply script has been on the branch since. Corrected here; read the apply script as what it is.Why the check exists separately
relay.relaysusing the stdlib only, so it has no PyYAML dependency (import yamlis not satisfiable from thepython3asudo bash β¦actually gets here).lighthouse.hostscarries the same mesh addresses, so a pattern like^\s+- <relay>$matches a config whoserelays:is empty. Measured: it reported a match on the pre-change file and would have certified a change that had not happened. The trap is in the header and asserted in the self-test.--self-test, which must read arelays: []fixture and a populated one correctly before any PASS is printed.0advertised Β·1not advertised Β·2cannot determine. Every rc-2 path also prints one machine-readableREASON: <token>line, so a caller can branch on the condition instead of pattern-matching prose. A--self-testfailure is rc 2, not rc 1 β 1 means "the relay is absent", a different claim. (An earlier version of this body said rc 1. Corrected.)Round 3 β the eight findings from the audit of
e894fe10..e7f2e45aswitchregisters the profile and installs the bootloader before activation, so a failed activation still moved the machine β measured: generation 387 heldrelays: - 10.42.0.2after the script reported a clean rollback. The order is nownixos-rebuild testβ verify βnixos-rebuild switchβ verify.testactivates without registering a generation or touching the bootloader, so a failure there really does persist nothing. The trap tracks five states and its message distinguishes never activated Β· test failed Β· activated-not-persisted Β· switch attempted (profile may have moved, withreadlink /nix/var/nix/profiles/systemto check) Β· persisted.NEBULA_NETNEBULA_NET=travelreportedDONE β¦ advertised by nebula@travel.servicewhile the four lines landed in the mesh block. Theservices.nebula.networks.<NET> = {block is now located first (brace depth, comments and string bodies skipped) and the anchor pair is only accepted inside it. No unique declaration, a block that never closes, or an anchor count β 1 inside the range each abort./tmpmktemp -d(0700) for the verifier capture, removed by the trap; the patch temp is amktempsibling of$CFGso the finalmvstays atomic.elseexists,cp's status is checked, and failure printsπ΄ ROLLBACK FAILED β your config is still patched at <path>with the exact four lines to delete by hand.[ -f "$CFG" ]accepted a symlink andmvdestroyed it/tmpone. Compared viareadlink -f, not[ -L ], so a symlinked directory component is caught too; the error names the resolved target and the one-flag fix.if False:left--self-testgreen. Fixture 5 keeps its (real) shape with an honest comment, and a new fixture 5b β a later top-levelam_relay:β kills the mutant with its own message. π΄ The comment states plainly thatpkgs.formats.yamlcannot emit that shape, so 5b pins the parser's structural correctness, not a reachable production config.systemctl restartfires only onREASON: unit-process-disagree. Six other rc-2 conditions (inactive unit, unreadable-config, parser failure, β¦) no longer drop every mesh session for nothing.Nits also fixed: unused
indentandlocal spellingsremoved; the preflight tool list now names every binary the script execs (diff,mktemp,tr,cut,wc,cp,mv,date,readlink,grep, β¦); both scripts now warn that applying a relay restarts the mesh the operator may be connected over.F-H β the scripts now have repo-gate coverage
They had none:
--self-testwas never invoked byscripts/gate.shand the apply script had no automated exercise at all.scripts/tests/test_nebula_relay_apply.pyβ 30 tests, in the existing hermeticscripts/teststarget, soscripts/gate.shruns them. Nothing in it can reach a realnixos-rebuild,/nix/var/nix/profiles/systemor/etc/nixos: the config is a fixture undertmp_pathreached viaNEBULA_CFG, andid/ip/systemctl/nixos-rebuild/nix-instantiateare shims onPATH.-config <rendered.yml>and thenixos-rebuildshim re-renders that yml from the.nixthe script just patched. Every fake process is stopped by its own PID; no pattern ever reachespkill.scripts/tests/mutants-nebula-relay.shβ the mutation battery, in the tree so "mutation-verified" can be re-derived rather than believed. Same shape as the existingmutants-*.sh: CONTROL-CLEAN / CONTROL-KILL / CONTROL-DETECTOR, each mutation applied-verified by occurrence count, and each mutant naming the test that must kill it and the message it must die with β a mutant that dies to a different test's error reportsWRONG-KILLERrather than counting as covered.Coverage matrix (all red before the fix, green at HEAD)
nixos-rebuildnever invokedrelayslistnixos-rebuild testfails (F-A)switchnever reachedtestsucceeds, verifier failsswitchfails after a goodtestreadlink β¦; never claims nothing was persistedCFGis a symlink (F-E)CFGunder a symlinked directoryNEBULA_NETwhose block lacks the anchor (F-B)NEBULA_NET/tmppath live while the verifier runs (F-C)EGRESSES THE RELAYpresentcheck --self-test(F-H)Mutation results β 22/22, one declared survivor
20 mutants killed by their named test, with that test's own message, plus CONTROL-CLEAN / CONTROL-KILL / CONTROL-DETECTOR green (22/22 including the controls). Two entries are recorded honestly rather than rounded up:
M-X-1-line-count-guard-off([ "$added" = "4" ]) is a declared survivor: the awk pass already exits 3 unless it made exactly one insertion, and one insertion is always four lines, so no input reachable through the script's own guards can move$added. Kept as defence in depth, not counted as coverage. The battery asserts it survives, so if the justification ever stops holding the battery says so.M-FB-2-patch-pass-unscopedis killed, but by the awkn != 1guard (two anchors β two insertions β abort), not by the assertion naming the wrong block. So the range guard inside the patch pass is defence in depth behind the scoped anchor count; the battery scores it on the message it really dies with.Both scripts also exercised end-to-end against a copy of the live
/etc/nixos/configuration.nix(not the file itself): block located at lines 702β733, anchor unique inside it, +4 lines landing in the mesh block,rebuild log == ["test", "switch"].shellcheck -S warningis clean on both.One thing the gate turned up that is NOT this PR's
A full
scripts/gate.sh --tier pyteston the branch reports two failures. One was mine and is fixed (test_no_test_writes_a_usr_bin_env_shebang_at_runtimeβ the harness wrote its own#!/usr/bin/env bash; it now goes throughtestlib.mockbin.write_exec, and the shim bodies are POSIX sh, controlled withdash -nand a full behavioural re-run under dash, because this host's/bin/shis bash and would hide a bashism until the sandbox).The other β
test_clawgate_task_interview_guard.py::test_a_body_file_written_by_a_heredoc_on_the_same_line_is_readβ is not from this work.scripts/claude-hooksis byte-identical between this branch's tip before my commits and after (ls-treeOID2a363038both sides), andmainhas since deleted that test along with 85 lines of the guard. So it is a branch-is-behind-mainartifact, and the merge resolves it.Gating was therefore done on an integration tree (
origin/main+ this branch), not on the branch alone:scripts/gate.sh --tier pytestthere is PASS, 30/30 targets,TOTAL collected=21508 passed=21505 skipped=3 failed=0, withscripts/testsat 12477/12477.Not verified
/etc/nixos/configuration.nixis unmodified,nixos-rebuildwas never run in any form, and/nix/var/nix/profiles/systemwas not touched or rolled back. The pending generation from the earlier run is still in place, by the operator's choice.nixos-rebuild test's no-profile-no-bootloader property is taken from its documented behaviour and from the F-A measurement ofswitch; it was not re-measured on this host, because doing so means running a rebuild./tmpsymlink attack is shown closed by observing that no predictable path is live while the verifier runs (plus a source-level check). A literal symlink-planting reproduction against the fixed script was not performed β it needs the script's$$in advance.π€ Generated with Claude Code
https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW