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

13 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

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:

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

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

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

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

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

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)