Files
security-hooks/docs/superpowers/specs/2026-03-26-security-hooks-design.md
Flo 8d0ed3dd0d Add security hooks design spec
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>
2026-03-26 12:49:38 +01:00

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)