guardrails: enforce live-host and secrets policy with hooks
Some checks failed
shell-lint / shellcheck (pull_request) Failing after 14s
secrets-guard / encrypted (pull_request) Successful in 16s
PR build (required check) / changes (pull_request) Successful in 18s
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 4s

CLAUDE.md can only ask; nothing enforced it. The estate's widest privilege is
that `agent` has passwordless sudo on prod and beta while global settings allow
`Bash(ssh prod:*)` outright, so any session in any directory could restart,
roll or delete production with no prompt. CI cannot see this: nothing about
`ssh prod 'docker service rm ...'` goes through a pull request.

Adds three PreToolUse/PostToolUse hooks, checked in so they travel with the
repo rather than living in one machine's global settings.

prod-guard.sh — reads stay friction-free, mutations ask first. Covers Bash ssh
to prod/beta (by alias, public IP or mesh IP) plus Centralis run_on_host,
sql_query(write:true), rollback_to, promote and secrets_rotate. sql_query's own
description notes it escalates to the app role with no confirmation of its own;
this supplies one.

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 through. Compound commands are split on &&, ||, ;
and | and every segment must be recognised, so `docker ps; rm -rf /` asks.
Redirection to a file, command substitution, `sed -i`, and mutating
docker/git/systemctl subcommands all count as writes.

Beta is guarded as strictly as prod: it serves beta.thermograph.org and hosts
Forgejo, so one destructive command there takes out git, CI and the registry
together. LAN dev is deliberately unguarded.

