Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions hooks/finish-flow-guard.sh
Original file line number Diff line number Diff line change
@@ -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"(?<![\w-])pr(?![\w-])", re.I)
AUTO = re.compile(r"(?<![\w-])auto(?![\w-])", re.I)

if not PR.search(m.group(1)):
sys.exit(0)

tp = os.environ.get("TRANSCRIPT", "")
if not tp or not os.path.isfile(tp):
sys.exit(0) # fail OPEN: cannot read intent

try:
data = open(tp, "rb").read()
except Exception:
sys.exit(0) # fail OPEN
if len(data) > 64 * 1024 * 1024:
data = data[-64 * 1024 * 1024:]
lines = data.split(b"\n")

CMDNAME = re.compile(rb"<command-name>/?([A-Za-z-]+)</command-name>")
CMDARGS = re.compile(rb"<command-args>(.*?)</command-args>", 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 <command-message>; 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("<command-message>"):
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 <<EOF
🛑 BLOCKED: \`pr\` was injected into an unattended run

Command: $COMMAND
Invocation: $VERDICT

This run is unattended (an \`auto\` token is in the dispatch chain) and its invocation carried no
\`pr\` token, so the flow is MERGE. Passing \`pr\` here is a flow the user never asked for.

If you were about to justify pr with "every recent issue shipped via PR" — that history is a
product of the INTERACTIVE convention and is not evidence about this run. Three issues drifted on
exactly that reasoning (JA-390, JA-367, JA-415).

Do instead:
- Run \`finish-detect-mode.sh merge\` (or with no action token — it defaults to merge inside a
/start wt worktree).
- If a PR genuinely is wanted, the user asks for it: /auto pr <ISSUE-ID>, or /loop /auto pr.
EOF
exit 2
fi

exit 0
118 changes: 118 additions & 0 deletions hooks/finish-flow-guard.test.sh
Original file line number Diff line number Diff line change
@@ -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":
"<command-message>%s</command-message>\n<command-name>/%s</command-name>\n<command-args>%s</command-args>" % (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 '<command-message>finish</command-message>
<command-name>/finish</command-name>
<command-args>pr JA-1</command-args>'; } >"$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 ]]
4 changes: 4 additions & 0 deletions settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down