From e23624960e85a665745260696aba007797d5f7eb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:57:12 -0700 Subject: [PATCH] fix(chat): normalize zero-width sentinel prefixes --- .../agentMessageService.chatNoise.test.js | 23 +++++++++++++++++++ backend/services/agentMessageService.ts | 10 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index aac4c4363f..a3c918e190 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -139,6 +139,29 @@ 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. + [ + '\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', + '\u061C', '\u200E', '\u2062', '\u00AD', + ].forEach((formatCharacter) => { + expect(AgentMessageService.sanitizeAgentContent( + `${formatCharacter}NO_REPLY\n\nprivate reasoning`, + )).toBe(''); + expect(AgentMessageService.sanitizeAgentContent(`${formatCharacter}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. + const family = '👨‍👩‍👧'; + expect(AgentMessageService.sanitizeAgentContent(`${family} shipped the fix.`)) + .toBe(`${family} shipped the fix.`); + }); + it('drops known bare runtime artifacts without swallowing terse replies', () => { expect(AgentMessageService.sanitizeAgentContent('RGCTX')).toBe(''); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 4bd7d036e8..8ab44b4737 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -59,6 +59,11 @@ 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 +// 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; /** * Word-boundary test for the sentinel scan. Deliberately not `\w` via regex: @@ -1835,12 +1840,13 @@ 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, ''); // Sentinels are total-match contracts: suppress only when the complete // reply consists of NO_REPLY tokens. Gateways have historically joined // silent blocks into "NO_REPLYNO_REPLY" (or separated duplicates with // whitespace), so retain that compatibility. - if (/^(?:NO_REPLY\s*)+$/.test(trimmed)) return ''; + if (/^(?:NO_REPLY\s*)+$/.test(sentinelContent)) return ''; // A substantive fully fenced reply is explicitly code-formatted even // though this sanitizer removes the outer transport fence before storage. @@ -1863,7 +1869,7 @@ class AgentMessageService { // reads as silent there too. That is the intended reading of those rows — // and it does not apply to the AX-43 leaks themselves, which were stored // with the token already stripped. - if (opensWithBareSentinel(trimmed)) { + if (opensWithBareSentinel(sentinelContent)) { if (observe) { console.warn( `[agent-msg] suppressed a reply opening with a bare sentinel (intended silence, not an edit) from agent=${observe.agentName} instance=${observe.instanceId} pod=${observe.podId}: ${trimmed.slice(0, 120)}`,