secrets-guard.sh — denies any direct Write/Edit of infra/deploy/secrets/*.yaml.
All six vault files are SOPS-encrypted and secrets-guard.yml fails the build on
a plaintext one, but only after the secret is already on disk and probably
committed. Denies rather than asks: `sops edit` is the only correct path, so a
prompt would just be an invitation to click through.

lint-after-edit.sh — shellchecks an edited *.sh and feeds findings back in the
same turn instead of after a five-minute CI round trip. These scripts run as
root over SSH against live hosts with no test suite in front of them. It exits
quietly when shellcheck is absent, so it is inert until installed; shell-lint
CI remains the backstop.

All three fail toward the prompt: exit 0 with no output means "no opinion" and
the normal permission flow proceeds, which is also what happens if jq is
missing or a script errors.

Verified by piping tool payloads directly to each hook — 21 cases covering
reads, mutations, compound smuggling and out-of-scope calls. That testing
caught two silent bypasses in the first draft: an unescaped `[` in a case
pattern list, and a missing trailing newline that made `read` return non-zero
on the only line so the loop body never ran and every command classified as
read-only. Both are called out in .claude/hooks/README.md.
This commit is contained in:
Emi Griffith 2026-07-24 21:09:49 -07:00
parent 72ad6f300f
commit 5418628989
5 changed files with 328 additions and 0 deletions

65
.claude/hooks/README.md Normal file
View file

@ -0,0 +1,65 @@
# .claude/hooks — enforcement, not advice
`CLAUDE.md` files can only ask. These hooks are the part that enforces, and they
travel with the repo so a session started anywhere in this checkout gets them.
They are wired up in `.claude/settings.json`.
| Hook | Event | What it does |
|---|---|---|
| `prod-guard.sh` | PreToolUse | Live-host commands: reads run unprompted, mutations ask first |
| `secrets-guard.sh` | PreToolUse | Denies any direct write to `infra/deploy/secrets/*.yaml` |
| `lint-after-edit.sh` | PostToolUse | shellchecks an edited `*.sh` and feeds findings back immediately |
## Why prod-guard exists
`agent` has passwordless sudo on prod and beta, and the operator's global settings
allow `Bash(ssh prod:*)` outright. Nothing about `ssh prod 'docker service rm …'`
goes through a pull request, so CI cannot see it — before this hook, any session in
any directory could delete production with no prompt.
**It classifies by allowlist, not blocklist.** Only commands positively recognised as
read-only are allowed through; everything else asks. A blocklist of dangerous verbs
is wrong by construction — the first destructive verb nobody thought of sails
straight through. Being wrong in the "ask" direction costs a keystroke; being wrong
the other way costs production.
Beta is guarded as strictly as prod: it serves beta.thermograph.org *and* hosts
Forgejo, so a destructive command there takes out git, CI and the registry at once.
The LAN dev box is deliberately unguarded.
## Changing the classifier
`is_readonly()` in `prod-guard.sh` splits a command on `&&`, `||`, `;` and `|`, then
checks each segment's leading word. Redirection to a file, command substitution,
`sed -i`, and mutating `docker`/`git`/`systemctl` subcommands all count as writes.
If you add a command to the allowlist, **re-run the test matrix**. There is a
non-obvious failure mode this already hit once: the loop is fed by
`printf '%s\n' | sed`, and dropping that trailing newline makes `read` return
non-zero on the only line, so the loop body never runs and *every* command
classifies as read-only — a silent, total bypass that still looks like it works.
Test by piping a payload straight in:
```bash
echo '{"tool_name":"Bash","tool_input":{"command":"ssh prod '\''rm -rf /'\''"}}' \
| .claude/hooks/prod-guard.sh
# expect: permissionDecision "ask"
```
Exit 0 with no output means "no opinion" and the normal permission flow proceeds.
That is also what happens if `jq` is missing or the script errors, so a bug here
fails toward the prompt rather than toward silent execution.
## lint-after-edit needs shellcheck on PATH
It exits quietly when shellcheck is absent, so it is **inert until installed** — a
missing local tool must not break editing, and `shell-lint` CI is still the backstop.
Install the same pinned version CI uses:
```bash
VER=v0.11.0
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${VER}/shellcheck-${VER}.linux.x86_64.tar.xz" \
| tar -xJ --strip-components=1 -C ~/.local/bin "shellcheck-${VER}/shellcheck"
```

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# PostToolUse: shellcheck a shell script the moment it is edited.
#
# The scripts under infra/deploy/ run as root over SSH against live hosts with no
# test suite in front of them — a quoting bug or an unset-variable typo is found on
# the host, or not at all. shell-lint.yml already guards this, but only after a
# push: a five-minute round trip to learn about a missing quote, by which point the
# context that produced it is gone.
#
# Findings are fed back to the model rather than blocking the edit. The file is
# already written; the useful move is to fix it now, in the same turn.
#
# Matches shell-lint.yml's pinned shellcheck when one is on PATH. If shellcheck is
# not installed this exits quietly — CI is still the backstop, and a missing local
# tool must not break editing.
set -uo pipefail
payload=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
command -v shellcheck >/dev/null 2>&1 || exit 0
path=$(printf '%s' "$payload" | jq -r '.tool_response.filePath // .tool_input.file_path // ""')
[ -n "$path" ] || exit 0
case "$path" in *.sh) ;; *) exit 0 ;; esac
[ -f "$path" ] || exit 0
if out=$(shellcheck -f gcc "$path" 2>&1); then
exit 0
fi
# Keep the feedback small: findings only, capped.
out=$(printf '%s' "$out" | head -30)
jq -n --arg p "$path" --arg o "$out" \
'{decision:"block", reason:("shellcheck findings in \($p) — shell-lint CI will fail on these, so fix them now:\n\($o)")}'

153
.claude/hooks/prod-guard.sh Executable file
View file

@ -0,0 +1,153 @@
#!/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.
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

34
.claude/hooks/secrets-guard.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# PreToolUse guard: never write infra/deploy/secrets/*.yaml directly.
#
# Every file matching that glob is SOPS-encrypted, and secrets-guard.yml fails the
# build if one is not. This is the same rule enforced one step earlier: a direct
# Write or Edit produces plaintext, and by the time CI catches it the secret has
# already been written to disk and probably committed.
#
# The supported path is `sops edit <file>`, which decrypts to a temp file, opens an
# editor and re-encrypts on save — the plaintext never touches the working tree.
#
# Denies rather than asks: there is no legitimate reason to hand-write one of these,
# so a prompt would only be an invitation to click through.
set -uo pipefail
payload=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
path=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // ""')
[ -n "$path" ] || exit 0
case "$path" in
*/infra/deploy/secrets/*.yaml)
jq -n --arg p "$path" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Refusing to write \($p) directly — every file under infra/deploy/secrets/ is SOPS-encrypted and a direct write would land plaintext. Use `sops edit \($p)` instead, which re-encrypts on save. (secrets-guard.yml would fail the build on this anyway, but only after the secret was already on disk.)")
}
}'
exit 0
;;
*) exit 0 ;;
esac

42
.claude/settings.json Normal file
View file

@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__centralis__run_on_host|mcp__centralis__sql_query|mcp__centralis__rollback_to|mcp__centralis__promote|mcp__centralis__secrets_rotate",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/prod-guard.sh\"",
"timeout": 10,
"statusMessage": "Checking live-host access"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/secrets-guard.sh\"",
"timeout": 10,
"statusMessage": "Checking SOPS vault"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/lint-after-edit.sh\"",
"timeout": 30,
"statusMessage": "shellcheck"
}
]
}
]
}
}