guardrails: enforce live-host and secrets policy with hooks
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.
2026-07-25 04:09:49 +00:00
|
|
|
# .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
|
|
|
|
|
|
2026-07-25 04:12:53 +00:00
|
|
|
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:
|
guardrails: enforce live-host and secrets policy with hooks
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.
2026-07-25 04:09:49 +00:00
|
|
|
|
|
|
|
|
```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"
|
|
|
|
|
```
|
2026-07-25 04:12:53 +00:00
|
|
|
|
|
|
|
|
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.
|