Files
stack/packages/mosaic/framework/tools/git/test-wrapper-guard.sh
T
Hermes Agent 1bfd0ddd71
ci/woodpecker/pr/ci Pipeline was successful
wrapper-guard: an arm may only claim the span its wrapper actually covers
Round eight replaced an absence-driven allow with an endpoint inventory, and
review found the inventory answered the wrong question. It recorded which
wrapper TOUCHES an endpoint, when the sound question is whether the wrapper
SPANS it. A PATCH to a numbered milestone blocked with "use milestone-close.sh".
That wrapper takes only -t <title> and sends state=closed on both the gh and tea
paths, so it cannot express a title, description or due-date edit. The block was
correct and the advice was not — the same remediation-accuracy defect as the
round-seven subresource arm, one step quieter, because a wrapper was rounded up
from owning a slice to owning the endpoint.

The treatment already existed two arms away: /pulls/{n} named pr-close.sh for
state and said in the message that a PR title/body edit is a real wrapper gap.
So this was a consistency failure rather than a missing idea, which is why the
fix is not just the reported arm. Auditing every arm for span against the flags
each script accepts found a second bad one that review had not reached:
/pulls/{n}/requested_reviewers was mapped to pr-review.sh, and pr-review.sh
takes -a <action> -c <comment> and files a verdict. Nothing in this tree adds a
requested reviewer, so that arm was advertising a wrapper that cannot make the
call. It is unowned and now flows through, like a comment edit.

Changes:
  - /milestones/{n} keeps blocking, and the message states that milestone-close.sh
    owns the close only while title/description/due-date is a wrapper gap.
  - /pulls/{n}/requested_reviewers becomes residue, above the reviews arm so it
    cannot be refused with "use pr-review.sh".
  - /issues/{n} now also names issue-assign.sh, which owns the assignee field;
    issue-edit.sh has no assignee flag, so the old advice was short by one wrapper
    for a PATCH that sets one.
  - The map comment carries a span column, so a future arm has to state what its
    wrapper covers rather than imply all of it.

Fixtures assert the span language, not just the wrapper name: the milestone edit
must say it owns the close only, and a PATCH setting an assignee must name
issue-assign.sh. Negative-controlled — reverting each of the three behaviours
fails that fixture and only that fixture.

92/92 (was 89), locally and in ci-base. shellcheck clean at warning+. The
18-command ordinary sweep blocks the same three round-six flips and nothing new.

Gates: fixtures 92/92 in ci-base, shellcheck clean at warning+.
2026-08-12 18:36:23 -05:00

238 lines
23 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>
# [ TAB <substring the block message must contain> ]
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
# itself gets checked; without it a block is only asserted to have happened,
# not to have been useful.
{
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'
# --- round six changed the contract in this direction, and these fixtures are
# where it shows. They used to assert that discussing a call is not making one.
# Five rounds proved there is no textual way to tell a quoted example from a
# quoted command, so the guard stopped trying: it judges the payload, and a
# payload inside quotes is still a payload. Quoting one of these on a Bash
# command line is now refused, and the way to write the example is a
# file-writing tool. This is the deliberate cost of the mechanism change.
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
# The boundary that keeps this from being "block everything": what is refused
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
# these are asserted as hard as the blocks above.
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\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'
# --- the case the AUTHOR hit twice while chasing the above: sending a message
# that QUOTED one of these fixtures. Under the old contract that was a defect
# to be parsed away; under this one it is the documented cost, and the message
# gets composed with a file-writing tool instead.
printf '2\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\\""}}\tquoting the repro in a message is refused too\n'
printf '2\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 carrying the payload is refused with it\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. Each of these four is a real write that a bare-name match
# for the client could not see, because an ordinary word sat in front of it.
# They are kept as fixtures after the mechanism change even though the guard no
# longer looks for a client at all: they are the evidence for WHY it stopped,
# and a future re-narrowing that reintroduced position would fail here first.
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'
printf '2\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 the call after echo carries the payload, so it is refused\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'
# ...and the questions that used to follow — is the pipe target a shell, does a
# shell on one line execute a string on another — no longer have to be answered
# at all. Both of these carry the payload, both are refused, and neither
# outcome depends on parsing what the pipe or the other line does.
printf '2\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 the payload to wc is refused without asking what wc is\n'
printf '2\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 no longer changes the answer either way\n'
# --- round four. The guard was still reading the command as typed rather than
# as the shell will run it: a backslash before a newline is removed before
# anything else happens, so the endpoint token can be split across the join.
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
# Transparent prefixes take option VALUES, and the value was a word the list
# did not know — so the client went missing again behind an ordinary `sudo -u`.
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
# Execution through another command needed its own case under the old design.
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
# --- round five, and the finding that ended the parser. Command substitution
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
# span as inert prose. The unquoted and process-substitution forms already
# blocked, which is what made it a classification defect rather than a spelling
# one: the same call was refused or allowed depending on a quote character.
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
# A client the guard was never taught is the point of dropping client
# detection: neither of these names curl at all.
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
# Round six scoped the guard on `https?://`, and review found the absence shape
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
# so the guard never reached the write question. These are the reported repros.
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
# ...and the boundary that stops a broader scope gate becoming block-everything.
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
# Wrong remediation is its own defect: /issues/1/labels used to block with
# "use issue-create.sh", which is not the wrapper for that call. Round seven
# answered that by letting EVERY path under a numbered issue or PR through,
# and review showed the reasoning ("no wrapper owns these") was false in this
# tree. These are the reported repros, all rc 0 before round eight, and each
# asserts the wrapper the advice must name — not merely that a block happened.
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
# state=closed, so a title/description/due-date edit is a gap and the message
# has to say so — round eight named the wrapper and stopped there.
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
# The residue: still genuinely owned by nothing, and still flowing through.
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
# nothing in the tree adds a requested reviewer.
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
# The arms above the numbered ones must keep blocking, with their own wrappers.
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
# must never trip it.
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
# Documented over-block, pinned so it is a known boundary and not a surprise.
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
} > "$FIXTURES"
fail=0 n=0
while IFS=$'\t' read -r want payload why remedy; do
[ -n "${want:-}" ] || continue
n=$((n + 1))
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
got=$?
if [ "$got" != "$want" ]; then
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
continue
fi
# A block that names the wrong wrapper is a defect in its own right, and until
# now it was invisible here: the harness read the exit code and nothing else,
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
# six rounds. Where a fixture states the remediation it expects, assert it.
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
fail=1
continue
fi
printf 'ok %s\n' "$why"
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"