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>
This commit is contained in:
Flo
2026-03-26 13:00:10 +01:00
parent 8d0ed3dd0d
commit 0dca8797be

View File

@@ -22,9 +22,13 @@ A short bash script that Claude Code invokes as a hook command. It:
- 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: `~/Library/Application Support/security-hooks/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`)
@@ -33,7 +37,7 @@ A long-running BEAM process distributed as a Burrito binary (single executable,
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`
- **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`)
@@ -42,7 +46,7 @@ Components:
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
- **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
@@ -145,7 +149,7 @@ PreToolUse ask (tier: suspicious):
}
```
PostToolUse block:
PostToolUse block (note: PostToolUse uses top-level `decision`/`reason` fields, unlike PreToolUse which nests under `hookSpecificOutput`):
```json
{
"decision": "block",
@@ -183,6 +187,11 @@ suspicious "rule-name"
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
@@ -200,16 +209,30 @@ 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.
**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.
@@ -234,7 +257,7 @@ Rules are evaluated in file order. First match wins. Place specific rules before
- 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
- 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
@@ -282,9 +305,7 @@ 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`
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
@@ -294,7 +315,7 @@ tools = ["sequentialthinking"]
[executables]
allowed = [
"git", "mix", "elixir", "iex", "cargo", "rustc",
"go", "python", "pip", "uv", "node", "npm", "pnpm",
"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",
@@ -340,7 +361,7 @@ Merges on top of `config.toml`:
## 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.
**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.
@@ -371,7 +392,26 @@ The install script:
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.
**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