ci/woodpecker/pr/ci Pipeline was successful
Round-four remediation of both blockers gate-ultron-01 raised ond99ff57e. Measured against that head before anything was touched; all seven returned rc=0, and each executes the program the check exists to recognize: "/usr/bin/curl" --config /tmp/w.cfg -> allowed './curl' --config /tmp/w.cfg -> allowed $(which curl) --config /tmp/w.cfg -> allowed `which curl` --config /tmp/w.cfg -> allowed /usr/bin/gh api -X POST repos/a/b/issues … -> allowed ./gh api -X POST repos/a/b/issues … -> allowed /usr/local/bin/tea api -X POST repos/a/b/… … -> allowed This is the third appearance of one defect, and the shape is worth stating plainly because the first two repairs each fixed an INSTANCE and left the class: the check matched the bare word, then it matched the unquoted basename. Both were models of one TEXTUAL PRESENTATION of a shell word rather than of the word, so the first repair was defeated by an absolute path and the second by two quote characters. Recognizing a name is either done after quote removal or it is caller-name parsing wearing a longer regex. The second blocker is the same defect sitting untouched in the API SCOPE gate the whole time, while the curl arm was repaired twice beside it. That one is worse than it looks: the scope gate decides whether write detection runs AT ALL, so failing to admit `/usr/bin/gh api -X POST` is not a missed match, it is an allow. No URL marker rescued those commands either — provider CLI endpoints are spelled `repos/…` with no leading slash, so `/repos/` never matched them. Fix, and the reason it is one fix rather than two: - $CMD_NAMES — a second reading of the same command with quote and substitution punctuation turned into whitespace. Names are read from it. - $NAME_PREFIX — the one place the shape of a program name is written down. Both callers use it, so the next fix to this class lands in a single location instead of whichever arm review happened to probe. That is the actual lesson of finding this defect twice in one file. The prefix still 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; both remain negative fixtures, and `mygh` and an absolute-path READ join them. The cost is the one this file already chose and documented for the payload check: quoting an example does not exempt it, so writing one of these commands inside quotes on a Bash line is refused too. Applying that rule to the name arms makes the file coherent — the alternative is a guard where the payload arm treats quotes as text and the name arms treat them as armour. Still open, stated rather than left to be found: a name absent from the text — assembled from variables, or reached through a wrapper script that execs the program — is invisible here. That is a limit of inspecting a command string, not something a pattern closes. Controls: the 7 positive fixtures FAIL atd99ff57eand pass here; the 4 negative fixtures pass at BOTH heads, so they measure over-blocking rather than decorate the diff. Suite 157/157.
752 lines
41 KiB
Bash
Executable File
752 lines
41 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 only by the two checks below that
|
|
# have to recognize a program by name. Quote characters and the punctuation of
|
|
# command substitution become whitespace, so a name that 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
|
|
#
|
|
# All five executed the real program and all five returned ALLOW from the two
|
|
# name checks, at this file's previous head and the one before it. The first
|
|
# repair of this class matched curl as a basename and stopped there, which fixed
|
|
# the unquoted spelling only: the check still modelled ONE TEXTUAL PRESENTATION
|
|
# of a shell word rather than the word itself, so adding two quote characters
|
|
# restored the bypass, and the same defect sat untouched in the provider-CLI arm
|
|
# the whole time. Recognizing a name is either done after quote removal or it is
|
|
# caller-name parsing wearing a longer regex.
|
|
#
|
|
# 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 '"'"'"'\`()' ' ')"
|
|
CMD_NAMES="$(printf '%s' "$CMD_NAMES" | sed 's/\$/ /g')"
|
|
|
|
# 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.
|
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
|
|
&& printf '%s' "$CMD" | 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
|
|
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
|