ci/woodpecker/pr/ci Pipeline was successful
Round-9 review found the fail-closed rule narrower than the block it guards.
The scope gate admits three shapes — a scheme URL, a schemeless /api/vN path,
and a provider CLI's `api` subcommand — but the unreadable-endpoint test asked
only for https?://. So a split endpoint token in the other two shapes was in
scope to be blocked, produced no readable endpoint, and fell through to ALLOW,
while the identical split behind a literal scheme blocked.
Four writes reaching the provider unexamined, one of them a review verdict:
p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x
p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x
p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED
p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x host${p}${q}
This is the same defect class as the milestone arm one round earlier, one layer
up: there the map claimed a span its wrapper did not cover, here a control
claimed a surface it did not measure. A control is only as wide as its narrowest
arm, and widening the scope gate without widening the fail-closed rule left the
gap exactly where the gate had just been extended.
Three arms now, one per admitted shape: a scheme URL token carrying an
expansion; a schemeless token carrying both a forge fragment and an expansion,
in either order; and the endpoint argument of a provider-CLI api call, read
positionally. Limits are stated in the source rather than implied — a caller who
splits the hostname as well, and an endpoint pushed past an option whose value
contains whitespace, are both outside what this measures.
An expansion in a BODY is explicitly not unreadable. Passing a payload in a
variable is the safe practice and leaves the endpoint fully legible; blocking it
would have been a control punishing the behaviour it wants.
101/101 fixtures, up from 92. Each arm is negative-controlled separately:
removing the schemeless arm fails exactly the two schemeless fixtures, removing
the provider-CLI arm fails exactly the four CLI fixtures, and neither disturbs
any pre-existing fixture. One added fixture was rewritten after it passed for
the wrong reason — its endpoint was readable, so it blocked on the endpoint map
and never exercised the arm it was written for.
Evidence: 101/101 locally and in ci-base; shellcheck clean at warning+; a
12-command sweep of ordinary forge work — reads with split endpoints, bodies in
variables, unwrapped endpoints, an artifact PUT — blocks none of them; the
18-command sweep still blocks the same three round-six flips and nothing new.
462 lines
25 KiB
Bash
Executable File
462 lines
25 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# wrapper-guard.sh — PreToolUse hook on Bash.
|
|
#
|
|
# Blocks three specific, mechanically-detectable mistakes that prose has
|
|
# repeatedly failed to prevent:
|
|
#
|
|
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
|
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
|
#
|
|
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
|
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
|
# provider dialect, identity, and queue-guard ordering that raw curl does
|
|
# not. Reads are untouched — they are how you gather evidence.
|
|
#
|
|
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
|
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
|
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
|
# is what a verdict silently failing to land looks like.
|
|
#
|
|
# Design constraint: this hook must not become something agents route around.
|
|
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
|
# curl for reads, for registry/manifest calls, and for endpoints with no
|
|
# wrapper (there are many) all pass untouched.
|
|
#
|
|
# One consequence is worth knowing before it surprises you: it judges the
|
|
# payload, not the caller, so a command that merely QUOTES such a write is
|
|
# refused as well. See the long note at section 2 for why that trade was made.
|
|
#
|
|
# Break-glass, for a genuine gap where no wrapper can express the call:
|
|
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
|
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
|
# extend the wrapper, not to keep typing the override.
|
|
#
|
|
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
|
|
|
set -euo pipefail
|
|
|
|
INPUT="$(cat)"
|
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
[ -z "$CMD" ] && exit 0
|
|
|
|
# Read the command the SHELL will run, not the text as typed. A backslash before
|
|
# a newline is removed before anything else happens, so
|
|
# curl -d@b https://host/api/v1/repos/a/b/iss\
|
|
# ues/1/comments
|
|
# executes the comments endpoint while the literal token `issues` never appears
|
|
# in the text. Every check below — position, URL, body, endpoint — reads the
|
|
# joined form, because that is the command.
|
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
|
|
|
# Honour the override only when it is set in the command itself or the env.
|
|
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
|
|
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
|
|
|
|
# The wrappers this guard points at are its own siblings. Resolving relative to
|
|
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
|
|
# names the wrappers from the same install it was launched from, and that it
|
|
# still works from a repo checkout with no installed mosaic home (which is how it
|
|
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
|
|
# absolute path from somewhere unusual.
|
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git"
|
|
|
|
# ---- 1. checkout into $HOME ------------------------------------------------
|
|
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
|
|
# Any argument that resolves under $HOME and is not under a work filesystem.
|
|
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then
|
|
cat <<EOF
|
|
BLOCKED: this checks a repository out under \$HOME.
|
|
|
|
\$HOME holds configuration, credentials, state and caches. It does not hold
|
|
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
|
|
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
|
|
|
|
Use the helper, which derives the path so you do not have to choose one:
|
|
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
|
|
|
Worktrees, not clones: they share the object store, and \`git worktree list\`
|
|
makes every one of them enumerable — which is the only reason cleanup can
|
|
ever be safe.
|
|
EOF
|
|
exit 2
|
|
fi
|
|
fi
|
|
|
|
# ---- 2/3. provider API writes ---------------------------------------------
|
|
# A raw provider write this guard cares about is two things: a WRITE, and a URL
|
|
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
|
|
# are how you gather evidence — and the many endpoints with no wrapper flow
|
|
# through.
|
|
#
|
|
# It deliberately does NOT ask which program makes the call, or whether that
|
|
# program sits at shell command position. It used to, and that is the whole
|
|
# history of this file. Answering "is this code or is this data" from the text
|
|
# of a shell command required a skeleton with quoted spans and heredoc bodies
|
|
# removed, an invoker list for the forms where a shell executes quoted text, a
|
|
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
|
|
# backslash-newline joining. Five rounds of adversarial review put nineteen
|
|
# writes straight through it, and every one had the same shape: the client was
|
|
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
|
|
# command prefixes, option values, pipes into a shell, and finally command
|
|
# substitution inside the very quotes the skeleton was discarding:
|
|
# echo "$(curl -d@b .../issues/1/comments)"
|
|
# msg="$(curl -d@b .../issues/1/comments)"
|
|
# Classifying code against data in shell text with sed and awk is not a hard
|
|
# problem, it is the wrong problem. It was not even portable: under CI's busybox
|
|
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
|
|
# and the guard started refusing ordinary prose instead — which is the other way
|
|
# a control like this dies.
|
|
#
|
|
# So the client detection is gone, and with it that entire failure class: what
|
|
# is left cannot fail open by hiding the caller, because it never looks for one.
|
|
# It looks for the payload. Something that names a wrapped endpoint and carries
|
|
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
|
|
# nobody has thought of yet.
|
|
#
|
|
# The cost is real and belongs in the open, because over-blocking is how a hook
|
|
# gets switched off: QUOTING one of these calls on a Bash command line now
|
|
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
|
|
# an example into a file. There is no textual way to tell a quoted example from
|
|
# a quoted command — that is exactly the finding above — so the rule is the one
|
|
# an agent can hold in mind without a parser:
|
|
#
|
|
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
|
|
# not even inside quotes.
|
|
#
|
|
# Write the example with a file-writing tool, or leave the body flag out of it.
|
|
# That is a deliberate narrowing of scope, not an oversight. This hook stops
|
|
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
|
|
# control nobody can trust the boundaries of.
|
|
#
|
|
# Scoped to commands that are provider-API-shaped, so nothing else is even
|
|
# considered. The first version of this scope gate asked only for `https?://`,
|
|
# and review found the absence shape had simply moved to the new boundary:
|
|
# gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE
|
|
# tea api -X POST repos/a/b/issues/1/comments -f body=x
|
|
# curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues
|
|
# all carry a real write to a wrapped endpoint and none carries a scheme, so the
|
|
# 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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)'
|
|
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then
|
|
|
|
# Write detection, now client-agnostic. Every spelling curl accepts, because
|
|
# the guard is defeated by the one spelling it does not know: `-d@body` (no
|
|
# space) and `--request=POST` (equals form) both slipped past the first
|
|
# version. Plus wget's forms and a library call, which a client-shaped test
|
|
# could not have seen at all.
|
|
#
|
|
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
|
|
# .../issues/1/comments` is therefore refused, which is a read wearing a
|
|
# write's flag. That direction is the acceptable one: it costs an override on
|
|
# a rare command, where the reverse costs a silent raw write.
|
|
is_write=0
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
|
# curl sends POST implicitly when handed a body, in any of these forms.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
|
|
# The provider CLIs POST implicitly the same way curl does, when handed a
|
|
# field. Matched only in `-f key=value` shape, so the far more common `rm -f`
|
|
# and `grep -f` cannot be read as a body. The trailing `[]` is the array
|
|
# spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and
|
|
# without it the key class stopped at the bracket and the field was not seen
|
|
# as a body at all — found while pinning the labels/assignees repros, both of
|
|
# which carry it.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1
|
|
# ...and a library call is a write without any flag at all.
|
|
#
|
|
# Second documented over-block, and broader than the body flags because it
|
|
# needs no flag: any text carrying `.post(` near a wrapped URL is refused,
|
|
# including prose that merely quotes it. That follows from the same rule as
|
|
# the quoted-curl cost above — the payload is judged, not the caller — and it
|
|
# is stated here so it is a known boundary rather than a surprise.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'\.(post|put|patch|delete)\(' && is_write=1
|
|
|
|
if [ "$is_write" -eq 1 ]; then
|
|
# The endpoint map, and the rule that keeps it honest: an arm exists here
|
|
# ONLY because a wrapper in this directory owns that call. It is an
|
|
# inventory, not a model — read off `ls tools/git/*.sh` and the flags each
|
|
# script accepts, so it can be re-derived and checked rather than believed.
|
|
# Second column is what the wrapper SPANS; a partial span must be stated in
|
|
# the block message, never rounded up to ownership of the whole endpoint.
|
|
#
|
|
# /pulls/{n}/reviews pr-review.sh full
|
|
# /pulls/{n}/merge pr-merge.sh full (-m method, -d)
|
|
# /issues/{n}/comments issue-comment.sh create only (POST)
|
|
# /issues|pulls/{n}/assignees issue-assign.sh full (-a, -r)
|
|
# /issues|pulls/{n}/labels issue-edit.sh sets the whole list;
|
|
# issue-assign.sh -l same
|
|
# /milestones/{n} milestone-close.sh CLOSE ONLY — title,
|
|
# description, due date
|
|
# are a wrapper gap
|
|
# /issues/{n} issue-edit.sh title/body/labels/
|
|
# milestone; close/reopen
|
|
# for state; assignee is
|
|
# issue-assign.sh
|
|
# /pulls/{n} pr-close.sh state only — title and
|
|
# body are a wrapper gap
|
|
# /pulls, /issues, the create wrappers full
|
|
# /milestones
|
|
#
|
|
# Owned by nothing, so they flow through: /issues/comments/{id} (a comment
|
|
# EDIT), /pulls/{n}/requested_reviewers, and the residue caught by
|
|
# subtraction below.
|
|
#
|
|
# Round seven had a single arm allowing EVERY path under a numbered issue or
|
|
# PR, on the reasoning that no wrapper owned any of them. Review showed that
|
|
# was false in this tree — issue-edit.sh takes --title/--body/--labels/
|
|
# --milestone and issue-assign.sh takes assignee/labels/milestone — so the
|
|
# guard was answering "allow" because wrapper ownership had been ASSUMED
|
|
# absent instead of looked up. That is the same absence-driven allow the
|
|
# whole file exists to remove, committed inside the fix for it. The lesson
|
|
# is not "block more"; it is that ownership is an inventory question and an
|
|
# inventory has to be read.
|
|
#
|
|
# Wrong advice remains its own defect — a block an agent cannot comply with
|
|
# teaches that the hook is broken and the override is routine, and a routine
|
|
# override is a guard that is off. So the fix is precision in BOTH
|
|
# directions: every arm names the wrapper that actually owns the call, and
|
|
# anything genuinely unowned still flows through (below).
|
|
#
|
|
# Round eight got the inventory right and the SPAN wrong, which review caught
|
|
# on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes
|
|
# state=closed, so it cannot express a title, description or due-date edit,
|
|
# and naming it there told an agent to use a wrapper that cannot make the
|
|
# call. "Which wrapper touches this endpoint" is the wrong question; "does
|
|
# the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only
|
|
# a slice, `alsoown` must say which slice and name the rest as a gap — the
|
|
# treatment /pulls/{n} already had, and that two other arms did not, so this
|
|
# was a consistency failure rather than a missing idea. Auditing every arm
|
|
# for span (not just the reported one) is what found requested_reviewers.
|
|
endpoint=""; wrapper=""; alsoown=""
|
|
case "$CMD" in
|
|
# Requesting a reviewer is not submitting one. pr-review.sh takes
|
|
# -a <action> -c <comment> and files a verdict; nothing in the tree adds a
|
|
# requested reviewer. Unowned, so it flows through — placed above the
|
|
# reviews arm so it cannot be refused with "use pr-review.sh".
|
|
*"/pulls/"*"/requested_reviewers"*) : ;;
|
|
*"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
|
# A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the
|
|
# numbered issue, not a child of it. issue-comment.sh only creates, so
|
|
# nothing owns this one. Placed above the create arm so it cannot be
|
|
# refused with "use issue-comment.sh", which would be the wrong call.
|
|
*"/issues/comments/"*) : ;;
|
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
|
*"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*)
|
|
endpoint="issue assignee"; wrapper="issue-assign.sh" ;;
|
|
*"/issues/"*"/labels"*|*"/pulls/"*"/labels"*)
|
|
endpoint="issue label"; wrapper="issue-edit.sh"
|
|
alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;;
|
|
*"/milestones/"[0-9]*) endpoint="milestone"; wrapper="milestone-close.sh"
|
|
alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title>
|
|
and sends state=closed. A milestone's title, description or due date is a real
|
|
wrapper gap: no tool in this tree edits them, and the override exists for it." ;;
|
|
# The numbered object itself. These two arms are the fuzzy ones — they
|
|
# match a number and then anything — so they are refined immediately
|
|
# below rather than trusted as written.
|
|
*"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh"
|
|
alsoown="issue-close.sh and issue-reopen.sh own the state change, and
|
|
issue-assign.sh owns the assignee, labels and milestone fields at this same
|
|
number — issue-edit.sh does not set an assignee." ;;
|
|
*"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh"
|
|
alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and
|
|
milestone are the ISSUE object on both providers, so issue-edit.sh and
|
|
issue-assign.sh own those at the same number. Nothing wraps a PR title/body
|
|
edit — that one is a real wrapper gap, and the override exists for it." ;;
|
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
|
esac
|
|
|
|
# Refine the two fuzzy arms, and note WHY this is a regex and not another
|
|
# case arm: `case` globs cannot express a path SEGMENT, so an allow arm
|
|
# written as *"/issues/"[0-9]*"/"* would clear
|
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"
|
|
# on the strength of a slash inside the body. An allow decided by a glob
|
|
# over the whole command is exactly the fail-open shape this file keeps
|
|
# finding; the regex pins the segment to the number.
|
|
#
|
|
# The residue is defined by SUBTRACTION rather than by listing provider API
|
|
# surface: every subresource a wrapper owns was consumed by an arm above, so
|
|
# whatever still carries /issues|pulls/{n}/<segment> here is owned by
|
|
# nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's
|
|
# files or commits. Listing them instead would rot the moment a provider
|
|
# adds one, and rot in the blocking direction with wrong advice.
|
|
case "$endpoint" in
|
|
"issue edit"|"pull-request edit")
|
|
if printf '%s' "$CMD" | grep -Eq '/(issues|pulls)/[0-9]+/[A-Za-z_]'; then
|
|
endpoint=""; wrapper=""; alsoown=""
|
|
fi ;;
|
|
esac
|
|
|
|
# An endpoint the guard cannot READ is an endpoint the guard must not CLEAR.
|
|
#
|
|
# Round one fixed one spelling of this and review immediately produced the
|
|
# general form: split the endpoint token itself across two variables —
|
|
# a=/api/v1/repos/o/r/iss; b=ues/1/comments
|
|
# curl -d@body "https://host${a}${b}"
|
|
# — and no fragment above ever appears contiguously. Chasing that with more
|
|
# fragments is unwinnable: the endpoint does not exist until the shell
|
|
# expands it, and this hook runs before that.
|
|
#
|
|
# So stop pretending to read it. If a write's endpoint contains an expansion,
|
|
# the guard has no endpoint to judge, and "no endpoint" must not mean
|
|
# "allowed" — that is the same absence-driven allow as the missing-wrapper
|
|
# case, wearing different clothes.
|
|
#
|
|
# SPAN, and the defect review found here: a fail-closed rule must cover the
|
|
# same surface as the block it guards. This test asked only for `https?://`
|
|
# while the scope gate above had already been widened to three shapes, so
|
|
# p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x
|
|
# p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}
|
|
# were in scope to be blocked, produced no readable endpoint, and then fell
|
|
# through to ALLOW — while the identical split behind a literal `https://`
|
|
# blocked. Same shape as the milestone arm one round earlier: the correct
|
|
# treatment already existed and was applied to one of the surfaces it
|
|
# covered. A control is only as wide as its narrowest arm.
|
|
#
|
|
# Three arms, one per shape the scope gate admits:
|
|
# A a scheme-bearing URL token carrying an expansion
|
|
# B a schemeless token carrying BOTH a forge fragment and an expansion
|
|
# C the endpoint argument of a provider-CLI `api` call carrying one
|
|
#
|
|
# Stated limits, because a control may not claim more than it measures. B
|
|
# requires the fragment and the expansion in the SAME shell token, so a
|
|
# caller who splits the hostname and `/api/` as well gets through. C reads
|
|
# the endpoint positionally — the first bare token after `api` and its option
|
|
# run — so an endpoint pushed past an option whose value itself contains
|
|
# whitespace is not seen. Both are deliberate: this hook stops mistakes, it
|
|
# is not a sandbox, and pretending otherwise is how you get a control nobody
|
|
# can trust the boundaries of.
|
|
#
|
|
# 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.
|
|
URLTOK='[^[:space:]"'"'"'|;&)]*'
|
|
FORGE='(/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo)'
|
|
unreadable=0
|
|
if printf '%s' "$CMD" | grep -Eq "https?://$URLTOK"'[$`]' \
|
|
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
|
|
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:]]*[$`]' \
|
|
&& unreadable=1
|
|
|
|
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
|
|
cat <<EOF
|
|
BLOCKED: raw provider API write whose endpoint this guard cannot read.
|
|
|
|
The endpoint is assembled from shell expansions, so the path it names does not
|
|
exist until the shell builds it — after this check runs. The guard cannot tell
|
|
whether it is a wrapped endpoint, and an unreadable endpoint is not a cleared
|
|
one.
|
|
|
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
|
|
|
If you are calling a wrapped endpoint (reviews, merges, comments, pulls,
|
|
issues, milestones), use the wrapper — it also resolves identity explicitly,
|
|
which matters on a host whose default provider login is an admin account.
|
|
|
|
If this is genuinely not a provider endpoint, either write the endpoint
|
|
literally so the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
|
A variable in the BODY is fine and does not trigger this; only the endpoint
|
|
itself has to be legible.
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
# Block on the ENDPOINT, never on whether the wrapper file happens to exist.
|
|
# The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
|
|
# with a broken or absent install allowed exactly the raw writes the guard
|
|
# exists to stop — an absence-driven allow, and the second one found in this
|
|
# file. A missing wrapper is a broken install; it is not a licence to bypass
|
|
# gate 7. Say so, and say which is which.
|
|
if [ -n "$endpoint" ]; then
|
|
if [ -x "$W/$wrapper" ]; then
|
|
remedy="Use the wrapper the Constitution (gate 7) requires:
|
|
|
|
$W/$wrapper
|
|
|
|
Run \`$wrapper --help\` for the flags."
|
|
# Several wrappers can own one endpoint (labels are settable from both
|
|
# issue-edit.sh and issue-assign.sh; state has its own pair). Naming
|
|
# only one of them is how a correct block still ends up reading as
|
|
# wrong advice, so say which wrapper owns which part of the call.
|
|
[ -n "$alsoown" ] && remedy="$remedy
|
|
|
|
$alsoown"
|
|
else
|
|
remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
|
|
present or not executable at:
|
|
|
|
$W/$wrapper
|
|
|
|
That is a broken or incomplete install, not permission to send the call raw.
|
|
Repair the install (\`mosaic doctor\`) and use the wrapper."
|
|
[ -n "$alsoown" ] && remedy="$remedy
|
|
|
|
$alsoown"
|
|
fi
|
|
cat <<EOF
|
|
BLOCKED: raw provider API write to the $endpoint endpoint.
|
|
|
|
$remedy
|
|
|
|
The wrappers are not a formality. They carry provider-dialect differences that
|
|
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
|
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
|
|
as PENDING. They also resolve identity explicitly, which matters on a host
|
|
where the default login is an admin account.
|
|
|
|
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
|
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
|
EOF
|
|
exit 2
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
|
|
# Both spellings the trap arrives in: the JSON body `"event": "APPROVE"` and the
|
|
# provider-CLI field `-f event=APPROVE`. The trailing [^A-Z] is what keeps the
|
|
# correct value out of it — APPROVED must never match.
|
|
if printf '%s' "$CMD" | grep -Eq 'event"?[[:space:]]*[=:][[:space:]]*"?APPROVE([^A-Z]|$)'; then
|
|
cat <<EOF
|
|
BLOCKED: review event "APPROVE" is not valid on Gitea.
|
|
|
|
Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
|
|
files the review as PENDING, and then fails the submit endpoint with
|
|
422 "review stay pending" — so the verdict looks placed and is not.
|
|
|
|
("REQUEST_CHANGES" is spelled identically on both providers; only the approve
|
|
path carries this trap.)
|
|
|
|
Use $W/pr-review.sh, which sends the correct token for the detected provider.
|
|
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
|
|
before reporting a verdict placed.
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
exit 0
|