Files
skills/hooks/secret-guard.sh
T
mathiasandClaude Opus 5 747c6c7f4c feat(plugin): ship the secret-guard and pre-task-ritual hooks + MCP servers
The plugin now carries the two hooks Mathias owns and the brain/gitea/infra
MCP server definitions, not just skills.

Why this matters more than tidiness: both hooks existed ONLY in koala's
~/.claude/, which is host-local and unsynced. flamingo and iguana have been
running with no secret-guard at all — that is the control which blocks the
decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the op-item-get
shape behind 2026-07-30, and the base64-bypass shape behind 2026-06-11. Its
absence on two of three machines is a security gap, not a config gap. Shipping
it in the plugin means installing the plugin installs the guard.

.mcp.json references tokens as ${BRAIN_MCP_TOKEN} / ${GITEA_MCP_TOKEN} only —
env indirection, never literal values, so the manifest is safe to commit.

tests/secret_guard_test.sh pins 14 cases. It asserts on
hookSpecificOutput.permissionDecision, NOT exit status: the guard always exits
0 by contract, so an exit-status assertion would pass vacuously while the guard
silently allowed everything. Each deny case names the incident that produced it.

gsd-* hooks are deliberately NOT migrated — GSD owns and updates those in place.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:31:04 +02:00

