From fbdfebf87380673097c1f320bf39def257cff804 Mon Sep 17 00:00:00 2001 From: emi Date: Sat, 25 Jul 2026 07:09:11 +0000 Subject: [PATCH] guardrails: enforce live-host and secrets policy with hooks (#82) --- .claude/hooks/README.md | 84 +++++++++++++++++ .claude/hooks/lint-after-edit.sh | 34 +++++++ .claude/hooks/prod-guard.sh | 156 +++++++++++++++++++++++++++++++ .claude/hooks/secrets-guard.sh | 34 +++++++ .claude/settings.json | 42 +++++++++ 5 files changed, 350 insertions(+) create mode 100644 .claude/hooks/README.md create mode 100755 .claude/hooks/lint-after-edit.sh create mode 100755 .claude/hooks/prod-guard.sh create mode 100755 .claude/hooks/secrets-guard.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 0000000..0075e86 --- /dev/null +++ b/.claude/hooks/README.md @@ -0,0 +1,84 @@ +# .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 — a missing local tool must not break +editing, and `shell-lint` CI is still the backstop. v0.11.0 (the version CI pins) is +installed at `~/.local/bin/shellcheck`; if you move machines, reinstall it: + +```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" +``` + +Keep it matched to `shell-lint.yml`'s pin. A drifted shellcheck grows *new* warnings +and fails CI on an unrelated push, which is why that workflow pins the version and +its sha256 rather than using `apt-get install`. + +## The global copy + +`~/.claude/settings.json` runs `prod-guard.sh` from `~/.claude/hooks/` as well, so a +session started **outside** this repo is covered too — otherwise the guard would be +trivially bypassed by working from `~/Code`. That copy is a mirror; this one is +canonical. If you change the classifier here, copy it over: + +```bash +cp .claude/hooks/prod-guard.sh ~/.claude/hooks/prod-guard.sh +``` + +The blanket `Bash(ssh prod:*)` / `Bash(ssh beta:*)` allow rules were removed from +global settings at the same time, so the hook is the only thing granting live-host +access. If it is missing or broken, nothing allows the command and you get a prompt. diff --git a/.claude/hooks/lint-after-edit.sh b/.claude/hooks/lint-after-edit.sh new file mode 100755 index 0000000..304cdea --- /dev/null +++ b/.claude/hooks/lint-after-edit.sh @@ -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)")}' diff --git a/.claude/hooks/prod-guard.sh b/.claude/hooks/prod-guard.sh new file mode 100755 index 0000000..5d81dda --- /dev/null +++ b/.claude/hooks/prod-guard.sh @@ -0,0 +1,156 @@ +#!/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 "\t" 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 diff --git a/.claude/hooks/secrets-guard.sh b/.claude/hooks/secrets-guard.sh new file mode 100755 index 0000000..eed51f1 --- /dev/null +++ b/.claude/hooks/secrets-guard.sh @@ -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 `, 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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..11fcff0 --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +}