diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index 0c4ce3a88c..0c061749e2 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -108,12 +108,22 @@ export function estimateNeurons(promptChars: number, maxOutputTokens: number, ca return Math.max(1, Math.ceil((inputTokens + maxOutputTokens) * 0.035) * Math.max(1, calls)); } -/** Returns the text unchanged if it is public-safe, otherwise null (drop — never publish). */ +function neutralizePublicMarkdown(text: string): string { + return text + .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/@/g, "@\u200B") + .replace(/:\/\//g, ":\u200B//") + .replace(/([\\`*_{}\[\]()#+!|])/g, "\\$1"); +} + +/** Returns neutralized text if it is public-safe, otherwise null (drop — never publish). */ export function toPublicSafe(text: string | null | undefined): string | null { const trimmed = (text ?? "").trim(); if (!trimmed) return null; try { - return sanitizePublicComment(trimmed); + return neutralizePublicMarkdown(sanitizePublicComment(trimmed)); } catch { return null; } diff --git a/src/signals/engine.ts b/src/signals/engine.ts index bb946b2a6f..f939288647 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -4088,9 +4088,9 @@ export function buildPublicPrIntelligenceComment(args: { "", "_Generated from public PR metadata and the diff. Advisory only; deterministic signals remain authoritative._", "", - // Notes are already public-safe (built via toPublicSafe upstream). Escape angle brackets so a - // stray tag (e.g. or an HTML comment marker) cannot break the panel structure, while - // preserving the markdown bullet/line layout that sanitizePanelText would otherwise flatten. + // Notes are already public-safe and markdown-neutralized (built via toPublicSafe upstream). Escape + // angle brackets as a final guard so a stray tag (e.g. or an HTML comment marker) cannot + // break the panel structure while preserving the section/bullet layout we add ourselves. args.aiReview.notes.replace(/[<>]/g, (char) => (char === "<" ? "<" : ">")).slice(0, 4000), "", "", diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index 6e34088c99..40b63c634f 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -242,9 +242,12 @@ describe("Workers AI fallback + degraded output", () => { }); describe("pure helpers", () => { - it("toPublicSafe drops forbidden public text and keeps safe text", () => { + it("toPublicSafe drops forbidden public text and neutralizes markdown, mentions, links, and control characters", () => { expect(toPublicSafe("This change is solid.")).toBe("This change is solid."); expect(toPublicSafe("Boost your reward payout")).toBeNull(); + expect(toPublicSafe("Ping @octo-team about [urgent update](https://evil.example/p) ![pixel](https://evil.example/i.png)\n- injected")).toBe( + "Ping @\u200Bocto-team about \\[urgent update\\]\\(https:\u200B//evil.example/p\\) \\!\\[pixel\\]\\(https:\u200B//evil.example/i.png\\) - injected", + ); expect(toPublicSafe("")).toBeNull(); expect(toPublicSafe(null)).toBeNull(); expect(toPublicSafe(undefined)).toBeNull();