Skip to content

fix: bind eleven more failure-token probes to their probe, and quote a spaced skill dir - #3474

Merged
claude[bot] merged 1 commit into
mainfrom
claude/pipefail-probe-guard-followup
Aug 28, 2026
Merged

fix: bind eleven more failure-token probes to their probe, and quote a spaced skill dir#3474
claude[bot] merged 1 commit into
mainfrom
claude/pipefail-probe-guard-followup

Conversation

@claude

@claude claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

No linked issue

Summary

#3469 fixed five filtered probes whose || fallback fires spuriously under set -o pipefail, and stopped there. Two independent verifiers found the same shape still live at eleven more sites across six plugins — four of them introduced by #3468, the commit immediately before the one that diagnosed the bug class.

The mechanism is unchanged. guard >/dev/null 2>&1 && probe | cap || echo TOKEN takes the pipeline's exit status. Without pipefail that is the cap's, always 0. With pipefail it is the last non-zero status, which two ordinary situations produce: a filter matching nothing, and the writer taking SIGPIPE when the cap closes the pipe. Either fires the failure token on a healthy probe.

Fix

The two that matter

ai-slop:audit was live and unconditional. detect.sh --show-config emits 10 lines against a head -8 cap, writing them one at a time, so head closes the pipe mid-run every time. PIPESTATUS reads 141 0. The probe rendered the full correct config and then appended detector unavailable beneath it — reproducible from any directory, with no configuration at all.

knowledge had a consequence beyond a wrong string. video-digest/SKILL.md states: "STOP if the pre-computed context above shows MISSING for yt-dlp, ffmpeg, or ImageMagick. Cloud agents without the media toolchain fail closed." #3468 fixed that gate's fail-open defect and shipped this inversion in its place, so the gate would refuse to run on a machine that has the toolchain.

Measured over 30 runs each, tool installed and working, under pipefail:

Probe Output size Token fired
ffmpeg 41 lines 29/30
magick 5 lines 19/30
yt-dlp 1 line 0/30

It is load-dependent: ffmpeg near-deterministic, magick a race, yt-dlp latent by output size. An intermittent false MISSING is harder to diagnose than a consistent one. All three are fixed regardless. After the fix: 0/30 at every size.

Also fixed

course-digest's two probes; firecrawl:firecrawl (pre-existing, and the idiom the fleet copied); provenance:audit's two detector probes (latent by shape only — its changelog says so rather than claiming a live fix); docs-hygiene:audit-derivability, whose capture-first form protects only the git call and leaves its awk-and-head data run exposed past 20 dirty .md files; and code-tidying:tidy, where a bare third unknown was appended to well-formed output.

Grants

Eight sites keep their original leading token, so every existing grant still matches.

One grant did move, and an earlier draft of this commit message denied it. Quoting ${CLAUDE_SKILL_DIR} changes the literal command string, and Bash permission rules are globs over that string, so Bash(${CLAUDE_SKILL_DIR}/scripts/detect.sh:*) stops matching "${CLAUDE_SKILL_DIR}/scripts/detect.sh". Three companion quoted rules were added (ai-slop detect.sh; provenance list-corpus.sh, check-stamps.sh) with the unquoted ones retained, because both skill bodies still instruct unquoted invocation elsewhere. Same script, same anchor, same :* scope. Nothing widened.

The quoting itself fixes a real defect: installed under a path containing a space, the unquoted expansion made a working detector report itself unavailable. firecrawl:update already quoted it; these now match.

Verification

Every site executed in three states, with and without set -o pipefail:

ffmpeg probe, fake tool on PATH
  BEFORE  absent/on    MISSING — install ffmpeg
  BEFORE  multi/on     ffmpeg version 7.1 …
                       MISSING — install ffmpeg      <-- inversion, tool present and working
  AFTER   multi/on     ffmpeg version 7.1 …          <-- no token

