framework: make tool discoverability, workspace placement and model tiering mechanical (#1174)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #1174.
This commit is contained in:
Mos
2026-08-13 14:21:22 +00:00
parent 41749bbd33
commit afdaa6d0e6
11 changed files with 2865 additions and 8 deletions
+18
View File
@@ -46,10 +46,28 @@ steps:
# [0] of the pnpm chain, so severing that chain would silence it together # [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running. # with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Tool-index gate: a shipped wrapper that appears in no resident index doc
# is undiscoverable from inside a session, and an agent that cannot learn a
# wrapper exists reaches for raw curl instead — which is how a Gitea review
# got filed PENDING three times. Ships-and-documented is one commit, or red.
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH # Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
# and sandboxes a throwaway git repo, so it resolves no real credentials and # and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file. # joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
# a guard that over-blocks gets routed around, which fails just as hard.
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
# output. A repo only reaches that once it has enough worktrees, so the
# stub supplies the scale instead of the host's own checkout.
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
# Blocking gate (#791): a framework upgrade must never write or delete an # Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel # operator-owned path. The HARD GATE proves an unanticipated operator sentinel
+53
View File
@@ -1538,6 +1538,59 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
--- ---
## Workspace placement guard hardening (#1174)
### Problem and objective
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
will route around it.
### Scope and requirements
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
operands, not from every HOME-shaped word in the command.
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
operands and SHALL be refused when they resolve under HOME.
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
without relying on an enumerable allowlist of flag spellings.
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
handling SHALL preserve existing fail-closed checkout coverage.
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
repeated separators, and existing symlink parents before the HOME boundary comparison.
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
#1197.
### Acceptance and verification
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
guard allows a HOME-valued source with an explicit safe destination.
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
against the prior head where the defect existed.
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
after shell command boundaries.
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
terminal-green CI pass before merge.
5. Any option-classification residual is documented with its deliberate failure direction.
### Constraints, risks, and assumptions
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
acceptable repair.
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
while separate-value options have explicit grammar and must be classified as such.
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
chosen residual direction and pin every currently supported placement option in behavior tests.
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
---
## Assumptions ## Assumptions
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now. 1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
@@ -0,0 +1,89 @@
# #1174 — Wrapper guard rounds 1011
## Objective
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
## Plan
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
2. Add RED fixtures before production changes.
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
## Progress and evidence
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
- GREEN: expanded suite passes 242/242.
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
- Historical discrimination with the 242-fixture suite:
- `3d0a882a`: 216 pass / 26 fail.
- `4b8eba95`: 222 pass / 20 fail.
- `20d86e39`: 234 pass / 8 fail.
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
## Residual / risk
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
## Round 11 objective and intake
- **Issue / PR:** #1174.
- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks.
- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197.
- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes.
- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage.
### Round 11 plan
1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations.
2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar.
3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special.
4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI.
### Root-cause evidence
- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown.
- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family.
- `bash -c "printf '%s' <word>"` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it.
- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options.
### TDD and verification checkpoints
- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing.
- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path.
- GREEN after remediation: 280/280.
- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement.
- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls.
- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17.
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
### Deliberate residual
A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197.
### Independent review checkpoint
- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag.
- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata.
- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings.
- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292.
- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199.
- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199.
### Final local evidence
- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail.
- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17.
- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass.
- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain.
- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head.
### Documentation checklist
- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk.
- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only.
- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable.
@@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
- Do not auto-resolve data conflicts in shared state files. - Do not auto-resolve data conflicts in shared state files.
- Keep commits scoped to a single logical change set. - Keep commits scoped to a single logical change set.
## Model Tiering
Model choice is a standard, not a preference. Delegating a mechanical grep to a
frontier reasoning model wastes budget; sending a security review to a cheap tier
produces a review that passes and proves nothing. Both are defects.
Tiers are named by **capability class**, so the standard survives a model
generation. An operator binds each class to a concrete model id.
| Class | Use for |
| ------------- | ----------------------------------------------------------------------------------------- |
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
| `build` | feature implementation, test writing, bugfixes, routine refactors |
| `judge` | code review, planning, API/compat-sensitive changes |
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
Rules:
1. **Start at the cheapest class that can do the task; escalate on evidence, not
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
which is usually the most expensive one.
2. **Compat-sensitive work escalates one class.** A change that must interoperate
with an existing contract is judged, not just built.
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
cheaper tier only against a blind A/B on real work from this codebase, ranked
by someone other than the author. "It seemed fine" is not evidence.
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
the model that wrote the code is not a second opinion (see Constitution gate 16).
### Where the binding lives
The class→model map is operator configuration, never framework source: model
availability, cost, and quotas differ per operator and per host.
Resolution order, first hit wins:
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
injects it)
3. the framework default — the class names above, with no binding
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
and 3 exist so a host with no config service still runs. A local override that
silently disagrees with the config service is drift — the same failure class the
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
## Prompting Contract ## Prompting Contract
All runtime adapters should inject: All runtime adapters should inject:
@@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`.
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands. Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
supply it explicitly on any host where the provider CLI's default account is an admin.
| Issues | |
| ------------------ | --------------------------------- |
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
| `issue-view.sh` | Show one issue |
| `issue-list.sh` | List issues |
| `issue-edit.sh` | Edit title/body/labels/milestone |
| `issue-comment.sh` | Add a comment |
| `issue-assign.sh` | Assign or unassign |
| `issue-close.sh` | Close an issue |
| `issue-reopen.sh` | Reopen a closed issue |
| Pull requests | |
| ---------------- | --------------------------------------------------------- |
| `pr-create.sh` | Open a pull request |
| `pr-view.sh` | Show one PR |
| `pr-list.sh` | List PRs |
| `pr-diff.sh` | Fetch a PR's diff |
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
| `pr-merge.sh` | Merge a PR |
| `pr-close.sh` | Close a PR without merging |
| Milestones | |
| --------------------- | ------------------ |
| `milestone-create.sh` | Create a milestone |
| `milestone-list.sh` | List milestones |
| `milestone-close.sh` | Close a milestone |
| Gates and guards | |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
| Context | |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
| Workspace | |
| -------------------- | ------------------------------------------------------------------------ |
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
because a decision an agent has to make is a decision that drifts: 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 conventions on a single host.
```bash ```bash
# Issues ~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
~/.config/mosaic/tools/git/issue-create.sh ~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
~/.config/mosaic/tools/git/issue-close.sh ~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
```
# PRs Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
~/.config/mosaic/tools/git/pr-create.sh clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
~/.config/mosaic/tools/git/pr-merge.sh `gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
else is preserved and reported. `--force` exists and is yours to type deliberately.
# Milestones `wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
~/.config/mosaic/tools/git/milestone-create.sh `runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
a missing flag — extend the wrapper.
```bash
~/.config/mosaic/tools/git/issue-create.sh --help
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
# CI queue guard (required before push/merge; defaults to the checked-out branch) # CI queue guard (required before push/merge; defaults to the checked-out branch)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
``` ```
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex) ### Code Review (Codex)
@@ -52,6 +52,16 @@
"timeout": 10 "timeout": 10
} }
] ]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
"timeout": 10
}
]
} }
], ],
"PostToolUse": [ "PostToolUse": [
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env bash
# mosaic-worktree.sh — the only supported way to create and dispose of a git
# worktree on a fleet host.
#
# Why this exists as a helper and not as a rule: the rule already existed, in
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
# $HOME across 842 directories anyway. Five placement conventions were live on
# one fleet host simultaneously. Every one was a decision an agent had to make,
# and a decision an agent has to make is a decision that drifts.
#
# So this script makes NO placement decision available. The caller supplies a
# branch name. Every path is DERIVED:
#
# main worktree <- git worktree list --porcelain (never cwd, which may
# itself already be a worktree)
# REPO_NAME <- basename of the main worktree
# REPO_PARENT <- dirname of the main worktree
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
# SLUG <- branch with '/' replaced by '-'
# WT_PATH <- $WT_ROOT/$SLUG
#
# The derivation puts the worktree on the same filesystem as the object store
# it shares, as a sibling of the repo, under one root per repo. Those are the
# properties that make the checkout cheap and — via `git worktree list` —
# enumerable, which is the only reason automated cleanup can ever be safe.
#
# Usage:
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
# mosaic-worktree.sh path <branch> print derived path, no side effect
# mosaic-worktree.sh list this repo's worktrees + state
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
#
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
# commits absent from every remote, or holding ignored files that are not of the
# well-known regenerable kind (a `.env` is ignored so it is never committed,
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
# by size or age. --force overrides it and is yours to type deliberately.
#
# Run from anywhere inside the repo, or pass --repo <path>.
set -euo pipefail
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
REPO_HINT=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO_HINT="${2:-}"; shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]+"${ARGS[@]}"}"
CMD="${1:-}"
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
shift || true
# ---- mechanical derivation -------------------------------------------------
# The FIRST entry of `git worktree list --porcelain` is always the main
# worktree, regardless of which worktree we are standing in. Deriving from cwd
# would nest worktrees inside worktrees.
resolve_repo() {
local start="${REPO_HINT:-$PWD}"
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|| die "not inside a git repository: $start"
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
# first match closes the read end while git is still writing, git takes SIGPIPE,
# and under `set -euo pipefail` the command substitution returns 141 and this
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
# printing nothing at all.
#
# Whether it happens depends on how much git still had to write when awk left,
# so the failure is a function of REPO SIZE: fine on a repo with three
# worktrees, reliably broken on one with seventy. That is backwards — the repos
# this helper exists to serve are exactly the ones that accumulated worktrees,
# and it silently did nothing on those while working everywhere it was tried.
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
#
# The file's own comment block below already names this class for `head -200`
# and removed that cap for the same reason. The `exit` here is the same defect
# in the same file, so the rule is now uniform: nothing in this script closes a
# git pipe early. Dropping `exit` costs one pass over a few KB.
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
REPO_NAME="$(basename -- "$MAIN_WT")"
REPO_PARENT="$(dirname -- "$MAIN_WT")"
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
}
slugify() { printf '%s' "$1" | tr '/' '-'; }
derive_path() {
local branch="$1"
[ -n "$branch" ] || die "branch name required"
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
}
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
# $HOME is for configuration and state, not work products. Refuse rather than
# silently produce the layout we are trying to eliminate.
assert_not_home() {
local p="$1" home_real repo_real
home_real="$(cd "$HOME" && pwd -P)"
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
case "$repo_real/" in
"$home_real"/*)
die "refusing: derived path is under \$HOME ($p).
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
configuration, credentials, state and caches — not checkouts." ;;
esac
}
# ---- work-loss evidence ----------------------------------------------------
# Two independent questions, both answered from git, neither from size or age:
# dirty — anything uncommitted in the tree
# unpushed — commits reachable from HEAD that no remote ref contains
# precious — IGNORED files git will not mention and will not miss
#
# The third question is not obvious and was missed on the first pass. An
# independent reviewer demonstrated it in four commands: a pushed, clean
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
# precisely the files that are ignored BECAUSE they must not be committed, which
# is also why nothing else is holding a copy.
#
# So ignored files count as work unless they are the well-known regenerable
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
# an over-narrow one deletes the only copy of a secret (silent, permanent).
# The list stays short and conservative for that reason.
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
# These three run under `set -euo pipefail` inside command substitution, which
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
# silently. Two ways that bites, one of which shipped:
#
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
# `rm` exit 1 with no message and no removal — found by review.
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
# with 201 dirty files into the same silent abort. Not reported; it is the
# same defect one step upstream, so the cap is gone. Counting is cheap;
# the cap only ever protected output that is now never printed.
#
# Every one of them therefore ends in a total, and every stage that can
# legitimately exit nonzero says so explicitly.
wt_dirty() {
local out
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
}
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
# directory entry instead of being enumerated and then discarded.
wt_precious() {
local ignored
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
| awk '/^!! /{print substr($0,4)}' || true)"
[ -n "$ignored" ] || { printf '0'; return 0; }
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
}
wt_state() {
local wt="$1" d u p
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
else
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
fi
}
# ---- commands --------------------------------------------------------------
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
cmd_new() {
local branch="${1:-}" base=""
shift || true
while [ $# -gt 0 ]; do
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
done
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
resolve_repo
local path; path="$(derive_path "$branch")"
assert_not_home "$path"
if [ -e "$path" ]; then
echo "exists: $path"
echo "(already checked out — reuse it, or 'rm' it first)"
return 0
fi
mkdir -p "$WT_ROOT"
# Existing branch -> check it out. New branch -> create from base (default:
# the remote's default branch if resolvable, else current HEAD).
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
git -C "$MAIN_WT" worktree add "$path" "$branch"
else
if [ -z "$base" ]; then
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
[ -n "$base" ] || base="HEAD"
fi
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
fi
cat <<EOF
worktree: $path
branch: $branch
Removal is part of this task, not a later chore. When the work is pushed:
mosaic-worktree.sh rm $branch
EOF
}
cmd_list() {
resolve_repo
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
git -C "$MAIN_WT" worktree list --porcelain \
| awk '/^worktree /{print substr($0,10)}' \
| while read -r wt; do
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
done
}
cmd_rm() {
local branch="${1:-}" force=0
shift || true
while [ $# -gt 0 ]; do
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
done
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
resolve_repo
local path; path="$(derive_path "$branch")"
[ -d "$path" ] || die "no worktree at $path"
local d u p
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
die "refusing to remove $path
uncommitted files: $d
unpushed commits: $u
ignored, not disposable: $p
Commit and push first — that is the contract. Ignored files are counted because
git will neither report them nor miss them: a .env or a *.secret is ignored
precisely so it is never committed, which is also why nothing else holds a copy.
List them with: git -C $path status --porcelain --ignored | grep '^!!'
If this work is genuinely disposable, re-run with --force."
fi
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
if [ "$force" -eq 1 ]; then
git -C "$MAIN_WT" worktree remove --force "$path"
else
git -C "$MAIN_WT" worktree remove "$path"
fi
git -C "$MAIN_WT" worktree prune
echo "removed: $path"
rmdir "$WT_ROOT" 2>/dev/null || true
}
cmd_gc() {
local apply=0
[ "${1:-}" = "--apply" ] && apply=1
resolve_repo
git -C "$MAIN_WT" worktree prune
git -C "$MAIN_WT" worktree list --porcelain \
| awk '/^worktree /{print substr($0,10)}' \
| while read -r wt; do
[ "$wt" = "$MAIN_WT" ] && continue
local_state="$(wt_state "$wt")"
case "$local_state" in
SAFE*)
if [ "$apply" -eq 1 ]; then
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
else
echo "reclaimable (clean + fully pushed): $wt"
fi ;;
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
esac
done
git -C "$MAIN_WT" worktree prune
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
}
case "$CMD" in
new) cmd_new "$@" ;;
path) cmd_path "$@" ;;
list) cmd_list "$@" ;;
rm) cmd_rm "$@" ;;
gc) cmd_gc "$@" ;;
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
esac
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for.
#
# resolve_repo() took the first line of `git worktree list --porcelain` with
# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end
# of the pipe while git is still writing, git takes SIGPIPE, and under
# `set -euo pipefail` the command substitution returns 141 — so the assignment
# fails, `set -e` aborts the function, and the script dies printing NOTHING. No
# message, no path, no worktree, exit 141.
#
# What makes it worth a dedicated test rather than a fixture line is WHEN it
# fires. If git finishes writing before awk leaves, there is no SIGPIPE and
# everything works. So the failure is a function of how much porcelain the repo
# produces: invisible on a three-worktree repo, reliable on a seventy-worktree
# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141,
# no output — and it had passed every hand-check before that, on small repos.
#
# A test that ran `git worktree list` against whatever repo it happens to sit in
# would inherit that same size dependence and would have PASSED on the tree that
# was broken. So git is stubbed on PATH and made to emit a large porcelain
# stream, which turns "depends on the repo you are standing in" into "always".
#
# Exit: 0 = the helper resolved the repo · 1 = it did not
set -uo pipefail
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TOOL="${1:-$HERE/mosaic-worktree.sh}"
[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/bin"
# The stub answers exactly the two calls resolve_repo makes, and answers the
# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the
# writer is still writing when a reader that quits early goes away. Anything
# else exits non-zero rather than pretending to be git.
cat > "$TMP/bin/git" <<'STUB'
#!/bin/sh
while [ $# -gt 0 ]; do
case "$1" in -C) shift 2 ;; *) break ;; esac
done
case "$*" in
"rev-parse --git-dir")
echo .git; exit 0 ;;
"worktree list --porcelain")
# The first entry is the main worktree. That single line is all the helper
# needs, and it is exactly what it stopped receiving.
printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0
awk 'BEGIN{ for (i = 0; i < 4000; i++)
printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }'
# NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail
# in the caller is what turns that into the silent abort. A stub that exits 0
# regardless hands the caller a clean status and the probe passes on the
# broken tree — which is how this test failed to be a test on its first run.
exit $? ;;
esac
exit 1
STUB
chmod +x "$TMP/bin/git"
fail=0
check() {
local why="$1" want="$2" got="$3"
if [ "$want" = "$got" ]; then
printf 'ok %s\n' "$why"
else
printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got"
fail=1
fi
}
out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)"
rc=$?
# Both halves are asserted. rc alone would pass if the helper started printing a
# usage error, and output alone would miss a non-zero exit — and the defect's
# signature is precisely a non-zero exit with no output, which only the pair
# distinguishes from every other way this could go wrong.
check 'resolving a repo with a large worktree list exits 0' 0 "$rc"
check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out"
printf '\n'
if [ "$fail" -eq 0 ]; then
printf 'mosaic-worktree: resolves against a large porcelain stream.\n'
else
cat <<'EOF'
mosaic-worktree could not resolve the repository.
An empty output with a non-zero exit is the SIGPIPE signature: a reader that
quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns
that into a silent abort. Nothing in this script may close a git pipe early.
EOF
fi
exit "$fail"
+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env bash
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
#
# Resolves no credentials, touches no network, and creates no repository: the
# guard reads a hook payload on stdin and answers with an exit code, so the whole
# contract is testable from fixtures.
#
# The fixtures are written to a temp file rather than passed inline, and this is
# not stylistic. The guard inspects the literal text of the Bash command it is
# handed. A test that embeds `git clone ... $HOME` inside its own command line
# trips the guard on the harness instead of on the fixture — which is exactly
# what happened the first time this was checked by hand. Substring matching over
# whole command text is the guard's deliberate fail-closed posture; a test that
# does not account for it silently measures the wrong thing.
#
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
set -uo pipefail
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
GUARD="${1:-$HERE/wrapper-guard.sh}"
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
FIXTURES="$TMP/fixtures.tsv"
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
# [ TAB <substring the block message must contain> ]
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
# itself gets checked; without it a block is only asserted to have happened,
# not to have been useful.
{
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
# Path words use the same quote/escape state machine as names, but preserve
# substitutions so HOME remains visible. Quotes do not split the path word.
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
# Lexically equivalent absolute paths must be compared after shell-known HOME
# expansion and dot-segment normalization, without resolving filesystem links.
printf '2\t{"tool_input":{"command":"git clone x /var/../$HOME/wt"}}\tHOME expansion after parent traversal is normalized before comparison\n'
printf '2\t{"tool_input":{"command":"git worktree add /var/../${HOME}/wt"}}\tworktree placement also normalizes embedded HOME expansion\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=/var/../$HOME/gd x /src/wt"}}\tseparate Git state cannot hide behind parent traversal\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME/../outside-home/wt"}}\ta parent segment that leaves HOME is not over-blocked\n'
# The target may be HOME itself. End-of-command and whitespace terminate the
# token just as a slash does; punctuation that can extend a path does not.
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
# Unquoted POSIX metacharacters terminate the target word even without spaces.
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
# Quote removal must not create an expansion the shell never performs.
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
# Round ten: placement is decided by the destination and the one clone option
# that creates repository state elsewhere, not by every HOME-valued word in
# the command. Sources, templates, references, and environment are not targets.
printf '0\t{"tool_input":{"command":"NOTE=$HOME git clone https://example.invalid/x /src/wt"}}\tan unrelated assignment carrying HOME is not checkout placement\n'
printf '0\t{"tool_input":{"command":"git clone --reference=$HOME https://example.invalid/x /src/wt"}}\ta HOME reference is an object source, not checkout placement\n'
printf '0\t{"tool_input":{"command":"GIT_DIR=$HOME/x git clone https://example.invalid/x /src/wt"}}\tclone does not place its destination from ambient GIT_DIR\n'
printf '0\t{"tool_input":{"command":"git clone --template=$HOME/t https://example.invalid/x /src/wt"}}\ta HOME template source is not checkout placement\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=$HOME/gd https://example.invalid/x /src/wt"}}\tseparate-git-dir explicitly places repository state under HOME\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir $HOME/gd https://example.invalid/x /src/wt"}}\tthe space-separated placement option is equivalent\n'
printf '0\t{"tool_input":{"command":"git worktree add --reason=$HOME/note /src/wt"}}\ta worktree reason is metadata, not its path\n'
printf '0\t{"tool_input":{"command":"git clone $HOME/source /src/wt"}}\ta HOME source with an explicit safe destination is not placement\n'
printf '0\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x /src/wt"}}\ta space-separated HOME reference remains a source\n'
printf '0\t{"tool_input":{"command":"git clone --template $HOME/t https://example.invalid/x /src/wt"}}\ta space-separated HOME template remains a source\n'
printf '2\t{"tool_input":{"command":"git clone 2>/dev/null https://example.invalid/x $HOME/wt"}}\ta redirection before clone arguments does not become the destination\n'
printf '2\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x $HOME/wt"}}\ta source option does not hide a later HOME destination\n'
# Round eleven: Git accepts boolean options as a rule-generated family,
# including --no-* negations. Each command below was checked with Git itself:
# `git clone <option> /nonexistent-src /nonexistent-dst` reaches the missing
# source instead of reporting an unknown option. The HOME word is the source,
# not the explicit /src destination, so Bash expansion is allowed here.
printf '0\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt"}}\tbare clone keeps its HOME source distinct from the safe destination\n'
printf '0\t{"tool_input":{"command":"git clone --mirror $HOME/source /src/wt"}}\tmirror is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --ipv4 $HOME/source /src/wt"}}\tipv4 is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --ipv6 $HOME/source /src/wt"}}\tipv6 is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --no-local $HOME/source /src/wt"}}\tgenerated no-local remains a flag rather than a placement option\n'
printf '0\t{"tool_input":{"command":"git clone --no-reject-shallow $HOME/source /src/wt"}}\tgenerated no-reject-shallow remains a flag rather than placement\n'
printf '0\t{"tool_input":{"command":"git clone -4 $HOME/source /src/wt"}}\tthe short IPv4 flag leaves the HOME word in source position\n'
printf '0\t{"tool_input":{"command":"git clone -6 $HOME/source /src/wt"}}\tthe short IPv6 flag leaves the HOME word in source position\n'
printf '0\t{"tool_input":{"command":"git clone --no-bare $HOME/source /src/wt"}}\tan unusual generated negation is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-sparse $HOME/source /src/wt"}}\tgenerated no-sparse is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-dissociate $HOME/source /src/wt"}}\tgenerated no-dissociate is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-shallow-submodules $HOME/source /src/wt"}}\ta long generated negation is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-quiet $HOME/source /src/wt"}}\tgenerated no-quiet is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-progress $HOME/source /src/wt"}}\tgenerated no-progress is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-recurse-submodules $HOME/source /src/wt"}}\tgenerated no-recurse-submodules is accepted without enumeration\n'
# Git also generates accepted long abbreviations and short-option bundles.
# The closed value-taking option grammar must consume their values correctly.
printf '0\t{"tool_input":{"command":"git clone --templ $HOME/t $HOME/source /src/wt"}}\tan accepted template abbreviation consumes metadata rather than the source\n'
printf '0\t{"tool_input":{"command":"git clone -qj 1 $HOME/source /src/wt"}}\ta short flag bundle ending in jobs consumes its separate value\n'
printf '0\t{"tool_input":{"command":"git clone -qb topic $HOME/source /src/wt"}}\ta short flag bundle ending in branch consumes its separate value\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-d=$HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in attached form\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-d $HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in separate form\n'
# Worktree boolean options have the same generated-negation grammar. The next
# positional is its real path, so safe paths allow and HOME paths still block.
printf '0\t{"tool_input":{"command":"git worktree add --no-force /src/wt"}}\tgenerated worktree no-force accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-detach /src/wt"}}\tgenerated worktree no-detach accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-lock /src/wt"}}\tgenerated worktree no-lock accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-guess-remote /src/wt"}}\ta long worktree negation accepts a safe path without enumeration\n'
printf '0\t{"tool_input":{"command":"git worktree add -d /src/wt"}}\tthe documented short detach flag accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add -q /src/wt"}}\tthe documented short quiet flag accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --lock --rea $HOME/note /src/wt"}}\tan accepted reason abbreviation consumes metadata rather than the path\n'
printf '0\t{"tool_input":{"command":"git worktree add -fb $HOME/topic /src/wt"}}\ta short branch bundle consumes its HOME-valued branch before the safe path\n'
printf '2\t{"tool_input":{"command":"git worktree add -fb topic $HOME/wt"}}\ta short branch bundle does not hide the later HOME path\n'
# Upstream Git defines --orphan as a boolean flag; -b still carries the branch.
printf '0\t{"tool_input":{"command":"git worktree add --orphan /src/wt"}}\torphan mode accepts a safe path without consuming it as a value\n'
printf '2\t{"tool_input":{"command":"git worktree add --orphan $HOME/wt"}}\torphan mode does not hide its HOME path\n'
printf '0\t{"tool_input":{"command":"git worktree add --orphan -b $HOME/topic /src/wt"}}\torphan mode leaves HOME branch metadata to the branch option\n'
printf '2\t{"tool_input":{"command":"git worktree add --orphan -b topic $HOME/wt"}}\torphan mode plus a branch option preserves HOME path blocking\n'
# The optional second positional is commit-ish metadata, never placement.
# HOME expands here, but the explicit worktree path remains safely under /src.
printf '0\t{"tool_input":{"command":"git worktree add /src/wt $HOME/topic"}}\ta HOME-shaped commit-ish is not the worktree path\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-force $HOME/wt"}}\ta generated worktree negation does not hide the HOME path\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-guess-remote $HOME/wt"}}\ta long worktree negation preserves HOME placement blocking\n'
# Explicit placement options and later simple commands remain traps.
printf '2\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt && git clone x $HOME/wt"}}\ta boolean flag in one command does not hide a later HOME destination\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-force /src/wt; git clone x $HOME/wt"}}\ta worktree flag before a boundary does not hide later HOME placement\n'
# Routing this arm through the shared name site also repaired an over-block it
# had carried from the start: the old whole-command regex found `git` INSIDE a
# longer word, so these two were refused at every head before this commit.
# Same class as mycurl and curl-wrapper, and refusing them is how a guard gets
# routed around instead of repaired.
printf '0\t{"tool_input":{"command":"mygit clone https://example.invalid/x $HOME/wt"}}\tmygit is a different program and its checkout is not ours\n'
printf '0\t{"tool_input":{"command":"gitfoo clone https://example.invalid/x $HOME/wt"}}\tthe name has to end where git ends\n'
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
# --- bypasses an independent reviewer demonstrated against the first version.
# Each of these returned 0 (allowed) and each is a real write. They are pinned
# as fixtures rather than fixed-and-forgotten because the class is recurring:
# the guard reads text, so every spelling it does not know is a hole.
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
# Reads must survive every one of those broadenings, or the guard gets disabled.
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
# amount of fragment matching, because the endpoint does not exist until the
# shell expands it. The guard now refuses to clear a write whose URL it cannot
# read, rather than pretending it read one.
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
# --- round six changed the contract in this direction, and these fixtures are
# where it shows. They used to assert that discussing a call is not making one.
# Five rounds proved there is no textual way to tell a quoted example from a
# quoted command, so the guard stopped trying: it judges the payload, and a
# payload inside quotes is still a payload. Quoting one of these on a Bash
# command line is now refused, and the way to write the example is a
# file-writing tool. This is the deliberate cost of the mechanism change.
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
# The boundary that keeps this from being "block everything": what is refused
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
# these are asserted as hard as the blocks above.
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
# Command position must still catch the real thing behind operators and env.
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
# --- the case the AUTHOR hit twice while chasing the above: sending a message
# that QUOTED one of these fixtures. Under the old contract that was a defect
# to be parsed away; under this one it is the documented cost, and the message
# gets composed with a file-writing tool instead.
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
# ...but quotes stop being data the moment something executes them.
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
# --- round three. Each of these four is a real write that a bare-name match
# for the client could not see, because an ordinary word sat in front of it.
# They are kept as fixtures after the mechanism change even though the guard no
# longer looks for a client at all: they are the evidence for WHY it stopped,
# and a future re-narrowing that reintroduced position would fail here first.
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
# A shell standing between quoted data and execution makes that data code,
# and the pipe is the form agents actually use. Filing it as data allowed the
# call to vanish from the skeleton while still running.
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
# ...and the questions that used to follow — is the pipe target a shell, does a
# shell on one line execute a string on another — no longer have to be answered
# at all. Both of these carry the payload, both are refused, and neither
# outcome depends on parsing what the pipe or the other line does.
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
# --- round four. The guard was still reading the command as typed rather than
# as the shell will run it: a backslash before a newline is removed before
# anything else happens, so the endpoint token can be split across the join.
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
# Transparent prefixes take option VALUES, and the value was a word the list
# did not know — so the client went missing again behind an ordinary `sudo -u`.
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
# Execution through another command needed its own case under the old design.
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
# --- round five, and the finding that ended the parser. Command substitution
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
# span as inert prose. The unquoted and process-substitution forms already
# blocked, which is what made it a classification defect rather than a spelling
# one: the same call was refused or allowed depending on a quote character.
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
# A client the guard was never taught is the point of dropping client
# detection: neither of these names curl at all.
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
# Round six scoped the guard on `https?://`, and review found the absence shape
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
# so the guard never reached the write question. These are the reported repros.
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
# ...and the boundary that stops a broader scope gate becoming block-everything.
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
# Wrong remediation is its own defect: /issues/1/labels used to block with
# "use issue-create.sh", which is not the wrapper for that call. Round seven
# answered that by letting EVERY path under a numbered issue or PR through,
# and review showed the reasoning ("no wrapper owns these") was false in this
# tree. These are the reported repros, all rc 0 before round eight, and each
# asserts the wrapper the advice must name — not merely that a block happened.
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
# state=closed, so a title/description/due-date edit is a gap and the message
# has to say so — round eight named the wrapper and stopped there.
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
# The residue: still genuinely owned by nothing, and still flowing through.
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
# nothing in the tree adds a requested reviewer.
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
# for `https?://`, so a split endpoint in the other two was in scope to block,
# produced no readable endpoint, and fell through to allow. Same defect class
# as the milestone arm, one layer up. Each shape gets its own fixture, because
# a single one would have passed on the arm that already worked.
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
# And the reason this is not "any variable blocks a write": a payload in a
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
# The arms above the numbered ones must keep blocking, with their own wrappers.
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
# which is Gitea's spelling. GitHub's API carries no version segment at all
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
# than to what identifies a provider API. `/repos/` is the marker both share.
# These endpoints are READABLE, so each asserts the wrapper it must name; a
# rc-only fixture here would pass on the unreadable arm and prove nothing.
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
# The end-of-options marker, which is the one option not spelled like one.
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
# in the newly admitted shape have to stay allowed, or this is a regression
# wearing a fix'"'"'s clothes.
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
# must never trip it.
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
# Documented over-block, pinned so it is a known boundary and not a surprise.
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
# --- round nine, all four from one adversarial pass, and three of them are
# the same shape: a test written over the WHOLE command text deciding an
# ALLOW. That is the fail-open form this file keeps rediscovering, and it had
# reached the break-glass itself.
#
# BREAK-GLASS. `case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire
# command if that string appeared anywhere in it — so quoting the override in a
# note, or naming a variable after it, disabled the guard for the call sitting
# beside it. The override is now read POSITIONALLY: leading `NAME=value`
# assignments only, exactly where the shell would honour one.
printf '2\t{"tool_input":{"command":"echo \\"MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews\\" >> notes.md"}}\tquoting the override in a document does not arm it\n'
printf '2\t{"tool_input":{"command":"NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tan assignment whose VALUE is the override is not the override\n'
printf '2\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t=10 matched the old substring test and is not the value 1\n'
printf '0\t{"tool_input":{"command":"GITEA_TOKEN=$T MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe override still works behind other assignments, as the shell reads it\n'
# The cost, pinned rather than discovered later: positional means positional.
printf '2\t{"tool_input":{"command":"cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tan override after && is not in command position and does not arm\n'
# SUBRESOURCE REFINEMENT, same defect one arm lower. It asked whether a
# subresource appears ANYWHERE in the command, so a numbered-object write was
# cleared on the strength of text in its own BODY. Inverted: clear only when
# EVERY numbered-object occurrence carries a subresource.
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/pulls/2/files"}}\ta subresource in the body does not clear a write to the numbered issue\tissue-edit.sh\n'
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/issues/3/reactions"}}\tsame, quoting a subresource of the same object type\tissue-edit.sh\n'
# ...and its documented cost, in the safe direction.
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=cf-/issues/2"}}\tan unwrapped subresource write that quotes a bare issue is refused\n'
# -K/--config. curl reads the method, the body, the headers AND the URL from
# that file, so none of them are in the command: every write test above read 0
# and the call went through. An unreadable request is not a cleared one.
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe request in a config file is unreadable, so it is refused\t--config/-K\n'
printf '2\t{"tool_input":{"command":"curl -K /tmp/req https://git.example.invalid/api/v1/repos/a/b/issues"}}\tthe short spelling, same answer\t--config/-K\n'
# Cost, stated: this refuses a --config read against a host that has nothing
# to do with a forge. The alternative is to require a forge marker in a
# command whose URL may itself be in the file, which is the hole again.
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://example.invalid/anything"}}\tan unrelated https URL with --config is refused too, by decision\t--config/-K\n'
printf '0\t{"tool_input":{"command":"eslint --config .eslintrc.json src/"}}\t--config on a command that is not curl is nobody'"'"'s business\n'
# ROUND TEN. The --config check above was first written INSIDE the API-shape
# gate, so it was guarded by a condition that the capability it guards against
# removes. A config file can carry the URL; delete the URL from the command and
# nothing is API-shaped, the branch is never entered, and the guard reports
# clean on precisely the call it exists to refuse. It is now asked of any curl.
printf '2\t{"tool_input":{"command":"curl --config /tmp/provider-write.cfg"}}\ta config file can own the URL, so there is nothing API-shaped left to gate on\t--config/-K\n'
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
# curl by any ordinary path spelling. The first version of the config check
# matched the bare word only, so these three executed the same wrapped write
# while the guard reported clean. Recognizing only the unqualified name is
# caller-name parsing, and that is the class this file exists to refuse.
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
# The prefix must end at a slash: a basename that merely ENDS in curl is a
# different program, and blocking it would be the over-block that gets a guard
# routed around rather than fixed.
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
# And the same name once it is punctuated. The basename repair above fixed the
# UNQUOTED path spelling and nothing else, so two quote characters restored the
# bypass it had just closed: the check was still modelling one presentation of
# a shell word instead of the word. Every one of these executes the real curl.
printf '2\t{"tool_input":{"command":"\\"/usr/bin/curl\\" --config /tmp/provider-write.cfg"}}\tquoting a path does not make it a different program\t--config/-K\n'
printf '2\t{"tool_input":{"command":"'"'"'./curl'"'"' --config /tmp/provider-write.cfg"}}\tnor does quoting a relative one\t--config/-K\n'
printf '2\t{"tool_input":{"command":"$(which curl) --config /tmp/provider-write.cfg"}}\tthe name is in the text even when a substitution supplies the path\t--config/-K\n'
printf '2\t{"tool_input":{"command":"`which curl` --config /tmp/provider-write.cfg"}}\tand in the older spelling of the same substitution\t--config/-K\n'
# The provider-CLI SCOPE gate had the identical defect, untouched while the
# curl arm was repaired twice. It decides whether write detection runs at all,
# so failing to admit these is indistinguishable from allowing them — and no
# URL marker rescues them, because provider CLI paths carry no leading slash.
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/issues -f title=x"}}\tan absolute path to a provider CLI is still a provider CLI\n'
printf '2\t{"tool_input":{"command":"./gh api -X POST repos/a/b/issues -f title=x"}}\tand a relative one still is too\n'
printf '2\t{"tool_input":{"command":"/usr/local/bin/tea api -X POST repos/a/b/issues -f title=x"}}\tthe same is true of every CLI the gate names, not just the first\n'
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
# characters with whitespace, which is token separation and not quote removal:
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
# one word naming curl while whitespace made it two words naming neither.
# `"/usr/bin/curl"` passed under that version only because the inserted space
# happened to land after a slash, which established nothing.
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
# A backslash is NOT uniformly removed. It is literal inside single quotes,
# and inside double quotes when it precedes anything other than $, `, ",
# backslash, or newline. These spell a different program and must stay allowed.
printf '0\t{"tool_input":{"command":"'"'"'cu\\\\rl'"'"' --config /tmp/provider-write.cfg"}}\ta backslash inside single quotes remains literal\n'
printf '0\t{"tool_input":{"command":"\\"cu\\\\rl\\" --config /tmp/provider-write.cfg"}}\ta backslash before r inside double quotes remains literal\n'
printf '0\t{"tool_input":{"command":"'"'"'g\\\\it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a single-quoted non-git name is not a checkout\n'
printf '0\t{"tool_input":{"command":"\\"g\\\\it\\" clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a double-quoted non-git name is not a checkout\n'
# The other branch of the same rule: OUTSIDE quotes a backslash escapes the
# next character, so an escaped quote is a literal quote IN the name and the
# program is not curl. Held separately from the cases above because it is a
# different arm of the state machine, and an arm without a fixture is a rule
# that is not held.
printf '0\t{"tool_input":{"command":"cu\\\\\\"rl\\\\\\" --config /tmp/provider-write.cfg"}}\tan escaped quote is a literal quote in the name\n'
# Three quoted segments concatenate into ONE word. This is the shape that
# distinguishes quote removal from token separation, so it is worth its own line.
printf '2\t{"tool_input":{"command":"\\"cu\\"'"'"'r'"'"'\\"l\\" --config /tmp/provider-write.cfg"}}\tadjacent quoted segments are one word, and that word is curl\t--config/-K\n'
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
# The FLAG is the same recognition problem as the name, and is read the same
# way. No review raised this one; the name was simply the easier half to reach.
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
# bare-name copy of the scope gate's regex, so a caller could be admitted by
# the repaired gate and then go unrecognized by the fail-closed refinement —
# a gate and its own refinement disagreeing about who the caller is.
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
# Shapes nobody raised. Written down because reasoning that they were already
# covered is precisely what produced two of the rounds above; each one below
# was measured, and the three that fail at df83a9ee are here on that evidence.
# `\curl` is the ordinary way to bypass a shell alias and is a thing people
# actually type, which makes it the least hypothetical entry in the file.
printf '2\t{"tool_input":{"command":"\\\\curl --config /tmp/provider-write.cfg"}}\tescaping the leading character to dodge an alias still names curl\t--config/-K\n'
printf '2\t{"tool_input":{"command":"cur\\"l\\" --config /tmp/provider-write.cfg"}}\tthe quote may sit at any offset in the word\t--config/-K\n'
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST \\"repos/a/b/$EP\\" -f title=x"}}\tboth halves dressed at once, which is where they last disagreed\n'
# Over-blocking is a real failure and not a safe direction: a guard that
# refuses legitimate work gets routed around instead of repaired. These four
# pass at both heads, which is what a regression guard is for.
printf '0\t{"tool_input":{"command":"gh api \\"repos/a/b/issues\\""}}\ta quoted read is still a read\n'
printf '0\t{"tool_input":{"command":"curl https://example.com/file.txt -o /tmp/f"}}\tan ordinary download is not a provider write\n'
printf '0\t{"tool_input":{"command":"echo \\"$EP\\" && gh --version"}}\tno api subcommand, so nothing to refuse\n'
printf '0\t{"tool_input":{"command":"echo \\"not a curl call\\""}}\tthe word inside a string, with no flag, is prose\n'
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
# both returned HTTP 200 with the same object from the live forge, so the
# encoded spelling IS the wrapped endpoint and the literal comparison below it
# sees a segment matching nothing. Refused rather than decoded — a decoder has
# to be exactly right about depth and normalization, which is the parser
# mistake this file declines everywhere else.
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%75es/1/comments -f body=x"}}\tan encoded path segment reaches the wrapped endpoint\tpercent-escape\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/revi%%65ws"}}\tsame for the review endpoint, which is the one that matters most\tpercent-escape\n'
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%2575es/1/comments -f body=x"}}\tdouble-encoded too, which is why this refuses instead of decoding\tpercent-escape\n'
# Scoped to writes, deliberately. Reads are never blocked by this guard and a
# query string carrying %%20 is an ordinary URL, not a hazard.
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
} > "$FIXTURES"
fail=0 n=0
while IFS=$'\t' read -r want payload why remedy; do
[ -n "${want:-}" ] || continue
n=$((n + 1))
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
got=$?
if [ "$got" != "$want" ]; then
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
continue
fi
# A block that names the wrong wrapper is a defect in its own right, and until
# now it was invisible here: the harness read the exit code and nothing else,
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
# six rounds. Where a fixture states the remediation it expects, assert it.
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
fail=1
continue
fi
printf 'ok %s\n' "$why"
done < "$FIXTURES"
# ---- $HOME resolution ------------------------------------------------------
# These cannot be fixtures. Every case above varies the COMMAND; this defect
# varies the ENVIRONMENT, and the loop has no way to express that.
#
# The checkout arm built its pattern from "$HOME" without asking whether $HOME
# was a usable value. Three values it is not: unset (which is a crash under
# `set -u`, not a decision), empty (the pattern collapses to `/`, so `~|\$HOME|`
# matches whatever the empty alternative touches), and "/" (every absolute path
# is under it, so the comparison stops discriminating). An agent seat running
# with no HOME — a systemd unit without one, a container, `env -i` — got the
# checkout question answered by accident rather than on the merits.
#
# The fix resolves $HOME once, rejects all three, and BLOCKS the checkout it
# cannot adjudicate. A guard may not clear a question it was unable to ask. The
# blast radius of that fail-closed arm is asserted below to be one command shape
# and not the session: with no HOME at all, ordinary commands still pass and the
# API arms still block.
home_case() {
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
local out got
n=$((n + 1))
local payload
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
if [ "$homeval" = "@unset" ]; then
out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
else
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
fi
got=$?
if [ "$got" != "$want" ]; then
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
return
fi
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
fi
printf 'ok %s\n' "$why"
}
home_case 'HOME unset: a checkout is refused, not adjudicated' \
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
home_case 'HOME empty: same, and it is not the same thing as unset' \
2 '' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
home_case 'HOME=/ : every path is under it, so it discriminates nothing' \
2 '/' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
home_case 'a usable HOME still allows a checkout onto a work filesystem' \
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
home_case 'a usable HOME still catches the literal path' \
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
home_case 'quotes around the exact literal HOME path do not change the target' \
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under'
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
home_case 'a repeated leading slash is the same absolute HOME path' \
2 '/home/tester' 'git clone https://example.invalid/x //home/tester/wt' 'checks a repository out under'
home_case 'dot segments cannot disguise the literal HOME path' \
2 '/home/tester' 'git worktree add /var/../home/tester/./wt' 'checks a repository out under'
home_case 'parent traversal into HOME is normalized for separate Git state' \
2 '/home/tester' 'git clone --separate-git-dir=/home/other/../tester/gd x /src/wt' 'checks a repository out under'
home_case 'normalization still permits a literal HOME sibling' \
0 '/home/tester' 'git clone x /home/tester/../tester-sibling/wt'
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
# Resolve the longest existing parent physically before appending a nonexistent
# destination. Lexical normalization alone cannot see a symlink into HOME, and
# it applies `..` in the wrong order when the preceding component is a symlink.
SYMLINK_HOME="$TMP/symlink-home"
SYMLINK_SAFE="$TMP/symlink-safe"
mkdir -p "$SYMLINK_HOME/nested" "$SYMLINK_SAFE"
ln -s "$SYMLINK_HOME" "$TMP/home-link"
ln -s "$SYMLINK_HOME/nested" "$TMP/home-nested-link"
ln -s "$SYMLINK_SAFE" "$TMP/safe-link"
home_case 'a clone path through a symlink into HOME is refused' \
2 "$SYMLINK_HOME" "git clone x $TMP/home-link/wt" 'checks a repository out under'
home_case 'a worktree path through a symlink into HOME is refused' \
2 "$SYMLINK_HOME" "git worktree add $TMP/home-link/wt" 'checks a repository out under'
home_case 'symlink resolution occurs before a following parent segment' \
2 "$SYMLINK_HOME" "git clone x $TMP/home-nested-link/../wt" 'checks a repository out under'
home_case 'a symlink to a physical path outside HOME remains allowed' \
0 "$SYMLINK_HOME" "git clone x $TMP/safe-link/wt"
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
# HOME would have every command it runs refused, which is how a guard gets
# disabled rather than fixed.
home_case 'HOME unset does not block an ordinary command' \
0 '@unset' 'ls -la /src'
home_case 'HOME unset does not stop the API arms doing their job' \
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
# ---- the guard standing on its own -----------------------------------------
# Every case above runs the guard from the directory holding its siblings, so
# `[ -x "$W/pr-review.sh" ]` succeeds and the $HOME fallback beside it never
# evaluates. That is a property of the HARNESS, not of the guard, and it hid a
# live fail-open: with the guard copied somewhere alone AND no HOME, the
# fallback expanded an unset variable under `set -u` and the script died at
# rc=1 — on EVERY arm, before any adjudication. A PreToolUse hook exiting
# nonzero-but-not-2 is a non-blocking error, so that seat ran with no guard and
# nothing reported it.
#
# The first remediation moved that expansion four lines earlier and called it
# closed. It was not closed, because the test could not reach it. So the guard
# is copied ALONE here — no siblings, no installed mosaic home — which is the
# deployment this file already claims to support ("still works from a repo
# checkout with no installed mosaic home").
LONE="$TMP/lone"; mkdir -p "$LONE"
cp "$GUARD" "$LONE/wrapper-guard.sh"; chmod +x "$LONE/wrapper-guard.sh"
lone_case() {
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
local out got
n=$((n + 1))
if [ "$homeval" = "@unset" ]; then
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$LONE/wrapper-guard.sh" 2>&1)"
else
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$LONE/wrapper-guard.sh" 2>&1)"
fi
got=$?
if [ "$got" != "$want" ]; then
printf 'FAIL %s [standalone] (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
return
fi
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
fi
printf 'ok %s [standalone]\n' "$why"
}
lone_case 'no siblings and no HOME: an ordinary command still passes, not rc=1' \
0 '@unset' 'ls -la /src'
lone_case 'no siblings and no HOME: a wrapped write is still refused' \
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
lone_case 'no siblings and no HOME: a checkout is refused, not adjudicated' \
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
# Spelled without quotes on purpose. The payload is interpolated into a JSON
# string by the helper, so a fixture carrying bare double quotes produces
# malformed JSON, jq returns empty, and the guard exits 0 on an empty command —
# a PASS that measures nothing. That is what the first version of this case did.
lone_case 'no siblings and no HOME: the APPROVE trap still fires' \
2 '@unset' 'gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE'
lone_case 'no siblings, usable HOME: ordinary commands unaffected' \
0 '/home/tester' 'ls -la /src'
printf '\n'
if [ "$fail" -eq 0 ]; then
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
else
cat <<'EOF'
wrapper-guard drifted from its contract.
A guard that blocks too much gets routed around, and a guard that blocks too
little is decoration. Both directions are failures here, which is why the
allowed cases are asserted as hard as the blocked ones.
EOF
fi
exit "$fail"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# check-tools-index.sh — assert every shipped tool is discoverable from the
# resident documentation an agent actually has in context.
#
# WHY THIS GATE EXISTS
# --------------------
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
# undocumented ones was `pr-review.sh` — the wrapper that carries the
# APPROVED/APPROVE provider-dialect split.
#
# The observable consequence, on a live fleet host: an agent needing to place a
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
# host, and got HTTP 200 with the review silently filed PENDING — three times,
# because nothing about the failure pointed at the wrapper that already handled
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
# an index that said the tool did not exist.
#
# That is not a discipline problem and no amount of prose fixes it. A wrapper
# that is not in the resident index is, from inside a session, indistinguishable
# from a wrapper that was never written. So the invariant is mechanical:
#
# shipping a tool and documenting it are the same commit, or CI fails.
#
# WHAT IT CHECKS
# --------------
# forward every non-excluded tool in an ENFORCED suite is named in at least
# one index document (missing tool -> undiscoverable -> FAIL)
# reverse every `<name>.sh` an index document attributes to an enforced
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
#
# Suites outside the enforced set are reported with a coverage percentage but do
# not fail the build, so the ratchet can be tightened one suite per PR instead of
# landing as one unreviewable sweep. `--strict` fails on those too.
#
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
# -------------------------------------------------------------------
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
# would let an operator silence this gate by editing their own copy — the gate
# would then be strongest exactly where it is least needed and absent where it
# is needed most. The list is framework-owned and changes only through a
# reviewed PR.
#
# Usage:
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
#
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
set -euo pipefail
# Suites whose coverage is a HARD requirement. Add a suite here only together
# with the doc changes that make it pass.
#
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
# an undiscoverable git wrapper converts a hard gate into a coin flip.
ENFORCED_SUITES=(git)
# Files that are not agent-callable tools and must not be required in an index.
EXCLUDE_GLOBS=(
'test-*' # hermetic regression scripts, invoked by CI not by agents
'_*' # private helpers (_lib, _scripts internals)
'*.bak' # editor/installer debris
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
'README.md'
)
STRICT=0
SELF_TEST=0
TOOLS_DIR=""
DOCS=()
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
while [ $# -gt 0 ]; do
case "$1" in
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
--doc) DOCS+=("${2:-}"); shift 2 ;;
--strict) STRICT=1; shift ;;
--self-test) SELF_TEST=1; shift ;;
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
# ---- location resolution ---------------------------------------------------
# Runs from two places with different layouts, and must not silently check the
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
resolve_locations() {
local here framework
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# .../framework/tools/quality/scripts -> .../framework
framework="$(cd -- "$here/../../.." && pwd)"
if [ -z "$TOOLS_DIR" ]; then
if [ -d "$framework/tools" ]; then
TOOLS_DIR="$framework/tools"
else
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
fi
fi
if [ ${#DOCS[@]} -eq 0 ]; then
# The two layouts are mutually exclusive on purpose. Unioning them would let
# a well-maintained operator TOOLS.md on the developer's own machine mask a
# gap in the shipped defaults — the check would pass locally and the defect
# would still install on every other host. Repo layout wins when present.
if [ -f "$framework/defaults/TOOLS.md" ]; then
DOCS+=("$framework/defaults/TOOLS.md")
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
else
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
fi
fi
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
}
is_excluded() {
local name="$1" glob
for glob in "${EXCLUDE_GLOBS[@]}"; do
# shellcheck disable=SC2254 # glob is intentionally a pattern
case "$name" in $glob) return 0 ;; esac
done
return 1
}
# A tool counts as documented when its basename appears anywhere in the corpus.
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
# about *presence*: the gate's job is "an agent can find it", not house style.
documented() { grep -qF -- "$1" "$CORPUS"; }
# ---- the check -------------------------------------------------------------
run_check() {
local rc=0 suite dir tool base enforced
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
cat "${DOCS[@]}" > "$CORPUS"
printf 'tools: %s\n' "$TOOLS_DIR"
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
printf '\n'
for dir in "$TOOLS_DIR"/*/; do
[ -d "$dir" ] || continue
suite="$(basename -- "$dir")"
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
[ "$STRICT" -eq 1 ] && enforced=1
case "$suite" in _*) continue ;; esac
local total=0 found=0
local -a suite_missing=() suite_noexec=()
for tool in "$dir"*.sh; do
[ -e "$tool" ] || continue
base="$(basename -- "$tool")"
is_excluded "$base" && continue
total=$((total + 1))
if documented "$base"; then
found=$((found + 1))
# Documented AND present is not enough. The index presents these as
# commands to run, and every caller — the wrapper guard included —
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
# documented, present, and dead: it reads as absent to every check that
# matters while scoring 100% here. That is a false green, which is worse
# than a red, so it fails rather than warns.
[ -x "$tool" ] || suite_noexec+=("$base")
else
suite_missing+=("$base")
fi
done
[ "$total" -eq 0 ] && continue
local pct=$(( found * 100 / total ))
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
rc=1
fi
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
rc=1
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
else
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
fi
# Reverse: an index that names a tool this suite does not have sends agents
# after something that cannot run. Only checked for enforced suites, where
# the naming is unambiguous enough to attribute.
if [ "$enforced" -eq 1 ]; then
local -a stale=()
local ref
while read -r ref; do
[ -n "$ref" ] || continue
is_excluded "$ref" && continue
[ -e "$dir$ref" ] || stale+=("$ref")
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
| sed "s|^$suite/||" | sort -u)
if [ ${#stale[@]} -gt 0 ]; then
printf 'FAIL %-12s stale index references (no such file): %s\n' \
"$suite" "${stale[*]}"
rc=1
fi
fi
done
printf '\n'
if [ "$rc" -ne 0 ]; then
cat <<EOF
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
tells it to use a wrapper it has no way to learn exists — it will reach for raw
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
Fix by naming each tool above in one of the index documents listed at the top,
in the same commit that ships it.
EOF
else
printf 'every enforced suite is fully discoverable.\n'
fi
return "$rc"
}
# ---- self-test -------------------------------------------------------------
# Proves the gate can actually fail. A checker that only ever passes is
# indistinguishable from one that is not running, which is the failure mode this
# whole file exists to prevent — so it must demonstrate a red on demand.
self_test() {
local tmp rc
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
mkdir -p "$tmp/tools/git"
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
# command-prefix assignment, so point the globals at the fixture directly.
TOOLS_DIR="$tmp/tools"
DOCS=("$tmp/doc.md")
# Case 1: fully documented -> pass.
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
if run_check >/dev/null; then
printf 'self-test 1/4 ok (complete index passes)\n'
else
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
fi
# Case 2: an undocumented tool -> fail.
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
else
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
fi
# Case 3: a stale index reference -> fail.
rm "$tmp/tools/git/undocumented-tool.sh"
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
else
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
fi
# Case 4: documented, present, and NOT executable -> fail. Found by an
# independent reviewer: a 0644 wrapper scored 100% here while reading as
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
sed -i '/deleted-tool/d' "$tmp/doc.md"
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
else
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
fi
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
}
if [ "$SELF_TEST" -eq 1 ]; then
self_test
else
resolve_locations
run_check
fi