wrapper-guard: name the marker every forge API shares, not one dialect's
ci/woodpecker/pr/ci Pipeline was successful

The scope gate asked for a scheme URL, a `/api/v[0-9]` path, or a provider-CLI
`api` subcommand. `/api/v[0-9]` is Gitea's spelling. GitHub's API carries no
version segment at all -- `api.github.com/repos/a/b/issues` -- so the schemeless
Gitea write was in scope and the schemeless GitHub one was not:

    curl -X POST -d x api.github.com/repos/a/b/issues              rc 0
    curl -X POST -d x api.github.com/repos/a/b/issues/1/comments   rc 0
    host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues rc 0

A gate calibrated to one provider's spelling rather than to what identifies a
provider API. `/repos/` is the marker both dialects share -- every forge API
addresses a repository through it -- so the gate now names both.

This is SPAN a third time, and the third layer it has appeared on. Round 8: a
block message named a wrapper that could not make the call. Round 9: a map
claimed a span its wrapper did not cover. Round 10: a control claimed a surface
it did not measure. Here the scope gate itself claimed a class of API and
recognised one member of it. Same question each time -- does this thing SPAN
what it claims -- and it has now been the answer four rounds running, which is
the argument for asking it of every arm rather than of the reported one.

Widening a scope gate can only make the guard stricter. Downstream a block still
requires a body flag AND either a mapped endpoint or an unreadable one, so this
widens what is CONSIDERED, not what is refused. The three fixtures asserting that
-- a schemeless GitHub read, an unwrapped GitHub endpoint, and a read past `--`
-- exist to hold that claim to account rather than state it.

Also: the provider-CLI arm's option scanner required a letter after the dashes,
so `gh api -X POST -- ${p}${q} -f title=x` walked its endpoint straight past.
The end-of-options marker is the one option not spelled like one, and a scanner
that skips options had to be told so.

Because the new endpoints are READABLE, each blocked fixture asserts the wrapper
its message must name. A rc-only fixture here would have passed on the unreadable
arm and proved nothing -- which is the failure mode this suite caught in itself
last round.

Evidence: 110/110 fixtures (was 101) locally and in ci-base. Negative-controlled
per change, not in aggregate: dropping `/repos/` from the gate fails exactly the
five GitHub fixtures; dropping the `--` alternative fails exactly one; neither
disturbs a pre-existing fixture. 18-command sweep unchanged (same three round-6
flips, nothing new); 12-command ordinary-work sweep 0 blocked. shellcheck clean
at warning+. CI 2374 on the previous head was green across all nine steps.

Residual, stated rather than implied: a caller who splits `/repos/` itself in a
schemeless GitHub URL leaves no literal marker anywhere and is out of scope --
the same boundary as splitting the hostname, and no longer a mistake anyone
makes by accident.

Round 11, addressing rev0 review 154.
This commit is contained in:
Hermes Agent
2026-08-12 19:07:44 -05:00
parent 51746f44eb
commit 029af418f0
2 changed files with 47 additions and 3 deletions
@@ -209,6 +209,27 @@ FIXTURES="$TMP/fixtures.tsv"
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'
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
# which is Gitea's spelling. GitHub's API carries no version segment at all
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
# than to what identifies a provider API. `/repos/` is the marker both share.
# These endpoints are READABLE, so each asserts the wrapper it must name; a
# rc-only fixture here would pass on the unreadable arm and prove nothing.
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
# The end-of-options marker, which is the one option not spelled like one.
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
# in the newly admitted shape have to stay allowed, or this is a regression
# wearing a fix'"'"'s clothes.
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\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'
@@ -142,16 +142,33 @@ fi
# guard never asked the write question at all. Gate 7 covers raw provider CLIs,
# so these are in scope and the gate now names the shapes they come in.
#
# Then review found the same absence at the same boundary a second time, in the
# one provider whose paths carry no version marker at all:
# curl -X POST -d x api.github.com/repos/a/b/issues
# GitHub's API is `api.github.com/repos/...`; Gitea's is `/api/v1/repos/...`.
# Asking for `/api/v[0-9]` therefore admitted the schemeless Gitea write and
# excluded the schemeless GitHub one — a gate calibrated to one dialect's
# spelling rather than to what identifies a provider API. So the gate names both
# markers: a version segment, and the `/repos/` path that every forge API uses
# to address a repository.
#
# Adding alternatives to a scope gate can only make it stricter — it cannot
# create a new allow — which is why this is a list of triggers rather than a
# model of any one caller.
# model of any one caller. Downstream, a block still requires a body flag AND
# either a mapped endpoint or an unreadable one, so widening the gate widens
# what is CONSIDERED, not what is refused.
#
# Residual, stated rather than implied: a caller who splits `/repos/` itself in
# a schemeless GitHub URL (`h=api.github.com/rep; q=os/a/b/issues`) leaves no
# literal marker anywhere and is out of scope. That is the same boundary as
# splitting the hostname — no longer a mistake anyone makes by accident.
#
# Boundary, deliberate and worth stating: this covers the `api` subcommand,
# which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`,
# `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb
# grammar, which is the parser mistake again in a new costume. Porcelain is a
# gate-7 gap for prose and review to hold, not this hook.
API_SHAPED='https?://|/api/v[0-9]'
API_SHAPED='https?://|/api/v[0-9]|/repos/'
API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)'
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then
@@ -348,6 +365,12 @@ edit — that one is a real wrapper gap, and the override exists for it." ;;
# is not a sandbox, and pretending otherwise is how you get a control nobody
# can trust the boundaries of.
#
# C's option run also accepts the bare `--` end-of-options marker, because
# review found that `gh api -X POST -- ${p}${q} -f title=x` walked straight
# past an option class that required a letter after the dashes. The marker is
# the one "option" that is not spelled like one, and a scanner that skips
# options had to be told that.
#
# Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`,
# `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would
# punish the safest way to pass a payload. Only the endpoint region counts.
@@ -359,7 +382,7 @@ edit — that one is a real wrapper gap, and the override exists for it." ;;
printf '%s' "$CMD" | grep -Eq \
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
printf '%s' "$CMD" | grep -Eq \
'(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+--?[A-Za-z][A-Za-z-]*([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \
'(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \
&& unreadable=1
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then