Initial public release
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
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Hook script to validate file paths are within PROJECT_PATH
|
||||
// Generic project-boundary enforcement: requires PROJECT_PATH env var to be set;
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const projectPath = process.env.PROJECT_PATH;
|
||||
if (!projectPath) process.exit(0);
|
||||
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
const tool = data.tool_name || data.tool || '';
|
||||
const toolInput = data.tool_input || {};
|
||||
|
||||
let filePath = '';
|
||||
if (['Write', 'Edit', 'Read'].includes(tool)) {
|
||||
filePath = toolInput.file_path || '';
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!filePath) process.exit(0);
|
||||
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
const resolvedProject = path.resolve(projectPath);
|
||||
|
||||
if (resolvedFile.startsWith(resolvedProject + path.sep) || resolvedFile === resolvedProject) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (resolvedFile.startsWith('/tmp/') || resolvedFile.startsWith('/tmp')) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write('Security: Cannot access "' + filePath + '" - outside project directory "' + projectPath + '"\n');
|
||||
process.exit(2);
|
||||
} catch (e) {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user