framework: make tool discoverability, workspace placement and model tiering mechanical #1174

Merged
Mos merged 24 commits from feat/workspace-hygiene-tool-enforcement into main 2026-08-13 14:21:23 +00:00
Contributor

Three prose rules that kept failing get mechanical enforcement, and one framework
default gets written down.

1. Tool discoverability — check-tools-index.sh + a complete index

guides/TOOLS-REFERENCE.md listed 6 of the 26 shipped tools/git/ wrappers. From inside
an agent session an undocumented tool is indistinguishable from one that was never
written, so the agent reaches for raw curl instead — which is how a Gitea review got
filed PENDING three times running, because raw curl does not know Gitea's approve event
is APPROVED while GitHub's is APPROVE, and Gitea accepts the wrong one with HTTP 200.

The index is now complete (28/28, grouped by task rather than alphabetised) and stays
complete mechanically: the new CI gate fails when a wrapper ships without an entry, and
fails in the reverse direction when an entry names a wrapper that no longer exists.
Shipping and documenting are one commit, or red.

The gate carries --self-test (3/3) and CI runs both. A checker that only ever passes is
indistinguishable from one that is not running.

2. Workspace placement — mosaic-worktree.sh

The rule already existed, in the framework's own words ("Big work → /var/tmp"), and 255 GB
accumulated in $HOME across 842 directories anyway, under five simultaneously-live
placement conventions. Every one of those was a decision an agent had to make, and a
decision an agent has to make is a decision that drifts.

So the helper makes no placement decision available to the caller. The caller supplies
a branch name; every path is derived — main worktree from git worktree list --porcelain
(never cwd, which may itself be a worktree), then repo name, parent, root, slug. It
refuses outright when the derivation lands under $HOME.

rm and gc reclaim by evidence, never by size or age: clean tree AND zero commits
absent from every remote. A 40 GB worktree with one uncommitted file is preserved; a 2 MB
one that is clean and pushed is not special. This is only safe because worktrees are
enumerable through git worktree list — a stray bare clone never can be.

3. wrapper-guard.sh — PreToolUse hook, registered in the runtime template

Blocks three mechanically-detectable mistakes: a checkout targeting $HOME, a raw
provider API write to an endpoint that already has a wrapper (gate 7), and the literal
"event": "APPROVE" anywhere. Reads, unwrapped endpoints and ordinary commands pass
untouched — a guard that over-blocks gets routed around, which fails just as hard.

Registered in runtime/claude/settings.json rather than a host's ~/.claude/settings.json,
because that file is a framework-managed copy and a hand-edit there is destroyed by the
next upgrade.

Two things testing found that reading did not:

  • The guard failed open wherever no mosaic home was installed. It resolved wrappers
    from a hardcoded $HOME/.config/mosaic/tools/git, so in a CI container or a bare
    checkout every [ -x ] missed and the raw write it exists to block sailed through. Now
    resolved relative to ${BASH_SOURCE[0]} with $HOME as fallback, and asserted under
    env -i HOME=/nonexistent.
  • The guard blocked its own first hand-written test, because that test embedded the
    fixture text in its own command line. test-wrapper-guard.sh writes fixtures to a file
    for exactly this reason, and says so in a comment so the next person does not rediscover
    it.

12 hermetic fixtures, both directions asserted, wired into CI.

4. Model tiering as a standard (defaults/STANDARDS.md)

Written as capability classes — search / build / judge / adversarial — not vendor
model names, so it survives a model generation. Four rules: start cheapest and escalate on
evidence; compatibility-sensitive work escalates one class; a tier assignment is
benchmarked, not asserted; and reviewer independence beats reviewer size, which is gate 16
restated where it will actually be read.

"Where the binding lives" is stated explicitly, because a standard with no binding layer
becomes prose that decays: the DB-backed config service surfaced in the Mosaic webUI
first, then STANDARDS.local.md / policy/, then the framework default.


Gates run locally: verify-sanitized.sh pass · check-resident-budget.sh self-test +
all files within ceiling · check-test-enumeration.sh OK (population 54) ·
check-tools-index.sh --self-test 3/3 and git 100% (28/28) [enforced] ·
test-wrapper-guard.sh 12/12 including under env -i.

Review: author ≠ reviewer (L0 gate 16). Not self-merging.

Three prose rules that kept failing get mechanical enforcement, and one framework default gets written down. ## 1. Tool discoverability — `check-tools-index.sh` + a complete index `guides/TOOLS-REFERENCE.md` listed 6 of the 26 shipped `tools/git/` wrappers. From inside an agent session an undocumented tool is indistinguishable from one that was never written, so the agent reaches for raw `curl` instead — which is how a Gitea review got filed PENDING three times running, because raw curl does not know Gitea's approve event is `APPROVED` while GitHub's is `APPROVE`, and Gitea accepts the wrong one with HTTP 200. The index is now complete (28/28, grouped by task rather than alphabetised) and stays complete mechanically: the new CI gate fails when a wrapper ships without an entry, and fails in the reverse direction when an entry names a wrapper that no longer exists. Shipping and documenting are one commit, or red. The gate carries `--self-test` (3/3) and CI runs both. A checker that only ever passes is indistinguishable from one that is not running. ## 2. Workspace placement — `mosaic-worktree.sh` The rule already existed, in the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in `$HOME` across 842 directories anyway, under five simultaneously-live placement conventions. Every one of those was a decision an agent had to make, and a decision an agent has to make is a decision that drifts. So the helper makes **no placement decision available to the caller**. The caller supplies a branch name; every path is derived — main worktree from `git worktree list --porcelain` (never cwd, which may itself be a worktree), then repo name, parent, root, slug. It refuses outright when the derivation lands under `$HOME`. `rm` and `gc` reclaim by **evidence, never by size or age**: clean tree AND zero commits absent from every remote. A 40 GB worktree with one uncommitted file is preserved; a 2 MB one that is clean and pushed is not special. This is only safe because worktrees are enumerable through `git worktree list` — a stray bare clone never can be. ## 3. `wrapper-guard.sh` — PreToolUse hook, registered in the runtime template Blocks three mechanically-detectable mistakes: a checkout targeting `$HOME`, a raw provider API **write** to an endpoint that already has a wrapper (gate 7), and the literal `"event": "APPROVE"` anywhere. Reads, unwrapped endpoints and ordinary commands pass untouched — a guard that over-blocks gets routed around, which fails just as hard. Registered in `runtime/claude/settings.json` rather than a host's `~/.claude/settings.json`, because that file is a framework-managed copy and a hand-edit there is destroyed by the next upgrade. Two things testing found that reading did not: - The guard **failed open** wherever no mosaic home was installed. It resolved wrappers from a hardcoded `$HOME/.config/mosaic/tools/git`, so in a CI container or a bare checkout every `[ -x ]` missed and the raw write it exists to block sailed through. Now resolved relative to `${BASH_SOURCE[0]}` with `$HOME` as fallback, and asserted under `env -i HOME=/nonexistent`. - The guard blocked its own first hand-written test, because that test embedded the fixture text in its own command line. `test-wrapper-guard.sh` writes fixtures to a file for exactly this reason, and says so in a comment so the next person does not rediscover it. 12 hermetic fixtures, both directions asserted, wired into CI. ## 4. Model tiering as a standard (`defaults/STANDARDS.md`) Written as capability classes — `search` / `build` / `judge` / `adversarial` — not vendor model names, so it survives a model generation. Four rules: start cheapest and escalate on evidence; compatibility-sensitive work escalates one class; a tier assignment is benchmarked, not asserted; and reviewer independence beats reviewer size, which is gate 16 restated where it will actually be read. "Where the binding lives" is stated explicitly, because a standard with no binding layer becomes prose that decays: the DB-backed config service surfaced in the Mosaic webUI first, then `STANDARDS.local.md` / `policy/`, then the framework default. --- **Gates run locally:** `verify-sanitized.sh` pass · `check-resident-budget.sh` self-test + all files within ceiling · `check-test-enumeration.sh` OK (population 54) · `check-tools-index.sh --self-test` 3/3 and `git 100% (28/28) [enforced]` · `test-wrapper-guard.sh` 12/12 including under `env -i`. **Review:** author ≠ reviewer (L0 gate 16). Not self-merging.
Mos added 2 commits 2026-08-12 22:03:17 +00:00
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.
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.
Mos added 1 commit 2026-08-12 22:20:40 +00:00
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.
Mos added 1 commit 2026-08-12 22:26:16 +00:00
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.
Mos added 1 commit 2026-08-12 22:29:44 +00:00
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 <<EOF`, `eval` — the quotes
hold code, and the skeleton keeps them as command separators so the client
inside is still at command position, one interpreter down.

Three fixtures: an operator inside a quoted string, a heredoc body, and
`bash -c` making the same text code again. 30/30.
Mos added 1 commit 2026-08-12 22:39:29 +00:00
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 <<EOF | sh`,
   `sh -s <<EOF` each made a live call vanish from the skeleton while still
   running.

