#!/usr/bin/env python3 """ Stop hook: enforces CLAUDE.md's "Verify Before Claiming" rule. Blocks the turn if the final assistant message contains hedging language that signals unverified factual claims, without either: (a) an inline [unverified] tag, or (b) a verifying tool call (WebSearch, WebFetch, Read, Bash, Grep, Glob) in the turn. Improvements over v1 (2026-04-18, informed by Critic + Research Scout agents): - Uses `assistant_message` from payload instead of parsing transcript - Removed false-positive hedges: "should be", "must be", "seems", "appears to" - Added real hedging signals: "I believe", "I think", "IIRC", "might be", etc. - Removed Edit/Write/NotebookEdit from VERIFYING_TOOLS (mutation != verification) - Narrowed MCP bypass to search/fetch/query tools only - Skips short responses (<100 chars) as trivial - Skips mid-turn stops (stop_reason == "tool_use") """ import json import os import re import sys # Hedging language that signals unverified factual claims. # These are phrases people use when citing from memory, not from evidence. HEDGES = [ r"I believe", r"I think (?:it|the|this|that)", r"if I recall", r"IIRC", r"as far as I know", r"AFAIK", r"from memory", r"from what I remember", r"might be", r"could be", r"probably", r"likely", r"presumably", r"plausibly", r"my understanding is", ] # Tools that constitute actual verification of a claim. # Mutation tools (Edit, Write) do NOT verify anything. VERIFYING_TOOLS = { "Bash", "Read", "Grep", "Glob", "WebSearch", "WebFetch", } # MCP tools that count as verification (must contain one of these substrings) MCP_VERIFY_SUBSTRINGS = {"search", "fetch", "query", "get", "list", "read"} # Minimum response length to check. Short responses are trivial. MIN_RESPONSE_LENGTH = 100 def main() -> int: try: payload = json.load(sys.stdin) except json.JSONDecodeError: return 0 # Bypass: env var override if os.environ.get("CLAUDE_SKIP_VERIFY_HOOK"): return 0 # Bypass: already in forced-continuation (prevents infinite loops) if payload.get("stop_hook_active"): return 0 # Bypass: Claude is mid-turn (making a tool call, not presenting final answer) if payload.get("stop_reason") == "tool_use": return 0 # Get the assistant message — prefer payload field over transcript parsing text = payload.get("assistant_message") or payload.get("last_assistant_message") or "" # Fallback: parse transcript if payload doesn't have the message if not text: transcript_path = payload.get("transcript_path") if transcript_path and os.path.isfile(transcript_path): text = _extract_last_assistant_text(transcript_path) if not text or len(text.strip()) < MIN_RESPONSE_LENGTH: return 0 # Strip code blocks and inline code before scanning text_clean = re.sub(r"```.*?```", "", text, flags=re.DOTALL) text_clean = re.sub(r"`[^`\n]+`", "", text_clean) # Strip blockquotes (lines starting with >) text_clean = re.sub(r"^>.*$", "", text_clean, flags=re.MULTILINE) # Bypass: explicit [unverified] tag present if re.search(r"\[unverified\]", text_clean, re.IGNORECASE): return 0 # Check for hedging language pattern = re.compile( r"(?:" + "|".join(HEDGES) + r")", re.IGNORECASE, ) matches = sorted({m.group(0).lower() for m in pattern.finditer(text_clean)}) if not matches: return 0 # Check if any verifying tool was called in this turn if _turn_has_verification(payload): return 0 # Hedging found, no verification, no [unverified] tag — block reason = ( "Hedging language detected without [unverified] tags or tool " f"verification in this turn: {', '.join(matches)}. " "Per your CLAUDE.md 'Verify Before Claiming' rule: either " "(a) verify the claim via WebSearch/Bash/Read, or (b) tag it " "inline, e.g. 'X supports Y [unverified]'. Revise before " "ending the turn." ) out = {"decision": "block", "reason": reason} print(json.dumps(out)) return 0 def _turn_has_verification(payload: dict) -> bool: """Check if any verifying tool was called in the current turn.""" transcript_path = payload.get("transcript_path") if not transcript_path or not os.path.isfile(transcript_path): return False try: with open(transcript_path, "r", encoding="utf-8") as f: entries = [json.loads(line) for line in f if line.strip()] except (json.JSONDecodeError, OSError): return False # Find the last user message index last_user_idx = -1 for i, entry in enumerate(entries): if entry.get("type") == "user": last_user_idx = i # Check assistant messages after the last user message for tool calls for entry in entries[last_user_idx + 1:]: if entry.get("type") != "assistant": continue content = entry.get("message", {}).get("content", []) if not isinstance(content, list): continue for block in content: if not isinstance(block, dict) or block.get("type") != "tool_use": continue name = block.get("name", "") # Direct tool match if name in VERIFYING_TOOLS: return True # MCP tool match — only if the tool name suggests verification if name.startswith("mcp__"): name_lower = name.lower() if any(sub in name_lower for sub in MCP_VERIFY_SUBSTRINGS): return True return False def _extract_last_assistant_text(transcript_path: str) -> str: """Fallback: extract last assistant message text from transcript JSONL.""" try: with open(transcript_path, "r", encoding="utf-8") as f: entries = [json.loads(line) for line in f if line.strip()] except (json.JSONDecodeError, OSError): return "" for entry in reversed(entries): if entry.get("type") != "assistant": continue content = entry.get("message", {}).get("content", []) if isinstance(content, str): return content if isinstance(content, list): return "\n".join( b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ) return "" if __name__ == "__main__": sys.exit(main())