diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 9af08039..ef1c77fe 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -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' diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index fd1e8d74..666caa40 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -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