ci/woodpecker/pr/ci Pipeline failed
Round-three review found two more absence-driven allows, both in the skeleton introduced by round two, and fixing them exposed a third the reviewer had not reached yet. 1. A word in front of a command does not displace the command. `env VAR=v curl`, `command curl`, `timeout 10 curl` and `/usr/bin/curl` were all real writes at execution position that a bare-name match could not see. The `env` form is the one that matters: it is what an agent reaches for to keep a credential out of the global environment, so the careful spelling was the invisible one. 2. Quoted data stops being data when a shell is about to execute it, and the first version knew only `bash -c`, `sh <<` and `eval`. It did not know the pipe, which is the form people actually use: `printf ... | sh`, `cat <<EOF | sh`, `sh -s <<EOF` each made a live call vanish from the skeleton while still running. 3. Found while testing the fix: that decision was made for the WHOLE command, so a single unrelated `docker run ... sh -c 'echo hi'` promoted every other quoted span on every other line to code. It blocked its own author for the second time in a day. A shell on one line does not execute a string on another line, and over-blocking is not the safe direction — a guard that blocks ordinary work gets switched off, and a guard that is off permits everything. The skeleton is now built per line, and a heredoc body is code only when the line that opened it fed a shell. All seven reviewer repros are pinned as fixtures, each with a counter-fixture in the allowed direction: `echo timeout 10 curl ...` is not a call, a pipe to `wc` is not execution, an unrelated shell on another line changes nothing. Fixtures 40/40, and a sweep of 18 ordinary commands blocks none of them. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), prettier.
127 lines
10 KiB
Bash
Executable File
127 lines
10 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'
|
|
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
|
# amount of fragment matching, because the endpoint does not exist until the
|
|
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
|
# read, rather than pretending it read one.
|
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
|
# And the other direction, which is the failure mode that gets a hook deleted:
|
|
# discussing a call is not making one. In each of these the client sits behind
|
|
# a quote, never at command position.
|
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tgrepping for an example is not calling it\n'
|
|
printf '0\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\twriting an example into a file is not calling it\n'
|
|
printf '0\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\tprinting an example is not calling it\n'
|
|
# Command position must still catch the real thing behind operators and env.
|
|
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
|
# --- and the case the AUTHOR hit, one level in from the reported one: an
|
|
# operator INSIDE a quoted string is not an operator. This blocked a message
|
|
# that merely quoted the fixture above. Position is judged on the skeleton.
|
|
printf '0\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tan operator inside a quoted string is not an operator\n'
|
|
printf '0\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body is data, not code\n'
|
|
# ...but quotes stop being data the moment something executes them.
|
|
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
|
# --- round three. The skeleton was right about position and wrong about which
|
|
# words hold it. A word in front of a command does not displace the command:
|
|
# each of these four is a real write that the bare-name match could not see.
|
|
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
|
# ...and the prefix list must stay a list of prefixes. `echo` is not one.
|
|
printf '0\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming a command after echo is not running it\n'
|
|
# A shell standing between quoted data and execution makes that data code,
|
|
# and the pipe is the form agents actually use. Filing it as data allowed the
|
|
# call to vanish from the skeleton while still running.
|
|
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
|
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
|
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
|
# ...but a pipe to anything that is not a shell leaves the data as data.
|
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping an example to wc is not executing it\n'
|
|
# ...and a shell on ONE line does not execute a string on another. Switching
|
|
# the whole command into code because it contains an unrelated `sh -c` is how
|
|
# this blocked its author a second time.
|
|
printf '0\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line does not promote quoted prose to code\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"
|