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
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-ritual.sh\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.sh\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Surfaces rule 0 on every prompt so the pre-task ritual is visible in context.
|
||||
echo "Rule 0 (non-negotiable): brain_query → load skill → failing test → state success criterion"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/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"}}'
|
||||
Reference in New Issue
Block a user