Add hybrid daemon lifecycle: systemd, launchd, fallback
- Linux/WSL: systemd user service + socket activation for zero cold-start latency and automatic crash recovery - macOS: launchd plist with KeepAlive and socket activation - Fallback: shim-managed with lock file (containers, minimal VMs) - Shell shim simplified — no longer manages daemon lifecycle - Daemon detects inherited file descriptors for socket activation - Add service/ directory with unit files and plist template - Update install.sh to detect platform and install appropriate service Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Security Hooks for Claude Code
|
# 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.
|
A general-purpose, distributable set of Claude Code hooks 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, a custom rule DSL, and layered command analysis (regex + AST parsing).
|
||||||
|
|
||||||
## Threat Model
|
## Threat Model
|
||||||
|
|
||||||
1. **Prompt injection via untrusted content** — malicious README, fetched webpage, or MCP response tricks the agent into running harmful commands
|
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)
|
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
|
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
|
4. **Data exfiltration** — the agent leaks secrets, env vars, or private code to external services
|
||||||
@@ -19,14 +19,16 @@ A short bash script that Claude Code invokes as a hook command. It:
|
|||||||
|
|
||||||
- Reads the JSON hook payload from stdin
|
- Reads the JSON hook payload from stdin
|
||||||
- Sends it to the daemon over a Unix socket
|
- 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
|
- 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.
|
The shim is deliberately simple — it does not manage daemon lifecycle. That responsibility belongs to the platform service manager (see Daemon Lifecycle below).
|
||||||
|
|
||||||
Socket path defaults:
|
**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.
|
||||||
- Linux/WSL: `$XDG_RUNTIME_DIR/security-hooks/sock` (fallback: `/tmp/security-hooks-$UID/sock`)
|
|
||||||
- macOS: `$TMPDIR/security-hooks/sock`
|
**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)
|
||||||
|
|
||||||
The socket's containing directory is created with mode `0700` to prevent other local processes from connecting.
|
The socket's containing directory is created with mode `0700` to prevent other local processes from connecting.
|
||||||
|
|
||||||
@@ -36,46 +38,51 @@ A long-running BEAM process distributed as a Burrito binary (single executable,
|
|||||||
|
|
||||||
Components:
|
Components:
|
||||||
- **Socket listener** — accepts connections on Unix socket, parses JSON payloads
|
- **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 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)
|
||||||
- **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.
|
- **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
|
- **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)
|
- **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`)
|
- **Logger** — writes JSONL to `$XDG_STATE_HOME/security-hooks/hook.log` (macOS: `~/Library/Logs/security-hooks/hook.log`)
|
||||||
|
|
||||||
### 3. Rule files
|
### 3. Rule files
|
||||||
|
|
||||||
Two kinds:
|
Two kinds:
|
||||||
- **Pattern rules** in `.rules` files using a custom DSL (see Rule Format below)
|
- **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 `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.
|
- **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
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
security-hooks/
|
security-hooks/
|
||||||
├── bin/
|
├── bin/
|
||||||
│ └── security-hook # shell shim
|
│ └── security-hook # shell shim
|
||||||
|
├── service/
|
||||||
|
│ ├── security-hookd.service # systemd user service unit
|
||||||
|
│ ├── security-hookd.socket # systemd socket activation unit
|
||||||
|
│ └── com.security-hooks.daemon.plist # macOS launchd agent
|
||||||
├── rules/
|
├── rules/
|
||||||
│ ├── bash.rules # bash command rules
|
│ ├── bash.rules # bash command rules
|
||||||
│ ├── edit.rules # file edit rules
|
│ ├── edit.rules # file edit rules
|
||||||
│ ├── mcp.rules # MCP tool rules
|
│ └── mcp.rules # MCP tool rules
|
||||||
│ ├── post.rules # post-tool-use checks (deferred, reserved)
|
|
||||||
│ └── validators/ # complex Elixir validators
|
|
||||||
│ ├── unknown_executable.ex
|
|
||||||
│ ├── dependency_mutation.ex
|
|
||||||
│ └── secret_access.ex
|
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── config.toml # default settings
|
│ ├── config.toml # default settings
|
||||||
│ └── config.local.toml # user overrides (gitignored)
|
│ └── config.local.toml # user overrides (gitignored)
|
||||||
├── daemon/ # Elixir application source
|
├── daemon/ # Elixir application source
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── security_hooks/
|
│ │ ├── security_hooks/
|
||||||
│ │ │ ├── application.ex
|
│ │ │ ├── application.ex
|
||||||
│ │ │ ├── socket_listener.ex
|
│ │ │ ├── socket_listener.ex
|
||||||
│ │ │ ├── rule_engine.ex
|
│ │ │ ├── rule_engine.ex
|
||||||
│ │ │ ├── rule_loader.ex
|
│ │ │ ├── rule_loader.ex
|
||||||
|
│ │ │ ├── bash_analyzer.ex # AST parsing via bash Hex package
|
||||||
│ │ │ ├── file_watcher.ex
|
│ │ │ ├── file_watcher.ex
|
||||||
│ │ │ ├── config.ex
|
│ │ │ ├── config.ex
|
||||||
│ │ │ └── logger.ex
|
│ │ │ ├── logger.ex
|
||||||
|
│ │ │ └── validators/
|
||||||
|
│ │ │ ├── unknown_executable.ex
|
||||||
|
│ │ │ ├── dependency_mutation.ex
|
||||||
|
│ │ │ └── secret_access.ex
|
||||||
│ │ └── security_hooks.ex
|
│ │ └── security_hooks.ex
|
||||||
│ ├── mix.exs
|
│ ├── mix.exs
|
||||||
│ └── test/
|
│ └── test/
|
||||||
@@ -104,12 +111,9 @@ security-hook pre edit
|
|||||||
security-hook pre mcp
|
security-hook pre mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
### PostToolUse hook (can block after execution)
|
### PostToolUse hook
|
||||||
|
|
||||||
**Post-check** (matcher: `Bash|Edit|Write`):
|
Deferred to a future version. Post-tool-use linting is project-specific and requires detecting the project's toolchain, choosing the right linter, handling timeouts, and distinguishing agent-introduced errors from pre-existing ones. This deserves its own design pass.
|
||||||
```
|
|
||||||
security-hook post check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response format
|
### Response format
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ PreToolUse deny (tier: block):
|
|||||||
"hookSpecificOutput": {
|
"hookSpecificOutput": {
|
||||||
"hookEventName": "PreToolUse",
|
"hookEventName": "PreToolUse",
|
||||||
"permissionDecision": "deny",
|
"permissionDecision": "deny",
|
||||||
"permissionDecisionReason": "destructive rm detected",
|
"permissionDecisionReason": "destructive rm detected (in subshell)",
|
||||||
"additionalContext": "Use trash-cli or move to a temp directory"
|
"additionalContext": "Use trash-cli or move to a temp directory"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,56 +153,93 @@ PreToolUse ask (tier: suspicious):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
PostToolUse responses (note: PostToolUse uses top-level `decision`/`reason` fields, unlike PreToolUse which nests under `hookSpecificOutput`):
|
|
||||||
|
|
||||||
PostToolUse allow (no issues found):
|
|
||||||
```json
|
|
||||||
{}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## 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.
|
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
|
### Syntax
|
||||||
|
|
||||||
```
|
```
|
||||||
# Comments start with #
|
# Regex matching — pattern is everything after "match " to end of line
|
||||||
|
block "fork-bomb"
|
||||||
|
match :\(\)\s*\{.*\|.*&\s*\}\s*;
|
||||||
|
nudge "Fork bomb detected"
|
||||||
|
|
||||||
block "rule-name"
|
# AST matching — structural analysis of parsed command
|
||||||
match <regex pattern to end of line>
|
block "destructive-rm"
|
||||||
nudge "Message with {variable} interpolation"
|
match command("rm") with_flags("-r", "-rf", "-fr")
|
||||||
|
nudge "Use trash-cli or move to a temp directory"
|
||||||
|
|
||||||
block "rule-name"
|
block "pipe-to-exfil"
|
||||||
match_any
|
match pipeline_to("curl", "wget", "nc")
|
||||||
<regex pattern>
|
nudge "Don't pipe output to network commands"
|
||||||
<regex pattern>
|
|
||||||
nudge "Message"
|
|
||||||
|
|
||||||
suspicious "rule-name"
|
block "curl-data-upload"
|
||||||
match_base_command_not_in <config key>
|
match command("curl") with_flags("-d", "--data", "-F", "--form")
|
||||||
nudge "Message with {command} interpolation"
|
nudge "Don't upload data via curl — only downloads are allowed"
|
||||||
|
|
||||||
block "rule-name"
|
block "eval-obfuscation"
|
||||||
validator ModuleName
|
match command("eval", "exec")
|
||||||
nudge "Message"
|
nudge "Don't use eval/exec — run the command directly"
|
||||||
|
|
||||||
# Concrete example of match_base_command_not_in:
|
# 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"
|
suspicious "unknown-executable"
|
||||||
match_base_command_not_in allowed_executables
|
match_base_command_not_in allowed_executables
|
||||||
nudge "Unknown command '{base_command}'. Add it to allowed_executables in config.toml"
|
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 | `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")` |
|
||||||
|
|
||||||
|
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
|
### Grammar
|
||||||
@@ -211,16 +252,22 @@ tier := "block" | "suspicious"
|
|||||||
name := '"' <text> '"'
|
name := '"' <text> '"'
|
||||||
clauses := matcher nudge
|
clauses := matcher nudge
|
||||||
matcher := match | match_any | match_not_in | validator
|
matcher := match | match_any | match_not_in | validator
|
||||||
match := INDENT "match " <regex to end of line> NL
|
match := INDENT "match " (ast_expr | regex_pattern) NL
|
||||||
match_any := INDENT "match_any" NL (INDENT2 <regex to end of line> 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 " <config key> NL
|
||||||
validator := INDENT "validator " <elixir module name> NL
|
validator := INDENT "validator " <elixir module name> NL
|
||||||
nudge := INDENT "nudge " '"' <text with {var} interpolation> '"' 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.
|
`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.
|
**Note:** `only_when` / `except_when` conditions are deferred to a future version.
|
||||||
|
|
||||||
### Tiers
|
### Tiers
|
||||||
|
|
||||||
@@ -236,18 +283,24 @@ Each hook type matches against a specific field from the Claude Code JSON payloa
|
|||||||
| bash.rules | `tool_input.command` | `rm -rf /tmp/foo` |
|
| bash.rules | `tool_input.command` | `rm -rf /tmp/foo` |
|
||||||
| edit.rules | `tool_input.file_path` | `/home/user/project/src/main.rs` |
|
| edit.rules | `tool_input.file_path` | `/home/user/project/src/main.rs` |
|
||||||
| mcp.rules | `tool_name` (parsed into server + tool) | `mcp__context7__query-docs` |
|
| 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.
|
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
|
### Evaluation
|
||||||
|
|
||||||
Rules are evaluated in file order. First match wins. Place specific rules before general catch-alls.
|
Rules are evaluated in file order. First match wins. Place specific rules before general catch-alls.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Variable interpolation in nudges
|
### Variable interpolation in nudges
|
||||||
|
|
||||||
- `{command}` — the full command string (Bash hooks)
|
- `{command}` — the full command string (Bash hooks)
|
||||||
- `{base_command}` — the first token of the command
|
- `{base_command}` — the first token / primary executable
|
||||||
- `{file_path}` — the target file path (Edit/Write hooks)
|
- `{file_path}` — the target file path (Edit/Write hooks)
|
||||||
- `{tool_name}` — the tool name
|
- `{tool_name}` — the tool name
|
||||||
- `{server_name}` — the MCP server name (MCP hooks)
|
- `{server_name}` — the MCP server name (MCP hooks)
|
||||||
@@ -256,29 +309,66 @@ Rules are evaluated in file order. First match wins. Place specific rules before
|
|||||||
|
|
||||||
### bash.rules
|
### bash.rules
|
||||||
|
|
||||||
**Tier: block**
|
**Tier: block (AST-matched where evasion is a concern)**
|
||||||
- Destructive filesystem ops: `rm -rf /`, `mkfs`, `dd of=/dev/*`
|
|
||||||
- Fork bombs: `:(){ :|:& };:`
|
Destructive filesystem operations:
|
||||||
- Git history destruction: `git push --force` (not `--force-with-lease`), `git reset --hard` on remote-tracking branches, `git clean -fdx`
|
- `command("rm") with_flags("-r", "-rf", "-fr")` — recursive delete
|
||||||
- Package registry attacks: `npm unpublish`, `gem yank`, `cargo yank`
|
- `command("mkfs")` — format filesystem
|
||||||
- Cloud resource deletion: `aws .* delete-`, `gcloud .* delete`, `az .* delete`, `fly destroy`
|
- `command("dd") with_args_matching("of=/dev/")` — raw disk write
|
||||||
- Privilege escalation: `sudo`, `su -`, `chmod 777`, `chown root`
|
|
||||||
- Env var poisoning: setting `LD_PRELOAD`, `PATH`, `NODE_OPTIONS`, `PYTHONPATH`
|
Git history destruction:
|
||||||
- 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.
|
- `command("git") with_args_matching("push\\s+.*--force(?!-with-lease)")` — force push (not `--force-with-lease`)
|
||||||
- Agent recursion: `claude --dangerously-skip-permissions`, `claude -p` with untrusted input
|
- `command("git") with_args_matching("reset\\s+--hard")` — hard reset
|
||||||
- Crypto miners: `xmrig`, `minerd`, known mining pool domains
|
- `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 non-evasible patterns)**
|
||||||
|
|
||||||
|
- Fork bombs: `:\(\)\s*\{.*\|.*&\s*\}\s*;`
|
||||||
|
- Crypto miners: `xmrig|minerd|stratum\+tcp://`
|
||||||
|
|
||||||
**Tier: suspicious**
|
**Tier: suspicious**
|
||||||
- Unknown base command not in allowed executables list
|
|
||||||
- Output redirection to files outside project root
|
- Unknown base command not in allowed executables list (`match_base_command_not_in`)
|
||||||
- Command substitution with pipes or chaining (`$(curl ... | sh)`)
|
- Long base64-encoded strings: `[A-Za-z0-9+/]{100,}={0,2}` (obfuscation signal)
|
||||||
- Long base64-encoded strings in commands (obfuscation signal)
|
|
||||||
- `eval`, `exec`, `source` with dynamic arguments
|
|
||||||
|
|
||||||
### edit.rules
|
### edit.rules
|
||||||
|
|
||||||
**Tier: block**
|
**Tier: block**
|
||||||
- Edits outside `$CLAUDE_PROJECT_DIR`
|
- Edits outside `$CLAUDE_PROJECT_DIR` — path prefix check
|
||||||
- Edits to shell config files: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
- Edits to shell config files: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
||||||
- Edits to `.env` files
|
- Edits to `.env` files
|
||||||
- Edits to `~/.ssh/`, `~/.aws/`, `~/.config/gcloud/`
|
- Edits to `~/.ssh/`, `~/.aws/`, `~/.config/gcloud/`
|
||||||
@@ -292,43 +382,39 @@ Rules are evaluated in file order. First match wins. Place specific rules before
|
|||||||
### mcp.rules
|
### mcp.rules
|
||||||
|
|
||||||
**Tier: block**
|
**Tier: block**
|
||||||
- Unknown/unregistered MCP servers (catch-all deny)
|
- Unknown/unregistered MCP servers (catch-all deny at end of file)
|
||||||
- MCP tool call parameters containing shell metacharacters or injection patterns
|
- MCP tool call parameters containing shell metacharacters or injection patterns
|
||||||
|
|
||||||
**Tier: suspicious**
|
**Tier: suspicious**
|
||||||
- MCP tools that fetch URLs or access external resources
|
- MCP tools that fetch URLs or access external resources
|
||||||
|
|
||||||
Allowed MCP servers and their tools are configured in `config.toml`:
|
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:
|
||||||
|
|
||||||
```toml
|
|
||||||
[[mcp.servers]]
|
|
||||||
name = "context7"
|
|
||||||
tools = ["resolve-library-id", "query-docs"]
|
|
||||||
|
|
||||||
[[mcp.servers]]
|
|
||||||
name = "sequential-thinking"
|
|
||||||
tools = ["sequentialthinking"]
|
|
||||||
```
|
```
|
||||||
|
block "unknown-mcp-server"
|
||||||
### post.rules
|
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 = [\"*\"]"
|
||||||
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
|
## Configuration
|
||||||
|
|
||||||
### config.toml (defaults, checked into repo)
|
### config.toml (defaults, checked into repo)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
[meta]
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
[executables]
|
[executables]
|
||||||
allowed = [
|
allowed = [
|
||||||
"git", "mix", "elixir", "iex", "cargo", "rustc",
|
"git", "mix", "elixir", "iex", "cargo", "rustc",
|
||||||
"go", "python", "pip", "uv", "node", "npm", "pnpm", "yarn",
|
"go", "python", "pip", "uv", "node", "npm", "pnpm", "yarn",
|
||||||
"rg", "fd", "jq", "cat", "ls", "head", "tail",
|
"rg", "fd", "jq", "cat", "ls", "head", "tail", "wc", "sort", "uniq",
|
||||||
"mkdir", "cp", "mv", "touch", "echo", "grep", "sed", "awk",
|
"mkdir", "cp", "mv", "touch", "echo", "grep", "sed", "awk",
|
||||||
"make", "cmake", "gcc", "clang",
|
"make", "cmake", "gcc", "clang",
|
||||||
"ruby", "gem", "bundler", "rake",
|
"ruby", "gem", "bundler", "rake",
|
||||||
"php", "composer",
|
"php", "composer",
|
||||||
"java", "javac", "mvn", "gradle",
|
"java", "javac", "mvn", "gradle",
|
||||||
|
"curl", "wget",
|
||||||
]
|
]
|
||||||
|
|
||||||
[secrets]
|
[secrets]
|
||||||
@@ -351,12 +437,18 @@ sensitive = [
|
|||||||
"/etc/passwd",
|
"/etc/passwd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[meta]
|
[[mcp.servers]]
|
||||||
version = "1.0.0"
|
name = "context7"
|
||||||
|
tools = ["resolve-library-id", "query-docs"]
|
||||||
|
|
||||||
|
[[mcp.servers]]
|
||||||
|
name = "sequential-thinking"
|
||||||
|
tools = ["sequentialthinking"]
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
idle_timeout_minutes = 30
|
idle_timeout_minutes = 30
|
||||||
log_format = "jsonl"
|
log_format = "jsonl"
|
||||||
|
shim_timeout_ms = 500
|
||||||
|
|
||||||
[rules]
|
[rules]
|
||||||
disabled = []
|
disabled = []
|
||||||
@@ -364,28 +456,145 @@ disabled = []
|
|||||||
|
|
||||||
### config.local.toml (user overrides, gitignored)
|
### config.local.toml (user overrides, gitignored)
|
||||||
|
|
||||||
Merges on top of `config.toml`:
|
Merges on top of `config.toml` with three operations per list:
|
||||||
- List values are appended
|
- `append` — add entries to the default list
|
||||||
|
- `exclude` — remove entries from the default list
|
||||||
- Scalar values are overwritten
|
- Scalar values are overwritten
|
||||||
- `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 = 200
|
||||||
|
```
|
||||||
|
|
||||||
## Daemon Lifecycle
|
## Daemon Lifecycle
|
||||||
|
|
||||||
**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.
|
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.
|
||||||
|
|
||||||
**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. If two sessions race to start the daemon simultaneously, the second will get `EADDRINUSE` — the shim handles this by retrying the socket connection (the first daemon won the race and is now available).
|
### Linux/WSL: systemd user service + socket activation
|
||||||
|
|
||||||
**Idle shutdown:** The daemon exits after 30 minutes of inactivity (configurable via `daemon.idle_timeout_minutes`). Handles `SIGTERM` gracefully.
|
`install.sh` installs two systemd user units:
|
||||||
|
|
||||||
**Hot-reload:** A `FileSystem` watcher monitors `rules/` and `config/` directories. On change, the rule engine reloads rules and config without restarting the daemon.
|
**`~/.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_CONFIG=%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>~/.local/bin/security-hookd</string>
|
||||||
|
</array>
|
||||||
|
<key>Sockets</key>
|
||||||
|
<dict>
|
||||||
|
<key>Listeners</key>
|
||||||
|
<dict>
|
||||||
|
<key>SockPathName</key>
|
||||||
|
<string>/tmp/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_CONFIG</key>
|
||||||
|
<string>~/.config/security-hooks</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
**Logging:** All decisions are written as JSONL:
|
||||||
|
|
||||||
```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-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-26T14:02:05Z","event":"PreToolUse","tool":"Bash","input":"mix test","rule":null,"decision":"allow"}
|
{"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.
|
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).
|
Future: streaming connectors for centralized logging (stdout, webhook, syslog).
|
||||||
@@ -401,8 +610,13 @@ The install script:
|
|||||||
2. Installs the binary and shell shim to `~/.local/bin/` (or user-specified location)
|
2. Installs the binary and shell shim to `~/.local/bin/` (or user-specified location)
|
||||||
3. Copies default rules and config to `~/.config/security-hooks/`
|
3. Copies default rules and config to `~/.config/security-hooks/`
|
||||||
4. Creates `config.local.toml` from a template if it does not exist
|
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)
|
5. Auto-detects installed MCP servers from Claude Code config and pre-populates the MCP allowlist in `config.local.toml`
|
||||||
6. Prints a summary of what was configured
|
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. Merges hook entries into Claude Code's `~/.claude/settings.json` (preserving existing hooks)
|
||||||
|
8. Prints a summary of what was configured
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -443,3 +657,12 @@ The default allowed executables and dependency mutation validators cover:
|
|||||||
- PHP (composer)
|
- PHP (composer)
|
||||||
- C/C++ (gcc, clang, make, cmake)
|
- C/C++ (gcc, clang, make, cmake)
|
||||||
- Elixir (mix)
|
- Elixir (mix)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
Reference in New Issue
Block a user