thermograph/.claude/hooks/prod-guard.sh
Emi Griffith c184fd55ea
All checks were successful
PR build (required check) / changes (pull_request) Successful in 11s
secrets-guard / encrypted (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 14s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 3s
guardrails: silence the intentional SC2016 in prod-guard
shell-lint flagged the literal `$(` / backtick patterns the read-only classifier
searches for inside a command string. Expanding them is exactly what must not
happen — the check exists to refuse commands containing command substitution, so
the single quotes are the point. Same treatment and rationale as the existing
disables in render-secrets.sh and verify-centralis-render.sh.

Verified with the pinned shellcheck v0.11.0 (sha256 matching shell-lint.yml):
all 34 scripts in the tree clean. Hook behaviour unchanged — the classifier
test matrix still allows reads and asks on mutations and compound smuggling.

Also documents that ~/.claude/hooks/ carries a mirror of prod-guard.sh so
sessions started outside this repo are covered, and that the blanket ssh allow
rules were removed from global settings so the hook cannot be out-ranked.
2026-07-24 21:12:53 -07:00

156 lines
7 KiB
Bash
Executable file

#!/usr/bin/env bash
# PreToolUse guard for commands that reach a live host.
#
# Policy: reads stay friction-free, mutations ask first.
#
# The estate's widest privilege is that `agent` has passwordless sudo on prod and
# beta, and the global settings allow `ssh prod:*` outright — so before this hook
# existed, any session in any directory could roll, delete or restart production
# with no prompt. CI cannot help here: nothing about `ssh prod 'docker service rm'`
# goes through a pull request.
#
# Classification is an ALLOWLIST of read-only commands, not a blocklist of
# dangerous ones. A blocklist is wrong by construction: the first destructive verb
# nobody thought of sails straight through. Anything this script does not
# positively recognise as a read becomes an "ask", which is the safe direction to
# be wrong in.
#
# Exit 0 with no output = "no opinion", and the normal permission flow proceeds.
# That is also what happens if this script errors or jq is missing, so a bug here
# fails toward the prompt rather than toward silent execution.
#
# Beta is guarded as strictly as prod: it serves beta.thermograph.org AND hosts
# Forgejo, so a destructive command there can take out the git server, CI and the
# container registry at once.
set -uo pipefail
payload=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
tool=$(printf '%s' "$payload" | jq -r '.tool_name // ""')
# An ssh target naming a live host, matched as a whole token after any `user@`.
# Backslashes are doubled because awk -v processes escape sequences in the value
# before the regex ever sees it; a single \. arrives as a bare dot (and warns).
readonly HOST_TOKEN_RE='^(prod|beta|169\\.58\\.46\\.181|75\\.119\\.132\\.91|10\\.10\\.0\\.[12])$'
emit() { # $1=allow|ask|deny $2=reason
jq -n --arg d "$1" --arg r "$2" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
exit 0
}
# --- is this shell command read-only? ----------------------------------------
# Returns 0 only when every segment leads with a recognised read-only command.
is_readonly() {
local cmd="$1" seg w1 w2 stripped
# Discard the harmless redirections that appear in ordinary read commands,
# then treat any surviving redirect as a write.
stripped=${cmd//2>&1/}
stripped=${stripped//&>\/dev\/null/}
stripped=${stripped//2>\/dev\/null/}
stripped=${stripped//>\/dev\/null/}
stripped=${stripped//> \/dev\/null/}
case "$stripped" in *'>'*) return 1 ;; esac
# Command substitution can hide anything; refuse to reason about it.
# shellcheck disable=SC2016 # single quotes are the point: these are literal
# `$(` and backtick characters being searched for inside the command text, not
# expansions to perform. Expanding them here would defeat the check entirely.
case "$cmd" in *'$('*|*'`'*) return 1 ;; esac
while IFS= read -r seg; do
seg="${seg#"${seg%%[![:space:]]*}"}"
[ -z "$seg" ] && continue
w1=$(printf '%s' "$seg" | awk '{print $1}')
w2=$(printf '%s' "$seg" | awk '{print $2}')
case "$w1" in
cat|ls|head|tail|wc|grep|egrep|fgrep|rg|find|stat|df|du|free|uptime|whoami) ;;
id|hostname|date|ps|env|printenv|pwd|which|uname|lsblk|ip|ss|netstat) ;;
pg_isready|echo|true|sort|uniq|cut|tr|awk|jq|nproc|readlink|realpath|test) ;;
sed) case "$seg" in *' -i'*) return 1 ;; esac ;;
curl) case "$seg" in *' -X'*|*' -d'*|*'--data'*|*' -T'*|*' -F'*|*'--upload'*) return 1 ;; esac ;;
journalctl) ;;
systemctl)
case "$w2" in status|show|is-active|is-enabled|is-failed|list-units|cat) ;; *) return 1 ;; esac ;;
docker)
case "$w2" in
ps|logs|inspect|stats|images|version|info|top|port|events|history|diff) ;;
service) case "$seg" in *'service ls'*|*'service ps'*|*'service inspect'*|*'service logs'*) ;; *) return 1 ;; esac ;;
stack) case "$seg" in *'stack ls'*|*'stack ps'*|*'stack services'*) ;; *) return 1 ;; esac ;;
compose) case "$seg" in *'compose ps'*|*'compose logs'*|*'compose config'*|*'compose top'*|*'compose images'*) ;; *) return 1 ;; esac ;;
node) case "$seg" in *'node ls'*|*'node inspect'*) ;; *) return 1 ;; esac ;;
volume) case "$seg" in *'volume ls'*|*'volume inspect'*) ;; *) return 1 ;; esac ;;
*) return 1 ;;
esac ;;
git)
case "$w2" in status|log|diff|show|branch|remote|rev-parse|describe|blame|ls-files|config) ;; *) return 1 ;; esac ;;
*) return 1 ;;
esac
# NOTE: the trailing newline matters. Without it `read` returns non-zero on the
# final (only) line, the loop body never runs, and every command classifies as
# read-only — a silent false negative that allows anything.
done < <(printf '%s\n' "$cmd" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g')
return 0
}
# --- find an ssh target and return what would run on it -----------------------
# Prints "<host>\t<remote command>" when a live host is addressed, nothing
# otherwise. The remote command is empty for a bare login shell.
ssh_target_of() {
printf '%s' "$1" | awk -v re="$HOST_TOKEN_RE" '
{
for (i = 1; i <= NF; i++) {
t = $i; sub(/^[^@]*@/, "", t)
if (t ~ re) {
out = ""
for (j = i + 1; j <= NF; j++) out = out (out == "" ? "" : " ") $j
printf "%s\t%s\n", t, out
exit
}
}
}'
}
case "$tool" in
Bash)
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
case "$cmd" in *ssh*) ;; *) exit 0 ;; esac
found=$(ssh_target_of "$cmd")
[ -n "$found" ] || exit 0 # no live host addressed
host=${found%%$'\t'*}
remote=${found#*$'\t'}
remote=$(printf '%s' "$remote" | sed -E "s/^'(.*)'$/\1/; s/^\"(.*)\"$/\1/")
[ -z "$remote" ] && exit 0 # bare login shell
if is_readonly "$remote"; then
emit allow "read-only command on ${host}"
fi
emit ask "This mutates ${host}, a live host where the agent user has passwordless sudo. Command: ${remote}"
;;
mcp__centralis__run_on_host)
env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""')
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
case "$env" in prod|beta) ;; *) exit 0 ;; esac
if is_readonly "$cmd"; then
emit allow "read-only command on ${env}"
fi
emit ask "run_on_host would mutate ${env}. Command: ${cmd}"
;;
mcp__centralis__sql_query)
env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""')
write=$(printf '%s' "$payload" | jq -r '.tool_input.write // false')
[ "$write" = "true" ] || exit 0
case "$env" in prod|beta) ;; *) exit 0 ;; esac
sql=$(printf '%s' "$payload" | jq -r '.tool_input.sql // ""' | tr '\n' ' ' | cut -c1-300)
emit ask "Write query against the ${env} database. sql_query escalates to the app role and has no confirmation of its own. SQL: ${sql}"
;;
mcp__centralis__rollback_to|mcp__centralis__promote|mcp__centralis__secrets_rotate)
emit ask "${tool#mcp__centralis__} changes production state directly."
;;
*) exit 0 ;;
esac