# 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 nudge "Message with {variable} interpolation" block "rule-name" match_any nudge "Message" suspicious "rule-name" match_base_command_not_in nudge "Message with {command} interpolation" block "rule-name" validator ModuleName nudge "Message" ``` ### Grammar ``` file := (comment | blank | rule)* comment := '#' rule := tier SP name NL clauses tier := "block" | "suspicious" name := '"' '"' clauses := matcher nudge [condition]* matcher := match | match_any | match_not_in | validator match := INDENT "match " NL match_any := INDENT "match_any" NL (INDENT2 NL)+ match_not_in := INDENT "match_base_command_not_in " NL validator := INDENT "validator " NL nudge := INDENT "nudge " '"' '"' NL condition := INDENT ("only_when" | "except_when") SP 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)