
Agents Hooks
- 106 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
Helps with ai & agent building tasks.
About
agents-hooks is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- agents-hooks
- AI & Agent Building
- AI-coding skill
Agents Hooks by the numbers
- 106 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #4,152 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill agents-hooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 106 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
What it does
Helps with ai & agent building tasks.
Files
Claude Code Hooks — Meta Reference
This skill provides the definitive reference for creating Claude Code hooks. Use this when building automation that triggers on Claude Code events.
---
When to Use This Skill
- Building event-driven automation for Claude Code
- Creating PreToolUse guards to block dangerous commands
- Implementing PostToolUse formatters, linters, or auditors
- Adding Stop hooks for testing or notifications
- Setting up SessionStart/SessionEnd for environment management
- Integrating Claude Code with CI/CD pipelines (headless mode)
---
Quick Reference
| Event | Trigger | Use Case |
|---|---|---|
SessionStart | Session begins/resumes | Initialize environment |
UserPromptSubmit | User submits prompt | Preprocess/validate input |
PreToolUse | Before tool execution | Validate, block dangerous commands |
PermissionRequest | Permission dialog shown | Auto-allow/deny permissions |
PostToolUse | After tool succeeds | Format, audit, notify |
PostToolUseFailure | After tool fails | Capture failures, add guidance |
SubagentStart | Subagent spawns | Inspect subagent metadata |
Stop | When Claude finishes | Run tests, summarize |
SubagentStop | Subagent finishes | Verify subagent completion |
Notification | On notifications | Alert integrations |
PreCompact | Before context compaction | Preserve critical context |
Setup | --init/--maintenance | Initialize repo/env |
SessionEnd | Session ends | Cleanup, save state |
Hook Structure
.claude/hooks/
├── pre-tool-validate.sh
├── post-tool-format.sh
├── post-tool-audit.sh
├── stop-run-tests.sh
└── session-start-init.sh---
Configuration
settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-format.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-validate.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-run-tests.sh"
}
]
}
]
}
}---
Execution Model (Jan 2026)
- Hooks receive a JSON payload via stdin (treat it as untrusted input) and run with your user permissions (outside the Bash tool sandbox).
- Default timeout is 60s per hook command; all matching hooks run in parallel; identical commands are deduplicated.
Hook Input (stdin)
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "ls -la"
}
}Environment Variables (shell)
| Variable | Description |
|---|---|
CLAUDE_PROJECT_DIR | Absolute project root where Claude Code started |
CLAUDE_PLUGIN_ROOT | Plugin root (plugin hooks only) |
CLAUDE_CODE_REMOTE | "true" in remote/web environments; empty/local otherwise |
CLAUDE_ENV_FILE | File path to persist export ... lines (available in SessionStart; check docs for Setup support) |
---
Exit Codes
| Code | Meaning | Notes |
|---|---|---|
0 | Success | JSON written to stdout is parsed for structured control |
2 | Blocking error | stderr becomes the message; JSON in stdout is ignored |
| Other | Non-blocking error | Execution continues; stderr is visible in verbose mode |
Stdout injection note: for UserPromptSubmit, SessionStart, and Setup, non-JSON stdout (exit 0) is injected into Claude’s context; most other events show stdout only in verbose mode.
---
Decision Control + Input Modification (v2.0.10+)
PreToolUse hooks can allow/deny/ask and optionally modify the tool input via updatedInput.
Hook Output Schema
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Reason shown to user (and to Claude on deny)",
"updatedInput": { "command": "echo 'modified'" },
"additionalContext": "Extra context added before tool runs"
}
}Note: older decision/reason fields are deprecated; prefer the hookSpecificOutput.* fields.
See hook-templates.md for full examples: redirect sensitive file edits to /dev/null and strip .env files from git add commands.
---
Prompt-Based Hooks
For complex decisions, use LLM-evaluated hooks (type: "prompt") instead of bash scripts. They are most useful for Stop and SubagentStop decisions.
Configuration
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate whether Claude should stop. Context JSON: $ARGUMENTS. Return {\"ok\": true} if all tasks are complete, otherwise {\"ok\": false, \"reason\": \"what remains\"}.",
"timeout": 30
}
]
}
]
}
}Response Schema
- Allow:
{"ok": true} - Block:
{"ok": false, "reason": "Explanation shown to Claude"}
Combining Command and Prompt Hooks
Use command hooks for fast, deterministic checks. Use prompt hooks for nuanced decisions:
{
"Stop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/quick-check.sh" },
{ "type": "prompt", "prompt": "Verify code quality meets standards" }
]
}
]
}---
Permission Prompt Fatigue Reduction Pattern
Use this when frequent approval dialogs slow down repeated safe workflows.
Strategy
1. Identify repetitive, low-risk command prefixes (for example: test runners, read-only diagnostics). 2. Approve narrow prefixes instead of full commands. 3. Keep destructive or broad shells (rm, git reset --hard, generic interpreters with arbitrary input) out of auto-approval rules. 4. Re-check approved prefixes periodically; remove stale ones.
Practical Guardrails
- Allow only task-scoped prefixes (example:
npm run test:e2e), not unrestricted executors. - Keep separate policy for write-outside-workspace actions.
- Pair allow-rules with deny-rules for dangerous patterns.
Outcome
This reduces repeated permission interruptions while preserving high-safety boundaries.
Runtime Preflight Hooks (Mandatory for Tool Reliability)
Add a lightweight runtime preflight hook when workflows depend on specific local tool versions (for example Node for JS REPL, test runners, linters).
Preflight Responsibilities
- Verify required binaries exist.
- Verify minimum version constraints.
- Emit a clear remediation message when requirements are not met.
- Fail fast before expensive task execution starts.
Recommended Trigger
SessionStartfor general runtime checks.Setupfor repository bootstrap checks.
Minimal Policy
- Keep checks deterministic and fast (<1s target).
- Do not auto-install dependencies silently in hooks.
- Print exact command the operator should run to remediate.
Hook Templates
Copy-paste templates for the five most common hook scenarios: PreToolUse validation, PostToolUse formatting, PostToolUse security audit, Stop test runner, and SessionStart environment check.
See references/hook-templates.md for all scripts.
---
Matchers
Matchers filter which tool triggers the hook:
- Exact match:
Writematches only the Write tool - Regex:
Edit|WriteorNotebook.* - Match all:
*(also works with""or omitted matcher)
---
Security Best Practices
Hooks run with full user permissions outside the Bash tool sandbox. Key rules: validate all stdin input, quote every variable ("$VAR"), use absolute paths, never eval untrusted data, and set -euo pipefail.
See references/hook-security.md for the full checklist, command injection prevention, path traversal defense, credential protection, and ShellCheck requirements.
---
Hook Composition
Multiple Hooks on Same Event
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/format.sh" },
{ "type": "command", "command": ".claude/hooks/audit.sh" },
{ "type": "command", "command": ".claude/hooks/notify.sh" }
]
}
]
}All matching hooks run in parallel. If you need strict ordering (format → lint → test), make one wrapper script that runs them sequentially.
---
Debugging Hooks
# Test a PostToolUse hook manually (stdin JSON)
export CLAUDE_PROJECT_DIR="$(pwd)"
echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"'"$(pwd)"'/src/app.ts"}}' \
| bash .claude/hooks/post-tool-format.sh
# Check exit code
echo $?---
Navigation
Resources
- references/hook-templates.md — Copy-paste hook scripts
- references/hook-patterns.md — Common patterns
- references/hook-security.md — Security guide
- references/runtime-preflight-hooks.md — Runtime/version preflight patterns for SessionStart and Setup
- data/sources.json — Documentation links
- assets/template-preflight-runtime-hook.sh — Shell hook template for runtime/tool version checks
Related Skills
- ../agents-subagents/SKILL.md — Agent creation
- ../agents-skills/SKILL.md — Skill creation
- ../ops-devops-platform/SKILL.md — CI/CD integration
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
#!/usr/bin/env bash
set -euo pipefail
# Template: SessionStart runtime preflight hook
# Usage: configure in hooks settings as command hook for SessionStart
MIN_NODE_MAJOR=22
MIN_NODE_MINOR=22
fail() {
echo "$1" >&2
exit 2
}
if ! command -v node >/dev/null 2>&1; then
fail "Runtime preflight failed: node not found. Install Node >= 22.22.0"
fi
NODE_VERSION_RAW="$(node -v | sed 's/^v//')"
NODE_MAJOR="${NODE_VERSION_RAW%%.*}"
NODE_MINOR="$(echo "$NODE_VERSION_RAW" | cut -d. -f2)"
if [[ "$NODE_MAJOR" -lt "$MIN_NODE_MAJOR" ]] || { [[ "$NODE_MAJOR" -eq "$MIN_NODE_MAJOR" ]] && [[ "$NODE_MINOR" -lt "$MIN_NODE_MINOR" ]]; }; then
fail "Runtime preflight failed: node v${NODE_VERSION_RAW} detected, requires >= v${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.0. Run: nvm install 22.22.0 && nvm use 22.22.0"
fi
# Add additional tool checks below if needed
# command -v jq >/dev/null 2>&1 || fail "jq is required"
echo "Runtime preflight ok: node v${NODE_VERSION_RAW}"
exit 0
{
"metadata": {
"title": "Claude Code Hooks - Sources",
"description": "Official documentation and community resources for Claude Code event hooks",
"last_updated": "2026-01-21",
"skill": "agents-hooks"
},
"official_documentation": [
{
"name": "Claude Code Hooks Reference",
"url": "https://code.claude.com/docs/en/hooks.md",
"description": "Official hooks reference with events, environment variables, and exit codes",
"add_as_web_search": true
},
{
"name": "Claude Code Hooks Guide",
"url": "https://code.claude.com/docs/en/hooks-guide.md",
"description": "Getting started guide with input modification examples",
"add_as_web_search": true
},
{
"name": "Claude Code Best Practices",
"url": "https://code.claude.com/docs/en/best-practices.md",
"description": "Best practices for configuring and operating Claude Code",
"add_as_web_search": true
},
{
"name": "Claude Code Plugins",
"url": "https://code.claude.com/docs/en/plugins.md",
"description": "Plugin system reference (skills, agents, hooks, and MCP components)",
"add_as_web_search": true
},
{
"name": "Claude Code Changelog",
"url": "https://code.claude.com/docs/en/changelog.md",
"description": "Official changelog with hook feature updates",
"add_as_web_search": false
},
{
"name": "Claude Code Overview",
"url": "https://code.claude.com/docs/en/overview.md",
"description": "Official overview and documentation hub",
"add_as_web_search": false
}
],
"community_resources": [
{
"name": "Claude Code Hooks Mastery",
"url": "https://github.com/disler/agents-hooks-mastery",
"description": "Community examples and patterns for hooks",
"add_as_web_search": false
},
{
"name": "DataCamp Claude Code Hooks Tutorial",
"url": "https://www.datacamp.com/tutorial/agents-hooks",
"description": "Practical guide with prompt-based hooks examples",
"add_as_web_search": false
},
{
"name": "eesel.ai Hooks Guide",
"url": "https://www.eesel.ai/blog/hooks-in-claude-code",
"description": "Complete workflow automation guide",
"add_as_web_search": false
},
{
"name": "Builder.io Claude Code Tips",
"url": "https://www.builder.io/blog/claude-code",
"description": "Practical tips including context window optimization",
"add_as_web_search": false
},
{
"name": "Awesome Claude Code",
"url": "https://github.com/hesreallyhim/awesome-claude-code",
"description": "Curated list of skills, hooks, commands, and plugins",
"add_as_web_search": false
}
],
"security_references": [
{
"name": "Bash Pitfalls",
"url": "https://mywiki.wooledge.org/BashPitfalls",
"description": "Common bash scripting mistakes and security issues",
"add_as_web_search": false
},
{
"name": "ShellCheck",
"url": "https://www.shellcheck.net/",
"description": "Static analysis tool for shell scripts (mandatory for hooks)",
"add_as_web_search": false
},
{
"name": "Google Shell Style Guide",
"url": "https://google.github.io/styleguide/shellguide.html",
"description": "Shell scripting best practices including 50-line guideline",
"add_as_web_search": false
},
{
"name": "MIT Safe Shell Guide",
"url": "https://sipb.mit.edu/doc/safe-shell/",
"description": "Writing safe shell scripts from MIT SIPB",
"add_as_web_search": false
}
]
}
Hook Patterns
Common patterns for Claude Code hooks.
Assumptions (Jan 2026):
- Hook input arrives as JSON via stdin (parse with
jq). - The only reliable hook env var is
CLAUDE_PROJECT_DIR(plus plugin/session vars likeCLAUDE_CODE_REMOTEwhen applicable).
---
CI/CD Integration
Headless Mode for Automation
# Pre-commit hook
claude -p "Review staged changes for issues" --output-format stream-json
# GitHub Actions
claude -p "Analyze PR #$PR_NUMBER for security issues" \
--allowedTools "Read,Grep,Glob" \
--output-format jsonPre-Commit Hook Script
#!/bin/bash
# .git/hooks/pre-commit
set -euo pipefail
# Get staged files
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
if [[ -z "$STAGED" ]]; then
exit 0
fi
# Run Claude analysis
claude -p "Check these files for issues: $STAGED" \
--allowedTools "Read,Grep" \
--max-turns 3
exit $?---
Multi-Hook Composition
Chain: Format → Lint → Test
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/01-format.sh" },
{ "type": "command", "command": ".claude/hooks/02-lint.sh" },
{ "type": "command", "command": ".claude/hooks/03-typecheck.sh" }
]
}
]
}All matching hooks run in parallel. If you need strict ordering, run one wrapper hook that sequences sub-steps:
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
printf '%s' "$INPUT" | "$CLAUDE_PROJECT_DIR/.claude/hooks/01-format.sh"
printf '%s' "$INPUT" | "$CLAUDE_PROJECT_DIR/.claude/hooks/02-lint.sh"
printf '%s' "$INPUT" | "$CLAUDE_PROJECT_DIR/.claude/hooks/03-typecheck.sh"Context Window Optimization
Problem: Every PostToolUse formatting change triggers a system reminder to Claude. Aggressive formatting wastes context tokens that could be doing useful work.
Solution: Format on commit, not on every edit.
{
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format-on-commit.sh"
}
]
}
]
}#!/bin/bash
# format-on-commit.sh - Only format when committing
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Only run formatter on git commit
if [[ "$COMMAND" =~ ^git\ commit ]]; then
# Format all staged files
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
for file in $STAGED; do
case "${file##*.}" in
ts|tsx|js|jsx|json) npx prettier --write "$file" 2>/dev/null ;;
py) ruff format "$file" 2>/dev/null ;;
esac
done
fi
exit 0Alternative: Use a pre-commit git hook instead of PostToolUse.
Parallel Execution Pattern
#!/bin/bash
# Run checks in parallel, collect results
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
pids=()
results=()
# Start parallel jobs
prettier --check "$FILE_PATH" &
pids+=($!)
eslint "$FILE_PATH" &
pids+=($!)
# Wait and collect
for pid in "${pids[@]}"; do
wait "$pid" && results+=(0) || results+=($?)
done
# Fail if any failed
for r in "${results[@]}"; do
[[ "$r" -ne 0 ]] && exit 1
done
exit 0---
Notification Patterns
Slack Notification on Stop
#!/bin/bash
# stop-notify-slack.sh
set -euo pipefail
WEBHOOK_URL="${SLACK_WEBHOOK:-}"
[[ -z "$WEBHOOK_URL" ]] && exit 0
MESSAGE="Claude completed session in $(basename "$CLAUDE_PROJECT_DIR")"
curl -s -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"$MESSAGE\"}" \
>/dev/null
exit 0Desktop Notification (macOS)
#!/bin/bash
# stop-notify-desktop.sh
set -euo pipefail
osascript -e "display notification \"Task completed\" with title \"Claude Code\""
exit 0Log to File
#!/bin/bash
# post-tool-log.sh
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
LOG_FILE="$CLAUDE_PROJECT_DIR/.claude/audit.log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "[$TIMESTAMP] Tool: $TOOL_NAME, File: $FILE_PATH" >> "$LOG_FILE"
exit 0---
Error Recovery Patterns
Retry on Transient Failure
#!/bin/bash
set -euo pipefail
MAX_RETRIES=3
RETRY_DELAY=1
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
for i in $(seq 1 $MAX_RETRIES); do
if npm run lint -- "$FILE_PATH"; then
exit 0
fi
[[ $i -lt $MAX_RETRIES ]] && sleep $RETRY_DELAY
done
echo "Failed after $MAX_RETRIES retries"
exit 1Fallback Command
#!/bin/bash
set -euo pipefail
# Common case: PostToolUse with matcher Edit|Write
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
# Try prettier, fall back to eslint --fix
if command -v prettier &>/dev/null; then
prettier --write "$FILE_PATH"
elif command -v eslint &>/dev/null; then
eslint --fix "$FILE_PATH"
else
echo "No formatter available"
fi
exit 0---
Conditional Execution
By File Type
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
case "${FILE_PATH##*.}" in
ts|tsx|js|jsx) npx prettier --write "$FILE_PATH" ;;
py) ruff format "$FILE_PATH" ;;
go) gofmt -w "$FILE_PATH" ;;
rs) rustfmt "$FILE_PATH" ;;
*) echo "Skipping: $FILE_PATH" ;;
esac
exit 0By Directory
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
case "$FILE_PATH" in
src/*) npm run lint -- "$FILE_PATH" ;;
tests/*) npm run test -- "$FILE_PATH" ;;
docs/*) npx markdownlint "$FILE_PATH" ;;
esac
exit 0Skip Certain Files
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
SKIP_PATTERNS="node_modules|dist|.min.|vendor"
if echo "$FILE_PATH" | grep -qE "$SKIP_PATTERNS"; then
exit 0
fi
prettier --write "$FILE_PATH"
exit 0---
Environment Setup
Session Start: Verify Prerequisites
#!/bin/bash
set -euo pipefail
cd "$CLAUDE_PROJECT_DIR"
# Check Node.js version
if command -v node &>/dev/null; then
NODE_VERSION=$(node -v | cut -d. -f1 | tr -d 'v')
[[ $NODE_VERSION -lt 18 ]] && echo "WARNING: Node.js 18+ recommended"
fi
# Check dependencies
[[ -f "package.json" && ! -d "node_modules" ]] && echo "NOTE: Run npm install"
[[ -f "requirements.txt" && ! -d ".venv" ]] && echo "NOTE: Create virtualenv"
# Check git state
if git rev-parse --git-dir &>/dev/null; then
BRANCH=$(git branch --show-current)
echo "Branch: $BRANCH"
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
echo "WARNING: Working on protected branch"
fi
fi
exit 0---
Cost Tracking
Token Usage Logger
#!/bin/bash
# stop-token-log.sh
set -euo pipefail
INPUT="$(cat)"
SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // empty')"
LOG_FILE="$CLAUDE_PROJECT_DIR/.claude/token-usage.csv"
TIMESTAMP=$(date -u +"%Y-%m-%d")
# Create header if new file
[[ ! -f "$LOG_FILE" ]] && echo "date,session_id,project" > "$LOG_FILE"
echo "$TIMESTAMP,$SESSION_ID,$(basename "$CLAUDE_PROJECT_DIR")" >> "$LOG_FILE"
exit 0---
Navigation
- SKILL.md - Main reference
- hook-security.md - Security guide
Hook Security Guide
Security best practices for Claude Code hooks. Hooks run with full user permissions—no sandboxing.
Assumptions (Jan 2026):
- Hook input arrives as JSON via stdin (parse with
jq). - Don’t assume
CLAUDE_TOOL_NAME,CLAUDE_TOOL_INPUT,CLAUDE_FILE_PATHS, orCLAUDE_SESSION_IDexist as environment variables; derive from stdin instead.
---
Critical Rules
HOOK SECURITY CHECKLIST
[x] Validate all inputs with regex
[x] Quote all variables: "$VAR" not $VAR
[x] Use absolute paths
[x] No eval with untrusted input
[x] Set -euo pipefail at top
[x] Keep hooks fast (<1 second)
[x] Log actions for audit
[x] Test manually before deploying---
Command Injection Prevention
Dangerous Pattern
# NEVER do this - command injection risk
eval "$(cat)"
bash -c "$USER_PROVIDED_COMMAND"
$(echo "$UNTRUSTED_DATA")Safe Pattern
#!/bin/bash
set -euo pipefail
# Validate input is safe before use (example: file path from an Edit/Write hook)
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
# Only allow alphanumeric, dash, underscore, dot, slash
if [[ -z "$FILE_PATH" || ! "$FILE_PATH" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo "ERROR: Invalid characters in input" >&2
exit 2
fi
# Now safe to use
cat "$FILE_PATH"Allowlist Approach
#!/bin/bash
set -euo pipefail
COMMAND="$1"
# Only allow specific commands
case "$COMMAND" in
lint) npm run lint ;;
test) npm test ;;
build) npm run build ;;
*)
echo "ERROR: Unknown command: $COMMAND" >&2
exit 2
;;
esac---
Path Traversal Defense
Dangerous Pattern
# Attacker could pass: "../../../etc/passwd"
cat "$CLAUDE_PROJECT_DIR/$USER_FILE"Safe Pattern
#!/bin/bash
set -euo pipefail
FILE="$1"
# Block path traversal
if [[ "$FILE" == *".."* ]]; then
echo "ERROR: Path traversal detected" >&2
exit 2
fi
# Resolve to absolute and verify within project
RESOLVED=$(realpath -m "$CLAUDE_PROJECT_DIR/$FILE")
if [[ "$RESOLVED" != "$CLAUDE_PROJECT_DIR"/* ]]; then
echo "ERROR: Path outside project" >&2
exit 2
fi
# Now safe
cat "$RESOLVED"Canonical Path Check
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
validate_path() {
local file="$1"
local base="$CLAUDE_PROJECT_DIR"
# Get canonical paths
local canonical_base=$(cd "$base" && pwd -P)
local canonical_file=$(cd "$(dirname "$file")" 2>/dev/null && pwd -P)/$(basename "$file")
# Must be under project root
if [[ "$canonical_file" != "$canonical_base"/* ]]; then
return 1
fi
return 0
}
if ! validate_path "$FILE_PATH"; then
echo "ERROR: Invalid path: $FILE_PATH" >&2
exit 2
fi---
Variable Quoting
Dangerous Pattern
# Word splitting and glob expansion risk
rm $FILE
cat $FILE_PATHSafe Pattern
#!/bin/bash
set -euo pipefail
# Always quote variables
rm "$FILE"
# For paths derived from stdin JSON, avoid word splitting:
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -n "$FILE_PATH" ]] && cat "$FILE_PATH"---
Credential Protection
Never Log Secrets
#!/bin/bash
set -euo pipefail
# WRONG: May expose secrets
echo "Input: $(cat)"
echo "Env: $(env)"
# RIGHT: Sanitize before logging
INPUT="$(cat)"
CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
SAFE_CMD="$(echo "$CMD" | sed 's/password=[^ ]*/password=REDACTED/g')"
echo "Command: $SAFE_CMD"Skip Sensitive Files
#!/bin/bash
set -euo pipefail
SENSITIVE_PATTERNS="\.env|\.env\.|credentials|secrets|\.pem|\.key|id_rsa"
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
[[ -z "$FILE_PATH" ]] && exit 0
if echo "$FILE_PATH" | grep -qE "$SENSITIVE_PATTERNS"; then
echo "SKIP: Sensitive file $FILE_PATH" >&2
exit 0
fi
process_file "$FILE_PATH"Block Credential Commits
#!/bin/bash
# pre-tool-validate.sh for Bash tool
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')"
CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
if [[ "$TOOL_NAME" == "Bash" ]]; then
# Block git add of sensitive files
if echo "$CMD" | grep -qE 'git\s+add.*\.(env|pem|key)'; then
echo "BLOCKED: Cannot stage sensitive files" >&2
exit 2
fi
fi
exit 0---
Audit Logging
Comprehensive Audit Log
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')"
SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // empty')"
LOG_DIR="$CLAUDE_PROJECT_DIR/.claude/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/audit-$(date +%Y-%m-%d).log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
USER=$(whoami)
# Structured log entry
cat >> "$LOG_FILE" << EOF
{"timestamp":"$TIMESTAMP","user":"$USER","tool":"$TOOL_NAME","session":"$SESSION_ID"}
EOFTamper-Evident Logging
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')"
LOG_FILE="$CLAUDE_PROJECT_DIR/.claude/audit.jsonl"
# Append-only with hash chain
PREV_HASH=""
[[ -f "$LOG_FILE" ]] && PREV_HASH=$(tail -1 "$LOG_FILE" | sha256sum | cut -d' ' -f1)
ENTRY="{\"ts\":\"$(date -u +%s)\",\"tool\":\"$TOOL_NAME\",\"prev\":\"$PREV_HASH\"}"
echo "$ENTRY" >> "$LOG_FILE"---
Resource Limits
Timeout Protection
#!/bin/bash
set -euo pipefail
# Kill hook if takes too long
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
timeout 5s npm run lint -- "$FILE_PATH" || {
echo "ERROR: Lint timed out" >&2
exit 1
}Memory Limit
#!/bin/bash
set -euo pipefail
# Limit memory usage (Linux)
ulimit -v 500000 # 500MB virtual memory
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
node expensive-analysis.js "$FILE_PATH"Output Truncation
#!/bin/bash
set -euo pipefail
# Prevent massive output
npm test 2>&1 | head -100
# Or use tail for recent output
npm run build 2>&1 | tail -50---
Testing Hooks Safely
Manual Testing
export CLAUDE_PROJECT_DIR="$(pwd)"
# Run hook with stdin JSON
echo '{"hook_event_name":"PostToolUse","session_id":"test-session","tool_name":"Edit","tool_input":{"file_path":"'"$(pwd)"'/src/test.ts"}}' \
| bash .claude/hooks/post-tool-format.sh
# Check exit code
echo "Exit code: $?"Dry Run Mode
#!/bin/bash
set -euo pipefail
DRY_RUN="${DRY_RUN:-false}"
if [[ "$DRY_RUN" == "true" ]]; then
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
echo "DRY RUN: Would format $FILE_PATH"
exit 0
fi
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
prettier --write "$FILE_PATH"Sandboxed Testing
# Test in temporary directory
TEMP_DIR=$(mktemp -d)
cp -r . "$TEMP_DIR"
cd "$TEMP_DIR"
CLAUDE_PROJECT_DIR="$TEMP_DIR" bash .claude/hooks/dangerous-hook.sh
# Cleanup
rm -rf "$TEMP_DIR"---
Common Vulnerabilities
| Vulnerability | Example | Fix |
|---|---|---|
| Command injection | eval "$INPUT" | Use allowlist |
| Path traversal | cat "../$FILE" | Validate paths |
| Word splitting | rm $FILES | Quote: "$FILES" |
| Glob expansion | cat *.txt | Quote or disable |
| Secret exposure | echo "$API_KEY" | Redact logs |
| Symlink attacks | cat "$LINK" | Use realpath -P |
| Race conditions | Check-then-use | Atomic operations |
---
Security Review Checklist
Before deploying a hook:
[ ] No eval, bash -c, or $() with untrusted input
[ ] All variables quoted
[ ] Path traversal blocked
[ ] Sensitive files skipped
[ ] Output sanitized (no secrets logged)
[ ] Timeout protection added
[ ] Tested in isolation
[ ] Exit codes correct (0=ok, 1=error, 2=block)
[ ] ShellCheck passes with no errors
[ ] Script under 50 lines (or refactored)---
Tooling Requirements
ShellCheck (Mandatory)
All hook scripts must pass ShellCheck before deployment.
# Install
brew install shellcheck # macOS
apt install shellcheck # Debian/Ubuntu
pacman -S shellcheck # Arch
# Run on all hooks
shellcheck .claude/hooks/*.sh
# CI integration (GitHub Actions)
- name: Lint hooks
run: shellcheck .claude/hooks/*.sh --severity=warningCommon ShellCheck Fixes
| Code | Issue | Fix |
|---|---|---|
| SC2086 | Unquoted variable | "$VAR" not $VAR |
| SC2046 | Unquoted command substitution | "$(cmd)" not $(cmd) |
| SC2006 | Legacy backticks | $(cmd) not ` cmd ` |
| SC2164 | cd without |
Inline Suppressions
# Suppress specific warning (use sparingly)
# shellcheck disable=SC2086
echo $KNOWN_SAFE_VAR---
Script Size Guidelines
Google's Shell Style Guide recommends keeping scripts under 50 lines.
Why 50 Lines?
- Easier to audit for security issues
- Faster execution (less parsing)
- More maintainable
- Forces modular design
When Scripts Grow
If a hook exceeds 50 lines:
1. Split into multiple hooks — Chain in settings.json 2. Use a real language — Python, Node.js, Go 3. Create a CLI tool — Compile and distribute
Example: Refactoring Large Hook
Before (80 lines):
#!/bin/bash
# All-in-one formatting, linting, testing hook
# ... 80 lines of bash ...After (3 hooks, 20 lines each):
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/01-format.sh" },
{ "type": "command", "command": ".claude/hooks/02-lint.sh" },
{ "type": "command", "command": ".claude/hooks/03-test.sh" }
]
}
]
}---
Navigation
- SKILL.md - Main reference
- hook-patterns.md - Common patterns
Hook Templates
Ready-to-use shell scripts for common Claude Code hook scenarios.
Assumptions (Jan 2026):
- Hook input arrives as JSON via stdin (parse with
jq). - Scripts run with full user permissions; use
set -euo pipefailand validate input.
---
Pre-Tool Validation
Guards the Bash tool against dangerous commands. Use with PreToolUse + matcher: "Bash".
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')"
CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
if [[ "$TOOL_NAME" == "Bash" ]]; then
# Block rm -rf /
if echo "$CMD" | grep -qE 'rm\s+-rf\s+/'; then
echo '{}' | jq -cn '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Dangerous rm command detected"
}
}'
exit 0
fi
# Block force push to main
if echo "$CMD" | grep -qE 'git\s+push.*--force.*(main|master)'; then
echo '{}' | jq -cn '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Force push to main/master not allowed"
}
}'
exit 0
fi
# Soft-warning: possible credential exposure
if echo "$CMD" | grep -qE '(password|secret|api_key)\s*='; then
echo '{}' | jq -cn '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Possible credential exposure in command",
additionalContext: "Command may include a secret. Confirm intent and avoid committing secrets."
}
}'
exit 0
fi
fi
exit 0---
Post-Tool Formatting
Auto-formats files after Edit/Write. Use with PostToolUse + matcher: "Edit|Write".
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
if [[ "$TOOL_NAME" =~ ^(Edit|Write)$ && -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
case "$FILE_PATH" in
*.js|*.ts|*.jsx|*.tsx|*.json|*.md)
npx prettier --write "$FILE_PATH" 2>/dev/null || true
;;
*.py)
ruff format "$FILE_PATH" 2>/dev/null || true
;;
*.go)
gofmt -w "$FILE_PATH" 2>/dev/null || true
;;
*.rs)
rustfmt "$FILE_PATH" 2>/dev/null || true
;;
esac
fi
exit 0---
Post-Tool Security Audit
Checks for hardcoded secrets and debug statements after Edit/Write.
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
if [[ "$TOOL_NAME" =~ ^(Edit|Write)$ && -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Check for hardcoded secrets
if grep -qE '(password|secret|api_key|token)\s*[:=]\s*["\x27][^"\x27]+["\x27]' "$FILE_PATH"; then
echo "WARNING: Possible hardcoded secret in $FILE_PATH" >&2
fi
# Check for console.log in production code
if [[ "$FILE_PATH" =~ \.(ts|js|tsx|jsx)$ ]] && grep -q 'console.log' "$FILE_PATH"; then
echo "NOTE: console.log found in $FILE_PATH" >&2
fi
fi
exit 0---
Stop Hook (Run Tests)
Runs the project test suite after Claude finishes. Use with Stop.
#!/bin/bash
set -euo pipefail
# Run tests after Claude finishes
cd "$CLAUDE_PROJECT_DIR"
# Detect test framework
if [[ -f "package.json" ]]; then
if grep -q '"vitest"' package.json; then
npm run test 2>&1 | head -50
elif grep -q '"jest"' package.json; then
npm test 2>&1 | head -50
fi
elif [[ -f "pytest.ini" ]] || [[ -f "pyproject.toml" ]]; then
pytest --tb=short 2>&1 | head -50
fi
exit 0---
Session Start
Checks git state and dependencies at session startup.
#!/bin/bash
set -euo pipefail
cd "$CLAUDE_PROJECT_DIR"
# Check git status
echo "=== Git Status ==="
git status --short
# Check for uncommitted changes
if ! git diff --quiet; then
echo "WARNING: Uncommitted changes detected"
fi
# Verify dependencies
if [[ -f "package.json" ]]; then
if [[ ! -d "node_modules" ]]; then
echo "NOTE: node_modules missing, run npm install"
fi
fi
exit 0---
Input Modification: Redirect Sensitive File Edits
Redirects writes to package-lock.json to /dev/null using updatedInput.
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')"
# Redirect package-lock.json edits to /dev/null
if [[ "$FILE_PATH" == *"package-lock.json" ]]; then
UPDATED_INPUT="$(echo "$INPUT" | jq -c '.tool_input | .file_path = "/dev/null"')"
jq -cn --argjson updatedInput "$UPDATED_INPUT" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Redirected write to /dev/null",
updatedInput: $updatedInput
}
}'
exit 0
fi
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'---
Input Modification: Strip Sensitive Files from Git Add
Removes .env files from git add commands before execution.
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')"
CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
if [[ "$TOOL_NAME" == "Bash" && "$CMD" =~ ^git[[:space:]]+add ]]; then
# Remove .env files from staging
SAFE_CMD="$(echo "$CMD" | sed 's/\.env[^ ]*//g')"
if [[ "$SAFE_CMD" != "$CMD" ]]; then
echo '{}' | jq -cn --arg cmd "$SAFE_CMD" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Removed .env from git add",
updatedInput: { command: $cmd }
}
}'
exit 0
fi
fi
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'---
Navigation
- SKILL.md — Main reference
- hook-patterns.md — Advanced patterns (CI/CD, composition, notifications)
- hook-security.md — Security guide
Runtime Preflight Hooks
Use these patterns to validate local runtime/tool prerequisites at session start.
Goal
Fail fast with actionable remediation when required tools or versions are missing, instead of discovering mismatch deep in execution.
Recommended Events
SessionStart: verify runtime versions and binary presence.Setup: verify repo-local requirements (package managers, language toolchains).
Checks to Include
- binary exists (
command -v node) - version satisfies minimum (
node -v >= v22.22.0) - configured path exists (for tool-specific runtime paths)
Output Pattern
- success: concise pass log
- failure: explicit error + one-line remediation command
Example Failure Message
Runtime preflight failed: node v20.19.5 detected, requires >= v22.22.0.
Run: nvm install 22.22.0 && nvm use 22.22.0Safety Notes
- Keep preflight read-only by default.
- Avoid automatic installs in hooks unless explicitly approved by project policy.
- Keep execution under 1 second where possible.