ai-slop, real detector      before 9 lines ending "detector unavailable"; after 8, no token
audit-derivability, 3k dirty .md   before 20 / 21 with token; after 20 / 20
tidy                        before 3 lines with bare "unknown"; after 2
scripts/affected-tests.sh --run                    exit 3 (documented non-shell-lane pass)
scripts/check-changed-skills.sh origin/main        7 skills checked, 0 failed
scripts/check-changelog-parity.sh --check-order    86 changelogs, no duplicates
scripts/check-changelog-parity.sh --check-bump     every bumped plugin has its entry
node scripts/validate-plugin-contracts.mjs         52 setup skills, 3059 files
markdownlint-cli2 <13 changed>                     0 issues
typos                                              clean

Roster completeness was re-derived by a method unrelated to the first grep: a parser over every extracted injection tracking brace depth and quote state, flagging a top-level | followed by a top-level ||. It found zero of these eleven (confirming they are fixed) and nine survivors, all correctly-drawn declines: claude-memory, implement, observability and audit-comment-residue are structurally immune (wc/tr/tail/sed drain their input); audit-noise:17, rename-references, find-handoff are buffer-bounded rather than immune — measured to fire 0/5 at 1,000 producer lines and 5/5 at 5,000, and audit-noise:17 runs at 61 lines and fired 0/10.

Open, and stated rather than assumed

Whether Claude Code runs ! injections under pipefail remains undocumented. The skills page specifies working directory, stderr merging, timeout, output size and exit-code semantics, but names no shell options. That governs whether these are live or latent, not whether they are defects — the brace-group form is correct under both settings, so the exposure is avoidable regardless.

Related

Refs #3469, refs #3468, refs #3460.


Generated by Claude Code

…a spaced skill dir

#3469 fixed five filtered probes whose `||` fallback fires spuriously under
`set -o pipefail`, and stopped there. The same shape is still live at eleven
more sites across six plugins, four of them introduced by #3468 -- the commit
immediately before the one that diagnosed the bug class.

The mechanism is unchanged. `guard >/dev/null 2>&1 && probe | cap || echo TOKEN`
takes the pipeline's exit status. Without pipefail that is the cap's, always 0.
With pipefail it is the last non-zero status, which two ordinary situations
produce: a filter matching nothing, and the writer taking SIGPIPE when the cap
closes the pipe. Either fires the failure token on a healthy probe.

ai-slop:audit is the worst and was live, not latent. detect.sh --show-config
emits 10 lines against a head -8 cap, writing one at a time, so head always
closes the pipe mid-run: PIPESTATUS reads 141 0. The probe rendered the full
correct config and then appended "detector unavailable" beneath it. It
reproduced from any directory with no configuration at all.

knowledge is the one with a consequence beyond a wrong string. video-digest
states "STOP if the pre-computed context above shows MISSING for yt-dlp,
ffmpeg, or ImageMagick. Cloud agents without the media toolchain fail closed."
#3468 fixed that gate's fail-open defect and shipped this inversion in its
place, so the gate would refuse to run on a machine that HAS the toolchain.
Whether the token fires is a race between the tool writing its banner and the
cap closing the pipe, so it scales with output size. Measured here over 30 runs
per size against a synthetic tool on PATH, installed and working: 41 lines
fired 29/30, 5 lines fired 19/30, 1 line fired 0/30. So ffmpeg fails on nearly
every run, magick (5 lines) is a genuine race, and yt-dlp (1 line) is latent by
shape only. An intermittent false MISSING is harder to diagnose than a
consistent one; all three are fixed regardless.

Also fixed here, same shape: course-digest's two probes, firecrawl:firecrawl
(pre-existing, and the idiom the fleet copied), provenance:audit's two detector
probes, docs-hygiene:audit-derivability -- whose capture-first form protects
only the git call, leaving its awk-and-head data run exposed past 20 dirty
files -- and code-tidying:tidy, where a bare third "unknown" line was appended
to well-formed output.

Two of the eleven are latent by shape, not observed failures, and the
provenance changelog says so plainly rather than claiming a live fix:
list-corpus.sh --show-config emits 7 lines against a head -10 cap, and
check-stamps.sh is piped into tail -3, which drains its input and cannot raise
SIGPIPE. Both were verified to behave identically before and after in three
states under both settings.

