claude-failover/docs/security/claude-safety-hook.md
Ubuntu 58690da69f 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>
2026-04-19 17:48:27 +00:00

150 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`