Hook Policy Engine
Installable hook policy packs for Claude Code. Each pack is a hardened bash script registered in .claude/settings.json. Install individually or all at once via /cc-hooks install.
1. Hook Events Reference
| Event | When it fires | stdin payload | stdout expected |
|---|---|---|---|
PreToolUse |
Before a tool call executes | {tool_name, tool_input} |
{"decision":"approve"} or {"decision":"block","reason":"..."} |
PostToolUse |
After a tool call completes | {tool_name, tool_input, tool_response} |
{"decision":"approve"} or {"decision":"block","reason":"..."} |
PostToolUseFailure |
After a tool call errors | {tool_name, tool_input, error} |
{"decision":"approve"} (errors are logged) |
Notification |
Claude sends a message | {message} |
{"decision":"approve"} |
Stop |
Claude is about to stop (end of turn) | {stop_reason} |
{"decision":"approve"} or {"decision":"block","reason":"..."} |
UserPromptSubmit |
User submits a prompt | {prompt} |
{"decision":"approve"} or {"decision":"block","reason":"..."} |
SessionStart |
New session begins | {session_id} |
{"decision":"approve"} |
stdin format for PreToolUse/PostToolUse:
{
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file",
"content": "..."
},
"tool_response": "..."
}
All hooks must exit 0. Non-zero exit codes cause the harness to log an error and default to approve.
2. If-Based Filtering (matcher field)
The matcher field in settings.json is a regex matched against tool_name. A hook only fires when the regex matches the tool being called.
Registration structure
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }
]
}
]
}
}
Common matcher patterns
| Matcher | Fires on |
|---|---|
"Write|Edit" |
Write or Edit tool calls only |
"Task" |
Task tool calls only (create, update, etc.) |
"Compact" |
Compaction events only |
"Bash" |
Bash tool calls only |
"Write|Edit|Bash" |
Write, Edit, or Bash |
"" |
All tool calls (no filter) |
"^(?!Bash)" |
Everything except Bash |
Multiple hooks under the same event+matcher execute in order. If any returns {"decision":"block"}, the operation is blocked. All hooks receive the same stdin JSON.
3. The 8 Packs
Pack 1: protect-sensitive-files
Event: PreToolUse
Matcher: "Write|Edit"
Purpose: Blocks writes to sensitive files before they happen.
settings.json registration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }]
}
]
}
}
Script: .claude/hooks/protect-sensitive-files.sh
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
if [ -z "$FILE" ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Resolve to absolute path for reliable matching
RESOLVED=$(realpath -m "$FILE" 2>/dev/null || echo "$FILE")
BLOCKED_PATTERNS=(
".env"
"*.key"
"*.pem"
"*.p12"
"*.pfx"
"*.crt"
"*.cer"
"secrets/"
"credentials"
".aws/credentials"
".ssh/id_rsa"
".ssh/id_ed25519"
".npmrc"
".pypirc"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
# Match against both resolved path and basename
BASENAME=$(basename "$RESOLVED")
case "$RESOLVED" in
*"$pattern"*) ;;
*) case "$BASENAME" in *"$pattern"*) ;; *) continue ;; esac ;;
esac
jq -n --arg f "$FILE" \
'{"decision":"block","reason":("Blocked write to sensitive file: "+$f+" — store secrets in environment variables or a secrets manager, never in source files")}'
exit 0
done
echo '{"decision":"approve"}'
Example behavior:
- Write to
.env→ blocked with reason - Edit to
config/app.ts→ approved - Write to
secrets/api-key.txt→ blocked
Pack 2: auto-format-after-edit
Event: PostToolUse
Matcher: "Write|Edit"
Purpose: Runs the appropriate formatter after every file write, keeping code style consistent without manual intervention.
settings.json registration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/auto-format-after-edit.sh" }]
}
]
}
}
Script: .claude/hooks/auto-format-after-edit.sh
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Security: reject path traversal outside project root
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
RESOLVED=$(realpath "$FILE" 2>/dev/null || echo "$FILE")
if [[ "$RESOLVED" != "$PROJECT_ROOT"* ]]; then
echo '{"decision":"approve"}'
exit 0
fi
EXT="${FILE##*.}"
case "$EXT" in
ts|tsx|js|jsx|mjs|cjs)
if command -v prettier &>/dev/null; then
prettier --write "$FILE" --log-level warn 2>/dev/null || true
fi
if command -v eslint &>/dev/null; then
eslint --fix "$FILE" --quiet 2>/dev/null || true
fi
;;
py)
if command -v black &>/dev/null; then
black --quiet "$FILE" 2>/dev/null || true
fi
if command -v isort &>/dev/null; then
isort --quiet "$FILE" 2>/dev/null || true
fi
;;
rs)
if command -v rustfmt &>/dev/null; then
rustfmt --edition 2021 "$FILE" 2>/dev/null || true
fi
;;
go)
if command -v gofmt &>/dev/null; then
gofmt -w "$FILE" 2>/dev/null || true
fi
;;
json)
if command -v prettier &>/dev/null; then
prettier --write "$FILE" --parser json --log-level warn 2>/dev/null || true
fi
;;
md|mdx)
if command -v prettier &>/dev/null; then
prettier --write "$FILE" --parser markdown --log-level warn 2>/dev/null || true
fi
;;
css|scss|less)
if command -v prettier &>/dev/null; then
prettier --write "$FILE" --log-level warn 2>/dev/null || true
fi
;;
*)
# No formatter for this extension — approve silently
;;
esac
echo '{"decision":"approve"}'
Example behavior:
- Write to
src/components/Button.tsx→ runs prettier then eslint --fix - Write to
api/handler.py→ runs black then isort - Write to
lib/parser.rs→ runs rustfmt - Write to
README.md→ runs prettier with markdown parser
Pack 3: stop-until-tests-pass
Event: Stop
Matcher: "" (all stop events)
Purpose: Prevents Claude from completing a turn if the test suite is currently failing. Forces fix-the-tests-first discipline.
settings.json registration:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/stop-until-tests-pass.sh" }]
}
]
}
}
Script: .claude/hooks/stop-until-tests-pass.sh
#!/usr/bin/env bash
set -euo pipefail
# Only run if package.json has a test script
if [ ! -f "package.json" ]; then
echo '{"decision":"approve"}'
exit 0
fi
TEST_SCRIPT=$(jq -r '.scripts.test // ""' package.json 2>/dev/null || echo "")
if [ -z "$TEST_SCRIPT" ] || [ "$TEST_SCRIPT" = "null" ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Skip if no test files exist (new project with stub test script)
TEST_FILE_COUNT=$(find . \( -name "*.test.ts" -o -name "*.test.js" -o -name "*.spec.ts" -o -name "*.spec.js" \) \
-not -path "*/node_modules/*" 2>/dev/null | wc -l || echo 0)
if [ "$TEST_FILE_COUNT" -eq 0 ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Run tests with timeout (120s max)
RESULT=$(timeout 120 npm test --silent 2>&1 || true)
# Count failure indicators (Vitest, Jest, Mocha, and generic patterns)
FAILURES=$(printf '%s' "$RESULT" | grep -cE "FAIL |failed|✗|× |Error:|AssertionError" 2>/dev/null || true)
if [ "$FAILURES" -gt 0 ]; then
SUMMARY=$(printf '%s' "$RESULT" | tail -10)
jq -n --arg summary "$SUMMARY" \
'{"decision":"block","reason":("Tests are failing — fix before completing:\n\n"+$summary)}'
exit 0
fi
echo '{"decision":"approve"}'
Example behavior:
- All tests pass → approved, Claude stops normally
- 3 tests fail → blocked, Claude told to fix them first
- No
package.json→ approved (non-Node projects unaffected) - No test files yet → approved (empty test suites skip)
Pack 4: post-compact-context-restoration
Event: PostToolUse
Matcher: "Compact"
Purpose: After context compaction, re-injects the active task description from .claude/active-task.md so Claude doesn't lose the thread of work in progress.
settings.json registration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Compact",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/post-compact-context-restoration.sh" }]
}
]
}
}
Script: .claude/hooks/post-compact-context-restoration.sh
#!/usr/bin/env bash
set -euo pipefail
TASK_FILE=".claude/active-task.md"
if [ -f "$TASK_FILE" ]; then
CONTENT=$(cat "$TASK_FILE")
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '\n[CONTEXT RESTORED AFTER COMPACT — %s]\n%s\n[END CONTEXT RESTORATION]\n' \
"$TIMESTAMP" "$CONTENT" >&2
fi
# Also check for in-progress work notes
NOTES_FILE=".claude/work-in-progress.md"
if [ -f "$NOTES_FILE" ]; then
NOTES=$(cat "$NOTES_FILE")
printf '\n[WORK IN PROGRESS]\n%s\n[END WORK IN PROGRESS]\n' "$NOTES" >&2
fi
echo '{"decision":"approve"}'
Workflow:
To use this pack effectively, maintain .claude/active-task.md with the current task description. Update it when starting a new major task:
# Active Task
## Goal
Refactor authentication module to use JWT refresh tokens.
## In Progress
- [x] Updated token generation
- [ ] Update token validation middleware
- [ ] Update frontend auth store
## Key Context
- Token endpoint: /api/auth/refresh
- Store: src/stores/authStore.ts
- Middleware: src/middleware/auth.ts
After every compaction, this content is printed to stderr, which the harness injects back into context.
Pack 5: direnv-reload-on-cwd-change
Event: UserPromptSubmit
Matcher: "" (all prompts)
Purpose: Ensures environment variables are always current by reloading .envrc via direnv before processing each prompt. Prevents stale env vars when switching between projects or after .envrc changes.
settings.json registration:
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/direnv-reload-on-cwd-change.sh" }]
}
]
}
}
Script: .claude/hooks/direnv-reload-on-cwd-change.sh
#!/usr/bin/env bash
set -euo pipefail
# Only run if direnv is installed and .envrc exists in current or parent dir
if ! command -v direnv &>/dev/null; then
echo '{"decision":"approve"}'
exit 0
fi
# Walk up to find .envrc
CUR="$PWD"
ENVRC_FOUND=false
while [ "$CUR" != "/" ]; do
if [ -f "$CUR/.envrc" ]; then
ENVRC_FOUND=true
break
fi
CUR=$(dirname "$CUR")
done
if [ "$ENVRC_FOUND" = false ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Allow and export — suppress output unless changed
direnv allow . 2>/dev/null || true
DIRENV_OUT=$(direnv export bash 2>/dev/null || true)
if [ -n "$DIRENV_OUT" ]; then
printf '[direnv] Environment reloaded from .envrc (%d chars exported)\n' "${#DIRENV_OUT}" >&2
fi
echo '{"decision":"approve"}'
Example behavior:
.envrcexists →direnv allow+ export on each promptdirenvnot installed → silently approved- No
.envrcanywhere up the tree → silently approved
Pack 6: task-created-governance
Event: PreToolUse
Matcher: "Task"
Purpose: Enforces minimum quality on task descriptions before subagents are spawned. Prevents "do the thing" tasks with no context, which produce poor agent output.
settings.json registration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Task",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/task-created-governance.sh" }]
}
]
}
}
Script: .claude/hooks/task-created-governance.sh
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
DESCRIPTION=$(printf '%s' "$INPUT" | jq -r '.tool_input.description // ""')
PROMPT=$(printf '%s' "$INPUT" | jq -r '.tool_input.prompt // ""')
# Use whichever field is populated
CONTENT="${DESCRIPTION:-$PROMPT}"
# Strip whitespace for length check
TRIMMED=$(printf '%s' "$CONTENT" | tr -d '[:space:]')
MIN_LENGTH=20
if [ "${#TRIMMED}" -lt "$MIN_LENGTH" ]; then
jq -n \
--arg len "${#TRIMMED}" \
--arg min "$MIN_LENGTH" \
'{"decision":"block","reason":("Task description is too short ("+$len+" chars, minimum "+$min+"). Add context: what needs to be done, what files are involved, and what success looks like.")}'
exit 0
fi
# Block obviously vague tasks
VAGUE_PATTERNS=("fix it" "do this" "handle this" "implement it" "make it work")
LOWER=$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')
for pattern in "${VAGUE_PATTERNS[@]}"; do
if [ "$LOWER" = "$pattern" ]; then
echo '{"decision":"block","reason":"Task description is too vague. Describe the specific change, the files involved, and the expected outcome."}'
exit 0
fi
done
echo '{"decision":"approve"}'
Example behavior:
- Task "fix" → blocked (too short)
- Task "fix it" → blocked (vague pattern)
- Task "Refactor the authentication middleware in src/middleware/auth.ts to use JWT refresh tokens instead of session cookies" → approved
Pack 7: task-completed-quality-gate
Event: PostToolUse
Matcher: "Task"
Purpose: After a subagent task completes, runs TypeScript type checking to catch type errors introduced by the agent before the turn ends.
settings.json registration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Task",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/task-completed-quality-gate.sh" }]
}
]
}
}
Script: .claude/hooks/task-completed-quality-gate.sh
#!/usr/bin/env bash
set -euo pipefail
# Only run if tsconfig exists
if [ ! -f "tsconfig.json" ]; then
echo '{"decision":"approve"}'
exit 0
fi
# Only run if TypeScript is installed
if ! command -v npx &>/dev/null; then
echo '{"decision":"approve"}'
exit 0
fi
# Run type check with timeout
TS_RESULT=$(timeout 60 npx tsc --noEmit 2>&1 || true)
TS_ERROR_COUNT=$(printf '%s' "$TS_RESULT" | grep -cE "error TS[0-9]+" 2>/dev/null || true)
if [ "$TS_ERROR_COUNT" -gt 0 ]; then
# Truncate to first 20 errors for readability
TS_SUMMARY=$(printf '%s' "$TS_RESULT" | grep -E "error TS[0-9]+" | head -20)
jq -n \
--arg count "$TS_ERROR_COUNT" \
--arg errors "$TS_SUMMARY" \
'{"decision":"block","reason":($count+" TypeScript error(s) introduced by task — fix before completing:\n\n"+$errors)}'
exit 0
fi
echo '{"decision":"approve"}'
Example behavior:
- Agent introduces 3 type errors → blocked, errors listed
- Agent's code is clean → approved
- No
tsconfig.json→ approved (JS-only projects unaffected) tsctimes out after 60s → approved (prevent infinite block)
Pack 8: teammate-idle-enforcement
Event: Stop
Matcher: "" (all stop events)
Purpose: Records each completion timestamp so external monitoring tools can detect when Claude has been idle for extended periods. Pairs with the loop skill for automated idle detection.
settings.json registration:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/teammate-idle-enforcement.sh" }]
}
]
}
}
Script: .claude/hooks/teammate-idle-enforcement.sh
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date -u +%s)
TIMESTAMP_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
IDLE_LOG="/tmp/claude-last-stop"
IDLE_THRESHOLD_MINUTES=10
# Atomic write using flock
(
flock -x 200
printf '%s\n' "$TIMESTAMP" > "$IDLE_LOG"
) 200>"${IDLE_LOG}.lock" 2>/dev/null || printf '%s\n' "$TIMESTAMP" > "$IDLE_LOG"
# Check if previous stop was recorded and calculate idle duration
PREV_STOP_FILE="/tmp/claude-prev-stop"
if [ -f "$PREV_STOP_FILE" ]; then
PREV_TS=$(cat "$PREV_STOP_FILE" 2>/dev/null || echo 0)
IDLE_SECONDS=$(( TIMESTAMP - PREV_TS ))
IDLE_MINUTES=$(( IDLE_SECONDS / 60 ))
if [ "$IDLE_MINUTES" -ge "$IDLE_THRESHOLD_MINUTES" ]; then
printf '[teammate-idle] Claude has been idle for %d minutes (since %s)\n' \
"$IDLE_MINUTES" "$TIMESTAMP_ISO" >&2
fi
fi
# Update previous stop timestamp
printf '%s\n' "$TIMESTAMP" > "$PREV_STOP_FILE"
echo '{"decision":"approve"}'
Example behavior:
- Normal completion → records timestamp, approves
- First stop of session → no idle warning
- Next stop 15 minutes later → logs "[teammate-idle] Claude has been idle for 15 minutes"
/tmp/claude-last-stopcontains Unix timestamp readable by external tools
External idle check script:
#!/usr/bin/env bash
IDLE_LOG="/tmp/claude-last-stop"
THRESHOLD=600 # 10 minutes in seconds
if [ -f "$IDLE_LOG" ]; then
LAST=$(cat "$IDLE_LOG")
NOW=$(date +%s)
DIFF=$(( NOW - LAST ))
if [ "$DIFF" -gt "$THRESHOLD" ]; then
echo "Claude idle for $(( DIFF / 60 )) minutes"
exit 1
fi
fi
echo "Active"
4. Composing Packs
Multiple packs stack in settings.json. Install order does not matter — all packs under the same event+matcher run in sequence:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }
]
},
{
"matcher": "Task",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/task-created-governance.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/auto-format-after-edit.sh" }
]
},
{
"matcher": "Compact",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/post-compact-context-restoration.sh" }
]
},
{
"matcher": "Task",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/task-completed-quality-gate.sh" }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/stop-until-tests-pass.sh" },
{ "type": "command", "command": "bash .claude/hooks/teammate-idle-enforcement.sh" }
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/direnv-reload-on-cwd-change.sh" }
]
}
]
}
}
Execution order within a matcher group
When two hooks share the same event+matcher, they run left-to-right in the hooks array. If stop-until-tests-pass blocks, teammate-idle-enforcement still runs (all hooks get a chance to execute — the block decision is applied after all hooks complete).
Recommended pack combinations
Safety-first setup (minimal overhead):
protect-sensitive-files— always-on PreToolUse guard
Active development (medium overhead):
protect-sensitive-filesauto-format-after-edittask-created-governance
CI-grade local enforcement (higher overhead):
- All of the above plus
stop-until-tests-passandtask-completed-quality-gate
Agent team setup (full pack):
--all— install everything
5. Custom Pack Template
Use this template to write a new hook pack from scratch.
Checklist
- Script starts with
#!/usr/bin/env bashandset -euo pipefail - Reads stdin exactly once into
INPUT=$(cat)— stdin is a stream, can only be read once - Uses
jq -rto extract fields from$INPUT— never string parsing - Returns valid JSON to stdout:
{"decision":"approve"}or{"decision":"block","reason":"..."} - Uses
jq -nwith--argfor constructing output JSON — never string concatenation - Validates file paths with
realpathwhen path traversal is a risk - Uses
flockfor atomic writes to shared files - Uses
|| trueafter commands that may legitimately fail (grep, find, etc.) - Exits 0 in all paths — non-zero exit defaults to
approvebut logs an error - Has a graceful "no-op" path for environments where the dependency is absent
Template
#!/usr/bin/env bash
# <pack-name>.sh — <one-line description>
# Event: <PreToolUse|PostToolUse|Stop|UserPromptSubmit|SessionStart>
# Matcher: "<regex|empty-for-all>"
set -euo pipefail
# Read stdin exactly once
INPUT=$(cat)
# --- Prerequisite checks ---
# Exit early if the environment doesn't support this pack
if ! command -v jq &>/dev/null; then
# jq is required for safe JSON parsing — approve without running
echo '{"decision":"approve"}'
exit 0
fi
# --- Extract relevant fields ---
# Adjust field names based on the event type:
# PreToolUse: .tool_name, .tool_input.<field>
# PostToolUse: .tool_name, .tool_input.<field>, .tool_response
# Stop: .stop_reason
# UserPromptSubmit: .prompt
FIELD=$(printf '%s' "$INPUT" | jq -r '.tool_input.some_field // ""')
# --- Guard: skip if irrelevant ---
if [ -z "$FIELD" ]; then
echo '{"decision":"approve"}'
exit 0
fi
# --- Main logic ---
if <condition that should block>; then
# Use jq -n with --arg to safely construct JSON (never string concatenation)
jq -n --arg reason "Blocked because: $FIELD fails the policy" \
'{"decision":"block","reason":$reason}'
exit 0
fi
# Default: approve
echo '{"decision":"approve"}'
Testing your pack
# Test approve path
echo '{"tool_name":"Write","tool_input":{"file_path":"src/index.ts","content":"..."}}' \
| bash .claude/hooks/my-pack.sh
# Test block path
echo '{"tool_name":"Write","tool_input":{"file_path":".env","content":"SECRET=abc"}}' \
| bash .claude/hooks/my-pack.sh
# Validate output is valid JSON
echo '{"tool_name":"Write","tool_input":{"file_path":".env"}}' \
| bash .claude/hooks/my-pack.sh | jq .
Registering in settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/my-pack.sh" }
]
}
]
}
}
Use /cc-hooks test .claude/hooks/my-pack.sh after writing to validate both paths with the built-in harness test runner.