feat(safety): PreToolUse hook gating destructive tool calls (FNDG-04b, Option A)
Adds internal/safety/ — the in-repo source of truth for the PreToolUse hook
deployed into every project before a Claude Code agent is launched. The hook
blocks destructive Bash/Edit/Write patterns on sessions running with
--dangerously-skip-permissions, closing the exploitation path where a prompt
injection via MCP sessions.send could otherwise trigger arbitrary destruction
without interactive confirmation.
Wire-up:
- internal/dispatcher/dispatcher.go launchAgent: deploys hook before claude
launch; fail-closed if deployment fails.
- internal/switcher/account_switcher.go relaunchDedicatedSessions: redeploys
hook before --resume after account failover; fail-open (log + continue)
since the initial deployment is still in place.
Blocks (exit 2, stderr shown to model):
- rm -rf targeting /, ~, $HOME, /etc, /var, /usr, /boot
- dd of=/dev/{sd,nvme,disk,hd,mmcblk}*, mkfs*
- git push --force (but allows --force-with-lease)
- git reset --hard on main|master|production
- sudo outside short allowlist (systemctl, journalctl, cp, install, apt*)
- curl|sh, bash <(curl ...), eval "$(curl ...)", fork bomb, crontab -e
- chmod 777 on system paths / home
- Writes to .claude/settings*.json, .claude/hooks/, ~/.ssh/authorized_keys,
shell rc files, /etc/sudoers*, /etc/systemd/*
Warn-only (logged, not blocked):
- kubectl delete, helm uninstall, terraform destroy
- DROP TABLE, TRUNCATE TABLE, DELETE FROM ... WHERE 1=1
Hook script is embedded via //go:embed so a single binary release carries
the authoritative copy. Every launch rewrites the deployed file with mode
0555 (anti-tamper); the hook itself also blocks writes to .claude/hooks/
for defense in depth.
Decision: Olivier, 2026-04-19 — Option A now, Option C (two pools) tracked
separately. Complements FNDG-04 input sanitization in secuaas-mcp.
Tests: 8 unit/integration tests in internal/safety/, plus a dispatcher-level
test verifying the hook is written before launch. go vet clean, go test ./...
all pass.
Refs: FNDG-04 audit (secuaas-mcp branch audit/mcp-stdio-2026-04-18)
Task: .agent-queue/inbox/20260418-211102-fndg-04b-*.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
336f1f27bb
commit
58690da69f
8 changed files with 885 additions and 1 deletions
150
docs/security/claude-safety-hook.md
Normal file
150
docs/security/claude-safety-hook.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Claude Safety Hook (FNDG-04b)
|
||||
|
||||
## Purpose
|
||||
|
||||
Sessions launched by claude-failover run with `--dangerously-skip-permissions`
|
||||
so the dispatcher pool (`ccl-auto-11`–`ccl-auto-20`) stays headless. That flag
|
||||
suppresses the interactive confirmation for every tool call. Combined with the
|
||||
MCP `sessions.send` endpoint, a prompt injection could otherwise execute
|
||||
arbitrary destructive commands with zero friction.
|
||||
|
||||
This package is the **second line of defence** — the first is input
|
||||
sanitization in `secuaas-mcp` (FNDG-04). The hook applies a deny-list of
|
||||
destructive Bash patterns and write paths to every tool call the agent
|
||||
attempts, and exits non-zero to block them.
|
||||
|
||||
Decision: **Option A** (per Olivier, 2026-04-19). Option B (removing the flag)
|
||||
was rejected — it breaks headless dispatch. Option C (two pools) is deferred
|
||||
to a separate task.
|
||||
|
||||
## Mechanism
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
|
||||
│ claude-failover │ │ project dir │ │ claude (TUI) │
|
||||
│ launchAgent() │──1──▶│ .claude/hooks/ │ │ reads settings.json │
|
||||
│ │ │ claude-safety-hook.sh │◀──3──│ runs hook per tool │
|
||||
│ EnsureHook- │ │ .claude/settings.json │ │ │
|
||||
│ Deployed() │──2──▶│ PreToolUse → hook │ │ │
|
||||
└──────────────────┘ └──────────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────────────────── 4. tmux SendKeys claude ─────────────┘
|
||||
```
|
||||
|
||||
1. Before each claude launch, `safety.EnsureHookDeployed(projectDir)` writes
|
||||
the embedded hook script to `.claude/hooks/claude-safety-hook.sh` with
|
||||
mode `0555` (read+execute, no write).
|
||||
2. It merges a `PreToolUse` entry into `.claude/settings.json` without
|
||||
touching other keys (e.g., an existing `security-gate.sh` from CLAUDE.md
|
||||
is preserved).
|
||||
3. Claude Code evaluates `PreToolUse` hooks matching
|
||||
`Bash|Edit|Write|MultiEdit|NotebookEdit`, pipes the tool-call JSON to the
|
||||
hook on stdin, and honours its exit code (`0` allow, `2` block).
|
||||
4. Only then does the dispatcher send `claude --dangerously-skip-permissions`.
|
||||
|
||||
The hook script is **embedded in the Go binary** (`//go:embed hook.sh`) so
|
||||
there is a single source of truth. Every launch overwrites the deployed copy,
|
||||
so a stale or tampered hook is self-healing on the next session.
|
||||
|
||||
## What the hook blocks
|
||||
|
||||
**Bash patterns (exit 2, message shown to the model):**
|
||||
|
||||
- `rm -rf /`, `rm -rf /*`, `rm -rf ~`, `rm -rf $HOME`, `rm -rf /home`, `rm -rf /etc`, `rm -rf /usr`, `rm -rf /var`, `rm -rf /boot`
|
||||
- `dd of=/dev/{sd,nvme,disk,hd,mmcblk}*` and shell redirects to the same
|
||||
- `mkfs` / `mkfs.*`
|
||||
- `git push --force` / `git push -f` (but `--force-with-lease` is allowed — it is safe in rebased workflows)
|
||||
- `git reset --hard` when the target is `main`, `master`, or `production`
|
||||
- `git clean -fdx /`, `git clean -fdx ~`, `git clean -fdx $HOME`
|
||||
- `sudo …` except the short allow-list `systemctl`, `journalctl`, `cp`, `install`
|
||||
- `su -`, `su root`
|
||||
- `chmod 777` on `/`, `/etc`, `/usr`, `/var`, `/boot`, `~`, `$HOME`
|
||||
- `curl … | sh|bash|zsh|dash`, `wget … | sh`, `bash <(curl …)`, `sh <(wget …)`
|
||||
- `eval "$(curl …)"`, `eval $(wget …)`
|
||||
- `crontab -e`, `crontab -E`, `>> /etc/crontab`
|
||||
- Canonical fork bomb `:(){ :|:& };:`
|
||||
|
||||
**Bash patterns (warn only — logged, not blocked):**
|
||||
|
||||
- `kubectl delete`, `helm uninstall`, `terraform destroy`
|
||||
- `DROP TABLE`, `TRUNCATE TABLE`, `DELETE FROM … WHERE 1=1`
|
||||
|
||||
**Edit/Write/MultiEdit/NotebookEdit paths (exit 2):**
|
||||
|
||||
- Anything under `.claude/settings*.json`, `.claude/hooks/` (project and `~/.claude/`) — anti self-disable
|
||||
- `~/.ssh/authorized_keys`, `~/.ssh/config`
|
||||
- `~/.bashrc`, `~/.bash_profile`, `~/.zshrc`, `~/.profile`
|
||||
- `/etc/sudoers`, `/etc/sudoers.d/*`, `/etc/systemd/*`, `/etc/crontab`, `/etc/cron.*/*`
|
||||
|
||||
## What the hook does **not** do
|
||||
|
||||
- It does not gate MCP tools, `WebFetch`, or custom tool names not in the
|
||||
matcher list. Extend the matcher if a new high-risk tool is added.
|
||||
- It does not claim to be exhaustive against a determined attacker — it is a
|
||||
seat-belt for the pattern-based attacks enumerated in FNDG-04. Defense in
|
||||
depth still requires (a) MCP input sanitization, (b) rate-limiting on
|
||||
`sessions.send`, and eventually (c) Option C (two pools).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `bash` 4+ (for `[[ … =~ … ]]` and `case`)
|
||||
- `jq` (used to parse the tool-call JSON). If `jq` is missing, the hook
|
||||
**fails closed** — every tool call is blocked and the log records
|
||||
`FAIL-CLOSED jq not installed`.
|
||||
|
||||
## Logs
|
||||
|
||||
Each decision is appended to `$CLAUDE_SAFETY_HOOK_LOG` (default
|
||||
`~/.claude/safety-hook.log`). The log is per-user, not per-project.
|
||||
|
||||
## Deployment sites in claude-failover
|
||||
|
||||
| Go file | Line | Context |
|
||||
|---|---|---|
|
||||
| `internal/dispatcher/dispatcher.go` | `launchAgent` | Every initial session launch. Failure to deploy is fatal — the agent will not start. |
|
||||
| `internal/switcher/account_switcher.go` | `relaunchDedicatedSessions` | After a quota failover resume. Failure is logged, not fatal — the pre-existing deployed hook remains in place. |
|
||||
|
||||
## Coordination with the bash dispatcher (`lib-common.sh`)
|
||||
|
||||
The legacy bash dispatcher in `dev-management/agent-orchestrator/lib-common.sh`
|
||||
also launches `claude --dangerously-skip-permissions`. To stay consistent it
|
||||
should source the hook before spawning the session. Suggested integration:
|
||||
|
||||
```bash
|
||||
# In lib-common.sh, just before `tmux send-keys … "claude --dangerously-skip-permissions"`:
|
||||
go run forge.secuaas.ovh/olivier/claude-failover/cmd/deploy-safety-hook -- "$project_dir" \
|
||||
|| { echo "safety hook deploy failed, refusing to launch" >&2; exit 1; }
|
||||
```
|
||||
|
||||
(Or vendor `hook.sh` + `settings.json` merge into a small shell helper — the
|
||||
Go package is the single source of truth either way.) Cross-repo wiring is
|
||||
tracked in a follow-up task; this PR only lands the claude-failover side.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./internal/safety/ -v
|
||||
```
|
||||
|
||||
Covers:
|
||||
|
||||
- File layout and modes (hook script is `0555`, settings is valid JSON)
|
||||
- Idempotency (three deploys = one hook entry)
|
||||
- Existing settings keys are preserved
|
||||
- Stale matcher is refreshed
|
||||
- A representative set of malicious inputs exits `2`
|
||||
- A representative set of benign inputs exits `0`
|
||||
|
||||
Manual test:
|
||||
|
||||
```bash
|
||||
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ~"}}' \
|
||||
| bash .claude/hooks/claude-safety-hook.sh
|
||||
echo $? # → 2
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- FNDG-04 audit: `secuaas-mcp` branch `audit/mcp-stdio-2026-04-18`, file `docs/audit-mcp-stdio-2026-04-18.md`
|
||||
- Decision: Olivier, 2026-04-19 — Option A now, Option C tracked separately
|
||||
- Task spec: `.agent-queue/inbox/20260418-211102-fndg-04b-dangerously-skip-permissions-sessions-headless.md`
|
||||
Loading…
Add table
Add a link
Reference in a new issue