ci/woodpecker/pr/ci Pipeline failed
Four blockers from adversarial review, and three are one defect wearing three hats: a test over the WHOLE command text deciding an ALLOW. That is the fail-open shape this file keeps rediscovering, and it had reached the break-glass itself. - Break-glass read POSITIONALLY. `case $CMD in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire command if that string appeared anywhere in it, so quoting the override in a note, naming a variable after it, or writing =10 disabled the guard for the call sitting beside it. Now only leading NAME=value assignments count, exactly where the shell would honour one. Cost, pinned as a fixture: an override after `&&` no longer arms. - $HOME resolved once, and unset / empty / "/" refused. This was filed as a checkout-arm defect and is larger: under `set -u` the old file died at line 62 on EVERY command with HOME unset, exit 1, before the API arms or the APPROVE trap ran. A seat with no HOME (systemd unit, container, env -i) had no guard at all. A checkout whose question cannot be asked now blocks; the blast radius is asserted to be that one command shape and not the session. - Subresource refinement inverted. It asked whether a subresource appears anywhere in the command, so `gh api -X PATCH .../issues/1 -f body=cf-/pulls/2/files` was cleared on the strength of text in its own body. It now clears only when EVERY numbered-object occurrence carries a subresource. - -K/--config refused. curl reads the method, body, headers and URL from that file, so none of them are in the command: every write test read 0 and the call went through. An unreadable request is not a cleared one. mosaic-worktree: never close a git pipe early resolve_repo took the first porcelain line with `awk ... exit`, which closes the read end while git is still writing. git takes SIGPIPE, pipefail returns 141, and the function aborts SILENTLY — no message, no path, exit 141. It fires as a function of REPO SIZE: fine on three worktrees, reliable on seventy. Measured at 73 worktrees (10 KB of porcelain): rc=141, no output. The file already removed a `head -200` for this exact reason; the rule is now uniform. Evidence — every new case run against029af418, the tree before these fixes: test-wrapper-guard.sh 130/130 pass here; 15 FAIL against029af418test-mosaic-worktree-large-repo.sh 2/2 pass here; 2 FAIL against029af418(got rc=141 and empty output, the signature) No pre-existing fixture changed behaviour on the old guard, so the new cases are the whole delta. The size dependence is stubbed out rather than inherited: a test that ran against whatever repo it sits in would have PASSED on the broken tree.
626 lines
34 KiB
Bash
Executable File
626 lines
34 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# wrapper-guard.sh — PreToolUse hook on Bash.
|
|
#
|
|
# Blocks three specific, mechanically-detectable mistakes that prose has
|
|
# repeatedly failed to prevent:
|
|
#
|
|
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
|
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
|
#
|
|
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
|
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
|
# provider dialect, identity, and queue-guard ordering that raw curl does
|
|
# not. Reads are untouched — they are how you gather evidence.
|
|
#
|
|
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
|
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
|
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
|
# is what a verdict silently failing to land looks like.
|
|
#
|
|
# Design constraint: this hook must not become something agents route around.
|
|
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
|
# curl for reads, for registry/manifest calls, and for endpoints with no
|
|
# wrapper (there are many) all pass untouched.
|
|
#
|
|
# One consequence is worth knowing before it surprises you: it judges the
|
|
# payload, not the caller, so a command that merely QUOTES such a write is
|
|
# refused as well. See the long note at section 2 for why that trade was made.
|
|
#
|
|
# Break-glass, for a genuine gap where no wrapper can express the call:
|
|
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
|
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
|
# extend the wrapper, not to keep typing the override.
|
|
#
|
|
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
|
|
|
set -euo pipefail
|
|
|
|
INPUT="$(cat)"
|
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
[ -z "$CMD" ] && exit 0
|
|
|
|
# Read the command the SHELL will run, not the text as typed. A backslash before
|
|
# a newline is removed before anything else happens, so
|
|
# curl -d@b https://host/api/v1/repos/a/b/iss\
|
|
# ues/1/comments
|
|
# executes the comments endpoint while the literal token `issues` never appears
|
|
# in the text. Every check below — position, URL, body, endpoint — reads the
|
|
# joined form, because that is the command.
|
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
|
|
|
# Honour the override only 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.
|
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
[ -x "$W/pr-review.sh" ] || W="$HOME/.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'
|
|
home_known=0
|
|
case "${HOME-}" in
|
|
/?*) home_re="$home_re|$(printf '%s' "$HOME" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
|
|
home_known=1 ;;
|
|
esac
|
|
|
|
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.
|
|
API_SHAPED='https?://|/api/v[0-9]|/repos/'
|
|
API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)'
|
|
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then
|
|
|
|
# curl takes its options from a file with -K/--config, and that file may carry
|
|
# the method, the body, the headers and the URL itself. Every write test below
|
|
# reads the command TEXT, and none of those things are in it:
|
|
#
|
|
# curl --config /tmp/req .../repos/a/b/pulls/1/reviews
|
|
#
|
|
# carries no -X, no -d, no -f, so is_write stayed 0 and the call went straight
|
|
# through. This is not a spelling the write detection was missing — it is the
|
|
# write question being unaskable, which is the same situation as an endpoint
|
|
# assembled from expansions, and it gets the same answer. Refusing a READ that
|
|
# happens to use --config is the acceptable side of that trade, and the message
|
|
# says how to proceed.
|
|
if printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-K|--config)([[:space:]=]|$)'; then
|
|
cat <<EOF
|
|
BLOCKED: provider API call whose options are 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, or what endpoint it reaches. 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
|
|
|
|
# Write detection, now client-agnostic. Every spelling curl accepts, because
|
|
# the guard is defeated by the one spelling it does not know: `-d@body` (no
|
|
# space) and `--request=POST` (equals form) both slipped past the first
|
|
# version. Plus wget's forms and a library call, which a client-shaped test
|
|
# could not have seen at all.
|
|
#
|
|
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
|
|
# .../issues/1/comments` is therefore refused, which is a read wearing a
|
|
# write's flag. That direction is the acceptable one: it costs an override on
|
|
# a rare command, where the reverse costs a silent raw write.
|
|
is_write=0
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
|
# curl sends POST implicitly when handed a body, in any of these forms.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
|
|
# The provider CLIs POST implicitly the same way curl does, when handed a
|
|
# field. Matched only in `-f key=value` shape, so the far more common `rm -f`
|
|
# and `grep -f` cannot be read as a body. The trailing `[]` is the array
|
|
# spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and
|
|
# without it the key class stopped at the bracket and the field was not seen
|
|
# as a body at all — found while pinning the labels/assignees repros, both of
|
|
# which carry it.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1
|
|
# ...and a library call is a write without any flag at all.
|
|
#
|
|
# Second documented over-block, and broader than the body flags because it
|
|
# needs no flag: any text carrying `.post(` near a wrapped URL is refused,
|
|
# including prose that merely quotes it. That follows from the same rule as
|
|
# the quoted-curl cost above — the payload is judged, not the caller — and it
|
|
# is stated here so it is a known boundary rather than a surprise.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'\.(post|put|patch|delete)\(' && is_write=1
|
|
|
|
if [ "$is_write" -eq 1 ]; then
|
|
# The endpoint map, and the rule that keeps it honest: an arm exists here
|
|
# ONLY because a wrapper in this directory owns that call. It is an
|
|
# inventory, not a model — read off `ls tools/git/*.sh` and the flags each
|
|
# script accepts, so it can be re-derived and checked rather than believed.
|
|
# Second column is what the wrapper SPANS; a partial span must be stated in
|
|
# the block message, never rounded up to ownership of the whole endpoint.
|
|
#
|
|
# /pulls/{n}/reviews pr-review.sh full
|
|
# /pulls/{n}/merge pr-merge.sh full (-m method, -d)
|
|
# /issues/{n}/comments issue-comment.sh create only (POST)
|
|
# /issues|pulls/{n}/assignees issue-assign.sh full (-a, -r)
|
|
# /issues|pulls/{n}/labels issue-edit.sh sets the whole list;
|
|
# issue-assign.sh -l same
|
|
# /milestones/{n} milestone-close.sh CLOSE ONLY — title,
|
|
# description, due date
|
|
# are a wrapper gap
|
|
# /issues/{n} issue-edit.sh title/body/labels/
|
|
# milestone; close/reopen
|
|
# for state; assignee is
|
|
# issue-assign.sh
|
|
# /pulls/{n} pr-close.sh state only — title and
|
|
# body are a wrapper gap
|
|
# /pulls, /issues, the create wrappers full
|
|
# /milestones
|
|
#
|
|
# Owned by nothing, so they flow through: /issues/comments/{id} (a comment
|
|
# EDIT), /pulls/{n}/requested_reviewers, and the residue caught by
|
|
# subtraction below.
|
|
#
|
|
# Round seven had a single arm allowing EVERY path under a numbered issue or
|
|
# PR, on the reasoning that no wrapper owned any of them. Review showed that
|
|
# was false in this tree — issue-edit.sh takes --title/--body/--labels/
|
|
# --milestone and issue-assign.sh takes assignee/labels/milestone — so the
|
|
# guard was answering "allow" because wrapper ownership had been ASSUMED
|
|
# absent instead of looked up. That is the same absence-driven allow the
|
|
# whole file exists to remove, committed inside the fix for it. The lesson
|
|
# is not "block more"; it is that ownership is an inventory question and an
|
|
# inventory has to be read.
|
|
#
|
|
# Wrong advice remains its own defect — a block an agent cannot comply with
|
|
# teaches that the hook is broken and the override is routine, and a routine
|
|
# override is a guard that is off. So the fix is precision in BOTH
|
|
# directions: every arm names the wrapper that actually owns the call, and
|
|
# anything genuinely unowned still flows through (below).
|
|
#
|
|
# Round eight got the inventory right and the SPAN wrong, which review caught
|
|
# on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes
|
|
# state=closed, so it cannot express a title, description or due-date edit,
|
|
# and naming it there told an agent to use a wrapper that cannot make the
|
|
# call. "Which wrapper touches this endpoint" is the wrong question; "does
|
|
# the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only
|
|
# a slice, `alsoown` must say which slice and name the rest as a gap — the
|
|
# treatment /pulls/{n} already had, and that two other arms did not, so this
|
|
# was a consistency failure rather than a missing idea. Auditing every arm
|
|
# for span (not just the reported one) is what found requested_reviewers.
|
|
endpoint=""; wrapper=""; alsoown=""
|
|
case "$CMD" in
|
|
# Requesting a reviewer is not submitting one. pr-review.sh takes
|
|
# -a <action> -c <comment> and files a verdict; nothing in the tree adds a
|
|
# requested reviewer. Unowned, so it flows through — placed above the
|
|
# reviews arm so it cannot be refused with "use pr-review.sh".
|
|
*"/pulls/"*"/requested_reviewers"*) : ;;
|
|
*"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
|
# A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the
|
|
# numbered issue, not a child of it. issue-comment.sh only creates, so
|
|
# nothing owns this one. Placed above the create arm so it cannot be
|
|
# refused with "use issue-comment.sh", which would be the wrong call.
|
|
*"/issues/comments/"*) : ;;
|
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
|
*"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*)
|
|
endpoint="issue assignee"; wrapper="issue-assign.sh" ;;
|
|
*"/issues/"*"/labels"*|*"/pulls/"*"/labels"*)
|
|
endpoint="issue label"; wrapper="issue-edit.sh"
|
|
alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;;
|
|
*"/milestones/"[0-9]*) endpoint="milestone"; wrapper="milestone-close.sh"
|
|
alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title>
|
|
and sends state=closed. A milestone's title, description or due date is a real
|
|
wrapper gap: no tool in this tree edits them, and the override exists for it." ;;
|
|
# The numbered object itself. These two arms are the fuzzy ones — they
|
|
# match a number and then anything — so they are refined immediately
|
|
# below rather than trusted as written.
|
|
*"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh"
|
|
alsoown="issue-close.sh and issue-reopen.sh own the state change, and
|
|
issue-assign.sh owns the assignee, labels and milestone fields at this same
|
|
number — issue-edit.sh does not set an assignee." ;;
|
|
*"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh"
|
|
alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and
|
|
milestone are the ISSUE object on both providers, so issue-edit.sh and
|
|
issue-assign.sh own those at the same number. Nothing wraps a PR title/body
|
|
edit — that one is a real wrapper gap, and the override exists for it." ;;
|
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
|
esac
|
|
|
|
# Refine the two fuzzy arms, and note WHY this is a regex and not another
|
|
# case arm: `case` globs cannot express a path SEGMENT, so an allow arm
|
|
# written as *"/issues/"[0-9]*"/"* would clear
|
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"
|
|
# on the strength of a slash inside the body. An allow decided by a glob
|
|
# over the whole command is exactly the fail-open shape this file keeps
|
|
# finding; the regex pins the segment to the number.
|
|
#
|
|
# The residue is defined by SUBTRACTION rather than by listing provider API
|
|
# surface: every subresource a wrapper owns was consumed by an arm above, so
|
|
# whatever still carries /issues|pulls/{n}/<segment> here is owned by
|
|
# nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's
|
|
# files or commits. Listing them instead would rot the moment a provider
|
|
# adds one, and rot in the blocking direction with wrong advice.
|
|
# 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
|