claude-failover/internal/safety/hook.sh

181 lines
7.4 KiB
Bash
Raw Normal View History

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
#!/usr/bin/env bash
# claude-safety-hook — PreToolUse gate for Claude Code agents launched by
# claude-failover. Receives tool call JSON on stdin; exits 0 to allow,
# 2 to block (stderr is shown back to the model).
#
# Scope (FNDG-04b, Option A): block destructive Bash/Edit/Write patterns on
# sessions launched with --dangerously-skip-permissions so a prompt-injected
# `sessions.send` cannot trivially escalate into arbitrary destruction.
#
# This file is deployed by claude-failover before every Claude launch and is
# the authoritative copy; do not edit in-place in the project tree.
set -uo pipefail
LOG_FILE=${CLAUDE_SAFETY_HOOK_LOG:-$HOME/.claude/safety-hook.log}
log() {
local ts
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
printf '[%s] pid=%s %s\n' "$ts" "$$" "$*" >>"$LOG_FILE" 2>/dev/null || true
}
deny() {
local reason=$1
log "BLOCK reason=\"$reason\" tool=${tool:-?}"
printf 'claude-safety-hook: BLOCKED — %s\n' "$reason" >&2
exit 2
}
warn() {
log "WARN $1"
}
input=$(cat)
if ! command -v jq >/dev/null 2>&1; then
# Fail-closed: without jq we cannot safely parse input. Prefer a noisy
# false-positive over silently letting a dangerous command through.
printf 'claude-safety-hook: jq is required but not installed — blocking to fail closed\n' >&2
log "FAIL-CLOSED jq not installed"
exit 2
fi
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)
case "$tool" in
Bash)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
# Fork bomb.
if [[ $cmd == *':(){'*'|'*'&'*'};:'* ]]; then
deny "fork bomb pattern detected"
fi
# rm -rf on root/home/$HOME — match before generic sudo/etc.
if [[ $cmd =~ rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[fF]?[a-zA-Z]*|-[a-zA-Z]*[fF][a-zA-Z]*[rR][a-zA-Z]*)[[:space:]]+(/|/\*|~|~/\*|\$HOME|/home|/home/|/etc|/var|/usr|/boot) ]]; then
deny "recursive rm targeting filesystem root / home / system dirs"
fi
# Raw device writes.
if [[ $cmd =~ dd[[:space:]]+.*of=/dev/(sd|nvme|disk|hd|mmcblk) ]]; then
deny "dd write to raw block device"
fi
if [[ $cmd =~ \>\|?[[:space:]]*/dev/(sd|nvme|disk|hd|mmcblk) ]]; then
deny "redirect to raw block device"
fi
# Filesystem wipe.
if [[ $cmd =~ (^|[^a-zA-Z_])(mkfs|mkfs\.[a-z0-9]+)([[:space:]]|$) ]]; then
deny "mkfs invocation"
fi
# Force push — but allow --force-with-lease (safe in rebased workflows).
if [[ $cmd =~ git[[:space:]]+push[[:space:]]+.*(--force([[:space:]]|$)|-f([[:space:]]|$)) ]] \
&& ! [[ $cmd =~ --force-with-lease ]]; then
deny "git push --force (use --force-with-lease instead)"
fi
# Hard reset ONLY on main/master/production.
if [[ $cmd =~ git[[:space:]]+reset[[:space:]]+--hard[[:space:]]+(origin/)?(main|master|production)([[:space:]]|$) ]]; then
deny "git reset --hard on protected branch"
fi
# git clean against filesystem root or home.
if [[ $cmd =~ git[[:space:]]+clean[[:space:]]+.*-.*[fdxX].*[[:space:]]+(/|~|\$HOME|/home) ]]; then
deny "git clean targeting root / home"
fi
# sudo — allow a short allowlist, deny the rest.
if [[ $cmd =~ (^|[^a-zA-Z_])sudo([[:space:]]+-[a-zA-Z]+)*[[:space:]] ]]; then
if ! [[ $cmd =~ (^|[^a-zA-Z_])sudo[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*(systemctl|journalctl|cp|install)[[:space:]] ]]; then
deny "sudo invocation (only systemctl/journalctl/cp/install allowlisted)"
fi
fi
if [[ $cmd =~ (^|[^a-zA-Z_])su[[:space:]]+-($|[[:space:]]) ]]; then
deny "su - invocation"
fi
if [[ $cmd =~ (^|[^a-zA-Z_])su[[:space:]]+root($|[[:space:]]) ]]; then
deny "su root invocation"
fi
# World-writable perms on system paths.
if [[ $cmd =~ chmod[[:space:]]+.*[0-7]*777 ]]; then
if [[ $cmd =~ chmod[[:space:]]+.*(-R[[:space:]]|[[:space:]])*777[[:space:]]+(/([[:space:]]|$)|/etc|/usr|/var|/boot|~|\$HOME) ]]; then
deny "chmod 777 on system path / home"
fi
fi
# curl|sh / wget|sh / bash <(curl ...).
# Use variables to avoid bash 5.2 [[ =~ ]] parser issues with & in character classes.
re_curlpipe='(curl|wget)[[:space:]]+[^|]*\|[[:space:]]*(sh|bash|zsh|dash)'
re_procsub='(bash|sh|zsh)[[:space:]]+\<\([[:space:]]*(curl|wget)'
re_evalremote='eval[[:space:]]+["\x27]?\$\((curl|wget)'
if [[ $cmd =~ $re_curlpipe ]]; then
deny "pipe from curl/wget into shell"
fi
if [[ $cmd =~ $re_procsub ]]; then
deny "process substitution feeding curl/wget to shell"
fi
if [[ $cmd =~ $re_evalremote ]]; then
deny "eval on remote-fetched content"
fi
# Crontab tampering.
if [[ $cmd =~ (^|[^a-zA-Z_])crontab[[:space:]]+-[eE]([[:space:]]|$) ]]; then
deny "crontab edit"
fi
if [[ $cmd =~ \>\>?[[:space:]]*/etc/crontab ]]; then
deny "append to /etc/crontab"
fi
# DB destructive statements (warn-only per spec — log, do not block).
if [[ $cmd =~ (DROP[[:space:]]+TABLE|TRUNCATE[[:space:]]+TABLE|DELETE[[:space:]]+FROM[[:space:]]+.*WHERE[[:space:]]+1[[:space:]]*=[[:space:]]*1) ]]; then
warn "destructive SQL statement in command (not blocking, logged)"
fi
# k8s / infra destructive ops (warn-only).
if [[ $cmd =~ (kubectl[[:space:]]+delete|helm[[:space:]]+uninstall|terraform[[:space:]]+destroy) ]]; then
warn "k8s/infra destructive op (not blocking, logged)"
fi
;;
Edit|Write|MultiEdit|NotebookEdit)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')
# Normalize: expand ~ since the hook receives literal paths.
case "$path" in
"$HOME/"* | "$HOME") abs=$path ;;
/*) abs=$path ;;
*) abs="$PWD/$path" ;;
esac
# Anti self-disable: block all writes to .claude/settings*.json and .claude/hooks/*.
if [[ $abs =~ (^|/)\.claude/(settings(\.local)?\.json|hooks(/|$)) ]]; then
deny "write to .claude/settings*.json or .claude/hooks/ (anti self-disable)"
fi
if [[ $abs == "$HOME/.claude/settings.json" || $abs == "$HOME/.claude/settings.local.json" ]]; then
deny "write to ~/.claude/settings.json (anti self-disable)"
fi
if [[ $abs == "$HOME/.claude/hooks/"* ]]; then
deny "write inside ~/.claude/hooks/ (anti self-disable)"
fi
# SSH / persistence / privileged config.
case "$abs" in
"$HOME/.ssh/authorized_keys"|"$HOME/.ssh/config") deny "write to ~/.ssh/authorized_keys or ~/.ssh/config" ;;
"$HOME/.bashrc"|"$HOME/.bash_profile"|"$HOME/.zshrc"|"$HOME/.profile") deny "write to shell rc file (persistence vector)" ;;
/etc/sudoers|/etc/sudoers.d/*) deny "write to sudoers" ;;
/etc/systemd/*) deny "write to /etc/systemd/" ;;
/etc/crontab|/etc/cron.*/*) deny "write to cron system files" ;;
esac
;;
*)
: # other tools not gated here
;;
esac
log "ALLOW tool=$tool"
exit 0