From 23c9e5101f215520292d35d236f77ec07f79d217 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 24 Aug 2026 10:58:53 +0900 Subject: [PATCH] Neutralize accidental @everyone/@here in outbound agent text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/router-mass-mentions.test.ts | 45 ++++++++++++++++++++++++++++++++ src/router.ts | 21 ++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/router-mass-mentions.test.ts diff --git a/src/router-mass-mentions.test.ts b/src/router-mass-mentions.test.ts new file mode 100644 index 0000000..54038e7 --- /dev/null +++ b/src/router-mass-mentions.test.ts @@ -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'); + }); +}); diff --git a/src/router.ts b/src/router.ts index fcf5d65..3130269 100644 --- a/src/router.ts +++ b/src/router.ts @@ -146,10 +146,24 @@ export function neutralizeStrayMarkdown(text: string): string { /** @deprecated Kept for back-compat; use 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 - * channel buffers). Strips internal tags + tool-call leaks and redacts - * secrets, but does NOT touch markdown delimiters. + * channel buffers). Strips internal tags + tool-call leaks, redacts secrets, + * and neutralizes accidental @everyone/@here pings, but does NOT touch markdown + * delimiters. * * Use this when the text will pass through another `formatOutbound` call * downstream (e.g., the Discord channel boundary). Applying the markdown @@ -161,7 +175,8 @@ export function sanitizeForOutbound(rawText: string): string { if (!text) return ''; text = stripToolCallLeaks(text); if (!text) return ''; - return redactSecrets(text); + text = redactSecrets(text); + return neutralizeMassMentions(text); } /**