Separately, ${CLAUDE_SKILL_DIR} is now quoted in ai-slop:audit and
provenance:audit. Installed under a path containing a space, the unquoted
expansion made a working detector report itself unavailable. firecrawl:update
already quoted it; these now match. Pre-existing, but these commits are what
made the token reachable at all -- before them the same failure rendered empty.

Quoting changes the literal command string, and Bash permission rules are globs
over that literal string, so the unquoted script grants no longer match the
quoted invocations. Three companion rules are added, one per quoted script:
Bash("${CLAUDE_SKILL_DIR}/scripts/detect.sh":*) in ai-slop, and the list-corpus
and check-stamps equivalents in provenance. The unquoted rules are kept because
both skill bodies still instruct unquoted invocation elsewhere. Each pair names
one script under the same ${CLAUDE_SKILL_DIR} anchor with the same :* argument
scope, so nothing is authorized that the plugin could not already run. No other
grant changed: every other rewritten command keeps its original leading token,
which is why code-tidying:tidy takes the ||-inside-the-brace-group form rather
than the brace-group-first one.

The remedy elsewhere is the brace group #3469 established: the data pipeline
sits in { ...; :; }, closed by a command that cannot fail, so the || is
reachable only when the guard short-circuits. No new idiom, and no $ expansion
introduced beyond the quoted ${CLAUDE_SKILL_DIR}; audit-derivability keeps the
pre-existing $s capture, which is the part that legitimately drives its ||.

