From e3a0ee87b34b0afc1a88ae9838a7fb148f6f81cf Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 16:51:17 -0500 Subject: [PATCH 01/24] framework: make tool discoverability, workspace placement and model tiering mechanical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An undocumented tool is, from inside an agent session, indistinguishable from a tool that was never written. The framework shipped 26 git wrappers and named 6 of them in its resident index docs — 23% discoverability, with pr-review.sh among the missing. The observable consequence was an agent obeying Constitution gate 7 as best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea host, and getting HTTP 200 with the review silently filed PENDING. Three times. That is not a discipline failure and no amount of prose fixes it. Four changes, each converting a rule that decayed into a mechanism that cannot: - check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be named in a resident index doc, and every tool an index names must exist. The git suite is enforced now; other suites report coverage without failing, so the ratchet tightens one reviewed PR at a time instead of landing as one sweep. The enforced list is framework-owned rather than a marker inside operator-owned TOOLS.md — a doc marker would let an operator silence the gate on exactly the host where it matters most. Carries --self-test, because a checker that only ever passes is indistinguishable from one that is not running. - TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE dialect note that explains why pr-review.sh is not a formality. - mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a work filesystem" already existed in prose, and 255 GB accumulated in $HOME across 842 directories anyway, under five simultaneous placement conventions on one host. The helper therefore exposes no placement decision — given a branch name, every path is derived from `git worktree list --porcelain`. Worktrees rather than clones because enumerability is the only thing that makes reclaim safe, and reclaim is by evidence (clean tree + no unpushed commits), never by size or age. The guard blocks three mechanically-detectable mistakes and nothing else: a checkout into $HOME, a raw provider-API write to an endpoint that has a wrapper, and the literal APPROVE event. Reads pass untouched. - STANDARDS.md: model tiering as a standard, named by capability class so it survives a model generation. Start cheapest, escalate on evidence, benchmark before demoting a task class, and keep the class->model binding in operator config with the DB-backed config service as the end state. Registering the guard in runtime/claude/settings.json is the point of upstreaming it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there is destroyed by the next upgrade. In the template it survives, and it reaches every host instead of one. --- .woodpecker/ci.yml | 6 + .../mosaic/framework/defaults/STANDARDS.md | 46 +++ .../framework/guides/TOOLS-REFERENCE.md | 99 ++++++- .../framework/runtime/claude/settings.json | 10 + .../framework/tools/git/mosaic-worktree.sh | 236 ++++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 135 +++++++++ .../quality/scripts/check-tools-index.sh | 266 ++++++++++++++++++ 7 files changed, 790 insertions(+), 8 deletions(-) create mode 100755 packages/mosaic/framework/tools/git/mosaic-worktree.sh create mode 100755 packages/mosaic/framework/tools/git/wrapper-guard.sh create mode 100755 packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 63fb349d..b6b9e30c 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -46,6 +46,12 @@ steps: # [0] of the pnpm chain, so severing that chain would silence it together # with everything it guards; this direct line keeps one instrument running. - 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 # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. diff --git a/packages/mosaic/framework/defaults/STANDARDS.md b/packages/mosaic/framework/defaults/STANDARDS.md index d9ea7f40..a1cf2bc0 100644 --- a/packages/mosaic/framework/defaults/STANDARDS.md +++ b/packages/mosaic/framework/defaults/STANDARDS.md @@ -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. - 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 All runtime adapters should inject: diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 0eca6c5a..5ba36d1e 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -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. +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 ` 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 ` takes a branch +name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree, +repo name, parent dir, then `/-worktrees/`. 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 -# Issues -~/.config/mosaic/tools/git/issue-create.sh -~/.config/mosaic/tools/git/issue-close.sh +~/.config/mosaic/tools/git/mosaic-worktree.sh new [--from ] +~/.config/mosaic/tools/git/mosaic-worktree.sh path # derived path, no side effect +~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state +~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task +~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones +``` -# PRs -~/.config/mosaic/tools/git/pr-create.sh -~/.config/mosaic/tools/git/pr-merge.sh +Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare +clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and +`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 -~/.config/mosaic/tools/git/milestone-create.sh +`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see +`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) ~/.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 ` 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) diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index eada96fe..1f60a7c3 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -52,6 +52,16 @@ "timeout": 10 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/git/wrapper-guard.sh", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh new file mode 100755 index 00000000..7d230934 --- /dev/null +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -0,0 +1,236 @@ +#!/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 [--from ] create (branch may exist) +# mosaic-worktree.sh path print derived path, no side effect +# mosaic-worktree.sh list this repo's worktrees + state +# mosaic-worktree.sh rm [--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 or with +# commits absent from every remote. 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 . + +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" + MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')" + [ -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 +wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; } +wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; } + +wt_state() { + local wt="$1" d u + d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")" + if [ "$d" -eq 0 ] && [ "$u" = "0" ]; then + printf 'SAFE\tclean; 0 unpushed' + else + printf 'PRESERVE\t%s uncommitted; %s unpushed' "$d" "$u" + 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 [--from ]" + + 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 < [--force]" + + resolve_repo + local path; path="$(derive_path "$branch")" + [ -d "$path" ] || die "no worktree at $path" + + local d u + d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")" + if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ]; }; then + die "refusing to remove $path + uncommitted files: $d + unpushed commits: $u +Commit and push first — that is the contract. 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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh new file mode 100755 index 00000000..685ea328 --- /dev/null +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# wrapper-guard.sh — PreToolUse hook on Bash. +# +# Blocks three specific, mechanically-detectable mistakes that prose has +# repeatedly failed to prevent: +# +# 1. A checkout (git clone / git worktree add) targeting $HOME. +# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs. +# +# 2. A raw provider API WRITE against an endpoint that already has a Mosaic +# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows +# provider dialect, identity, and queue-guard ordering that raw curl does +# not. Reads are untouched — they are how you gather evidence. +# +# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED; +# it accepts APPROVE with HTTP 200, silently files the review PENDING, +# and then 422s on submit. This one is unconditionally wrong on Gitea and +# is what a verdict silently failing to land looks like. +# +# Design constraint: this hook must not become something agents route around. +# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw +# curl for reads, for registry/manifest calls, and for endpoints with no +# wrapper (there are many) all pass untouched. +# +# Break-glass, for a genuine gap where no wrapper can express the call: +# MOSAIC_WRAPPER_OVERRIDE=1 +# Using it means "no wrapper covers this" — if that is wrong, the fix is to +# extend the wrapper, not to keep typing the override. +# +# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message. + +set -euo pipefail + +INPUT="$(cat)" +CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" +[ -z "$CMD" ] && exit 0 + +# Honour the override only when it is set in the command itself or the env. +case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac +[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 + +W="$HOME/.config/mosaic/tools/git" + +# ---- 1. checkout into $HOME ------------------------------------------------ +if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then + # Any argument that resolves under $HOME and is not under a work filesystem. + if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then + cat < # /src/-worktrees/ + ~/.config/mosaic/tools/git/mosaic-worktree.sh path # show where it would go + ~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task + +Worktrees, not clones: they share the object store, and \`git worktree list\` +makes every one of them enumerable — which is the only reason cleanup can +ever be safe. +EOF + exit 2 + fi +fi + +# ---- 2/3. provider API writes --------------------------------------------- +# Only consider calls that are (a) to a provider API path and (b) mutating. +is_api=0 +printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1 +if [ "$is_api" -eq 1 ]; then + is_write=0 + printf '%s' "$CMD" | grep -Eq -- '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]]*(POST|PATCH|PUT|DELETE)' && is_write=1 + # curl sends POST implicitly when given a body. + printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1 + + if [ "$is_write" -eq 1 ]; then + endpoint=""; wrapper="" + case "$CMD" in + *"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*) + endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;; + *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;; + *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;; + *"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;; + *"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;; + *"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;; + esac + + if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then + cat < undiscoverable -> FAIL) +# reverse every `.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=() + 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)) + else + suite_missing+=("$base") + fi + done + [ "$total" -eq 0 ] && continue + + local pct=$(( found * 100 / total )) + 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 ]; 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 < "$tmp/tools/git/documented-tool.sh" + printf '#!/bin/sh\n' > "$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/3 ok (complete index passes)\n' + else + printf 'self-test 1/3 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/3 ok (undocumented tool fails the gate)\n' + else + printf 'self-test 2/3 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/3 ok (stale index reference fails the gate)\n' + else + printf 'self-test 3/3 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1 + fi + + printf '\nself-test passed: the gate demonstrably reds on both drift directions.\n' +} + +if [ "$SELF_TEST" -eq 1 ]; then + self_test +else + resolve_locations + run_check +fi -- 2.54.0 From 96609bdade09c5e1fd8a440478b3c5bb5bc3ec52 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 16:54:18 -0500 Subject: [PATCH 02/24] framework: prove the wrapper guard both ways, and resolve its wrappers relatively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by running the guard rather than reading it. 1. The guard resolved its sibling wrappers through a hardcoded $HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed, and the guard fell through allowing the raw API write it exists to block. It failed OPEN, silently, in exactly the environment least likely to notice. It now resolves relative to its own path, so it names the wrappers from the install it was launched from, with $HOME as the fallback. 2. There was no test. Adding one surfaced the guard's other sharp edge immediately: it matches the literal text of the Bash command, so a harness that embeds a blocked pattern inline trips the guard on itself rather than on the fixture. That is the correct fail-closed posture and it is now recorded in the test's own comments, because the next person will hit it too. test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as hard as the blocked ones. A guard that over-blocks gets routed around and a guard that under-blocks is decoration; only pinning both edges keeps it useful. It is hermetic — no network, no credentials, no repository — so it joins the CI sanitization step directly rather than the exclusions file. --- .woodpecker/ci.yml | 5 ++ .../framework/tools/git/test-wrapper-guard.sh | 70 +++++++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 9 ++- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100755 packages/mosaic/framework/tools/git/test-wrapper-guard.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index b6b9e30c..ae488a73 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -56,6 +56,11 @@ steps: # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. - 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 # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh new file mode 100755 index 00000000..8c268ec2 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -0,0 +1,70 @@ +#!/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: TAB TAB +# 0 = allowed, 2 = blocked. +{ + 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 '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\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' +} > "$FIXTURES" + +fail=0 n=0 +while IFS=$'\t' read -r want payload why; do + [ -n "${want:-}" ] || continue + n=$((n + 1)) + printf '%s' "$payload" | "$GUARD" >/dev/null 2>&1 + got=$? + if [ "$got" = "$want" ]; then + printf 'ok %s\n' "$why" + else + printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got" + fail=1 + fi +done < "$FIXTURES" + +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" diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 685ea328..9e815c13 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -39,7 +39,14 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac [ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 -W="$HOME/.config/mosaic/tools/git" +# The wrappers this guard points at are its own siblings. Resolving relative to +# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard +# names the wrappers from the same install it was launched from, and that it +# still works from a repo checkout with no installed mosaic home (which is how it +# is exercised in CI). $HOME remains the fallback for a guard invoked by an +# absolute path from somewhere unusual. +W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git" # ---- 1. checkout into $HOME ------------------------------------------------ if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then -- 2.54.0 From 8b7ac5b51e32bb5d29be48b11b5ae7aea8034be6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 17:20:11 -0500 Subject: [PATCH 03/24] guard: close four fail-open holes found by independent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent reviewer broke all three new controls before they shipped. Every finding is reproduced as a fixture or a repro, because the class is recurring rather than incidental: each hole was a case where the answer was "allow" because something was ABSENT rather than because it was CHECKED. 1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space), `--request=POST` (equals form), and a URL path assembled from shell variables each carried a real provider write straight through. Write detection now covers every body and method form curl accepts, and the endpoint match no longer anchors on a literal host path that a variable can dissolve. 2. wrapper-guard blocked only when the wrapper FILE existed. A host with a broken or partial install therefore permitted exactly the raw writes the guard exists to stop. Blocking is now on the endpoint; a missing wrapper changes the remedy text, not the verdict — a broken install is not permission to bypass gate 7. 3. mosaic-worktree read a worktree's safety from two questions, and a clean, fully-pushed tree holding a gitignored `local.secret` answered both with zero. `git worktree remove` then deleted the one copy in existence. A file is gitignored precisely so nothing else holds it, so ignored-but-not- disposable files are now a third evidence question. Build junk (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common case still reads SAFE. 4. check-tools-index counted a documented tool as discoverable at mode 0644. Every caller tests `[ -x ]`, so a non-executable tool is a missing tool; it now fails the gate with its own message. Local gates green: sanitization, resident budget, test enumeration, tools-index (4/4 self-test, 100% on the enforced git suite), and wrapper-guard 20/20. --- .../framework/tools/git/mosaic-worktree.sh | 61 ++++++++++++---- .../framework/tools/git/test-wrapper-guard.sh | 13 ++++ .../framework/tools/git/wrapper-guard.sh | 71 +++++++++++++------ .../quality/scripts/check-tools-index.sh | 45 +++++++++--- 4 files changed, 146 insertions(+), 44 deletions(-) diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh index 7d230934..a770f798 100755 --- a/packages/mosaic/framework/tools/git/mosaic-worktree.sh +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -31,9 +31,11 @@ # mosaic-worktree.sh rm [--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 or with -# commits absent from every remote. That check is by EVIDENCE, never by size or -# age. --force overrides it and is yours to type deliberately. +# `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 . @@ -98,16 +100,41 @@ configuration, credentials, state and caches — not checkouts." ;; # 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)$' + wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; } wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; } +# Default --ignored (not =matching) so a 40k-file node_modules collapses to one +# directory entry instead of being enumerated and then discarded. +wt_precious() { + git -C "$1" status --porcelain --ignored 2>/dev/null \ + | awk '/^!! /{print substr($0,4)}' \ + | grep -Ev "$DISPOSABLE_RE" | head -200 | wc -l +} wt_state() { - local wt="$1" d u - d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")" - if [ "$d" -eq 0 ] && [ "$u" = "0" ]; then - printf 'SAFE\tclean; 0 unpushed' + 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' "$d" "$u" + printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p" fi } @@ -180,14 +207,18 @@ cmd_rm() { local path; path="$(derive_path "$branch")" [ -d "$path" ] || die "no worktree at $path" - local d u - d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")" - if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ]; }; then + 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 -Commit and push first — that is the contract. If this work is genuinely -disposable, re-run with --force." + 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). diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 8c268ec2..3782464e 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -39,6 +39,19 @@ FIXTURES="$TMP/fixtures.tsv" 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' } > "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 9e815c13..a3e8b445 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -74,35 +74,68 @@ EOF fi # ---- 2/3. provider API writes --------------------------------------------- -# Only consider calls that are (a) to a provider API path and (b) mutating. -is_api=0 -printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1 -if [ "$is_api" -eq 1 ]; then +# A raw provider write is four things at once: an HTTP client, a URL, a mutating +# verb or a request body, and a path fragment naming an endpoint a wrapper +# already owns. All four are required, which is what keeps reads and unwrapped +# endpoints flowing. +# +# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer +# broke that version in one line: build the path in shell variables +# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}" +# and the host-anchored literal never appears, so the check read clean while the +# write went through. The endpoint fragments below survive it, because the +# fragment has to appear somewhere for the URL to be constructible at all. +if printf '%s' "$CMD" | grep -Eq 'curl|wget|http(ie)?[[:space:]]' \ + && printf '%s' "$CMD" | grep -Eq 'https?://'; then + + # Write detection. Every spelling curl accepts, because the guard is defeated + # by the one spelling it does not know: `-d@body` (no space) and + # `--request=POST` (equals form) both slipped past the first version. is_write=0 - printf '%s' "$CMD" | grep -Eq -- '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]]*(POST|PATCH|PUT|DELETE)' && is_write=1 - # curl sends POST implicitly when given a body. - printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1 + printf '%s' "$CMD" | grep -Eq -- \ + '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1 + # curl sends POST implicitly when handed a body, in any of these forms. + printf '%s' "$CMD" | grep -Eq -- \ + '(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file' && is_write=1 if [ "$is_write" -eq 1 ]; then endpoint=""; wrapper="" case "$CMD" in *"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*) - endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;; - *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;; - *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;; - *"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;; - *"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;; - *"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;; + endpoint="pull-request review"; wrapper="pr-review.sh" ;; + *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;; + *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;; + *"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;; + *"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;; + *"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;; esac - if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then + # Block on the ENDPOINT, never on whether the wrapper file happens to exist. + # The previous version required `[ -x "$W/$wrapper" ]`, which meant a host + # with a broken or absent install allowed exactly the raw writes the guard + # exists to stop — an absence-driven allow, and the second one found in this + # file. A missing wrapper is a broken install; it is not a licence to bypass + # gate 7. Say so, and say which is which. + if [ -n "$endpoint" ]; then + if [ -x "$W/$wrapper" ]; then + remedy="Use the wrapper the Constitution (gate 7) requires: + + $W/$wrapper + +Run \`$wrapper --help\` for the flags." + else + remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT +present or not executable at: + + $W/$wrapper + +That is a broken or incomplete install, not permission to send the call raw. +Repair the install (\`mosaic doctor\`) and use the wrapper." + fi cat < "$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. @@ -231,18 +244,18 @@ self_test() { # 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/3 ok (complete index passes)\n' + printf 'self-test 1/4 ok (complete index passes)\n' else - printf 'self-test 1/3 FAIL (complete index should pass)\n'; return 1 + 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/3 ok (undocumented tool fails the gate)\n' + printf 'self-test 2/4 ok (undocumented tool fails the gate)\n' else - printf 'self-test 2/3 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1 + 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. @@ -250,12 +263,26 @@ self_test() { 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/3 ok (stale index reference fails the gate)\n' + printf 'self-test 3/4 ok (stale index reference fails the gate)\n' else - printf 'self-test 3/3 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1 + printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1 fi - printf '\nself-test passed: the gate demonstrably reds on both drift directions.\n' + # 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 -- 2.54.0 From 8a901cc19aa1e6088354bf6de4a466e295b10ba0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 17:26:09 -0500 Subject: [PATCH 04/24] guard: read command position, refuse unreadable URLs, survive pipefail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two of the same independent review. Three findings, all real, and the first two share a root cause: the guard was reading command TEXT as though it were a command. 1. Splitting the endpoint token itself defeats fragment matching outright — `a=/api/v1/repos/o/r/iss; b=ues/1/comments` leaves no fragment contiguous. Round one fixed one spelling of this and the reviewer produced the general form immediately. It is not winnable by more fragments: the endpoint does not exist until the shell expands it, and this hook runs first. So the guard stops pretending to read it. A write whose URL contains an expansion, on a visibly forge-shaped command, is now BLOCKED as unreadable — because "I could not find an endpoint" must not mean "there is no endpoint". Opaque URLs that are not forge-shaped (webhooks, artifact stores) still pass. 2. The broadened body detection false-blocked ordinary work: `grep -R "curl -d https://.../issues" docs/`, `echo "curl -d ..." > note.txt`, printing an example from python. Talking about a call is not making one, and this is the direction that actually kills a control — an over-blocking hook gets turned off, and an off hook permits everything. The client must now appear at COMMAND POSITION: line start or after a shell operator, optionally behind VAR=value. In every false positive it sat behind a quote instead. Quotes are deliberately NOT stripped before matching; real calls quote their URLs. 3. `wt_precious()` aborted `cmd_rm` under `set -euo pipefail`: `grep -v` exits 1 when it filters everything out, which is exactly the disposable-only case, so a SAFE worktree failed to remove with no message. Fixed, and the same defect was latent one step upstream in `wt_dirty()`, where `head -200` SIGPIPEs git on any worktree with 201 changed files. The cap is gone — counting is cheap and the cap only ever truncated output that is no longer printed. Seven new fixtures pin all of it, in both directions. 27/27. --- .../mosaic/framework/defaults/STANDARDS.md | 10 +-- .../framework/guides/TOOLS-REFERENCE.md | 74 +++++++++---------- .../framework/tools/git/mosaic-worktree.sh | 32 ++++++-- .../framework/tools/git/test-wrapper-guard.sh | 15 ++++ .../framework/tools/git/wrapper-guard.sh | 60 ++++++++++++++- 5 files changed, 143 insertions(+), 48 deletions(-) diff --git a/packages/mosaic/framework/defaults/STANDARDS.md b/packages/mosaic/framework/defaults/STANDARDS.md index a1cf2bc0..43d1c8ba 100644 --- a/packages/mosaic/framework/defaults/STANDARDS.md +++ b/packages/mosaic/framework/defaults/STANDARDS.md @@ -61,11 +61,11 @@ 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 | +| 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: diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 5ba36d1e..df2379ad 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -19,51 +19,51 @@ from a wrapper that was never written — which is how the APPROVE/APPROVED inci Every command takes `--help`. All of them accept `--login ` 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 | +| 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 | +| 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 | +| `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 | | -| --- | --- | +| Milestones | | +| --------------------- | ------------------ | | `milestone-create.sh` | Create a milestone | -| `milestone-list.sh` | List milestones | -| `milestone-close.sh` | Close 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 | +| 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 | | -| --- | --- | +| 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 | +| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | -| Workspace | | -| --- | --- | +| 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 | +| `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 ` takes a branch name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree, @@ -83,7 +83,7 @@ anyway, under five simultaneous conventions on a single host. Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and `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 +`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. `wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh index a770f798..5b3cf936 100755 --- a/packages/mosaic/framework/tools/git/mosaic-worktree.sh +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -118,14 +118,36 @@ configuration, credentials, state and caches — not checkouts." ;; # 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)$' -wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; } -wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; } +# 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() { - git -C "$1" status --porcelain --ignored 2>/dev/null \ - | awk '/^!! /{print substr($0,4)}' \ - | grep -Ev "$DISPOSABLE_RE" | head -200 | wc -l + 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() { diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 3782464e..60f51241 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -52,6 +52,21 @@ FIXTURES="$TMP/fixtures.tsv" 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' + # And the other direction, which is the failure mode that gets a hook deleted: + # discussing a call is not making one. In each of these the client sits behind + # a quote, never at command position. + printf '0\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tgrepping for an example is not calling it\n' + printf '0\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\twriting an example into a file is not calling it\n' + printf '0\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\tprinting an example is not calling it\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' } > "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index a3e8b445..35844b6d 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -85,7 +85,23 @@ fi # and the host-anchored literal never appears, so the check read clean while the # write went through. The endpoint fragments below survive it, because the # fragment has to appear somewhere for the URL to be constructible at all. -if printf '%s' "$CMD" | grep -Eq 'curl|wget|http(ie)?[[:space:]]' \ +# The client must be at COMMAND POSITION — start of the command, or directly +# after a shell operator, optionally behind VAR=value assignments. Substring +# presence is not enough, and this is the second thing review caught: with a +# bare substring test, +# grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/ +# echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt +# were both BLOCKED. Talking about a call is not making one. Over-blocking is +# not the safe direction here — a guard that blocks ordinary work gets switched +# off, and a guard that is off permits everything. +# +# Quoting is what separates the two: in every false positive the client sits +# immediately after a quote character, never after an operator. Note the +# deliberate absence of quote-stripping: the URL is frequently quoted in REAL +# calls, so stripping quotes before matching would blind the endpoint check. +CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\()[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(curl|wget|httpie|http)([[:space:]]|$)' + +if printf '%s' "$CMD" | grep -Eq "$CLIENT_AT_CMD_POS" \ && printf '%s' "$CMD" | grep -Eq 'https?://'; then # Write detection. Every spelling curl accepts, because the guard is defeated @@ -110,6 +126,48 @@ if printf '%s' "$CMD" | grep -Eq 'curl|wget|http(ie)?[[:space:]]' \ *"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;; esac + # A URL the guard cannot READ is a URL the guard must not CLEAR. + # + # Round one fixed one spelling of this and review immediately produced the + # general form: split the endpoint token itself across two variables — + # a=/api/v1/repos/o/r/iss; b=ues/1/comments + # curl -d@body "https://host${a}${b}" + # — and no fragment above ever appears contiguously. Chasing that with more + # fragments is unwinnable: the endpoint does not exist until the shell + # expands it, and this hook runs before that. + # + # So stop pretending to read it. If a write's URL contains an expansion, + # the guard has no endpoint to judge, and "no endpoint" must not mean + # "allowed" — that is the same absence-driven allow as the missing-wrapper + # case, wearing different clothes. + # + # Scoped to commands that are visibly forge-shaped, so an opaque webhook or + # artifact POST is untouched. A caller who splits `/api/` and the hostname + # as well does get through; that is no longer a mistake anyone makes by + # accident, and this hook stops mistakes. It is not a sandbox, and pretending + # otherwise is how you get a control nobody can trust the boundaries of. + if [ -z "$endpoint" ] \ + && printf '%s' "$CMD" | grep -Eq 'https?://[^[:space:]"'"'"'|;&)]*[$`]' \ + && printf '%s' "$CMD" | grep -Eq '/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo'; then + cat < Date: Wed, 12 Aug 2026 17:29:33 -0500 Subject: [PATCH 05/24] guard: judge command position on the code, not on the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The position test added an hour ago blocked its own author. The message being sent quoted one of the fixtures, so the quoted text contained an operator followed by a client, and an operator inside a string is not an operator. That is the reported over-blocking defect one level in, and it landed within an hour of shipping the fix for the reported one — which is the argument for pinning both directions as fixtures rather than reasoning about them. Position is now judged against a SKELETON: the command with its data spans (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection keep running against the full text, because real calls quote their URLs and a skeleton would be blind to them. The exception is what makes quotes data in the first place. If something is about to EXECUTE the quoted text — `bash -c`, `sh <> notes.md < "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 35844b6d..ab26c75b 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -85,23 +85,46 @@ fi # and the host-anchored literal never appears, so the check read clean while the # write went through. The endpoint fragments below survive it, because the # fragment has to appear somewhere for the URL to be constructible at all. -# The client must be at COMMAND POSITION — start of the command, or directly -# after a shell operator, optionally behind VAR=value assignments. Substring -# presence is not enough, and this is the second thing review caught: with a -# bare substring test, +# The client must be at COMMAND POSITION, and that has to be judged against the +# CODE in the command, not against its text. Review caught the text version +# blocking ordinary work: # grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/ # echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt -# were both BLOCKED. Talking about a call is not making one. Over-blocking is -# not the safe direction here — a guard that blocks ordinary work gets switched -# off, and a guard that is off permits everything. +# Talking about a call is not making one, and over-blocking is not the safe +# direction: a guard that blocks ordinary work gets switched off, and a guard +# that is off permits everything. # -# Quoting is what separates the two: in every false positive the client sits -# immediately after a quote character, never after an operator. Note the -# deliberate absence of quote-stripping: the URL is frequently quoted in REAL -# calls, so stripping quotes before matching would blind the endpoint check. +# A first fix required the client to follow a shell operator. That lasted until +# the author sent a message quoting one of these fixtures — the quoted text +# contained `... && GITEA_TOKEN=$T curl -d@b .../merge`, so an operator appeared +# INSIDE the quotes and the guard blocked the message. Same defect, one level +# in: an operator inside a string is not an operator. +# +# So the position test runs against a SKELETON — the command with its data spans +# (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection all +# still run against the FULL text, because real calls quote their URLs and a +# skeleton would be blind to them. +# +# The exception is the reason quotes are data at all: if something is about to +# EXECUTE the quoted text (`bash -c`, `sh < Date: Wed, 12 Aug 2026 17:39:05 -0500 Subject: [PATCH 06/24] wrapper-guard: judge command position on prefixes and on what a shell will execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-three review found two more absence-driven allows, both in the skeleton introduced by round two, and fixing them exposed a third the reviewer had not reached yet. 1. A word in front of a command does not displace the command. `env VAR=v curl`, `command curl`, `timeout 10 curl` and `/usr/bin/curl` were all real writes at execution position that a bare-name match could not see. The `env` form is the one that matters: it is what an agent reaches for to keep a credential out of the global environment, so the careful spelling was the invisible one. 2. Quoted data stops being data when a shell is about to execute it, and the first version knew only `bash -c`, `sh <<` and `eval`. It did not know the pipe, which is the form people actually use: `printf ... | sh`, `cat <> notes.md <> notes.md"}}\tnaming a command after echo is not running it\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 <> notes.md"}}\tan unrelated shell on another line does not promote quoted prose to code\n' } > "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index ab26c75b..0250a109 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -106,23 +106,50 @@ fi # skeleton would be blind to them. # # The exception is the reason quotes are data at all: if something is about to -# EXECUTE the quoted text (`bash -c`, `sh < Date: Wed, 12 Aug 2026 17:46:25 -0500 Subject: [PATCH 07/24] wrapper-guard: read the command the shell will run, and stop losing the client behind option values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-four review, three more absence-driven allows. 1. The guard read the command as TYPED. A backslash before a newline is removed before anything else happens, so an endpoint token split across the join (`.../iss\` + newline + `ues/1/comments`) executed the comments endpoint while the literal token never appeared in the text. Continuations are now joined before every check, because the joined form IS the command. This is the same defect as the split-across-variables case, minus the excuse: there the token genuinely does not exist until the shell expands it, here it was sitting in the input the whole time and the guard chose the wrong reading of it. 2. Transparent prefixes take option VALUES. `sudo -u root curl` hid a live write because `root` was a word the prefix list did not know. Enumerating option grammars per prefix is the wrong game, so what is skipped is an option and at most one value for it, plus a bare duration for `timeout` — never an arbitrary word. `xargs echo curl ...` therefore stays ALLOWED, because there the command is echo and the client is its argument. 3. `find -exec` runs the client. It opens command position the same way an operator does, and now reads that way. All seven reviewer repros are fixtures, each with its counter-case in the allowed direction: a continuation inside a heredoc document stays a document, `xargs echo curl` stays allowed, `sudo apt-get install curl` stays allowed, a prefixed READ stays allowed. 48/48, and the 18-command ordinary sweep still blocks none. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), prettier. --- .../framework/tools/git/test-wrapper-guard.sh | 16 ++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 21 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index aaaf6587..52dd6d21 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -95,6 +95,22 @@ FIXTURES="$TMP/fixtures.tsv" # the whole command into code because it contains an unrelated `sh -c` is how # this blocked its author a second time. printf '0\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 does not promote quoted prose to code\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 '0\t{"tool_input":{"command":"cat >> notes.md < "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 0250a109..cd539736 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -35,6 +35,15 @@ INPUT="$(cat)" CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" [ -z "$CMD" ] && exit 0 +# Read the command the SHELL will run, not the text as typed. A backslash before +# a newline is removed before anything else happens, so +# curl -d@b https://host/api/v1/repos/a/b/iss\ +# ues/1/comments +# executes the comments endpoint while the literal token `issues` never appears +# in the text. Every check below — position, URL, body, endpoint — reads the +# joined form, because that is the command. +CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" + # Honour the override only when it is set in the command itself or the env. case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac [ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 @@ -146,10 +155,18 @@ SKEL="$(printf '%s' "$CMD" | awk -v inv="$SHELL_EXECUTES_DATA" ' # `timeout 10 curl`, `/usr/bin/curl`. The `env` form matters most, because it is # precisely what an agent reaches for to keep a credential out of the global # environment — the careful spelling was the invisible one. -CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|[0-9]+[smhd]?|-[^[:space:]]+)[[:space:]]+' +CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|watch)[[:space:]]+' +# Their options take values: `sudo -u root curl` hid a live write because `root` +# was a word the list did not know. What is skipped is an OPTION and at most one +# value for it — not any word — so `xargs echo curl ...` stays allowed, because +# there `echo` is the command and curl is its argument. Plus a bare duration, +# which is `timeout`'s operand. +PREFIX_OPT='-[^[:space:]]+[[:space:]]+([^-][^[:space:]|;&(){}<>]*[[:space:]]+)?' +PREFIX_ARG='('"$PREFIX_OPT"'|[0-9]+[smhd]?[[:space:]]+)' # A path in front of the client is still the client. CLIENT='([^[:space:]]*/)?(curl|wget|httpie|http)' -CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\()[[:space:]]*('"$CMD_PREFIX"')*'"$CLIENT"'([[:space:]]|$)' +# `find -exec` opens command position the same way an operator does. +CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\(|-execdir|-exec)[[:space:]]*('"$CMD_PREFIX"'('"$CMD_PREFIX"'|'"$PREFIX_ARG"')*)?'"$CLIENT"'([[:space:]]|$)' if printf '%s' "$SKEL" | grep -Eq "$CLIENT_AT_CMD_POS" \ && printf '%s' "$CMD" | grep -Eq 'https?://'; then -- 2.54.0 From b1254f52f32ec764d6211de9ecc3844d34eb4515 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 17:59:14 -0500 Subject: [PATCH 08/24] =?UTF-8?q?wrapper-guard:=20judge=20the=20payload,?= =?UTF-8?q?=20not=20the=20caller=20=E2=80=94=20delete=20the=20code/data=20?= =?UTF-8?q?parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-five review found command substitution executing inside the very quoted spans the skeleton was discarding as prose: echo "$(curl -d@b .../issues/1/comments)" msg="$(curl -d@b .../issues/1/comments)" The unquoted and process-substitution forms already blocked, so the same call was refused or allowed depending on a quote character. That makes it a classification defect rather than another spelling, and it is the nineteenth write to reach execution through this file by the same route: the client was ABSENT from the skeleton, so the guard allowed. The reviewer's judgement, which I asked for and accept: this is fitting to the test set. Answering "code or data" from shell text with sed and awk is not a hard problem, it is the wrong problem. It was also not portable. CI has been red at `sanitization` since round four, and the log says why: under the image's busybox awk the octal escape in the quote-stripping regex does not bite, every quoted span survives into the skeleton, and the guard began refusing ordinary prose. Five allow-direction fixtures failed in CI that pass under GNU awk. A control that reverses its verdict with the awk on the host is not a control. So the client detection is gone — the skeleton, the invoker list, the prefix list, the option-value skipping, all of it. What remains asks two questions of the text: is this a write, and does it name an endpoint a wrapper owns. It cannot fail open by hiding the caller because it never looks for one, and it now catches clients it was never taught: `python -c ... requests.post(...)` and `wget --post-data` are both fixtures. The cost is stated in the file and pinned in both directions: QUOTING one of these calls on a Bash command line is refused as well. Ten fixtures that used to assert "discussing a call is not making one" now assert the opposite, and the boundary that stops this becoming block-everything is asserted just as hard — a quoted READ, an endpoint named without a body flag, a quoted write to an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The 18-command ordinary-work sweep blocks none. The rule an agent can hold without a parser: do not put a raw write to a wrapped forge endpoint on a Bash command line, not even inside quotes. Write the example with a file-writing tool. 60/60 fixtures, verified inside the CI image (busybox) as well as locally. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), issue-close, prettier. --- .../framework/tools/git/test-wrapper-guard.sh | 80 ++++++---- .../framework/tools/git/wrapper-guard.sh | 150 ++++++++---------- 2 files changed, 115 insertions(+), 115 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 52dd6d21..150a5f9f 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -59,58 +59,84 @@ FIXTURES="$TMP/fixtures.tsv" 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' - # And the other direction, which is the failure mode that gets a hook deleted: - # discussing a call is not making one. In each of these the client sits behind - # a quote, never at command position. - printf '0\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tgrepping for an example is not calling it\n' - printf '0\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\twriting an example into a file is not calling it\n' - printf '0\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\tprinting an example is not calling it\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' - # --- and the case the AUTHOR hit, one level in from the reported one: an - # operator INSIDE a quoted string is not an operator. This blocked a message - # that merely quoted the fixture above. Position is judged on the skeleton. - printf '0\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\\""}}\tan operator inside a quoted string is not an operator\n' - printf '0\t{"tool_input":{"command":"cat >> notes.md <> notes.md <> notes.md"}}\tnaming a command after echo is not running it\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 <> notes.md"}}\tan unrelated shell on another line does not promote quoted prose to code\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 '0\t{"tool_input":{"command":"cat >> notes.md <> notes.md < "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index cd539736..6e2370cd 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -22,6 +22,10 @@ # curl for reads, for registry/manifest calls, and for endpoints with no # wrapper (there are many) all pass untouched. # +# One consequence is worth knowing before it surprises you: it judges the +# payload, not the caller, so a command that merely QUOTES such a write is +# refused as well. See the long note at section 2 for why that trade was made. +# # Break-glass, for a genuine gap where no wrapper can express the call: # MOSAIC_WRAPPER_OVERRIDE=1 # Using it means "no wrapper covers this" — if that is wrong, the fix is to @@ -83,103 +87,73 @@ EOF fi # ---- 2/3. provider API writes --------------------------------------------- -# A raw provider write is four things at once: an HTTP client, a URL, a mutating -# verb or a request body, and a path fragment naming an endpoint a wrapper -# already owns. All four are required, which is what keeps reads and unwrapped -# endpoints flowing. +# A raw provider write this guard cares about is two things: a WRITE, and a URL +# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they +# are how you gather evidence — and the many endpoints with no wrapper flow +# through. # -# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer -# broke that version in one line: build the path in shell variables -# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}" -# and the host-anchored literal never appears, so the check read clean while the -# write went through. The endpoint fragments below survive it, because the -# fragment has to appear somewhere for the URL to be constructible at all. -# The client must be at COMMAND POSITION, and that has to be judged against the -# CODE in the command, not against its text. Review caught the text version -# blocking ordinary work: -# grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/ -# echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt -# Talking about a call is not making one, and over-blocking is not the safe -# direction: a guard that blocks ordinary work gets switched off, and a guard -# that is off permits everything. +# It deliberately does NOT ask which program makes the call, or whether that +# program sits at shell command position. It used to, and that is the whole +# history of this file. Answering "is this code or is this data" from the text +# of a shell command required a skeleton with quoted spans and heredoc bodies +# removed, an invoker list for the forms where a shell executes quoted text, a +# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and +# backslash-newline joining. Five rounds of adversarial review put nineteen +# writes straight through it, and every one had the same shape: the client was +# ABSENT from the skeleton, so the guard allowed. Variables, line continuations, +# command prefixes, option values, pipes into a shell, and finally command +# substitution inside the very quotes the skeleton was discarding: +# echo "$(curl -d@b .../issues/1/comments)" +# msg="$(curl -d@b .../issues/1/comments)" +# Classifying code against data in shell text with sed and awk is not a hard +# problem, it is the wrong problem. It was not even portable: under CI's busybox +# awk the quote-stripping silently failed, the skeleton kept every quoted span, +# and the guard started refusing ordinary prose instead — which is the other way +# a control like this dies. # -# A first fix required the client to follow a shell operator. That lasted until -# the author sent a message quoting one of these fixtures — the quoted text -# contained `... && GITEA_TOKEN=$T curl -d@b .../merge`, so an operator appeared -# INSIDE the quotes and the guard blocked the message. Same defect, one level -# in: an operator inside a string is not an operator. +# So the client detection is gone, and with it that entire failure class: what +# is left cannot fail open by hiding the caller, because it never looks for one. +# It looks for the payload. Something that names a wrapped endpoint and carries +# a body is refused however it is spelled — curl, wget, `python -c`, or a form +# nobody has thought of yet. # -# So the position test runs against a SKELETON — the command with its data spans -# (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection all -# still run against the FULL text, because real calls quote their URLs and a -# skeleton would be blind to them. +# The cost is real and belongs in the open, because over-blocking is how a hook +# gets switched off: QUOTING one of these calls on a Bash command line now +# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing +# an example into a file. There is no textual way to tell a quoted example from +# a quoted command — that is exactly the finding above — so the rule is the one +# an agent can hold in mind without a parser: # -# The exception is the reason quotes are data at all: if something is about to -# EXECUTE the quoted text, then the quotes hold code and the skeleton is the -# full text again. The first version of this list named only `bash -c`, `sh <<` -# and `eval`, and review immediately produced the spellings it did not know: -# printf '%s\n' 'curl -d@b .../comments' | sh -# cat < Date: Wed, 12 Aug 2026 18:10:19 -0500 Subject: [PATCH 09/24] wrapper-guard: close the absence shape at the new boundary; stop giving wrong advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round six deleted the code/data parser and scoped what remained on `https?://`. Review found the failure class had not been eliminated, only relocated: a raw provider CLI carries no scheme, so the scope gate answered "not my business" because the URL was ABSENT — the same shape, at the new boundary. gh api -X POST repos/a/b/issues -f title=x -f body=y gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE tea api -X POST repos/a/b/issues/1/comments -f body=x curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues All four were real writes to endpoints a wrapper owns, and all four passed. Constitution gate 7 names raw provider CLIs explicitly, so they are in scope rather than something to narrow the docs around. The scope gate now also triggers on `/api/v{n}` and on the `api` subcommand of the provider CLIs, and `-f key=value` joins curl's `-d` as an implicit POST. Adding triggers to a scope gate can only make it stricter — it cannot open a new hole — which is why this is a list of shapes rather than a model of any one caller. The boundary is stated in the file rather than left to be discovered: provider PORCELAIN (`tea pulls create`) is NOT covered, because catching it means modelling every CLI's verb grammar, which is the parser mistake wearing a new costume. That is a wrapper-and-review gap, not a thing this hook can hold. Second finding, and the one I had flagged as my own worry: the endpoint `case` was prefix-greedy, so `/issues/1/labels` blocked with "use issue-create.sh" — the wrong wrapper for that call. A block an agent cannot comply with is worse than no block, because it teaches that the hook is broken and the override is routine, and an override that is routine is a guard that is off. Issue and PR subresources now flow through, exactly as /releases and every other endpoint no wrapper owns already does. This hook enforces "use the wrapper"; where there is no wrapper it has nothing to enforce, and the gap belongs in the wrapper set. I am departing from the review on that one deliberately: the review held that blocking is correct there and only the remediation wrong. Naming a wrapper gap in a refusal keeps gate-7 pressure, but it makes the override the normal path for every labels and assignees call, which spends the override's meaning on the cases where it is least needed. Also: the APPROVE trap now catches the provider-CLI spelling `-f event=APPROVE` alongside the JSON body, and still never matches the correct value APPROVED. 79/79 fixtures, locally and inside the CI image, with both blockers pinned in both directions — the four repros block and name the right wrapper, while a provider-CLI read, an unwrapped endpoint reached through one, porcelain, and the `rm -f`/`grep -f` collisions all still pass. The 18-command ordinary sweep blocks the same three round-six flips and nothing new, so the broader gate cost nothing on ordinary work. Second over-block documented rather than found: prose carrying `.post(` near a wrapped URL is refused, which follows from judging the payload and is now stated next to the quoted-curl cost. Gates: sanitization (all eight commands green in ci-base), shellcheck clean. --- .../framework/tools/git/test-wrapper-guard.sh | 33 ++++++++++++ .../framework/tools/git/wrapper-guard.sh | 53 +++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 150a5f9f..544f8313 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -137,6 +137,39 @@ FIXTURES="$TMP/fixtures.tsv" # 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. No wrapper + # owns an issue/PR subresource, so these flow through like every other + # unwrapped endpoint — and the two arms above them must keep blocking. + printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/labels"}}\tno wrapper owns issue labels, so there is nothing to enforce\n' + printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/assignees"}}\tnor assignees\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 either; only creating one does\n' + printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tand the wrapped subresource must not fall through the arm above it\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\n' + printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\tPR labels are unwrapped on the same reasoning as issue labels\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\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' } > "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 6e2370cd..b9cc393a 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -132,8 +132,28 @@ fi # mistakes; it is not a sandbox, and pretending otherwise is how you get a # control nobody can trust the boundaries of. # -# Scoped to commands carrying a URL, so nothing without one is even considered. -if printf '%s' "$CMD" | grep -Eq 'https?://'; then +# Scoped to commands that are provider-API-shaped, so nothing else is even +# considered. The first version of this scope gate asked only for `https?://`, +# and review found the absence shape had simply moved to the new boundary: +# gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE +# tea api -X POST repos/a/b/issues/1/comments -f body=x +# curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues +# all carry a real write to a wrapped endpoint and none carries a scheme, so the +# guard never asked the write question at all. Gate 7 covers raw provider CLIs, +# so these are in scope and the gate now names the shapes they come in. +# +# Adding alternatives to a scope gate can only make it stricter — it cannot +# create a new allow — which is why this is a list of triggers rather than a +# model of any one caller. +# +# Boundary, deliberate and worth stating: this covers the `api` subcommand, +# which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`, +# `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb +# grammar, which is the parser mistake again in a new costume. Porcelain is a +# gate-7 gap for prose and review to hold, not this hook. +API_SHAPED='https?://|/api/v[0-9]' +API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' +if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then # Write detection, now client-agnostic. Every spelling curl accepts, because # the guard is defeated by the one spelling it does not know: `-d@body` (no @@ -151,7 +171,18 @@ if printf '%s' "$CMD" | grep -Eq 'https?://'; then # curl sends POST implicitly when handed a body, in any of these forms. printf '%s' "$CMD" | grep -Eq -- \ '(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1 + # The provider CLIs POST implicitly the same way curl does, when handed a + # field. Matched only in `-f key=value` shape, so the far more common `rm -f` + # and `grep -f` cannot be read as a body. + printf '%s' "$CMD" | grep -Eq -- \ + '(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*=|--input[[:space:]=]' && is_write=1 # ...and a library call is a write without any flag at all. + # + # Second documented over-block, and broader than the body flags because it + # needs no flag: any text carrying `.post(` near a wrapped URL is refused, + # including prose that merely quotes it. That follows from the same rule as + # the quoted-curl cost above — the payload is judged, not the caller — and it + # is stated here so it is a known boundary rather than a surprise. printf '%s' "$CMD" | grep -Eq -- \ '\.(post|put|patch|delete)\(' && is_write=1 @@ -162,6 +193,19 @@ if printf '%s' "$CMD" | grep -Eq 'https?://'; then endpoint="pull-request review"; wrapper="pr-review.sh" ;; *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;; *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;; + # Any OTHER path UNDER a numbered issue or PR — labels, assignees, times, + # a comment edit at /issues/comments/{id}, a PATCH of the issue itself. + # No wrapper owns these, and this arm exists so the guard does not claim + # one does: before it, /issues/1/labels fell through to the generic arm + # below and was refused with "use issue-create.sh", which is the wrong + # call. Wrong remediation is worse than no remediation, because a block an + # agent cannot comply with teaches it that the hook is broken and the + # override is routine — and an override that is routine is a guard that is + # off. So these flow through, exactly as /releases and every other + # unwrapped endpoint already does. That is the standing rule here: this + # hook enforces "use the wrapper", and where there is no wrapper it has + # nothing to enforce. The gap belongs in the wrapper set, not in a block. + *"/issues/"?*|*"/pulls/"?*) : ;; *"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;; *"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;; *"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;; @@ -251,7 +295,10 @@ EOF fi # ---- 3. the APPROVE/APPROVED trap, wherever it appears --------------------- -if printf '%s' "$CMD" | grep -Eq '"event"[[:space:]]*:[[:space:]]*"APPROVE"'; then +# Both spellings the trap arrives in: the JSON body `"event": "APPROVE"` and the +# provider-CLI field `-f event=APPROVE`. The trailing [^A-Z] is what keeps the +# correct value out of it — APPROVED must never match. +if printf '%s' "$CMD" | grep -Eq 'event"?[[:space:]]*[=:][[:space:]]*"?APPROVE([^A-Z]|$)'; then cat < Date: Wed, 12 Aug 2026 18:25:13 -0500 Subject: [PATCH 10/24] wrapper-guard: make subresource ownership an inventory, and check the advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round seven fixed "wrong wrapper advice" by letting every path under a numbered issue or PR flow through, on the stated reasoning that no wrapper owned any of them. Review checked that reasoning against the directory and it was false: gh api -X PATCH repos/a/b/issues/1 -f title=x curl -X PATCH -d @b https://host/api/v1/repos/a/b/issues/1 gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u issue-edit.sh takes --title/--body/--labels/--milestone and issue-assign.sh takes assignee/labels/milestone, so all four are wrapped calls and all four returned 0. The guard answered "allow" because wrapper ownership had been ASSUMED absent rather than looked up — the same absence-driven allow this file exists to remove, committed inside the fix for it. I withdraw the round-seven departure: the reviewer's position was right on the evidence, and my argument for it was sound reasoning applied to a fact I never checked. The endpoint map is now an inventory read off tools/git/*.sh and their flags: assignees to issue-assign.sh, labels to issue-edit.sh (naming issue-assign.sh alongside it, since both set them), a numbered issue to issue-edit.sh (naming issue-close.sh/issue-reopen.sh for state), a numbered PR to pr-close.sh (with the PR title/body gap stated in the message rather than papered over), and /milestones/{n} to milestone-close.sh instead of the create wrapper. The residue is defined by SUBTRACTION, not by listing provider API surface: everything a wrapper owns is consumed by an arm above, so a numbered path that reaches the end is owned by nothing and still flows through — times, stopwatch, reactions, a comment edit at /issues/comments/{id}. A list would rot the moment a provider adds an endpoint, and rot in the blocking direction with wrong advice. That residue test is a regex, deliberately. `case` globs cannot express a path SEGMENT, so the natural allow arm *"/issues/"[0-9]*"/"* clears gh api -X PATCH repos/a/b/issues/1 -f body="see /docs" on the strength of a slash inside the body. An allow decided by a glob over the whole command is the fail-open shape again; the regex pins the segment to the number, and that command is pinned as a fixture. Also: `-f labels[]=bug` was not read as a body at all, because the key class stopped at the bracket. The array spelling is what the provider CLIs use for repeated fields, so an implicit POST carrying only array fields was invisible. And the reason six rounds of this were invisible: the harness read the exit code and nothing else, so a block naming the WRONG wrapper passed every run. Fixtures may now state the wrapper the message must name, and the wrapped ones do. The assertion was negative-controlled — pointing one fixture at the wrong wrapper fails that fixture and only that fixture. 89/89 (was 79), locally and in ci-base. All eight sanitization commands green in-image. The 18-command ordinary sweep blocks the same three round-six flips and nothing new, so the tighter map cost nothing on ordinary work. Gates: sanitization (all eight green in ci-base), shellcheck clean at warning+. --- .../framework/tools/git/test-wrapper-guard.sh | 59 +++++++--- .../framework/tools/git/wrapper-guard.sh | 106 +++++++++++++++--- 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 544f8313..d42cc12c 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -25,7 +25,10 @@ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT FIXTURES="$TMP/fixtures.tsv" # Each line: TAB TAB -# 0 = allowed, 2 = blocked. +# [ TAB ] +# 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' @@ -154,16 +157,31 @@ FIXTURES="$TMP/fixtures.tsv" 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. No wrapper - # owns an issue/PR subresource, so these flow through like every other - # unwrapped endpoint — and the two arms above them must keep blocking. - printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/labels"}}\tno wrapper owns issue labels, so there is nothing to enforce\n' - printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/assignees"}}\tnor assignees\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 either; only creating one does\n' - printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tand the wrapped subresource must not fall through the arm above it\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\n' - printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\tPR labels are unwrapped on the same reasoning as issue labels\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\n' + # "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' + # The residue: still genuinely owned by nothing, and still flowing through. + 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' # The APPROVE trap, in the spelling a provider CLI uses, and the value that # must never trip it. @@ -173,17 +191,26 @@ FIXTURES="$TMP/fixtures.tsv" } > "$FIXTURES" fail=0 n=0 -while IFS=$'\t' read -r want payload why; do +while IFS=$'\t' read -r want payload why remedy; do [ -n "${want:-}" ] || continue n=$((n + 1)) - printf '%s' "$payload" | "$GUARD" >/dev/null 2>&1 + out="$(printf '%s' "$payload" | "$GUARD" 2>&1)" got=$? - if [ "$got" = "$want" ]; then - printf 'ok %s\n' "$why" - else + 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" printf '\n' diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index b9cc393a..cc745ac6 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -173,9 +173,13 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then '(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1 # The provider CLIs POST implicitly the same way curl does, when handed a # field. Matched only in `-f key=value` shape, so the far more common `rm -f` - # and `grep -f` cannot be read as a body. + # and `grep -f` cannot be read as a body. The trailing `[]` is the array + # spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and + # without it the key class stopped at the bracket and the field was not seen + # as a body at all — found while pinning the labels/assignees repros, both of + # which carry it. printf '%s' "$CMD" | grep -Eq -- \ - '(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*=|--input[[:space:]=]' && is_write=1 + '(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1 # ...and a library call is a write without any flag at all. # # Second documented over-block, and broader than the body flags because it @@ -187,30 +191,90 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then '\.(post|put|patch|delete)\(' && is_write=1 if [ "$is_write" -eq 1 ]; then - endpoint=""; wrapper="" + # The endpoint map, and the rule that keeps it honest: an arm exists here + # ONLY because a wrapper in this directory owns that call. It is an + # inventory, not a model — read off `ls tools/git/*.sh` and the flags each + # script accepts, so it can be re-derived and checked rather than believed. + # + # /pulls/{n}/reviews, /requested_reviewers pr-review.sh + # /pulls/{n}/merge pr-merge.sh + # /issues/{n}/comments issue-comment.sh (create) + # /issues|pulls/{n}/assignees issue-assign.sh + # /issues|pulls/{n}/labels issue-edit.sh, issue-assign.sh + # /milestones/{n} milestone-close.sh + # /issues/{n} issue-edit.sh; issue-close.sh, + # issue-reopen.sh for state + # /pulls/{n} pr-close.sh (state only) + # /pulls, /issues, /milestones the create wrappers + # + # Round seven had a single arm allowing EVERY path under a numbered issue or + # PR, on the reasoning that no wrapper owned any of them. Review showed that + # was false in this tree — issue-edit.sh takes --title/--body/--labels/ + # --milestone and issue-assign.sh takes assignee/labels/milestone — so the + # guard was answering "allow" because wrapper ownership had been ASSUMED + # absent instead of looked up. That is the same absence-driven allow the + # whole file exists to remove, committed inside the fix for it. The lesson + # is not "block more"; it is that ownership is an inventory question and an + # inventory has to be read. + # + # Wrong advice remains its own defect — a block an agent cannot comply with + # teaches that the hook is broken and the override is routine, and a routine + # override is a guard that is off. So the fix is precision in BOTH + # directions: every arm names the wrapper that actually owns the call, and + # anything genuinely unowned still flows through (below). + endpoint=""; wrapper=""; alsoown="" case "$CMD" in *"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;; *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;; + # A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the + # numbered issue, not a child of it. issue-comment.sh only creates, so + # nothing owns this one. Placed above the create arm so it cannot be + # refused with "use issue-comment.sh", which would be the wrong call. + *"/issues/comments/"*) : ;; *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;; - # Any OTHER path UNDER a numbered issue or PR — labels, assignees, times, - # a comment edit at /issues/comments/{id}, a PATCH of the issue itself. - # No wrapper owns these, and this arm exists so the guard does not claim - # one does: before it, /issues/1/labels fell through to the generic arm - # below and was refused with "use issue-create.sh", which is the wrong - # call. Wrong remediation is worse than no remediation, because a block an - # agent cannot comply with teaches it that the hook is broken and the - # override is routine — and an override that is routine is a guard that is - # off. So these flow through, exactly as /releases and every other - # unwrapped endpoint already does. That is the standing rule here: this - # hook enforces "use the wrapper", and where there is no wrapper it has - # nothing to enforce. The gap belongs in the wrapper set, not in a block. - *"/issues/"?*|*"/pulls/"?*) : ;; + *"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*) + endpoint="issue assignee"; wrapper="issue-assign.sh" ;; + *"/issues/"*"/labels"*|*"/pulls/"*"/labels"*) + endpoint="issue label"; wrapper="issue-edit.sh" + alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;; + *"/milestones/"[0-9]*) endpoint="milestone state"; wrapper="milestone-close.sh" ;; + # The numbered object itself. These two arms are the fuzzy ones — they + # match a number and then anything — so they are refined immediately + # below rather than trusted as written. + *"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh" + alsoown="issue-close.sh and issue-reopen.sh own the state change." ;; + *"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh" + alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and +milestone are the ISSUE object on both providers, so issue-edit.sh and +issue-assign.sh own those at the same number. Nothing wraps a PR title/body +edit — that one is a real wrapper gap, and the override exists for it." ;; *"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;; *"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;; *"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;; esac + # Refine the two fuzzy arms, and note WHY this is a regex and not another + # case arm: `case` globs cannot express a path SEGMENT, so an allow arm + # written as *"/issues/"[0-9]*"/"* would clear + # gh api -X PATCH repos/a/b/issues/1 -f body="see /docs" + # on the strength of a slash inside the body. An allow decided by a glob + # over the whole command is exactly the fail-open shape this file keeps + # finding; the regex pins the segment to the number. + # + # The residue is defined by SUBTRACTION rather than by listing provider API + # surface: every subresource a wrapper owns was consumed by an arm above, so + # whatever still carries /issues|pulls/{n}/ here is owned by + # nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's + # files or commits. Listing them instead would rot the moment a provider + # adds one, and rot in the blocking direction with wrong advice. + case "$endpoint" in + "issue edit"|"pull-request edit") + if printf '%s' "$CMD" | grep -Eq '/(issues|pulls)/[0-9]+/[A-Za-z_]'; then + endpoint=""; wrapper=""; alsoown="" + fi ;; + esac + # A URL the guard cannot READ is a URL the guard must not CLEAR. # # Round one fixed one spelling of this and review immediately produced the @@ -266,6 +330,13 @@ EOF $W/$wrapper Run \`$wrapper --help\` for the flags." + # Several wrappers can own one endpoint (labels are settable from both + # issue-edit.sh and issue-assign.sh; state has its own pair). Naming + # only one of them is how a correct block still ends up reading as + # wrong advice, so say which wrapper owns which part of the call. + [ -n "$alsoown" ] && remedy="$remedy + +$alsoown" else remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT present or not executable at: @@ -274,6 +345,9 @@ present or not executable at: That is a broken or incomplete install, not permission to send the call raw. Repair the install (\`mosaic doctor\`) and use the wrapper." + [ -n "$alsoown" ] && remedy="$remedy + +$alsoown" fi cat < Date: Wed, 12 Aug 2026 18:36:23 -0500 Subject: [PATCH 11/24] wrapper-guard: an arm may only claim the span its wrapper actually covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round eight replaced an absence-driven allow with an endpoint inventory, and review found the inventory answered the wrong question. It recorded which wrapper TOUCHES an endpoint, when the sound question is whether the wrapper SPANS it. A PATCH to a numbered milestone blocked with "use milestone-close.sh". That wrapper takes only -t and sends state=closed on both the gh and tea paths, so it cannot express a title, description or due-date edit. The block was correct and the advice was not — the same remediation-accuracy defect as the round-seven subresource arm, one step quieter, because a wrapper was rounded up from owning a slice to owning the endpoint. The treatment already existed two arms away: /pulls/{n} named pr-close.sh for state and said in the message that a PR title/body edit is a real wrapper gap. So this was a consistency failure rather than a missing idea, which is why the fix is not just the reported arm. Auditing every arm for span against the flags each script accepts found a second bad one that review had not reached: /pulls/{n}/requested_reviewers was mapped to pr-review.sh, and pr-review.sh takes -a <action> -c <comment> and files a verdict. Nothing in this tree adds a requested reviewer, so that arm was advertising a wrapper that cannot make the call. It is unowned and now flows through, like a comment edit. Changes: - /milestones/{n} keeps blocking, and the message states that milestone-close.sh owns the close only while title/description/due-date is a wrapper gap. - /pulls/{n}/requested_reviewers becomes residue, above the reviews arm so it cannot be refused with "use pr-review.sh". - /issues/{n} now also names issue-assign.sh, which owns the assignee field; issue-edit.sh has no assignee flag, so the old advice was short by one wrapper for a PATCH that sets one. - The map comment carries a span column, so a future arm has to state what its wrapper covers rather than imply all of it. Fixtures assert the span language, not just the wrapper name: the milestone edit must say it owns the close only, and a PATCH setting an assignee must name issue-assign.sh. Negative-controlled — reverting each of the three behaviours fails that fixture and only that fixture. 92/92 (was 89), locally and in ci-base. shellcheck clean at warning+. The 18-command ordinary sweep blocks the same three round-six flips and nothing new. Gates: fixtures 92/92 in ci-base, shellcheck clean at warning+. --- .../framework/tools/git/test-wrapper-guard.sh | 9 +++ .../framework/tools/git/wrapper-guard.sh | 61 ++++++++++++++----- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index d42cc12c..accbb444 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -170,7 +170,16 @@ FIXTURES="$TMP/fixtures.tsv" 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' 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' diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index cc745ac6..4881fc6b 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -195,17 +195,30 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then # ONLY because a wrapper in this directory owns that call. It is an # inventory, not a model — read off `ls tools/git/*.sh` and the flags each # script accepts, so it can be re-derived and checked rather than believed. + # Second column is what the wrapper SPANS; a partial span must be stated in + # the block message, never rounded up to ownership of the whole endpoint. # - # /pulls/{n}/reviews, /requested_reviewers pr-review.sh - # /pulls/{n}/merge pr-merge.sh - # /issues/{n}/comments issue-comment.sh (create) - # /issues|pulls/{n}/assignees issue-assign.sh - # /issues|pulls/{n}/labels issue-edit.sh, issue-assign.sh - # /milestones/{n} milestone-close.sh - # /issues/{n} issue-edit.sh; issue-close.sh, - # issue-reopen.sh for state - # /pulls/{n} pr-close.sh (state only) - # /pulls, /issues, /milestones the create wrappers + # /pulls/{n}/reviews pr-review.sh full + # /pulls/{n}/merge pr-merge.sh full (-m method, -d) + # /issues/{n}/comments issue-comment.sh create only (POST) + # /issues|pulls/{n}/assignees issue-assign.sh full (-a, -r) + # /issues|pulls/{n}/labels issue-edit.sh sets the whole list; + # issue-assign.sh -l same + # /milestones/{n} milestone-close.sh CLOSE ONLY — title, + # description, due date + # are a wrapper gap + # /issues/{n} issue-edit.sh title/body/labels/ + # milestone; close/reopen + # for state; assignee is + # issue-assign.sh + # /pulls/{n} pr-close.sh state only — title and + # body are a wrapper gap + # /pulls, /issues, the create wrappers full + # /milestones + # + # Owned by nothing, so they flow through: /issues/comments/{id} (a comment + # EDIT), /pulls/{n}/requested_reviewers, and the residue caught by + # subtraction below. # # Round seven had a single arm allowing EVERY path under a numbered issue or # PR, on the reasoning that no wrapper owned any of them. Review showed that @@ -222,10 +235,25 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then # override is a guard that is off. So the fix is precision in BOTH # directions: every arm names the wrapper that actually owns the call, and # anything genuinely unowned still flows through (below). + # + # Round eight got the inventory right and the SPAN wrong, which review caught + # on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes + # state=closed, so it cannot express a title, description or due-date edit, + # and naming it there told an agent to use a wrapper that cannot make the + # call. "Which wrapper touches this endpoint" is the wrong question; "does + # the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only + # a slice, `alsoown` must say which slice and name the rest as a gap — the + # treatment /pulls/{n} already had, and that two other arms did not, so this + # was a consistency failure rather than a missing idea. Auditing every arm + # for span (not just the reported one) is what found requested_reviewers. endpoint=""; wrapper=""; alsoown="" case "$CMD" in - *"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*) - endpoint="pull-request review"; wrapper="pr-review.sh" ;; + # Requesting a reviewer is not submitting one. pr-review.sh takes + # -a <action> -c <comment> and files a verdict; nothing in the tree adds a + # requested reviewer. Unowned, so it flows through — placed above the + # reviews arm so it cannot be refused with "use pr-review.sh". + *"/pulls/"*"/requested_reviewers"*) : ;; + *"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;; *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;; # A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the # numbered issue, not a child of it. issue-comment.sh only creates, so @@ -238,12 +266,17 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then *"/issues/"*"/labels"*|*"/pulls/"*"/labels"*) endpoint="issue label"; wrapper="issue-edit.sh" alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;; - *"/milestones/"[0-9]*) endpoint="milestone state"; wrapper="milestone-close.sh" ;; + *"/milestones/"[0-9]*) endpoint="milestone"; wrapper="milestone-close.sh" + alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title> +and sends state=closed. A milestone's title, description or due date is a real +wrapper gap: no tool in this tree edits them, and the override exists for it." ;; # The numbered object itself. These two arms are the fuzzy ones — they # match a number and then anything — so they are refined immediately # below rather than trusted as written. *"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh" - alsoown="issue-close.sh and issue-reopen.sh own the state change." ;; + alsoown="issue-close.sh and issue-reopen.sh own the state change, and +issue-assign.sh owns the assignee, labels and milestone fields at this same +number — issue-edit.sh does not set an assignee." ;; *"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh" alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and milestone are the ISSUE object on both providers, so issue-edit.sh and -- 2.54.0 From 51746f44eb0967d9155c85d8f455e9d080dba0c5 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Wed, 12 Aug 2026 18:53:25 -0500 Subject: [PATCH 12/24] wrapper-guard: fail closed on an unreadable endpoint in every shape the scope gate admits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 review found the fail-closed rule narrower than the block it guards. The scope gate admits three shapes — a scheme URL, a schemeless /api/vN path, and a provider CLI's `api` subcommand — but the unreadable-endpoint test asked only for https?://. So a split endpoint token in the other two shapes was in scope to be blocked, produced no readable endpoint, and fell through to ALLOW, while the identical split behind a literal scheme blocked. Four writes reaching the provider unexamined, one of them a review verdict: p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x host${p}${q} This is the same defect class as the milestone arm one round earlier, one layer up: there the map claimed a span its wrapper did not cover, here a control claimed a surface it did not measure. A control is only as wide as its narrowest arm, and widening the scope gate without widening the fail-closed rule left the gap exactly where the gate had just been extended. Three arms now, one per admitted shape: a scheme URL token carrying an expansion; a schemeless token carrying both a forge fragment and an expansion, in either order; and the endpoint argument of a provider-CLI api call, read positionally. Limits are stated in the source rather than implied — a caller who splits the hostname as well, and an endpoint pushed past an option whose value contains whitespace, are both outside what this measures. An expansion in a BODY is explicitly not unreadable. Passing a payload in a variable is the safe practice and leaves the endpoint fully legible; blocking it would have been a control punishing the behaviour it wants. 101/101 fixtures, up from 92. Each arm is negative-controlled separately: removing the schemeless arm fails exactly the two schemeless fixtures, removing the provider-CLI arm fails exactly the four CLI fixtures, and neither disturbs any pre-existing fixture. One added fixture was rewritten after it passed for the wrong reason — its endpoint was readable, so it blocked on the endpoint map and never exercised the arm it was written for. Evidence: 101/101 locally and in ci-base; shellcheck clean at warning+; a 12-command sweep of ordinary forge work — reads with split endpoints, bodies in variables, unwrapped endpoints, an artifact PUT — blocks none of them; the 18-command sweep still blocks the same three round-six flips and nothing new. --- .../framework/tools/git/test-wrapper-guard.sh | 17 +++++ .../framework/tools/git/wrapper-guard.sh | 67 ++++++++++++++----- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index accbb444..9af08039 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -180,6 +180,23 @@ FIXTURES="$TMP/fixtures.tsv" # 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' diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 4881fc6b..fd1e8d74 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -308,7 +308,7 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; fi ;; esac - # A URL the guard cannot READ is a URL the guard must not CLEAR. + # An endpoint the guard cannot READ is an endpoint the guard must not CLEAR. # # Round one fixed one spelling of this and review immediately produced the # general form: split the endpoint token itself across two variables — @@ -318,25 +318,58 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; # fragments is unwinnable: the endpoint does not exist until the shell # expands it, and this hook runs before that. # - # So stop pretending to read it. If a write's URL contains an expansion, + # So stop pretending to read it. If a write's endpoint contains an expansion, # the guard has no endpoint to judge, and "no endpoint" must not mean # "allowed" — that is the same absence-driven allow as the missing-wrapper # case, wearing different clothes. # - # Scoped to commands that are visibly forge-shaped, so an opaque webhook or - # artifact POST is untouched. A caller who splits `/api/` and the hostname - # as well does get through; that is no longer a mistake anyone makes by - # accident, and this hook stops mistakes. It is not a sandbox, and pretending - # otherwise is how you get a control nobody can trust the boundaries of. - if [ -z "$endpoint" ] \ - && printf '%s' "$CMD" | grep -Eq 'https?://[^[:space:]"'"'"'|;&)]*[$`]' \ - && printf '%s' "$CMD" | grep -Eq '/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo'; then - cat <<EOF -BLOCKED: raw provider API write whose URL this guard cannot read. + # SPAN, and the defect review found here: a fail-closed rule must cover the + # same surface as the block it guards. This test asked only for `https?://` + # while the scope gate above had already been widened to three shapes, so + # p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x + # p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q} + # were in scope to be blocked, produced no readable endpoint, and then fell + # through to ALLOW — while the identical split behind a literal `https://` + # blocked. Same shape as the milestone arm one round earlier: the correct + # treatment already existed and was applied to one of the surfaces it + # covered. A control is only as wide as its narrowest arm. + # + # Three arms, one per shape the scope gate admits: + # A a scheme-bearing URL token carrying an expansion + # B a schemeless token carrying BOTH a forge fragment and an expansion + # C the endpoint argument of a provider-CLI `api` call carrying one + # + # Stated limits, because a control may not claim more than it measures. B + # requires the fragment and the expansion in the SAME shell token, so a + # caller who splits the hostname and `/api/` as well gets through. C reads + # the endpoint positionally — the first bare token after `api` and its option + # run — so an endpoint pushed past an option whose value itself contains + # whitespace is not seen. Both are deliberate: this hook stops mistakes, it + # is not a sandbox, and pretending otherwise is how you get a control nobody + # can trust the boundaries of. + # + # Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`, + # `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would + # punish the safest way to pass a payload. Only the endpoint region counts. + URLTOK='[^[:space:]"'"'"'|;&)]*' + FORGE='(/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo)' + unreadable=0 + if printf '%s' "$CMD" | grep -Eq "https?://$URLTOK"'[$`]' \ + && printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi + printf '%s' "$CMD" | grep -Eq \ + "$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1 + printf '%s' "$CMD" | grep -Eq \ + '(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+--?[A-Za-z][A-Za-z-]*([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \ + && unreadable=1 -The URL is assembled from shell expansions, so the endpoint it names does not + if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then + cat <<EOF +BLOCKED: raw provider API write whose endpoint this guard cannot read. + +The endpoint is assembled from shell expansions, so the path it names does not exist until the shell builds it — after this check runs. The guard cannot tell -whether it is a wrapped endpoint, and an unreadable URL is not a cleared one. +whether it is a wrapped endpoint, and an unreadable endpoint is not a cleared +one. $W/ <- the wrappers; use the one for the endpoint you are calling @@ -344,8 +377,10 @@ If you are calling a wrapped endpoint (reviews, merges, comments, pulls, issues, milestones), use the wrapper — it also resolves identity explicitly, which matters on a host whose default provider login is an admin account. -If this is genuinely not a provider endpoint, either write the URL literally so -the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1. +If this is genuinely not a provider endpoint, either write the endpoint +literally so the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1. +A variable in the BODY is fine and does not trigger this; only the endpoint +itself has to be legible. EOF exit 2 fi -- 2.54.0 From 029af418f03afe663c18adab692f23fd182274f1 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Wed, 12 Aug 2026 19:07:44 -0500 Subject: [PATCH 13/24] wrapper-guard: name the marker every forge API shares, not one dialect's The scope gate asked for a scheme URL, a `/api/v[0-9]` path, or a provider-CLI `api` subcommand. `/api/v[0-9]` is Gitea's spelling. GitHub's API carries no version segment at all -- `api.github.com/repos/a/b/issues` -- so the schemeless Gitea write was in scope and the schemeless GitHub one was not: curl -X POST -d x api.github.com/repos/a/b/issues rc 0 curl -X POST -d x api.github.com/repos/a/b/issues/1/comments rc 0 host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues rc 0 A gate calibrated to one provider's spelling rather than to what identifies a provider API. `/repos/` is the marker both dialects share -- every forge API addresses a repository through it -- so the gate now names both. This is SPAN a third time, and the third layer it has appeared on. Round 8: a block message named a wrapper that could not make the call. Round 9: a map claimed a span its wrapper did not cover. Round 10: a control claimed a surface it did not measure. Here the scope gate itself claimed a class of API and recognised one member of it. Same question each time -- does this thing SPAN what it claims -- and it has now been the answer four rounds running, which is the argument for asking it of every arm rather than of the reported one. Widening a scope gate can only make the guard stricter. Downstream a block still requires a body flag AND either a mapped endpoint or an unreadable one, so this widens what is CONSIDERED, not what is refused. The three fixtures asserting that -- a schemeless GitHub read, an unwrapped GitHub endpoint, and a read past `--` -- exist to hold that claim to account rather than state it. Also: the provider-CLI arm's option scanner required a letter after the dashes, so `gh api -X POST -- ${p}${q} -f title=x` walked its endpoint straight past. The end-of-options marker is the one option not spelled like one, and a scanner that skips options had to be told so. Because the new endpoints are READABLE, each blocked fixture asserts the wrapper its message must name. A rc-only fixture here would have passed on the unreadable arm and proved nothing -- which is the failure mode this suite caught in itself last round. Evidence: 110/110 fixtures (was 101) locally and in ci-base. Negative-controlled per change, not in aggregate: dropping `/repos/` from the gate fails exactly the five GitHub fixtures; dropping the `--` alternative fails exactly one; neither disturbs a pre-existing fixture. 18-command sweep unchanged (same three round-6 flips, nothing new); 12-command ordinary-work sweep 0 blocked. shellcheck clean at warning+. CI 2374 on the previous head was green across all nine steps. Residual, stated rather than implied: a caller who splits `/repos/` itself in a schemeless GitHub URL leaves no literal marker anywhere and is out of scope -- the same boundary as splitting the hostname, and no longer a mistake anyone makes by accident. Round 11, addressing rev0 review 154. --- .../framework/tools/git/test-wrapper-guard.sh | 21 ++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 29 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 9af08039..ef1c77fe 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -209,6 +209,27 @@ FIXTURES="$TMP/fixtures.tsv" 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' diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index fd1e8d74..666caa40 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -142,16 +142,33 @@ fi # guard never asked the write question at all. Gate 7 covers raw provider CLIs, # so these are in scope and the gate now names the shapes they come in. # +# Then review found the same absence at the same boundary a second time, in the +# one provider whose paths carry no version marker at all: +# curl -X POST -d x api.github.com/repos/a/b/issues +# GitHub's API is `api.github.com/repos/...`; Gitea's is `/api/v1/repos/...`. +# Asking for `/api/v[0-9]` therefore admitted the schemeless Gitea write and +# excluded the schemeless GitHub one — a gate calibrated to one dialect's +# spelling rather than to what identifies a provider API. So the gate names both +# markers: a version segment, and the `/repos/` path that every forge API uses +# to address a repository. +# # Adding alternatives to a scope gate can only make it stricter — it cannot # create a new allow — which is why this is a list of triggers rather than a -# model of any one caller. +# model of any one caller. Downstream, a block still requires a body flag AND +# either a mapped endpoint or an unreadable one, so widening the gate widens +# what is CONSIDERED, not what is refused. +# +# Residual, stated rather than implied: a caller who splits `/repos/` itself in +# a schemeless GitHub URL (`h=api.github.com/rep; q=os/a/b/issues`) leaves no +# literal marker anywhere and is out of scope. That is the same boundary as +# splitting the hostname — no longer a mistake anyone makes by accident. # # Boundary, deliberate and worth stating: this covers the `api` subcommand, # which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`, # `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb # grammar, which is the parser mistake again in a new costume. Porcelain is a # gate-7 gap for prose and review to hold, not this hook. -API_SHAPED='https?://|/api/v[0-9]' +API_SHAPED='https?://|/api/v[0-9]|/repos/' API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then @@ -348,6 +365,12 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; # is not a sandbox, and pretending otherwise is how you get a control nobody # can trust the boundaries of. # + # C's option run also accepts the bare `--` end-of-options marker, because + # review found that `gh api -X POST -- ${p}${q} -f title=x` walked straight + # past an option class that required a letter after the dashes. The marker is + # the one "option" that is not spelled like one, and a scanner that skips + # options had to be told that. + # # Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`, # `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would # punish the safest way to pass a payload. Only the endpoint region counts. @@ -359,7 +382,7 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; printf '%s' "$CMD" | grep -Eq \ "$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1 printf '%s' "$CMD" | grep -Eq \ - '(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+--?[A-Za-z][A-Za-z-]*([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \ + '(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \ && unreadable=1 if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then -- 2.54.0 From f8d04d1bf4c37960c2c4725ae006011f41769c2e Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 01:57:07 -0500 Subject: [PATCH 14/24] wrapper-guard: decide allows on the shell's reading, not on the whole command text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four blockers from adversarial review, and three are one defect wearing three hats: a test over the WHOLE command text deciding an ALLOW. That is the fail-open shape this file keeps rediscovering, and it had reached the break-glass itself. - Break-glass read POSITIONALLY. `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, naming a variable after it, or writing =10 disabled the guard for the call sitting beside it. Now only leading NAME=value assignments count, exactly where the shell would honour one. Cost, pinned as a fixture: an override after `&&` no longer arms. - $HOME resolved once, and unset / empty / "/" refused. This was filed as a checkout-arm defect and is larger: under `set -u` the old file died at line 62 on EVERY command with HOME unset, exit 1, before the API arms or the APPROVE trap ran. A seat with no HOME (systemd unit, container, env -i) had no guard at all. A checkout whose question cannot be asked now blocks; the blast radius is asserted to be that one command shape and not the session. - Subresource refinement inverted. It asked whether a subresource appears anywhere in the command, so `gh api -X PATCH .../issues/1 -f body=cf-/pulls/2/files` was cleared on the strength of text in its own body. It now clears only when EVERY numbered-object occurrence carries a subresource. - -K/--config refused. curl reads the method, body, headers and URL from that file, so none of them are in the command: every write test read 0 and the call went through. An unreadable request is not a cleared one. mosaic-worktree: never close a git pipe early resolve_repo took the first porcelain line with `awk ... exit`, which closes the read end while git is still writing. git takes SIGPIPE, pipefail returns 141, and the function aborts SILENTLY — no message, no path, exit 141. It fires as a function of REPO SIZE: fine on three worktrees, reliable on seventy. Measured at 73 worktrees (10 KB of porcelain): rc=141, no output. The file already removed a `head -200` for this exact reason; the rule is now uniform. Evidence — every new case run against 029af418, the tree before these fixes: test-wrapper-guard.sh 130/130 pass here; 15 FAIL against 029af418 test-mosaic-worktree-large-repo.sh 2/2 pass here; 2 FAIL against 029af418 (got rc=141 and empty output, the signature) No pre-existing fixture changed behaviour on the old guard, so the new cases are the whole delta. The size dependence is stubbed out rather than inherited: a test that ran against whatever repo it sits in would have PASSED on the broken tree. --- .../framework/tools/git/mosaic-worktree.sh | 19 ++- .../git/test-mosaic-worktree-large-repo.sh | 95 +++++++++++ .../framework/tools/git/test-wrapper-guard.sh | 98 ++++++++++++ .../framework/tools/git/wrapper-guard.sh | 149 +++++++++++++++++- 4 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh index 5b3cf936..926365eb 100755 --- a/packages/mosaic/framework/tools/git/mosaic-worktree.sh +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -65,7 +65,24 @@ 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" - MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')" + # 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")" diff --git a/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh b/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh new file mode 100644 index 00000000..5b2eef7b --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh @@ -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" diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index ef1c77fe..aaccc77f 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -235,6 +235,44 @@ FIXTURES="$TMP/fixtures.tsv" 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: the scope gate admits any https URL, so 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 outside an API-shaped command is nobody'"'"'s business\n' } > "$FIXTURES" fail=0 n=0 @@ -260,6 +298,66 @@ while IFS=$'\t' read -r want payload why remedy; do 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)) + if [ "$homeval" = "@unset" ]; then + out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$GUARD" 2>&1)" + else + out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | 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 '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' +# 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' + printf '\n' if [ "$fail" -eq 0 ]; then printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n" diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 666caa40..423ece4b 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -48,8 +48,50 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | # joined form, because that is the command. CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" -# Honour the override only when it is set in the command itself or the env. -case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac +# Honour the override only where a shell would actually TREAT it as one: the +# environment-assignment run at the head of the command, or this process's own +# environment. The first version asked whether the token appeared ANYWHERE in the +# command text. That is not a test of what the shell does; it is a test of what +# the string contains, and three shapes turned the whole guard off silently, each +# with exit 0 and no message: +# +# curl -d '{"body":"... MOSAIC_WRAPPER_OVERRIDE=1 ..."}' .../issues/1/comments +# a quoted BODY disabling the guard for its own write — and the bodies most +# likely to carry the token are this file's own documentation, a relayed +# block message, or a commit message quoting a previous refusal; +# NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl ... +# the token as another variable's VALUE, which sets nothing; +# ... MOSAIC_WRAPPER_OVERRIDE=10 ... +# `*=1*` matched `=10`, `=1x`, `=123`; the glob never bounded the value. +# +# A control that is off is worse than no control, because the block message is +# what tells an agent the control exists. So the override is now read +# POSITIONALLY, by the rule a shell uses: leading assignments only, up to the +# first token that is not one. A body can never occupy that position, and the +# value must be exactly 1. +# +# Deliberate cost, stated rather than discovered: `cd /x && MOSAIC_WRAPPER_OVERRIDE=1 +# curl ...` is NOT honoured — only the head of the command is, and only its first +# line, because an override applies to the command it prefixes and not to a later +# one. Putting the override first is the remedy, and this direction fails closed. +override_prefixed() { + local first tok + first="${CMD%%$'\n'*}" + local IFS=$' \t' + set -f + # shellcheck disable=SC2086 + set -- $first + set +f + for tok in "$@"; do + case "$tok" in + MOSAIC_WRAPPER_OVERRIDE=1) return 0 ;; + [A-Za-z_]*=*) ;; + *) return 1 ;; + esac + done + return 1 +} +override_prefixed && exit 0 [ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 # The wrappers this guard points at are its own siblings. Resolving relative to @@ -62,9 +104,53 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" [ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git" # ---- 1. checkout into $HOME ------------------------------------------------ +# $HOME has to be RESOLVED before anything can be compared against it, and the +# first version interpolated it directly into the pattern. Both ways of it being +# absent were wrong, in OPPOSITE directions, which is why neither showed up as a +# simple "it stopped working": +# +# HOME unset under `set -u` the expansion aborts the script. A PreToolUse +# hook exiting nonzero-but-not-2 is a non-blocking error, so the +# checkout it was asked about is ALLOWED. The guard failed open in +# precisely the case where it could not answer the question. +# HOME='' the alternation gained an EMPTY branch — (~|\$HOME|)/ — which +# matches any slash at all, so a legitimate /src checkout was +# refused. Unusable in the other direction. +# +# Empty and unset are different values and neither one is a home directory. '/' +# is rejected for the same reason as '': every path is under it, so the +# comparison stops discriminating at all. Where the literal path cannot be +# established the `~` and `$HOME` spellings are still checked, and a checkout +# left unresolved BLOCKS rather than clears — a guard may not clear a question it +# was unable to ask. The blast radius of that fail-closed arm is exactly one +# command shape (clone / worktree add), not the session. +home_re='~|\$HOME' +home_known=0 +case "${HOME-}" in + /?*) home_re="$home_re|$(printf '%s' "$HOME" | sed 's/[][\.*^$+?(){}|]/\\&/g')" + home_known=1 ;; +esac + if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then + if [ "$home_known" -eq 0 ]; then + cat <<EOF +BLOCKED: this is a checkout, and \$HOME is unset or unusable in this shell. + +The guard's job here is to answer one question — does this check out under +\$HOME — and it cannot answer it without a usable \$HOME. An unanswerable +question is not a cleared one, so this refuses rather than guesses. (\$HOME +empty, unset, and "/" are all treated the same way: none of them is a home +directory, and "/" would match every path there is.) + +Set HOME to the account's home directory and re-run, or use the helper, which +derives the path and never consults \$HOME at all: + + $W/mosaic-worktree.sh new <branch> +EOF + exit 2 + fi # Any argument that resolves under $HOME and is not under a work filesystem. - if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then + if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])($home_re)/"; then cat <<EOF BLOCKED: this checks a repository out under \$HOME. @@ -172,6 +258,37 @@ API_SHAPED='https?://|/api/v[0-9]|/repos/' API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then + # curl takes its options from a file with -K/--config, and that file may carry + # the method, the body, the headers and the URL itself. Every write test below + # reads the command TEXT, and none of those things are in it: + # + # curl --config /tmp/req .../repos/a/b/pulls/1/reviews + # + # carries no -X, no -d, no -f, so is_write stayed 0 and the call went straight + # through. This is not a spelling the write detection was missing — it is the + # write question being unaskable, which is the same situation as an endpoint + # assembled from expansions, and it gets the same answer. Refusing a READ that + # happens to use --config is the acceptable side of that trade, and the message + # says how to proceed. + if printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-K|--config)([[:space:]=]|$)'; then + cat <<EOF +BLOCKED: provider API call whose options are supplied from a --config/-K file. + +curl reads the request method, body, headers and even the URL from that file. +None of them appear in the command, so this guard cannot tell whether the call +is a read or a write, or what endpoint it reaches. An unreadable request is not +a cleared one. + + $W/ <- the wrappers; use the one for the endpoint you are calling + +If this is a read, spell the request on the command line so it is legible. If it +is a write to a wrapped endpoint, use the wrapper. For a genuine gap no wrapper +can express, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first thing in the +command — it is read positionally, not matched as text). +EOF + exit 2 + fi + # Write detection, now client-agnostic. Every spelling curl accepts, because # the guard is defeated by the one spelling it does not know: `-d@body` (no # space) and `--request=POST` (equals form) both slipped past the first @@ -318,9 +435,33 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; # nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's # files or commits. Listing them instead would rot the moment a provider # adds one, and rot in the blocking direction with wrong advice. + # The refinement is an ALLOW, and an allow decided by a test over the whole + # command is the fail-open shape this file keeps rediscovering — the comment + # above says exactly that about `case` globs, and then the regex it replaced + # them with made the same mistake one level down. Asking "does a subresource + # appear ANYWHERE in this command" cleared a write on the strength of text + # that was not the endpoint: + # + # gh api -X PATCH repos/a/b/issues/1 -f body="see /pulls/2/files" + # gh api -X PATCH repos/a/b/issues/1 -f body="cf /issues/3/reactions" + # + # Both PATCH the numbered issue that issue-edit.sh owns, and both were + # allowed because a subresource appeared in the BODY. Same class as the + # backslash-newline case at the top of the file: the guard read the text as + # typed instead of the call being made. + # + # Extracting "the endpoint token" is the parser problem this file already + # refused to take on, so the test is inverted instead, which needs no parser: + # clear ONLY when every numbered-object occurrence in the command carries a + # subresource. One bare /issues/{n} anywhere means a wrapped call may be in + # play, and the block stands. Cost, in the same direction as every other + # trade here: writing to /issues/1/reactions while quoting /issues/2 is + # refused. Over-blocking costs an override on a rare command; the reverse + # cost is a silent raw write to a wrapped endpoint. case "$endpoint" in "issue edit"|"pull-request edit") - if printf '%s' "$CMD" | grep -Eq '/(issues|pulls)/[0-9]+/[A-Za-z_]'; then + occ="$(printf '%s' "$CMD" | grep -oE '/(issues|pulls)/[0-9]+(/[A-Za-z_])?' || true)" + if [ -n "$occ" ] && ! printf '%s' "$occ" | grep -q '[0-9]$'; then endpoint=""; wrapper=""; alsoown="" fi ;; esac -- 2.54.0 From 06046f76754bd6897cc3da3ff4d38b07dab4ffb3 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 02:18:35 -0500 Subject: [PATCH 15/24] wrapper-guard: close three fail-opens the last round left, and enumerate the large-repo test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-two remediation of the four blockers gate-ultron-01 raised on f8d04d1b. All four were confirmed by my own measurement before being touched; none is taken on the reviewer's word. 1. The --config/-K refusal never ran. It sat nested inside `if API_SHAPED`, and API_SHAPED is a test for a provider URL in the command text — which is exactly what a config file removes. The check was guarded by the condition that the capability it guards against defeats, so `curl --config /tmp/write.cfg` walked past it. It now keys on curl itself, ahead of the URL gate, and covers the attached (`-K/tmp/f`) and bundled (`-sK`) spellings a space-separated test cannot see. 2. Percent-encoded endpoints are a live route, not a theoretical one. Measured against the provider: `…/issues/1174` and `…/iss%75es/1174` both return HTTP 200 for the same object. A write carrying any percent-escape is now refused rather than decoded — a decoder has to be exactly right about depth (%2569 -> %69 -> i) and about the provider's own normalisation, and being approximately right there is indistinguishable from not checking. Scoped to writes: a read is never this hook's business and a query string carrying %20 is an ordinary URL. 3. HOME was still expanded unguarded at the `W=` fallback, which runs before any of the new HOME adjudication — so a guard deployed without its siblings still died on an unset HOME, upstream of the fix that was supposed to survive it. Moving a fail-open earlier in the file is not closing it. HOME is now resolved once, above every use, and every later site reads the resolved value. The existing harness could not have caught this: it runs the guard beside its siblings, so `[ -x "$W/pr-review.sh" ]` always succeeded and the fallback was never reached. A test's blind spot can be a property of the harness rather than of the code. The new lone_case() block copies the guard alone into an empty directory and re-asserts the four behaviours there. 4. test-mosaic-worktree-large-repo.sh shipped at mode 100644 and appeared in no CI step, so the enumeration guard (#1017) redded pipeline 2386 — correctly. Committed mode is now 100755 and the test is enumerated in the sanitization step. My own process miss: I verified the CI queue before pushing and never verified terminal CI after. Controls: the 25 fixtures added here all FAIL against 029af418 (rc 0 or 1 where 2 is required) and all pass at this head, 143/143. --- .woodpecker/ci.yml | 7 ++ .../git/test-mosaic-worktree-large-repo.sh | 0 .../framework/tools/git/test-wrapper-guard.sh | 88 ++++++++++++- .../framework/tools/git/wrapper-guard.sh | 119 +++++++++++++----- 4 files changed, 180 insertions(+), 34 deletions(-) mode change 100644 => 100755 packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index ae488a73..26ea0848 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -61,6 +61,13 @@ steps: # 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 # operator-owned path. The HARD GATE proves an unanticipated operator sentinel diff --git a/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh b/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh old mode 100644 new mode 100755 diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index aaccc77f..309c64d4 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -267,12 +267,34 @@ FIXTURES="$TMP/fixtures.tsv" # 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: the scope gate admits any https URL, so 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. + # 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 outside an API-shaped command is nobody'"'"'s business\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' + + # 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 @@ -358,6 +380,62 @@ home_case 'HOME unset does not block an ordinary command' \ 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" diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 423ece4b..abc8e6e2 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -100,8 +100,26 @@ override_prefixed && exit 0 # still works from a repo checkout with no installed mosaic home (which is how it # is exercised in CI). $HOME remains the fallback for a guard invoked by an # absolute path from somewhere unusual. +# $HOME is resolved HERE, once, before anything expands it. The previous attempt +# fixed the checkout arm's use of $HOME and left this one, four lines earlier, +# reading it raw — so under `set -u` a seat with no HOME still died before +# reaching the adjudication that was supposed to handle exactly that. Moving a +# fail-open earlier in the file is not closing it. There is now exactly one +# expansion of HOME in this script and it is guarded; every later use reads +# HOME_DIR / home_known instead, so a new use cannot reintroduce the abort +# without going through this block. +# +# Empty and unset are different values and neither one is a home directory. '/' +# is rejected for the same reason as '': every path is under it, so comparing +# against it stops discriminating at all. +HOME_DIR="" +home_known=0 +case "${HOME-}" in + /?*) HOME_DIR="$HOME"; home_known=1 ;; +esac + W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git" +[ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git" # ---- 1. checkout into $HOME ------------------------------------------------ # $HOME has to be RESOLVED before anything can be compared against it, and the @@ -125,11 +143,9 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # was unable to ask. The blast radius of that fail-closed arm is exactly one # command shape (clone / worktree add), not the session. home_re='~|\$HOME' -home_known=0 -case "${HOME-}" in - /?*) home_re="$home_re|$(printf '%s' "$HOME" | sed 's/[][\.*^$+?(){}|]/\\&/g')" - home_known=1 ;; -esac +if [ "$home_known" -eq 1 ]; then + home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" +fi if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then if [ "$home_known" -eq 0 ]; then @@ -254,30 +270,35 @@ fi # `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb # grammar, which is the parser mistake again in a new costume. Porcelain is a # gate-7 gap for prose and review to hold, not this hook. -API_SHAPED='https?://|/api/v[0-9]|/repos/' -API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' -if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then - - # curl takes its options from a file with -K/--config, and that file may carry - # the method, the body, the headers and the URL itself. Every write test below - # reads the command TEXT, and none of those things are in it: - # - # curl --config /tmp/req .../repos/a/b/pulls/1/reviews - # - # carries no -X, no -d, no -f, so is_write stayed 0 and the call went straight - # through. This is not a spelling the write detection was missing — it is the - # write question being unaskable, which is the same situation as an endpoint - # assembled from expansions, and it gets the same answer. Refusing a READ that - # happens to use --config is the acceptable side of that trade, and the message - # says how to proceed. - if printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-K|--config)([[:space:]=]|$)'; then - cat <<EOF -BLOCKED: provider API call whose options are supplied from a --config/-K file. +# ---- curl whose request lives in a file ------------------------------------ +# curl takes its options from a file with -K/--config, and that file may carry +# the method, the body, the headers AND THE URL. That last one is why this test +# cannot live inside the API-shape gate below, which is where it was first put: +# +# curl --config /tmp/provider-write.cfg +# +# has no URL, no /repos/, no `gh api` — nothing API-shaped in the text at all — +# so it never entered the branch that was supposed to refuse it, and the guard +# reported clean on the exact capability the check exists to deny. The check was +# guarded by a condition the thing it guards against defeats. It is now asked of +# any curl, because "is this a provider call" is not answerable about a command +# whose URL is in a file, and a question that cannot be asked is not a question +# that came back clean. +# +# The spelling is deliberately loose. curl accepts the value attached to the +# short flag (`-K/tmp/req`, verified) and inside a bundle (`-sK /tmp/req`), and a +# guard that recognizes only the space- and equals-separated forms is defeated by +# deleting one character. Scoped to curl so that `eslint --config .eslintrc.json` +# and every other tool with a --config flag are untouched. +if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])curl([[:space:]]|$)' \ + && printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then + cat <<EOF +BLOCKED: curl invocation whose request is supplied from a --config/-K file. curl reads the request method, body, headers and even the URL from that file. None of them appear in the command, so this guard cannot tell whether the call -is a read or a write, or what endpoint it reaches. An unreadable request is not -a cleared one. +is a read or a write, what endpoint it reaches, or whether it is a provider call +at all. An unreadable request is not a cleared one. $W/ <- the wrappers; use the one for the endpoint you are calling @@ -286,8 +307,12 @@ is a write to a wrapped endpoint, use the wrapper. For a genuine gap no wrapper can express, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first thing in the command — it is read positionally, not matched as text). EOF - exit 2 - fi + exit 2 +fi + +API_SHAPED='https?://|/api/v[0-9]|/repos/' +API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' +if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then # Write detection, now client-agnostic. Every spelling curl accepts, because # the guard is defeated by the one spelling it does not know: `-d@body` (no @@ -325,6 +350,42 @@ EOF '\.(post|put|patch|delete)\(' && is_write=1 if [ "$is_write" -eq 1 ]; then + # A percent-escape makes the endpoint unreadable HERE and perfectly readable + # to the PROVIDER, which is the whole hazard. Measured against the live + # forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and + # GET /repos/mosaicstack/stack/iss%75es/1174 both returned HTTP 200 with the + # same object. So `iss%75es` IS the wrapped endpoint by the only authority + # that gets a vote, while every literal comparison below sees a segment that + # matches nothing and clears the write. + # + # This refuses rather than decodes. A decoder has to be exactly right about + # depth (%2569 -> %69 -> i) and about which characters the provider + # normalizes, and being exactly right about someone else's parser is the + # mistake this file declines everywhere else. Refusing is correct at every + # depth at once. + # + # Scoped to WRITES. Reads are never blocked by this guard, and a query + # string carrying %20 is not a hazard — it is an ordinary URL. Putting this + # test on the whole API-shaped branch would have refused those too, which is + # how a guard earns being routed around. + if printf '%s' "$CMD" | grep -Eq '%[0-9A-Fa-f][0-9A-Fa-f]'; then + cat <<EOF +BLOCKED: provider API WRITE containing a percent-escape. + +The provider decodes the path before routing; this guard compares it literally. +An encoded segment therefore reaches a wrapped endpoint while reading, here, as +a segment that matches nothing — /iss%75es/ and /issues/ were measured returning +the same object from the same repository. + + $W/ <- the wrappers; use the one for the endpoint you are calling + +Spell the path literally and re-run. If the escape is genuinely required and no +wrapper can express the call, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first +thing in the command — it is read positionally, not matched as text). +EOF + exit 2 + fi + # The endpoint map, and the rule that keeps it honest: an arm exists here # ONLY because a wrapper in this directory owns that call. It is an # inventory, not a model — read off `ls tools/git/*.sh` and the flags each -- 2.54.0 From d99ff57e14ad2ff29591af047f48a07657a3c3af Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 02:36:51 -0500 Subject: [PATCH 16/24] wrapper-guard: match curl by basename, not by bare word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-three remediation of the single blocker gate-ultron-01 raised on 06046f76. Confirmed by measurement before being touched: all three spellings returned rc=0 against that head. /usr/bin/curl --config /tmp/provider-write.cfg -> allowed env /usr/bin/curl -K/tmp/provider-write.cfg -> allowed ./curl --config /tmp/provider-write.cfg -> allowed The config file still owned the URL, method, body and headers in every one of them, so each executed exactly the wrapped raw provider write the previous commit was written to refuse, while the guard reported clean. The mistake is worth naming precisely, because it is the one this file already exists to refuse and I reintroduced it: recognizing the unqualified name only is CALLER-NAME PARSING. `/usr/bin/curl` is not a different program from `curl`, and a control that can be defeated by typing the absolute path is not a control. The match is now on curl as a BASENAME — an optional prefix that must end at a slash — so a path spelling costs the caller nothing and buys them nothing. The prefix must end at a slash deliberately: `mycurl` and `curl-wrapper` are different programs, and blocking them would be the over-block that gets a guard routed around instead of fixed. Both are negative fixtures. Still open and stated rather than left to be discovered: a wrapper script that execs curl on the operator's behalf is invisible here, because neither the name nor the request appears in the command text. That is a limit of inspecting a command string, not something this regex can close, and it is now written in the comment above the check. Controls: the three bypass fixtures FAIL against 06046f76 and pass at this head; the two over-block fixtures pass against both. Suite 148/148. --- .../framework/tools/git/test-wrapper-guard.sh | 12 ++++++++++++ .../mosaic/framework/tools/git/wrapper-guard.sh | 13 ++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 309c64d4..3ab9fd85 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -282,6 +282,18 @@ FIXTURES="$TMP/fixtures.tsv" 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' # 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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index abc8e6e2..763536b6 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -290,7 +290,18 @@ fi # guard that recognizes only the space- and equals-separated forms is defeated by # deleting one character. Scoped to curl so that `eslint --config .eslintrc.json` # and every other tool with a --config flag are untouched. -if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])curl([[:space:]]|$)' \ +# +# curl is matched as a BASENAME, not as a bare word: `/usr/bin/curl`, `./curl` +# and `env /usr/bin/curl` are ordinary spellings of the same invocation, and the +# first version of this check saw none of them. Recognizing only the unqualified +# name is caller-name parsing, which is the failure class this file removed +# elsewhere and which came straight back in with this control. +# +# A wrapper script that execs curl on the operator's behalf is still invisible +# here, because neither the name nor the request appears in the command text. +# That is a real limit of inspecting a command string rather than a defect this +# regex can close, and it is stated rather than left for the next reader to find. +if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])([^[:space:]|;&()]*/)?curl([[:space:]]|$)' \ && printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then cat <<EOF BLOCKED: curl invocation whose request is supplied from a --config/-K file. -- 2.54.0 From df83a9eec2f5fc82eba0e67422a8a489816bc7e0 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 03:12:39 -0500 Subject: [PATCH 17/24] wrapper-guard: recognize program names after quote removal, in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-four remediation of both blockers gate-ultron-01 raised on d99ff57e. Measured against that head before anything was touched; all seven returned rc=0, and each executes the program the check exists to recognize: "/usr/bin/curl" --config /tmp/w.cfg -> allowed './curl' --config /tmp/w.cfg -> allowed $(which curl) --config /tmp/w.cfg -> allowed `which curl` --config /tmp/w.cfg -> allowed /usr/bin/gh api -X POST repos/a/b/issues … -> allowed ./gh api -X POST repos/a/b/issues … -> allowed /usr/local/bin/tea api -X POST repos/a/b/… … -> allowed This is the third appearance of one defect, and the shape is worth stating plainly because the first two repairs each fixed an INSTANCE and left the class: the check matched the bare word, then it matched the unquoted basename. Both were models of one TEXTUAL PRESENTATION of a shell word rather than of the word, so the first repair was defeated by an absolute path and the second by two quote characters. Recognizing a name is either done after quote removal or it is caller-name parsing wearing a longer regex. The second blocker is the same defect sitting untouched in the API SCOPE gate the whole time, while the curl arm was repaired twice beside it. That one is worse than it looks: the scope gate decides whether write detection runs AT ALL, so failing to admit `/usr/bin/gh api -X POST` is not a missed match, it is an allow. No URL marker rescued those commands either — provider CLI endpoints are spelled `repos/…` with no leading slash, so `/repos/` never matched them. Fix, and the reason it is one fix rather than two: - $CMD_NAMES — a second reading of the same command with quote and substitution punctuation turned into whitespace. Names are read from it. - $NAME_PREFIX — the one place the shape of a program name is written down. Both callers use it, so the next fix to this class lands in a single location instead of whichever arm review happened to probe. That is the actual lesson of finding this defect twice in one file. The prefix still must end at a slash. `mycurl` and `curl-wrapper` are different programs and blocking them is the over-block that gets a guard routed around instead of repaired; both remain negative fixtures, and `mygh` and an absolute-path READ join them. The cost is the one this file already chose and documented for the payload check: quoting an example does not exempt it, so writing one of these commands inside quotes on a Bash line is refused too. Applying that rule to the name arms makes the file coherent — the alternative is a guard where the payload arm treats quotes as text and the name arms treat them as armour. Still open, stated rather than left to be found: a name absent from the text — assembled from variables, or reached through a wrapper script that execs the program — is invisible here. That is a limit of inspecting a command string, not something a pattern closes. Controls: the 7 positive fixtures FAIL at d99ff57e and pass here; the 4 negative fixtures pass at BOTH heads, so they measure over-blocking rather than decorate the diff. Suite 157/157. --- .../framework/tools/git/test-wrapper-guard.sh | 19 +++++ .../framework/tools/git/wrapper-guard.sh | 80 ++++++++++++++++--- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 3ab9fd85..68e24467 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -295,6 +295,25 @@ FIXTURES="$TMP/fixtures.tsv" 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' + # 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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 763536b6..bd826a94 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -48,6 +48,47 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | # joined form, because that is the command. CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" +# A second reading of the SAME command, used only by the two checks below that +# have to recognize a program by name. Quote characters and the punctuation of +# command substitution become whitespace, so a name that the shell will resolve +# is a word here regardless of how it was dressed: +# +# "/usr/bin/curl" --config /tmp/req quoted absolute path +# './curl' --config /tmp/req quoted relative path +# $(which curl) --config /tmp/req command substitution +# `which curl` --config /tmp/req the older spelling of the same thing +# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI +# +# All five executed the real program and all five returned ALLOW from the two +# name checks, at this file's previous head and the one before it. The first +# repair of this class matched curl as a basename and stopped there, which fixed +# the unquoted spelling only: the check still modelled ONE TEXTUAL PRESENTATION +# of a shell word rather than the word itself, so adding two quote characters +# restored the bypass, and the same defect sat untouched in the provider-CLI arm +# the whole time. Recognizing a name is either done after quote removal or it is +# caller-name parsing wearing a longer regex. +# +# This does NOT try to be a shell. It cannot see a name that is absent from the +# text — assembled from variables, or reached through a wrapper script that +# execs the program — and those remain stated limits of inspecting a command +# string, not defects a pattern closes. What it removes is the class where the +# name IS present and merely punctuated. +# +# The cost is the one this file already chose and documented for the payload +# check further down: quoting an example no longer exempts it, so writing one of +# these commands inside quotes on a Bash line is refused too. Applying that same +# rule here keeps the file coherent — the alternative is a guard where the +# payload arm treats quotes as text and the name arms treat them as armour. +CMD_NAMES="$(printf '%s' "$CMD" | tr '"'"'"'\`()' ' ')" +CMD_NAMES="$(printf '%s' "$CMD_NAMES" | sed 's/\$/ /g')" + +# The one place the shape of a program NAME is written down. Both callers below +# use it, so the next fix to this class lands in a single location instead of +# being applied to whichever arm review happened to probe. The prefix must end +# at a slash: `mycurl` and `curl-wrapper` are different programs, and blocking +# them is the over-block that gets a guard routed around instead of repaired. +NAME_PREFIX='(^|[[:space:]|;&])([^[:space:]|;&]*/)?' + # Honour the override only where a shell would actually TREAT it as one: the # environment-assignment run at the head of the command, or this process's own # environment. The first version asked whether the token appeared ANYWHERE in the @@ -291,17 +332,15 @@ fi # deleting one character. Scoped to curl so that `eslint --config .eslintrc.json` # and every other tool with a --config flag are untouched. # -# curl is matched as a BASENAME, not as a bare word: `/usr/bin/curl`, `./curl` -# and `env /usr/bin/curl` are ordinary spellings of the same invocation, and the -# first version of this check saw none of them. Recognizing only the unqualified -# name is caller-name parsing, which is the failure class this file removed -# elsewhere and which came straight back in with this control. -# -# A wrapper script that execs curl on the operator's behalf is still invisible -# here, because neither the name nor the request appears in the command text. -# That is a real limit of inspecting a command string rather than a defect this -# regex can close, and it is stated rather than left for the next reader to find. -if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])([^[:space:]|;&()]*/)?curl([[:space:]]|$)' \ +# curl is recognized as a NAME, through $CMD_NAMES and $NAME_PREFIX — see the +# comment on those at the top of the file for why matching the bare word, and +# then matching the unquoted basename, were both the same mistake at different +# depths. A wrapper script that execs curl on the operator's behalf is still +# invisible here, because neither the name nor the request appears in the +# command text at all. That is a limit of inspecting a command string rather +# than a defect this pattern can close, and it is stated rather than left for +# the next reader to find. +if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \ && printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then cat <<EOF BLOCKED: curl invocation whose request is supplied from a --config/-K file. @@ -321,9 +360,24 @@ EOF exit 2 fi +# The scope gate comes in two halves because its two triggers are different +# kinds of thing, and collapsing them into one regex over one string is what +# hid the second instance of the caller-name defect for three review rounds. +# +# The URL markers are TEXT: a scheme, a version segment, a `/repos/` path. They +# are read from the command as written. API_SHAPED='https?://|/api/v[0-9]|/repos/' -API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)' -if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then +# +# The provider-CLI marker is a NAME, so it is read the way names are read here — +# through $CMD_NAMES, with $NAME_PREFIX. Asking for a bare `gh api` let every +# path spelling through the gate that decides whether write detection runs AT +# ALL, so `/usr/bin/gh api -X POST repos/a/b/issues -f title=x` was never even +# considered. No URL marker rescued it: provider CLI endpoints are spelled +# `repos/...` with no leading slash, so `/repos/` does not match them either. +# A scope gate that fails to admit is indistinguishable from an allow. +PROVIDER_CLI="${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" +if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \ + || printf '%s' "$CMD_NAMES" | grep -Eq "$PROVIDER_CLI"; then # Write detection, now client-agnostic. Every spelling curl accepts, because # the guard is defeated by the one spelling it does not know: `-d@body` (no -- 2.54.0 From 46f52eedfeaf035fadd1dcee809d81888f1eee6a Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 03:36:42 -0500 Subject: [PATCH 18/24] wrapper-guard: remove quotes instead of splitting on them; last name consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-five remediation of both blockers gate-ultron-01 raised on df83a9ee. Measured against that head first; all eight returned rc=0 and each executes the program the check exists to recognize: cu"rl" --config /tmp/w.cfg -> allowed cu'rl' --config /tmp/w.cfg -> allowed /usr/bin/cu\rl --config /tmp/w.cfg -> allowed g"h" api -X POST repos/a/b/issues … -> allowed /usr/bin/g\h api -X POST repos/a/b/… … -> allowed curl --con"fig" /tmp/w.cfg -> allowed /usr/bin/gh api -X POST repos/a/b/$EP … -> allowed g"h" api -X POST repos/a/b/$EP … -> allowed BLOCKER 1. The previous commit said names are recognized "after quote removal" and did not do that. It replaced quote characters with whitespace, which is token SEPARATION: 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"` blocked under that version only because the inserted space happened to land after a slash — a passing case that established nothing about quote removal, and I read it as confirmation. The characters are now DELETED, which is what quote removal is. Backslashes go with them, because escaping is ordinary word formation too. Deletion still handles substitution: `$(which curl)` becomes `which curl`, where the name is a word on its own. The flag is read from the same normalized copy for the same reason — `--con"fig"` is one word spelling --config. No review raised that; the name was simply the easier half to reach, and reading both halves the same way is the entire point of having one normalization. BLOCKER 2. A THIRD name consumer never went through the shared site: the unreadable-endpoint arm kept a private bare-name copy of the scope gate's regex against raw $CMD. 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, which is the defect one layer downstream. Fixing that surfaced the same mistake a third time inside this very edit: my first version left the NAME in the refinement's tail regex, so the name gate recognized `g"h" api` while the tail still demanded the undressed spelling, and the two halves disagreed exactly as before. Caught by the fixture, not by reading. Each half now asks one question: the name gate asks WHO, from the normalized copy; the tail asks whether the ENDPOINT is readable, from the raw text — deliberately raw, because the expansion markers that make an endpoint unreadable are the characters the normalized copy removes, and reading the tail from it would erase the evidence. Controls: the 8 positive fixtures FAIL at df83a9ee and pass here; the negatives — mycurl, curl-wrapper, mygh, mygh with an assembled endpoint, an absolute-path read, and -K on a non-curl — pass at BOTH heads. Suite 166/166. Unchanged and still stated in the comment rather than this message: a name ABSENT from the text, assembled from variables or reached through a wrapper script that execs the program, is invisible to any of this. --- .../framework/tools/git/test-wrapper-guard.sh | 23 ++++++ .../framework/tools/git/wrapper-guard.sh | 71 ++++++++++++++----- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 68e24467..a280e41a 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -314,6 +314,29 @@ FIXTURES="$TMP/fixtures.tsv" 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' + 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' + # 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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index bd826a94..963d4ead 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -48,25 +48,35 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | # joined form, because that is the command. CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" -# A second reading of the SAME command, used only by the two checks below that -# have to recognize a program by name. Quote characters and the punctuation of -# command substitution become whitespace, so a name that the shell will resolve -# is a word here regardless of how it was dressed: +# A second reading of the SAME command, used by every check below that has to +# recognize a program by NAME. Quote characters, backslashes and the punctuation +# of command substitution are DELETED, so a name the shell will resolve is a word +# here regardless of how it was dressed: # # "/usr/bin/curl" --config /tmp/req quoted absolute path # './curl' --config /tmp/req quoted relative path # $(which curl) --config /tmp/req command substitution # `which curl` --config /tmp/req the older spelling of the same thing # /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI +# cu"rl" --config /tmp/req quotes INSIDE the word +# /usr/bin/cu\rl --config /tmp/req a backslash inside the word +# g"h" api -X POST repos/a/b/... the same, in the provider-CLI name # -# All five executed the real program and all five returned ALLOW from the two -# name checks, at this file's previous head and the one before it. The first -# repair of this class matched curl as a basename and stopped there, which fixed -# the unquoted spelling only: the check still modelled ONE TEXTUAL PRESENTATION -# of a shell word rather than the word itself, so adding two quote characters -# restored the bypass, and the same defect sat untouched in the provider-CLI arm -# the whole time. Recognizing a name is either done after quote removal or it is -# caller-name parsing wearing a longer regex. +# Every one of them executed the real program and every one returned ALLOW, at +# each of this file's three previous heads. The repairs went bare word, then +# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and +# left the class, and the third is worth spelling out because it is the subtlest +# and it was mine: replacing quote characters with whitespace is token +# SEPARATION, 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"` blocked under that version only +# because the whitespace happened to land after a slash — a passing case that +# established nothing. +# +# So the characters are DELETED rather than replaced, which is what quote removal +# is, and backslashes go with them because escaping is ordinary word formation +# too. Deletion also handles substitution: `$(which curl)` becomes `which curl`, +# where the name is a word on its own. # # This does NOT try to be a shell. It cannot see a name that is absent from the # text — assembled from variables, or reached through a wrapper script that @@ -79,8 +89,7 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" # these commands inside quotes on a Bash line is refused too. Applying that same # rule here keeps the file coherent — the alternative is a guard where the # payload arm treats quotes as text and the name arms treat them as armour. -CMD_NAMES="$(printf '%s' "$CMD" | tr '"'"'"'\`()' ' ')" -CMD_NAMES="$(printf '%s' "$CMD_NAMES" | sed 's/\$/ /g')" +CMD_NAMES="$(printf '%s' "$CMD" | tr -d '"'"'"'\`()$\\')" # The one place the shape of a program NAME is written down. Both callers below # use it, so the next fix to this class lands in a single location instead of @@ -340,8 +349,12 @@ fi # command text at all. That is a limit of inspecting a command string rather # than a defect this pattern can close, and it is stated rather than left for # the next reader to find. +# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as +# the name — `--con"fig"` is one word spelling --config — and no review has +# raised it yet only because the name was the easier half to reach. Reading both +# halves the same way is the point of having one normalization. if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \ - && printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then + && printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then cat <<EOF BLOCKED: curl invocation whose request is supplied from a --config/-K file. @@ -648,9 +661,31 @@ edit — that one is a real wrapper gap, and the override exists for it." ;; && printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi printf '%s' "$CMD" | grep -Eq \ "$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1 - printf '%s' "$CMD" | grep -Eq \ - '(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \ - && unreadable=1 + # THIRD name consumer, and the one that proves the point about a single + # site: while the scope gate above was repaired for path- and quote-dressed + # provider CLIs, this fail-closed refinement kept its own bare-name copy of + # the same regex against raw $CMD. A caller could therefore enter the scope + # gate through the fixed check and then fail to be recognized by the arm + # that refuses unreadable endpoints — name recognition differing between a + # gate and its own refinement, which is the defect one layer downstream. + # It reads $CMD_NAMES through $NAME_PREFIX like every other name check. + # + # The endpoint tail still asks $CMD, deliberately: this arm fires on an + # endpoint the guard CANNOT READ, and the expansion markers that make it + # unreadable are exactly the characters $CMD_NAMES removes. Reading the tail + # from the normalized copy would erase the evidence the check exists to find. + # + # The tail carries NO name of its own. Leaving one there was the same defect + # a third time in the same edit — the name gate would recognize `g"h" api` + # while the tail still demanded the undressed spelling, so the two halves + # disagreed about the same caller and the refinement failed open. Each half + # now asks exactly one question: the name gate asks WHO, from the normalized + # copy; the tail asks whether the ENDPOINT is readable, from the raw text. + if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \ + && printf '%s' "$CMD" | grep -Eq \ + '(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then + unreadable=1 + fi if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then cat <<EOF -- 2.54.0 From 1c3e79a9ed35bfb2dd7c4524f6757a5738d31d2f Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 03:40:41 -0500 Subject: [PATCH 19/24] wrapper-guard tests: cover the shapes I had only reasoned about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-only. No change to the guard; every case below already behaves correctly at 46f52eed. They are committed because reasoning that a shape was already covered is exactly what produced rounds four and five, and an unmeasured belief about a security control is worth nothing. Three fail at df83a9ee and pass here, so they discriminate: \curl --config /tmp/w.cfg escaping the leading character cur"l" --config /tmp/w.cfg the quote at a different offset g"h" api -X POST "repos/a/b/$EP" … both halves dressed at once `\curl` is the ordinary way to bypass a shell alias. It is a thing people type, which makes it the least hypothetical entry in the file, and it was not covered by any of the eight fixtures added in the previous commit. The last one is the case I would have bet on breaking: the name gate reads the normalized copy while the unreadable-endpoint tail reads RAW text, so dressing BOTH halves at once is the input where those two readings are most likely to disagree. They do not — the tail matches through the quote — but the previous commit's message asserted that from reading the regex rather than running it, and one round earlier the same kind of assertion was wrong. Four negatives pass at BOTH heads and are here as regression guards: a quoted read, an ordinary download, `gh --version` with no api subcommand, and the word curl inside a string with no flag. Over-blocking is a real failure and not a safe direction — a guard that refuses legitimate work gets routed around instead of repaired, which costs more than the bypass it was protecting against. Suite 173/173. --- .../framework/tools/git/test-wrapper-guard.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index a280e41a..abe0750a 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -337,6 +337,23 @@ FIXTURES="$TMP/fixtures.tsv" 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 -- 2.54.0 From 3d0a882a6322e09eae2343bf19f9449f4a672b59 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 04:10:59 -0500 Subject: [PATCH 20/24] wrapper-guard: model quote removal and escaping as separate operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round six of the same class: a program NAME is not one SPELLING. Two findings, and the second is one this change's own predecessor introduced. A FOURTH name consumer never went through the shared site. Checkout detection still recognized git by a raw whole-command regex, so `g"it" clone`, `g'it' clone` and `g\it clone` into $HOME were all allowed. `/usr/bin/git` blocked only because the raw text still happened to contain contiguous `git` — the same passing presentation that established nothing during the curl rounds. It now uses CMD_NAMES and NAME_PREFIX like the other three, so all four consumers share one definition of what a name looks like. Routing it through NAME_PREFIX also repaired an over-block the arm had carried from the start: the old regex found `git` INSIDE a longer word, so `mygit clone` and `gitfoo clone` were refused at every previous head. That is the mycurl and curl-wrapper class, and refusing it is how a guard gets routed around instead of repaired. The normalization itself was creating names the shell never runs. It deleted every backslash regardless of quote context, but a backslash inside single quotes is literal, so `'cu\rl' --config` names a program called cu\rl and was refused. The same holds inside double quotes before any character other than $, `, " or backslash. Both were false positives, and both were regressions — the pre-PR head allowed them. Quote removal and escape handling are different operations that were sharing one context-blind deletion pass. They are now a small state machine that follows the actual rule: outside quotes a backslash escapes the next character; inside single quotes everything is literal; inside double quotes a backslash is special only before $, `, " or backslash. Quote characters drop without splitting the word, and the substitution flattening that makes `$(which curl)` resolve to a bare name is unchanged. An over-block is not the safe direction. A guard that refuses legitimate work gets routed around rather than fixed, which is the same outcome as a bypass and arrives faster. Unchanged and still disclosed: names absent from the literal text — assembled from braces or variables — remain invisible to text matching, and `$((curl))` is over-matched at every head including the pre-PR one. Fixtures: 173 -> 184. Every one added here discriminates against the previous head 1c3e79a9 (8 fail there: 3 git bypasses, 5 over-blocks), and the positives also fail at the pre-PR head df83a9ee. Suite green at head, bash -n and shellcheck clean, enumeration gate 55/38/18. --- .../framework/tools/git/test-wrapper-guard.sh | 26 +++++++ .../framework/tools/git/wrapper-guard.sh | 73 ++++++++++++++++--- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index abe0750a..b4304669 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -32,7 +32,17 @@ FIXTURES="$TMP/fixtures.tsv" { 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' printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\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' @@ -323,6 +333,22 @@ FIXTURES="$TMP/fixtures.tsv" 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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 963d4ead..ac12b449 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -49,9 +49,9 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" # A second reading of the SAME command, used by every check below that has to -# recognize a program by NAME. Quote characters, backslashes and the punctuation -# of command substitution are DELETED, so a name the shell will resolve is a word -# here regardless of how it was dressed: +# recognize a program by NAME. It applies shell word formation without executing +# expansions, so a name the shell will resolve is a word here regardless of how +# it was dressed: # # "/usr/bin/curl" --config /tmp/req quoted absolute path # './curl' --config /tmp/req quoted relative path @@ -73,10 +73,14 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" # because the whitespace happened to land after a slash — a passing case that # established nothing. # -# So the characters are DELETED rather than replaced, which is what quote removal -# is, and backslashes go with them because escaping is ordinary word formation -# too. Deletion also handles substitution: `$(which curl)` becomes `which curl`, -# where the name is a word on its own. +# Quote removal and escape handling are separate operations. Outside quotes, a +# backslash escapes the next character. Inside single quotes it is literal. +# Inside double quotes it escapes only $, `, ", backslash, or newline; before +# anything else both the backslash and following character remain literal. Quote +# characters themselves are dropped without splitting the word. The existing +# substitution flattening remains: unquoted and double-quoted $, (, ), and ` are +# dropped, so `$(which curl)` becomes `which curl`, where the name is a word on +# its own. # # This does NOT try to be a shell. It cannot see a name that is absent from the # text — assembled from variables, or reached through a wrapper script that @@ -89,10 +93,57 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" # these commands inside quotes on a Bash line is refused too. Applying that same # rule here keeps the file coherent — the alternative is a guard where the # payload arm treats quotes as text and the name arms treat them as armour. -CMD_NAMES="$(printf '%s' "$CMD" | tr -d '"'"'"'\`()$\\')" +normalize_command_names() { + awk ' + BEGIN { state = "outside"; out = "" } + { + if (NR > 1) out = out "\n" + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + if (state == "single") { + if (c == "\047") state = "outside" + else out = out c + continue + } + if (state == "double") { + if (c == "\"") { + state = "outside" + } else if (c == "\\") { + if (i == length($0)) { + out = out c + } else { + nextc = substr($0, i + 1, 1) + if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") { + out = out nextc + } else { + out = out c nextc + } + i++ + } + } else if (c != "$" && c != "(" && c != ")" && c != "`") { + out = out c + } + continue + } + if (c == "\\") { + if (i == length($0)) out = out c + else { out = out substr($0, i + 1, 1); i++ } + } else if (c == "\047") { + state = "single" + } else if (c == "\"") { + state = "double" + } else if (c != "$" && c != "(" && c != ")" && c != "`") { + out = out c + } + } + } + END { printf "%s", out } + ' +} +CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_names)" -# The one place the shape of a program NAME is written down. Both callers below -# use it, so the next fix to this class lands in a single location instead of +# The one place the shape of a program NAME is written down. Every name consumer +# below uses it, so the next fix to this class lands in a single location instead of # being applied to whichever arm review happened to probe. The prefix must end # at a slash: `mycurl` and `curl-wrapper` are different programs, and blocking # them is the over-block that gets a guard routed around instead of repaired. @@ -197,7 +248,7 @@ if [ "$home_known" -eq 1 ]; then home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" fi -if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then +if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then if [ "$home_known" -eq 0 ]; then cat <<EOF BLOCKED: this is a checkout, and \$HOME is unset or unusable in this shell. -- 2.54.0 From 4b8eba95a3a4e8ad68f2b946fa28986539e9bb77 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 04:38:44 -0500 Subject: [PATCH 21/24] fix(guard): apply word normalization to the path arm, not just the name arm The quote/escape handling added over rounds 1-6 was wired into the command NAME reading only. The PATH reading one line below it still matched the raw text, so every spelling the name arm had learned to see was invisible to the checkout check: `"$HOME"/wt`, `${HOME}/wt`, `"${HOME}"/wt` and a quoted literal home path all cleared a guard whose entire purpose is to refuse them. An identifier is not one spelling, and this file has now proven that seven times; the arms of the rule were audited one at a time, and the class survived in the arm nobody looked at. The two readings need the same quote and escape handling but differ in one respect, so this is one state machine with two modes rather than a copy: substitution flattening is correct for a name and wrong for a path, where an expansion-capable `$HOME` must stay visible. In path mode a shell-LITERAL dollar or tilde -- single-quoted, escaped, or a quoted tilde -- becomes an internal nonmatching marker, so quote removal cannot manufacture a home spelling the shell would never expand, and `"~/wt"` is no longer refused. `home_re` treats the braces as the pair they are. `$HOME}` expands HOME and appends a literal brace; `${HOME` is not an expansion at all. Admitting either as `${HOME}` would invent a home path the shell never resolves. Verified by oracle rather than by assertion: for each spelling, `printf` under bash performs expansion and quote removal without executing, and the resulting path decides the expected verdict. 24 spellings, 0 mismatched here; 6 mismatched at 3d0a882a, which is what makes this a fix and not a rewrite. Fixtures 184 -> 198. --- .../framework/tools/git/test-wrapper-guard.sh | 24 +++++- .../framework/tools/git/wrapper-guard.sh | 75 ++++++++++++++----- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index b4304669..d0a3998f 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -35,6 +35,22 @@ FIXTURES="$TMP/fixtures.tsv" 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' + 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}/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' # 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 @@ -438,10 +454,12 @@ 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' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$GUARD" 2>&1)" + out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)" else - out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$GUARD" 2>&1)" + out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)" fi got=$? if [ "$got" != "$want" ]; then @@ -467,6 +485,8 @@ 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 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 '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' # The fail-closed arm is scoped to checkouts. If it were not, a seat with no diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index ac12b449..9cd9d10e 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -93,16 +93,29 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')" # these commands inside quotes on a Bash line is refused too. Applying that same # rule here keeps the file coherent — the alternative is a guard where the # payload arm treats quotes as text and the name arms treat them as armour. -normalize_command_names() { - awk ' - BEGIN { state = "outside"; out = "" } +normalize_command_words() { + local flatten_substitutions="${1:-1}" + local protect_path_literals="${2:-0}" + awk -v flatten_substitutions="$flatten_substitutions" \ + -v protect_path_literals="$protect_path_literals" ' + BEGIN { + state = "outside"; out = ""; word_start = 1 + literal_dollar = sprintf("%c", 28) + literal_tilde = sprintf("%c", 29) + } { - if (NR > 1) out = out "\n" + if (NR > 1) { out = out "\n"; word_start = 1 } for (i = 1; i <= length($0); i++) { c = substr($0, i, 1) if (state == "single") { - if (c == "\047") state = "outside" - else out = out c + if (c == "\047") { + state = "outside" + } else { + if (protect_path_literals && c == "$") out = out literal_dollar + else if (protect_path_literals && c == "~") out = out literal_tilde + else out = out c + word_start = 0 + } continue } if (state == "double") { @@ -114,33 +127,58 @@ normalize_command_names() { } else { nextc = substr($0, i + 1, 1) if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") { - out = out nextc + if (protect_path_literals && nextc == "$") out = out literal_dollar + else out = out nextc } else { - out = out c nextc + out = out c + if (protect_path_literals && nextc == "~") out = out literal_tilde + else out = out nextc } + word_start = 0 i++ } - } else if (c != "$" && c != "(" && c != ")" && c != "`") { - out = out c + } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) { + if (protect_path_literals && c == "~") out = out literal_tilde + else out = out c + word_start = 0 } continue } if (c == "\\") { - if (i == length($0)) out = out c - else { out = out substr($0, i + 1, 1); i++ } + if (i == length($0)) { + out = out c + } else { + nextc = substr($0, i + 1, 1) + if (protect_path_literals && nextc == "$") out = out literal_dollar + else if (protect_path_literals && nextc == "~") out = out literal_tilde + else out = out nextc + i++ + } + word_start = 0 } else if (c == "\047") { state = "single" } else if (c == "\"") { state = "double" - } else if (c != "$" && c != "(" && c != ")" && c != "`") { - out = out c + } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) { + if (protect_path_literals && c == "~" && !word_start) out = out literal_tilde + else out = out c + if (c ~ /[[:space:]|;&]/) word_start = 1 + else word_start = 0 } } } END { printf "%s", out } ' } -CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_names)" +CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)" +# Paths need the same quote and escape handling, but not name-mode substitution +# flattening: an expansion-capable `$HOME` spelling must remain visible to the +# checkout check. Shell-literal dollar/tilde characters (single-quoted, escaped, +# or a quoted tilde) become internal nonmatching markers; otherwise quote removal +# would create a HOME spelling the shell never expands. A path assembled from a +# different variable or command substitution is absent from the literal text and +# remains outside a text guard's visibility. +CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)" # The one place the shape of a program NAME is written down. Every name consumer # below uses it, so the next fix to this class lands in a single location instead of @@ -243,7 +281,10 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # left unresolved BLOCKS rather than clears — a guard may not clear a question it # was unable to ask. The blast radius of that fail-closed arm is exactly one # command shape (clone / worktree add), not the session. -home_re='~|\$HOME' +# Braces are a pair, not independently optional: `$HOME}` expands HOME and +# appends a literal `}`, while `${HOME` is not a valid expansion. Treating either +# as `${HOME}` would create a home path the shell never resolves. +home_re='~|\$HOME|\$\{HOME\}' if [ "$home_known" -eq 1 ]; then home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" fi @@ -267,7 +308,7 @@ EOF exit 2 fi # Any argument that resolves under $HOME and is not under a work filesystem. - if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])($home_re)/"; then + if printf '%s' "$CMD_PATHS" | grep -Eq "(^|[[:space:]=])($home_re)/"; then cat <<EOF BLOCKED: this checks a repository out under \$HOME. -- 2.54.0 From 20d86e392bef528787cbb9f05596acc66c66b9d0 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 05:08:01 -0500 Subject: [PATCH 22/24] fix(guard): end the home match at a shell word boundary, not at whitespace Rounds 8 and 9 of the same class, in the two halves of one line. The path arm required the home token to be followed by `/`. That silently made `$HOME` itself -- the exact target the rule names -- legal: `git worktree add $HOME` cleared a guard whose message is "this checks a repository out under $HOME". Reachability is not theoretical; the command succeeds against an empty home directory. Trailing `/` was then admitted, and with it every terminator that is not whitespace: `$HOME;`, `$HOME&&`, `$HOME|`, `$HOME&` and end-of-string all cleared, 25 shapes in all. The fix that did not happen is worth recording, because it was mine. The brief for this round prescribed a closed continuation class, `([^A-Za-z0-9_.-]|$)`, on the reasoning that terminator sets are open and continuation sets are closed. That is true of some axes and false of this one: `+ @ , : = %` all continue a FILENAME, so `$HOME+bak/wt` and five siblings like it would have been refused -- a new over-block traded for a closed bypass, which is not a trade. The implementer measured the six counterexamples and declined the brief rather than pick between two acceptance conditions that cannot both hold. They are now permanent fixtures; a rejected over-block that nothing pins comes back. The axis that IS closed is word termination, and it is closed by specification rather than by anyone's imagination: POSIX fixes the unquoted metacharacter set at space, tab, newline, and | & ; ( ) < >. So the path normalizer marks those as an internal word boundary, in the same state machine and by the same mechanism as the existing literal-dollar and literal-tilde markers, which is what lets a QUOTED or escaped metacharacter stay word content: `"$HOME;bak"` is one word and must be allowed. A raw marker byte arriving in the input is encoded first, so input cannot forge or suppress a boundary. The home token must now be preceded by start, `=`, or a boundary, and followed by a boundary, `/` for a descendant, or end. Verified by oracle rather than against the brief -- `bash -c "printf '%s' WORD"` performs expansion and quote removal without executing, so the expected verdict comes from the shell instead of from the reading that has now been wrong once. Fixtures 198 -> 230; the new ones are red at both prior heads (15 failing at 4b8eba95, 21 at 3d0a882a), so they measure the change rather than passing on it. Known and deliberately not addressed here: a checkout target that never names $HOME at all. A relative target resolves against the cwd, and every agent seat on this host runs with a cwd under $HOME, so `git clone URL` with no target at all lands in $HOME and is invisible to a rule that matches home spellings. That is a different rule -- it needs the effective cwd, which `cd` inside the command can move -- and it is filed separately rather than becoming round ten in this file. --- .../framework/tools/git/test-wrapper-guard.sh | 37 +++++++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 35 ++++++++++++++---- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index d0a3998f..bc201aab 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -40,8 +40,41 @@ FIXTURES="$TMP/fixtures.tsv" 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' + # 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. @@ -485,6 +518,10 @@ 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 'and the unexpanded $HOME spelling, which needs no resolution at all' \ diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 9cd9d10e..570ec7b9 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -100,13 +100,23 @@ normalize_command_words() { -v protect_path_literals="$protect_path_literals" ' BEGIN { state = "outside"; out = ""; word_start = 1 + word_boundary = sprintf("%c", 27) literal_dollar = sprintf("%c", 28) literal_tilde = sprintf("%c", 29) } { - if (NR > 1) { out = out "\n"; word_start = 1 } + if (NR > 1) { + if (protect_path_literals) out = out word_boundary + else out = out "\n" + word_start = 1 + } for (i = 1; i <= length($0); i++) { c = substr($0, i, 1) + # A raw marker byte is ordinary word content. Encode it visibly so only + # this machine can manufacture an internal boundary marker. + if (protect_path_literals && c == word_boundary) { + out = out "\\x1b"; word_start = 0; continue + } if (state == "single") { if (c == "\047") { state = "outside" @@ -144,7 +154,10 @@ normalize_command_words() { } continue } - if (c == "\\") { + if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) { + out = out word_boundary + word_start = 1 + } else if (c == "\\") { if (i == length($0)) { out = out c } else { @@ -173,12 +186,14 @@ normalize_command_words() { CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)" # Paths need the same quote and escape handling, but not name-mode substitution # flattening: an expansion-capable `$HOME` spelling must remain visible to the -# checkout check. Shell-literal dollar/tilde characters (single-quoted, escaped, -# or a quoted tilde) become internal nonmatching markers; otherwise quote removal -# would create a HOME spelling the shell never expands. A path assembled from a -# different variable or command substitution is absent from the literal text and +# checkout check. Unquoted POSIX shell metacharacters become an internal word- +# boundary marker; quoted/escaped metacharacters remain content. Shell-literal +# dollar/tilde characters become separate nonmatching markers; otherwise quote +# removal would create a HOME spelling the shell never expands. A path assembled +# from a different variable or command substitution is absent from the literal text and # remains outside a text guard's visibility. CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)" +PATH_WORD_BOUNDARY=$'\033' # The one place the shape of a program NAME is written down. Every name consumer # below uses it, so the next fix to this class lands in a single location instead of @@ -307,8 +322,12 @@ derives the path and never consults \$HOME at all: EOF exit 2 fi - # Any argument that resolves under $HOME and is not under a work filesystem. - if printf '%s' "$CMD_PATHS" | grep -Eq "(^|[[:space:]=])($home_re)/"; then + # Any argument that resolves to $HOME itself or beneath it. The path-mode + # normalizer marks every unquoted POSIX shell word boundary, so the home token + # must be preceded by a boundary/start/assignment and followed by a boundary, + # slash (a descendant), or end. Arbitrary filename bytes remain content; this + # avoids over-blocking sibling names such as $HOME+bak or $HOME.bak. + if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then cat <<EOF BLOCKED: this checks a repository out under \$HOME. -- 2.54.0 From 91cc37bcf6170e80a56680b2c0dcdc60753ded54 Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 05:55:33 -0500 Subject: [PATCH 23/24] fix(guard): inspect checkout placement operands Classify git clone and worktree add operands so HOME-valued environment assignments, sources, references, templates, and metadata do not impersonate checkout destinations. Preserve both forms of clone --separate-git-dir as real placement targets and distinguish shell words, command boundaries, and redirections in the existing quote-aware normalized stream. Deliberate fail-closed residual: unknown future Git options with a separate following word are not adjudicated as source-only. Their value remains a possible placement, so a HOME-shaped value blocks rather than silently creating a bypass. Relative destinations whose effective path depends on cwd remain out of scope in #1197. --- .../scratchpads/1174-wrapper-guard-round10.md | 30 ++++ .../framework/tools/git/test-wrapper-guard.sh | 15 ++ .../framework/tools/git/wrapper-guard.sh | 150 +++++++++++++++++- 3 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 docs/scratchpads/1174-wrapper-guard-round10.md diff --git a/docs/scratchpads/1174-wrapper-guard-round10.md b/docs/scratchpads/1174-wrapper-guard-round10.md new file mode 100644 index 00000000..e6886619 --- /dev/null +++ b/docs/scratchpads/1174-wrapper-guard-round10.md @@ -0,0 +1,30 @@ +# #1174 — Wrapper guard round 10 + +## 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. diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index bc201aab..45a4d102 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -85,6 +85,21 @@ FIXTURES="$TMP/fixtures.tsv" 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' # 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. diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 570ec7b9..6e9ea867 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -100,13 +100,15 @@ normalize_command_words() { -v protect_path_literals="$protect_path_literals" ' BEGIN { state = "outside"; out = ""; word_start = 1 + redirection = sprintf("%c", 25) + command_boundary = sprintf("%c", 26) word_boundary = sprintf("%c", 27) literal_dollar = sprintf("%c", 28) literal_tilde = sprintf("%c", 29) } { if (NR > 1) { - if (protect_path_literals) out = out word_boundary + if (protect_path_literals) out = out command_boundary else out = out "\n" word_start = 1 } @@ -114,6 +116,12 @@ normalize_command_words() { c = substr($0, i, 1) # A raw marker byte is ordinary word content. Encode it visibly so only # this machine can manufacture an internal boundary marker. + if (protect_path_literals && c == redirection) { + out = out "\\x19"; word_start = 0; continue + } + if (protect_path_literals && c == command_boundary) { + out = out "\\x1a"; word_start = 0; continue + } if (protect_path_literals && c == word_boundary) { out = out "\\x1b"; word_start = 0; continue } @@ -154,9 +162,15 @@ normalize_command_words() { } continue } - if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) { + if (protect_path_literals && c ~ /[[:space:]]/) { out = out word_boundary word_start = 1 + } else if (protect_path_literals && c ~ /[|&;()]/) { + out = out command_boundary + word_start = 1 + } else if (protect_path_literals && c ~ /[<>]/) { + out = out word_boundary redirection word_boundary + word_start = 1 } else if (c == "\\") { if (i == length($0)) { out = out c @@ -193,6 +207,8 @@ CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)" # from a different variable or command substitution is absent from the literal text and # remains outside a text guard's visibility. CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)" +PATH_REDIRECTION=$'\031' +PATH_COMMAND_BOUNDARY=$'\032' PATH_WORD_BOUNDARY=$'\033' # The one place the shape of a program NAME is written down. Every name consumer @@ -304,6 +320,118 @@ if [ "$home_known" -eq 1 ]; then home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" fi +# Emit only paths the checkout syntax can PLACE. The previous whole-command +# match treated a HOME-valued environment assignment, reference, or template as +# the destination. Conversely, dropping `=` entirely lost clone's +# --separate-git-dir=<path>, which really does create repository state there. +# +# The path normalizer supplies three collision-safe lexical markers. Command +# markers bound each simple command; word markers split arguments without +# splitting quoted whitespace; redirection markers let this scanner discard +# redirection operands rather than mistake them for clone destinations. +# +# Known Git options are classified by whether they consume a value. An unknown +# option with a separate following word is deliberately emitted as a possible +# placement as well as left available to positional parsing: Git can add a new +# path-taking option, and an unreadable option value must fail closed when it +# names HOME rather than silently becoming another bypass. +checkout_placements() { + printf '%s' "$CMD_PATHS" | awk \ + -v wb="$PATH_WORD_BOUNDARY" \ + -v cb="$PATH_COMMAND_BOUNDARY" \ + -v rb="$PATH_REDIRECTION" ' + BEGIN { RS = cb; FS = wb } + + function is_git(word) { + return word ~ /(^|\/)git$/ + } + function clone_value_option(word) { + return word ~ /^(-[obujc]|--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format))$/ + } + function clone_flag_option(word) { + return word ~ /^--(local|no-hardlinks|shared|dissociate|quiet|verbose|progress|no-checkout|reject-shallow|no-tags|single-branch|no-single-branch|recurse-submodules|shallow-submodules|remote-submodules|sparse|also-filter-submodules)$/ || word ~ /^-[lqvns]$/ + } + function worktree_value_option(word) { + return word ~ /^(-b|-B|--reason|--orphan)$/ + } + function worktree_flag_option(word) { + return word ~ /^--(force|detach|checkout|no-checkout|lock|guess-remote|track|no-track)$/ || word == "-f" + } + function attached_known_value(word) { + return word ~ /^-[obujc].+/ || word ~ /^--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format)=/ || word ~ /^(-b|-B).+/ || word ~ /^--(reason|orphan|track)=/ + } + function emit_clone(start, count, i, j, word, options, positions, value) { + delete positional + options = 1; positions = 0 + for (i = start; i <= count; i++) { + word = token[i] + if (options && word == "--") { options = 0; continue } + if (options && word ~ /^--separate-git-dir=/) { + value = substr(word, index(word, "=") + 1) + if (value != "") print value + continue + } + if (options && word == "--separate-git-dir") { + if (i < count) print token[++i] + continue + } + if (options && clone_value_option(word)) { i++; continue } + if (options && (clone_flag_option(word) || attached_known_value(word))) continue + if (options && word ~ /^-/) { + # Unknown separate option value: fail closed if it is HOME-shaped. + if (word !~ /=/ && i < count) print token[i + 1] + continue + } + positional[++positions] = word + } + # clone positional 1 is the source; every later positional can only be an + # explicit destination (or invalid excess input, which remains fail closed). + for (j = 2; j <= positions; j++) print positional[j] + } + function emit_worktree(start, count, i, word, options) { + options = 1 + for (i = start; i <= count; i++) { + word = token[i] + if (options && word == "--") { options = 0; continue } + if (options && worktree_value_option(word)) { i++; continue } + if (options && (worktree_flag_option(word) || attached_known_value(word))) continue + if (options && word ~ /^-/) { + # Same forward-compatible fail-closed rule as clone. + if (word !~ /=/ && i < count) print token[i + 1] + continue + } + print word + return + } + } + { + delete raw; delete token + raw_count = 0 + for (i = 1; i <= NF; i++) if ($i != "") raw[++raw_count] = $i + + # Remove redirection operators and their operands. A numeric fd attached + # before the operator is not an argument either. + count = 0 + for (i = 1; i <= raw_count; i++) { + if (i < raw_count && raw[i + 1] == rb && raw[i] ~ /^[0-9]+$/) { + i += 2 + continue + } + if (raw[i] == rb) { i++; continue } + token[++count] = raw[i] + } + + for (i = 1; i <= count; i++) { + if (!is_git(token[i])) continue + if (token[i + 1] == "clone") emit_clone(i + 2, count) + else if (token[i + 1] == "worktree" && token[i + 2] == "add") { + emit_worktree(i + 3, count) + } + } + } + ' +} + if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then if [ "$home_known" -eq 0 ]; then cat <<EOF @@ -322,12 +450,18 @@ derives the path and never consults \$HOME at all: EOF exit 2 fi - # Any argument that resolves to $HOME itself or beneath it. The path-mode - # normalizer marks every unquoted POSIX shell word boundary, so the home token - # must be preceded by a boundary/start/assignment and followed by a boundary, - # slash (a descendant), or end. Arbitrary filename bytes remain content; this - # avoids over-blocking sibling names such as $HOME+bak or $HOME.bak. - if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then + # A placement resolves to HOME when it is the exact home token or a descendant. + # Matching an extracted argument rather than the whole command is what keeps a + # HOME-valued source, option, or environment assignment from impersonating it. + placement_blocked=0 + while IFS= read -r placement; do + [ -n "$placement" ] || continue + if printf '%s' "$placement" | grep -Eq "^($home_re)(/|$)"; then + placement_blocked=1 + break + fi + done < <(checkout_placements) + if [ "$placement_blocked" -eq 1 ]; then cat <<EOF BLOCKED: this checks a repository out under \$HOME. -- 2.54.0 From c09392e0c4f3fcbaf956386b3955fc19bb310f4e Mon Sep 17 00:00:00 2001 From: Hermes Agent <hermes@web1.uscllc.com> Date: Thu, 13 Aug 2026 06:46:25 -0500 Subject: [PATCH 24/24] fix(guard): classify Git option grammar Replace finite clone/worktree boolean allowlists with the closed separate-value grammar, including Git's accepted long abbreviations and bundled short options. Keep placement operands distinct from HOME-valued sources, metadata, commit-ish values, and rule-generated --no-* flags while preserving separate-git-dir and later-command traps. Canonicalize shell-known HOME spellings, dot aliases, and existing symlink parents before placement comparison. Expand the hermetic suite from 242 to 292 fixtures and document the requirements and review evidence. Deliberate residuals: a future unclassified value-taking clone placement option can fail open, and a future worktree value option can shift the inferred path; defaulting it to flag grammar avoids present-day over-blocking of Git's non-enumerable boolean family. PreToolUse symlink canonicalization is non-atomic against replacement after inspection; architectural closure is tracked by #1199. --- docs/PRD.md | 53 +++++ .../scratchpads/1174-wrapper-guard-round10.md | 61 +++++- .../framework/tools/git/test-wrapper-guard.sh | 83 +++++++ .../framework/tools/git/wrapper-guard.sh | 206 ++++++++++++++---- 4 files changed, 364 insertions(+), 39 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 806ea7d3..b8791b85 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1529,6 +1529,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 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. diff --git a/docs/scratchpads/1174-wrapper-guard-round10.md b/docs/scratchpads/1174-wrapper-guard-round10.md index e6886619..3629652f 100644 --- a/docs/scratchpads/1174-wrapper-guard-round10.md +++ b/docs/scratchpads/1174-wrapper-guard-round10.md @@ -1,4 +1,4 @@ -# #1174 — Wrapper guard round 10 +# #1174 — Wrapper guard rounds 10–11 ## Objective @@ -28,3 +28,62 @@ Make checkout enforcement judge Git placement operands rather than every HOME-sh - 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. diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index 45a4d102..a72fe1e1 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -40,6 +40,12 @@ FIXTURES="$TMP/fixtures.tsv" 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' @@ -100,6 +106,57 @@ FIXTURES="$TMP/fixtures.tsv" 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. @@ -539,8 +596,34 @@ 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. diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 6e9ea867..e2a226d4 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -288,6 +288,104 @@ case "${HOME-}" in /?*) HOME_DIR="$HOME"; home_known=1 ;; esac +# Resolve shell-known HOME spellings without evaluating arbitrary expansions. +# Path mode already encoded quoted/escaped dollar and tilde markers, so only +# expansion-capable spellings reach these token replacements. +expand_known_home() { + local path="$1" + awk -v path="$path" -v home="$HOME_DIR" ' + function replace_home_token(value, token, bounded, out, pos, rest, nextc) { + out = "" + while ((pos = index(value, token)) > 0) { + rest = substr(value, pos + length(token)) + nextc = substr(rest, 1, 1) + if (!bounded || nextc == "" || nextc !~ /[[:alnum:]_]/) { + out = out substr(value, 1, pos - 1) home + value = rest + } else { + out = out substr(value, 1, pos + length(token) - 1) + value = rest + } + } + return out value + } + BEGIN { + path = replace_home_token(path, "${HOME}", 0) + path = replace_home_token(path, "$HOME", 1) + if (path == "~" || substr(path, 1, 2) == "~/") { + path = home substr(path, 2) + } + printf "%s", path + } + ' +} + +# Collapse repeated separators and dot segments after filesystem resolution. +lexically_normalize_absolute_path() { + local path="$1" + awk -v path="$path" ' + BEGIN { + if (substr(path, 1, 1) != "/") { + printf "%s", path + exit + } + count = split(path, component, "/") + depth = 0 + for (i = 1; i <= count; i++) { + if (component[i] == "" || component[i] == ".") continue + if (component[i] == "..") { + if (depth > 0) depth-- + continue + } + normalized[++depth] = component[i] + } + printf "/" + for (i = 1; i <= depth; i++) { + if (i > 1) printf "/" + printf "%s", normalized[i] + } + } + ' +} + +# Resolve the longest existing directory prefix physically, then append and +# normalize the nonexistent suffix. Resolving before collapsing `..` matters: +# the kernel follows a symlink first, then applies the parent segment. This is a +# pre-execution check, so a concurrent symlink replacement remains an inherent +# TOCTOU residual; existing aliases are nevertheless adjudicated correctly. +canonicalize_placement_path() { + local expanded candidate suffix leaf physical + expanded="$(expand_known_home "$1")" + case "$expanded" in + /*) ;; + *) printf '%s' "$expanded"; return 0 ;; + esac + + candidate="$expanded" + suffix="" + while [ "$candidate" != "/" ] && [ "${candidate%/}" != "$candidate" ]; do + candidate="${candidate%/}" + done + while [ ! -d "$candidate" ]; do + [ "$candidate" = "/" ] && break + leaf="${candidate##*/}" + suffix="/$leaf$suffix" + candidate="${candidate%/*}" + [ -n "$candidate" ] || candidate="/" + done + + physical="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1 + lexically_normalize_absolute_path "$physical$suffix" +} + +HOME_CANON="" +if [ "$home_known" -eq 1 ]; then + if ! HOME_CANON="$(canonicalize_placement_path "$HOME_DIR")"; then + HOME_CANON="" + home_known=0 + fi +fi + W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" [ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git" @@ -317,7 +415,7 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # as `${HOME}` would create a home path the shell never resolves. home_re='~|\$HOME|\$\{HOME\}' if [ "$home_known" -eq 1 ]; then - home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" + home_re="$home_re|$(printf '%s' "$HOME_CANON" | sed 's/[][\.*^$+?(){}|]/\\&/g')" fi # Emit only paths the checkout syntax can PLACE. The previous whole-command @@ -330,58 +428,89 @@ fi # splitting quoted whitespace; redirection markers let this scanner discard # redirection operands rather than mistake them for clone destinations. # -# Known Git options are classified by whether they consume a value. An unknown -# option with a separate following word is deliberately emitted as a possible -# placement as well as left available to positional parsing: Git can add a new -# path-taking option, and an unreadable option value must fail closed when it -# names HOME rather than silently becoming another bypass. +# Git options are classified by the small, closed grammar that consumes a +# SEPARATE value. Boolean flags are deliberately not listed: Git generates a +# `--no-` spelling for every boolean option, so that family is defined by a rule +# and cannot be completed by enumeration. Any option not in the separate-value +# grammar is one option token and leaves the next word in positional context. +# +# Git also accepts unique long-option abbreviations and bundled short options. +# Prefix matching models the former. For a short bundle, the first value-taking +# letter consumes the rest of that token; it consumes the next word only when it +# is the bundle's final letter. +# +# Deliberate residual: a future value-taking option absent from these closed +# lists defaults to flag grammar, so its following word remains positional. For +# clone, that can fail open if the future option itself places repository state. +# For worktree, it can shift which word is read as the path. Every value-taking +# and placement option Git supports today is classified (including accepted +# abbreviations of `--separate-git-dir`). Accepting this hypothetical future +# ambiguity avoids failing closed on Git's unbounded present-day boolean and +# generated-negation family. checkout_placements() { printf '%s' "$CMD_PATHS" | awk \ -v wb="$PATH_WORD_BOUNDARY" \ -v cb="$PATH_COMMAND_BOUNDARY" \ -v rb="$PATH_REDIRECTION" ' - BEGIN { RS = cb; FS = wb } + BEGIN { + RS = cb; FS = wb + clone_value_count = split("--origin --branch --upload-pack --template --reference --reference-if-able --depth --shallow-since --shallow-exclude --filter --server-option --jobs --config --bundle-uri --revision --ref-format", clone_value_name, " ") + worktree_value_count = split("--reason", worktree_value_name, " ") + clone_placement_name = "--separate-git-dir" + } function is_git(word) { return word ~ /(^|\/)git$/ } - function clone_value_option(word) { - return word ~ /^(-[obujc]|--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format))$/ + function clone_separate_value_option(word, i, c) { + if (word ~ /^-[^-]/) { + for (i = 2; i <= length(word); i++) { + c = substr(word, i, 1) + if (c ~ /[obujc]/) return i == length(word) + } + return 0 + } + if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0 + for (i = 1; i <= clone_value_count; i++) { + if (index(clone_value_name[i], word) == 1) return 1 + } + return 0 } - function clone_flag_option(word) { - return word ~ /^--(local|no-hardlinks|shared|dissociate|quiet|verbose|progress|no-checkout|reject-shallow|no-tags|single-branch|no-single-branch|recurse-submodules|shallow-submodules|remote-submodules|sparse|also-filter-submodules)$/ || word ~ /^-[lqvns]$/ + function clone_placement_option(word) { + return word ~ /^--/ && word !~ /^--no-/ && index(clone_placement_name, word) == 1 } - function worktree_value_option(word) { - return word ~ /^(-b|-B|--reason|--orphan)$/ + function worktree_separate_value_option(word, i, c) { + if (word ~ /^-[^-]/) { + for (i = 2; i <= length(word); i++) { + c = substr(word, i, 1) + if (c ~ /[bB]/) return i == length(word) + } + return 0 + } + if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0 + for (i = 1; i <= worktree_value_count; i++) { + if (index(worktree_value_name[i], word) == 1) return 1 + } + return 0 } - function worktree_flag_option(word) { - return word ~ /^--(force|detach|checkout|no-checkout|lock|guess-remote|track|no-track)$/ || word == "-f" - } - function attached_known_value(word) { - return word ~ /^-[obujc].+/ || word ~ /^--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format)=/ || word ~ /^(-b|-B).+/ || word ~ /^--(reason|orphan|track)=/ - } - function emit_clone(start, count, i, j, word, options, positions, value) { + function emit_clone(start, count, i, j, word, options, positions, equals, value) { delete positional options = 1; positions = 0 for (i = start; i <= count; i++) { word = token[i] if (options && word == "--") { options = 0; continue } - if (options && word ~ /^--separate-git-dir=/) { - value = substr(word, index(word, "=") + 1) + equals = index(word, "=") + if (options && equals > 0 && clone_placement_option(substr(word, 1, equals - 1))) { + value = substr(word, equals + 1) if (value != "") print value continue } - if (options && word == "--separate-git-dir") { + if (options && clone_placement_option(word)) { if (i < count) print token[++i] continue } - if (options && clone_value_option(word)) { i++; continue } - if (options && (clone_flag_option(word) || attached_known_value(word))) continue - if (options && word ~ /^-/) { - # Unknown separate option value: fail closed if it is HOME-shaped. - if (word !~ /=/ && i < count) print token[i + 1] - continue - } + if (options && clone_separate_value_option(word)) { i++; continue } + if (options && word ~ /^-/) continue positional[++positions] = word } # clone positional 1 is the source; every later positional can only be an @@ -393,13 +522,10 @@ checkout_placements() { for (i = start; i <= count; i++) { word = token[i] if (options && word == "--") { options = 0; continue } - if (options && worktree_value_option(word)) { i++; continue } - if (options && (worktree_flag_option(word) || attached_known_value(word))) continue - if (options && word ~ /^-/) { - # Same forward-compatible fail-closed rule as clone. - if (word !~ /=/ && i < count) print token[i + 1] - continue - } + if (options && worktree_separate_value_option(word)) { i++; continue } + if (options && word ~ /^-/) continue + # Only the first positional is placement; the optional second one is + # commit-ish metadata and must not impersonate the worktree path. print word return } @@ -456,7 +582,11 @@ EOF placement_blocked=0 while IFS= read -r placement; do [ -n "$placement" ] || continue - if printf '%s' "$placement" | grep -Eq "^($home_re)(/|$)"; then + if ! normalized_placement="$(canonicalize_placement_path "$placement")"; then + placement_blocked=1 + break + fi + if printf '%s' "$normalized_placement" | grep -Eq "^($home_re)(/|$)"; then placement_blocked=1 break fi -- 2.54.0