- Remove deferred condition from grammar production - Add [meta] version to config.toml example - Add PostToolUse allow response (empty object) - Mark post.rules as deferred in directory tree - Complete lockfile list for all supported ecosystems - Handle startup race condition (EADDRINUSE retry) - Note log rotation as deferred Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
Security Hooks for Claude Code
A general-purpose, distributable set of hooks that catch prompt injection, agent autonomy drift, supply chain attacks, and data exfiltration in Claude Code sessions.
Threat Model
- Prompt injection via untrusted content — malicious README, fetched webpage, or MCP response tricks the agent into running harmful commands
- Agent autonomy drift — the agent does something "helpful" that is destructive (force push, delete files, install malware packages)
- Supply chain / dependency attacks — the agent installs compromised packages or runs untrusted scripts
- Data exfiltration — the agent leaks secrets, env vars, or private code to external services
Architecture
Three components:
1. Shell shim (security-hook)
A short bash script that Claude Code invokes as a hook command. It:
- Reads the JSON hook payload from stdin
- Sends it to the daemon over a Unix socket
- If the daemon is not running, starts it and retries
- Prints the daemon's JSON response to stdout
Fail-closed policy: If the shim cannot reach the daemon within its timeout (default: 5 seconds), it returns deny. The system never fails open.
Socket path defaults:
- Linux/WSL:
$XDG_RUNTIME_DIR/security-hooks/sock(fallback:/tmp/security-hooks-$UID/sock) - macOS:
$TMPDIR/security-hooks/sock
The socket's containing directory is created with mode 0700 to prevent other local processes from connecting.
2. Elixir daemon (security-hookd)
A long-running BEAM process distributed as a Burrito binary (single executable, no Erlang/Elixir runtime required). Target platforms: macOS (aarch64, x86_64), Linux (x86_64, aarch64), WSL (x86_64).
Components:
- Socket listener — accepts connections on Unix socket, parses JSON payloads
- Rule engine — loads rules from
.rulesfiles and Elixir validator modules, evaluates them against the payload, returns the first matching result - Rule loader — parses the custom
.rulesDSL. Validator modules are compiled into the Burrito binary at build time (not loaded dynamically) to prevent code injection into the security daemon. Users who add custom validators must rebuild the binary. - File watcher — monitors
rules/andconfig/directories, triggers hot-reload on change - Config manager — loads
config.toml, mergesconfig.local.tomlon top (lists are appended,disabledrules are subtracted) - Logger — writes JSONL to
$XDG_STATE_HOME/security-hooks/hook.log(macOS:~/Library/Logs/security-hooks/hook.log)
3. Rule files
Two kinds:
- Pattern rules in
.rulesfiles using a custom DSL (see Rule Format below) - Validator modules in
rules/validators/*.exfor complex logic that cannot be expressed as regex patterns. These are compiled into the binary at build time — not loaded dynamically — to prevent code injection into the security daemon.
Directory Structure
security-hooks/
├── bin/
│ └── security-hook # shell shim
├── rules/
│ ├── bash.rules # bash command rules
│ ├── edit.rules # file edit rules
│ ├── mcp.rules # MCP tool rules
│ ├── post.rules # post-tool-use checks (deferred, reserved)
│ └── validators/ # complex Elixir validators
│ ├── unknown_executable.ex
│ ├── dependency_mutation.ex
│ └── secret_access.ex
├── config/
│ ├── config.toml # default settings
│ └── config.local.toml # user overrides (gitignored)
├── daemon/ # Elixir application source
│ ├── lib/
│ │ ├── security_hooks/
│ │ │ ├── application.ex
│ │ │ ├── socket_listener.ex
│ │ │ ├── rule_engine.ex
│ │ │ ├── rule_loader.ex
│ │ │ ├── file_watcher.ex
│ │ │ ├── config.ex
│ │ │ └── logger.ex
│ │ └── security_hooks.ex
│ ├── mix.exs
│ └── test/
├── install.sh
└── README.md
Hook Events & Claude Code Integration
Hooks are registered via install.sh into Claude Code's settings.json:
PreToolUse hooks (can allow/deny/ask)
Bash (matcher: Bash):
security-hook pre bash
Edit/Write (matcher: Edit|Write):
security-hook pre edit
MCP (matcher: mcp__.*):
security-hook pre mcp
PostToolUse hook (can block after execution)
Post-check (matcher: Bash|Edit|Write):
security-hook post check
Response format
The daemon returns JSON matching Claude Code's hook output spec.
PreToolUse allow:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}
PreToolUse deny (tier: block):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "destructive rm detected",
"additionalContext": "Use trash-cli or move to a temp directory"
}
}
PreToolUse ask (tier: suspicious):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "unknown executable: foo",
"additionalContext": "Add foo to allowed_executables in config.toml"
}
}
PostToolUse responses (note: PostToolUse uses top-level decision/reason fields, unlike PreToolUse which nests under hookSpecificOutput):
PostToolUse allow (no issues found):
{}
PostToolUse block:
{
"decision": "block",
"reason": "ESLint errors in files you just edited",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Fix the lint errors before continuing"
}
}
Rule Format: .rules DSL
Regex patterns are never quoted. Everything after match to end of line is the pattern, taken verbatim. This eliminates escaping issues that plague YAML/TOML for regex-heavy configs.
Syntax
# Comments start with #
block "rule-name"
match <regex pattern to end of line>
nudge "Message with {variable} interpolation"
block "rule-name"
match_any
<regex pattern>
<regex pattern>
nudge "Message"
suspicious "rule-name"
match_base_command_not_in <config key>
nudge "Message with {command} interpolation"
block "rule-name"
validator ModuleName
nudge "Message"
# Concrete example of match_base_command_not_in:
suspicious "unknown-executable"
match_base_command_not_in allowed_executables
nudge "Unknown command '{base_command}'. Add it to allowed_executables in config.toml"
Grammar
file := (comment | blank | rule)*
comment := '#' <text to end of line>
rule := tier SP name NL clauses
tier := "block" | "suspicious"
name := '"' <text> '"'
clauses := matcher nudge
matcher := match | match_any | match_not_in | validator
match := INDENT "match " <regex to end of line> NL
match_any := INDENT "match_any" NL (INDENT2 <regex to end of line> NL)+
match_not_in := INDENT "match_base_command_not_in " <config key> NL
validator := INDENT "validator " <elixir module name> NL
nudge := INDENT "nudge " '"' <text with {var} interpolation> '"' NL
INDENT = 2 spaces, INDENT2 = 4 spaces.
Note: only_when / except_when conditions are deferred to a future version. They are not part of the v1 grammar.
Tiers
- block — hard deny via
permissionDecision: "deny". The nudge is sent asadditionalContextso the agent can self-correct. - suspicious — soft deny via
permissionDecision: "ask". Falls through to Claude Code's permission prompt so the human decides. The nudge is shown as context.
Match targets
Each hook type matches against a specific field from the Claude Code JSON payload:
| Hook file | Payload field matched | Example value |
|---|---|---|
| bash.rules | tool_input.command |
rm -rf /tmp/foo |
| edit.rules | tool_input.file_path |
/home/user/project/src/main.rs |
| mcp.rules | tool_name (parsed into server + tool) |
mcp__context7__query-docs |
| post.rules | tool_name + tool result |
varies |
For bash rules, {base_command} is extracted as the first whitespace-delimited token of tool_input.command after stripping leading environment variable assignments.
Evaluation
Rules are evaluated in file order. First match wins. Place specific rules before general catch-alls.
Variable interpolation in nudges
{command}— the full command string (Bash hooks){base_command}— the first token of the command{file_path}— the target file path (Edit/Write hooks){tool_name}— the tool name{server_name}— the MCP server name (MCP hooks)
Default Rule Sets
bash.rules
Tier: block
- Destructive filesystem ops:
rm -rf /,mkfs,dd of=/dev/* - Fork bombs:
:(){ :|:& };: - Git history destruction:
git push --force(not--force-with-lease),git reset --hardon remote-tracking branches,git clean -fdx - Package registry attacks:
npm unpublish,gem yank,cargo yank - Cloud resource deletion:
aws .* delete-,gcloud .* delete,az .* delete,fly destroy - Privilege escalation:
sudo,su -,chmod 777,chown root - Env var poisoning: setting
LD_PRELOAD,PATH,NODE_OPTIONS,PYTHONPATH - Outbound exfiltration:
curl/wgetwith POST data from stdin or env vars (curl -d @-,curl -d $SECRET),ncwith piped input,sshwith piped commands. Note: barecurl/wgetfor downloads are allowed — only exfil patterns (data upload, piping secrets) are blocked. - Agent recursion:
claude --dangerously-skip-permissions,claude -pwith untrusted input - Crypto miners:
xmrig,minerd, known mining pool domains
Tier: suspicious
- Unknown base command not in allowed executables list
- Output redirection to files outside project root
- Command substitution with pipes or chaining (
$(curl ... | sh)) - Long base64-encoded strings in commands (obfuscation signal)
eval,exec,sourcewith dynamic arguments
edit.rules
Tier: block
- Edits outside
$CLAUDE_PROJECT_DIR - Edits to shell config files:
.bashrc,.zshrc,.profile,.bash_profile - Edits to
.envfiles - Edits to
~/.ssh/,~/.aws/,~/.config/gcloud/
Tier: suspicious
- Edits to CI/CD config:
.github/workflows/,.gitlab-ci.yml,Jenkinsfile - Edits to
Dockerfile,docker-compose.yml - Edits to lockfiles:
package-lock.json,pnpm-lock.yaml,yarn.lock,mix.lock,Cargo.lock,poetry.lock,Gemfile.lock,go.sum,composer.lock - Dependency field changes in manifest files (validator:
DependencyMutation)
mcp.rules
Tier: block
- Unknown/unregistered MCP servers (catch-all deny)
- MCP tool call parameters containing shell metacharacters or injection patterns
Tier: suspicious
- MCP tools that fetch URLs or access external resources
Allowed MCP servers and their tools are configured in config.toml:
[[mcp.servers]]
name = "context7"
tools = ["resolve-library-id", "query-docs"]
[[mcp.servers]]
name = "sequential-thinking"
tools = ["sequentialthinking"]
post.rules
Deferred to a future version. Post-tool-use linting is project-specific and requires detecting the project's toolchain (presence of .eslintrc, mix.exs, Cargo.toml, etc.), choosing the right linter, handling timeouts, and distinguishing agent-introduced errors from pre-existing ones. This deserves its own design pass.
Configuration
config.toml (defaults, checked into repo)
[executables]
allowed = [
"git", "mix", "elixir", "iex", "cargo", "rustc",
"go", "python", "pip", "uv", "node", "npm", "pnpm", "yarn",
"rg", "fd", "jq", "cat", "ls", "head", "tail",
"mkdir", "cp", "mv", "touch", "echo", "grep", "sed", "awk",
"make", "cmake", "gcc", "clang",
"ruby", "gem", "bundler", "rake",
"php", "composer",
"java", "javac", "mvn", "gradle",
]
[secrets]
env_vars = [
"AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_ACCESS_KEY_ID",
"GITHUB_TOKEN", "GH_TOKEN",
"DATABASE_URL",
"OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"STRIPE_SECRET_KEY",
"PRIVATE_KEY", "SECRET_KEY",
]
[paths]
sensitive = [
"~/.ssh",
"~/.aws/credentials",
"~/.config/gcloud",
"~/.netrc",
"/etc/shadow",
"/etc/passwd",
]
[meta]
version = "1.0.0"
[daemon]
idle_timeout_minutes = 30
log_format = "jsonl"
[rules]
disabled = []
config.local.toml (user overrides, gitignored)
Merges on top of config.toml:
- List values are appended
- Scalar values are overwritten
rules.disabledentries cause matching rules to be skipped
Daemon Lifecycle
Starting: The shell shim starts the daemon on first hook call. The daemon writes its PID to $XDG_RUNTIME_DIR/security-hooks/pid (Linux/WSL) or $TMPDIR/security-hooks/pid (macOS) and opens the Unix socket. First-ever startup with a Burrito binary may take 1-3 seconds (unpacking); subsequent starts are ~300ms (BEAM boot). The install script pre-warms the daemon to avoid first-call latency.
Health check: The shim checks socket connectivity. If the PID file exists but the socket is dead, the shim kills the stale process and restarts. If two sessions race to start the daemon simultaneously, the second will get EADDRINUSE — the shim handles this by retrying the socket connection (the first daemon won the race and is now available).
Idle shutdown: The daemon exits after 30 minutes of inactivity (configurable via daemon.idle_timeout_minutes). Handles SIGTERM gracefully.
Hot-reload: A FileSystem watcher monitors rules/ and config/ directories. On change, the rule engine reloads rules and config without restarting the daemon.
Logging: All decisions are written as JSONL:
{"ts":"2026-03-26T14:02:03Z","event":"PreToolUse","tool":"Bash","input":"rm -rf /","rule":"destructive-rm","decision":"deny","nudge":"Use trash-cli or move to a temp directory"}
{"ts":"2026-03-26T14:02:05Z","event":"PreToolUse","tool":"Bash","input":"mix test","rule":null,"decision":"allow"}
Log rotation and size limits are deferred — users can manage this with external tools (logrotate, etc.) since the log path is well-defined.
Future: streaming connectors for centralized logging (stdout, webhook, syslog).
Installation
./install.sh
The install script:
- Downloads the Burrito binary for the current platform (macOS aarch64/x86_64, Linux x86_64/aarch64) or builds from source if Elixir is available
- Installs the binary and shell shim to
~/.local/bin/(or user-specified location) - Copies default rules and config to
~/.config/security-hooks/ - Creates
config.local.tomlfrom a template if it does not exist - Merges hook entries into Claude Code's
~/.claude/settings.json(preserving existing hooks) - Prints a summary of what was configured
Existing hooks: Claude Code supports multiple hooks per event. install.sh appends security-hooks entries without removing existing user hooks. Both run on each tool call.
Uninstall: ./install.sh --uninstall removes hook entries from settings and optionally removes the config directory and binary.
Versioning & Updates
Rule files and config carry a version field:
# rules/bash.rules
# version: 1.0.0
# config/config.toml
[meta]
version = "1.0.0"
install.sh --update compares installed version against the repo version, merges new default rules (preserving config.local.toml overrides and disabled entries), and logs what changed.
Target Platforms
- macOS (aarch64, x86_64)
- Linux (x86_64, aarch64)
- WSL (x86_64) — uses Linux binary with Linux-style paths
Supported Language Ecosystems
The default allowed executables and dependency mutation validators cover:
- Rust (cargo)
- Python (pip, uv, poetry)
- TypeScript/JavaScript (npm, pnpm, yarn)
- Go (go)
- Java (maven, gradle)
- Ruby (gem, bundler)
- PHP (composer)
- C/C++ (gcc, clang, make, cmake)
- Elixir (mix)