Neutralize accidental @everyone/@here in outbound agent text

Agent-authored replies pass through sanitizeForOutbound before Discord
send, but it did not touch mass-mention tokens — so a reply that merely
*mentioned* "@everyone" while explaining a feature pinged the whole
channel. Add neutralizeMassMentions (zero-width space after @) to the
central sanitizer so normal reply/progress/edit text can never mass-ping;
intentional broadcasts (e.g. the disk-usage alert) use a dedicated path.
Leaves <@id> mentions and emails intact. Covered by unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-24 10:58:53 +09:00
parent 8349a020ce
commit 23c9e5101f
2 changed files with 63 additions and 3 deletions

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { neutralizeMassMentions, sanitizeForOutbound } from './router.js';
const ZWSP = '\u200b';
describe('neutralizeMassMentions', () => {
it('neutralizes @everyone / @here so they no longer ping', () => {
expect(neutralizeMassMentions('@everyone hi')).toBe(`@${ZWSP}everyone hi`);
expect(neutralizeMassMentions('ping @here now')).toBe(
`ping @${ZWSP}here now`,
);
// The rendered text still reads the same to a human...
expect(neutralizeMassMentions('@everyone').replace(ZWSP, '')).toBe(
'@everyone',
);
// ...but the literal mention token is broken.
expect(neutralizeMassMentions('@everyone')).not.toBe('@everyone');
});
it('leaves user/role mentions, emails, and plain text untouched', () => {
expect(neutralizeMassMentions('<@216851709744513024> hello')).toBe(
'<@216851709744513024> hello',
);
expect(neutralizeMassMentions('mail me@example.com')).toBe(
'mail me@example.com',
);
expect(neutralizeMassMentions('everyone here knows')).toBe(
'everyone here knows',
);
});
it('is idempotent (already-neutralized text is unchanged)', () => {
const once = neutralizeMassMentions('@everyone and @here');
expect(neutralizeMassMentions(once)).toBe(once);
});
});
describe('sanitizeForOutbound applies mass-mention neutralization', () => {
it('breaks an accidental @everyone in normal agent output', () => {
const out = sanitizeForOutbound('알림: @everyone 디스크 부족');
expect(out).toContain(`@${ZWSP}everyone`);
expect(out).not.toContain('@everyone');
});
});

View File

@@ -146,10 +146,24 @@ export function neutralizeStrayMarkdown(text: string): string {
/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */ /** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */
export const neutralizeStrayBackticks = neutralizeStrayMarkdown; export const neutralizeStrayBackticks = neutralizeStrayMarkdown;
/**
* Prevent accidental mass pings. Agent-authored text that literally contains
* `@everyone` / `@here` must never notify the whole channel — that is only ever
* intended via a dedicated broadcast path (e.g. the disk-usage alert), not via a
* normal reply/progress/edit message. A zero-width space (U+200B) after the `@`
* keeps the text readable ("@everyone" still reads the same) while stopping
* Discord from parsing it as a mention. Idempotent, and leaves `<@id>` user/role
* mentions and ordinary emails (`me@example.com`) untouched.
*/
export function neutralizeMassMentions(text: string): string {
return text.replace(/@(everyone|here)\b/g, '@\u200b$1');
}
/** /**
* Sanitize raw agent output for internal use (storage, IPC, intermediate * Sanitize raw agent output for internal use (storage, IPC, intermediate
* channel buffers). Strips internal tags + tool-call leaks and redacts * channel buffers). Strips internal tags + tool-call leaks, redacts secrets,
* secrets, but does NOT touch markdown delimiters. * and neutralizes accidental @everyone/@here pings, but does NOT touch markdown
* delimiters.
* *
* Use this when the text will pass through another `formatOutbound` call * Use this when the text will pass through another `formatOutbound` call
* downstream (e.g., the Discord channel boundary). Applying the markdown * downstream (e.g., the Discord channel boundary). Applying the markdown
@@ -161,7 +175,8 @@ export function sanitizeForOutbound(rawText: string): string {
if (!text) return ''; if (!text) return '';
text = stripToolCallLeaks(text); text = stripToolCallLeaks(text);
if (!text) return ''; if (!text) return '';
return redactSecrets(text); text = redactSecrets(text);
return neutralizeMassMentions(text);
} }
/** /**