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

7.1 KiB
Raw Blame History

Claude Safety Hook (FNDG-04b)

Purpose

Sessions launched by claude-failover run with --dangerously-skip-permissions so the dispatcher pool (ccl-auto-11ccl-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:

# 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

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:

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