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>
394 lines
13 KiB
Markdown
394 lines
13 KiB
Markdown
# 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
|
|
|
|
1. **Prompt injection via untrusted content** — malicious README, fetched webpage, or MCP response tricks the agent into running harmful commands
|
|
2. **Agent autonomy drift** — the agent does something "helpful" that is destructive (force push, delete files, install malware packages)
|
|
3. **Supply chain / dependency attacks** — the agent installs compromised packages or runs untrusted scripts
|
|
4. **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 `.rules` files and Elixir validator modules, evaluates them against the payload, returns the first matching result
|
|
- **Rule loader** — parses the custom `.rules` DSL and compiles Elixir validator modules via `Code.compile_file`
|
|
- **File watcher** — monitors `rules/` and `config/` directories, triggers hot-reload on change
|
|
- **Config manager** — loads `config.toml`, merges `config.local.toml` on top (lists are appended, `disabled` rules 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 `.rules` files using a custom DSL (see Rule Format below)
|
|
- **Validator modules** in `rules/validators/*.ex` for 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:
|
|
```json
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow"
|
|
}
|
|
}
|
|
```
|
|
|
|
PreToolUse deny (tier: block):
|
|
```json
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": "destructive rm detected",
|
|
"additionalContext": "Use trash-cli or move to a temp directory"
|
|
}
|
|
}
|
|
```
|
|
|
|
PreToolUse ask (tier: suspicious):
|
|
```json
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "ask",
|
|
"permissionDecisionReason": "unknown executable: foo",
|
|
"additionalContext": "Add foo to allowed_executables in config.toml"
|
|
}
|
|
}
|
|
```
|
|
|
|
PostToolUse block:
|
|
```json
|
|
{
|
|
"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 as `additionalContext` so 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 --hard` on 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`, `ssh` piping stdin or env vars to remote destinations
|
|
- Agent recursion: `claude --dangerously-skip-permissions`, `claude -p` with 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`, `source` with dynamic arguments
|
|
|
|
### edit.rules
|
|
|
|
**Tier: block**
|
|
- Edits outside `$CLAUDE_PROJECT_DIR`
|
|
- Edits to shell config files: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
|
- Edits to `.env` files
|
|
- 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`:
|
|
|
|
```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)
|
|
|
|
```toml
|
|
[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.disabled` entries 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:
|
|
|
|
```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
|
|
|
|
```bash
|
|
./install.sh
|
|
```
|
|
|
|
The install script:
|
|
1. Downloads the Burrito binary for the current platform (macOS aarch64/x86_64, Linux x86_64/aarch64) or builds from source if Elixir is available
|
|
2. Installs the binary and shell shim to `~/.local/bin/` (or user-specified location)
|
|
3. Copies default rules and config to `~/.config/security-hooks/`
|
|
4. Creates `config.local.toml` from a template if it does not exist
|
|
5. Merges hook entries into Claude Code's `~/.claude/settings.json` (preserving existing hooks)
|
|
6. 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)
|