119 lines
8.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# PreToolUse hook (Bash matcher) — hard-blocks command patterns that reveal
# secret values into the transcript, rather than relying on remembering an
# existence-only check every time.
#
# Triggered by two real leaks in one session (2026-07-30):
# 1. `op item get <name> --format json` — JSON mode has NO masking, unlike
# the human-readable table view (`op item get <name>` alone masks
# concealed fields with bullets by default). Printed a live Gitea MCP
# token into the transcript.
# 2. `env | grep -i gitea` (infra#228, 2026-07-27) — same shape: an
# inspection command that dumps values instead of checking existence.
#
# Policy: block ALL direct reads of secret material via these command
# shapes, unconditionally, regardless of flags. Existence/rotation must go
# through a wrapper script (e.g. infra's scripts/op-safe.sh, scripts/rotate-
# *.sh) that strips values before anything reaches stdout — those scripts
# call the same underlying commands internally, but as a subprocess of the
# script, never as a literal Bash tool call the model composes and can typo.
#
# Reads PreToolUse hook JSON on stdin, decides allow/deny for the Bash tool.
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')"
[ "$TOOL_NAME" = "Bash" ] || { printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'; exit 0; }
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
deny() {
local reason="$1"
jq -n --arg reason "$reason" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}, systemMessage: $reason}'
exit 0
}
# 1Password CLI raw reads — block unconditionally, any flags.
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+get\b'; then
deny "Blocked: 'op item get' prints secret values to stdout (JSON mode has no masking at all; even table mode can leak via --fields/--reveal). Use a wrapper that strips .fields[].value before output (e.g. scripts/op-safe.sh exists|meta), or op item edit to WRITE without ever printing the value."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+list\b'; then
deny "Blocked: 'op item list' can include concealed field previews. Use a wrapper that filters to titles only (e.g. scripts/op-safe.sh list)."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+read\b'; then
deny "Blocked: 'op read' prints the raw secret value directly. Consume it inside a subprocess only (op run -- <cmd>, or a script that never echoes/captures it to stdout)."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then
deny "Blocked: 'op document get' prints document contents (may contain secrets) to stdout. Use a wrapper script instead."
fi
# Raw env/secret-file dumps (infra#228/#229 root cause).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)env(\s|$).*\|.*grep'; then
deny "Blocked: 'env | grep' prints matching values in full. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eq '(^|[;&|])[[:space:]]*env[[:space:]]*($|[;&|])'; then
deny "Blocked: bare 'env' prints every variable's value, including secrets. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then
deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*\.env'; then
deny "Blocked: cat of a .env file prints secret values. Use: grep -c '=' file.env (count only) or [ -s file.env ] && echo present"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*(\.llmkeys|\.op-env|\.op-agent-write\.env)\b'; then
deny "Blocked: cat of a known secrets file prints raw values. Use: grep -o '^[A-Z_]*=' file (key names only, never the value)."
fi
# kubectl secrets — -o yaml/json prints base64 (trivially reversible).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)kubectl\b[^;&|]*\bget\s+secrets?\b[^;&|]*(-o|--output)[[:space:]=]*(yaml|json)'; then
deny "Blocked: 'kubectl get secret -o yaml/json' prints base64-encoded values (trivially reversible, same as plaintext). Use 'kubectl describe secret <name>' (shows keys + byte lengths only, never values) for existence checks."
fi
# Gitea app.ini — mixes plain config ([queue]/[indexer]/[repository]) with
# live secrets ([security].INTERNAL_TOKEN/SECRET_KEY, [oauth2].JWT_SECRET,
# [server].LFS_JWT_SECRET) in the SAME file, auto-generated on install and
# not git/SOPS-tracked. A context-range grep (-A/-B/-C) doesn't respect
# section boundaries and silently pulls a secret line into stdout even when
# targeting an unrelated section. Root cause of a real leak, infra sprint
# 2026-08-04 (`kubectl exec ... cat app.ini | grep -A15 "^\[queue\]"` pulled
# through into [oauth2].JWT_SECRET).
# Tight on purpose: requires "cat" immediately followed by a bare path-shaped
# token ending in app.ini (no intervening whitespace) so this only fires on
# an actual invocation ('cat /data/gitea/conf/app.ini', 'cat app.ini') and
# never on prose that merely mentions both words, e.g. a commit message
# describing this exact guard (that false-positived once while writing the
# fix — commands are matched as raw strings, heredoc bodies included).
if printf '%s' "$CMD" | grep -Eq '\bcat[[:space:]]+[A-Za-z0-9_./-]*app\.ini\b'; then
deny "Blocked: cat of Gitea's app.ini prints secrets mixed into the same file ([security]/[oauth2]/[server].LFS_JWT_SECRET). Use exact-key checks: grep -c '^KEY = ' app.ini (existence only) — never cat the whole file."
fi
if printf '%s' "$CMD" | grep -Eq '\bgrep\b[^;&|]*-[A-Za-z]*[ABC][A-Za-z]*[[:space:]]*[0-9][^;&|]*app\.ini\b'; then
deny "Blocked: grep -A/-B/-C context flags against app.ini can bleed past section boundaries into [security]/[oauth2]/[server] secrets, even when targeting an unrelated section (this is exactly how the 2026-08-04 leak happened). Use an exact anchor instead: grep '^KEY = ' app.ini, or bound to one section with awk '/^\\[section\\]/,/^\\[/' and confirm the section has no secret keys first."
fi
# sops decrypt output inspected directly — infra sweep-sprint incident,
# 2026-08-05: `sops -d ntfy-tokens.enc.yaml > /tmp/plain.yaml` (safe,
# redirected to a file) immediately followed by `cat /tmp/plain.yaml | head`
# in the SAME command block printed a live ntfy token into the transcript.
# Re-encrypting a secret (e.g. switching a file to data-only encryption per
# infra#177's pattern) requires decrypting it locally at some point — that
# redirect-to-file step alone is fine, the leak is any print command touching
# the plaintext afterward. Block the combination unconditionally rather than
# trying to track which file a decrypt wrote to across separate tool calls.
if printf '%s' "$CMD" | grep -Eq '\bsops\b[^;&|]*(-d\b|--decrypt\b|[[:space:]]decrypt\b)' \
&& printf '%s' "$CMD" | grep -Eq '\b(cat|head|tail|less|more|bat|od|xxd)\b'; then
deny "Blocked: this command decrypts a sops-encrypted file AND runs a print command (cat/head/tail/etc) in the same invocation — that's exactly the 2026-08-05 ntfy-token leak shape. Decrypt to a temp file (sops -d file > /tmp/x, fine on its own), do whatever transform is needed WITHOUT printing it (e.g. sops -e --encrypted-regex '^(data|stringData)\$' /tmp/x > newfile), then shred -u the temp file. Never cat/head/tail a sops-decrypted file, even to 'just check a few lines'."
fi
# base64 transform of a secret-shaped env var — the documented 2026-06-11
# 'defeats op run masking' bypass.
if printf '%s' "$CMD" | grep -Eqi '\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)[A-Za-z0-9_]*\}?[^;&|]*\|[^;&|]*base64'; then
deny "Blocked: piping a secret-shaped variable through base64 to inspect it. base64 is an encoding, not masking — op run's own masking only recognizes the raw value, so this is a known bypass (2026-06-11 incident). Never transform a secret to look at it."
fi
if printf '%s' "$CMD" | grep -Eqi 'base64[^;&|]*<<<\s*"?\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)'; then
deny "Blocked: base64 <<< of a secret-shaped variable. Same bypass as above — never transform a secret to inspect it."
fi
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'