From 8d0ed3dd0d6ba343832f64cabe8fe4d7991b5a0c Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 26 Mar 2026 12:49:38 +0100 Subject: [PATCH] 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) --- .../specs/2026-03-26-security-hooks-design.md | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-security-hooks-design.md diff --git a/docs/superpowers/specs/2026-03-26-security-hooks-design.md b/docs/superpowers/specs/2026-03-26-security-hooks-design.md new file mode 100644 index 0000000..b5ae637 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-security-hooks-design.md @@ -0,0 +1,393 @@ +# 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)