Address spec review iteration 3: parser strategy, semantics, examples

Critical:
- Add Bash Parser Strategy section with 3 options (bash Hex, tree-sitter,
  shlex) and validation plan for adversarial inputs
- Fix evaluation order: explicitly document strategy-grouped (regex first,
  then AST) rather than claiming pure file order

Important:
- Define reads_file, writes_file, sets_env detection semantics
- Define validator module behaviour/callback interface
- Add dual timeouts: 200ms steady-state, 3000ms cold start
- Define config.local.toml merge semantics for MCP servers (by name)
- Reframe "non-evasible" regex rules as "common pattern" detection
- Add XDG_RUNTIME_DIR unset fallback (/tmp/security-hooks-$UID/)
- Add match_server_not_in for MCP rules (clearer than overloading
  match_base_command_not_in)
- Add complete edit.rules and mcp.rules DSL examples

Suggestions:
- Add CLI commands: status, test, reload, log
- Note Burrito binary size expectations in parser strategy section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Flo
2026-03-27 20:58:18 +01:00
parent e396632e2b
commit 91911973d6

View File

@@ -23,12 +23,16 @@ A short bash script that Claude Code invokes as a hook command. It:
The shim is deliberately simple — it does not manage daemon lifecycle. That responsibility belongs to the platform service manager (see Daemon Lifecycle below).
**Fail-closed policy:** If the shim cannot reach the daemon within its timeout (default: 500ms), it exits with code 2 (blocking error) and writes a deny reason to stderr. The system never fails open. 500ms is enough for socket-activated startup but fast enough that users don't notice.
**Fail-closed policy:** If the shim cannot reach the daemon within its timeout, it exits with code 2 (blocking error) and writes a deny reason to stderr. The system never fails open.
**Timeouts:** Two configurable values:
- `shim_timeout_ms` (default: 200ms) — steady-state timeout for a warm daemon. If a warm daemon doesn't respond in 200ms, something is wrong.
- `shim_cold_start_timeout_ms` (default: 3000ms) — used when the shim detects a cold start (socket activation just triggered, or fallback daemon just spawned). Allows time for BEAM boot and Burrito unpacking. The shim detects cold start by checking whether the socket existed before the connection attempt.
**Socket paths:**
- Linux/WSL with systemd: managed by systemd socket activation at `$XDG_RUNTIME_DIR/security-hooks/sock`
- macOS with launchd: managed by launchd at `$TMPDIR/security-hooks/sock`
- Fallback (no service manager): `$XDG_RUNTIME_DIR/security-hooks/sock` (Linux/WSL) or `$TMPDIR/security-hooks/sock` (macOS)
- Fallback (no service manager): `$XDG_RUNTIME_DIR/security-hooks/sock` (Linux/WSL) or `$TMPDIR/security-hooks/sock` (macOS). If `$XDG_RUNTIME_DIR` is unset (containers, SSH sessions), falls back to `/tmp/security-hooks-$UID/sock`.
The socket's containing directory is created with mode `0700` to prevent other local processes from connecting.
@@ -39,7 +43,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, evaluates them against the payload using the appropriate matching strategy (regex or AST), returns the first matching result
- **Bash analyzer** — parses shell commands into an AST using the `bash` Hex package, enabling structural matching that catches evasion via subshells, pipes, and obfuscation (see Matching Strategies below)
- **Bash analyzer** — parses shell commands into an AST for structural matching that catches evasion via subshells, pipes, and obfuscation (see Matching Strategies and Bash Parser Strategy below)
- **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` overrides (see Configuration)
@@ -220,9 +224,36 @@ These functions operate on the parsed AST of a bash command. They match against
| `with_args_matching(regex)` | Modifier: command args must match regex | `command("chmod") with_args_matching("777")` |
| `pipeline_to("name", ...)` | Matches if a pipeline ends with one of these commands | `pipeline_to("curl", "nc")` |
| `pipeline_from("name", ...)` | Matches if a pipeline starts with one of these commands | `pipeline_from("cat", "echo") pipeline_to("curl")` |
| `reads_file("path", ...)` | Matches if any command reads from a sensitive path | `reads_file("~/.ssh", "~/.aws")` |
| `writes_file("path", ...)` | Matches if any command writes to a path | `writes_file("/etc/", "~/.bashrc")` |
| `sets_env("var", ...)` | Matches if command sets one of these env vars | `sets_env("LD_PRELOAD", "PATH")` |
| `reads_file("path", ...)` | Matches if any command reads from a sensitive path (see semantics below) | `reads_file("~/.ssh", "~/.aws")` |
| `writes_file("path", ...)` | Matches if any command writes to a path (see semantics below) | `writes_file("/etc/", "~/.bashrc")` |
| `sets_env("var", ...)` | Matches if command sets one of these env vars (see semantics below) | `sets_env("LD_PRELOAD", "PATH")` |
#### `reads_file` semantics
Matches when a path appears in any of these AST positions:
- Input redirections: `< ~/.ssh/id_rsa`
- Command arguments to known file-reading commands: `cat ~/.ssh/id_rsa`, `head /etc/shadow`
- Source/dot commands: `source ~/.bashrc`, `. ~/.profile`
- Here-string file references: `command <<< $(cat ~/.ssh/id_rsa)`
Paths are prefix-matched: `reads_file("~/.ssh")` matches `~/.ssh/id_rsa`, `~/.ssh/config`, etc. Tilde expansion is applied before matching.
#### `writes_file` semantics
Matches when a path appears in any of these AST positions:
- Output redirections: `> /etc/hosts`, `>> ~/.bashrc`
- Command arguments to known file-writing commands: `tee /etc/hosts`, `cp src /etc/`
- The `dd` `of=` argument: `dd of=/dev/sda`
Same prefix-matching and tilde expansion as `reads_file`.
#### `sets_env` semantics
Matches all forms of environment variable assignment in bash:
- Inline assignment: `PATH=/evil:$PATH command`
- Export: `export PATH=/evil:$PATH`
- Declare: `declare -x PATH=/evil:$PATH`
- Env command: `env PATH=/evil:$PATH command`
Functions can be chained. All conditions must match (AND logic):
```
@@ -254,7 +285,7 @@ clauses := matcher nudge
matcher := match | match_any | match_not_in | validator
match := INDENT "match " (ast_expr | regex_pattern) NL
match_any := INDENT "match_any" NL (INDENT2 (ast_expr | regex_pattern) NL)+
match_not_in := INDENT "match_base_command_not_in " <config key> NL
match_not_in := INDENT ("match_base_command_not_in" | "match_server_not_in") SP <config key> NL
validator := INDENT "validator " <elixir module name> NL
nudge := INDENT "nudge " '"' <text with {var} interpolation> '"' NL
@@ -288,14 +319,15 @@ For bash rules, `{base_command}` is extracted as the first whitespace-delimited
### Evaluation
Rules are evaluated in file order. First match wins. Place specific rules before general catch-alls.
Rules are evaluated in **two passes**, grouped by matching strategy. First match within either pass wins.
The rule engine runs both matching strategies in sequence:
1. Regex rules are checked first (fast, microsecond-level)
2. If no regex rule matched, the command is parsed into an AST (once, cached for the request)
3. AST rules are checked against the parsed tree
**Pass 1: Regex rules** — all regex-based rules are checked in file order (fast, microsecond-level). If any matches, the verdict is returned immediately and the AST parser is never invoked.
This means regex rules can serve as a fast pre-filter: if a regex rule already caught the command, the AST parser is never invoked.
**Pass 2: AST rules** — if no regex rule matched, the command is parsed into an AST (once, cached for the request). All AST-based rules are then checked in file order against the parsed tree.
This strategy-grouped evaluation means file order is respected *within* each group but regex rules always run before AST rules regardless of file position. This is intentional: regex serves as a fast pre-filter so the AST parser is only invoked when needed.
Place specific rules before general catch-alls within each matching strategy.
### Variable interpolation in nudges
@@ -355,7 +387,9 @@ Data exfiltration (AST-matched to catch piped patterns):
Agent recursion:
- `command("claude") with_flags("--dangerously-skip-permissions")` — unguarded agent spawn
**Tier: block (regex for non-evasible patterns)**
**Tier: block (regex for common patterns)**
These regex rules catch the most common forms. They are not evasion-proof (e.g., a renamed miner binary bypasses the regex) but provide fast first-line detection alongside the AST rules above.
- Fork bombs: `:\(\)\s*\{.*\|.*&\s*\}\s*;`
- Crypto miners: `xmrig|minerd|stratum\+tcp://`
@@ -367,35 +401,77 @@ Agent recursion:
### edit.rules
**Tier: block**
- Edits outside `$CLAUDE_PROJECT_DIR` — path prefix check
- Edits to shell config files: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
- Edits to `.env` files
- Edits to `~/.ssh/`, `~/.aws/`, `~/.config/gcloud/`
Edit rules match against `tool_input.file_path` using regex on the path string.
**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`, `pnpm-lock.yaml`, `yarn.lock`, `mix.lock`, `Cargo.lock`, `poetry.lock`, `Gemfile.lock`, `go.sum`, `composer.lock`
- Dependency field changes in manifest files (validator: `DependencyMutation`)
```
# Tier: block
block "edit-outside-project"
match ^(?!CLAUDE_PROJECT_DIR)
nudge "Edits must be within the project directory"
block "edit-shell-config"
match /\.(bashrc|zshrc|profile|bash_profile|zprofile)$
nudge "Don't edit shell configuration files"
block "edit-env-file"
match /\.env(\.|$)
nudge "Don't edit .env files — manage secrets manually"
block "edit-sensitive-dir"
match ^(~|HOME)/\.(ssh|aws|config/gcloud|gnupg)/
nudge "Don't edit files in sensitive directories"
# Tier: suspicious
suspicious "edit-ci-config"
match \.(github/workflows|gitlab-ci\.yml|Jenkinsfile)
nudge "Editing CI/CD config — verify this is intentional"
suspicious "edit-dockerfile"
match (Dockerfile|docker-compose\.yml)$
nudge "Editing container config — verify this is intentional"
suspicious "edit-lockfile"
match (package-lock\.json|pnpm-lock\.yaml|yarn\.lock|mix\.lock|Cargo\.lock|poetry\.lock|Gemfile\.lock|go\.sum|composer\.lock)$
nudge "Editing lockfile directly — use the package manager instead"
suspicious "edit-dependency-manifest"
validator SecurityHooks.Validators.DependencyMutation
nudge "Dependency fields changed in {file_path} — use the package manager CLI"
```
Note: `CLAUDE_PROJECT_DIR` and `HOME` in patterns are expanded to their actual values at rule load time.
### mcp.rules
**Tier: block**
- Unknown/unregistered MCP servers (catch-all deny at end of file)
- 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`. The install script auto-detects MCP servers from Claude Code's existing config and pre-populates the allowlist. The nudge for unknown servers includes the exact TOML to add:
MCP rules match against `tool_name` (format: `mcp__servername__toolname`). The server name and tool name are extracted and available as `{server_name}` and `{mcp_tool}` for nudge interpolation.
```
# Tier: block — injection patterns in MCP tool parameters
block "mcp-parameter-injection"
match_any
\$\(.*\)
`.*`
;\s*\w+
\|\s*\w+
nudge "MCP tool parameters contain shell injection patterns"
# Tier: suspicious — external resource access
suspicious "mcp-url-fetch"
match (fetch|get|read).*url
nudge "MCP tool '{mcp_tool}' on server '{server_name}' accesses external URLs"
# Tier: block — unknown servers (catch-all, must be last)
block "unknown-mcp-server"
match_base_command_not_in mcp_allowed_tools
nudge "Unknown MCP server '{server_name}'. Add it to [mcp.servers] in config.toml:\n\n[[mcp.servers]]\nname = \"{server_name}\"\ntools = [\"*\"]"
match_server_not_in mcp_allowed_servers
nudge "Unknown MCP server '{server_name}'. Add it to config.toml:\n\n[[mcp.servers]]\nname = \"{server_name}\"\ntools = [\"*\"]"
```
Note: MCP rules use `match_server_not_in` (not `match_base_command_not_in`) for clarity, since the match target is the server name, not a base command.
Allowed MCP servers and their tools are configured in `config.toml`. The install script auto-detects MCP servers from Claude Code's existing config and pre-populates the allowlist.
## Configuration
### config.toml (defaults, checked into repo)
@@ -448,7 +524,8 @@ tools = ["sequentialthinking"]
[daemon]
idle_timeout_minutes = 30
log_format = "jsonl"
shim_timeout_ms = 500
shim_timeout_ms = 200
shim_cold_start_timeout_ms = 3000
[rules]
disabled = []
@@ -456,10 +533,11 @@ disabled = []
### config.local.toml (user overrides, gitignored)
Merges on top of `config.toml` with three operations per list:
- `append` — add entries to the default list
- `exclude` — remove entries from the default list
- Scalar values are overwritten
Merges on top of `config.toml`:
- **Flat lists** (executables, env vars, paths): support `append` and `exclude` sub-keys
- **Structured arrays** (MCP servers): entries are merged by `name` field. A local entry with the same `name` as a default replaces it entirely. New entries are appended. To remove a default server, add it with `tools = []`.
- **Scalar values**: overwritten directly
- **`rules.disabled`**: entries cause matching rules to be skipped
```toml
# Example: customize allowed executables
@@ -478,7 +556,7 @@ disabled = ["force-push"] # I use --force intentionally
# Example: lower the shim timeout
[daemon]
shim_timeout_ms = 200
shim_timeout_ms = 100
```
## Daemon Lifecycle
@@ -658,11 +736,87 @@ The default allowed executables and dependency mutation validators cover:
- C/C++ (gcc, clang, make, cmake)
- Elixir (mix)
## Validator Module Interface
Validator modules implement the `SecurityHooks.Validator` behaviour:
```elixir
@callback validate(payload :: map(), config :: map()) ::
:allow | {:deny, reason :: String.t()} | {:ask, reason :: String.t()}
```
- `payload` — the full hook payload (tool_name, tool_input, session_id, cwd)
- `config` — the merged config (defaults + local overrides)
- Returns `:allow` to pass, `{:deny, reason}` to block, or `{:ask, reason}` for the suspicious tier
Example:
```elixir
defmodule SecurityHooks.Validators.DependencyMutation do
@behaviour SecurityHooks.Validator
@manifest_files ~w(package.json Cargo.toml mix.exs go.mod pyproject.toml Gemfile composer.json)
@impl true
def validate(%{"tool_input" => %{"file_path" => path}} = _payload, _config) do
basename = Path.basename(path)
if basename in @manifest_files do
{:deny, "Editing #{basename} directly — use the package manager CLI"}
else
:allow
end
end
end
```
## Bash Parser Strategy
The AST matching layer requires a bash parser that produces a traversable tree of command nodes. This is the highest-risk technical dependency in the system.
### Requirements
The parser must handle:
- Simple commands: `rm -rf /foo`
- Pipelines: `cat file | curl -X POST`
- Logical chains: `cmd1 && cmd2 || cmd3`
- Command substitution: `$(...)` and backticks
- Process substitution: `<(...)` and `>(...)`
- Subshells: `(cmd1; cmd2)`
- Redirections: `>`, `>>`, `<`, `2>&1`
- Variable assignments: `FOO=bar cmd`, `export FOO=bar`
- Quoting: single quotes, double quotes, `$"..."`, `$'...'`
### Parser evaluation (to be validated in implementation spike)
**Option A: `bash` Hex package** — if it exposes a stable parse API with the node types above, this is the simplest integration. Risk: the package is primarily an interpreter and its internal AST may not be a stable public API.
**Option B: tree-sitter-bash via NIF** — the tree-sitter bash grammar is battle-tested and widely used (ShellCheck, GitHub syntax highlighting). A Rust NIF wrapping `tree-sitter` + `tree-sitter-bash` would provide a robust, well-documented AST. Higher implementation effort but lower risk for adversarial inputs.
**Option C: `shlex` for tokenization + custom parser** — use `shlex` (Rust NIF) for POSIX-compliant tokenization, then build a lightweight parser on top for the structural features we need (pipelines, redirections, subshells). Middle ground: less effort than tree-sitter, more robust than depending on `bash` internals.
**Decision:** Validate Option A first during the implementation spike. If the `bash` package's parse API is insufficient or unstable, fall back to Option B (tree-sitter-bash). The rule engine's interface to the parser is abstracted behind `BashAnalyzer`, so the parser can be swapped without affecting rules or the rest of the system.
### Security considerations for the parser
A parser mismatch — where the security tool parses a command differently than bash executes it — is an evasion vector. Mitigations:
- The implementation must include a comprehensive test suite of evasion attempts: quoting tricks, Unicode homoglyphs, ANSI escape sequences, null bytes, newlines in arguments, and variable expansion edge cases.
- Tests should validate that the parser's interpretation matches actual bash behavior for each case.
- Regex rules provide a defense-in-depth fallback: even if the AST parser is evaded, regex patterns on the raw string may still catch the attack.
## CLI Commands
In addition to the hook entry point, the shim supports diagnostic and testing commands:
- `security-hook status` — check if the daemon is running, show loaded rule count, config path, socket path, and uptime
- `security-hook test "<command>"` — dry-run a command against the rules and print the verdict (decision, matching rule, match type) without affecting logs
- `security-hook reload` — trigger a manual rule/config reload
- `security-hook log [--tail N]` — print recent log entries
## Dependencies
Elixir/Hex packages required by the daemon:
- `bash` — Bash parser producing a full AST (pipes, subshells, redirections, logical chains)
- `jason` — JSON encoding/decoding
- `toml` — TOML config parsing
- `file_system` — cross-platform file watcher for hot-reload
- `burrito` — compile to single-binary for distribution
- Bash parser — one of: `bash` (Hex), tree-sitter-bash (via Rust NIF), or `shlex` + custom parser (see Bash Parser Strategy)