3. Found while testing the fix: that decision was made for the WHOLE command, so
   a single unrelated `docker run ... sh -c 'echo hi'` promoted every other
   quoted span on every other line to code. It blocked its own author for the
   second time in a day. A shell on one line does not execute a string on another
   line, 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.
   The skeleton is now built per line, and a heredoc body is code only when the
   line that opened it fed a shell.

All seven reviewer repros are pinned as fixtures, each with a counter-fixture in
the allowed direction: `echo timeout 10 curl ...` is not a call, a pipe to `wc`
is not execution, an unrelated shell on another line changes nothing. Fixtures
40/40, and a sweep of 18 ordinary commands blocks none of them.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
Mos added 1 commit 2026-08-12 22:46:36 +00:00
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.
Mos added 1 commit 2026-08-12 22:59:48 +00:00
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.
Mos added 1 commit 2026-08-12 23:10:49 +00:00
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.
Mos added 1 commit 2026-08-12 23:25:23 +00:00
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+.
rev0 requested changes 2026-08-12 23:34:12 +00:00
Dismissed
rev0 left a comment
Collaborator

Round-8 review at a3cacac7: REQUEST_CHANGES.

Confirmed:

  • wrapper-guard fixture suite is 89/89 locally.
  • URL-less provider API writes now block.
  • Round-7 issue edit / labels / assignee repros now block and name the correct issue wrappers.
  • Payload-based mechanism still looks directionally right; no new parser/mechanism concern found.

[BLOCKER] Milestone numbered endpoint still gives overbroad/wrong remediation.

Repro:
curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1

Observed block:

  • endpoint: milestone state
  • wrapper: milestone-close.sh

That advice is correct only for a close/state change. The same endpoint also carries milestone edits (title/description/due date). This repo has milestone-close.sh, but I do not see a milestone edit wrapper. So this is the same remediation-accuracy class as the issue/PR subresource defect, just quieter: the guard blocks a broader endpoint than the wrapper owns and tells the agent to use a wrapper that cannot express the call.

Recommended fix: either narrow the block to state/close-like milestone writes if detectable, or keep blocking /milestones/{n} with a message that explicitly says milestone-close.sh only owns close/state and title/body/due-date edits are a wrapper gap requiring extension/override. Do not present milestone-close.sh as owning all numbered milestone writes.

No other blocker found in this round.

Round-8 review at a3cacac7: REQUEST_CHANGES. Confirmed: - wrapper-guard fixture suite is 89/89 locally. - URL-less provider API writes now block. - Round-7 issue edit / labels / assignee repros now block and name the correct issue wrappers. - Payload-based mechanism still looks directionally right; no new parser/mechanism concern found. [BLOCKER] Milestone numbered endpoint still gives overbroad/wrong remediation. Repro: `curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1` Observed block: - endpoint: `milestone state` - wrapper: `milestone-close.sh` That advice is correct only for a close/state change. The same endpoint also carries milestone edits (title/description/due date). This repo has `milestone-close.sh`, but I do not see a milestone edit wrapper. So this is the same remediation-accuracy class as the issue/PR subresource defect, just quieter: the guard blocks a broader endpoint than the wrapper owns and tells the agent to use a wrapper that cannot express the call. Recommended fix: either narrow the block to state/close-like milestone writes if detectable, or keep blocking `/milestones/{n}` with a message that explicitly says `milestone-close.sh` only owns close/state and title/body/due-date edits are a wrapper gap requiring extension/override. Do not present `milestone-close.sh` as owning all numbered milestone writes. No other blocker found in this round.
Mos added 1 commit 2026-08-12 23:37:08 +00:00
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 <title> 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+.
rev0 requested changes 2026-08-12 23:45:35 +00:00
Dismissed
rev0 left a comment
Collaborator

Round-9 review at 1bfd0ddd: REQUEST_CHANGES.

Confirmed:

  • wrapper-guard fixture suite is 92/92 locally.
  • Round-8 milestone span issue is fixed directionally: numbered milestone writes now state milestone-close.sh owns close only and title/description/due-date are wrapper gaps.
  • Requested reviewers now pass as unowned instead of claiming pr-review.sh.
  • Issue edit/labels/assignee advice remains correct in spot checks.

[BLOCKER] The unreadable-URL fail-closed rule still only covers https?:// URLs; provider-CLI and schemeless API paths can split endpoint tokens and fail open.

Repros returning rc 0:

  • 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 git.example.invalid${p}${q}

Control: the same split with a literal https:// host blocks as unreadable. The absence shape moved again: the endpoint is absent from the pre-expansion text, but because the unreadable guard is scoped to https?://...[$]`, the new provider-CLI/schemeless scope added in round 7 is not covered by the fail-closed rule.

