181 lines
7.4 KiB
Bash
181 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
|