Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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('');

Expand Down
10 changes: 8 additions & 2 deletions backend/services/agentMessageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)}`,
Expand Down
Loading