Defense-in-depth hooks for Claude Code: Elixir daemon with custom .rules DSL, shell shim, JSONL logging, hot-reload, tiered block/suspicious decisions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 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
Socket path defaults:
- Linux/WSL:
$XDG_RUNTIME_DIR/security-hooks/sock(fallback:/tmp/security-hooks-$UID/sock) - macOS:
~/Library/Application Support/security-hooks/sock
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 and compiles Elixir validator modules viaCode.compile_file - 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
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
│ └── 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 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"
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 [condition]*
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
condition := INDENT ("only_when" | "except_when") SP <condition expr> NL
INDENT = 2 spaces, INDENT2 = 4 spaces.
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.
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,wget,nc,sshpiping stdin or env vars to remote destinations - 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,mix.lock,Cargo.lock,poetry.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
- If a file was edited, run project-appropriate linter (detected from project files)
- If linter errors are in files the agent just touched, block
- If errors are in untouched files, inform only via
additionalContext
Configuration
config.toml (defaults, checked into repo)
[executables]
allowed = [
"git", "mix", "elixir", "iex", "cargo", "rustc",
"go", "python", "pip", "uv", "node", "npm", "pnpm",
"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",
]
[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 a platform-appropriate location and opens the Unix socket. Startup is ~300ms (BEAM boot), occurs once per session.
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.
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"}
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
Uninstall: ./install.sh --uninstall removes hook entries from settings and optionally removes the config directory and binary.
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)