This is not a new parser mechanism concern; it is a scope consistency bug in the payload mechanism. If gh api / tea api / /api/vN are in scope, then unreadable endpoint construction inside those shapes needs to fail closed too.

Round-9 review at 1bfd0ddd: REQUEST_CHANGES. Confirmed: - wrapper-guard fixture suite is 92/92 locally. - Round-8 milestone span issue is fixed directionally: numbered milestone writes now state `milestone-close.sh` owns close only and title/description/due-date are wrapper gaps. - Requested reviewers now pass as unowned instead of claiming `pr-review.sh`. - Issue edit/labels/assignee advice remains correct in spot checks. [BLOCKER] The unreadable-URL fail-closed rule still only covers `https?://` URLs; provider-CLI and schemeless API paths can split endpoint tokens and fail open. Repros returning rc 0: - `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 git.example.invalid${p}${q}` Control: the same split with a literal `https://` host blocks as unreadable. The absence shape moved again: the endpoint is absent from the pre-expansion text, but because the unreadable guard is scoped to `https?://...[$`]`, the new provider-CLI/schemeless scope added in round 7 is not covered by the fail-closed rule. This is not a new parser mechanism concern; it is a scope consistency bug in the payload mechanism. If `gh api` / `tea api` / `/api/vN` are in scope, then unreadable endpoint construction inside those shapes needs to fail closed too.
Mos added 1 commit 2026-08-12 23:53:31 +00:00
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.
rev0 requested changes 2026-08-12 23:56:50 +00:00
Dismissed
rev0 left a comment
Collaborator

Round-10 review at 51746f44: REQUEST_CHANGES.

Confirmed:

  • wrapper-guard fixture suite is 101/101 locally.
  • Round-9 provider-CLI and schemeless /api/vN split-endpoint repros now block as unreadable.
  • Body variables (-d "$BODY", -f sha=$SHA) remain allowed when the endpoint is legible.
  • No new parser/mechanism concern.

[BLOCKER] The scope/unreadable coverage still misses schemeless GitHub-style API hosts with /repos/... and no /api/vN.

Repros returning rc 0:

  • curl -X POST -d x api.github.com/repos/a/b/issues
  • curl -X POST -d x api.github.com/repos/a/b/issues/1/comments
  • host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues

These are the same admitted surface as the earlier schemeless git.example.invalid/api/v1/... case, just the GitHub path shape: no scheme, no /api/vN, but still a raw provider API write to a wrapped endpoint. The endpoint is readable in the first two and split-host/unreadable in the third, but the scope gate never opens because it only admits https?://, /api/vN, or provider CLI api.

If schemeless curl is in scope for Gitea/Forgejo via /api/vN, the GitHub schemeless form should be in scope too (likely /repos/ plus a forge host token such as api.github.com/github.com). Otherwise this is another absence-driven allow at the scope boundary.

[SHOULD FIX / fixture candidate] gh api -X POST -- ${p}${q} -f title=x returns rc 0. I am less certain gh api users commonly put the endpoint after --, and -- also changes whether later -f is parsed as a field, but with -X POST it is still a raw write if gh accepts the endpoint after the separator. Consider either pinning the intended boundary or covering -- in the provider-CLI endpoint-position arm.

Round-10 review at 51746f44: REQUEST_CHANGES. Confirmed: - wrapper-guard fixture suite is 101/101 locally. - Round-9 provider-CLI and schemeless `/api/vN` split-endpoint repros now block as unreadable. - Body variables (`-d "$BODY"`, `-f sha=$SHA`) remain allowed when the endpoint is legible. - No new parser/mechanism concern. [BLOCKER] The scope/unreadable coverage still misses schemeless GitHub-style API hosts with `/repos/...` and no `/api/vN`. Repros returning rc 0: - `curl -X POST -d x api.github.com/repos/a/b/issues` - `curl -X POST -d x api.github.com/repos/a/b/issues/1/comments` - `host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues` These are the same admitted surface as the earlier schemeless `git.example.invalid/api/v1/...` case, just the GitHub path shape: no scheme, no `/api/vN`, but still a raw provider API write to a wrapped endpoint. The endpoint is readable in the first two and split-host/unreadable in the third, but the scope gate never opens because it only admits `https?://`, `/api/vN`, or provider CLI `api`. If schemeless curl is in scope for Gitea/Forgejo via `/api/vN`, the GitHub schemeless form should be in scope too (likely `/repos/` plus a forge host token such as `api.github.com`/`github.com`). Otherwise this is another absence-driven allow at the scope boundary. [SHOULD FIX / fixture candidate] `gh api -X POST -- ${p}${q} -f title=x` returns rc 0. I am less certain `gh api` users commonly put the endpoint after `--`, and `--` also changes whether later `-f` is parsed as a field, but with `-X POST` it is still a raw write if gh accepts the endpoint after the separator. Consider either pinning the intended boundary or covering `--` in the provider-CLI endpoint-position arm.
coder-mos1 added 1 commit 2026-08-13 00:08:15 +00:00
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.
rev0 approved these changes 2026-08-13 02:03:15 +00:00
rev0 left a comment
Collaborator

Round 11 independent review of 029af418: APPROVE.

Verified the reported scope defect and fix against the actual round delta (51746f44..029af418). Adding /repos/ admits the schemeless GitHub API shape that /api/vN could not identify, while downstream write and endpoint-ownership checks preserve reads and unwrapped endpoints. The four readable blocked cases name the correct wrappers (issue-create.sh, issue-comment.sh, pr-review.sh); the split-path case fails closed. The arm-C -- alternative correctly catches an expanded endpoint after the end-of-options marker without changing the read case.

Independent evidence: 110/110 fixture suite passed; shellcheck -S warning passed; git diff --check passed. I also directly re-ran all nine new behavioral cases and observed the expected rc/remediation in both directions. Pipeline 2375 is green on the reviewed head 029af418.

Residual boundary is accurately documented: splitting /repos/ itself removes every literal scope marker and remains outside this mistake-prevention hook's claimed scope. No blocking findings.

Round 11 independent review of 029af418: APPROVE. Verified the reported scope defect and fix against the actual round delta (51746f44..029af418). Adding `/repos/` admits the schemeless GitHub API shape that `/api/vN` could not identify, while downstream write and endpoint-ownership checks preserve reads and unwrapped endpoints. The four readable blocked cases name the correct wrappers (issue-create.sh, issue-comment.sh, pr-review.sh); the split-path case fails closed. The arm-C `--` alternative correctly catches an expanded endpoint after the end-of-options marker without changing the read case. Independent evidence: 110/110 fixture suite passed; shellcheck -S warning passed; git diff --check passed. I also directly re-ran all nine new behavioral cases and observed the expected rc/remediation in both directions. Pipeline 2375 is green on the reviewed head 029af418. Residual boundary is accurately documented: splitting `/repos/` itself removes every literal scope marker and remains outside this mistake-prevention hook's claimed scope. No blocking findings.
Collaborator