Every site proven by execution in three states -- tool or script absent, output
under the cap, output over the cap -- with and without pipefail. The five sites
#3469 fixed were re-verified at 3,000 dirty files under both settings and are
correct as shipped, as is claude-ops:observability, whose sed drains its input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmrZGdp1dgbZuPCy7tcRJo
@claude
claude Bot merged commit bd77698 into main Aug 28, 2026
61 of 62 checks passed
@claude
claude Bot deleted the claude/pipefail-probe-guard-followup branch August 28, 2026 14:46
claude Bot added a commit that referenced this pull request Aug 28, 2026
…five wrong keeps (#3475)

#3468 closed 16 cross-plugin path citations under ADR 0018 and kept 23 on a
test it never wrote down. Its own verifier reported '3 keeps refuted; 4 cannot
verify' and that reasoning was unreadable, because the verifier ran as a remote
session the agent could not read back. An independent verifier settled it: five
are wrong, not three, and it found why this recurred.

The dividing test #3468 applied -- fix a citation that is an ADDRESS for an
obligation, keep one that is EVIDENCE about this tree at a moment in time --
appears nowhere in ADR 0018. Clause 2 reads unconditionally. But ADR 0018 is
itself a docs/** file and path-cites plugin skill privates five times, quoting
their contents inline. Either it violates itself on its own first page, or
clause 2 is scoped to citations that function as an address. Only the second
reading is coherent, and an unwritten rule is what let one commit rule two ways
in two files. This writes it down, as an Amendment following ADR 0011's
convention: the evidence/address test, scripts/lib being outside the
entry-surface carve-out, and how plugin-level non-skill trees generalize.

The five wrong keeps. shell-test-helpers cited a private scripts/lib/ path --
and that is a self-contradiction inside c66f26c itself, which added
detector-findings 2.8.1 fixing the byte-identical shape and stating the ground,
then kept it one file over. Three line and step pins are dropped as the same
class 2.7.1 removed a :414 pin for; each citation already quotes its content
inline, so the drop is lossless. records.json:3's keep-ground was false: the
sweep kept it because generation overwrites hand-edits, but that store's own
note says 'Hand-editable and human-gated'.

Recorded as open remainder rather than swept: twelve citations c66f26c created
itself, every fix having written a changelog entry quoting the path it removed,
so the population is 16 fixed and 35 kept rather than 16 and 23; three trees
excluded by fiat with no carve-out authorizing it; 51 citations into
plugin-level non-skill trees; and a stale dometrain security record whose own
version-bump re-trigger has fired unnoticed.

A fifth review round found two more false claims, both stale statuses. '34 to
32' was wrong: re-deriving all 34 rows against the roster's own text test finds
22 already closed, twelve of them by #3380, the same commit that last wrote the
roster and left its own 'all 34 re-verified' line standing over twelve citations
it had just deleted. This change set republished that status in a file whose
line 18 reads 'the check is the text, never the status and never the line
number.' And the spec still shipped the probe shape #3469 removed, presented as
verified in three states, two of which print the failure token under pipefail.

Refs #3468, refs #3469, refs #3460. Sibling #3474.
claude Bot added a commit that referenced this pull request Aug 28, 2026
…test, and record the pipefail question (#3476)

The last five findings from an independent security review of #3460/#3468/
#3469. #3474 and #3475 closed the probe-shape and citation halves; this closes
the filter half and writes the open question into the convention.

Four probes silently under-reported any filename git status --porcelain
C-quotes. A plain space is enough to defeat grep '\.md$' and awk '{print $NF}'.
Executed against a repo holding seven files per extension (plain, space, single
quote, double quote, semicolon, pipe, embedded newline), each of the four went
from catching 4 of 7 to 7 of 7. Two correct forms already shipped in the same
two plugins, so this was drift rather than a missing idea: the .md sites took
audit-noise's grep form, which keeps their leading token and so keeps their
grant valid, and dissolve-comments took audit-comment-residue's -z NUL parse.
The residual is stated in the changelogs rather than hidden.

audit-comment-residue's parity test could not see an over-reporting preview.
Its loop only checked one direction, so mutating the SKILL.md awk to drop its
rename skip made the preview emit a phantom path while detect.sh audited five
real ones, and the suite still passed 53/53. A reverse loop now asserts every
previewed path is one detect.sh audited: the same mutation fails 1/54 naming the
phantom, and reverted it passes 54/54. The forward loop is byte-identical.

claude-ops:observability rendered 'unknown' both outside a repo and inside one
whose toplevel directory is literally named unknown. The failure case now
renders a distinct token.

Six labels asserted 'empty = none' in two states the probe does not establish:
the brace group's : makes the outer || unreachable, which also masks a
filter-stage failure and a guard/data double invocation. The labels now say
'empty = none matched or the probe returned nothing'. No plumbing restructured;
the ~25 unfiltered probes were left alone because their claim is already true.

The durable fix is in precompute-context.md: whether Claude Code runs !
injections under pipefail is undocumented, the brace group is correct under both
settings while guard && pipeline || token is correct under only one, and the
brace group's own price is that an internal failure renders empty, which is why
the label must carry it. The recheck trigger now covers shell options.

Two derivations unrelated to the review's table both return the same six
filtered probes: the true set is six, four defective, not five and three.
#3474 fixed audit-derivability's SIGPIPE shape hours earlier and left its filter
untouched, so both defects landed on one line; this branch was rebuilt onto
current main and carries both, since committing either alone would have
reverted the other.

70 pipefail cells all exit 0; affected-tests real exit 0 with 17/17 suites;
changelog parity including --check-preserved across 306 headings; 3061 plugin
files validated; markdownlint clean over 173 files. No grant widened or broken.

Refs #3474, refs #3475, refs #3469, refs #3468, refs #3460.
claude Bot added a commit that referenced this pull request Aug 28, 2026
…, and flag a fired security re-trigger (#3477)

Every one of #3468's ADR-0018 citation fixes wrote a CHANGELOG entry quoting
the path it removed, creating twelve new instances of the shape it was closing.
The population at main is 16 fixed and 35 kept, not 16 and 23. #3475 wrote the
governing test into ADR 0018; this applies it to those twelve so a later
re-derivation does not re-open them.

All twelve are keep-correct, established row by row and anchored on text rather
than line numbers. A dated changelog entry quoting a citation it removed is the
evidence case exactly. No CHANGELOG was edited, so no convention bump is owed.

Three sub-rulings the class needed, now written into the spec rather than left
implicit. config-cascade's three rows are the only clause-3 question in the set:
they are plugin-relative and resolve against nothing, and the entry says so in
the same sentence. Clause 3 governs addresses a doc offers, not strings it
quotes in order to report them broken, and forcing resolution would delete the
finding. The other nine resolve on disk today, verified one path at a time
across eleven distinct targets. No row carries a line or step pin, the part the
amendment says rots first.

One excluded on purpose and recorded so it is not re-opened:
plugins/review/reference/topic-docs.md, a plugin-level non-skill tree the
amendment routes to its own pass, which the entry declares a keep rather than
quoting as removed.

Separately, MIGRATION-PLAYBOOK.md records a dometrain security review performed
at 0.1.0 and states that a version bump adding a new trust surface re-triggers
it. The manifest now reads 0.2.7, eleven releases later. The note added states
the reviewed version, the shipping version, that the ACCEPT below it describes
0.1.0 only, and that the re-review is owed and deliberately not performed here.
It stops short of asserting a trust surface was added: that is the condition the
clause turns on and cannot be settled by reading the page. Two checks were run
to avoid claiming otherwise and neither settles it, which the note says.

The spec's dometrain entry pinned MIGRATION-PLAYBOOK.md:943, a line this
change's own edit would have invalidated. Re-anchored on the record's heading
text. That decay rule has been violated three times across this sweep; this is
the first time it was caught before landing.

Recorded because two earlier claims about it were wrong in opposite directions:
this worker's definition declares the Agent tool and the harness withheld it at
spawn, so nesting is gated by depth realized as pre-launch tool omission. There
is no refusal string because there is no call to refuse.

Refs #3476, refs #3475, refs #3474, refs #3469, refs #3468, refs #3460.
claude Bot added a commit that referenced this pull request Aug 28, 2026
…reopened it three times (#3478)

The L4 roster's Group 2, eight intra-plugin path-form citations, was the last
block anyone believed open. All eight were already closed, by 6c7a103 (#3380)
on 2026-08-26 -- the same commit that wrote the roster. Its own message names
what it did: eight citations written with an implied base of the plugin root
while the real base was reference/, none of which resolved for any reader.

Verified one at a time against the roster's own text test. All eight now read
${CLAUDE_PLUGIN_ROOT}/skills/<skill>/<path> and all seven distinct targets
exist on disk. Zero plugins/** files edited, so no bump and no plugin changelog
entry: nothing needed changing.

The reason four separate passes each re-derived this roster from scratch is one
sentence. #3380 fixed 32 of 34 rows and left its summary asserting that all 34
path:line citations were re-verified on 2026-08-26 and every one resolves --
standing over citations it had just deleted. A fifth-round audit caught that for
Group 1, where 22 of 32 were already closed and twelve of those by #3380 itself.
This closes the same hole for Group 2. The generalizable rule is now in the
sweep spec: a record that fixes findings and updates its own summary in the same
commit must update the summary, or the summary outranks the fix for every later
reader.

The roster's inventory rows are left verbatim, per its own decay rule that the
inventory is the part that cannot be re-derived, and per the precedent that
#3474 and #3475 both closed rows without touching the file. Only an additive
closure stamp is appended.

On whether ADR 0018 reaches this class, the honest answer is that its
encapsulation half does not. Clause 1 legalises intra-plugin citations and names
this exact citing surface. Clause 2 cannot apply, because both files ship inside
one plugin so the runtime absence motivating it cannot occur, and the 2026-08-28
amendment's fix-an-address / keep-evidence test divides clause 2 applications
only. Only clause 3 reaches Group 2, and clause 3 is a resolvability rule rather
than an encapsulation one: had these been open, the remedy would have been path
form and nothing else. The clause that earned its keep is the ADR's own
observation that proximity did not prevent them.

A second derivation making no reference to the roster resolved every citation
token in every plugin-level reference/, context/ and agents/ tree plus every
plugin README against the base its own form implies: 52 tokens, 0 clause-3
failures. Group 3's two anchors were also closed by #3380. 34 closed, 0 open.

No fresh-context verifier reviewed this diff. Nested spawning is unavailable at
this depth: the Agent tool is withheld pre-launch, so there is no call to refuse
and no refusal string. A second mechanical derivation was substituted and every
line reference re-checked against the live tree, which is weaker than a fresh
context and is recorded as such.

Refs #3477, refs #3476, refs #3475, refs #3474, refs #3469, refs #3468.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant