← back to Ticket Guard
test-ticket-guard.sh
108 lines
#!/usr/bin/env bash
# test-ticket-guard.sh — NEGATIVE TEST for ticket-guard.sh (CLAUDE.md TK-11431 amendment 3).
# A positive-only test on a detector proves nothing: it confirms the happy path and
# leaves the entire purpose of the component unverified. Each case below INJECTS a
# fault and asserts the guard goes RED (nudges), alongside the must-stay-silent cases.
#
# Run: bash test-ticket-guard.sh (tests the repo copy)
# HOOK=~/.claude/hooks/ticket-guard.sh bash test-ticket-guard.sh (tests the installed copy)
set -u
HOOK="${HOOK:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ticket-guard.sh}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
pass=0; fail=0
ck(){ # ck <name> <expect: NUDGE|SILENT> <output>
local n="$1" want="$2" out="$3" got
if printf '%s' "$out" | grep -q 'ticket-guard'; then got=NUDGE; else got=SILENT; fi
if [ "$got" = "$want" ]; then pass=$((pass+1)); printf ' ok %-52s -> %s\n' "$n" "$got"
else fail=$((fail+1)); printf ' FAIL %-52s -> %s (wanted %s)\n' "$n" "$got" "$want"; fi
}
mk(){ printf '%s' "$2" > "$TMP/$1.json"; }
# 1. BOUND by launcher argv (a run-ticket.sh session) -> must stay SILENT
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=111 TICKET_GUARD_FAKE_TTY=ttysA \
TICKET_GUARD_FAKE_ARGV='claude --model opus export TK_AGENT=claude-run-11621. You are driving ticket TK-11621' bash "$HOOK")"
ck "bound via launcher argv" SILENT "$out"
# 2. INJECTED FAULT: unbound session, no record at all -> must go RED
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=222 TICKET_GUARD_FAKE_TTY=ttysMISSING \
TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: bare claude, no status record" NUDGE "$out"
# 3. BOUND by an identity-MATCHED record -> SILENT
mk ttysB '{"owner":{"pid":333,"tty":"ttysB","started":"Sun Sep 13 10:00:00 2026"},"ticket":"TK-9999"}'
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=333 TICKET_GUARD_FAKE_TTY=ttysB TICKET_GUARD_FAKE_STARTED="Sun Sep 13 10:00:00 2026" \
TICKET_GUARD_FAKE_ARGV='claude --continue' bash "$HOOK")"
ck "bound via own identity-matched record" SILENT "$out"
# 4. THE FALSE-GREEN CASE: stale slot. Record carries a real TK-#### but belongs to a
# DEAD session that previously held this tty. A naive read would go SILENT and
# silence the guard precisely when it is needed. Must go RED.
# (Real instance observed on ttys073: record named pid 21464 @11:29 while the live
# session was pid 84660 @16:08.)
mk ttysC '{"owner":{"pid":21464,"tty":"ttysC"},"ticket":"TK-11155"}'
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=84660 TICKET_GUARD_FAKE_TTY=ttysC \
TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: stale-slot record from a previous session" NUDGE "$out"
# 5. INJECTED FAULT: record present, identity matches, but no ticket -> RED
mk ttysD '{"owner":{"pid":444,"tty":"ttysD","started":"Sun Sep 13 10:00:00 2026"},"ticket":"","ticket_source":"unbound"}'
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=444 TICKET_GUARD_FAKE_TTY=ttysD TICKET_GUARD_FAKE_STARTED="Sun Sep 13 10:00:00 2026" \
TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: matched record but ticket empty" NUDGE "$out"
# 6. INJECTED FAULT: corrupt/unreadable record -> UNMEASURED -> RED, never silent
printf '%s' '{not json' > "$TMP/ttysE.json"
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=555 TICKET_GUARD_FAKE_TTY=ttysE \
TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: corrupt record (unmeasured must not pass)" NUDGE "$out"
# 7. A near-miss argv must NOT be accepted as a binding (guards the regex itself):
# a session that merely MENTIONS a ticket in prose is not bound by the launcher.
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=777 TICKET_GUARD_FAKE_TTY=ttysG \
TICKET_GUARD_FAKE_ARGV='claude --continue please look at TK-11621 and TK_AGENT=someone' bash "$HOOK")"
ck "FAULT: prose mention of a ticket is not a binding" NUDGE "$out"
# 8. Emitted JSON must be valid and carry the right hook event (a malformed hook is inert)
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=666 TICKET_GUARD_FAKE_TTY=ttysF \
TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
if printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="UserPromptSubmit"; assert "ticket" in d["hookSpecificOutput"]["additionalContext"]' 2>/dev/null; then
pass=$((pass+1)); echo " ok emitted JSON is valid UserPromptSubmit context"
else fail=$((fail+1)); echo " FAIL emitted JSON invalid"; fi
# 9. INJECTED FAULT: PID REUSE. pid MATCHES the record, but the record was written by
# a DEAD process that merely held this pid earlier — start times differ. pid alone
# would re-validate a stale ticket here; pid+start must catch it. (Raised by a
# second-model review: pid + start time is the unique process identity.)
mk ttysH '{"owner":{"pid":900,"tty":"ttysH","started":"Sun Sep 13 09:00:00 2026"},"ticket":"TK-11155"}'
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=900 TICKET_GUARD_FAKE_TTY=ttysH \
TICKET_GUARD_FAKE_STARTED='Sun Sep 13 16:30:00 2026' TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: pid recycled from a dead process" NUDGE "$out"
# 10. INJECTED FAULT: headless / no controlling tty. Several ttyless sessions would
# otherwise collide on one state filename and inherit each other's binding.
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=901 TICKET_GUARD_FAKE_TTY='??' \
TICKET_GUARD_FAKE_STARTED='Sun Sep 13 16:30:00 2026' TICKET_GUARD_FAKE_ARGV='claude -p' bash "$HOOK")"
ck "FAULT: no controlling tty (must not share a slot)" NUDGE "$out"
# 11. INJECTED FAULT: record has no start time at all -> identity UNCONFIRMED -> RED.
mk ttysI '{"owner":{"pid":902,"tty":"ttysI"},"ticket":"TK-11155"}'
out="$(TICKET_GUARD_THROTTLE_MIN=0 TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=902 TICKET_GUARD_FAKE_TTY=ttysI \
TICKET_GUARD_FAKE_STARTED='Sun Sep 13 16:30:00 2026' TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "FAULT: record carries no start time (unconfirmed identity)" NUDGE "$out"
# 12. THROTTLE: a second nudge for the SAME identity inside the window stays quiet,
# but the FIRST one always fires. Uses a private stamp dir so it cannot leak.
STAMP="$TMP/stamps"
out="$(TICKET_GUARD_THROTTLE_MIN=15 TICKET_GUARD_STAMP_DIR=$STAMP TICKET_GUARD_STATE_DIR=$TMP \
TICKET_GUARD_FAKE_PID=903 TICKET_GUARD_FAKE_TTY=ttysJ TICKET_GUARD_FAKE_STARTED='S1' TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "throttle: first nudge fires" NUDGE "$out"
out="$(TICKET_GUARD_THROTTLE_MIN=15 TICKET_GUARD_STAMP_DIR=$STAMP TICKET_GUARD_STATE_DIR=$TMP \
TICKET_GUARD_FAKE_PID=903 TICKET_GUARD_FAKE_TTY=ttysJ TICKET_GUARD_FAKE_STARTED='S1' TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "throttle: repeat inside window stays quiet" SILENT "$out"
# A DIFFERENT session on the same tty must NOT be silenced by the old stamp.
out="$(TICKET_GUARD_THROTTLE_MIN=15 TICKET_GUARD_STAMP_DIR=$STAMP TICKET_GUARD_STATE_DIR=$TMP \
TICKET_GUARD_FAKE_PID=904 TICKET_GUARD_FAKE_TTY=ttysJ TICKET_GUARD_FAKE_STARTED='S2' TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
ck "throttle: a NEW session is not silenced by the old stamp" NUDGE "$out"
echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ]