Files
skills/tests/secret_guard_test.sh
T
mathiasandClaude Opus 5 4edf42ce9f
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 0s
fix(test): run the secret guard via its own shebang, not through sh
The suite piped hook input into `sh "$GUARD"`, which overrides the hook's
`#!/usr/bin/env bash` and tests an interpreter production never uses. Claude
Code executes the hook as a program.

On Debian /bin/sh is dash, which has no `set -o pipefail`, so the guard died
on line 23 before reading a byte of input:

    secret-guard.sh: 23: set: Illegal option -o pipefail
    secret_guard: 0 passed, 20 failed

while passing 20/20 on a host where /bin/sh happens to be bash. A security
gate that tests green and cannot start is the worst of both, and the test was
the only thing standing between those two states.

Found because baked-v16 added jq to the runner: this suite needs jq to build
its hook JSON, so it had been silently doing nothing in CI and started running
for the first time. Same shape as installing zip and immediately learning the
gitea backup check had never run -- a missing tool does not announce that it
is suppressing a whole suite.

Also asserts the hook is executable, since direct invocation depends on the
exec bit, and that assertion is what stops this regressing to `sh "$GUARD"`.

Checked the rest of the suite: every other `sh "$SUT"` targets a POSIX script,
which is correct. hooks/pre-task-ritual.sh is the other bash-shebang hook and
has no test at all -- filed separately rather than fixed here.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:23:06 +02:00

112 lines
4.4 KiB
Bash

#!/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}}' \
| "$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
}
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
# piping it into `sh` overrides that and tests something production never runs.
#
# It is not academic. On Debian /bin/sh is dash, which has no `set -o
# pipefail`, so the guard died on line 23 before reading a byte of input and
# every deny case returned empty -- 20 of 20 assertions failed in CI while
# passing on a host where /bin/sh happens to be bash. A security gate that
# tests green and cannot start is the worst of both.
if [ ! -x "$GUARD" ]; then
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
exit 1
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'
expect deny 'env -i | grep -i token' 'env -i with no command is still a dump'
expect deny 'env FOO=bar' 'env with only assignments and no command is a dump'
# --- allow: `env VAR=x cmd` / `env -u VAR cmd` RUN a command (#25) ---
# These print nothing themselves; a pipe into grep filters the command's own
# output, not the environment. Blocking them cost a documented workaround
# ((unset VAR; cmd)) on the 1Password write path, which is the shape below.
expect allow 'env -u ANTHROPIC_API_KEY claude --debug -p "ok" 2>&1 | grep -i plugin' 'env -u VAR runs a command'
expect allow 'env FOO=bar ./script.sh | grep x' 'env VAR=x runs a command'
expect allow 'env -u OP_SERVICE_ACCOUNT_TOKEN op item create - < item.json' 'env -u on the 1P write path'
expect deny 'env -u FOO ./run.sh; env | grep -i secret' 'a real dump later in the same line still denies'
# --- 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 ]