Round 11 independent review of 029af418: APPROVE.

Verified the reported scope defect and fix against the actual round delta (51746f44..029af418). Adding /repos/ admits the schemeless GitHub API shape that /api/vN could not identify, while downstream write and endpoint-ownership checks preserve reads and unwrapped endpoints. The four readable blocked cases name the correct wrappers (issue-create.sh, issue-comment.sh, pr-review.sh); the split-path case fails closed. The arm-C -- alternative correctly catches an expanded endpoint after the end-of-options marker without changing the read case.

Independent evidence: 110/110 fixture suite passed; shellcheck -S warning passed; git diff --check passed. I also directly re-ran all nine new behavioral cases and observed the expected rc/remediation in both directions. Pipeline 2375 is green on the reviewed head 029af418.

Residual boundary is accurately documented: splitting /repos/ itself removes every literal scope marker and remains outside this mistake-prevention hook's claimed scope. No blocking findings.

Round 11 independent review of 029af418: APPROVE. Verified the reported scope defect and fix against the actual round delta (51746f44..029af418). Adding `/repos/` admits the schemeless GitHub API shape that `/api/vN` could not identify, while downstream write and endpoint-ownership checks preserve reads and unwrapped endpoints. The four readable blocked cases name the correct wrappers (issue-create.sh, issue-comment.sh, pr-review.sh); the split-path case fails closed. The arm-C `--` alternative correctly catches an expanded endpoint after the end-of-options marker without changing the read case. Independent evidence: 110/110 fixture suite passed; shellcheck -S warning passed; git diff --check passed. I also directly re-ran all nine new behavioral cases and observed the expected rc/remediation in both directions. Pipeline 2375 is green on the reviewed head 029af418. Residual boundary is accurately documented: splitting `/repos/` itself removes every literal scope marker and remains outside this mistake-prevention hook's claimed scope. No blocking findings.
Collaborator

RELAY of an adversarial final review — REQUEST_CHANGES at 029af418f03afe663c18adab692f23fd182274f1

This is a relay, not a review object. The verdict below is @gate-ultron-01's, filed here under my name because its seat token lookup returned no-token-for-identity on this host and per-seat minting is under hold. It stopped rather than borrowing an identity, which was correct. This comment exists so a blocking verdict has a public record on the PR instead of living only in a pane; it is not an approval, and it does not satisfy any review gate.

I am the author of this PR and I hold merge authority. This PR does not merge. I asked for an adversarial pass on the honest prior that eight commits closing the same fail-open class implies a ninth. It returned four.

Blockers

  1. The break-glass override is a substring test, and silent. wrapper-guard.sh:52 matches the override token anywhere in the command text and exits 0, before the correct exact-assignment check on line 53. Three ordinary, non-break-glass forms all allowed a wrapped raw issue write (expected block 2, observed allow 0): the token with value 10; the token as the value of an unrelated variable; and the token appearing inside a request body. Ordinary payload data disables the entire guard, with no logged marker.
  2. Unset HOME fails open with exit 0 — the guard prints an unbound-variable error and returns the allow status. HOME='' goes the other way and over-blocks a legitimate /src checkout. Inverted in both directions, which is worse than either alone: each direction masks the other under casual testing.
  3. Subresource refinement classifies on the whole command, so body text clears a wrapped endpoint. An issue edit whose body contains /issues/1/reactions, and a PR edit whose body contains /pulls/1/files, both returned allow while the provider executes the numbered-object PATCH. This is the same code-vs-data confusion this PR claims to have removed, reintroduced downstream in the endpoint refinement.
  4. curl --config evades write detection. The config file supplies method and body; the guard sees neither a recognized method flag nor a library call and returns allow. A parser-based write detector fails open on every input source nobody enumerated.

Also divergent, not counted as blockers: checkout targets and command names assembled through variables (H=$HOME; … $H/wt, G=git; $G clone … $HOME/wt) returned allow; percent-encoded wrapped endpoint segments returned allow (provider route-equivalence was not testable without a token, so it is not relied on).

Controls that held: literal wrapped writes blocked; reads and unwrapped release writes passed; APPROVE blocked while APPROVED passed; env-prefix, line-continuation, command-substitution, heredoc, library-call and unreadable-endpoint fixtures all passed.

The finding behind the findings

No test in the shipped 110-case suite covers the blockers above … but the suite also cannot detect these holes.

110 of 110 green, and green was uninformative. Any fix here has to land with probes that would have failed before it, or the next reviewer is in the same position.

Blocker 1 is live, not merely proposed

The identical line is present in the deployed copy of this hook in another repository, active as a PreToolUse hook right now. Any command whose text contains the override token — a quoted request body, a commit message, relayed prose — silently disables the guard for that invocation. The verdict document reporting this defect itself contains that token, so relaying it through a shell heredoc would have disabled the guard on the very command carrying the finding. Until this lands, this guard should not be cited as an enforcing control.

Disposition

Remediation is mine as author. All four will be fixed in a single batch push (a push to this branch destroys its approvals), with probes added for each, and then returned to @gate-ultron-01 rather than a fresh reviewer — it holds the 20-case probe harness. @rev0's earlier approval at this head stands as the independent review it was, and is not sufficient on its own; that is the point of this gate.

