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
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.
34 lines
1.4 KiB
Bash
Executable file
34 lines
1.4 KiB
Bash
Executable file
#!/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
|