ci/woodpecker/pr/ci Pipeline was canceled
Round-five remediation of both blockers gate-ultron-01 raised ondf83a9ee. Measured against that head first; all eight returned rc=0 and each executes the program the check exists to recognize: cu"rl" --config /tmp/w.cfg -> allowed cu'rl' --config /tmp/w.cfg -> allowed /usr/bin/cu\rl --config /tmp/w.cfg -> allowed g"h" api -X POST repos/a/b/issues … -> allowed /usr/bin/g\h api -X POST repos/a/b/… … -> allowed curl --con"fig" /tmp/w.cfg -> allowed /usr/bin/gh api -X POST repos/a/b/$EP … -> allowed g"h" api -X POST repos/a/b/$EP … -> allowed BLOCKER 1. The previous commit said names are recognized "after quote removal" and did not do that. It replaced quote characters with whitespace, which is token SEPARATION: a shell removes a quote WITHOUT splitting the word around it, so `cu"rl"` is one word naming curl, while whitespace made it two words naming neither. `"/usr/bin/curl"` blocked under that version only because the inserted space happened to land after a slash — a passing case that established nothing about quote removal, and I read it as confirmation. The characters are now DELETED, which is what quote removal is. Backslashes go with them, because escaping is ordinary word formation too. Deletion still handles substitution: `$(which curl)` becomes `which curl`, where the name is a word on its own. The flag is read from the same normalized copy for the same reason — `--con"fig"` is one word spelling --config. No review raised that; the name was simply the easier half to reach, and reading both halves the same way is the entire point of having one normalization. BLOCKER 2. A THIRD name consumer never went through the shared site: the unreadable-endpoint arm kept a private bare-name copy of the scope gate's regex against raw $CMD. A caller could be admitted by the repaired gate and then go unrecognized by the fail-closed refinement — a gate and its own refinement disagreeing about who the caller is, which is the defect one layer downstream. Fixing that surfaced the same mistake a third time inside this very edit: my first version left the NAME in the refinement's tail regex, so the name gate recognized `g"h" api` while the tail still demanded the undressed spelling, and the two halves disagreed exactly as before. Caught by the fixture, not by reading. Each half now asks one question: the name gate asks WHO, from the normalized copy; the tail asks whether the ENDPOINT is readable, from the raw text — deliberately raw, because the expansion markers that make an endpoint unreadable are the characters the normalized copy removes, and reading the tail from it would erase the evidence. Controls: the 8 positive fixtures FAIL atdf83a9eeand pass here; the negatives — mycurl, curl-wrapper, mygh, mygh with an assembled endpoint, an absolute-path read, and -K on a non-curl — pass at BOTH heads. Suite 166/166. Unchanged and still stated in the comment rather than this message: a name ABSENT from the text, assembled from variables or reached through a wrapper script that execs the program, is invisible to any of this.
787 lines
43 KiB
Bash
Executable File
787 lines
43 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')"
|
|
|
|
# A second reading of the SAME command, used by every check below that has to
|
|
# recognize a program by NAME. Quote characters, backslashes and the punctuation
|
|
# of command substitution are DELETED, so a name the shell will resolve is a word
|
|
# here regardless of how it was dressed:
|
|
#
|
|
# "/usr/bin/curl" --config /tmp/req quoted absolute path
|
|
# './curl' --config /tmp/req quoted relative path
|
|
# $(which curl) --config /tmp/req command substitution
|
|
# `which curl` --config /tmp/req the older spelling of the same thing
|
|
# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI
|
|
# cu"rl" --config /tmp/req quotes INSIDE the word
|
|
# /usr/bin/cu\rl --config /tmp/req a backslash inside the word
|
|
# g"h" api -X POST repos/a/b/... the same, in the provider-CLI name
|
|
#
|
|
# Every one of them executed the real program and every one returned ALLOW, at
|
|
# each of this file's three previous heads. The repairs went bare word, then
|
|
# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and
|
|
# left the class, and the third is worth spelling out because it is the subtlest
|
|
# and it was mine: replacing quote characters with whitespace is token
|
|
# SEPARATION, not quote removal. A shell removes a quote WITHOUT splitting the
|
|
# word around it, so `cu"rl"` is one word naming curl, while whitespace made it
|
|
# two words naming neither. `"/usr/bin/curl"` blocked under that version only
|
|
# because the whitespace happened to land after a slash — a passing case that
|
|
# established nothing.
|
|
#
|
|
# So the characters are DELETED rather than replaced, which is what quote removal
|
|
# is, and backslashes go with them because escaping is ordinary word formation
|
|
# too. Deletion also handles substitution: `$(which curl)` becomes `which curl`,
|
|
# where the name is a word on its own.
|
|
#
|
|
# This does NOT try to be a shell. It cannot see a name that is absent from the
|
|
# text — assembled from variables, or reached through a wrapper script that
|
|
# execs the program — and those remain stated limits of inspecting a command
|
|
# string, not defects a pattern closes. What it removes is the class where the
|
|
# name IS present and merely punctuated.
|
|
#
|
|
# The cost is the one this file already chose and documented for the payload
|
|
# check further down: quoting an example no longer exempts it, so writing one of
|
|
# these commands inside quotes on a Bash line is refused too. Applying that same
|
|
# rule here keeps the file coherent — the alternative is a guard where the
|
|
# payload arm treats quotes as text and the name arms treat them as armour.
|
|
CMD_NAMES="$(printf '%s' "$CMD" | tr -d '"'"'"'\`()$\\')"
|
|
|
|
# The one place the shape of a program NAME is written down. Both callers below
|
|
# use it, so the next fix to this class lands in a single location instead of
|
|
# being applied to whichever arm review happened to probe. The prefix must end
|
|
# at a slash: `mycurl` and `curl-wrapper` are different programs, and blocking
|
|
# them is the over-block that gets a guard routed around instead of repaired.
|
|
NAME_PREFIX='(^|[[:space:]|;&])([^[:space:]|;&]*/)?'
|
|
|
|
# Honour the override only where a shell would actually TREAT it as one: the
|
|
# environment-assignment run at the head of the command, or this process's own
|
|
# environment. The first version asked whether the token appeared ANYWHERE in the
|
|
# command text. That is not a test of what the shell does; it is a test of what
|
|
# the string contains, and three shapes turned the whole guard off silently, each
|
|
# with exit 0 and no message:
|
|
#
|
|
# curl -d '{"body":"... MOSAIC_WRAPPER_OVERRIDE=1 ..."}' .../issues/1/comments
|
|
# a quoted BODY disabling the guard for its own write — and the bodies most
|
|
# likely to carry the token are this file's own documentation, a relayed
|
|
# block message, or a commit message quoting a previous refusal;
|
|
# NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl ...
|
|
# the token as another variable's VALUE, which sets nothing;
|
|
# ... MOSAIC_WRAPPER_OVERRIDE=10 ...
|
|
# `*=1*` matched `=10`, `=1x`, `=123`; the glob never bounded the value.
|
|
#
|
|
# A control that is off is worse than no control, because the block message is
|
|
# what tells an agent the control exists. So the override is now read
|
|
# POSITIONALLY, by the rule a shell uses: leading assignments only, up to the
|
|
# first token that is not one. A body can never occupy that position, and the
|
|
# value must be exactly 1.
|
|
#
|
|
# Deliberate cost, stated rather than discovered: `cd /x && MOSAIC_WRAPPER_OVERRIDE=1
|
|
# curl ...` is NOT honoured — only the head of the command is, and only its first
|
|
# line, because an override applies to the command it prefixes and not to a later
|
|
# one. Putting the override first is the remedy, and this direction fails closed.
|
|
override_prefixed() {
|
|
local first tok
|
|
first="${CMD%%$'\n'*}"
|
|
local IFS=$' \t'
|
|
set -f
|
|
# shellcheck disable=SC2086
|
|
set -- $first
|
|
set +f
|
|
for tok in "$@"; do
|
|
case "$tok" in
|
|
MOSAIC_WRAPPER_OVERRIDE=1) return 0 ;;
|
|
[A-Za-z_]*=*) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
override_prefixed && exit 0
|
|
[ "${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.
|
|
# $HOME is resolved HERE, once, before anything expands it. The previous attempt
|
|
# fixed the checkout arm's use of $HOME and left this one, four lines earlier,
|
|
# reading it raw — so under `set -u` a seat with no HOME still died before
|
|
# reaching the adjudication that was supposed to handle exactly that. Moving a
|
|
# fail-open earlier in the file is not closing it. There is now exactly one
|
|
# expansion of HOME in this script and it is guarded; every later use reads
|
|
# HOME_DIR / home_known instead, so a new use cannot reintroduce the abort
|
|
# without going through this block.
|
|
#
|
|
# Empty and unset are different values and neither one is a home directory. '/'
|
|
# is rejected for the same reason as '': every path is under it, so comparing
|
|
# against it stops discriminating at all.
|
|
HOME_DIR=""
|
|
home_known=0
|
|
case "${HOME-}" in
|
|
/?*) HOME_DIR="$HOME"; home_known=1 ;;
|
|
esac
|
|
|
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
[ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git"
|
|
|
|
# ---- 1. checkout into $HOME ------------------------------------------------
|
|
# $HOME has to be RESOLVED before anything can be compared against it, and the
|
|
# first version interpolated it directly into the pattern. Both ways of it being
|
|
# absent were wrong, in OPPOSITE directions, which is why neither showed up as a
|
|
# simple "it stopped working":
|
|
#
|
|
# HOME unset under `set -u` the expansion aborts the script. A PreToolUse
|
|
# hook exiting nonzero-but-not-2 is a non-blocking error, so the
|
|
# checkout it was asked about is ALLOWED. The guard failed open in
|
|
# precisely the case where it could not answer the question.
|
|
# HOME='' the alternation gained an EMPTY branch — (~|\$HOME|)/ — which
|
|
# matches any slash at all, so a legitimate /src checkout was
|
|
# refused. Unusable in the other direction.
|
|
#
|
|
# Empty and unset are different values and neither one is a home directory. '/'
|
|
# is rejected for the same reason as '': every path is under it, so the
|
|
# comparison stops discriminating at all. Where the literal path cannot be
|
|
# established the `~` and `$HOME` spellings are still checked, and a checkout
|
|
# left unresolved BLOCKS rather than clears — a guard may not clear a question it
|
|
# was unable to ask. The blast radius of that fail-closed arm is exactly one
|
|
# command shape (clone / worktree add), not the session.
|
|
home_re='~|\$HOME'
|
|
if [ "$home_known" -eq 1 ]; then
|
|
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
|
|
fi
|
|
|
|
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
|
|
if [ "$home_known" -eq 0 ]; then
|
|
cat <<EOF
|
|
BLOCKED: this is a checkout, and \$HOME is unset or unusable in this shell.
|
|
|
|
The guard's job here is to answer one question — does this check out under
|
|
\$HOME — and it cannot answer it without a usable \$HOME. An unanswerable
|
|
question is not a cleared one, so this refuses rather than guesses. (\$HOME
|
|
empty, unset, and "/" are all treated the same way: none of them is a home
|
|
directory, and "/" would match every path there is.)
|
|
|
|
Set HOME to the account's home directory and re-run, or use the helper, which
|
|
derives the path and never consults \$HOME at all:
|
|
|
|
$W/mosaic-worktree.sh new <branch>
|
|
EOF
|
|
exit 2
|
|
fi
|
|
# Any argument that resolves under $HOME and is not under a work filesystem.
|
|
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])($home_re)/"; 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.
|
|
#
|
|
# 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. 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.
|
|
# ---- curl whose request lives in a file ------------------------------------
|
|
# curl takes its options from a file with -K/--config, and that file may carry
|
|
# the method, the body, the headers AND THE URL. That last one is why this test
|
|
# cannot live inside the API-shape gate below, which is where it was first put:
|
|
#
|
|
# curl --config /tmp/provider-write.cfg
|
|
#
|
|
# has no URL, no /repos/, no `gh api` — nothing API-shaped in the text at all —
|
|
# so it never entered the branch that was supposed to refuse it, and the guard
|
|
# reported clean on the exact capability the check exists to deny. The check was
|
|
# guarded by a condition the thing it guards against defeats. It is now asked of
|
|
# any curl, because "is this a provider call" is not answerable about a command
|
|
# whose URL is in a file, and a question that cannot be asked is not a question
|
|
# that came back clean.
|
|
#
|
|
# The spelling is deliberately loose. curl accepts the value attached to the
|
|
# short flag (`-K/tmp/req`, verified) and inside a bundle (`-sK /tmp/req`), and a
|
|
# guard that recognizes only the space- and equals-separated forms is defeated by
|
|
# deleting one character. Scoped to curl so that `eslint --config .eslintrc.json`
|
|
# and every other tool with a --config flag are untouched.
|
|
#
|
|
# curl is recognized as a NAME, through $CMD_NAMES and $NAME_PREFIX — see the
|
|
# comment on those at the top of the file for why matching the bare word, and
|
|
# then matching the unquoted basename, were both the same mistake at different
|
|
# depths. A wrapper script that execs curl on the operator's behalf is still
|
|
# invisible here, because neither the name nor the request appears in the
|
|
# command text at all. That is a limit of inspecting a command string rather
|
|
# than a defect this pattern can close, and it is stated rather than left for
|
|
# the next reader to find.
|
|
# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as
|
|
# the name — `--con"fig"` is one word spelling --config — and no review has
|
|
# raised it yet only because the name was the easier half to reach. Reading both
|
|
# halves the same way is the point of having one normalization.
|
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
|
|
&& printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
|
|
cat <<EOF
|
|
BLOCKED: curl invocation whose request is supplied from a --config/-K file.
|
|
|
|
curl reads the request method, body, headers and even the URL from that file.
|
|
None of them appear in the command, so this guard cannot tell whether the call
|
|
is a read or a write, what endpoint it reaches, or whether it is a provider call
|
|
at all. An unreadable request is not a cleared one.
|
|
|
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
|
|
|
If this is a read, spell the request on the command line so it is legible. If it
|
|
is a write to a wrapped endpoint, use the wrapper. For a genuine gap no wrapper
|
|
can express, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first thing in the
|
|
command — it is read positionally, not matched as text).
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
# The scope gate comes in two halves because its two triggers are different
|
|
# kinds of thing, and collapsing them into one regex over one string is what
|
|
# hid the second instance of the caller-name defect for three review rounds.
|
|
#
|
|
# The URL markers are TEXT: a scheme, a version segment, a `/repos/` path. They
|
|
# are read from the command as written.
|
|
API_SHAPED='https?://|/api/v[0-9]|/repos/'
|
|
#
|
|
# The provider-CLI marker is a NAME, so it is read the way names are read here —
|
|
# through $CMD_NAMES, with $NAME_PREFIX. Asking for a bare `gh api` let every
|
|
# path spelling through the gate that decides whether write detection runs AT
|
|
# ALL, so `/usr/bin/gh api -X POST repos/a/b/issues -f title=x` was never even
|
|
# considered. No URL marker rescued it: provider CLI endpoints are spelled
|
|
# `repos/...` with no leading slash, so `/repos/` does not match them either.
|
|
# A scope gate that fails to admit is indistinguishable from an allow.
|
|
PROVIDER_CLI="${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)"
|
|
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \
|
|
|| printf '%s' "$CMD_NAMES" | grep -Eq "$PROVIDER_CLI"; 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
|
|
# A percent-escape makes the endpoint unreadable HERE and perfectly readable
|
|
# to the PROVIDER, which is the whole hazard. Measured against the live
|
|
# forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and
|
|
# GET /repos/mosaicstack/stack/iss%75es/1174 both returned HTTP 200 with the
|
|
# same object. So `iss%75es` IS the wrapped endpoint by the only authority
|
|
# that gets a vote, while every literal comparison below sees a segment that
|
|
# matches nothing and clears the write.
|
|
#
|
|
# This refuses rather than decodes. A decoder has to be exactly right about
|
|
# depth (%2569 -> %69 -> i) and about which characters the provider
|
|
# normalizes, and being exactly right about someone else's parser is the
|
|
# mistake this file declines everywhere else. Refusing is correct at every
|
|
# depth at once.
|
|
#
|
|
# Scoped to WRITES. Reads are never blocked by this guard, and a query
|
|
# string carrying %20 is not a hazard — it is an ordinary URL. Putting this
|
|
# test on the whole API-shaped branch would have refused those too, which is
|
|
# how a guard earns being routed around.
|
|
if printf '%s' "$CMD" | grep -Eq '%[0-9A-Fa-f][0-9A-Fa-f]'; then
|
|
cat <<EOF
|
|
BLOCKED: provider API WRITE containing a percent-escape.
|
|
|
|
The provider decodes the path before routing; this guard compares it literally.
|
|
An encoded segment therefore reaches a wrapped endpoint while reading, here, as
|
|
a segment that matches nothing — /iss%75es/ and /issues/ were measured returning
|
|
the same object from the same repository.
|
|
|
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
|
|
|
Spell the path literally and re-run. If the escape is genuinely required and no
|
|
wrapper can express the call, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first
|
|
thing in the command — it is read positionally, not matched as text).
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
# 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.
|
|
# The refinement is an ALLOW, and an allow decided by a test over the whole
|
|
# command is the fail-open shape this file keeps rediscovering — the comment
|
|
# above says exactly that about `case` globs, and then the regex it replaced
|
|
# them with made the same mistake one level down. Asking "does a subresource
|
|
# appear ANYWHERE in this command" cleared a write on the strength of text
|
|
# that was not the endpoint:
|
|
#
|
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /pulls/2/files"
|
|
# gh api -X PATCH repos/a/b/issues/1 -f body="cf /issues/3/reactions"
|
|
#
|
|
# Both PATCH the numbered issue that issue-edit.sh owns, and both were
|
|
# allowed because a subresource appeared in the BODY. Same class as the
|
|
# backslash-newline case at the top of the file: the guard read the text as
|
|
# typed instead of the call being made.
|
|
#
|
|
# Extracting "the endpoint token" is the parser problem this file already
|
|
# refused to take on, so the test is inverted instead, which needs no parser:
|
|
# clear ONLY when every numbered-object occurrence in the command carries a
|
|
# subresource. One bare /issues/{n} anywhere means a wrapped call may be in
|
|
# play, and the block stands. Cost, in the same direction as every other
|
|
# trade here: writing to /issues/1/reactions while quoting /issues/2 is
|
|
# refused. Over-blocking costs an override on a rare command; the reverse
|
|
# cost is a silent raw write to a wrapped endpoint.
|
|
case "$endpoint" in
|
|
"issue edit"|"pull-request edit")
|
|
occ="$(printf '%s' "$CMD" | grep -oE '/(issues|pulls)/[0-9]+(/[A-Za-z_])?' || true)"
|
|
if [ -n "$occ" ] && ! printf '%s' "$occ" | grep -q '[0-9]$'; 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.
|
|
#
|
|
# 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.
|
|
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
|
|
# THIRD name consumer, and the one that proves the point about a single
|
|
# site: while the scope gate above was repaired for path- and quote-dressed
|
|
# provider CLIs, this fail-closed refinement kept its own bare-name copy of
|
|
# the same regex against raw $CMD. A caller could therefore enter the scope
|
|
# gate through the fixed check and then fail to be recognized by the arm
|
|
# that refuses unreadable endpoints — name recognition differing between a
|
|
# gate and its own refinement, which is the defect one layer downstream.
|
|
# It reads $CMD_NAMES through $NAME_PREFIX like every other name check.
|
|
#
|
|
# The endpoint tail still asks $CMD, deliberately: this arm fires on an
|
|
# endpoint the guard CANNOT READ, and the expansion markers that make it
|
|
# unreadable are exactly the characters $CMD_NAMES removes. Reading the tail
|
|
# from the normalized copy would erase the evidence the check exists to find.
|
|
#
|
|
# The tail carries NO name of its own. Leaving one there was the same defect
|
|
# a third time in the same edit — the name gate would recognize `g"h" api`
|
|
# while the tail still demanded the undressed spelling, so the two halves
|
|
# disagreed about the same caller and the refinement failed open. Each half
|
|
# now asks exactly one question: the name gate asks WHO, from the normalized
|
|
# copy; the tail asks whether the ENDPOINT is readable, from the raw text.
|
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \
|
|
&& printf '%s' "$CMD" | grep -Eq \
|
|
'(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then
|
|
unreadable=1
|
|
fi
|
|
|
|
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
|