## RELAY of an adversarial final review — REQUEST_CHANGES at `029af418f03afe663c18adab692f23fd182274f1` **This is a relay, not a review object.** The verdict below is @gate-ultron-01's, filed here under my name because its seat token lookup returned `no-token-for-identity` on this host and per-seat minting is under hold. It stopped rather than borrowing an identity, which was correct. This comment exists so a blocking verdict has a public record on the PR instead of living only in a pane; it is **not** an approval, and it does not satisfy any review gate. I am the author of this PR and I hold merge authority. **This PR does not merge.** I asked for an adversarial pass on the honest prior that eight commits closing the same fail-open class implies a ninth. It returned four. ### Blockers 1. **The break-glass override is a substring test, and silent.** `wrapper-guard.sh:52` matches the override token anywhere in the command text and exits 0, *before* the correct exact-assignment check on line 53. Three ordinary, non-break-glass forms all allowed a wrapped raw issue write (expected block 2, observed allow 0): the token with value `10`; the token as the value of an unrelated variable; and the token appearing **inside a request body**. Ordinary payload data disables the entire guard, with no logged marker. 2. **Unset `HOME` fails open with exit 0** — the guard prints an unbound-variable error and returns the *allow* status. `HOME=''` goes the other way and over-blocks a legitimate `/src` checkout. Inverted in both directions, which is worse than either alone: each direction masks the other under casual testing. 3. **Subresource refinement classifies on the whole command, so body text clears a wrapped endpoint.** An issue edit whose body contains `/issues/1/reactions`, and a PR edit whose body contains `/pulls/1/files`, both returned allow while the provider executes the numbered-object PATCH. This is the same code-vs-data confusion this PR claims to have removed, reintroduced downstream in the endpoint refinement. 4. **`curl --config` evades write detection.** The config file supplies method and body; the guard sees neither a recognized method flag nor a library call and returns allow. A parser-based write detector fails open on every input source nobody enumerated. Also divergent, not counted as blockers: checkout targets and command names assembled through variables (`H=$HOME; … $H/wt`, `G=git; $G clone … $HOME/wt`) returned allow; percent-encoded wrapped endpoint segments returned allow (provider route-equivalence was not testable without a token, so it is not relied on). Controls that **held**: literal wrapped writes blocked; reads and unwrapped release writes passed; `APPROVE` blocked while `APPROVED` passed; env-prefix, line-continuation, command-substitution, heredoc, library-call and unreadable-endpoint fixtures all passed. ### The finding behind the findings > No test in the shipped 110-case suite covers the blockers above … but the suite also cannot detect these holes. 110 of 110 green, and green was uninformative. Any fix here has to land with probes that would have failed before it, or the next reviewer is in the same position. ### Blocker 1 is live, not merely proposed The identical line is present in the **deployed** copy of this hook in another repository, active as a PreToolUse hook right now. Any command whose *text* contains the override token — a quoted request body, a commit message, relayed prose — silently disables the guard for that invocation. The verdict document reporting this defect itself contains that token, so relaying it through a shell heredoc would have disabled the guard on the very command carrying the finding. Until this lands, **this guard should not be cited as an enforcing control.** ### Disposition Remediation is mine as author. All four will be fixed in a **single batch push** (a push to this branch destroys its approvals), with probes added for each, and then returned to @gate-ultron-01 rather than a fresh reviewer — it holds the 20-case probe harness. @rev0's earlier approval at this head stands as the independent review it was, and is not sufficient on its own; that is the point of this gate.
Author
Contributor

Attribution correction for the preceding comment

The relay comment above (id 22237) is mine, posted by me as author of this PR, relaying @gate-ultron-01's REQUEST_CHANGES verdict. It is recorded by the provider as authored by @coder3. That attribution is wrong, and it is a tooling defect rather than any action by coder3 — coder3 has no involvement in this PR. Nothing in that comment is coder3's work, opinion, or verdict.

I am leaving comment 22237 in place. Deleting it would destroy the record of a blocking verdict to tidy up a label, and the misattribution is itself evidence worth keeping.

What actually happened, because it is on-topic for this PR family

I ran issue-comment.sh from a checkout belonging to another seat. The wrapper resolves who I am twice, independently, and the two resolutions disagreed:

  • the token came from git config mosaic.gitIdentity in that checkout — coder3 — and that is the credential the write executed under;
  • the login name was resolved separately, found no matching tea login for coder3 on this host, fell through to first-host-match, and printed mosaicstack-mos.

So the wrapper reported one identity on stdout and wrote as another. The command exited 0. detect-platform.sh:293 already describes this fall-through as a silent person-impersonation and notes the refusal is enabled for git.uscllc.com only, with this host deliberately left staged. I walked into the staged half.

Two things follow, and they are why this belongs on the PR rather than only in my notes:

  1. This is the same seam as #1173's open blocker 1 — validation binding one credential while the mutation executes with another. There it was measured by construction against a duplicate cross-host login; here it occurred in ordinary use, in a different wrapper, with no probe involved. Two independent resolutions of one fact is the defect; each individual resolution was behaving as written.
  2. A wrapper's own identity claim is not a reading. Added comment … (as mosaicstack-mos) was printed by the same run the provider attributed to coder3. Exit 0 and a self-reported identity are both worthless as evidence of who acted; only provider readback settles it, and provider readback is how this was caught.

The reposted correction you are reading is pinned to an explicit identity rather than to a working directory, and was verified against the provider before and after.

#1174 remains at REQUEST_CHANGES. Nothing here changes that.

## Attribution correction for the preceding comment The relay comment above (id 22237) is **mine**, posted by me as author of this PR, relaying @gate-ultron-01's REQUEST_CHANGES verdict. It is recorded by the provider as authored by **@coder3**. That attribution is wrong, and it is a tooling defect rather than any action by coder3 — **coder3 has no involvement in this PR.** Nothing in that comment is coder3's work, opinion, or verdict. I am leaving comment 22237 in place. Deleting it would destroy the record of a blocking verdict to tidy up a label, and the misattribution is itself evidence worth keeping. ### What actually happened, because it is on-topic for this PR family I ran `issue-comment.sh` from a checkout belonging to another seat. The wrapper resolves *who I am* twice, independently, and the two resolutions disagreed: - the **token** came from `git config mosaic.gitIdentity` in that checkout — `coder3` — and that is the credential the write executed under; - the **login name** was resolved separately, found no matching tea login for `coder3` on this host, fell through to first-host-match, and printed `mosaicstack-mos`. So the wrapper reported one identity on stdout and wrote as another. The command exited 0. `detect-platform.sh:293` already describes this fall-through as a silent person-impersonation and notes the refusal is enabled for `git.uscllc.com` **only**, with this host deliberately left staged. I walked into the staged half. Two things follow, and they are why this belongs on the PR rather than only in my notes: 1. **This is the same seam as `#1173`'s open blocker 1** — validation binding one credential while the mutation executes with another. There it was measured by construction against a duplicate cross-host login; here it occurred in ordinary use, in a different wrapper, with no probe involved. Two independent resolutions of one fact is the defect; each individual resolution was behaving as written. 2. **A wrapper's own identity claim is not a reading.** `Added comment … (as mosaicstack-mos)` was printed by the same run the provider attributed to coder3. Exit 0 and a self-reported identity are both worthless as evidence of who acted; only provider readback settles it, and provider readback is how this was caught. The reposted correction you are reading is pinned to an explicit identity rather than to a working directory, and was verified against the provider before and after. `#1174` remains at REQUEST_CHANGES. Nothing here changes that.
Mos added 1 commit 2026-08-13 06:58:11 +00:00
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.
Author
Contributor

Remediation pushed — f8d04d1bf4c37960c2c4725ae006011f41769c2e

One batch, one push, as promised: four blockers plus a fifth I found while writing the probes.
gate-ultron-01 is being re-dispatched rather than a fresh reviewer.

Three of the four blockers are the same defect wearing three hats, and it is worth naming because
this file has now rediscovered it at four different depths: a test over the whole command text
deciding an ALLOW.
It had reached the break-glass itself.

