fix(guard): deny tmux kill-server on a shared socket β the command that destroyed 47 live conversations - #1415
Conversation
β¦lled 47 conversations
## What actually happened (the dispatching hypothesis was wrong)
Dispatched to add OOM protection after the operator's tmux server died at
2026-09-07 21:54:21 CDT, taking 47 live Claude conversations β 20 of which
needed manual identification to recover. The first job was to establish the
cause. It was NOT an out-of-memory event.
MEASURED:
- The kernel OOM killer did not run. Zero `oom-kill:` / `Killed process` lines
in `journalctl -k` for the incident boot. That zero is instrument-validated,
not a bare absence: the same pattern over the same journal DOES match real
kernel OOM kills from 2026-08-28, so it can fire and did not. The kernel emits
that line unconditionally before it kills.
- systemd-oomd is not installed (`systemctl cat systemd-oomd` -> no files), so
`ManagedOOMPreference` would have been decoration.
- tmux did not crash: core limit `unlimited`, `core_pattern` pipes to
systemd-coredump, and no tmux core exists for that window.
- The `57.7G / 38.2G / β¦` scope figures are systemd's per-scope LIFETIME
`memory peak` printed at teardown, over 7-23h wall clocks. Not concurrent
usage; not summable.
- The one coredump in the window is `tsserver.js` hitting its own V8 heap cap,
in a different scope, 24ms after the real cause. Coincidence.
THE CAUSE, recovered from the session transcripts:
02:54:15.802Z (= 21:54:15.802 CDT)
TMUX_TMPDIR=$SCRATCH/run tmux kill-server
42 `tmux-spawn-*.scope` units tore down 1.2s later. `TMUX_TMPDIR` does not
isolate a client: one run inside a pane reads `$TMUX`, whose socket path wins.
The same agent had measured that 72s earlier β with `$TMUX_TMPDIR` pointing at a
directory holding no socket, `tmux list-sessions` still listed the operator's
real sessions `(attached)` β and did not read it as the warning it was.
π΄ This is the argument for a STRUCTURAL guard over a written one. The agent that
ran it was a subagent of the session that is now shipping this fix, and its brief
warned about this exact hazard in capitals β "TMUX_TMPDIR does NOT isolate a
client; only `tmux -L <unique-socket>` does" β naming the earlier occurrence that
cost 43 panes. It proceeded anyway. Prose in a prompt does not re-assert itself
inside a long session; a PreToolUse hook fires on every call.
## The fix
`check_tmux_kill_shared_server` in guard_core.py, the 15th claude-code check.
Denies `kill-server`/`kill-session` unless `-L`/`-S` names a non-default socket.
Matched on the `kill-s` prefix because tmux resolves unambiguous command
prefixes, so a guard keyed on the two full words is walked past by `kill-ser`.
`kill-pane`/`kill-window` stay allowed β they destroy what the caller named, and
a guard that fires on correct work gets routed around.
The deny message corrects the specific wrong belief, not just the command.
Blast radius measured before landing: every real shell use of a `kill-sβ¦` in
this repo already passes `-L`, so this denies none of them.
Known blind spot, documented rather than papered over: `TB=$(command -v tmux);
$TB kill-server` reaches the binary through a variable and is invisible to a
text guard.
## The OOM protection, staged and honestly labelled
`nix/system/apply-tmux-oom-protection.sh` β STAGED, NOT APPLIED. It cannot live
in home-manager, for two measured reasons: an unprivileged process cannot LOWER
`oom_score_adj` (EPERM without CAP_SYS_RESOURCE β only raising works), and the
tmux server is not in a home-manager unit at all (per-login `session-N.scope`).
Its header states plainly that it does not address this incident, and a test
pins that it keeps saying so.
Displacement, addressed explicitly: the rejected design raised `OOMScoreAdjust`
on the `tmux-spawn-*.scope` units, reaching the same ordering by making the
conversations more killable. This lowers one process and touches nothing else.
-500 not -1000, so the server stays reclaimable.
Whether it is worth applying: tmux scores 668 while the top of the box is
1332-1333 (kubepods with explicit adj 998-1000), so it is far from the front of
the queue. The operator decides; nothing here applies it.
## Verification
- guard_core: 54 red at base c5e425c (of 1500), 1500 green at HEAD. The literal incident
command and the `-L` control are red-at-base by name;
`test_the_incident_and_its_control_differ_ONLY_in_the_socket_selector` is
labelled an INVARIANT GUARD because it passes at base β measured, not assumed.
- Sweep A (7 killing mutants + no-op control): all killed by this guard's OWN
assertions, incl. the fail-open case (dropping `-f` from the value-flag table,
which would let `tmux -f conf kill-server` through).
- Sweep B (8 + control) on the staged-script guards: all killed by their named
test.
- Sweep C (5 + control) on the incident regression pair: the blanket-deny mutant
is killed by the `-L` ALLOW control, which is the whole reason that control
exists.
- Sweep D: one sweep-C mutant was MIS-DESIGNED (`commands(cmd)[:1]` does not
model env-stripping, which happens inside `_peel_variants`' primary peeling).
Replaced by mutating `_ASSIGN` itself: the incident test IS among the killers,
so the `TMUX_TMPDIR=` prefix is genuinely load-bearing rather than decorative.
- All sweeps run under PYTHONDONTWRITEBYTECODE=1 and refuse to score a run that
collected no tests β sweep A's first version was a BROKEN HARNESS that reported
7 must-die mutants as SURVIVED because `nix develop` never ran under its
stripped env, and "no FAILED lines" was read as "survived".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
β¦guard-and-oom-protection Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
β¦guard-and-oom-protection Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
β¦guard-and-oom-protection Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
β¦, and an unresolved -L was taken as proof Round-2 fixes for the audit of #1415. Two blockers, three should-fixes, three nits β plus one stale claim of my own found while sweeping. BLOCKER 1 β the OOM unit selected no process and reported success forever. `pgrep -u 1000 -x tmux` matches NOTHING: `-x` is exact on /proc/<pid>/comm and the tmux server's comm is `tmux: server`. The loop body never ran, the unit printed `adjusted 0`, exited 0, and systemd called that success every two minutes. Measured 2026-09-09: `-x tmux` -> rc=1; `-x 'tmux: server'` -> 1111077. The selector was only half the defect, so all three are fixed: * selector corrected, and the in-loop comm re-read tightened from a `tmux*` prefix (which accepts `tmux: client`) to the exact string; * an APPLY-TIME positive control β the script runs the selector itself under sudo and refuses to install when it matches nothing, which is the control the every-2-minutes unit structurally cannot perform; * RUN-TIME counters split (`found` vs `adjusted`), the written value read back out of procfs, and a non-zero exit when servers were found but not lowered. `found 0` deliberately does NOT escalate β a permanently-red gate is worse than no gate. Verified behaviourally, not by reading: the generated unit body run as the unprivileged user prints `found 1 ... adjusted 0` and exits 1 (EPERM is the documented unprivileged-lower failure). The real server's oom_score_adj still reads 0 afterwards. BLOCKER 2 β the import-wiring awk could not match the only host it targets. It required `[` on the same line as `imports =`; /etc/nixos/configuration.nix puts the opener on the next line. Replaced with the logic from apply-airvpn-host.sh:88-116, INCLUDING the multiple-`imports =` guard this script had dropped. Verified by running the awk against the real config (insert at line 25, inside the list, nix-instantiate --parse OK) with the old program as the negative control (0 insertions). SHOULD-FIX 3 β an unresolved `-L`/`-S` is no longer proof of isolation. `-S "${TMUX%%,*}"`, `-S "$TMUX_SOCK"`, `-L "$sock"`, `-L ''`, `-S ''` now DENY; `-L my-probe-$$` still ALLOWs, because the rule is "a literal survives in the last path component", not "contains no variable". Two further fail-opens found and closed: leftover metacharacters from an unbalanced substitution, and `commands()` eliding a substitution so `-L` swallowed the subcommand. The action is now detected by scanning every token, so a flag-arity miscount can no longer shift the read. Scope stated in code: this protects exactly one socket NAME, `default` β `tmux -L work kill-server` is ALLOW by design. SHOULD-FIX 4 β `ssh <host> tmux kill-*` is denied, via a targeted arm. Adding `ssh` to `_WRAPPERS` was measured and rejected: `_peel_variants` reads a wrapper's first non-flag token as the command, which for ssh is the HOST, so the check would still miss while every other check began evaluating hostnames. The arm scans an ssh argv for a `tmux` token at any position, so it needs no ssh option-arity table. SHOULD-FIX 5 β the prose blast-radius ledger is now derived and enforced. Two-way over paths (not line numbers), with positive controls on both scanners, reusing public_ip_scan.repo_files so it works in the git-less sandbox tier. The docstring now says plainly that the ALLOW measurement is reassurance about the WRONG POPULATION: those sites are Python argument lists this hook, which gates Bash-tool text, structurally never sees. NIT 6 β the behavioural assertion moved first, so the red at base is `evaluate(...) is None` rather than an AttributeError on the import surface. NIT 7 β `cat >` not `mv` (inode + mode), module written after the wiring succeeds, one fixed backup name instead of one file per run, and restore() no longer claims to restore a backup that does not exist. NIT 8 β remediation split by what the caller wanted, including the kill-session branch (kill-pane/kill-window stay allowed) and the escape hatch: this hook gates the Bash tool, the operator's own terminal is not hooked β ask them. MY OWN PROSE, swept: the comment calling `_TMUX_VALUE_FLAGS` "the only way this check can fail OPEN" was true of the positional resolution it was written against and false after the token scan; corrected in both the source and the test whose docstring repeated it. "This exact command" in a message now shared by several shapes became "a command of this shape". "The FIFTEENTH check" was ambiguous (fifteenth added, eleventh in order) and now says which. MUTATION: 18 mutants, 18 killed, each by its own guard's assertion text; positive control killed; control run green. The round-1 SURVIVOR (`-x zzz-no-such-proc`) now dies. One mutant survived the first sweep β deleting the `n_imports` assignment left the token visible in the comparison β so that check now names the count, the comparison and the refusal as three separate claims. Merged origin/main (base c507d71) to re-check the pytests red on the old head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
audit-claims β round-2 fix pass on #1415Per prior finding: fixed / partially / not / made worse. Severity words, never coloured circles.
BLOCKER 1 β selectorSelector is now Three things changed, because the selector was only half the defect β the other half was that
Behavioural verification, not just a text check. The generated unit body was extracted and
BLOCKER 2 β import wiringCopied from Verified by running the awk, not by reading it β against the real Duplication across two standalone SHOULD-FIX 3 β unresolved
|
| mutant | verdict | killed by |
|---|---|---|
M1 selector back to the shipped-broken -x tmux |
KILLED | test_the_pgrep_pattern_matches_a_REALISTIC_comm |
M2 selector β -x zzz-no-such-proc (the mutant that SURVIVED in round 1) |
KILLED | same |
M3 awk [-split disabled |
KILLED | test_the_wiring_awk_actually_inserts_in_both_config_shapes |
M4 multiple-imports = guard deleted |
KILLEDΒΉ | test_the_import_wiring_is_the_SAME_logic_β¦ |
| M4b comparison neutralised (count taken, never acted on) | KILLED | same |
| M4c refusal removed from the guard branch | KILLED | same |
M5 cat β mv |
KILLED | test_the_config_is_overwritten_in_place_not_replaced_by_mv |
| M6 fixed backup β timestamped | KILLED | test_the_backup_does_not_accumulate_one_file_per_run |
M7 found>0 escalation disabled |
KILLED | test_an_empty_match_set_is_not_allowed_to_be_the_silent_path |
| M8 apply-time pre-flight removed | KILLED | same |
M9 /proc comm re-check loosened to tmux |
KILLED | test_the_comm_recheck_in_the_loop_uses_the_same_realistic_value |
| M10 empty-residue check removed | KILLED | test_an_unresolvable_socket_is_not_an_isolation_claim |
| M11 leftover-metacharacter check removed | KILLED | test_socket_isolation_is_proven_not_assumed |
| M12 elided-substitution guard removed | KILLED | test_an_unresolvable_socket_is_not_an_isolation_claim |
M13 prefix-OF relaxed to bare startswith |
KILLED | test_tmux_kill_near_misses_stay_allowed (kill-server-test) |
| M14 ssh arm removed | KILLED | test_tmux_kill_over_ssh_is_denied |
| M15 default-socket comparison neutralised | KILLED | test_tmux_kill_shared_server_is_denied (-L default) |
PC positive control: drop -f from _TMUX_VALUE_FLAGS (known fatal) |
KILLED | test_tmux_value_flag_table_covers_every_value_taking_server_flag |
ΒΉ M4 SURVIVED on the first sweep and that was a real finding, not noise: the test asked only
for the token n_imports, which still appeared in the if that compared it β the guard was gone
while the test read as covering it. A guard on a word is walkable by rewording; the check now names
the count, the comparison and the refusal as separate claims, and M4/M4b/M4c all die.
CI
tekton/devrc-pytests was red on 24c77099 on
test_the_SUMMARY_BANNER_names_the_real_selection_source
(scripts/tests/test_run_tests_targets.py), a file this PR does not touch. origin/main merged;
new base c507d71d (was 19 commits behind).
A concrete candidate rather than "base/environment": 9300f234 (#1406) touched that exact test
file and run-tests.sh, and is absent from the old base. The four most recent merged PRs on
bases that include it (#1432, #1427, #1426, #1425) all have pytests=SUCCESS. That is
correlation and a plausible mechanism, not a proof β the measurement is the tekton run on this
new head. If it stays red on a current base I will report it rather than merge through it.
What was NOT run, and why
The box was at load 72-99 throughout (six other sessions gating). Per the standing instruction not
to gate above ~25, I did not run scripts/gate.sh --tier both or the two nix build check
derivations locally. What was run: the affected test files directly (dev-host tier), plus a
git-less-tree run of the new corpus-scanning tests to cover the sandbox tier's file-listing path.
Naming this rather than implying a full gate.
Split into two PRs?
Recommendation: keep them together β but it is your call, and I did not split unilaterally.
The auditor's reasoning was "the guard is sound, both blockers are in the script". Both blockers
are now fixed and gated, so the thing that made splitting attractive has largely gone. And
merging the script applies nothing: it is staged, inert until someone runs it under sudo, so
carrying it costs no operational risk.
The honest asymmetry that remains is verification confidence, not correctness: the guard is
verified behaviourally end to end, while the script's real runtime effect (nixos-rebuild + the
timer firing every 2 minutes) cannot be exercised from here at all. If you would rather ship the
guard now and keep the OOM prophylaxis decision open β its own header says it is staged for an
explicit decision, against a mechanism that has never fired on this host β splitting is the right
call and I will do it on a word.
CI re-check β resolvedBoth tekton legs are green on the re-based head So the β Neither of those checks blocks a merge. β And this fix is not live on the workbench. Measured, with a positive control: the deployed |
β¦ERGED; #1415 round-1 fixed with Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
|
Machine-readable restatement of the round-1 fix pass above, in the fenced form β Two things the round-1 pass states it did NOT do, carried forward so round 2 does not |
audit round 2 β delta
|
β¦ ledger MEASURED as a MERGED-TREE break, not on either branch: #1415 (merged as 176f412) added a two-way ledger of every tracked file mentioning a wide tmux kill, plus a shell-text scanner with a named-files allowlist. This PR adds scripts/tests/test_tmux_restore_trigger.py, whose class docstring explains why the service needs `ConditionPathExists=` and names the wide-kill command in doing so. Both PRs are green ALONE. The merge is red β two failures: test_every_kill_server_call_site_in_the_repo_is_classified added: ['scripts/tests/test_tmux_restore_trigger.py'] test_no_tracked_shell_text_writes_a_kill_this_guard_would_deny shell text this guard denies, outside the named files: [('scripts/tests/test_tmux_restore_trigger.py', <the wide-kill spelling>)] This is the ledger working exactly as designed β it is two-way precisely so a new file has to be classified by a human rather than silently joining the set. Zero file overlap between the two diffs, which is why nothing before the merged tree could see it. It is also the textbook case from claude/RULES.md: disjoint files are not safety, and the trigger is the base MOVING. The mention is PROSE, one line of a class docstring about the event; that file executes no tmux kill at all. So it is classified in _KILL_MENTION_LEDGER with that reason, and named in the shell-text scanner's allowlist. Red-at-base confirmed rather than assumed: 2 failed / 1 passed before this change, 3 passed after, with the scanner's own positive control green throughout β so the pass is not a scanner wired to nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
β¦mer (#1376) * feat(tmux-restore): trigger on the tmux socket appearing, not a 45s timer The unit destroyed the workspace it existed to restore. Measured 2026-09-06: 43 `claude --resume` conversations, silently. It was started by an `OnActiveSec=45s` timer; on a cold boot nothing else has started tmux by second 45, so tmux-session-restore.py's own `tmux new-session -d` created the server INSIDE the unit's cgroup. The sends were delivered SUCCESSFULLY into it. Then ExecStart returned and Type=oneshot + RemainAfterExit=no + KillMode=control-group tore the cgroup down, taking the server and every claude process with it. The unit reported Result=success. A duration was never the variable, so this replaces the timer with the observable the restore actually depends on: a tmux server that this unit did not create, and therefore cannot destroy. * systemd.user.paths.tmux-session-restore watches %t/tmux-%U/default with PathChanged=, wanted by default.target. * The service is gated on ConditionPathExists= for the SAME socket, because PathChanged also fires on deletion (the operator's server exiting). * The service pins TMUX_TMPDIR=%t, so the socket the path unit WATCHES and the socket `tmux` QUERIES are one declaration rather than two that happen to agree. tmux's compiled-in default is /tmp, not %t. * No RemainAfterExit=yes: it is measured to work and is the wrong fix, since with KillMode=control-group the unit would then own the operator's server. PathChanged rather than PathExists is not a style choice. systemd re-checks a path unit's condition the moment the triggered unit terminates, so a STATE that goes on being true re-fires forever: measured on this host, PathExists= ran the oneshot 5 times in 8 seconds and left both units Result=start-limit-hit β which under OnFailure=notify-failure@%n is a DND-bypassing toast on every boot, strictly worse than the bug. Also updates the operator-facing not-run advice in tmux-restore-observe.sh, which still told the reader to wait for a 45s timer that no longer exists. Adds nix_units.section() β a nested-section reader β because `Unit` is both a section name and a directive name in a path unit, and directive() correctly refuses rather than answering about the wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * docs(tmux-restore): re-derive the exit-0 refusal reason against the new trigger The exit-0 justification named a precondition this branch removes β "until the unit is triggered on the tmux socket appearing rather than a fixed OnActiveSec=45s, no server is the normal COLD-BOOT state". That is now false, so the comment and the test docstring are re-derived rather than left asserting it. The DECISION is unchanged (still exit 0); only the reason is new: PathChanged fires on socket DELETION too, ConditionPathExists skips the ordinary case, and what reaches the refusal is a race β which must not raise a DND-bypassing alarm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * fix(tmux-restore): close the trigger/precondition gap; correct three claims the audit measured false Round-2 audit fixes on the socket-activation change. FINDING 1 (deploy-blocker) β the path unit fires when the SOCKET FILE appears; `cmd_restore` refused unless `tmux has-session` succeeded, which needs a SESSION. tmux creates the socket in `server_start()` BEFORE sourcing its config, and the first session is queued behind three blocking `run-shell` plugin loads plus continuum's replay. Measured 2026-09-07 (continuum EXCLUDED, so a lower bound): socket at t0+0.009s, first session at t_sock+0.098-0.112s, path-triggered ExecStart reaching `has-session` at t_sock+0.065s in one run and +0.288s in another β the outcome FLIPPED between runs. When it loses: refusal, exit 0, Result=success, no OnFailure, and NO retry, because the socket is created once. Silent no-restore. `wait_for_tmux_server()` is a bounded poll (30s) between the trigger's observable and the script's precondition. It bails INSTANTLY when the socket is absent β the free discriminator, and the same shape as `wait_for_workspace_to_settle`'s `no_server_after` bail, which exists because #1351 once burned a 120s timeout in the nix sandbox. `sleep` and the socket path are injected, so no test sleeps. Exceeding the bound falls through to the refusal, which now names the wait and which of the two faults it was. FINDING 2 β the exit-0 justification understated its own frequency. "A stale socket from a SIGKILLed server, or a server with zero sessions" framed as exotic the single most common way the branch was reached: every tmux server has zero sessions for its first ~100ms, exactly the window the trigger fires in. The conclusion (exit 0, do not toast) survives and does not depend on frequency; the post-fix frequency is stated as UNMEASURED rather than re-guessed a third time. The sweep found the same error in a second place the audit did not name β `no_tmux_server_to_restore_into`'s docstring asserted zero sessions is "a state the operator's workspace never sits in". FINDING 3 β the claim that `tmux-restore-observe.sh` surfaces the quiet path was FALSE: the resume verdict is gated on `sends != 0`, and a refused run logs zero sends, so a boot where nothing was resumed returned RC_CLEAN. Fixed the instrument rather than softening the claim: `refusals_logged=` is emitted from the unit's journal and a new RC_REFUSED (6) arm reports it, outside that gate. FINDING 4 β documented that the trigger depends on a `TMUX_TMPDIR` nobody declares: losing it moves tmux's socket to /tmp and the path unit never fires, silently. A fallback watch is argued against explicitly β `$TRIGGER_PATH` is documented by systemd as lossy and not to be relied on, so a second watch would reintroduce the trigger/query seam. `TestTheTriggerAndTheQueryNameOneServer` was narrower than its description (both operands came from `nix/home.nix`); it now carries a third side in the SCRIPT, plus its own positive control. Also: the unit `Description` no longer claims it runs after continuum restores (measured, it starts before); the `RemainAfterExit` comment is self-contained instead of pointing at a PR body; `observe.sh`'s header premise, stale twice over, is corrected; the `"45s"` substring ban is narrowed to phrases that can only mean a fixed delay; `nix_units.section()`'s two silent preconditions are documented. Mutation-tested: 10 mutants, all KILLED by the specific guard's own message, under PYTHONDONTWRITEBYTECODE=1, with a positive control and a byte-identical restore check. One initially died for the WRONG reason (a message-less tuple assertion firing first) and the test was reordered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * docs(tmux-restore): drop two unmeasured claims from the round-2 comment 'the single most common way this branch was reached' and 'a toast at 4am' were both inferences written in the same paragraph that corrects an unmeasured frequency claim. Which reachable state is most common was never measured; the argument does not need it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * fix(tmux-restore-observe): add rc 6 to the usage legend The new RC_REFUSED arm made the legend in the usage `die` incomplete β it listed 0-5 and the script can now exit 6. Nothing asserts this string, which is why it was missed; it is the line an operator reads when they mistype the subcommand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * test(guard): classify this PR's new test file in #1415's kill-mention ledger MEASURED as a MERGED-TREE break, not on either branch: #1415 (merged as 176f412) added a two-way ledger of every tracked file mentioning a wide tmux kill, plus a shell-text scanner with a named-files allowlist. This PR adds scripts/tests/test_tmux_restore_trigger.py, whose class docstring explains why the service needs `ConditionPathExists=` and names the wide-kill command in doing so. Both PRs are green ALONE. The merge is red β two failures: test_every_kill_server_call_site_in_the_repo_is_classified added: ['scripts/tests/test_tmux_restore_trigger.py'] test_no_tracked_shell_text_writes_a_kill_this_guard_would_deny shell text this guard denies, outside the named files: [('scripts/tests/test_tmux_restore_trigger.py', <the wide-kill spelling>)] This is the ledger working exactly as designed β it is two-way precisely so a new file has to be classified by a human rather than silently joining the set. Zero file overlap between the two diffs, which is why nothing before the merged tree could see it. It is also the textbook case from claude/RULES.md: disjoint files are not safety, and the trigger is the base MOVING. The mention is PROSE, one line of a class docstring about the event; that file executes no tmux kill at all. So it is classified in _KILL_MENTION_LEDGER with that reason, and named in the shell-text scanner's allowlist. Red-at-base confirmed rather than assumed: 2 failed / 1 passed before this change, 3 passed after, with the scanner's own positive control green throughout β so the pass is not a scanner wired to nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦socket trigger) are MERGED, SHI Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
β¦-back could overwrite a generation Round-1 audit findings, all four, in one commit. The π΄ is a data loss inside the mechanism this PR exists to build. π΄ 1 β THE STAMP IS NOW UTC. `list_generations` sorts on the NAME and pruning deletes from the older end, so every ordering guarantee here rests on the stamp being monotonic. Local time is not: it repeats an hour at every DST fall-back. Measured on this host's zone (America/Winnipeg), the two instants 2026-11-01 06:00 and 07:00 UTC are BOTH `20261101T010000` local β one stamp, two saves. The anchor then parsed that ambiguous string with `time.mktime`, `stamp > newest` could never be satisfied, and the fall-through returned an OCCUPIED stamp: a bound session id and its cheat-sheet destroyed at rc 0 with nothing printed, while `prune_generations` reported `0 pruned`. The docstring called that "a bounded, VISIBLE loss"; it was not visible by any means. `generation_stamp` uses `time.gmtime`, and the anchor uses `calendar.timegm` to match. The resurrect-style format is unchanged β only the clock. Nothing parses these names as local time. π΄ 1b β AN EXHAUSTED SEARCH NOW RAISES instead of returning an occupied stamp. A save that fails loudly costs one save; a save that clobbers costs the bound plan it existed to protect. π‘ 2 β THE CONCURRENT-SAVE RACE THE DOCSTRING NAMED IS CLOSED. `scripts/tmux-post-save.sh:21` backgrounds and disowns `save` with no lock, so a manual save genuinely races the 15-minute hook. What was implemented covered the SEQUENTIAL same-second case only. Two processes in the same second both saw the slot free via `exists()`, both took the stamp, then interleaved over FIXED temp names β measured: `FileNotFoundError` out of `os.replace`, a generation holding one process's bytes under the other's rename, and `FileExistsError` out of `os.symlink`. Now: the free-check and the claim are one `O_CREAT|O_EXCL` operation, and `_write_atomic`/`_point_at` use per-process temp names. `_write_atomic` also removes its own temp on failure β a leftover `.tmp` does not match `_GEN_PLAN_RE`, so pruning would never reap it. π‘ 3 β `prune_generations` REPORTS ONLY WHAT IT DELETED. The unlink `OSError` was swallowed and the stamp appended regardless. Measured: `2 kept (max 1), 1 pruned` while NOTHING had been pruned. A persistent unlink failure gives unbounded growth reported as healthy retention on every save. π‘ 4 β THE SHRINK REPORT CANNOT NAME A FILE THE SAME SAVE PRUNED. `protect=(stamp,)` did not cover `previous_gen` β the file the recovery command names. Measured at KEEP_GENERATIONS=1: the report named a path whose `exists()` was False. This file's own rule is that a warning pointing at the wrong file is worse than none. RED AT BASE / GREEN AT HEAD β the matrix, per test Base = this branch merged with main (d732106), source reverted, tests kept. RED at base, green at HEAD (regression coverage): test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[America/Winnipeg] test_an_exhausted_stamp_search_REFUSES_instead_of_overwriting test_a_concurrent_save_cannot_take_a_stamp_another_save_claimed test_write_atomic_temp_names_are_per_process test_prune_reports_only_what_it_actually_deleted test_the_shrink_report_never_names_a_file_this_save_just_pruned GREEN at base β labelled INVARIANT GUARDS in their own docstrings, not counted as regression coverage: test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[UTC] the non-vacuity control: in a zone without DST there is no collision, so this is what proves the Winnipeg parametrisation is doing the catching. test_a_save_inside_a_repeated_local_hour_does_not_destroy_a_generation[both] the end-to-end loss is NOT deterministic from a test: glibc's `mktime` tie-break for a repeated hour is unspecified, and in this harness it resolves so the base anchor steps forward and no loss occurs. The audit measured it going both ways. Kept because it pins the property on the real `cmd_save` path; labelled so nobody reads it as evidence the bug is caught. TWO FIXTURE ERRORS OF MY OWN, FOUND BY WATCHING THE TESTS AT BASE Both would have shipped as coverage that catches nothing: * The DST tests first used epoch `1793440800`, which is 2026-10-31 β a day off the transition. The fixture never entered the repeated hour and BOTH parametrisations passed at base. The constant is now DERIVED, with the derivation recorded in the test. * `test_an_exhausted_stamp_searchβ¦` first occupied a contiguous run of stamps, which does not exhaust the search at all: the anchor JUMPS PAST `newest`, so the slot after the newest generation is always free. It scored DID NOT RAISE β the guard was unreachable, not working. It now holds the listing empty while the files exist, which is the actual race (another process claimed them between this caller's listing and its claim). Change-scoped: 187 passed across test_tmux_session_restore.py, test_tmux_restore_observe.py and test_tmux_restore_trigger.py. The #1415 kill-mention ledger still passes with its own positive control (this change adds no new file). Per CLAUDE.md as of today, no full-tier run: CI is advisory and the local full-suite ritual is retired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
β¦r destroys the claude session bindings (#1383) * fix(tmux-restore): keep TIMESTAMPED plan generations β a bad save no longer destroys the bindings MEASURED data loss, workbench 2026-09-06: 21:47:17 a good plan β 47 entries, 46 carrying a bound session id 21:54:21 the tmux server died, taking 47 claude conversations 22:09:40 a continuum autosave fired on the DEGRADED post-crash workspace and `cmd_save` overwrote ~/.config/initiatives/restore-plan.json with 10 entries. The cheat-sheet went in the same second. NO BACKUP EXISTED. The conversations were recovered only because tmux-resurrect keeps its saves TIMESTAMPED (`tmux_resurrect_<ts>.txt` + a `last` symlink) and each pane line happens to carry a full `claude --resume <id>`; 24 were rebuilt from that file. THAT ASYMMETRY WAS THE BUG: a bad save cost the LAYOUT nothing and the BINDINGS everything. So mirror what resurrect already does. Each `save` writes an immutable generation into `restore-plans/restore-plan_<ts>.json` (+ the cheat-sheet, which had the identical defect from the identical writer) and repoints `restore-plan.json` / `restore-cheatsheet.md` at it as relative symlinks. Every existing reader β `cmd_restore`, `plan_staleness_hours`, `cmd_show`, `--plan`, `tmux-restore-observe.sh` β goes on reading the same two paths; `stat()` and `Path.exists()` follow symlinks, so `basis=layout` and the observe script's mtime reads are unchanged. What this deliberately does NOT do: refuse a shrinking save. The operator closing windows is ordinary use, so a guard on a falling entry count would fire on ordinary use and train everyone to bypass it. The degraded save is still written β it is simply no longer the only copy. What a shrink gets is a WARNING keyed on BOUND SESSION IDS (not the entry count: dropping five unbound windows loses nothing resumable and must stay quiet) naming the previous generation and the exact `restore --plan <path>` that recovers it. RETENTION: 192 generations, a COUNT rather than an age. The writer is hook- driven, so an age bound gives no bound on disk at all; a count bounds disk whatever the cadence does, at the price of a cadence-dependent span, which is stated rather than hidden. 192 is 48h at continuum's 15-min interval β long enough to outlast a crash noticed the following evening (~20h) or over a weekend (~40h). Cost: the live 10-entry plan measures 3,820 B + 3,267 B, so a 47-entry generation pair is ~33 KB and 192 of them ~6.3 MB. Also fixed, same writer, same shape: * `_write_atomic` β `write_text` truncates first, so a save killed mid-write left a zero-byte plan. Temp file + `os.replace`. * `free_generation_stamp` β the stamp has one-second resolution and the incident was a same-second write. A manual `save` racing the 15-min hook would have shared a stamp and clobbered the previous generation from inside the mechanism built to stop that. It must also be strictly NEWER than everything present, not merely free: a first draft returned the slot pruning had just freed, the new generation sorted OLDEST, and the run reported `4 kept (max 3), 0 pruned` β the retention cap silently unenforced. Caught by its own test; a backwards clock reproduces it with no race. * `adopt_pre_generation_files` β the DEPLOY of this change must not itself be the bad save. A host still on the old writer has a regular file at the pointer path holding possibly the only good plan; it is copied in as a generation before the pointer moves. * `prune_generations(protect=β¦)` β pruning is the only code here that deletes, so it is the only code that can recreate the defect. It refuses to unlink the generation the pointer was just aimed at, whatever the ordering argument does. TESTS β 13 new, red/green matrix measured at base c5e425c: RED at base on the DATA LOSS (not on an AttributeError; the assertions use the test module's own `_bound_ids` and walk the state dir implementation-blind, so they measure "is the binding still on disk"): a_shrinking_save_does_not_destroy_the_previous_bindings 46 of 46 ids lost a_shrinking_save_does_not_destroy_the_previous_cheat_sheet no resume cmd left a_pre_generations_plan_is_preserved_by_the_first_new_save 46 of 46 ids lost a_save_dropping_bound_ids_names_the_recovery_command no report at all plus 8 more red at base, all 13 green at HEAD (102 in the file). GREEN AT BASE BY DESIGN, labelled as invariant guards not regression coverage: a_shrinking_save_is_written_not_refused (the constraint on the fix) a_shrink_that_drops_no_bindings_stays_quiet (control: ids, not counts) the_pointer_still_reads_as_the_current_plan (reader compatibility) MUTATION SWEEP, 13 mutants under PYTHONDONTWRITEBYTECODE=1, fresh tree each, every patch verified to have applied (a `str.replace` matching nothing scores SURVIVED), each kill confirmed to carry THAT guard's own assertion text: 12 KILLED β no-adoption, protect-ignored, prune-wrong-end, prune-never, report-on-count, report-silent, stamp-free-not-newest, raw-second-stamp, non-atomic-write, empty-plan-spends-a-slot, generations-dir-hardcoded, and the full revert to the in-place overwrite. 1 SURVIVED and is explained, not waved away: removing ONLY the generation writes leaves adoption running, and adoption alone then provides a one-deep backup β the mutant fails to break the property rather than the test failing to see it. Reverting both together (M1b) kills both regression tests with their own messages, and the base-tree measurement above is the definitive form of that same mutant. Positive control for retention: 5 saves at keep=3 leave exactly the newest 3, with `0 pruned` asserted below the cap and `1 pruned` on the run that first exceeds it β a pruner wired to nothing cannot pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * fix(tmux-restore-observe): `stat -L` the plan β GNU stat uses lstat, and the plan is now a symlink Found by verifying the compatibility claim instead of asserting it. The previous commit's message said the observe script's mtime reads were "unchanged" because `stat()` follows symlinks. That is true of Python's `Path.stat()` β so `plan_staleness_hours` really was unaffected β and FALSE of GNU `stat(1)`, which uses **lstat** by default. MEASURED on a link whose target was stamped 12:00:00: stat -c '%y' <link> -> 2026-09-07 22:41:47 (the LINK was repointed then) stat -Lc '%y' <link> -> 2026-09-07 12:00:00 (the PLAN was written then) Both are timestamps, both render plausibly, and nothing in the output says which one you are looking at β so `plan_mtime=` would have silently become "when the pointer moved" and `plan_layout_skew_seconds` would have compared the pointer's repoint time against the layout's write time: two writers that are not the two that line claims to compare. In practice the link is repointed in the same second the generation is written, so the numbers would usually have looked right, which is what makes it worth pinning rather than shrugging at. Both `$PLAN` sites now dereference. The layout sites are deliberately left alone: `replayed_layout`/`newest_layout` glob `tmux_resurrect_*.txt`, which are always real files β `<resurrect-dir>/last` is a symlink but is never what those reach. `[ -f "$PLAN" ]` needs no flag either; `test` dereferences already. TEST: test_the_plan_mtime_is_the_PLANS_not_the_symlinks β a real generations dir plus a pointer into it, layout mtime 1 and generation mtime 1001. RED at base c5e425c on the skew: "the plan/layout skew was not computed from the plan GENERATION's mtime (1001) against the layout's (1) β `stat` read the symlink". Green at HEAD (44 in that file). The skew is asserted as the exact number 1000 rather than as "present" because the two candidate answers differ by ~1.8e9 seconds β arithmetic separates them and a presence check cannot. The `plan_mtime=` expectation is DERIVED by running `stat -c %y` on the generation rather than written as a literal: epoch 1001 renders as 1969-12-31 or 1970-01-01 depending on the host offset, and the first draft asserted `1970-` and failed at -0600. A test that passes in one timezone is the config-blind suite, not coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d * fix(tmux-restore): the generation stamp was LOCAL time, so a DST fall-back could overwrite a generation Round-1 audit findings, all four, in one commit. The π΄ is a data loss inside the mechanism this PR exists to build. π΄ 1 β THE STAMP IS NOW UTC. `list_generations` sorts on the NAME and pruning deletes from the older end, so every ordering guarantee here rests on the stamp being monotonic. Local time is not: it repeats an hour at every DST fall-back. Measured on this host's zone (America/Winnipeg), the two instants 2026-11-01 06:00 and 07:00 UTC are BOTH `20261101T010000` local β one stamp, two saves. The anchor then parsed that ambiguous string with `time.mktime`, `stamp > newest` could never be satisfied, and the fall-through returned an OCCUPIED stamp: a bound session id and its cheat-sheet destroyed at rc 0 with nothing printed, while `prune_generations` reported `0 pruned`. The docstring called that "a bounded, VISIBLE loss"; it was not visible by any means. `generation_stamp` uses `time.gmtime`, and the anchor uses `calendar.timegm` to match. The resurrect-style format is unchanged β only the clock. Nothing parses these names as local time. π΄ 1b β AN EXHAUSTED SEARCH NOW RAISES instead of returning an occupied stamp. A save that fails loudly costs one save; a save that clobbers costs the bound plan it existed to protect. π‘ 2 β THE CONCURRENT-SAVE RACE THE DOCSTRING NAMED IS CLOSED. `scripts/tmux-post-save.sh:21` backgrounds and disowns `save` with no lock, so a manual save genuinely races the 15-minute hook. What was implemented covered the SEQUENTIAL same-second case only. Two processes in the same second both saw the slot free via `exists()`, both took the stamp, then interleaved over FIXED temp names β measured: `FileNotFoundError` out of `os.replace`, a generation holding one process's bytes under the other's rename, and `FileExistsError` out of `os.symlink`. Now: the free-check and the claim are one `O_CREAT|O_EXCL` operation, and `_write_atomic`/`_point_at` use per-process temp names. `_write_atomic` also removes its own temp on failure β a leftover `.tmp` does not match `_GEN_PLAN_RE`, so pruning would never reap it. π‘ 3 β `prune_generations` REPORTS ONLY WHAT IT DELETED. The unlink `OSError` was swallowed and the stamp appended regardless. Measured: `2 kept (max 1), 1 pruned` while NOTHING had been pruned. A persistent unlink failure gives unbounded growth reported as healthy retention on every save. π‘ 4 β THE SHRINK REPORT CANNOT NAME A FILE THE SAME SAVE PRUNED. `protect=(stamp,)` did not cover `previous_gen` β the file the recovery command names. Measured at KEEP_GENERATIONS=1: the report named a path whose `exists()` was False. This file's own rule is that a warning pointing at the wrong file is worse than none. RED AT BASE / GREEN AT HEAD β the matrix, per test Base = this branch merged with main (d732106), source reverted, tests kept. RED at base, green at HEAD (regression coverage): test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[America/Winnipeg] test_an_exhausted_stamp_search_REFUSES_instead_of_overwriting test_a_concurrent_save_cannot_take_a_stamp_another_save_claimed test_write_atomic_temp_names_are_per_process test_prune_reports_only_what_it_actually_deleted test_the_shrink_report_never_names_a_file_this_save_just_pruned GREEN at base β labelled INVARIANT GUARDS in their own docstrings, not counted as regression coverage: test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[UTC] the non-vacuity control: in a zone without DST there is no collision, so this is what proves the Winnipeg parametrisation is doing the catching. test_a_save_inside_a_repeated_local_hour_does_not_destroy_a_generation[both] the end-to-end loss is NOT deterministic from a test: glibc's `mktime` tie-break for a repeated hour is unspecified, and in this harness it resolves so the base anchor steps forward and no loss occurs. The audit measured it going both ways. Kept because it pins the property on the real `cmd_save` path; labelled so nobody reads it as evidence the bug is caught. TWO FIXTURE ERRORS OF MY OWN, FOUND BY WATCHING THE TESTS AT BASE Both would have shipped as coverage that catches nothing: * The DST tests first used epoch `1793440800`, which is 2026-10-31 β a day off the transition. The fixture never entered the repeated hour and BOTH parametrisations passed at base. The constant is now DERIVED, with the derivation recorded in the test. * `test_an_exhausted_stamp_searchβ¦` first occupied a contiguous run of stamps, which does not exhaust the search at all: the anchor JUMPS PAST `newest`, so the slot after the newest generation is always free. It scored DID NOT RAISE β the guard was unreachable, not working. It now holds the listing empty while the files exist, which is the actual race (another process claimed them between this caller's listing and its claim). Change-scoped: 187 passed across test_tmux_session_restore.py, test_tmux_restore_observe.py and test_tmux_restore_trigger.py. The #1415 kill-mention ledger still passes with its own positive control (this change adds no new file). Per CLAUDE.md as of today, no full-tier run: CI is advisory and the local full-suite ritual is retired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Deny
tmux kill-server/kill-sessionunless an explicitly-named private socket makes it safe β and stage the OOM-score protection that was originally asked for.Why this exists β a real incident, tonight
At 21:54:15.802 CDT on 2026-09-07 an agent dispatched by a Claude Code session ran, inside a tmux pane, against the operator's live workspace:
42
tmux-spawn-*.scopeunits tore down 1.2s later. 47 live claude conversations were destroyed. 46 panes were eventually recovered β 20 of them only by manual forensic identification against the agent-ledger and restored pane scrollback β and one was never recovered.π΄
TMUX_TMPDIRdoes not isolate a tmux client. A process running inside a pane reads$TMUX, and that socket path wins. Only-L <socket>/-S <path>isolates.π΄ The agent that ran it had MEASURED the non-isolation 72 seconds earlier β an empty
TMUX_TMPDIRdirectory, no socket in it, and yettmux list-sessionsreturned the operator's real sessions marked(attached)β and did not read its own output as the warning it was. Its dispatch brief warned about this exact hazard, in capitals, citing an earlier instance in the same arc that destroyed 43 panes.That is the argument for this PR. A written warning had already failed twice. This is the structural version.
The guard
check_tmux_kill_shared_serverinscripts/claude-hooks/guard_core.py, wired intobash-guard.py. It denieskill-server/kill-sessionunless-Lor-Snames a non-default socket.kill-sprefix, because tmux resolves command abbreviations β a guard matching only the full spelling is walkable by typing less.kill-servercall in this repo already passes-L. Re-measured after merging current main, which brought two more such calls in fix(tmux-reply-agent): a launched pane gets a PATH that can findclaude(task 524)Β #1379 β both carry-L, so the claim still holds.Tests
The regression test pins the exact command, not a tidied equivalent:
test_the_exact_command_that_killed_47_conversations_is_deniedβ asserts the literalTMUX_TMPDIR=$SCRATCH/run tmux kill-serveris denied. Three properties are load-bearing and a cleaned-up fixture would lose all three:TMUX_TMPDIR=env prefix is kept β that prefix is the whole reason the command looked safe. A guard tested only against a baretmux kill-serveris untested against the form that actually occurred.$SCRATCH/runstays unexpanded β the guard sees the pre-expansion string.evaluate()'s result must equal this check's result), so a neighbouring deny cannot satisfy it.test_the_isolated_spelling_the_deny_message_prescribes_is_still_allowedβ the discriminating control.tmux -L my-probe-9182 kill-servermust still be ALLOWED. Without it, a guard hardcoded to refuse everything would pass the regression test while breaking every legitimate use.A third added test passes at base and is labelled an invariant guard, not counted as regression coverage.
Mutation: two sweeps. The blanket-deny mutant is killed by the
-LALLOW control β exactly what that control exists for. One mutant was mis-designed (commands(cmd)[:1]does not model env-stripping, since_peel_variantsstripsVAR=during primary peeling); it was replaced by mutating_ASSIGNitself, and the incident test is among the killers β so the env prefix is genuinely load-bearing rather than decorative.The OOM half β staged, not applied, and it does NOT address the measured cause
The dispatching brief hypothesised an OOM kill. That hypothesis is refuted, with instrument-validated negatives rather than bare absences:
oom-kill:/Killed processlines for the incident boot. Positive control: the same grep over the same journal does match real kernel OOM kills from 2026-08-28, so the pattern can fire and didn't.ManagedOOMPreferencewould have been decoration.unlimited,core_patternpipes to systemd-coredump, no tmux core exists.57.7G / 38.2G / 29.8Gfigures are not what they look like. They are systemd's per-scope lifetimememory peak, printed at teardown, over 7β23h wall clocks β high-water marks for scopes that mostly no longer overlapped. Not concurrent usage, and not summable.FatalProcessOutOfMemorywastsserver.js, hitting its own per-process heap cap in a different scope 24ms after the real cause.nix/system/apply-tmux-oom-protection.shis therefore staged and honestly labelled. It cannot be home-manager: unprivileged processes cannot loweroom_score_adj(EPERM, measured), and the tmux server is not in an HM unit β it lives in a per-loginsession-N.scope.π΄ On what it displaces: raising
OOMScoreAdjuston the pane scopes was rejected β it reaches the same relative ordering by making the conversations more killable, which buys an empty workspace. It lowers one process only, and to β500 rather than β1000, so tmux stays reclaimable. For context on whether it is worth applying at all: tmux currently scores 668 against a box top of 1332β1333.π΄ Gate status β owed, and stated plainly
Earlier runs on base
5d6fffa7reported: dev-host node PASS (1449); dev-host pytest 8 failures, 0 attributable to this diff β 6 were theage1.3.2 nixpkgs drift since fixed on main by #1392, and the other two did not reproduce; sandbox nodetests PASS read fromnix log.That is not sufficient and I am not claiming it is.
mainhas moved substantially since, this branch has been merged with it (head24c77099), and no tier has been run on that merged tree. The box is at load ~62 with two other sessions gating; adding a fourth concurrent suite is the exact condition that preceded tonight's incident, so the run is deliberately deferred rather than rushed.Do not merge until both tiers are green on the merged tree β
scripts/gate.sh --tier both, then the twonix buildderivations one at a time, with verdicts read fromnix lograther than build exit codes.Blast radius
Reversible β 793 insertions, 0 deletions; purely additive. The guard denies a command class that no existing caller uses. The sudo script changes nothing until someone runs it.