Address spec review iteration 4: config keys, match targets, launchd

Important fixes:
- Document config key resolution (allowed_executables -> executables.allowed,
  mcp_allowed_servers -> names from [[mcp.servers]])
- Clarify CLAUDE_PROJECT_DIR source (derived from payload cwd field)
- MCP rules: regex match_any operates on serialized tool_input, not tool_name
- Add with_args_matching semantics (joined argument string)

Suggestions also addressed:
- Fix launchd plist: use absolute paths, note install.sh expands placeholders
- Fix launchd socket path: use $TMPDIR for per-user isolation
- Rename SECURITY_HOOKS_CONFIG -> SECURITY_HOOKS_HOME (contains both
  rules/ and config/ subdirectories)
- Document directory discovery via single env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Flo
2026-03-27 21:11:26 +01:00
parent 91911973d6
commit 4c2226ae57

View File

@@ -57,6 +57,8 @@ Two kinds:
## 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/
├── bin/
@@ -300,6 +302,18 @@ regex_pattern := <any text not starting with IDENT '('> <to end of line>
**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.
@@ -309,11 +323,11 @@ regex_pattern := <any text not starting with IDENT '('> <to end of line>
Each hook type matches against a specific field from the Claude Code JSON payload:
| Hook file | Payload field matched | Example value |
|-----------|----------------------|---------------|
| bash.rules | `tool_input.command` | `rm -rf /tmp/foo` |
| edit.rules | `tool_input.file_path` | `/home/user/project/src/main.rs` |
| mcp.rules | `tool_name` (parsed into server + tool) | `mcp__context7__query-docs` |
| 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.
@@ -441,7 +455,7 @@ suspicious "edit-dependency-manifest"
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.
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
@@ -594,7 +608,7 @@ Type=simple
ExecStart=%h/.local/bin/security-hookd
Restart=on-failure
RestartSec=1
Environment=SECURITY_HOOKS_CONFIG=%h/.config/security-hooks
Environment=SECURITY_HOOKS_HOME=%h/.config/security-hooks
[Install]
WantedBy=default.target
@@ -617,14 +631,14 @@ After install: `systemctl --user enable --now security-hookd.socket`
<string>com.security-hooks.daemon</string>
<key>ProgramArguments</key>
<array>
<string>~/.local/bin/security-hookd</string>
<string>/Users/USERNAME/.local/bin/security-hookd</string>
</array>
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockPathName</key>
<string>/tmp/security-hooks/sock</string>
<string>TMPDIR/security-hooks/sock</string>
<key>SockPathMode</key>
<integer>384</integer> <!-- 0600 -->
</dict>
@@ -636,13 +650,15 @@ After install: `systemctl --user enable --now security-hookd.socket`
</dict>
<key>EnvironmentVariables</key>
<dict>
<key>SECURITY_HOOKS_CONFIG</key>
<string>~/.config/security-hooks</string>
<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