guardrails: enforce live-host and secrets policy with hooks #82
5 changed files with 350 additions and 0 deletions
84
.claude/hooks/README.md
Normal file
84
.claude/hooks/README.md
Normal file
|
|
@ -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.
|
||||
34
.claude/hooks/lint-after-edit.sh
Executable file
34
.claude/hooks/lint-after-edit.sh
Executable 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)")}'
|
||||
156
.claude/hooks/prod-guard.sh
Executable file
156
.claude/hooks/prod-guard.sh
Executable file
|
|
@ -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 "<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
34
.claude/hooks/secrets-guard.sh
Executable 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
42
.claude/settings.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue