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>
180 lines
7.4 KiB
Bash
180 lines
7.4 KiB
Bash
#!/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
|