#!/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