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,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 <command-string> [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 ]
|
||||
Reference in New Issue
Block a user