Files
stack/packages/mosaic/framework/tools/git/wrapper-guard.sh
T
Hermes Agent 96609bdade
ci/woodpecker/pr/ci Pipeline failed
framework: prove the wrapper guard both ways, and resolve its wrappers relatively
Two defects found by running the guard rather than reading it.

1. The guard resolved its sibling wrappers through a hardcoded
   $HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a
   CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed,
   and the guard fell through allowing the raw API write it exists to block. It
   failed OPEN, silently, in exactly the environment least likely to notice.
   It now resolves relative to its own path, so it names the wrappers from the
   install it was launched from, with $HOME as the fallback.

2. There was no test. Adding one surfaced the guard's other sharp edge
   immediately: it matches the literal text of the Bash command, so a harness
   that embeds a blocked pattern inline trips the guard on itself rather than on
   the fixture. That is the correct fail-closed posture and it is now recorded in
   the test's own comments, because the next person will hit it too.

test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as
hard as the blocked ones. A guard that over-blocks gets routed around and a guard
that under-blocks is decoration; only pinning both edges keeps it useful. It is
hermetic — no network, no credentials, no repository — so it joins the CI
sanitization step directly rather than the exclusions file.
2026-08-12 16:54:18 -05:00

143 lines
6.3 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 ---------------------------------------------
# Only consider calls that are (a) to a provider API path and (b) mutating.
is_api=0
printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1
if [ "$is_api" -eq 1 ]; then
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 given a body.
printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1
if [ "$is_write" -eq 1 ]; then
endpoint=""; wrapper=""
case "$CMD" in
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;;
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;;
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;;
*"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;;
*"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;;
*"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;;
esac
if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then
cat <<EOF
BLOCKED: raw provider API write to the $endpoint endpoint.
A Mosaic wrapper already covers this and the Constitution (gate 7) requires it
before any raw provider call:
$wrapper
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.
Run \`$(basename "$wrapper") --help\` for the flags.
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