guardrails: enforce live-host and secrets policy with hooks #82
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Jinemi/thermograph#82
Loading…
Reference in a new issue
No description provided.
Delete branch "guardrails/enforcement-hooks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.mdtext) plus post-hoc CI. The gap that matters most:agenthas passwordless sudo on prod and beta, and global settings allowBash(ssh prod:*)outright withdefaultMode: auto. So any session, in any directory, could restart / roll / delete production with no prompt. CI cannot help — nothing aboutssh 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 confirmCovers
Bashssh to prod/beta (by alias, public IP, or mesh IP) plus Centralisrun_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 — sodocker ps; rm -rf /asks. Redirection to a file, command substitution,sed -i, and mutatingdocker/git/systemctlsubcommands 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 writesAll six
infra/deploy/secrets/*.yamlare SOPS-encrypted, andsecrets-guard.ymlfails the build on a plaintext one — but only after the secret is on disk and probably committed. This denies (rather than asks) becausesops editis 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-sessioninfra/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-lintCI 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
jqis 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:
[in acasepattern list, which malformed the allowlist.printf '%s'with no trailing newline —readreturns 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.mdso 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.