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:
45
src/router-mass-mentions.test.ts
Normal file
45
src/router-mass-mentions.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user