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>
7.1 KiB
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 ─────────────┘
- Before each claude launch,
safety.EnsureHookDeployed(projectDir)writes the embedded hook script to.claude/hooks/claude-safety-hook.shwith mode0555(read+execute, no write). - It merges a
PreToolUseentry into.claude/settings.jsonwithout touching other keys (e.g., an existingsecurity-gate.shfrom CLAUDE.md is preserved). - Claude Code evaluates
PreToolUsehooks matchingBash|Edit|Write|MultiEdit|NotebookEdit, pipes the tool-call JSON to the hook on stdin, and honours its exit code (0allow,2block). - 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 /bootdd of=/dev/{sd,nvme,disk,hd,mmcblk}*and shell redirects to the samemkfs/mkfs.*git push --force/git push -f(but--force-with-leaseis allowed — it is safe in rebased workflows)git reset --hardwhen the target ismain,master, orproductiongit clean -fdx /,git clean -fdx ~,git clean -fdx $HOMEsudo …except the short allow-listsystemctl,journalctl,cp,installsu -,su rootchmod 777on/,/etc,/usr,/var,/boot,~,$HOMEcurl … | 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 destroyDROP 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
bash4+ (for[[ … =~ … ]]andcase)jq(used to parse the tool-call JSON). Ifjqis missing, the hook fails closed — every tool call is blocked and the log recordsFAIL-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-mcpbranchaudit/mcp-stdio-2026-04-18, filedocs/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