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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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+.
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+.
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.
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.
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.
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.
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.
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.
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.
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.
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.
2026-08-12 16:51:17 -05:00
14 changed files with 7 additions and 848 deletions
## Current addendum: #1194 — Installed framework-tool drift detection
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
# #1194 — Installed framework-tool drift detection and refresh analysis
## Decision
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
## Classification
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
## Current host analysis (observation only; no refresh performed)
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
## Reviewed refresh command — analyse only, do not run during active seats
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
## Post-refresh verification
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
- malformed payload: guard must print `state=malformed` and exit non-zero;
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
## Probe evidence
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.