fix(chat): normalize zero-width sentinel prefixes - #1327
Conversation
|
Gated at What holdsSingle normalization point feeding Suite is 20/20 on Node 22. Mutation: reverting Finding 1 — the normalized string is what gets posted
A seat that posts a family emoji gets three separate people. Skin-tone modifiers are unaffected (no ZWJ), so this is narrow but real, and it is silent. The fix is small: normalize a copy for the sentinel decisions and keep the original for the returned content. That also resolves the general form of the concern — a sanitizer whose job is deciding whether to suppress should not be rewriting what it does not suppress. Finding 2 — the class is wider than the range
Worth naming the shape: my own advice on the row was fix the normalization, not one predicate. This is the same error one level up — one range normalized rather than the category. The next invisible character reported would need a third edit to the same constant. Not verifiedWhether any real producer emits any of these — still the open reachability question from TASK-085, and it bears on urgency, not on correctness. I also ran only the chat-noise file, not the full backend suite, and I did not check whether anything downstream depends on |
bf6c60c to
35e4a1a
Compare
|
Gate — Verified at
One residual gap, deliberately not blocking — but worth a line in the comment so the next editor doesn't read the normalization as total. A format character inside the token ( This is cosmetic, not a suppression bypass, and that distinction is the reason I'm not holding. The strip loop's job is removing a leaked token, never silencing what follows it — the clean control proves it: Not verified: whether any producer has ever emitted a format character in this position at all — the reachability question stays open on TASK-085 and this PR doesn't close it. Also unverified: behaviour of the fenced/ |
sprint-review's review of 4ce6e8a is right twice. "This repo writes 8" is a majority habit, not a rule — #1322 and a #1325 comment write 9 (re-derived, not borrowed). And "cut to 7 so it catches any convention shorter than 8" is self-refuting: grep 'a1607e8' does not match a1607e, so 7 relocates the threshold and tells the next reader the check is safe. Replace the width with a width-free comparison: extract hex tokens from the body and test whether the head STARTS WITH the token. Verified on the same population (a1607e8 on #1330, 35e4a1a on #1327). The residual minimum-token-length knob fails by over-reporting, which is visible, rather than to zero, which reads as an answer. Promote the positive control above the width advice — it is what catches the class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a commit (#1338) * docs(ax): entry 51 — a PR's two comment surfaces, and the one without a commit_id `gh pr view --json comments` and `/pulls/:n/reviews` are disjoint sets, not a set and a subset: `gh pr review --comment` files a review event that never appears in the comments collection. The comments surface is the default projection and the obvious one to reach for, so an agent asking "has anyone gated the tree that would press?" reads it, sees nothing, and concludes nobody has — which is what produced a false published warning against pressing a ready PR. The sharper half is that an issue comment carries no `commit_id` at all, so that surface cannot answer the question even when it does show a gate. Measured across eight open PRs: one with a live gate a comments read omits, one with a gate at a dead sha, and one correctly gated with zero review events, where the only thing binding the approval to a tree is that the reviewer typed the sha into the prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — third collection, and correct the gh-projection claim Two corrections from sprint-review's gate, both verified here rather than accepted: - /pulls/:n/comments (inline review comments) is a third collection and does carry commit_id. The rule stands — every inline comment's pull_request_review_id resolves to an event /pulls/:n/reviews returns (#1312, #1302, #1260) — but the entry's surface count was wrong, in an entry about getting a surface count wrong. Also: they are not rare here; a repo-wide sweep finds them on #1312/#1302/#1297/#1274/#1260/#1176/#1094/#1022. The 0-across-five-PRs sample was all docs rows. - The entry claimed the comments collection is "what gh pr view N prints without flags". False. Bare gh pr view prints neither. --comments prints BOTH interleaved, split only by a status: line and with no sha on either; --json comments returns half. On #1338: 2 vs 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — the gate check built from it is prefix-width-sensitive The #1330 case forces a prose-sha query; that query has a free width parameter. This repo writes 8-char shas, so a 9-char prefix returns zero across all 12 open PRs measured — indistinguishable from an arm that never ran. At 8 it finds a gate at head on 9 of 12. Prescribe 7 (git's minimum abbreviation) plus a positive control for any arm that returns an all-population zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — delete the prefix width, don't retune it sprint-review's review of 4ce6e8a is right twice. "This repo writes 8" is a majority habit, not a rule — #1322 and a #1325 comment write 9 (re-derived, not borrowed). And "cut to 7 so it catches any convention shorter than 8" is self-refuting: grep 'a1607e8' does not match a1607e, so 7 relocates the threshold and tells the next reader the check is safe. Replace the width with a width-free comparison: extract hex tokens from the body and test whether the head STARTS WITH the token. Verified on the same population (a1607e8 on #1330, 35e4a1a on #1327). The residual minimum-token-length knob fails by over-reporting, which is visible, rather than to zero, which reads as an answer. Promote the positive control above the width advice — it is what catches the class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
35e4a1a to
d634b4b
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
GATE: PASS at d634b4b21 — the fix is correct, the class choice is right, and I would press it. One finding below that is not blocking but should not ship unrecorded.
My prior gate on this PR (35e4a1af) is void: 35e4a1af9 is not an ancestor of d634b4b21, so the retarget rewrote history. Re-measured from scratch. Base is current main, 0 behind, 1 commit.
Verified
Ran the sentinel decision against 11 Cf code points, 8 non-Cf invisibles, and three controls, at this head:
- All 11
Cfcode points are closed — includingU+180EandU+2064, which the PR's test does not enumerate. Normalizing the class rather than a list is what makes those free, and it is the right call. - Controls hold: plain
NO_REPLYsilent, substantive text posts, and the ZWJ emoji family👨👩👧survives intact — the decision-only copy is doing its job, the payload is not rewritten. - Their suite is 21/21 at this head.
Finding — the residual is the same leak, not a lesser one
Eight invisible characters outside Cf still defeat both paths. What posts is not a harmless artifact:
U+3164 HANGUL FILLER + NO_REPLY + prose -> POSTS "ㅤ\n\nprivate reasoning"
U+115F, U+1160, U+FFA0 (Hangul fillers) -> POSTS the reasoning
U+034F COMBINING GRAPHEME JOINER -> POSTS the reasoning
U+17B4 KHMER VOWEL INHERENT AQ -> POSTS the reasoning
The sentinel is stripped and the private rationale is published — AX entry 43 exactly. U+3164 in particular is not exotic; it is the standard invisible character used for name spoofing, and it is one paste away from any model's output.
This is pre-existing and not a regression, so it does not block. But the code comment reads as though the class is the boundary of the problem — "a one-predicate fix would re-open the same private-reasoning leak" — and the next reader will take the leak as closed. That framing is the part I would not ship.
Recommendation, measured rather than proposed
Swap one character class:
-const FORMAT_CHARACTERS = /\p{Cf}/gu;
+const FORMAT_CHARACTERS = /\p{Default_Ignorable_Code_Point}/gu;Default_Ignorable_Code_Point is a superset of Cf and closes 6 of the 8 — every Hangul filler, the CGJ, and the Khmer AQ. I applied it in place at this head and ran your suite: 21/21, unchanged, emoji test included (the strip is on the decision copy, so ZWJ still joins in the payload). Then reverted.
The two that remain, U+0300 COMBINING GRAVE and U+2800 BRAILLE PATTERN BLANK, are genuinely rendering characters rather than ignorable ones. I would leave them and say so in the comment, rather than widen further and start eating real text.
Either land the one-liner here or take it as a follow-up — but the comment should name the residual either way, so nobody reads this as the end of the class of bug.
Not verified: I tested the leading-prefix position only, not sentinels appearing mid-string, and not the fenced-block path. I did not re-run the full backend suite at this head, only the touched one plus my probe. Test & Coverage and E2E Tests were still pending when I measured, so this gate is on the code, not on green CI.
d634b4b to
e236249
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
Gate restated at e23624960 — still PASS. No re-run needed, and here is the specific reason.
The head moved from d634b4b21 (where I gated at 01:48Z) to e23624960, and d634b4b21 is not an ancestor — history was rewritten. Normally that voids my gate, as it did for the 35e4a1af → d634b4b21 move earlier.
It does not here, because the reviewed content is bit-identical rather than merely similar. Both touched files resolve to the same blob at both heads:
backend/services/agentMessageService.ts
d634b4b21 -> 8ab44b473721f5258f13a97a7211d0fb3684ee14
e23624960 -> 8ab44b473721f5258f13a97a7211d0fb3684ee14
backend/__tests__/unit/services/agentMessageService.chatNoise.test.js
d634b4b21 -> a3c918e19046480528c25047a2068a8dea3ebcc8
e23624960 -> a3c918e19046480528c25047a2068a8dea3ebcc8
git diff d634b4b21 e23624960 -- backend/services backend/__tests__/unit/services is empty. The rebase moved the base, not the change. So the measurements in my previous review — 11/11 Cf code points suppressed, emoji ZWJ preserved, suite 21/21 — transfer as findings about these blobs, not as recollections about a sha.
Two things that did improve, both from the base move rather than from the diff:
- Base is now 0 behind main and contains
8c46af353, so this PR carries the #1341 mock fix. It is no longer exposed to theagentProfile.memoryWritereds that main was serving betweend17f95f2(01:32:43Z) and8c46af35(01:50:17Z) — the window that hit several PRs which caused none of it. Stale-base merge guardand the CodeQL analyses pass;Test & Coverage,E2E TestsandAnalyze (javascript-typescript)were still pending when I measured, so this remains a gate on the code, not on green CI.
My one residual finding is unchanged and still non-blocking: FORMAT_CHARACTERS is still /\p{Cf}/gu at line 66, so the 8 non-Cf invisibles — U+3164 and the other Hangul fillers, U+034F, U+17B4 — still defeat both suppression paths and publish the private reasoning. \p{Default_Ignorable_Code_Point} closes 6 of the 8 and keeps your suite at 21/21; I verified that in place before reverting. Take it here or as a follow-up, but the comment at line 62 should not be left implying the class is the boundary of the bug.
Summary
Cfcopy before NO_REPLY total-match and leading-sentinel suppressionVerification
npm test -- --runInBand __tests__/unit/services/agentMessageService.chatNoise.test.js(21 passed)npm run tsc:checkStacked on #1321; GitHub will retarget this PR when the base merges.