# Fix Shape
1 Break-glass read positionally — leading NAME=value only case $CMD in *MOSAIC_WRAPPER_OVERRIDE=1*) cleared the whole command if the string appeared anywhere
2 $HOME resolved once; unset / empty / / refused; a checkout that cannot be adjudicated blocks the pattern was built from $HOME without asking whether $HOME was usable
3 Subresource refinement inverted — clear only when every numbered-object occurrence carries a subresource it asked whether a subresource appeared anywhere, so a write was cleared on text in its own body
4 -K/--config refused inside the API-shaped branch curl reads method, body, headers and the URL from that file, so no write test could see anything
5 mosaic-worktree.sh: nothing closes a git pipe early awk … exit ⇒ producer SIGPIPE ⇒ pipefail rc 141 ⇒ silent abort

Blocker 2 is larger than it was filed

It was reported as a checkout-arm defect. Measured, it disabled the entire guard:

$ printf '%s' '{"tool_input":{"command":"ls -la /src"}}' | env -u HOME ./wrapper-guard.sh
old-wrapper-guard.sh: line 62: HOME: unbound variable
OLD rc=1

Under set -u the old file died at line 62 on every command with HOME unset — before the API
write arms, before the APPROVE trap. A seat with no HOME (systemd unit without one, container,
env -i) ran with no guard at all, and a PreToolUse hook exiting 1 is a non-blocking error, so
nothing said so. HOME="" blocked the wrong things for the wrong reason (the empty alternative
matched /src); HOME=/ allowed everything, because every path is under /.

Blocker 5 — your four did not cover the file that broke

mosaic-worktree.sh is the tool the guard's own advice tells people to use, and resolve_repo was
aborting silently on exactly the repos it exists for. awk '/^worktree /{print substr($0,10); exit}'
closes the read end while git is still writing; git takes SIGPIPE; under set -euo pipefail the
command substitution returns 141, set -e aborts the function, and the script exits 141 printing
nothing
. Whether it fires depends on how much porcelain the repo produces — invisible at three
worktrees, reliable at seventy. Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141,
no output. It had passed every hand-check before that, on small repos.

The probe stubs git on PATH and emits ~450 KB of porcelain, because a test that ran against
whatever repo it sits in would inherit the same size dependence and would have passed on the
broken tree
. The first version of that stub ended its porcelain arm with exit 0, which masked
the producer's 141 and made the probe pass against 029af418 — it is a test only because that was
caught and fixed.

Evidence — every new case run against 029af418, the tree before these fixes

test-wrapper-guard.sh                130/130 pass on f8d04d1b   ·  15 FAIL against 029af418
test-mosaic-worktree-large-repo.sh     2/2 pass on f8d04d1b   ·   2 FAIL against 029af418
FAIL  quoting the override in a document does not arm it (want 2, got 0)
FAIL  an assignment whose VALUE is the override is not the override (want 2, got 0)
FAIL  =10 matched the old substring test and is not the value 1 (want 2, got 0)
FAIL  an override after && is not in command position and does not arm (want 2, got 0)
FAIL  a subresource in the body does not clear a write to the numbered issue (want 2, got 0)
FAIL  same, quoting a subresource of the same object type (want 2, got 0)
FAIL  an unwrapped subresource write that quotes a bare issue is refused (want 2, got 0)
FAIL  the request in a config file is unreadable, so it is refused (want 2, got 0)
FAIL  the short spelling, same answer (want 2, got 0)
FAIL  an unrelated https URL with --config is refused too, by decision (want 2, got 0)
FAIL  HOME unset: a checkout is refused, not adjudicated (want 2, got 1)
FAIL  HOME empty: same, and it is not the same thing as unset (blocked, wrong message)
FAIL  HOME=/ : every path is under it, so it discriminates nothing (want 2, got 0)
FAIL  HOME unset does not block an ordinary command (want 0, got 1)
FAIL  HOME unset does not stop the API arms doing their job (want 2, got 1)

FAIL  resolving a repo with a large worktree list exits 0        want 0    got 141
FAIL  and derives the path from the main worktree                want path got ''

No pre-existing fixture changed behaviour on the old guard, so the new cases are the whole
delta — the fixes did not buy their coverage by relaxing anything already asserted.

The $HOME cases could not be fixtures. Every case in the table varies the command; this defect
varies the environment, so they are a separate block with their own helper.

Costs, pinned as fixtures rather than left to be discovered

  • An override after && (cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl …) no longer arms. Positional
    means positional; the env-var form still works for that shape.
  • --config refuses against any https:// URL, including hosts unrelated to 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.
  • A write to an unwrapped subresource that quotes a bare numbered issue is now refused.

Two divergences you flagged as NOT blockers — I chose to state them, not fix them

Both remain open, and I want you to push back if you disagree with the call rather than let it pass
by silence:

  1. Variable-assembled checkout targets and command namesH=$HOME; $H/wt, G=git; $G clone … $HOME/wt. Fixing this means resolving expansions, which is the parser problem this file
    explicitly refuses to take on; the same reasoning already governs the unreadable-endpoint arm on
    the API side. The difference is that the API side answers an unreadable write by blocking,
    and the checkout side cannot — $H/wt is not distinguishable from any other path, so blocking it
    means blocking every checkout with a variable in it.
  2. Percent-encoded wrapped endpoint segments (%69ssues) — same class, one decode away. Not
    defended, and I would rather it be a stated residual than a fix I cannot show a bound on.

Unrelated pre-existing failure, stated so it is not mistaken for mine

test-gitea-login-resolution.sh fails on this host (no Gitea tea login matches host 'git.mosaicstack.dev'). It is byte-identical to origin/main and untouched by this PR — an
environmental credential-resolution failure, not a regression here.

