guardrails: enforce live-host and secrets policy with hooks #82

Merged
admin_emi merged 2 commits from guardrails/enforcement-hooks into dev 2026-07-25 07:09:14 +00:00
Owner

Phase 2 of the architecture simplification plan: add the enforcement layer that didn't exist.

Why

There were zero hooks configured. Guardrails were entirely prompt-based (CLAUDE.md text) plus post-hoc CI. The gap that matters most:

agent has passwordless sudo on prod and beta, and global settings allow Bash(ssh prod:*) outright with defaultMode: auto. So any session, in any directory, could restart / roll / delete production with no prompt. CI cannot help — nothing about ssh prod 'docker service rm …' goes through a pull request.

sql_query's own description says it escalates to the app role and "there is no confirmation prompt, so be deliberate on prod." This supplies the confirmation.

What this adds

Three hooks, checked into the repo so they travel with it instead of living in one machine's global settings.

prod-guard.sh (PreToolUse) — reads free, writes confirm

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, secrets_rotate.

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. Being wrong in the "ask" direction costs a keystroke; being wrong the other way costs production.

Compound commands are split on &&, ||, ;, | 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. The LAN dev box is deliberately unguarded.

secrets-guard.sh (PreToolUse) — deny direct vault writes

All six infra/deploy/secrets/*.yaml are SOPS-encrypted, and secrets-guard.yml fails the build on a plaintext one — but only after the secret is on disk and probably committed. This denies (rather than asks) because sops edit is the only correct path, so a prompt would just be an invitation to click through.

Relevant right now: Phase 0 is a live vault migration, so these files are about to be touched a lot.

lint-after-edit.sh (PostToolUse) — shellcheck in-session

infra/deploy/ scripts run as root over SSH against live hosts with no test suite in front of them. Findings come back in the same turn instead of after a five-minute CI round trip.

⚠️ shellcheck is not currently installed on this machine, so this hook is inert until it is. It exits quietly by design — a missing local tool must not break editing, and shell-lint CI is still the backstop. Install command is in .claude/hooks/README.md (pinned to v0.11.0, matching CI).

Failure behaviour

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. A bug here cannot cause silent execution.

Verification

Pipe-tested each hook with real tool payloads: 21 cases across reads, mutations, compound smuggling, and out-of-scope calls (local commands, LAN dev, read-only SQL) — all correct.

That testing earned its keep by catching two silent bypasses in the first draft, both of which looked like working code:

  1. An unescaped [ in a case pattern list, which malformed the allowlist.
  2. printf '%s' with no trailing newline — read returns non-zero on the only line, so the loop body never ran and every command classified as read-only. A total bypass that produced no error.

Both are documented in .claude/hooks/README.md so the next person to touch the classifier re-runs the matrix.

Not included here

worktree.baseRef: "fresh" is a change to the operator's global ~/.claude/settings.json, which is outside version control — called out separately rather than smuggled into a PR.

Phase 2 of the architecture simplification plan: add the enforcement layer that didn't exist. ## Why There were **zero hooks configured**. Guardrails were entirely prompt-based (`CLAUDE.md` text) plus post-hoc CI. The gap that matters most: `agent` has passwordless sudo on prod and beta, and global settings allow `Bash(ssh prod:*)` outright with `defaultMode: auto`. So any session, in any directory, could restart / roll / delete production **with no prompt**. CI cannot help — nothing about `ssh prod 'docker service rm …'` goes through a pull request. `sql_query`'s own description says it escalates to the app role and "there is no confirmation prompt, so be deliberate on prod." This supplies the confirmation. ## What this adds Three hooks, **checked into the repo** so they travel with it instead of living in one machine's global settings. ### `prod-guard.sh` (PreToolUse) — reads free, writes confirm 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`, `secrets_rotate`. **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. Being wrong in the "ask" direction costs a keystroke; being wrong the other way costs production. Compound commands are split on `&&`, `||`, `;`, `|` 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. The LAN dev box is deliberately unguarded. ### `secrets-guard.sh` (PreToolUse) — deny direct vault writes All six `infra/deploy/secrets/*.yaml` are SOPS-encrypted, and `secrets-guard.yml` fails the build on a plaintext one — but only *after* the secret is on disk and probably committed. This denies (rather than asks) because `sops edit` is the only correct path, so a prompt would just be an invitation to click through. Relevant right now: Phase 0 is a live vault migration, so these files are about to be touched a lot. ### `lint-after-edit.sh` (PostToolUse) — shellcheck in-session `infra/deploy/` scripts run as root over SSH against live hosts with no test suite in front of them. Findings come back in the same turn instead of after a five-minute CI round trip. ⚠️ **shellcheck is not currently installed on this machine**, so this hook is *inert until it is*. It exits quietly by design — a missing local tool must not break editing, and `shell-lint` CI is still the backstop. Install command is in `.claude/hooks/README.md` (pinned to v0.11.0, matching CI). ## Failure behaviour 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. A bug here cannot cause silent execution. ## Verification Pipe-tested each hook with real tool payloads: **21 cases** across reads, mutations, compound smuggling, and out-of-scope calls (local commands, LAN dev, read-only SQL) — all correct. That testing earned its keep by catching **two silent bypasses in the first draft**, both of which looked like working code: 1. An unescaped `[` in a `case` pattern list, which malformed the allowlist. 2. `printf '%s'` with no trailing newline — `read` returns non-zero on the only line, so the loop body never ran and **every** command classified as read-only. A total bypass that produced no error. Both are documented in `.claude/hooks/README.md` so the next person to touch the classifier re-runs the matrix. ## Not included here `worktree.baseRef: "fresh"` is a change to the operator's global `~/.claude/settings.json`, which is outside version control — called out separately rather than smuggled into a PR.
admin_emi added 1 commit 2026-07-25 04:10:19 +00:00
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
5418628989
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.
admin_emi added 1 commit 2026-07-25 04:12:57 +00:00
guardrails: silence the intentional SC2016 in prod-guard
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
c184fd55ea
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.
admin_emi merged commit fbdfebf873 into dev 2026-07-25 07:09:14 +00:00
admin_emi deleted branch guardrails/enforcement-hooks 2026-07-25 07:09:15 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Jinemi/thermograph#82
No description provided.