Files
stack/packages/mosaic/framework/tools/git/wrapper-guard.sh
T
Hermes Agent b1254f52f3
ci/woodpecker/pr/ci Pipeline was canceled
wrapper-guard: judge the payload, not the caller — delete the code/data parser
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.
2026-08-12 17:59:14 -05:00

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