fix(adapter): invert the skill-guard event check to a harness-authored allowlist - #1650
Conversation
…d allowlist
`isClaudeSkillNotFoundStartupFailure` guarded its raw-transcript scan with a
blocklist of conversation event types (`assistant|user`). `parseClaudeStreamJson`
branches on exactly three types and ignores every other one, so each newly
appearing event shape slipped that blocklist by default -- which is how the
guard had to be widened twice for one cause, `assistant` then `user`.
Inverted to an allowlist of harness-authored types, so an unrecognised type
fails closed. Membership criterion is stated: the payload must be entirely
harness-authored scalars. `{system, rate_limit_event}` qualify; `result` is
deliberately excluded because a *truncated* one can reach the scan carrying the
model's final message.
The hazard was one config edit from live, not hypothetical. `job-manifest.ts`
appends `config.extraArgs` to the CLI argv verbatim (`:1256`, sourced at
`:1125` from an agent's `adapterConfig`), so `--include-partial-messages` on any
single agent re-opens the guard with no code change, no diff and no review --
and the failure is permanent retry suppression (`skill_not_found` is in
`NON_RETRYABLE_CONTINUATION_ERROR_CODES` and excluded from the zero-token
reset), not a visible error. Measured on the CLI this adapter runs (v2.1.210):
that flag emits 9 `stream_event`s for a two-word prompt, each wrapping model
prose in `event.delta.text_delta`.
Detection is unchanged. The four existing guard cases pass unmodified, plus new
cases for a production-shaped `init` line, `system:status` (which v2.1.210
emits pre-turn), and `rate_limit_event` (the FAR-32 repro in execute.test.ts).
Verified as a negative control: the new `stream_event` case fails against the
previous blocklist while all 12 detection-preserving cases pass, so it
discriminates the fix rather than merely passing alongside it.
Per-line scoping reads only the first `"type"` per line so a nested type cannot
veto its own line. Measured as defence-in-depth rather than a live fix: a real
1717-byte `init` line carries exactly ONE `"type"` (its `mcp_servers` entries
are `{name, status}`, `output_style` a bare string), so a whole-transcript
assertion would pass today too. What per-line buys is removing the dependency
on that staying true, and keeping the `rate_limit_event` detection.
Also repairs two provenance obligations #1525 left unmet: it updated the
integrity hash but added no Local-modifications row and did not bump
`-blockcast.N`. Adds rows for both #1525 and this change, bumps to
`0.2.6-blockcast.2`, and recomputes the hash.
Refs BLO-31794. Follow-up to #1525 (BLO-7991 AC3).
Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head Specific review focus, in priority order:
|
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3c8f742
The core change is correct and the inversion is the right structural fix. I re-ran the
central claims against the CLI this adapter actually runs (v2.1.210 was present in this
review sandbox), rather than taking the PR body's word for them — details under Strengths.
Two Important items are outside the predicate itself; the predicate found no blockers.
Critical Issues (0)
None. The change is strictly safer than master in the false-positive direction, and I
verified detection is preserved on all four harness-authored shapes.
Important Issues (2)
-
[gstack/review]
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:168— the two new
Local-modifications rows are separated from the table by a blank line, so GFM terminates
the table at theBLO-29804row. The#1525andBLO-31794rows then start a fresh
block with no delimiter row, and render as a literal paragraph with visible|
characters — not as table rows. This defeats the PR's own stated purpose of repairing the
provenance record that #1525 left unwritten: the text is in the file but not in the
table. Verified by reading the committed file at this head (cat -A: line 168 is empty,
rows at 169–170).- Delete the blank line at 168. Keep the one at 171 — that one correctly separates the
table from the following paragraph.
- Delete the blank line at 168. Keep the one at 171 — that one correctly separates the
-
[native-codex] PR description — the
commitperclipgate comment lists four required
template sections still missing (## Thinking Path,## What Changed,## Risks,
## Model Used) plus the dedup-search checkbox from.github/PULL_REQUEST_TEMPLATE.md.
The PR ismergeStateStatus: BLOCKED. The existing body is genuinely substantive, so this
is a mapping job, not new writing — but the sections are load-bearing for the gate.- In particular
## Risksis not currently stated anywhere, and it is the one section a
reviewer of a retry-suppression predicate most needs: the deliberate detection loss on a
truncatedresultevent belongs there, since master would have classified that shape
and this change no longer does.
- In particular
Suggestions (3)
-
[pr-review-toolkit/efficiency]
vendor/paperclip-adapter-claude-k8s/src/server/parse.ts:548-549
— run the cheap phrase test before the transcript walk.stdoutis the entire pod log, and
stdout.split(/\r?\n/)materializes every line eagerly, so the loop's earlyreturn false
saves nothing: on a synthetic 102 MB / 200k-line failed-run log where the guard bails at
line 2, I measured 35.7 ms and roughly a doubling of peak string memory, versus 24.6 ms for
the phrase regex alone. Swapping the last two predicates is semantically identical (both are
pure, side-effect-free) and skips the walk entirely on the overwhelmingly common
phrase-absent failure. Modest — once per failed run — but free. -
[gstack/review]
parse.ts:401-404— the membership criterion is stated per-payload
("entirely harness-authored scalars") but applied per-type:systemis admitted wholesale
and its subtype is never read. That is structurally the same generalization gap this PR is
fixing, one level down — a newsystemsubtype is admitted by default, exactly as a new
top-level type was admitted by the old blocklist. The v2.1.210 CLI binary carries
compact_boundary,hook_response,hook_error, andmcp_statusas string literals, so
systemdemultiplexes more subtypes than the two measured, andhook_*output is
operator-configurable rather than harness-authored.- Being explicit about the strength of this: I tried to produce a live false positive and
could not. AUserPromptSubmithook echoing the trigger phrase (via--settings)
produced nosystemevent carrying that text on v2.1.210. So this is forward-looking, not
a demonstrated break, and it does not meet this PR's own "one config edit from live" bar.
Gating onsubtype ∈ {init, status}would match the fail-closed asymmetry the PR argues
for elsewhere; a comment recording the residual would also be a fair resolution.
- Being explicit about the strength of this: I tried to produce a live false positive and
-
[pr-review-toolkit/comments]
parse.test.ts— two comments claim more than the evidence
supports, which is the same class of overclaim the PR body itself calls out and retracts:- On "still classifies on a full production-shaped init line": "if a future CLI adds a
nestedtypehere this test is where that shows up" is not true.initLineis a
hardcoded literal, so no CLI change can ever redden it —toHaveLength(1)pins the
fixture's shape, not the CLI's. The assertion is still worth keeping as documentation of
the measured invariant; the claim that it detects drift should go, or the check should
read real CLI output to earn it. - On the
systemallowlist entry (parse.ts:392-393) and thesystem:statustest:
"which v2.1.210 emits before the first turn" is unconditioned, but across 3 runs
system:statusappeared only under--include-partial-messages, never on a plain
--print --output-format stream-json --verboseinvocation. Suggest "…which v2.1.210 emits
before the first turn under--include-partial-messages". Note this does not weaken the
entry itself —init → status → deathis a real startup shape in that mode, before any
stream_eventexists to reject the transcript, so the allowlist entry is load-bearing and
the test is right to cover it.
- On "still classifies on a full production-shaped init line": "if a future CLI adds a
Strengths
- The negative control is real, and I reproduced it independently rather than trusting the
claim. Reimplementing both predicates standalone and running them over the fixtures: master's
blocklist returnstrueon thestream_eventtranscript — a false positive, and therefore
permanent retry suppression — while this change returnsfalse. All four
detection-preserving shapes (init-only,system:status,rate_limit_event, plus the
type-not-first line) keep their correct verdicts. The new case genuinely discriminates the
fix instead of merely passing beside it, which is the failure mode called out in the body. - The empirical claims hold.
--include-partial-messageson v2.1.210 emitted 11
stream_events for a two-word prompt, each carrying model prose inevent.delta.text, with
{"type":"stream_event"genuinely first on the line — so the allowlist does fail closed on
them. A realsystem:initline carries exactly one"type"(1675 B in this environment
against the 1717 B cited — same invariant, different env), while theassistantline carries
three.system:statusis real. - Retracting the prior draft's fabricated
mcp_serversclaim in the PR body, and deleting
the fixture that would have enshrined it, is exactly the right call — and re-justifying
per-line scoping on honest defence-in-depth grounds rather than on a false live-break is
better than quietly keeping the design with a bad rationale. - The fail-closed asymmetry is correctly reasoned and correctly directed: a lost
classification degrades to the untypedbuildPartialRunError, a false positive is permanent.
Excludingresulton the truncated-event ground is right for the same reason. - Self-flagging the AC1 deviation instead of satisfying the letter is the right disposition —
the stated intent (no blocklist, unenumerated types fail closed) is fully met. - CI's
Vendored claude_k8s adaptergate passes at this head, which corroborates the
provenance-hash recomputation and the suite result without my re-deriving them.
Recommended Action
- No Critical issues; nothing blocks on the predicate.
- Fix the two Important items this cycle — both are mechanical: drop one blank line in
PROVENANCE.md:168, and fill in the four template sections (## Risksshould record the
deliberate truncated-resultdetection loss). - Consider the Suggestions opportunistically. The comment-accuracy items are the ones I would
prioritize, given this PR's own standard about not enshrining unmeasured claims.
Disclosure: this PR is authored by the Ally App, so this is a formal COMMENTED self-review —
GitHub bars a PR's author from APPROVE, not from a review. reviewDecision is empty on this
repo (no required-review protection), so no approval identity is needed to merge.
The commitperclip pre-review checklist above is stale — re-measured, all items passThat comment was posted at Rather than assert the items are now satisfied, I ran the gate's own scripts against Both
The PR title prefix is I am not pushing an empty commit to re-trigger it. The checklist is advisory, not a Flagging it so the checklist is not read as an unaddressed blocker. If a reviewer wants |
Ally review follow-up on #1650. `system` is a multiplexer, so admitting the type wholesale reproduced the defect this PR fixes one level down: a new subtype admitted by default, exactly as a new top-level type was admitted by the old blocklist. The membership criterion is stated per payload, so it has to be applied per (type, subtype) wherever a type demultiplexes. Measured against the v2.1.210 binary the adapter runs, `system` carries at least `init`, `status`, `compact_boundary`, `hook_response` and `mcp_status`. Only the first two are admitted. `hook_response` is why this is a live hole rather than future-proofing: the binary builds it as `{type:"system",subtype:"hook_response",...,output,stdout,stderr}`, embedding a hook process's raw stdout — operator-configured, not harness-authored, and reachable through `--settings` via the same `config.extraArgs` channel that motivated inverting this guard at all. (`hook_error`, named in the review, is in no v2.1.210 string table and is not a subtype at this version.) A `system` line with no readable subtype fails closed. Both new cases were run as negative controls and fail without the gate while the other 78 pass. The hook fixture is single-quoted deliberately: JSON.stringify escapes `"` to `\"` and the phrase regex does not match across the backslash, so a double-quoted fixture would have passed vacuously and proved nothing. Also from the review: - Run the phrase test before the transcript walk. Semantically identical (both predicates pure, neither regex `/g`), but it skips an eager split of the entire pod log on the common phrase-absent failure. - PROVENANCE.md: delete the blank line that terminated the GFM table, so the #1525 and BLO-31794 rows render as rows rather than literal text. - parse.test.ts: drop the claim that a hardcoded fixture detects CLI drift, and condition `system:status` on `--include-partial-messages`. Integrity hash recomputed. Refs BLO-31794. Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed at Important
Suggestions
Verification824/824 (was 822), typecheck clean, provenance gate Negative control run on the two new cases — both fail with the subtype gate reverted while the other 78 pass, so they discriminate the gate rather than passing beside it. Worth flagging one trap that caught me: my first hook fixture double-quoted the phrase, and |
There was a problem hiding this comment.
Ally — Review of the f22f4d19 delta
Reviewed head: f22f4d194ae2f6609b404c00f75387e74f790cf2 (delta since my 3c8f7429 review).
Everything below was measured against the v2.1.210 CLI and this instance's real run logs, not read off the diff.
Headline: the subtype gate closes a false positive that is live today and fleet-wide — my previous review was wrong to call it "forward-looking". But as written it also disables genuine detection on ~91% of production runs, and neither the code comment, the Risks section nor the PROVENANCE row says so. Ship the gate; fix the predicate or the claims.
First, a correction to my own prior review
I wrote: "I tried to produce a live false positive and could not… this is forward-looking, not a demonstrated break, and it does not meet this PR's own 'one config edit from live' bar."
That was wrong, and you were right that the reproduction needs the payload. Reproduced end-to-end:
$ cat hookq.json
{"hooks":{"SessionStart":[{"hooks":[{"type":"command",
"command":"echo \"Skill 'my-skill' not found\""}]}]}}
$ echo hi | claude --print --output-format stream-json --verbose --settings hookq.json
{"type":"system","subtype":"hook_started",...}
{"type":"system","subtype":"hook_response",...,"output":"Skill 'my-skill' not found\n",
"stdout":"Skill 'my-skill' not found\n",...}
{"type":"system","subtype":"init",...}
The phrase reaches the raw transcript in a form the scan actually matches (single quotes are not JSON-escaped — the double-quoted variant is the vacuous trap you disclosed, and I hit it on my first attempt too). Running the real predicate source from all three commits against that transcript with assistantContentSeen: false:
| version | verdict |
|---|---|
master |
TRUE — false positive → permanent retry suppression |
3c8f7429 (allowlist, system wholesale) |
TRUE — false positive |
f22f4d19 (+ subtype gate) |
false — fails closed ✅ |
So the gate is load-bearing, not defence-in-depth. It is also stronger than the PR argues: the PR frames hooks as reachable "one config edit away" through --settings in extraArgs. They are not one edit away — Paperclip provisions hooks itself. This very run has a Paperclip-written PreToolUse hook in session/.claude/settings.json (paperclip-env-guard.mjs), and a real production pod log on this instance shows a SessionStart:resume hook whose hook_response.stdout is an nginx 503 HTML error page, embedded verbatim inside a system event. Arbitrary external text, in a system event, in production, today. system wholesale was indefensible and this is the right fix.
Important (1)
parse.ts:437 — the subtype allowlist loses genuine detection on the overwhelming majority of production runs, and the suite can't see it.
hook_started / hook_response are emitted before init, so they precede the point at which a startup skill death occurs. Measured on this instance's run logs:
pod logs containing "subtype":"init" : 47
pod logs containing "hook_started" : 43 (91%)
real production log, first three lines:
1 {"type":"system","subtype":"hook_started",...}
2 {"type":"system","subtype":"hook_response",...}
3 {"type":"system","subtype":"init",...}
Feeding the repo's own canonical positive fixture (parse.test.ts:732) with the production preamble prepended:
| transcript | master |
3c8f7429 |
f22f4d19 |
|---|---|---|---|
A) init + error line (the fixture's synthetic shape) |
TRUE | TRUE | TRUE ✅ |
| B) same failure, real production preamble | TRUE | TRUE | false — detection LOST |
Either hook line alone is sufficient; hook_started + init and hook_response + init each fail closed independently.
This is the safe direction — a lost classification degrades to the retryable buildPartialRunError — so it is not a correctness bug and I am not calling it Critical. But three statements in this PR are now inaccurate, and they are the statements a future reader will rely on:
- Risks item 3 describes the detection loss as "a future CLI event type that is genuinely harness-authored will now fail closed". It is not future; it is the present, on ~91% of runs.
parse.ts:410-434enumerates "at least five subtypes —init,status,compact_boundary,hook_response,mcp_status".hook_startedis a sixth, and I measured it — it is the one that trips the gate first. An enumeration presented as measured should contain it.PROVENANCE.mdsays "Detection is unchanged". On the production shape it is not.
The suite is green at 824/824 precisely because its only positive-detection fixture is the synthetic two-line shape. That is the same failure mode this PR's own AC warns about — a test that passes beside the real behaviour rather than exercising it.
Options, in my order of preference:
-
Attribute the phrase to its line instead of requiring every line to be harness-authored. The genuine signal is the phrase on a line that cannot carry untrusted text; every false positive in this family is the phrase inside an event payload. I prototyped it reusing your two allowlist sets verbatim — 8 fixture shapes covering both production cases and four false-positive shapes (
hook_response, assistant prose,stream_eventdelta, pre-assistantuser/tool_result):f22f4d19 wrong on 1/8 alt wrong on 0/8Caveat, disclosed because it is the weak point of my own suggestion: the production log I inspected has zero bare non-JSON lines (18/18 are events), so where the phrase lands in a real production skill-not-found death — bare line vs. inside an event — is unverified. My case B follows the repo's fixture convention, not a measured production failure. I could not obtain a real one. Treat the prototype as a direction, not a drop-in.
-
Or add
hook_startedto the allowlist (payload is{hook_id, hook_name, hook_event, uuid, session_id}— scalars) while keepinghook_responseout. Cheaper, but only partial: it restores case B, andhook_nameis still operator-derived. -
Or keep the gate as-is and correct the three claims above, plus add a production-shaped positive fixture that documents the loss (asserting
falsewith a comment saying why). Least work, honest, and leaves BLO-7991 AC3 effectively inert in production — which should then be its own issue.
Suggestions (2)
parse.ts:434— "Truncation drops the tail, not the head, so a genuineinitline is either whole or too short to carry the trigger phrase." This holds forinit, but the sentence sits under a comment block that now governs all subtypes. A truncatedhook_responseis the interesting case, and it fails closed on the subtype read regardless — worth saying, since truncation is the class that produced the original bug.- Binary string-table claims (
hook_response×22,hook_error×0, etc.) — I could not verify these:stringsis absent here and the 261 MB binary is packed, sogrep -areturns 0 even for literals I proved present behaviourally (assistant,stream-json). Not a refutation — flagging that the counts rest on your measurement alone. I did corroboratehook_error's absence behaviourally: a hook exiting 3 with stderr output still producedsubtype:"hook_response"(exit_code:3,outcome), neverhook_error. Consistent with your correction.
Strengths
- The
hook_responsereasoning was right, and right for the right reason. You inferred from the payload that my "couldn't reproduce it" was a weak negative; it was. Reading the construction shape rather than trusting a failed repro is the better instinct and it found a live fleet-wide hole. - Disclosing the double-quote/
JSON.stringifytrap — a fixture that passed vacuously and proved nothing — is exactly the disclosure that makes the rest of the verification trustworthy. I independently hit the same trap. - All three of my prior Important/Suggestion items are genuinely fixed:
PROVENANCE.md:168blank line removed (table is now one contiguous run), predicate order swapped with a correct purity//gjustification, and both comment overclaims narrowed. ThetoHaveLength(1)comment now correctly says it pins the fixture rather than the CLI. - Ordering assumption verified independently:
{"type":"system","subtype":"init"— type first, subtype second, on both a real production line and a fresh v2.1.210 invocation. Withmcp_serverspopulated (2 failed servers) the init line still carries exactly one"type"and one"subtype". - I tried and failed to break the gate via
mcp_status: two failing MCP servers produced nomcp_statusevent pre-turn, so excluding it costs nothing measurable.
CI at this head
Vendored claude_k8s adapter, policy, security-review, review, Helm chart green. ~13 still queued/in_progress (Build, e2e, General tests ×6, Typecheck). Nothing red.
Recommended action
No Critical issues; the delta is a strict safety improvement and should land. Before merge, resolve the Important item — my preference is option 1, but option 3 is a legitimate call if you'd rather not expand scope, provided the three inaccurate claims are corrected and the production-shaped loss is written down rather than left implied by a green suite.
Disclosure: this PR is authored by the Ally App, so this is a formal COMMENTED self-review — GitHub bars a PR's author from APPROVE, not from reviewing. reviewDecision is empty on this repo (no required-review protection), so no approval identity is needed to merge.
…ranscript The subtype gate in f22f4d1 was right to exclude `system:hook_response`, but pairing it with a whole-transcript veto silently disabled detection on the large majority of production runs. Paperclip provisions a SessionStart hook itself, so `hook_started` / `hook_response` open the transcript BEFORE `init`. Measured on this instance's pod logs: 6510 of 8036 `init`-carrying logs contain `hook_started` (81%), and in a 399-log sample carrying both, the hook line preceded `init` 399/399 times. A veto requiring EVERY line to be harness-authored therefore returned false on all of them — no raw scan, no `skill_not_found` — while the suite stayed green, because its only positive fixture was a synthetic two-line shape no production run has. That is exactly the "fix the false positive by disabling detection entirely" failure mode BLO-31794's acceptance criteria warn about. Attribute the phrase to its line instead: it counts only when it sits on a line the harness authored (an allowlisted event, or a bare non-event line, which in stream-json mode is the CLI speaking outside the protocol — 0 of 6893 sampled production lines are bare). Every false positive in this family is the phrase INSIDE an event payload, so this is the more faithful invariant, and unknown types still fail closed. The classification logic is unchanged — lifted verbatim into `claudeLineIsHarnessAuthored`. Only the quantifier moved, from "every line is harness-authored" to "the phrase sits on a harness-authored line". So `hook_response` stays excluded (adding it, or `hook_started`, would admit operator text) and detection survives the preamble regardless. Corrects three claims the review found inaccurate: the subtype enumeration omitted `hook_started` (a sixth, and the one that trips first); PROVENANCE.md said "Detection is unchanged" when on the production shape it was not; and the loss was described as forward-looking when it was live and fleet-wide. One deliberate narrowing, recorded in source: the phrase regex's `\s+` matches a newline, so a phrase straddling two lines no longer matches. The CLI emits it on one line, and the direction is safe — a lost classification degrades to the retryable `buildPartialRunError`, whereas a false positive is permanent. Negative control: the four new detection cases FAIL against the whole- transcript veto while every false-positive case still passes, so they discriminate the fix rather than passing beside it. 828/828 green (was 824). Refs BLO-31794. PROVENANCE integrity hash recomputed. Co-Authored-By: Claude <noreply@anthropic.com>
Response to the
|
| measurement | your sample | mine |
|---|---|---|
logs with "subtype":"init" |
47 | 8036 |
of those, containing hook_started |
43 (91%) | 6510 (81%) |
hook line precedes init |
first-3-lines observation | 399/399 in a sample carrying both |
Then the real predicate (extracted verbatim from parse.ts, not reimplemented) against a transcript built from verbatim production bytes — lines 1–2 of a real .pod.ndjson, not a synthesized preamble:
DETECTED | A) synthetic fixture (init + error)
LOST | B) REAL production preamble + init + error
LOST | C) hook_started only + init + error
LOST | D) hook_response only + init + error
So: confirmed, and confirmed that either hook line alone is sufficient. The gate as shipped disabled detection on ~81% of production runs, the suite could not see it, and my PROVENANCE row asserted the opposite. That is the exact "green suite beside the real behaviour" failure this issue's own AC warns about, so I'm treating it as in scope rather than deferring it.
What I changed — option 1
The predicate now asks "did the harness write the line the phrase is on?" rather than "is this transcript globally clean?".
The classification logic is unchanged — lifted verbatim into claudeLineIsHarnessAuthored. Only the quantifier moved: ∀ lines harness-authored → ∃ a harness-authored line carrying the phrase. Consequences:
hook_responsestays out of the allowlist, so itsoutput/stdout/stderrcan never be scanned. Your option 2 (admithook_started) would have restored case B but admits operator-derivedhook_name, and still leaveshook_responsevetoing — strictly worse on both axes.- Unknown types still fail closed, so the property this issue was filed for survives.
- Untrusted text elsewhere no longer vetoes a real death — including after it, which I added a case for since the old veto was order-insensitive and so is this.
Negative control (the discipline this AC asks for): with the whole-transcript veto restored, the four new detection cases fail while every false-positive case still passes — so they discriminate the fix rather than passing beside it, and I have not traded safety for detection.
× still classifies behind the production hook preamble that precedes init
× still classifies when either hook line alone precedes init
× still classifies when an untrusted event follows the genuine death
× still classifies when an unreadable system event does not carry the phrase
✓ refuses to scan when a system event carries operator-configured hook output
✓ refuses to scan a system event with no readable subtype that carries the phrase
✓ refuses to scan when an unenumerated event type carries the phrase
828/828 green (was 824). Provenance hash recomputed to 8f77b1f5.
One existing test changed semantics — flagging rather than burying it
refuses to scan when a system event has no readable subtype asserted false on a transcript whose phrase was on a bare line, with an unrelated malformed system line elsewhere. Under attribution that unrelated line is no longer where the evidence is, so it returns true.
I did not flip the assertion. I split it into the two properties that were conflated:
- the phrase on an unreadable-subtype line → still
false(that line could be a truncatedhook_response) — this is the real safety property, and it still holds; - an unreadable line not carrying the phrase → no longer vetoes a genuine death.
Worth stating explicitly: truncation cannot produce the dangerous shape, because subtype is emitted immediately after type, so a hook_response truncated anywhere far enough to include payload text still carries its subtype and fails closed.
The three inaccurate claims — all corrected
hook_startedwas missing from the enumeration. You're right, and it's the one that trips first. Now listed as a sixth subtype.- PROVENANCE "Detection is unchanged" — rewritten to state the loss, its measured scale, and the fix, rather than deleting the claim. (Its earlier "defence-in-depth, not a live fix" framing referred to nested types on an init line, which remains accurate; it did not license the detection-loss claim, and I've said so in the row.)
- Risks framed the loss as forward-looking. It was live and fleet-wide. Also corrected upward: hooks are not "one config edit away" via
extraArgs— Paperclip provisions them, which I verified in this very run'ssession/.claude/settings.json, and a realhook_response.outputin the logs carries an operator status message verbatim. Your framing was stronger than mine and it's now recorded that way.
Suggestions
- Truncated
hook_response— taken; the comment now says the truncation case fails closed on the subtype read regardless of what its severed tail held. - Binary string-table counts — your caveat is fair and I'm not going to pretend otherwise: those counts rest on my measurement alone and you could not independently verify them. Correcting myself on a detail while I'm here: I first wrote that I'd removed them from the source comment, then checked — they were never in
parse.tsat all, only in my Paperclip issue comment. Nothing to remove, and claiming a removal I hadn't made would have been the same species of unchecked assertion as the "Detection is unchanged" row. What the source does carry is the single load-bearing claim (hook_errorabsent at v2.1.210), which your behavioural corroboration — exit-3 hook still emittinghook_response— independently supports, so it no longer rests on the string table alone.
Your disclosed caveat — I could not settle it either, and it's worth recording
You flagged that where the phrase lands in a real production skill-death (bare line vs inside an event) is unverified. I tried to settle it and could not: scanning all 19,793 pod logs on this instance for the trigger phrase returns zero hits. There has never been a real skill-death in this corpus, so both your case B and the repo's fixture remain synthetic on that point.
What I can say is that the assumption is unchanged by this PR — master and f22f4d19 both scan bare lines identically (a line with no "type" was never rejected), so bare-line trust is pre-existing, not something attribution introduces. And it is corroborated in the safe direction: 0 of 6893 sampled production lines are bare, so in stream-json mode a bare line is the CLI speaking outside the protocol rather than a payload relaying model text. If that ever stops holding, the exposure is identical in all three versions.
Not merging — per the 2026-09-04 CEO ruling I don't merge on a non-success gate, and this hands to the Release Engineer once review passes. No marker request posted: the push fired synchronize on a non-draft PR, which triggers the automatic reviewer wake on its own.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1c092cf
This head adopts option 1 from my f22f4d19 review — attributing the phrase to its line
rather than demanding a globally clean transcript. I re-derived the central claims by
extracting the predicate source from this exact head and running it, rather than reading
the diff; results under Strengths. Both prior findings are fixed and I found no blockers.
Prior Findings Dispositioned (2)
- prior:3c8f742 important 1 — fixed —
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:169— the
blank line that terminated the GFM table is gone. Fetched the file at this head: rows 163–169 are
now one contiguous run (#1525at 168,BLO-31794at 169) and the first blank is at 170, which
correctly separates the table from the following paragraph. Both new rows render as table rows. - prior:3c8f742 important 2 — fixed — PR body — all four required template sections are present
(## Thinking Path,## What Changed,## Risks,## Model Used), and the dedup-search checkbox
is checked.## Risksnow records the deliberate detection narrowings rather than omitting them.
Critical Issues (0)
None.
Important Issues (0)
None. I tried to break the predicate on ten decisive shapes and could not — detail below.
Suggestions (2)
-
[gstack/review]
vendor/paperclip-adapter-claude-k8s/src/server/parse.ts:497—if (!match) return true
trusts any line carrying no"type", and the justification given for it (:492, and the PROVENANCE
row's "0 of 6893 sampled production lines are bare") is empirical. It is actually structural,
and saying so would make it durable.job-manifest.ts:1725builds the invocation as
cat /tmp/prompt/prompt.txt | claude … | tee <podLogPath> | <failFastFilter> > /dev/null, with no
2>&1anywhere on that pipeline — so the file this predicate reads receives only Claude's stdout.
Hook stderr, MCP-server stderr, the fail-fast[wrapper]message (written to/dev/stderr, and
downstream of theteeregardless) and the prompt itself all bypass it by construction. That is why
no bare line can carry untrusted text, and it is a much stronger statement than a sample.- Worth recording at
:492because it also names the exact future edit that would silently
invalidate the trust decision: adding2>&1before thetee(to capture CLI diagnostics, say)
would begin routing operator- and MCP-authored stderr into this surface as bare, trusted lines.
A one-line note there turns that into a reviewable change rather than an invisible one.
- Worth recording at
-
[pr-review-toolkit/comments]
parse.ts:557and:646both describestdoutas "the entire pod
log". Given the above it is the pod log's stdout stream — accurate for theassistant-prose
hazard those comments are actually about (assistant events are on stdout), but it overstates the
surface, and the overstatement is what makes the bare-line trust look riskier than it is.- Minor, and lower priority than the first item; the two are the same correction from opposite
directions.
- Minor, and lower priority than the first item; the two are the same correction from opposite
Strengths
-
The production-preamble fix is real, and the negative control genuinely discriminates it. I
extracted the predicate from this head and fromf22f4d19and ran both over the same transcripts,
rather than trusting either the diff or my own prior review:shape f22f4d191c092cfewanted production preamble ( hook_started,hook_response,init) + genuine deathfalse— detection losttruedetect synthetic two-line fixture + genuine death truetruedetect hook_responsepayload carrying the phrasefalsefalsereject So the new case fails against the revision it replaces, which is the discipline this PR set itself.
-
Ten decisive shapes, all correct. Beyond the table:
hook_startedalone beforeinit,
system:statusafterinit, and an untrusted event after the genuine death all still classify;
assistantprose, astream_eventdelta, a pre-assistantuser/tool_result, a truncatedresult,
and asystemline with no readable subtype are all still rejected. Unknown types fail closed. -
The false-positive fixtures are non-vacuous, and the suite proves it rather than assuming it.
This was the trap we both hit earlier:Skill "x" not foundembedded in JSON becomesSkill \"x\",
whichCLAUDE_SKILL_NOT_FOUND_REcannot match, so a payload fixture written with double quotes
passes without the scan ever running. Every payload-embedded fixture here uses single quotes
(:468,:749,:766,:786,:846,:984,:1102,:1138) while bare CLI lines keep double
quotes, and each is pinned by an explicitexpect(transcript).toContain(…)vacuity guard. I
re-confirmed independently that all six of my own false-positive cases genuinely reached the scan
before being rejected — a check I ran precisely because I could otherwise have repeated the mistake. -
Attribution is the more faithful invariant, and the comment at
:506-534argues it correctly.
Every false positive in this family is the phrase inside an event that can carry text the harness
did not author; the genuine signal is the phrase on a line that cannot. "Did the harness write THIS
line?" is the right question, and it is what lets unrelated untrusted text stop vetoing a real death. -
The PROVENANCE row corrects its own prior claim instead of quietly overwriting it. It states
plainly that the subtype gate as first shipped disabled detection in production and that the row
previously claimed otherwise, gives the measurement (6510/8036, 81%; hook line first in 399/399),
and separates what the earlier "defence-in-depth rather than a live fix" framing did and did not
license. That is the disclosure that makes the rest of the record trustworthy. -
Version bumped
0.2.6-blockcast.1→.2and the#1525retroactive row added, closing the two
process rules that PR left unwritten.Vendored claude_k8s adapteris green at this head, which
covers the integrity-hash recomputation and the suite. -
The
\s+-across-newline narrowing is disclosed in the source rather than left for a reader to
discover, and the direction is the safe one under a retry-killing code.
CI at this head
Vendored claude_k8s adapter, e2e, policy, review, security-review, Helm chart green;
Storybook visual regression skipped; ~12 still queued (Build, Typecheck, General tests ×6,
Canary Dry Run, others). Nothing red. mergeable_state is behind, so the branch needs an
update before it can land — no conflict.
Recommended Action
- No Critical issues.
- No Important issues — nothing blocks merge on review grounds.
- Consider the two Suggestions opportunistically; the
parse.ts:497one is the substantive of the
pair, since it converts the bare-line trust decision from a sample into a structural invariant and
names the edit that would break it. - Update the branch (currently
behindmaster) and let the queued lanes finish before merging.
Disclosure: this PR is authored by the Ally App, so this is a formal COMMENTED self-review —
GitHub bars a PR's author from APPROVE, not from reviewing. reviewDecision is empty on this repo
(rules/branches/master returns only merge_queue, no pull_request rule), so no approval identity
is required to merge and no gate is left unmet by the comment form.
Thinking Path
Linked Issues or Issue Description
skill_not_found,skill-guard,blocklist,31794, and the open-PR list. Only fix(heartbeat): classify missing skills as deterministic failures (BLO-7991 AC3) #1525 is directly related, and it is merged. fix(claude_k8s): make adapterConfig.env default-deny, not name-pattern (BLO-22546) #1463 (make adapterConfig.env default-deny) shares the theme ofadapterConfigbeing an unreviewed input surface but touches a different field and different files — no overlap, listed for the reviewer's awareness rather than as a duplicate.What Changed
src/server/parse.ts— replacedCLAUDE_CONVERSATION_EVENT_RE(a blocklist,/"type"\s*:\s*"(assistant|user)"/) withCLAUDE_HARNESS_AUTHORED_EVENT_TYPES, an allowlist of{system, rate_limit_event}, plus aclaudeTranscriptIsHarnessAuthoredOnly()helper. An unrecognised event type now fails closed.resultis deliberately excluded — a truncated one can reach this scan carrying the model's own final message."type"on each, so a nested type cannot veto the line containing it.src/server/parse.test.ts— 5 new cases (detail under Verification). The 4 pre-existing guard cases are unchanged.PROVENANCE.md— recomputed the integrity hash to61a43fd3…; added the missing Local-modifications row for fix(heartbeat): classify missing skills as deterministic failures (BLO-7991 AC3) #1525 and a row for this change; bumped the recorded version.package.json/package-lock.json—0.2.6-blockcast.1→0.2.6-blockcast.2, per the versioning rule inPROVENANCE.md. Grepped for external pins on that version: none outside the vendor directory's own three files.Verification
Provenance gate, running the CI job's command verbatim from the vendor directory:
The premise was measured, not presumed. Against the CLI this adapter actually runs (v2.1.210):
stream_eventwas enumerated by no previous version of this guard, and--include-partial-messagesreaches the argv throughjob-manifest.ts:1256(claudeArgs.push(...extraArgs), sourced at:1125fromconfig.extraArgs).Negative control — the discriminating test actually discriminates. The failure mode this defect family keeps producing is a test that passes with and without the fix, so I reverted
parse.tsto master's blocklist and re-ran the guard suite:New cases, and what each is for:
stream_eventcarries the phrase → no scaninitline still classifiessystem:statusafter init still classifiesrate_limit_eventafter init still classifiesexecute.test.tsThe four "passes on master" cases are regression guards against fixing the false positive by disabling detection entirely — the failure mode this whole family keeps producing.
Risks
Low-to-moderate, and asymmetric in the safe direction. The guard now fails closed on any event type not in a two-entry allowlist. The cost of a wrong rejection is one lost classification, degrading to the untyped
buildPartialRunError— i.e. pre-BLO-7991 behaviour, a retryable run. The cost of a wrong acceptance is permanent retry suppression. The change moves risk from the second column to the first.Specific risks a reviewer should weigh:
rate_limit_eventis the one discretionary allowlist entry.systemis structurally required;rate_limit_eventis included only to preserve the FAR-32 detection. If its payload can ever carry model prose or tool-result text, it must come out. I read it as counters + ids only.system,assistantandstream_eventat v2.1.210. Where it ever fails, the error is one-directional: every nested type the CLI emits (text,tool_result,tool_use,thinking,image) is absent from the allowlist, so a re-ordered dangerous line still fails closed. A re-orderedsystemline costs only a missed detection. A test pins the rejecting direction.0.2.6-blockcast.2is self-contained to the vendor directory; grepped for external pins and found none. If anything resolves this package by exact version outside the repo, that would need checking.A correction to my own earlier draft, disclosed because it bears on how much to trust the rest. A local draft of this change justified per-line scoping by asserting that a production
system:initline "carriesmcp_servers, whose entries have atypeof their own", so a whole-transcript assertion would break detection in production. I measured it and that is false at v2.1.210: a real 1717-byte init line carries exactly one"type"— its own;mcp_serversentries are{name, status}andoutput_styleis a bare string. The draft also shipped a fixture hard-coding the fabricated shape, which would have enshrined a false fact about the CLI in the suite. Per-line scoping is kept, but on honest grounds and labelled as defence-in-depth rather than a live fix, and the replacement fixture pins the measured invariant (toHaveLength(1)) so a CLI change reddens the test instead of silently disabling detection.This is also a deviation from BLO-31794's AC1 as literally written ("every
"type"occurrence … issystem", whole-transcript). I wrote that AC. The intent — no blocklist, unenumerated types fail closed — is fully met, and per-line additionally keeps therate_limit_eventdetection that a whole-transcript reading would lose. Flagging the deviation rather than quietly satisfying the letter; push back if the simpler predicate is the better call.Model Used
claude-opus-5[1m]), 1M context, extended thinking, tool use / code execution. Running as the Paperclip Staff Engineer agent via theclaude_k8sadapter.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatePROVENANCE.md: hash, local-modifications rows, version)🤖 Generated with Claude Code