Files
stack/packages/mosaic/framework/tools/git/wrapper-guard.sh
T
Hermes Agent 8a901cc19a
ci/woodpecker/pr/ci Pipeline was canceled
guard: read command position, refuse unreadable URLs, survive pipefail
Round two of the same independent review. Three findings, all real, and the
first two share a root cause: the guard was reading command TEXT as though it
were a command.

1. Splitting the endpoint token itself defeats fragment matching outright —
   `a=/api/v1/repos/o/r/iss; b=ues/1/comments` leaves no fragment contiguous.
   Round one fixed one spelling of this and the reviewer produced the general
   form immediately. It is not winnable by more fragments: the endpoint does
   not exist until the shell expands it, and this hook runs first. So the guard
   stops pretending to read it. A write whose URL contains an expansion, on a
   visibly forge-shaped command, is now BLOCKED as unreadable — because "I
   could not find an endpoint" must not mean "there is no endpoint". Opaque
   URLs that are not forge-shaped (webhooks, artifact stores) still pass.

2. The broadened body detection false-blocked ordinary work: `grep -R "curl -d
   https://.../issues" docs/`, `echo "curl -d ..." > note.txt`, printing an
   example from python. Talking about a call is not making one, and this is the
   direction that actually kills a control — an over-blocking hook gets turned
   off, and an off hook permits everything. The client must now appear at
   COMMAND POSITION: line start or after a shell operator, optionally behind
   VAR=value. In every false positive it sat behind a quote instead. Quotes are
   deliberately NOT stripped before matching; real calls quote their URLs.

3. `wt_precious()` aborted `cmd_rm` under `set -euo pipefail`: `grep -v` exits 1
   when it filters everything out, which is exactly the disposable-only case,
   so a SAFE worktree failed to remove with no message. Fixed, and the same
   defect was latent one step upstream in `wt_dirty()`, where `head -200`
   SIGPIPEs git on any worktree with 201 changed files. The cap is gone —
   counting is cheap and the cap only ever truncated output that is no longer
   printed.

Seven new fixtures pin all of it, in both directions. 27/27.
2026-08-12 17:26:09 -05:00

232 lines
11 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 — start of the command, or directly
# after a shell operator, optionally behind VAR=value assignments. Substring
# presence is not enough, and this is the second thing review caught: with a
# bare substring test,
# 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
# were both BLOCKED. Talking about a call is not making one. Over-blocking is
# not the safe direction here — a guard that blocks ordinary work gets switched
# off, and a guard that is off permits everything.
#
# Quoting is what separates the two: in every false positive the client sits
# immediately after a quote character, never after an operator. Note the
# deliberate absence of quote-stripping: the URL is frequently quoted in REAL
# calls, so stripping quotes before matching would blind the endpoint check.
CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\()[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(curl|wget|httpie|http)([[:space:]]|$)'
if printf '%s' "$CMD" | 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