An undocumented tool is, from inside an agent session, indistinguishable from a tool that was never written. The framework shipped 26 git wrappers and named 6 of them in its resident index docs — 23% discoverability, with pr-review.sh among the missing. The observable consequence was an agent obeying Constitution gate 7 as best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea host, and getting HTTP 200 with the review silently filed PENDING. Three times. That is not a discipline failure and no amount of prose fixes it. Four changes, each converting a rule that decayed into a mechanism that cannot: - check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be named in a resident index doc, and every tool an index names must exist. The git suite is enforced now; other suites report coverage without failing, so the ratchet tightens one reviewed PR at a time instead of landing as one sweep. The enforced list is framework-owned rather than a marker inside operator-owned TOOLS.md — a doc marker would let an operator silence the gate on exactly the host where it matters most. Carries --self-test, because a checker that only ever passes is indistinguishable from one that is not running. - TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE dialect note that explains why pr-review.sh is not a formality. - mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a work filesystem" already existed in prose, and 255 GB accumulated in $HOME across 842 directories anyway, under five simultaneous placement conventions on one host. The helper therefore exposes no placement decision — given a branch name, every path is derived from `git worktree list --porcelain`. Worktrees rather than clones because enumerability is the only thing that makes reclaim safe, and reclaim is by evidence (clean tree + no unpushed commits), never by size or age. The guard blocks three mechanically-detectable mistakes and nothing else: a checkout into $HOME, a raw provider-API write to an endpoint that has a wrapper, and the literal APPROVE event. Reads pass untouched. - STANDARDS.md: model tiering as a standard, named by capability class so it survives a model generation. Start cheapest, escalate on evidence, benchmark before demoting a task class, and keep the class->model binding in operator config with the DB-backed config service as the end state. Registering the guard in runtime/claude/settings.json is the point of upstreaming it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there is destroyed by the next upgrade. In the template it survives, and it reaches every host instead of one.
136 lines
5.8 KiB
Bash
Executable File
136 lines
5.8 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
|
|
|
|
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
|