ci/woodpecker/pr/ci Pipeline was canceled
Round-five review found command substitution executing inside the very quoted spans the skeleton was discarding as prose: echo "$(curl -d@b .../issues/1/comments)" msg="$(curl -d@b .../issues/1/comments)" The unquoted and process-substitution forms already blocked, so the same call was refused or allowed depending on a quote character. That makes it a classification defect rather than another spelling, and it is the nineteenth write to reach execution through this file by the same route: the client was ABSENT from the skeleton, so the guard allowed. The reviewer's judgement, which I asked for and accept: this is fitting to the test set. Answering "code or data" from shell text with sed and awk is not a hard problem, it is the wrong problem. It was also not portable. CI has been red at `sanitization` since round four, and the log says why: under the image's busybox awk the octal escape in the quote-stripping regex does not bite, every quoted span survives into the skeleton, and the guard began refusing ordinary prose. Five allow-direction fixtures failed in CI that pass under GNU awk. A control that reverses its verdict with the awk on the host is not a control. So the client detection is gone — the skeleton, the invoker list, the prefix list, the option-value skipping, all of it. What remains asks two questions of the text: is this a write, and does it name an endpoint a wrapper owns. It cannot fail open by hiding the caller because it never looks for one, and it now catches clients it was never taught: `python -c ... requests.post(...)` and `wget --post-data` are both fixtures. The cost is stated in the file and pinned in both directions: QUOTING one of these calls on a Bash command line is refused as well. Ten fixtures that used to assert "discussing a call is not making one" now assert the opposite, and the boundary that stops this becoming block-everything is asserted just as hard — a quoted READ, an endpoint named without a body flag, a quoted write to an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The 18-command ordinary-work sweep blocks none. The rule an agent can hold 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. 60/60 fixtures, verified inside the CI image (busybox) as well as locally. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), issue-close, prettier.
273 lines
13 KiB
Bash
Executable File
273 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# wrapper-guard.sh — PreToolUse hook on Bash.
|
|
#
|
|
# Blocks three specific, mechanically-detectable mistakes that prose has
|
|
# repeatedly failed to prevent:
|
|
#
|
|
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
|
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
|
#
|
|
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
|
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
|
# provider dialect, identity, and queue-guard ordering that raw curl does
|
|
# not. Reads are untouched — they are how you gather evidence.
|
|
#
|
|
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
|
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
|
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
|
# is what a verdict silently failing to land looks like.
|
|
#
|
|
# Design constraint: this hook must not become something agents route around.
|
|
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
|
# curl for reads, for registry/manifest calls, and for endpoints with no
|
|
# wrapper (there are many) all pass untouched.
|
|
#
|
|
# One consequence is worth knowing before it surprises you: it judges the
|
|
# payload, not the caller, so a command that merely QUOTES such a write is
|
|
# refused as well. See the long note at section 2 for why that trade was made.
|
|
#
|
|
# Break-glass, for a genuine gap where no wrapper can express the call:
|
|
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
|
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
|
# extend the wrapper, not to keep typing the override.
|
|
#
|
|
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
|
|
|
set -euo pipefail
|
|
|
|
INPUT="$(cat)"
|
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
[ -z "$CMD" ] && exit 0
|
|
|
|
# Read the command the SHELL will run, not the text as typed. A backslash before
|
|
# a newline is removed before anything else happens, so
|
|
# curl -d@b https://host/api/v1/repos/a/b/iss\
|
|
# ues/1/comments
|
|
# executes the comments endpoint while the literal token `issues` never appears
|
|
# in the text. Every check below — position, URL, body, endpoint — reads the
|
|
# joined form, because that is the command.
|
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
|
|
|
# Honour the override only when it is set in the command itself or the env.
|
|
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
|
|
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
|
|
|
|
# The wrappers this guard points at are its own siblings. Resolving relative to
|
|
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
|
|
# names the wrappers from the same install it was launched from, and that it
|
|
# still works from a repo checkout with no installed mosaic home (which is how it
|
|
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
|
|
# absolute path from somewhere unusual.
|
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git"
|
|
|
|
# ---- 1. checkout into $HOME ------------------------------------------------
|
|
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
|
|
# Any argument that resolves under $HOME and is not under a work filesystem.
|
|
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then
|
|
cat <<EOF
|
|
BLOCKED: this checks a repository out under \$HOME.
|
|
|
|
\$HOME holds configuration, credentials, state and caches. It does not hold
|
|
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
|
|
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
|
|
|
|
Use the helper, which derives the path so you do not have to choose one:
|
|
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
|
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
|
|
|
Worktrees, not clones: they share the object store, and \`git worktree list\`
|
|
makes every one of them enumerable — which is the only reason cleanup can
|
|
ever be safe.
|
|
EOF
|
|
exit 2
|
|
fi
|
|
fi
|
|
|
|
# ---- 2/3. provider API writes ---------------------------------------------
|
|
# A raw provider write this guard cares about is two things: a WRITE, and a URL
|
|
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
|
|
# are how you gather evidence — and the many endpoints with no wrapper flow
|
|
# through.
|
|
#
|
|
# It deliberately does NOT ask which program makes the call, or whether that
|
|
# program sits at shell command position. It used to, and that is the whole
|
|
# history of this file. Answering "is this code or is this data" from the text
|
|
# of a shell command required a skeleton with quoted spans and heredoc bodies
|
|
# removed, an invoker list for the forms where a shell executes quoted text, a
|
|
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
|
|
# backslash-newline joining. Five rounds of adversarial review put nineteen
|
|
# writes straight through it, and every one had the same shape: the client was
|
|
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
|
|
# command prefixes, option values, pipes into a shell, and finally command
|
|
# substitution inside the very quotes the skeleton was discarding:
|
|
# echo "$(curl -d@b .../issues/1/comments)"
|
|
# msg="$(curl -d@b .../issues/1/comments)"
|
|
# Classifying code against data in shell text with sed and awk is not a hard
|
|
# problem, it is the wrong problem. It was not even portable: under CI's busybox
|
|
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
|
|
# and the guard started refusing ordinary prose instead — which is the other way
|
|
# a control like this dies.
|
|
#
|
|
# So the client detection is gone, and with it that entire failure class: what
|
|
# is left cannot fail open by hiding the caller, because it never looks for one.
|
|
# It looks for the payload. Something that names a wrapped endpoint and carries
|
|
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
|
|
# nobody has thought of yet.
|
|
#
|
|
# The cost is real and belongs in the open, because over-blocking is how a hook
|
|
# gets switched off: QUOTING one of these calls on a Bash command line now
|
|
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
|
|
# an example into a file. There is no textual way to tell a quoted example from
|
|
# a quoted command — that is exactly the finding above — so the rule is the one
|
|
# an agent can hold in mind without a parser:
|
|
#
|
|
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
|
|
# not even inside quotes.
|
|
#
|
|
# Write the example with a file-writing tool, or leave the body flag out of it.
|
|
# That is a deliberate narrowing of scope, not an oversight. This hook stops
|
|
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
|
|
# control nobody can trust the boundaries of.
|
|
#
|
|
# Scoped to commands carrying a URL, so nothing without one is even considered.
|
|
if printf '%s' "$CMD" | grep -Eq 'https?://'; 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
|
|
# ...and a library call is a write without any flag at all.
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'\.(post|put|patch|delete)\(' && is_write=1
|
|
|
|
if [ "$is_write" -eq 1 ]; then
|
|
endpoint=""; wrapper=""
|
|
case "$CMD" in
|
|
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
|
|
endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
|
esac
|
|
|
|
# A URL the guard cannot READ is a URL 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 URL 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.
|
|
#
|
|
# Scoped to commands that are visibly forge-shaped, so an opaque webhook or
|
|
# artifact POST is untouched. A caller who splits `/api/` and the hostname
|
|
# as well does get through; that is no longer a mistake anyone makes by
|
|
# accident, and this hook stops mistakes. It is not a sandbox, and pretending
|
|
# otherwise is how you get a control nobody can trust the boundaries of.
|
|
if [ -z "$endpoint" ] \
|
|
&& printf '%s' "$CMD" | grep -Eq 'https?://[^[:space:]"'"'"'|;&)]*[$`]' \
|
|
&& printf '%s' "$CMD" | grep -Eq '/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo'; then
|
|
cat <<EOF
|
|
BLOCKED: raw provider API write whose URL this guard cannot read.
|
|
|
|
The URL is assembled from shell expansions, so the endpoint 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 URL 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 URL literally so
|
|
the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
|
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."
|
|
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."
|
|
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 ---------------------
|
|
if printf '%s' "$CMD" | grep -Eq '"event"[[:space:]]*:[[:space:]]*"APPROVE"'; 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
|