From 747c6c7f4cbfb275970607c910e5ff9eab20d850 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 23 Aug 2026 14:31:04 +0200 Subject: [PATCH] feat(plugin): ship the secret-guard and pre-task-ritual hooks + MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m --- .mcp.json | 22 +++++++ hooks/hooks.json | 27 +++++++++ hooks/pre-task-ritual.sh | 3 + hooks/secret-guard.sh | 118 +++++++++++++++++++++++++++++++++++++ tests/secret_guard_test.sh | 86 +++++++++++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 .mcp.json create mode 100644 hooks/hooks.json create mode 100755 hooks/pre-task-ritual.sh create mode 100755 hooks/secret-guard.sh create mode 100644 tests/secret_guard_test.sh diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8da20c9 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "brain": { + "type": "http", + "url": "https://brain-mcp.d-ma.be/mcp", + "headers": { + "Authorization": "Bearer ${BRAIN_MCP_TOKEN}" + } + }, + "gitea": { + "type": "http", + "url": "https://git-mcp.d-ma.be/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_MCP_TOKEN}" + } + }, + "infra": { + "type": "http", + "url": "https://infra-mcp.d-ma.be/mcp" + } + } +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..b9cf8e2 --- /dev/null +++ b/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/hooks/pre-task-ritual.sh b/hooks/pre-task-ritual.sh new file mode 100755 index 0000000..72f03ac --- /dev/null +++ b/hooks/pre-task-ritual.sh @@ -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" diff --git a/hooks/secret-guard.sh b/hooks/secret-guard.sh new file mode 100755 index 0000000..5f5cd1c --- /dev/null +++ b/hooks/secret-guard.sh @@ -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 --format json` — JSON mode has NO masking, unlike +# the human-readable table view (`op item get ` 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 -- , 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 ' (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"}}' diff --git a/tests/secret_guard_test.sh b/tests/secret_guard_test.sh new file mode 100644 index 0000000..d9e27da --- /dev/null +++ b/tests/secret_guard_test.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Tests for hooks/secret-guard.sh — the PreToolUse gate that blocks command +# shapes which render secret values into the transcript. +# +# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers +# in the JSON body as hookSpecificOutput.permissionDecision = allow | deny. +# Asserting on exit status would pass vacuously — assert on the decision. +# +# Every deny case below is a shape that caused a real leak; the comment names +# the incident so nobody "simplifies" the pattern away later. + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +GUARD="$ROOT/hooks/secret-guard.sh" + +pass=0 +fail=0 + +# decision_for [tool_name] +# Emits the guard's permissionDecision for a synthetic Bash tool call. +decision_for() { + _cmd=$1 + _tool=${2:-Bash} + jq -n --arg t "$_tool" --arg c "$_cmd" \ + '{tool_name: $t, tool_input: {command: $c}}' \ + | sh "$GUARD" \ + | jq -r '.hookSpecificOutput.permissionDecision // "MISSING"' +} + +expect() { + _want=$1 + _cmd=$2 + _label=$3 + _got=$(decision_for "$_cmd") + if [ "$_got" = "$_want" ]; then + pass=$((pass + 1)) + printf ' ok %s\n' "$_label" + else + fail=$((fail + 1)) + printf ' FAIL %s\n want=%s got=%s\n cmd=%s\n' "$_label" "$_want" "$_got" "$_cmd" + fi +} + +if [ ! -f "$GUARD" ]; then + printf 'FAIL: %s does not exist\n' "$GUARD" + exit 1 +fi + +command -v jq >/dev/null 2>&1 || { printf 'SKIP: jq not installed\n'; exit 0; } + +# --- deny: decrypt-and-print in one invocation (2026-08-05 ntfy-token leak) --- +expect deny 'sops -d secrets.enc.yaml | tail -20' 'sops decrypt piped to tail' +expect deny 'sops -d ingress.enc.yaml > /tmp/x; cat /tmp/x' 'sops decrypt then cat' + +# --- deny: 1Password raw reads (2026-07-30) --- +expect deny 'op item get "gitea mcp" --format json' 'op item get' +expect deny 'op read "op://vault/item/field"' 'op read' + +# --- deny: env dumps (infra#228, 2026-07-27) --- +expect deny 'env | grep -i gitea' 'env piped to grep' +expect deny 'env' 'bare env' +expect deny 'printenv' 'printenv' + +# --- deny: kubernetes secret material --- +expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml' + +# --- deny: base64 as a masking bypass (2026-06-11) --- +expect deny 'echo "$GITEA_MCP_TOKEN" | base64' 'token piped through base64' + +# --- allow: ordinary commands must not be blocked --- +expect allow 'ls -la /tmp' 'plain ls' +expect allow 'git status --porcelain' 'git status' +expect allow 'kubectl describe secret brain-app' 'kubectl describe secret (values redacted by k8s)' +expect allow '[ -n "$GITEA_MCP_TOKEN" ] && echo set' 'existence-only check' + +# --- allow: the guard only judges Bash --- +_got=$(decision_for 'env' 'Read') +if [ "$_got" = "allow" ]; then + pass=$((pass + 1)); printf ' ok non-Bash tool passes through\n' +else + fail=$((fail + 1)); printf ' FAIL non-Bash tool passes through\n want=allow got=%s\n' "$_got" +fi + +printf '\nsecret_guard: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]