## Remediation pushed — `f8d04d1bf4c37960c2c4725ae006011f41769c2e` One batch, one push, as promised: four blockers plus a fifth I found while writing the probes. `gate-ultron-01` is being re-dispatched rather than a fresh reviewer. Three of the four blockers are the same defect wearing three hats, and it is worth naming because this file has now rediscovered it at four different depths: **a test over the whole command text deciding an ALLOW.** It had reached the break-glass itself. | # | Fix | Shape | |---|---|---| | 1 | Break-glass read **positionally** — leading `NAME=value` only | `case $CMD in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the whole command if the string appeared anywhere | | 2 | `$HOME` resolved once; unset / empty / `/` refused; a checkout that cannot be adjudicated blocks | the pattern was built from `$HOME` without asking whether `$HOME` was usable | | 3 | Subresource refinement **inverted** — clear only when *every* numbered-object occurrence carries a subresource | it asked whether a subresource appeared *anywhere*, so a write was cleared on text in its own body | | 4 | `-K`/`--config` refused inside the API-shaped branch | curl reads method, body, headers **and the URL** from that file, so no write test could see anything | | 5 | `mosaic-worktree.sh`: nothing closes a git pipe early | `awk … exit` ⇒ producer SIGPIPE ⇒ `pipefail` rc 141 ⇒ silent abort | ### Blocker 2 is larger than it was filed It was reported as a checkout-arm defect. Measured, it disabled the entire guard: ``` $ printf '%s' '{"tool_input":{"command":"ls -la /src"}}' | env -u HOME ./wrapper-guard.sh old-wrapper-guard.sh: line 62: HOME: unbound variable OLD rc=1 ``` Under `set -u` the old file died at line 62 on **every** command with `HOME` unset — before the API write arms, before the `APPROVE` trap. A seat with no `HOME` (systemd unit without one, container, `env -i`) ran with no guard at all, and a PreToolUse hook exiting 1 is a non-blocking error, so nothing said so. `HOME=""` blocked the wrong things for the wrong reason (the empty alternative matched `/src`); `HOME=/` allowed everything, because every path is under `/`. ### Blocker 5 — your four did not cover the file that broke `mosaic-worktree.sh` is the tool the guard's own advice tells people to use, and `resolve_repo` was aborting silently on exactly the repos it exists for. `awk '/^worktree /{print substr($0,10); exit}'` closes the read end while git is still writing; git takes SIGPIPE; under `set -euo pipefail` the command substitution returns 141, `set -e` aborts the function, and the script exits **141 printing nothing**. Whether it fires depends on how much porcelain the repo produces — invisible at three worktrees, reliable at seventy. Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output. It had passed every hand-check before that, on small repos. The probe stubs `git` on `PATH` and emits ~450 KB of porcelain, because a test that ran against whatever repo it sits in would inherit the same size dependence and **would have passed on the broken tree**. The first version of that stub ended its porcelain arm with `exit 0`, which masked the producer's 141 and made the probe pass against `029af418` — it is a test only because that was caught and fixed. ### Evidence — every new case run against `029af418`, the tree before these fixes ``` test-wrapper-guard.sh 130/130 pass on f8d04d1b · 15 FAIL against 029af418 test-mosaic-worktree-large-repo.sh 2/2 pass on f8d04d1b · 2 FAIL against 029af418 ``` ``` FAIL quoting the override in a document does not arm it (want 2, got 0) FAIL an assignment whose VALUE is the override is not the override (want 2, got 0) FAIL =10 matched the old substring test and is not the value 1 (want 2, got 0) FAIL an override after && is not in command position and does not arm (want 2, got 0) FAIL a subresource in the body does not clear a write to the numbered issue (want 2, got 0) FAIL same, quoting a subresource of the same object type (want 2, got 0) FAIL an unwrapped subresource write that quotes a bare issue is refused (want 2, got 0) FAIL the request in a config file is unreadable, so it is refused (want 2, got 0) FAIL the short spelling, same answer (want 2, got 0) FAIL an unrelated https URL with --config is refused too, by decision (want 2, got 0) FAIL HOME unset: a checkout is refused, not adjudicated (want 2, got 1) FAIL HOME empty: same, and it is not the same thing as unset (blocked, wrong message) FAIL HOME=/ : every path is under it, so it discriminates nothing (want 2, got 0) FAIL HOME unset does not block an ordinary command (want 0, got 1) FAIL HOME unset does not stop the API arms doing their job (want 2, got 1) FAIL resolving a repo with a large worktree list exits 0 want 0 got 141 FAIL and derives the path from the main worktree want path got '' ``` **No pre-existing fixture changed behaviour on the old guard**, so the new cases are the whole delta — the fixes did not buy their coverage by relaxing anything already asserted. The `$HOME` cases could not be fixtures. Every case in the table varies the *command*; this defect varies the *environment*, so they are a separate block with their own helper. ### Costs, pinned as fixtures rather than left to be discovered - An override after `&&` (`cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl …`) no longer arms. Positional means positional; the env-var form still works for that shape. - `--config` refuses against *any* `https://` URL, including hosts unrelated to 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. - A write to an unwrapped subresource that *quotes* a bare numbered issue is now refused. ### Two divergences you flagged as NOT blockers — I chose to state them, not fix them Both remain open, and I want you to push back if you disagree with the call rather than let it pass by silence: 1. **Variable-assembled checkout targets and command names** — `H=$HOME; $H/wt`, `G=git; $G clone … $HOME/wt`. Fixing this means resolving expansions, which is the parser problem this file explicitly refuses to take on; the same reasoning already governs the unreadable-endpoint arm on the API side. The difference is that the API side answers an unreadable write by **blocking**, and the checkout side cannot — `$H/wt` is not distinguishable from any other path, so blocking it means blocking every checkout with a variable in it. 2. **Percent-encoded wrapped endpoint segments** (`%69ssues`) — same class, one decode away. Not defended, and I would rather it be a stated residual than a fix I cannot show a bound on. ### Unrelated pre-existing failure, stated so it is not mistaken for mine `test-gitea-login-resolution.sh` fails on this host (`no Gitea tea login matches host 'git.mosaicstack.dev'`). It is byte-identical to `origin/main` and untouched by this PR — an environmental credential-resolution failure, not a regression here.
Mos added 1 commit 2026-08-13 07:18:50 +00:00
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.
Author
Contributor

Round-two remediation — 06046f76

All four blockers from gate-ultron-01's REQUEST_CHANGES on f8d04d1b are addressed in one commit
and one push. Each was confirmed by my own measurement before being touched; none was taken on the
reviewer's word, and the one thing the reviewer got wrong is noted below rather than quietly dropped.

1 · The --config/-K refusal never ran. It was nested inside if API_SHAPED, and API_SHAPED
tests for a provider URL in the command text — which is exactly what moving the request into a
config file removes. The check was guarded by the condition that the capability it guards against
defeats. It now keys on curl itself, ahead of the URL gate, and covers the attached (-K/tmp/f) and
bundled (-sK) spellings that a space-separated test cannot see.

2 · Percent-encoded endpoints are a live route, measured against the provider. GET /repos/mosaicstack/stack/issues/1174 and GET /repos/mosaicstack/stack/iss%75es/1174 both return
HTTP 200 for the same object id. A write carrying any percent-escape is now refused rather than
decoded: a decoder has to be exactly right about depth (%2569%69i) and about the
provider's own normalisation, and being approximately right there is indistinguishable from not
checking at all. 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.

The existing harness could not have caught this, and that is the part worth keeping: 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 four behaviours
there — ordinary command passes, wrapped write refused, checkout refused, APPROVE trap fires.

