#!/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}}' \ | "$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 ]