ebccdda936
A chezmoi-based fleet-dotfiles template for macOS workstations: - Two-way auto-sync via launchd watcher + 5-min puller - Mesh SSH via modify_authorized_keys driven by .chezmoidata/fleet.yaml - age-encrypted secrets file - Bundled Claude Code agentic team (11 agents) + /lite + /lite-sub commands - Verify-before-claiming Stop hook - Generic statusline + project-boundary validate-path hook - Reference launchd plist for cross-fleet task-durations aggregation (companion repo: gitea.tojo.team/cardinale/task-durations) - AGENTS.md walks an agent through the entire setup Q&A interactively - docs/ covers architecture, security model, fleet onboarding
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
#!/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())
|