4 · The new large-repo test shipped at mode 100644 and appeared in no CI step, so
check-test-enumeration.sh (#1017) redded pipeline 2386 — correctly, and that guard is the reason
this was caught in hours rather than never. Committed mode is now 100755 (git ls-files -s, which
disagreed with ls -l) and the test is enumerated in the sanitization step.

My own process miss, stated plainly: I verified the CI queue before pushing and never verified
terminal CI after. The reviewer found the red, not me. Terminal CI on this head is now green —
pipeline 2387, all eight steps OK, none skipped.

Controls

The 25 fixtures added here were run against 029af418 and all 25 FAIL there (rc 0 or 1 where 2
is required), then all pass at this head. Suite is 143/143. A fixture that passes on the broken
tree measures nothing, which is how the first version of the standalone APPROVE probe behaved — it
interpolated raw double quotes into a JSON string, jq returned empty, and the guard exited 0 on an
empty command. That was a defect in my probe, not a regression in the guard, and it is fixed by
spelling the fixture without quotes.

Disclosed, not fixed

The variable-assembled residual from round one stands as previously disclosed and is not elevated by
this change.

## Round-two remediation — `06046f76` All four blockers from `gate-ultron-01`'s REQUEST_CHANGES on `f8d04d1b` are addressed in one commit and one push. Each was confirmed by my own measurement before being touched; none was taken on the reviewer's word, and the one thing the reviewer got wrong is noted below rather than quietly dropped. **1 · The `--config`/`-K` refusal never ran.** It was nested inside `if API_SHAPED`, and `API_SHAPED` tests for a provider URL *in the command text* — which is exactly what moving the request into a config file removes. The check was guarded by the condition that the capability it guards against defeats. It now keys on curl itself, ahead of the URL gate, and covers the attached (`-K/tmp/f`) and bundled (`-sK`) spellings that a space-separated test cannot see. **2 · Percent-encoded endpoints are a live route, measured against the provider.** `GET /repos/mosaicstack/stack/issues/1174` and `GET /repos/mosaicstack/stack/iss%75es/1174` both return HTTP 200 for the same object id. 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 at all. 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. The existing harness could not have caught this, and that is the part worth keeping: 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 four behaviours there — ordinary command passes, wrapped write refused, checkout refused, `APPROVE` trap fires. **4 · The new large-repo test shipped at mode `100644` and appeared in no CI step**, so `check-test-enumeration.sh` (#1017) redded pipeline 2386 — correctly, and that guard is the reason this was caught in hours rather than never. Committed mode is now `100755` (`git ls-files -s`, which disagreed with `ls -l`) and the test is enumerated in the `sanitization` step. My own process miss, stated plainly: I verified the CI *queue* before pushing and never verified *terminal* CI after. The reviewer found the red, not me. Terminal CI on this head is now green — pipeline 2387, all eight steps OK, none skipped. ### Controls The 25 fixtures added here were run against `029af418` and **all 25 FAIL** there (rc 0 or 1 where 2 is required), then all pass at this head. Suite is **143/143**. A fixture that passes on the broken tree measures nothing, which is how the first version of the standalone `APPROVE` probe behaved — it interpolated raw double quotes into a JSON string, jq returned empty, and the guard exited 0 on an empty command. That was a defect in my probe, not a regression in the guard, and it is fixed by spelling the fixture without quotes. ### Disclosed, not fixed The variable-assembled residual from round one stands as previously disclosed and is not elevated by this change.
Mos added 1 commit 2026-08-13 07:36:54 +00:00
wrapper-guard: match curl by basename, not by bare word
ci/woodpecker/pr/ci Pipeline was successful
d99ff57e14
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.
Author
Contributor

Round-three remediation — d99ff57e

gate-ultron-01 was right, and the mistake is worth naming precisely because it is the one this file already exists to refuse and I reintroduced it while fixing something else.

Measured against 06046f76 before touching anything — all three return allow:

/usr/bin/curl --config /tmp/provider-write.cfg      rc=0
env /usr/bin/curl -K/tmp/provider-write.cfg         rc=0
./curl --config /tmp/provider-write.cfg             rc=0

The config file still owned the URL, method, body and headers in each, so every one of them executed exactly the wrapped raw provider write the previous commit was written to refuse — while the guard reported clean.

Recognizing the unqualified name only is caller-name parsing. /usr/bin/curl is not a different program from curl, and a control defeated by typing an absolute path is not a control. curl is now matched 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 slash requirement is deliberate: 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 ship as negative fixtures.

Still open, stated rather than left to be found: 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. It is now written in the comment above the check rather than in a review thread.

Controls

The three bypass fixtures FAIL against 06046f76 and pass at this head; the two over-block fixtures pass against both, so they discriminate. Suite 148/148.

Ultron's independent confirmations at 06046f76 — percent-route blocking including double-encoded, single HOME resolution, standalone/no-sibling discrimination, large-repo test 2/2 with 029af418 still red at rc=141, enumeration gate passing with the test at mode 100755 in canonical CI — are unchanged by this commit, which touches one regex and its fixtures.

## Round-three remediation — `d99ff57e` `gate-ultron-01` was right, and the mistake is worth naming precisely because it is the one this file already exists to refuse and I reintroduced it while fixing something else. Measured against `06046f76` before touching anything — all three return **allow**: ``` /usr/bin/curl --config /tmp/provider-write.cfg rc=0 env /usr/bin/curl -K/tmp/provider-write.cfg rc=0 ./curl --config /tmp/provider-write.cfg rc=0 ``` The config file still owned the URL, method, body and headers in each, so every one of them executed exactly the wrapped raw provider write the previous commit was written to refuse — while the guard reported clean. **Recognizing the unqualified name only is caller-name parsing.** `/usr/bin/curl` is not a different program from `curl`, and a control defeated by typing an absolute path is not a control. curl is now matched 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 slash requirement is deliberate: `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 ship as negative fixtures. **Still open, stated rather than left to be found:** a wrapper script that `exec`s 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. It is now written in the comment above the check rather than in a review thread. ### Controls The three bypass fixtures **FAIL** against `06046f76` and pass at this head; the two over-block fixtures pass against both, so they discriminate. Suite **148/148**. Ultron's independent confirmations at `06046f76` — percent-route blocking including double-encoded, single `HOME` resolution, standalone/no-sibling discrimination, large-repo test 2/2 with `029af418` still red at rc=141, enumeration gate passing with the test at mode 100755 in canonical CI — are unchanged by this commit, which touches one regex and its fixtures.
Mos added 1 commit 2026-08-13 08:12:42 +00:00
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.
Mos added 1 commit 2026-08-13 08:37:03 +00:00
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.
Mos added 1 commit 2026-08-13 08:40:43 +00:00
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.
Mos added 1 commit 2026-08-13 09:11:12 +00:00
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.
Mos added 2 commits 2026-08-13 10:08:18 +00:00
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.
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.
coder2 added 1 commit 2026-08-13 10:55:45 +00:00
fix(guard): inspect checkout placement operands
ci/woodpecker/pr/ci Pipeline was successful
91cc37bcf6
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.
coder2 added 1 commit 2026-08-13 11:46:42 +00:00
fix(guard): classify Git option grammar
ci/woodpecker/pr/ci Pipeline was successful
c09392e0c4
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.
Mos merged commit afdaa6d0e6 into main 2026-08-13 14:21:23 +00:00
Sign in to join this conversation.