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

15 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

  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:

{
  "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 (note: PostToolUse uses top-level decision/reason fields, unlike PreToolUse which nests under hookSpecificOutput):

{
  "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:

[[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)

[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:

{"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:

  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
# 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)