From 2c664cb5651fbf03bc52d7848add0c571708adcd Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 27 Jul 2026 13:32:34 -0500 Subject: fix(hooks): don't let a wrap sentinel outlive its session wrap-it-up drops /tmp/ai-wrap-teardown- to ask the Stop hook to kill the tmux session once the wrap certifies clean. When certification fails the Stop hook blocks and leaves the sentinel armed, which I did on purpose so a wrap blocked by a dirty tree can retry on a later stop without re-running the workflow. I never bounded that retry to the session. An uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree. work's 11:37 wrap today left one armed. The 13:20 session ran startup, committed the task filing and the template sync, went clean, and the next stop consumed the two-hour-old sentinel and killed the terminal mid-work. Every stop before those commits had been blocked by the same sentinel failing certification, so the session spent its whole life either blocked or dead. archsetup's had been armed since Saturday on a live attached terminal, and home's was armed and waiting. session-start-disarm.sh clears the project's sentinels at SessionStart. A new session means the wrap that armed one is gone, so its pending teardown is meaningless. Within-session retry is untouched, since the hook only runs at session start, and a test pins that. If teardown is still wanted, wrap-it-up re-arms it. I disarmed the three live ones by hand before writing this, backed up under /tmp/disarmed-sentinels. Four tests cover the disarm, one pins the retry behavior I did not want to lose. The scoping test matters most: a concurrent session in another project keeps its own sentinel. --- scripts/tests/ai-wrap-teardown-hook.bats | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) (limited to 'scripts/tests/ai-wrap-teardown-hook.bats') diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats index 6e33cca..12ac941 100644 --- a/scripts/tests/ai-wrap-teardown-hook.bats +++ b/scripts/tests/ai-wrap-teardown-hook.bats @@ -7,6 +7,7 @@ setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" SCRIPT="$REPO_ROOT/hooks/ai-wrap-teardown.sh" + DISARM="$REPO_ROOT/hooks/session-start-disarm.sh" GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate" TMPDIR_T="$(mktemp -d)" PROJ="proj-$$-$BATS_TEST_NUMBER" # unique so /tmp sentinels don't collide @@ -157,3 +158,54 @@ run_hook() { [ "$status" -eq 0 ] [ ! -f "$cert" ] } + +# --- Cross-session leakage (the 2026-07-27 work-session kill) --------------- +# +# A sentinel is deliberately preserved when certification fails, so a blocked +# wrap can retry within the same session once the tree is clean. But nothing +# bounded that to the session: the sentinel outlived it and detonated in +# whatever session next happened to have a clean tree. work's 11:37 wrap left +# one armed; the 13:20 session committed during startup, went clean, and was +# torn down mid-work. archsetup's had been armed for two days on a live +# terminal. +# +# A new session means the wrap that armed the sentinel is gone, so its pending +# teardown is moot. session-start-disarm.sh clears it. + +@test "session-start disarm: removes a teardown sentinel left by a prior session" { + : > "$TEARDOWN_SENTINEL" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \ + | bash "$DISARM" + [ ! -f "$TEARDOWN_SENTINEL" ] +} + +@test "session-start disarm: removes a shutdown sentinel too" { + : > "$SHUTDOWN_SENTINEL" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \ + | bash "$DISARM" + [ ! -f "$SHUTDOWN_SENTINEL" ] +} + +@test "session-start disarm: silent and exit 0 when nothing is armed" { + run bash -c "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"SessionStart\"}' | bash '$DISARM'" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "session-start disarm: only clears this project, not another's" { + other="/tmp/ai-wrap-teardown-someotherproj" + : > "$TEARDOWN_SENTINEL" + : > "$other" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" | bash "$DISARM" + [ ! -f "$TEARDOWN_SENTINEL" ] + [ -f "$other" ] + rm -f "$other" +} + +@test "within-session retry still works: sentinel survives a blocked stop" { + : > "$TEARDOWN_SENTINEL" + echo dirt > "$CWD/untracked.txt" + run run_hook + [ -f "$TEARDOWN_SENTINEL" ] + rm -f "$CWD/untracked.txt" +} -- cgit v1.2.3