ci/woodpecker/pr/ci Pipeline failed
Round-three review found two more absence-driven allows, both in the skeleton introduced by round two, and fixing them exposed a third the reviewer had not reached yet. 1. A word in front of a command does not displace the command. `env VAR=v curl`, `command curl`, `timeout 10 curl` and `/usr/bin/curl` were all real writes at execution position that a bare-name match could not see. The `env` form is the one that matters: it is what an agent reaches for to keep a credential out of the global environment, so the careful spelling was the invisible one. 2. Quoted data stops being data when a shell is about to execute it, and the first version knew only `bash -c`, `sh <<` and `eval`. It did not know the pipe, which is the form people actually use: `printf ... | sh`, `cat <<EOF | sh`, `sh -s <<EOF` each made a live call vanish from the skeleton while still running. 3. Found while testing the fix: that decision was made for the WHOLE command, so a single unrelated `docker run ... sh -c 'echo hi'` promoted every other quoted span on every other line to code. It blocked its own author for the second time in a day. A shell on one line does not execute a string on another line, and over-blocking is not the safe direction — a guard that blocks ordinary work gets switched off, and a guard that is off permits everything. The skeleton is now built per line, and a heredoc body is code only when the line that opened it fed a shell. All seven reviewer repros are pinned as fixtures, each with a counter-fixture in the allowed direction: `echo timeout 10 curl ...` is not a call, a pipe to `wc` is not execution, an unrelated shell on another line changes nothing. Fixtures 40/40, and a sweep of 18 ordinary commands blocks none of them. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), prettier.
282 lines
14 KiB
Bash
Executable File
282 lines
14 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.
|
|
#
|
|
# 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
|
|
|
|
# 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 is four things at once: an HTTP client, a URL, a mutating
|
|
# verb or a request body, and a path fragment naming an endpoint a wrapper
|
|
# already owns. All four are required, which is what keeps reads and unwrapped
|
|
# endpoints flowing.
|
|
#
|
|
# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer
|
|
# broke that version in one line: build the path in shell variables
|
|
# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}"
|
|
# and the host-anchored literal never appears, so the check read clean while the
|
|
# write went through. The endpoint fragments below survive it, because the
|
|
# fragment has to appear somewhere for the URL to be constructible at all.
|
|
# The client must be at COMMAND POSITION, and that has to be judged against the
|
|
# CODE in the command, not against its text. Review caught the text version
|
|
# blocking ordinary work:
|
|
# grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/
|
|
# echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt
|
|
# Talking about a call is not making one, and over-blocking is not the safe
|
|
# direction: a guard that blocks ordinary work gets switched off, and a guard
|
|
# that is off permits everything.
|
|
#
|
|
# A first fix required the client to follow a shell operator. That lasted until
|
|
# the author sent a message quoting one of these fixtures — the quoted text
|
|
# contained `... && GITEA_TOKEN=$T curl -d@b .../merge`, so an operator appeared
|
|
# INSIDE the quotes and the guard blocked the message. Same defect, one level
|
|
# in: an operator inside a string is not an operator.
|
|
#
|
|
# So the position test runs against a SKELETON — the command with its data spans
|
|
# (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection all
|
|
# still run against the FULL text, because real calls quote their URLs and a
|
|
# skeleton would be blind to them.
|
|
#
|
|
# The exception is the reason quotes are data at all: if something is about to
|
|
# EXECUTE the quoted text, then the quotes hold code and the skeleton is the
|
|
# full text again. The first version of this list named only `bash -c`, `sh <<`
|
|
# and `eval`, and review immediately produced the spellings it did not know:
|
|
# printf '%s\n' 'curl -d@b .../comments' | sh
|
|
# cat <<EOF | sh
|
|
# sh -s <<EOF
|
|
# Each one executes; each one had its client filed away as data. So the test is
|
|
# not "which invocation form did I think of" but "does a shell stand between
|
|
# this data and execution" — including the pipe, which is the common form.
|
|
SHELL_EXECUTES_DATA='(^|[[:space:]|;&(])((ba|z)?sh|dash)([[:space:]]+-[a-z]*[cs]([[:space:]]|$)|[[:space:]]*<<)'
|
|
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|(^|[[:space:]|;&(])eval([[:space:]]|$)'
|
|
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|\|[[:space:]]*((ba|z)?sh|dash)([[:space:]]|$)'
|
|
|
|
# That decision is made PER LINE, not for the whole command. The first version
|
|
# switched globally, and it blocked its author again within the hour: a script
|
|
# whose only shell invocation was an unrelated `docker run ... sh -c 'echo hi'`
|
|
# had every OTHER quoted span on every other line promoted to code with it.
|
|
# A shell on one line does not execute a string on another, and a guard that
|
|
# says otherwise is back to blocking ordinary work.
|
|
SKEL="$(printf '%s' "$CMD" | awk -v inv="$SHELL_EXECUTES_DATA" '
|
|
function ascode(s) { gsub(/["\047]/, ";", s); return s } # quotes separate
|
|
function asdata(s) { gsub(/\047[^\047]*\047/, "", s); gsub(/"[^"]*"/, "", s); return s }
|
|
{
|
|
# Inside a heredoc: the body is code only if the line that OPENED it fed a
|
|
# shell (`cat <<EOF | sh`, `sh -s <<EOF`). Otherwise it is a document.
|
|
if (hd != "") { if ($0 == hd) { hd=""; next }
|
|
if (hdcode) print ascode($0); next }
|
|
if (match($0, /<<-?[[:space:]]*\047?"?[A-Za-z_][A-Za-z0-9_]*/)) {
|
|
t = substr($0, RSTART, RLENGTH); sub(/^<<-?[[:space:]]*[\047"]?/, "", t)
|
|
hd = t; hdcode = ($0 ~ inv)
|
|
}
|
|
print ($0 ~ inv) ? ascode($0) : asdata($0)
|
|
}')"
|
|
|
|
# Command position is not "the first word is literally `curl`". A word can sit
|
|
# in front of a command without displacing it, and review found four ordinary
|
|
# ones hiding a real write: `env GITEA_TOKEN=$T curl`, `command curl`,
|
|
# `timeout 10 curl`, `/usr/bin/curl`. The `env` form matters most, because it is
|
|
# precisely what an agent reaches for to keep a credential out of the global
|
|
# environment — the careful spelling was the invisible one.
|
|
CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|[0-9]+[smhd]?|-[^[:space:]]+)[[:space:]]+'
|
|
# A path in front of the client is still the client.
|
|
CLIENT='([^[:space:]]*/)?(curl|wget|httpie|http)'
|
|
CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\()[[:space:]]*('"$CMD_PREFIX"')*'"$CLIENT"'([[:space:]]|$)'
|
|
|
|
if printf '%s' "$SKEL" | grep -Eq "$CLIENT_AT_CMD_POS" \
|
|
&& printf '%s' "$CMD" | grep -Eq 'https?://'; then
|
|
|
|
# Write detection. 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.
|
|
is_write=0
|
|
printf '%s' "$CMD" | grep -Eq -- \
|
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[: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' && 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
|