Homepage (site/index.html): integration-v14 promoted, Writings section integrated with 33 pieces clustered by type (stories/essays/miscellany), Writings welcome lightbox, content frame at 98% opacity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
10 KiB
Markdown
367 lines
10 KiB
Markdown
# Hook protocol
|
|
|
|
Claude Code hooks are executables that intercept agent lifecycle events. They
|
|
require no SDK or external library — the protocol is just stdin JSON in,
|
|
exit code out. This file contains everything needed to generate correct hooks
|
|
in Python (stdlib only) or JavaScript (Node.js, no packages).
|
|
|
|
---
|
|
|
|
## The protocol
|
|
|
|
Claude Code spawns the hook executable, writes a JSON event to its stdin, and
|
|
reads the exit code and stdout when it exits.
|
|
|
|
**Exit codes:**
|
|
|
|
- `0` — allow / success. Claude Code continues. Any stdout is added to context.
|
|
- `1` — non-blocking error. Claude Code sees the message and continues anyway.
|
|
- `2` — blocking deny. Claude Code stops the action and shows the reason.
|
|
|
|
**JSON control output** (optional, write to stdout as valid JSON):
|
|
|
|
For `PreToolUse` hooks, you can output a JSON object instead of relying solely
|
|
on the exit code. This gives you a reason string that Claude Code surfaces to
|
|
the agent:
|
|
|
|
```json
|
|
{"decision": "block", "reason": "Credential file write blocked by spore policy"}
|
|
```
|
|
|
|
```json
|
|
{"decision": "approve"}
|
|
```
|
|
|
|
For `PostToolUse` hooks, stdout text (not JSON) is injected into the agent's
|
|
context as tool result feedback.
|
|
|
|
**Input event shape** (arrives on stdin as a single JSON line):
|
|
|
|
```json
|
|
{
|
|
"tool_name": "Write",
|
|
"tool_input": {
|
|
"file_path": "path/to/file",
|
|
"content": "file content"
|
|
},
|
|
"tool_response": "...",
|
|
"session_id": "abc123"
|
|
}
|
|
```
|
|
|
|
`tool_response` is only present on `PostToolUse` events.
|
|
`tool_input` shape varies by tool — see field names below.
|
|
|
|
**Common tool_input fields by tool:**
|
|
|
|
- `Write` / `Edit` / `MultiEdit`: `file_path`, `content`
|
|
- `Bash`: `command`
|
|
- `Read`: `file_path`
|
|
- `WebFetch`: `url`
|
|
|
|
---
|
|
|
|
## Python template (stdlib only)
|
|
|
|
Use this for projects that have Python available. Zero dependencies — no pip
|
|
install, no virtualenv. Copy, rename, fill in the guard logic.
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
# hooks/{hook-name}.py
|
|
# Spore: {spore-identifier}
|
|
# {one-line description of what this hook guards}
|
|
#
|
|
# Protocol: reads JSON event from stdin, exits 0 (allow) or 2 (deny).
|
|
# No external dependencies — stdlib only.
|
|
|
|
import json
|
|
import sys
|
|
|
|
|
|
def main() -> None:
|
|
# Read the full event from stdin
|
|
try:
|
|
event = json.loads(sys.stdin.read())
|
|
except (json.JSONDecodeError, OSError):
|
|
# If we can't read the event, allow and let the agent handle it
|
|
sys.exit(0)
|
|
|
|
tool_name = event.get("tool_name", "")
|
|
tool_input = event.get("tool_input", {})
|
|
|
|
# --- Guard logic starts here ---
|
|
# Replace this block with the compiled spore decision.
|
|
# Example: block writes to credential files.
|
|
if tool_name == "Write":
|
|
file_path = tool_input.get("file_path", "")
|
|
BLOCKED = {".env", "secrets.json", "id_rsa", ".pem", ".key"}
|
|
if any(pattern in file_path for pattern in BLOCKED):
|
|
# Write structured denial to stdout for Claude Code to surface
|
|
print(json.dumps({
|
|
"decision": "block",
|
|
"reason": f"Credential file protected by spore policy: {file_path}"
|
|
}))
|
|
sys.exit(2)
|
|
# --- Guard logic ends here ---
|
|
|
|
# Default: allow
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## JavaScript template (Node.js, no packages)
|
|
|
|
Use this for TypeScript/JavaScript projects, or any project where Node.js is
|
|
available (Claude Code itself runs on Node so it is always present). Zero
|
|
dependencies — pure Node.js stdlib.
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
// hooks/{hook-name}.js
|
|
// Spore: {spore-identifier}
|
|
// {one-line description of what this hook guards}
|
|
//
|
|
// Protocol: reads JSON event from stdin, exits 0 (allow) or 2 (deny).
|
|
// No external dependencies — Node.js stdlib only.
|
|
|
|
async function main() {
|
|
// Read the full event from stdin
|
|
let raw = "";
|
|
for await (const chunk of process.stdin) {
|
|
raw += chunk;
|
|
}
|
|
|
|
let event;
|
|
try {
|
|
event = JSON.parse(raw);
|
|
} catch {
|
|
// If we can't parse the event, allow and let the agent handle it
|
|
process.exit(0);
|
|
}
|
|
|
|
const toolName = event.tool_name ?? "";
|
|
const toolInput = event.tool_input ?? {};
|
|
|
|
// --- Guard logic starts here ---
|
|
// Replace this block with the compiled spore decision.
|
|
// Example: block writes to credential files.
|
|
if (toolName === "Write") {
|
|
const filePath = toolInput.file_path ?? "";
|
|
const blocked = [".env", "secrets.json", "id_rsa", ".pem", ".key"];
|
|
if (blocked.some((pattern) => filePath.includes(pattern))) {
|
|
// Write structured denial to stdout for Claude Code to surface
|
|
process.stdout.write(JSON.stringify({
|
|
decision: "block",
|
|
reason: `Credential file protected by spore policy: ${filePath}`,
|
|
}));
|
|
process.exit(2);
|
|
}
|
|
}
|
|
// --- Guard logic ends here ---
|
|
|
|
// Default: allow
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch(() => process.exit(0)); // On unexpected error, fail open
|
|
```
|
|
|
|
---
|
|
|
|
## PostToolUse validator template (Python)
|
|
|
|
PostToolUse hooks receive the completed tool result and can inject feedback
|
|
into the agent's context. Use for output format validation, quality checks,
|
|
or logging.
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
# hooks/{hook-name}-post.py
|
|
# Spore: {spore-identifier}
|
|
# Validates output after a tool completes. Injects feedback into agent context.
|
|
|
|
import json
|
|
import sys
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
event = json.loads(sys.stdin.read())
|
|
except (json.JSONDecodeError, OSError):
|
|
sys.exit(0)
|
|
|
|
tool_name = event.get("tool_name", "")
|
|
tool_input = event.get("tool_input", {})
|
|
# tool_response contains what the tool returned
|
|
tool_response = event.get("tool_response", "")
|
|
|
|
# --- Validation logic starts here ---
|
|
# Example: verify annotation output is valid JSONL
|
|
if tool_name == "Write":
|
|
file_path = tool_input.get("file_path", "")
|
|
if file_path.endswith(".annotation"):
|
|
content = tool_input.get("content", "")
|
|
invalid = []
|
|
for i, line in enumerate(content.strip().splitlines(), 1):
|
|
if line.strip():
|
|
try:
|
|
json.loads(line)
|
|
except json.JSONDecodeError:
|
|
invalid.append(i)
|
|
if invalid:
|
|
# Exit 2 to block; message shown to agent
|
|
print(json.dumps({
|
|
"decision": "block",
|
|
"reason": f"Output format violation: lines {invalid} are not valid JSON. "
|
|
f"Annotation output must be JSONL (one JSON object per line)."
|
|
}))
|
|
sys.exit(2)
|
|
# --- Validation logic ends here ---
|
|
|
|
# Exit 0; any stdout text gets injected into agent context as feedback
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## SessionStart context loader template (Python)
|
|
|
|
Loads the spore file into session context on startup so the scion has its
|
|
policy available from the first turn.
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
# hooks/session-start.py
|
|
# Loads SCION_SPORE_FILE contents into session context on startup.
|
|
# The commissioning skill sets SCION_SPORE_FILE at spawn time.
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
event = json.loads(sys.stdin.read())
|
|
except (json.JSONDecodeError, OSError):
|
|
sys.exit(0)
|
|
|
|
# Only act on startup, not resume or clear
|
|
if event.get("source") != "startup":
|
|
sys.exit(0)
|
|
|
|
spore_file = os.environ.get("SCION_SPORE_FILE", "")
|
|
if not spore_file or not os.path.exists(spore_file):
|
|
# No spore file configured — allow and continue
|
|
sys.exit(0)
|
|
|
|
try:
|
|
with open(spore_file) as f:
|
|
content = f.read()
|
|
# Print to stdout — SessionStart hook output is added to session context
|
|
print(f"Spore policy active ({spore_file}):\n\n{content}")
|
|
except OSError:
|
|
pass # Fail open — don't block startup if spore file unreadable
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## Registration in .claude/settings.json
|
|
|
|
Register hooks at the module level (`.claude/settings.json` in the module
|
|
folder) rather than globally. This keeps hook scope aligned with spore scope.
|
|
|
|
```json
|
|
{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "Write",
|
|
"hooks": [
|
|
{ "type": "command", "command": "python hooks/env-guard.py" }
|
|
]
|
|
},
|
|
{
|
|
"matcher": "Bash",
|
|
"hooks": [
|
|
{ "type": "command", "command": "python hooks/bash-guard.py" }
|
|
]
|
|
}
|
|
],
|
|
"PostToolUse": [
|
|
{
|
|
"matcher": "Write",
|
|
"hooks": [
|
|
{ "type": "command", "command": "python hooks/output-validator-post.py" }
|
|
]
|
|
}
|
|
],
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "python hooks/session-start.py" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
For Node.js hooks, replace `python hooks/name.py` with `node hooks/name.js`.
|
|
For executable hooks (chmod +x), the command can be the file path directly.
|
|
|
|
---
|
|
|
|
## Choosing Python vs JavaScript
|
|
|
|
Use Python when the project uses Python or when no preference is stated —
|
|
Python stdlib is universally available and the syntax reads clearly to most
|
|
agents. Use JavaScript (Node.js) when the project is a TypeScript/JavaScript
|
|
codebase, or when the hook needs to share logic with existing project tooling.
|
|
The bartolli pattern (`.claude/hooks/{project-type}/quality-check.js`) is
|
|
worth adopting when multiple hook types share configuration — see that repo
|
|
for a mature example of project-type-aware Node.js hooks with SHA256 config
|
|
caching for performance.
|
|
|
|
Either language produces identical protocol behavior. The hook protocol does
|
|
not care what generates the exit code.
|
|
|
|
---
|
|
|
|
## Validation before registration
|
|
|
|
Every generated hook must pass two test cases before the commissioning skill
|
|
registers it: one that should trigger the deny condition, and one that should
|
|
allow cleanly. Run manually:
|
|
|
|
```bash
|
|
# Should exit 2 (deny) — triggers the guard
|
|
echo '{"tool_name":"Write","tool_input":{"file_path":".env","content":"SECRET=x"}}' \
|
|
| python hooks/env-guard.py
|
|
echo "Exit code: $?"
|
|
|
|
# Should exit 0 (allow) — safe path
|
|
echo '{"tool_name":"Write","tool_input":{"file_path":"output.txt","content":"hello"}}' \
|
|
| python hooks/env-guard.py
|
|
echo "Exit code: $?"
|
|
```
|
|
|
|
If either test fails, do not register the hook or reference it in a spore entry.
|
|
Fix first, validate again, then deposit.
|