ci/woodpecker/pr/ci Pipeline was canceled
An independent reviewer broke all three new controls before they shipped. Every finding is reproduced as a fixture or a repro, because the class is recurring rather than incidental: each hole was a case where the answer was "allow" because something was ABSENT rather than because it was CHECKED. 1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space), `--request=POST` (equals form), and a URL path assembled from shell variables each carried a real provider write straight through. Write detection now covers every body and method form curl accepts, and the endpoint match no longer anchors on a literal host path that a variable can dissolve. 2. wrapper-guard blocked only when the wrapper FILE existed. A host with a broken or partial install therefore permitted exactly the raw writes the guard exists to stop. Blocking is now on the endpoint; a missing wrapper changes the remedy text, not the verdict — a broken install is not permission to bypass gate 7. 3. mosaic-worktree read a worktree's safety from two questions, and a clean, fully-pushed tree holding a gitignored `local.secret` answered both with zero. `git worktree remove` then deleted the one copy in existence. A file is gitignored precisely so nothing else holds it, so ignored-but-not- disposable files are now a third evidence question. Build junk (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common case still reads SAFE. 4. check-tools-index counted a documented tool as discoverable at mode 0644. Every caller tests `[ -x ]`, so a non-executable tool is a missing tool; it now fails the gate with its own message. Local gates green: sanitization, resident budget, test enumeration, tools-index (4/4 self-test, 100% on the enforced git suite), and wrapper-guard 20/20.
84 lines
5.1 KiB
Bash
Executable File
84 lines
5.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
|
#
|
|
# Resolves no credentials, touches no network, and creates no repository: the
|
|
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
|
# contract is testable from fixtures.
|
|
#
|
|
# The fixtures are written to a temp file rather than passed inline, and this is
|
|
# not stylistic. The guard inspects the literal text of the Bash command it is
|
|
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
|
# trips the guard on the harness instead of on the fixture — which is exactly
|
|
# what happened the first time this was checked by hand. Substring matching over
|
|
# whole command text is the guard's deliberate fail-closed posture; a test that
|
|
# does not account for it silently measures the wrong thing.
|
|
#
|
|
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
|
|
|
set -uo pipefail
|
|
|
|
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
|
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
|
|
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
FIXTURES="$TMP/fixtures.tsv"
|
|
|
|
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
|
# 0 = allowed, 2 = blocked.
|
|
{
|
|
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
|
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
|
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
|
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
|
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
|
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
|
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
|
# --- bypasses an independent reviewer demonstrated against the first version.
|
|
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
|
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
|
# the guard reads text, so every spelling it does not know is a hole.
|
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
|
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
|
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
|
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
|
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
|
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
|
} > "$FIXTURES"
|
|
|
|
fail=0 n=0
|
|
while IFS=$'\t' read -r want payload why; do
|
|
[ -n "${want:-}" ] || continue
|
|
n=$((n + 1))
|
|
printf '%s' "$payload" | "$GUARD" >/dev/null 2>&1
|
|
got=$?
|
|
if [ "$got" = "$want" ]; then
|
|
printf 'ok %s\n' "$why"
|
|
else
|
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
|
fail=1
|
|
fi
|
|
done < "$FIXTURES"
|
|
|
|
printf '\n'
|
|
if [ "$fail" -eq 0 ]; then
|
|
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
|
else
|
|
cat <<'EOF'
|
|
wrapper-guard drifted from its contract.
|
|
|
|
A guard that blocks too much gets routed around, and a guard that blocks too
|
|
little is decoration. Both directions are failures here, which is why the
|
|
allowed cases are asserted as hard as the blocked ones.
|
|
EOF
|
|
fi
|
|
exit "$fail"
|