From 31a63a9eec3627181c6a16333b633957fed88956 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:40:29 -0700 Subject: [PATCH 1/3] fix(sentinel): normalize default ignorables --- .../agentMessageService.chatNoise.test.js | 39 +++++++++++++++---- backend/services/agentMessageService.ts | 10 +++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index a3c918e190..8d257bfd1d 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -139,21 +139,44 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () => expect(AgentMessageService.sanitizeAgentContent('NO_REPLY')).toBe(''); }); - it('normalizes Unicode format-character prefixes before both sentinel checks', () => { - // U+200B defeats both the total-match and leading-run checks without the - // shared normalization below. Keep the class coupled too: a one-predicate - // fix would re-open the same private-reasoning leak. - [ + it('normalizes Unicode ignorable prefixes before both sentinel checks', () => { + // U+200B defeated both checks before TASK-085. Keep the decision-copy + // normalization shared: fixing only one predicate re-opens the + // private-reasoning leak in the other. + const formatCharacters = [ '\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u061C', '\u200E', '\u2062', '\u00AD', - ].forEach((formatCharacter) => { + ]; + const nonFormatDefaultIgnorables = [ + // TASK-089: these do not have Unicode General_Category=Cf, so the + // previous format-only class let them defeat both sentinel checks. + '\u3164', // HANGUL FILLER + '\u115F', // HANGUL CHOSEONG FILLER + '\u1160', // HANGUL JUNGSEONG FILLER + '\uFFA0', // HALFWIDTH HANGUL FILLER + '\u034F', // COMBINING GRAPHEME JOINER + '\u17B4', // KHMER VOWEL INHERENT AQ + '\u17B5', // KHMER VOWEL INHERENT AA + ]; + + [...formatCharacters, ...nonFormatDefaultIgnorables].forEach((character) => { expect(AgentMessageService.sanitizeAgentContent( - `${formatCharacter}NO_REPLY\n\nprivate reasoning`, + `${character}NO_REPLY\n\nprivate reasoning`, )).toBe(''); - expect(AgentMessageService.sanitizeAgentContent(`${formatCharacter}NO_REPLY`)).toBe(''); + expect(AgentMessageService.sanitizeAgentContent(`${character}NO_REPLY`)).toBe(''); }); }); + it('does not treat U+2800 BRAILLE PATTERN BLANK as an ignorable sentinel prefix', () => { + // U+2800 looks blank but is a real glyph rather than a Unicode default + // ignorable. Keeping it means this remains a substantive reply where the + // embedded bare sentinel follows #785's strip-and-post behavior. + const brailleBlank = '\u2800'; + expect(AgentMessageService.sanitizeAgentContent( + `${brailleBlank}NO_REPLY\n\nvisible content`, + )).toBe(`${brailleBlank}\n\nvisible content`); + }); + it('keeps zero-width joiners in substantive agent output', () => { // U+200D is also in the sentinel-normalization class, but it is // load-bearing in emoji. Sentinel decisions may normalize a copy only. diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 8ab44b4737..ea6897de64 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -59,11 +59,13 @@ const ATTACH_CLAIM_SCAN_LIMIT = 2000; const BARE_RUNTIME_ARTIFACTS = new Set(['RGCTX']); const NO_REPLY_SENTINEL = 'NO_REPLY'; -// `trim()` does not remove Unicode format characters. Normalize a +// `trim()` does not remove every invisible Unicode code point. Normalize a // decision-only copy before total-match and leading-sentinel checks so one // invisible prefix cannot bypass both suppression paths. Do not rewrite the -// stored payload: U+200D, for example, joins emoji glyphs. -const FORMAT_CHARACTERS = /\p{Cf}/gu; +// stored payload: U+200D, for example, joins emoji glyphs. U+2800 BRAILLE +// PATTERN BLANK deliberately stays out: it is a real glyph, not an ignorable +// formatting character. +const SENTINEL_DECISION_IGNORABLES = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu; /** * Word-boundary test for the sentinel scan. Deliberately not `\w` via regex: @@ -1840,7 +1842,7 @@ class AgentMessageService { const outerFence = raw.match(/^```[^\n]*\n([\s\S]*?)```\s*$/s); const stripped = outerFence ? outerFence[1] : raw; const trimmed = stripped.trim(); - const sentinelContent = trimmed.replace(FORMAT_CHARACTERS, ''); + const sentinelContent = trimmed.replace(SENTINEL_DECISION_IGNORABLES, ''); // Sentinels are total-match contracts: suppress only when the complete // reply consists of NO_REPLY tokens. Gateways have historically joined From a1fcef0c9618011b926ca26edae9e118556834d9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:11:56 -0700 Subject: [PATCH 2/3] test(sentinel): guard Cf-only decision prefixes --- .../services/agentMessageService.chatNoise.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index 8d257bfd1d..a28509aa43 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -177,6 +177,16 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () => )).toBe(`${brailleBlank}\n\nvisible content`); }); + it('keeps format characters outside Default_Ignorable in the decision copy', () => { + // U+0600 is Cf but not Default_Ignorable. The union is intentional: using + // Default_Ignorable alone re-opens both suppression paths for this prefix. + const arabicNumberSign = '\u0600'; + expect(AgentMessageService.sanitizeAgentContent( + `${arabicNumberSign}NO_REPLY\n\nprivate reasoning`, + )).toBe(''); + expect(AgentMessageService.sanitizeAgentContent(`${arabicNumberSign}NO_REPLY`)).toBe(''); + }); + it('keeps zero-width joiners in substantive agent output', () => { // U+200D is also in the sentinel-normalization class, but it is // load-bearing in emoji. Sentinel decisions may normalize a copy only. From a14df6ea9d524c9b6f32c79a129ce3bbdd8216b1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:44:54 -0700 Subject: [PATCH 3/3] test(sentinel): name Cf-only control precisely --- .../unit/services/agentMessageService.chatNoise.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index a28509aa43..4d7f288d74 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -177,7 +177,7 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () => )).toBe(`${brailleBlank}\n\nvisible content`); }); - it('keeps format characters outside Default_Ignorable in the decision copy', () => { + it('normalizes Cf-only prefixes before both sentinel checks', () => { // U+0600 is Cf but not Default_Ignorable. The union is intentional: using // Default_Ignorable alone re-opens both suppression paths for this prefix. const arabicNumberSign = '\u0600';