chore: add agentic tooling, cleanup
This commit is contained in:
995
docs/specs/2026-03-26-security-hooks-design.md
Normal file
995
docs/specs/2026-03-26-security-hooks-design.md
Normal file
@@ -0,0 +1,995 @@
|
||||
# Security Hooks for AI Coding Agents
|
||||
|
||||
A general-purpose, distributable set of security hooks for AI coding agents (Claude Code, Gemini CLI, Codex) that catch prompt injection, agent autonomy drift, supply chain attacks, and data exfiltration. Ships as a single binary (Elixir daemon via Burrito) with a shell shim, adapter layer for multi-tool support, a custom rule DSL, and layered command analysis (regex + AST parsing).
|
||||
|
||||
## Threat Model
|
||||
|
||||
1. **Prompt injection via untrusted content** — a 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
|
||||
|
||||
Four components:
|
||||
|
||||
### 1. Shim (`security-hook`)
|
||||
|
||||
A small Rust binary (~1MB static, <1ms startup) that AI coding tools invoke as a hook command. Rust is chosen over bash to avoid shell quoting bugs, over Elixir escript to avoid ~300ms BEAM boot, and over a second Burrito binary to avoid unpack-on-every-invocation overhead. Since tree-sitter-bash already requires Rust in the build toolchain, this adds no new dependencies.
|
||||
|
||||
The shim:
|
||||
|
||||
- Accepts an `--adapter` flag to specify the calling tool (`claude`, `gemini`, `codex`)
|
||||
- Reads the JSON hook payload from stdin
|
||||
- Connects to the daemon's Unix socket and sends the payload with the adapter name
|
||||
- Reads the daemon's response and prints it to stdout
|
||||
- Handles timeouts and fail-closed behavior natively (no shell `timeout` command)
|
||||
|
||||
It does not manage daemon lifecycle. That responsibility belongs to the platform service manager (see Daemon Lifecycle below).
|
||||
|
||||
Usage:
|
||||
```
|
||||
security-hook --adapter claude pre bash # Claude Code
|
||||
security-hook --adapter gemini pre bash # Gemini CLI
|
||||
security-hook --adapter codex pre bash # Codex
|
||||
```
|
||||
|
||||
**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). 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.
|
||||
|
||||
### 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, 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 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)
|
||||
- **Logger** — writes JSONL to `$XDG_STATE_HOME/security-hooks/hook.log` (macOS: `~/Library/Logs/security-hooks/hook.log`)
|
||||
|
||||
### 3. Adapter layer
|
||||
|
||||
The adapter layer lives inside the daemon and handles the differences between Claude Code, Gemini CLI, and Codex. Each adapter module implements two functions:
|
||||
|
||||
1. **`normalize_input/1`** — transforms the tool-specific JSON payload into a common internal format
|
||||
2. **`format_output/2`** — transforms the daemon's internal verdict into the tool-specific response format
|
||||
|
||||
#### Common internal payload
|
||||
|
||||
All adapters normalize to this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"adapter": "claude",
|
||||
"event": "pre_tool_use",
|
||||
"tool": "bash",
|
||||
"input": {"command": "rm -rf /"},
|
||||
"cwd": "/project",
|
||||
"session_id": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
#### Common internal verdict
|
||||
|
||||
The rule engine returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "deny",
|
||||
"rule": "destructive-rm",
|
||||
"match_type": "ast",
|
||||
"reason": "destructive rm detected",
|
||||
"nudge": "Use trash-cli or move to a temp directory"
|
||||
}
|
||||
```
|
||||
|
||||
#### Adapter output differences
|
||||
|
||||
**Claude Code adapter** — wraps verdict in `hookSpecificOutput`:
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "destructive rm detected",
|
||||
"additionalContext": "Use trash-cli or move to a temp directory"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Gemini CLI adapter** — uses flat `decision` field:
|
||||
```json
|
||||
{
|
||||
"decision": "deny",
|
||||
"reason": "destructive rm detected",
|
||||
"message": "Use trash-cli or move to a temp directory"
|
||||
}
|
||||
```
|
||||
|
||||
**Codex adapter** — uses exit code 2 for deny, writes reason to stderr, no stdout JSON needed. For allow, exits 0 with empty stdout.
|
||||
|
||||
#### Event name mapping
|
||||
|
||||
| Internal event | Claude Code | Gemini CLI | Codex |
|
||||
|---------------|------------|------------|-------|
|
||||
| `pre_tool_use` | `PreToolUse` | `BeforeTool` | `PreToolUse` |
|
||||
| `post_tool_use` | `PostToolUse` | `AfterTool` | `PostToolUse` |
|
||||
| `session_start` | `SessionStart` | `SessionStart` | `SessionStart` |
|
||||
|
||||
#### Payload field mapping
|
||||
|
||||
| Internal field | Claude Code | Gemini CLI | Codex |
|
||||
|---------------|------------|------------|-------|
|
||||
| `tool` | `tool_name` | `tool_name` | `tool_name` |
|
||||
| `input` | `tool_input` | `tool_input` | `tool_input` |
|
||||
| `cwd` | `cwd` | `cwd` (or `$GEMINI_CWD`) | `cwd` |
|
||||
| `session_id` | `session_id` | `session_id` (or `$GEMINI_SESSION_ID`) | `session_id` |
|
||||
|
||||
### 4. Rule files
|
||||
|
||||
Two kinds:
|
||||
- **Pattern rules** in `.rules` files using a custom DSL (see Rule Format below). Rules can use regex patterns for simple matching or AST functions for structural analysis.
|
||||
- **Validator modules** in `daemon/lib/security_hooks/validators/*.ex` for complex logic that cannot be expressed in the DSL. These are compiled into the binary at build time — not loaded dynamically — to prevent code injection into the security daemon.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
When installed, the entire tree below is copied to `$SECURITY_HOOKS_HOME` (default: `~/.config/security-hooks/`). The daemon discovers both `rules/` and `config/` as subdirectories of `$SECURITY_HOOKS_HOME`. This single env var controls all path resolution.
|
||||
|
||||
```
|
||||
security-hooks/
|
||||
├── shim/ # Rust shim binary
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/
|
||||
│ └── main.rs
|
||||
├── service/
|
||||
│ ├── security-hookd.service # systemd user service unit
|
||||
│ ├── security-hookd.socket # systemd socket activation unit
|
||||
│ └── com.security-hooks.daemon.plist # macOS launchd agent
|
||||
├── rules/
|
||||
│ ├── bash.rules # bash command rules
|
||||
│ ├── edit.rules # file edit rules
|
||||
│ └── mcp.rules # MCP tool rules
|
||||
├── 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
|
||||
│ │ │ ├── bash_analyzer.ex # AST parsing
|
||||
│ │ │ ├── file_watcher.ex
|
||||
│ │ │ ├── config.ex
|
||||
│ │ │ ├── logger.ex
|
||||
│ │ │ ├── adapters/ # tool-specific adapters
|
||||
│ │ │ │ ├── claude.ex
|
||||
│ │ │ │ ├── gemini.ex
|
||||
│ │ │ │ └── codex.ex
|
||||
│ │ │ └── validators/
|
||||
│ │ │ ├── unknown_executable.ex
|
||||
│ │ │ ├── dependency_mutation.ex
|
||||
│ │ │ ├── secret_access.ex
|
||||
│ │ │ └── mcp_parameter_injection.ex
|
||||
│ │ └── security_hooks.ex
|
||||
│ ├── mix.exs
|
||||
│ └── test/
|
||||
├── install.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Hook Registration
|
||||
|
||||
`install.sh` auto-detects which AI coding tools are installed and registers hooks for each.
|
||||
|
||||
### Claude Code (`~/.claude/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter claude pre bash"}]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter claude pre edit"}]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter claude pre mcp"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini CLI (`~/.gemini/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "shell",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter gemini pre bash"}]
|
||||
},
|
||||
{
|
||||
"matcher": "edit|write",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter gemini pre edit"}]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp_.*",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter gemini pre mcp"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codex (`~/.codex/hooks.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": "security-hook --adapter codex pre bash"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Codex currently only supports Bash hooks in PreToolUse/PostToolUse. Edit and MCP hooks will be added when Codex expands its hook events.
|
||||
|
||||
### PostToolUse hook
|
||||
|
||||
Deferred to a future version. Post-tool-use linting is project-specific and requires its own design pass.
|
||||
|
||||
### Response format
|
||||
|
||||
The adapter layer translates the daemon's internal verdict into tool-specific responses. The examples below show Claude Code format; see the Adapter Layer section for Gemini and Codex formats.
|
||||
|
||||
PreToolUse allow:
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PreToolUse deny (tier: block):
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "destructive rm detected (in subshell)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rule Format: `.rules` DSL
|
||||
|
||||
Rules use a custom DSL designed so that regex patterns are never quoted (everything after `match ` to end of line is the pattern, verbatim) and AST-based structural matching uses readable function syntax.
|
||||
|
||||
### Matching strategies
|
||||
|
||||
The rule loader inspects each `match` value to determine the matching strategy:
|
||||
|
||||
- **Regex path** — if the value does not start with a known DSL function name, it is treated as a regex pattern matched against the raw input string. Fast, good for simple patterns.
|
||||
- **AST path** — if the value starts with a DSL function (`command(`, `pipeline_to(`, `reads_file(`, etc.), the command is parsed into an AST using the `bash` Hex package, and the function is evaluated against the tree. This catches evasion via subshells, pipes, quoting tricks, and obfuscation.
|
||||
|
||||
The two paths are distinguished unambiguously: regex patterns will never start with `identifier(`.
|
||||
|
||||
For bash rules specifically, the AST parser walks the full command tree — including subshells `$(...)`, pipes `|`, logical chains `&&`/`||`, and process substitution `<(...)` — to find matching command nodes regardless of nesting depth.
|
||||
|
||||
### Syntax
|
||||
|
||||
```
|
||||
# Regex matching — pattern is everything after "match " to end of line
|
||||
block "fork-bomb"
|
||||
match :\(\)\s*\{.*\|.*&\s*\}\s*;
|
||||
nudge "Fork bomb detected"
|
||||
|
||||
# AST matching — structural analysis of parsed command
|
||||
block "destructive-rm"
|
||||
match command("rm") with_flags("-r", "-rf", "-fr")
|
||||
nudge "Use trash-cli or move to a temp directory"
|
||||
|
||||
block "pipe-to-exfil"
|
||||
match pipeline_to("curl", "wget", "nc")
|
||||
nudge "Don't pipe output to network commands"
|
||||
|
||||
block "curl-data-upload"
|
||||
match command("curl") with_flags("-d", "--data", "-F", "--form")
|
||||
nudge "Don't upload data via curl — only downloads are allowed"
|
||||
|
||||
block "eval-obfuscation"
|
||||
match command("eval", "exec")
|
||||
nudge "Don't use eval/exec — run the command directly"
|
||||
|
||||
# Regex is fine for things that can't be obfuscated
|
||||
block "agent-recursion"
|
||||
match claude\s+.*--dangerously-skip-permissions
|
||||
nudge "Don't spawn Claude without permission checks"
|
||||
|
||||
# Config-referenced matching
|
||||
suspicious "unknown-executable"
|
||||
match_base_command_not_in allowed_executables
|
||||
nudge "Unknown command '{base_command}'. Add it to allowed_executables in config.toml"
|
||||
|
||||
# Elixir validator for complex logic
|
||||
block "dependency-mutation"
|
||||
validator SecurityHooks.Validators.DependencyMutation
|
||||
nudge "Don't modify dependencies directly — use the package manager CLI"
|
||||
```
|
||||
|
||||
### AST match functions
|
||||
|
||||
These functions operate on the parsed AST of a bash command. They match against any command node in the tree, including those nested inside subshells, pipelines, and logical chains.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `command("name", ...)` | Matches if any command node has one of the given executables | `command("rm", "rmdir")` |
|
||||
| `with_flags("flag", ...)` | Modifier: command must also have one of these flags | `command("rm") with_flags("-r", "-rf")` |
|
||||
| `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 (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 matched by **directory boundary**, not string prefix: `reads_file("~/.ssh")` matches `~/.ssh/id_rsa` and `~/.ssh/config`, but does not match `~/.ssh_backup/key` or `~/.sshrc`. The rule path is treated as a directory — the candidate path must either equal it exactly or have it as an ancestor with a `/` separator.
|
||||
|
||||
Tilde expansion uses the `HOME` value from the hook payload (the user's environment), not the daemon's process environment. This ensures correct resolution in containerized or remote SSH scenarios.
|
||||
|
||||
#### `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 directory-boundary 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):
|
||||
```
|
||||
block "exfil-secrets-via-curl"
|
||||
match pipeline_from("cat", "echo") pipeline_to("curl", "wget")
|
||||
nudge "Don't pipe local data to network commands"
|
||||
```
|
||||
|
||||
`match_any` works with both regex and AST functions:
|
||||
```
|
||||
block "privilege-escalation"
|
||||
match_any
|
||||
command("sudo")
|
||||
command("su") with_flags("-")
|
||||
command("chmod") with_args_matching("777|u\+s")
|
||||
command("chown") with_args_matching("root")
|
||||
nudge "Privilege escalation is not allowed"
|
||||
```
|
||||
|
||||
### 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
|
||||
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" | "match_server_not_in") SP <config key> NL
|
||||
validator := INDENT "validator " <elixir module name> NL
|
||||
nudge := INDENT "nudge " '"' <text with {var} interpolation> '"' NL
|
||||
|
||||
ast_expr := ast_func (SP ast_func)*
|
||||
ast_func := IDENT '(' quoted_args ')' [SP modifier]*
|
||||
modifier := IDENT '(' quoted_args ')'
|
||||
quoted_args := '"' <text> '"' (',' SP '"' <text> '"')*
|
||||
regex_pattern := <any text not starting with IDENT '('> <to end of line>
|
||||
```
|
||||
|
||||
`INDENT` = 2 spaces, `INDENT2` = 4 spaces.
|
||||
|
||||
**Note:** `only_when` / `except_when` conditions are deferred to a future version.
|
||||
|
||||
### Config key resolution
|
||||
|
||||
Config keys in `match_*_not_in` clauses map to TOML paths as follows:
|
||||
- `match_base_command_not_in allowed_executables` → reads `executables.allowed` (flat list of strings)
|
||||
- `match_server_not_in mcp_allowed_servers` → extracts the `name` field from all `[[mcp.servers]]` entries
|
||||
|
||||
This mapping is hardcoded in the rule engine. Custom config keys are not supported in v1.
|
||||
|
||||
### `with_args_matching` semantics
|
||||
|
||||
The `with_args_matching(regex)` modifier matches the regex against the joined argument string (space-separated) of the matched command node, excluding the command name and flags. For example, for `git push --force origin main`, the argument string is `push --force origin main`.
|
||||
|
||||
### 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 | Default match target | Notes |
|
||||
|-----------|---------------------|-------|
|
||||
| bash.rules | `tool_input.command` | Regex matches raw string; AST functions match parsed tree |
|
||||
| edit.rules | `tool_input.file_path` | Regex matches the file path string |
|
||||
| mcp.rules | `tool_name` for server/tool identification; serialized `tool_input` (JSON string) for parameter inspection | `match_server_not_in` extracts server name from `tool_name`; regex `match`/`match_any` matches against JSON-serialized `tool_input` to detect injection patterns |
|
||||
|
||||
For bash rules, `{base_command}` is extracted as the first whitespace-delimited token of `tool_input.command` after stripping leading environment variable assignments. For AST-matched rules, it is extracted from the parsed command node.
|
||||
|
||||
### Evaluation
|
||||
|
||||
Rules are evaluated in **two passes**, grouped by matching strategy. First match within either pass wins.
|
||||
|
||||
**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. Note: regex rules can only deny or flag as suspicious — they never produce an `allow` verdict. A regex false positive blocks a safe command (annoying but not a security hole); it can never greenlight a dangerous one.
|
||||
|
||||
All regex patterns are compiled with a 1ms evaluation timeout to prevent catastrophic backtracking from becoming a denial-of-service vector. If a regex times out, the rule is skipped (not matched), and the command falls through to the AST pass.
|
||||
|
||||
**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
|
||||
|
||||
- `{command}` — the full command string (Bash hooks)
|
||||
- `{base_command}` — the first token / primary executable
|
||||
- `{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 (AST-matched where evasion is a concern)**
|
||||
|
||||
Destructive filesystem operations:
|
||||
- `command("rm") with_flags("-r", "-rf", "-fr")` — recursive delete
|
||||
- `command("mkfs")` — format filesystem
|
||||
- `command("dd") with_args_matching("of=/dev/")` — raw disk write
|
||||
|
||||
Git history destruction:
|
||||
- `command("git") with_args_matching("push\\s+.*--force(?!-with-lease)")` — force push (not `--force-with-lease`)
|
||||
- `command("git") with_args_matching("reset\\s+--hard")` — hard reset
|
||||
- `command("git") with_args_matching("clean\\s+.*-f")` — force clean
|
||||
|
||||
Package registry attacks:
|
||||
- `command("npm") with_args_matching("unpublish")` — npm unpublish
|
||||
- `command("gem") with_args_matching("yank")` — gem yank
|
||||
- `command("cargo") with_args_matching("yank")` — cargo yank
|
||||
|
||||
Cloud resource deletion:
|
||||
- `command("aws") with_args_matching("delete-|terminate-|destroy")` — AWS destructive ops
|
||||
- `command("gcloud") with_args_matching("delete")` — GCloud destructive ops
|
||||
- `command("az") with_args_matching("delete")` — Azure destructive ops
|
||||
- `command("fly") with_args_matching("destroy")` — Fly.io destructive ops
|
||||
|
||||
Privilege escalation:
|
||||
```
|
||||
block "privilege-escalation"
|
||||
match_any
|
||||
command("sudo")
|
||||
command("su") with_flags("-")
|
||||
command("chmod") with_args_matching("777|u\\+s|4[0-7]{3}")
|
||||
command("chown") with_args_matching("root")
|
||||
nudge "Privilege escalation is not allowed"
|
||||
```
|
||||
|
||||
Environment variable poisoning:
|
||||
- `sets_env("LD_PRELOAD", "LD_LIBRARY_PATH", "PATH", "NODE_OPTIONS", "PYTHONPATH", "RUBYOPT")`
|
||||
|
||||
Data exfiltration (AST-matched to catch piped patterns):
|
||||
- `command("curl") with_flags("-d", "--data", "-F", "--form", "--upload-file")` — data upload
|
||||
- `command("wget") with_flags("--post-data", "--post-file")` — data upload
|
||||
- `pipeline_to("curl", "wget", "nc", "ncat")` — piping to network commands
|
||||
- `reads_file("~/.ssh", "~/.aws/credentials", "~/.config/gcloud", "~/.netrc")` — sensitive file access
|
||||
|
||||
Agent recursion:
|
||||
- `command("claude") with_flags("--dangerously-skip-permissions")` — unguarded agent spawn
|
||||
|
||||
**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://`
|
||||
|
||||
**Tier: suspicious**
|
||||
|
||||
- Unknown base command not in allowed executables list (`match_base_command_not_in`)
|
||||
- Long base64-encoded strings: `[A-Za-z0-9+/]{100,}={0,2}` (obfuscation signal)
|
||||
|
||||
### edit.rules
|
||||
|
||||
Edit rules match against `tool_input.file_path` using regex on the path string.
|
||||
|
||||
```
|
||||
# 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. `CLAUDE_PROJECT_DIR` is derived from the `cwd` field of each hook payload (the working directory of the Claude Code session). `HOME` is taken from the process environment. If `cwd` is unavailable, the `edit-outside-project` rule is skipped (not fail-closed, since blocking all edits would be overly disruptive).
|
||||
|
||||
### mcp.rules
|
||||
|
||||
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"
|
||||
validator SecurityHooks.Validators.McpParameterInjection
|
||||
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_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)
|
||||
|
||||
```toml
|
||||
[meta]
|
||||
version = "1.0.0"
|
||||
|
||||
[executables]
|
||||
allowed = [
|
||||
"git", "mix", "elixir", "iex", "cargo", "rustc",
|
||||
"go", "python", "pip", "uv", "node", "npm", "pnpm", "yarn",
|
||||
"rg", "fd", "jq", "cat", "ls", "head", "tail", "wc", "sort", "uniq",
|
||||
"mkdir", "cp", "mv", "touch", "echo", "grep", "sed", "awk",
|
||||
"make", "cmake", "gcc", "clang",
|
||||
"ruby", "gem", "bundler", "rake",
|
||||
"php", "composer",
|
||||
"java", "javac", "mvn", "gradle",
|
||||
"curl", "wget",
|
||||
]
|
||||
|
||||
[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",
|
||||
]
|
||||
|
||||
[[mcp.servers]]
|
||||
name = "context7"
|
||||
tools = ["resolve-library-id", "query-docs"]
|
||||
|
||||
[[mcp.servers]]
|
||||
name = "sequential-thinking"
|
||||
tools = ["sequentialthinking"]
|
||||
|
||||
[daemon]
|
||||
idle_timeout_minutes = 30
|
||||
log_format = "jsonl"
|
||||
shim_timeout_ms = 200
|
||||
shim_cold_start_timeout_ms = 3000
|
||||
|
||||
[rules]
|
||||
disabled = []
|
||||
```
|
||||
|
||||
### config.local.toml (user overrides, gitignored)
|
||||
|
||||
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
|
||||
[executables]
|
||||
append = ["my-custom-tool", "deno", "bun"]
|
||||
exclude = ["curl", "wget"] # force these through AST exfil checks only
|
||||
|
||||
# Example: add project-specific MCP servers
|
||||
[[mcp.servers]]
|
||||
name = "my-internal-tool"
|
||||
tools = ["*"]
|
||||
|
||||
# Example: disable specific rules
|
||||
[rules]
|
||||
disabled = ["force-push"] # I use --force intentionally
|
||||
|
||||
# Example: lower the shim timeout
|
||||
[daemon]
|
||||
shim_timeout_ms = 100
|
||||
```
|
||||
|
||||
## Daemon Lifecycle
|
||||
|
||||
The daemon is managed by the OS service manager where available, with a portable fallback. The `install.sh` script detects the platform and installs the appropriate mechanism.
|
||||
|
||||
### Linux/WSL: systemd user service + socket activation
|
||||
|
||||
`install.sh` installs two systemd user units:
|
||||
|
||||
**`~/.config/systemd/user/security-hookd.socket`** — systemd holds the socket open at all times. When the first connection arrives, systemd starts the daemon and hands over the file descriptor. Zero cold-start latency from the caller's perspective.
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Security Hooks socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=%t/security-hooks/sock
|
||||
SocketMode=0600
|
||||
DirectoryMode=0700
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
```
|
||||
|
||||
**`~/.config/systemd/user/security-hookd.service`** — the daemon unit. `Restart=on-failure` handles crashes automatically. No PID files, no health checks, no lock files.
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Security Hooks daemon
|
||||
Requires=security-hookd.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/security-hookd
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
Environment=SECURITY_HOOKS_HOME=%h/.config/security-hooks
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
After install: `systemctl --user enable --now security-hookd.socket`
|
||||
|
||||
### macOS: launchd plist
|
||||
|
||||
`install.sh` installs a launchd agent:
|
||||
|
||||
**`~/Library/LaunchAgents/com.security-hooks.daemon.plist`** — `KeepAlive` restarts on crash. The `Sockets` key provides socket activation analogous to systemd.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.security-hooks.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/USERNAME/.local/bin/security-hookd</string>
|
||||
</array>
|
||||
<key>Sockets</key>
|
||||
<dict>
|
||||
<key>Listeners</key>
|
||||
<dict>
|
||||
<key>SockPathName</key>
|
||||
<string>TMPDIR/security-hooks/sock</string>
|
||||
<key>SockPathMode</key>
|
||||
<integer>384</integer> <!-- 0600 -->
|
||||
</dict>
|
||||
</dict>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>SECURITY_HOOKS_HOME</key>
|
||||
<string>/Users/USERNAME/.config/security-hooks</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Note: `USERNAME` and `TMPDIR` placeholders are expanded by `install.sh` at install time. launchd does not expand tilde or environment variables in plist values.
|
||||
|
||||
After install: `launchctl load ~/Library/LaunchAgents/com.security-hooks.daemon.plist`
|
||||
|
||||
### Fallback: shim-managed daemon
|
||||
|
||||
When neither systemd nor launchd is available (rare — containers, minimal VMs), the shim falls back to managing the daemon directly:
|
||||
|
||||
- Acquires a lock file (`$RUNTIME_DIR/security-hooks/lock`) via `flock` (Linux) or `shlock` (macOS) before starting
|
||||
- The daemon writes its PID to `$RUNTIME_DIR/security-hooks/pid`
|
||||
- The shim checks socket connectivity; if the PID file is stale, it kills the old process and restarts
|
||||
- Race condition between concurrent sessions is handled by the lock file
|
||||
|
||||
This path is less reliable than the service manager paths and is documented as a fallback only.
|
||||
|
||||
### Common lifecycle behavior (all platforms)
|
||||
|
||||
**Idle shutdown:** The daemon exits after 30 minutes of inactivity (configurable via `daemon.idle_timeout_minutes`). The service manager restarts it on the next connection via socket activation. Handles `SIGTERM` gracefully — flushes pending log writes.
|
||||
|
||||
**Hot-reload:** A `FileSystem` watcher monitors `rules/` and `config/` directories. On change, the rule engine reloads rules and config without restarting the daemon. The AST function registry is also refreshed. Users see updated rules on the next tool call.
|
||||
|
||||
**Socket activation support in the daemon:** The daemon checks for an inherited file descriptor (systemd: `$LISTEN_FDS`, launchd: `launch_activate_socket`). If present, it uses the inherited socket. Otherwise, it opens its own (fallback path).
|
||||
|
||||
**Logging:** All decisions are written as JSONL:
|
||||
|
||||
```jsonl
|
||||
{"ts":"2026-03-27T14:02:03Z","event":"PreToolUse","tool":"Bash","input":"rm -rf $(echo /)","rule":"destructive-rm","match_type":"ast","decision":"deny","nudge":"Use trash-cli or move to a temp directory"}
|
||||
{"ts":"2026-03-27T14:02:05Z","event":"PreToolUse","tool":"Bash","input":"mix test","rule":null,"match_type":null,"decision":"allow"}
|
||||
```
|
||||
|
||||
The `match_type` field records whether the rule was matched via `regex`, `ast`, `config_list`, or `validator` — useful for understanding which matching layer caught a command and for tuning rules.
|
||||
|
||||
Log rotation and size limits are deferred — users can manage this with external tools (logrotate, etc.) since the log path is well-defined.
|
||||
|
||||
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 both binaries (`security-hookd` daemon + `security-hook` 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. Auto-detects installed MCP servers from Claude Code/Gemini CLI config and pre-populates the MCP allowlist in `config.local.toml`
|
||||
6. Detects the platform and installs the appropriate service manager integration:
|
||||
- Linux/WSL with systemd: installs user units, enables socket activation
|
||||
- macOS: installs launchd plist, loads agent
|
||||
- Fallback: prints instructions noting the shim will manage the daemon directly
|
||||
7. Auto-detects installed AI coding tools and registers hooks for each:
|
||||
- Claude Code: merges into `~/.claude/settings.json`
|
||||
- Gemini CLI: merges into `~/.gemini/settings.json`
|
||||
- Codex: merges into `~/.codex/hooks.json`
|
||||
- Preserves existing hooks in all tools
|
||||
8. Prints a summary: which tools were detected, how many rules loaded, which adapter(s) registered
|
||||
|
||||
**Existing hooks:** All three tools support multiple hooks per event. `install.sh` appends security-hooks entries without removing existing user hooks.
|
||||
|
||||
**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
|
||||
|
||||
- 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)
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### `McpParameterInjection` validator
|
||||
|
||||
Scans all string values in MCP `tool_input` for shell injection patterns. Rather than using regex on serialized JSON (which is fragile), it iterates over parameter values and runs any string that looks like it could be shell-executed through the BashAnalyzer. If the AST contains command substitutions, pipes, or semicolons, it denies. This gives MCP parameter checking the same evasion resistance as bash command checking.
|
||||
|
||||
## 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 options
|
||||
|
||||
**Option A: tree-sitter-bash via Rust NIF (recommended)** — the tree-sitter bash grammar is battle-tested and widely used (ShellCheck, GitHub syntax highlighting, every major editor). A Rust NIF wrapping `tree-sitter` + `tree-sitter-bash` provides a robust, well-documented AST with concrete node types (command, pipeline, subshell, redirected_statement, variable_assignment, etc.). The grammar is maintained by the tree-sitter community and handles adversarial inputs well. The Rust NIF compiles cleanly into the Burrito binary.
|
||||
|
||||
**Option B: `bash` Hex package** — simpler integration if it exposes a stable parse API. Risk: the package is primarily a bash interpreter and its internal AST may not be a stable public API. Suitable as a fallback if the tree-sitter NIF proves too complex to build.
|
||||
|
||||
**Option C: `shlex` for tokenization + custom parser** — use `shlex` (Rust NIF) for POSIX-compliant tokenization, then build a lightweight parser for structural features. Less robust than tree-sitter for deeply nested or adversarial inputs.
|
||||
|
||||
**Decision:** Use tree-sitter-bash via Rust NIF as the primary parser. The tree-sitter grammar is the most battle-tested option and is the standard choice for security-sensitive shell analysis. The rule engine's interface to the parser is abstracted behind `BashAnalyzer`, so the parser can be swapped if needed.
|
||||
|
||||
### 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:
|
||||
- `jason` — JSON encoding/decoding
|
||||
- `toml` — TOML config parsing
|
||||
- `file_system` — cross-platform file watcher for hot-reload
|
||||
- `burrito` — compile to single-binary for distribution
|
||||
|
||||
Rust (compiled into the Burrito binary as a NIF, and used standalone for the shim):
|
||||
- `tree-sitter` + `tree-sitter-bash` — bash AST parser for structural command analysis (primary parser, see Bash Parser Strategy)
|
||||
|
||||
Shim binary (`security-hook`, Rust):
|
||||
- `std::os::unix::net::UnixStream` — Unix socket client (stdlib, no external deps)
|
||||
- `serde_json` — JSON parsing
|
||||
- Cross-compiled for the same platform targets as the daemon
|
||||
Reference in New Issue
Block a user