thermograph/.claude/hooks/README.md

3.9 KiB

.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:

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:

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:

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.