Files
security-hooks/docs/superpowers/specs/2026-03-26-security-hooks-design.md
Flo 0dca8797be Address spec review: fail-closed policy, validator security, match targets
Critical fixes:
- Fail-closed: shim returns deny if daemon unreachable
- Validators compiled into binary, not loaded dynamically
- Socket directory created with 0700 permissions

Important fixes:
- Document match target fields per hook type
- Note PreToolUse vs PostToolUse response format difference
- Defer only_when/except_when conditions to future version
- Add concrete match_base_command_not_in example
- Specify PID file locations
- Add versioning scheme for rules and config
- Defer post.rules linting to future version

Other:
- Clarify exfiltration rules (not blocking bare curl/wget)
- Add missing yarn to allowed executables
- Fix macOS socket path (avoid space in Application Support)
- Note Burrito first-run unpack latency
- Document existing hooks coexistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:00:10 +01:00

434 lines
15 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
**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 `.rules` files and Elixir validator modules, evaluates them against the payload, returns the first matching result
- **Rule loader** — parses the custom `.rules` DSL. 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/` 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. 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
│ └── 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 (note: PostToolUse uses top-level `decision`/`reason` fields, unlike PreToolUse which nests under `hookSpecificOutput`):
```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"
# 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 [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
```
`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 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.
### 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 --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` with POST data from stdin or env vars (`curl -d @-`, `curl -d $SECRET`), `nc` with piped input, `ssh` with piped commands. Note: bare `curl`/`wget` for downloads are allowed — only exfil patterns (data upload, piping secrets) are blocked.
- 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
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)
```toml
[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",
]
[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 `$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.
**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
**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
```
```toml
# 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)