diff --git a/hooks/finish-flow-guard.sh b/hooks/finish-flow-guard.sh new file mode 100755 index 0000000..600e01e --- /dev/null +++ b/hooks/finish-flow-guard.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Claude PreToolUse(Bash) hook: refuse `finish-detect-mode.sh pr` in an UNATTENDED run whose +# originating invocation never carried a `pr` token. +# +# WHY: `/auto`'s worktree default is merge, and `pr` is opt-in. Three times a session added `pr` +# on its own judgment — JA-390 (2026-08-19, `/full auto wt pr` from a bare `/loop /auto`), JA-367 +# (2026-08-20, dispatched `auto JA-367 merge` then ran mode detection with `pr`), and JA-415 +# (2026-08-20, `/auto ja-415` -> `Skill(full,"auto wt pr JA-415")`). Each cited the same false +# evidence: "every recent issue shipped via PR." That history is a product of the INTERACTIVE +# convention, so the reasoning is circular — the run reads its own prior output back as proof. +# +# The cost is not cosmetic: in pr mode the source branch does not advance until the PR merges, so +# the next issue forks without its predecessor's code; `In Review` is a `started`-type state, so +# `blocks` edges never release and dependents stay invisible to /next and /auto; and SHIPPED-PR +# leaves the worktree behind. +# +# Prose already lost here once — jarvis CLAUDE.md 448ad05 stated the rule and JA-415 drifted hours +# later, because a worktree session reads a CLAUDE.md snapshot predating the fix, keys a separate +# memory namespace, and carries its own PR precedent through compaction. Per fleet-retro doctrine, +# a rule that already existed and lost gets a mechanical guard, not more prose (precedents: +# linear-create-state-guard.sh, no-blind-sleep.sh, auto-heartbeat.sh, full-continue.sh). +# +# SCOPE — interactive flows are deliberately untouched. The guard fires only when an `auto` token +# is in the dispatch chain, so a hand-typed `/finish pr`, and a bare `/finish` that means pr by a +# project's own convention, both pass. `/loop /auto pr` passes too: the user typed the token. + +set -uo pipefail + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) +[[ -z "${COMMAND:-}" ]] && exit 0 + +# The overwhelming majority of Bash calls are not mode detection — bail before any parsing work. +grep -q 'finish-detect-mode' <<<"$COMMAND" || exit 0 + +TRANSCRIPT_PATH=$(jq -r '.transcript_path // empty' <<<"$INPUT" 2>/dev/null || true) +TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}" + +VERDICT=$(COMMAND="$COMMAND" TRANSCRIPT="${TRANSCRIPT_PATH:-}" python3 -c ' +import json, os, re, sys + +cmd = os.environ["COMMAND"] + +# Quoted text is normally DATA and must not be read as an invocation, so a `grep +# "finish-detect-mode.sh pr" skills/` never trips this guard. But under an executor the quoted text +# IS the code, and stripping it would be a trivial bypass. Same rule as linear-create-state-guard.sh. +EXECUTOR = r"\b(?:ba|z|k)?sh\s+-[A-Za-z]*c\b|\beval\b|\bxargs\b" +scan = cmd if re.search(EXECUTOR, cmd) else re.sub(r"\x27[^\x27]*\x27|\"[^\"]*\"", " ", cmd) + +# Only a real invocation carrying a pr argument. Stop the arg capture at a statement boundary so a +# later `; echo pr` cannot manufacture a match. +m = re.search(r"finish-detect-mode\.sh([^;&|\n]*)", scan) +if not m: + sys.exit(0) + +# Token boundaries must treat a QUOTE as a delimiter: under `bash -c "... pr"` the argument ends +# at the closing quote, and a whitespace-anchored match would sail straight past it. Excluding +# only [\w-] keeps `pr-deploy` and `autocompact` from counting as the bare tokens. +PR = re.compile(r"(? 64 * 1024 * 1024: + data = data[-64 * 1024 * 1024:] +lines = data.split(b"\n") + +CMDNAME = re.compile(rb"/?([A-Za-z-]+)") +CMDARGS = re.compile(rb"(.*?)", re.S) +WANT = ("auto", "full", "finish", "loop") + +# Walk backwards to the most recent GENUINE slash-command invocation. A real one is a user entry +# whose message content is a plain STRING opening with ; a tool_result that merely +# echoes transcript text (this hook has been developed in exactly such a session) carries a LIST +# content and is skipped, so transcript-analysis work cannot spoof intent. +idx, name, args = -1, None, "" +for i in range(len(lines) - 1, -1, -1): + mm = CMDNAME.search(lines[i]) + if not mm or mm.group(1).decode().lower() not in WANT: + continue + try: + d = json.loads(lines[i]) + except Exception: + continue + if d.get("type") != "user": + continue + content = (d.get("message") or {}).get("content") + if not isinstance(content, str) or not content.lstrip().startswith(""): + continue + idx = i + name = mm.group(1).decode().lower() + am = CMDARGS.search(lines[i]) + args = am.group(1).decode("utf-8", "replace") if am else "" + break + +if idx < 0: + sys.exit(0) # fail OPEN: no invocation found (compaction, odd shape) + +user_pr = bool(PR.search(args)) + +# Unattended when the invocation itself is /auto, names auto among its args, or is a /loop wrapping +# one; failing those, when any skill dispatched SINCE that invocation carried an auto token. +chain_auto = name == "auto" or bool(AUTO.search(args)) or (name == "loop" and "auto" in args.lower()) +if not chain_auto: + SKILLARGS = re.compile(rb"\"name\"\s*:\s*\"Skill\".*?\"args\"\s*:\s*\"([^\"]*)\"", re.S) + for ln in lines[idx + 1:]: + if any(AUTO.search(g.decode("utf-8", "replace")) for g in SKILLARGS.findall(ln)): + chain_auto = True + break + +if chain_auto and not user_pr: + print("/%s %s" % (name, args.strip())) + sys.exit(1) +sys.exit(0) +' 2>/dev/null) +RC=$? + +# Block ONLY on a non-zero exit that also produced a verdict — any breakage in the analyzer above +# (python missing, regex error, unreadable transcript) exits non-zero with empty stdout and must +# fail OPEN. A false ALLOW costs one PR to close; a false BLOCK breaks a real ship mid-flight. +if [[ $RC -ne 0 && -n "${VERDICT:-}" ]]; then + cat >&2 <, or /loop /auto pr. +EOF + exit 2 +fi + +exit 0 diff --git a/hooks/finish-flow-guard.test.sh b/hooks/finish-flow-guard.test.sh new file mode 100755 index 0000000..9d18abc --- /dev/null +++ b/hooks/finish-flow-guard.test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Regression harness for finish-flow-guard.sh. Builds synthetic transcripts, feeds them with a Bash +# command, and asserts BLOCK/ALLOW. +# +# Cases 1-3 are the three real drifts the hook exists to stop — JA-415, JA-367, JA-390 — each +# reconstructed from its actual transcript. +# +# The ALLOW half matters MORE than the BLOCK half: a false allow costs one PR to close, a false +# block breaks a real ship mid-flight. Every interactive shape, every user-typed `pr`, and every +# unreadable-intent case must survive. +# +# GROW THIS SUITE, NEVER PRUNE IT — same contract as its hook siblings. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 + +TMP=$(mktemp -d) || exit 1 +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 + +usercmd() { python3 -c ' +import json, sys +n, a = sys.argv[1], sys.argv[2] +print(json.dumps({"type": "user", "message": {"content": + "%s\n/%s\n%s" % (n, n, a)}})) +' "$1" "$2"; } + +skill() { python3 -c ' +import json, sys +print(json.dumps({"type": "assistant", "message": {"content": + [{"type": "tool_use", "name": "Skill", "input": {"skill": sys.argv[1], "args": sys.argv[2]}}]}})) +' "$1" "$2"; } + +# A tool_result that ECHOES transcript text — list content, so it must never read as an invocation. +echoed() { python3 -c ' +import json, sys +print(json.dumps({"type": "user", "message": {"content": + [{"type": "tool_result", "content": sys.argv[1]}]}})) +' "$1"; } + +t() { # want desc command [transcript] + local want="$1" desc="$2" cmd="$3" tr="${4:-}" out rc got + out=$(jq -n --arg c "$cmd" --arg t "$tr" '{tool_input:{command:$c},transcript_path:$t}' | ./finish-flow-guard.sh 2>&1) + rc=$? + got=$([[ $rc -eq 2 ]] && echo BLOCK || echo ALLOW) + if [[ "$got" == "$want" ]]; then + PASS=$((PASS + 1)); printf ' ok %-6s %s\n' "$got" "$desc" + else + FAIL=$((FAIL + 1)); printf ' FAIL want=%s got=%s %s\n %s\n' "$want" "$got" "$desc" "$(head -1 <<<"$out")" + fi +} + +# --- transcripts ------------------------------------------------------------------------------- +JA415="$TMP/ja415.jsonl" +{ usercmd auto "ja-415"; skill full "auto wt pr JA-415"; skill start "auto wt JA-415"; } >"$JA415" + +JA367="$TMP/ja367.jsonl" +{ usercmd loop "/auto"; skill full "auto wt JA-367"; skill finish "auto JA-367 merge"; } >"$JA367" + +JA390="$TMP/ja390.jsonl" +{ usercmd loop "/auto"; skill full "auto wt pr JA-390"; } >"$JA390" + +FULLAUTO="$TMP/fullauto.jsonl" +{ usercmd full "auto wt JA-1"; skill start "auto wt JA-1"; } >"$FULLAUTO" + +AUTOPR="$TMP/autopr.jsonl" +{ usercmd auto "pr JA-1"; skill full "auto wt pr JA-1"; } >"$AUTOPR" + +LOOPPR="$TMP/looppr.jsonl" +{ usercmd loop "/auto pr"; skill full "auto wt pr JA-1"; } >"$LOOPPR" + +BAREFIN="$TMP/barefin.jsonl" +{ usercmd finish ""; } >"$BAREFIN" + +INTERPR="$TMP/interpr.jsonl" +{ usercmd finish "pr JA-1"; } >"$INTERPR" + +NOCMD="$TMP/nocmd.jsonl" +{ skill full "auto wt pr JA-1"; } >"$NOCMD" + +# Genuine `/auto ja-1`, then a tool_result echoing an interactive `/finish pr` invocation. If the +# echo were mistaken for intent the guard would wrongly ALLOW. +SPOOF="$TMP/spoof.jsonl" +{ usercmd auto "ja-1" + skill full "auto wt pr JA-1" + echoed 'finish +/finish +pr JA-1'; } >"$SPOOF" + +echo "finish-flow-guard.sh —" +echo " must BLOCK:" +t BLOCK "1 JA-415: /auto ja-415 -> pr injected at /full" '~/.claude/scripts/finish-detect-mode.sh pr 2>&1; echo "EXIT=$?"' "$JA415" +t BLOCK "2 JA-367: dispatched merge, ran pr" '~/.claude/scripts/finish-detect-mode.sh pr; echo "DETECT_EXIT=$?"' "$JA367" +t BLOCK "3 JA-390: pr injected, stderr redirect form" '~/.claude/scripts/finish-detect-mode.sh pr 2>tmp/finish-detect-ja-390.err; echo "EXIT=$?"' "$JA390" +t BLOCK "4 /full auto wt with no pr token" '~/.claude/scripts/finish-detect-mode.sh pr' "$FULLAUTO" +t BLOCK "5 echoed invocation must not spoof intent" '~/.claude/scripts/finish-detect-mode.sh pr' "$SPOOF" +t BLOCK "6 quotes are not a bypass under bash -c" 'bash -c "~/.claude/scripts/finish-detect-mode.sh pr"' "$JA415" + +echo " must ALLOW:" +t ALLOW "7 user typed /auto pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$AUTOPR" +t ALLOW "8 user typed /loop /auto pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$LOOPPR" +t ALLOW "9 bare interactive /finish (no auto in chain)" '~/.claude/scripts/finish-detect-mode.sh pr' "$BAREFIN" +t ALLOW "10 interactive /finish pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$INTERPR" +t ALLOW "11 merge under an auto chain" '~/.claude/scripts/finish-detect-mode.sh merge; echo "EXIT=$?"' "$JA415" +t ALLOW "12 no action token under an auto chain" '~/.claude/scripts/finish-detect-mode.sh' "$JA415" +t ALLOW "13 missing transcript_path fails open" '~/.claude/scripts/finish-detect-mode.sh pr' "" +t ALLOW "14 nonexistent transcript fails open" '~/.claude/scripts/finish-detect-mode.sh pr' "$TMP/absent.jsonl" +t ALLOW "15 no invocation in transcript fails open" '~/.claude/scripts/finish-detect-mode.sh pr' "$NOCMD" +t ALLOW "16 grep for the command is data, not an invocation" 'grep -rn "finish-detect-mode.sh pr" ~/.claude/skills/' "$JA415" +t ALLOW "17 unrelated command" 'git status --short' "$JA415" +t ALLOW "18 pr only after a statement boundary" '~/.claude/scripts/finish-detect-mode.sh merge; echo pr' "$JA415" +t ALLOW "19 docs edit quoting the anti-pattern" "echo 'never finish-detect-mode.sh pr in auto' >> tmp/notes.md" "$JA415" + +echo +echo "passed: $PASS failed: $FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/settings.json b/settings.json index 047a241..2a61223 100644 --- a/settings.json +++ b/settings.json @@ -178,6 +178,10 @@ "type": "command", "command": "~/.claude/hooks/linear-create-state-guard.sh" }, + { + "type": "command", + "command": "~/.claude/hooks/finish-flow-guard.sh" + }, { "type": "command", "command": "~/.claude/hooks/auto-deadline-gate.sh"