From 00038d364311fd7c73769e31854bbe6af6b1a584 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Feb 2026 18:08:24 -0800 Subject: [PATCH] fix(ui): strip inbound metadata blocks from user messages --- ...pi-embedded-subscribe.handlers.messages.ts | 5 ++- src/gateway/chat-sanitize.test.ts | 31 +++++++++++++++++++ src/gateway/chat-sanitize.ts | 16 +++++++--- src/shared/chat-envelope.ts | 24 ++++++++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 9aa445a1ab6..3b002682dda 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -1,4 +1,5 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; +import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -7,7 +8,6 @@ import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, } from "./pi-embedded-helpers.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; import { appendRawStream } from "./pi-embedded-subscribe.raw-stream.js"; import { extractAssistantText, @@ -21,6 +21,9 @@ import { const stripTrailingDirective = (text: string): string => { const openIndex = text.lastIndexOf("[["); if (openIndex < 0) { + if (text.endsWith("[")) { + return text.slice(0, -1); + } return text; } const closeIndex = text.indexOf("]]", openIndex + 2); diff --git a/src/gateway/chat-sanitize.test.ts b/src/gateway/chat-sanitize.test.ts index 29c3f3e9a74..3d47f77802b 100644 --- a/src/gateway/chat-sanitize.test.ts +++ b/src/gateway/chat-sanitize.test.ts @@ -39,4 +39,35 @@ describe("stripEnvelopeFromMessage", () => { const result = stripEnvelopeFromMessage(input) as { content?: string }; expect(result.content).toBe("note\n[message_id: 123]"); }); + test("removes inbound un-bracketed conversation info blocks from user messages", () => { + const input = { + role: "user", + content: + 'Conversation info (untrusted metadata):\n```json\n{\n "message_id": "123"\n}\n```\n\nHello there', + }; + const result = stripEnvelopeFromMessage(input) as { content?: string }; + expect(result.content).toBe("Hello there"); + }); + + test("removes all inbound metadata blocks before user text", () => { + const input = { + role: "user", + content: + "Thread starter (untrusted, for context):\n```json\n{\"seed\": 1}\n```\n\nSender (untrusted metadata):\n```json\n{\"name\": \"alice\"}\n```\n\nActual user message", + }; + const result = stripEnvelopeFromMessage(input) as { content?: string }; + expect(result.content).toBe("Actual user message"); + }); + + test("does not strip metadata-like blocks that are not a prefix", () => { + const input = { + role: "user", + content: + "Actual text\nConversation info (untrusted metadata):\n```json\n{\"message_id\": \"123\"}\n```\n\nFollow-up", + }; + const result = stripEnvelopeFromMessage(input) as { content?: string }; + expect(result.content).toBe( + "Actual text\nConversation info (untrusted metadata):\n```json\n{\"message_id\": \"123\"}\n```\n\nFollow-up", + ); + }); }); diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts index 5f9e8f98289..176af946b1d 100644 --- a/src/gateway/chat-sanitize.ts +++ b/src/gateway/chat-sanitize.ts @@ -1,4 +1,8 @@ -import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js"; +import { + stripEnvelope, + stripInboundMetadataBlocks, + stripMessageIdHints, +} from "../shared/chat-envelope.js"; export { stripEnvelope }; @@ -12,7 +16,7 @@ function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; cha if (entry.type !== "text" || typeof entry.text !== "string") { return item; } - const stripped = stripMessageIdHints(stripEnvelope(entry.text)); + const stripped = stripMessageIdHints(stripEnvelope(stripInboundMetadataBlocks(entry.text))); if (stripped === entry.text) { return item; } @@ -39,7 +43,9 @@ export function stripEnvelopeFromMessage(message: unknown): unknown { const next: Record = { ...entry }; if (typeof entry.content === "string") { - const stripped = stripMessageIdHints(stripEnvelope(entry.content)); + const stripped = stripMessageIdHints( + stripEnvelope(stripInboundMetadataBlocks(entry.content)), + ); if (stripped !== entry.content) { next.content = stripped; changed = true; @@ -51,7 +57,9 @@ export function stripEnvelopeFromMessage(message: unknown): unknown { changed = true; } } else if (typeof entry.text === "string") { - const stripped = stripMessageIdHints(stripEnvelope(entry.text)); + const stripped = stripMessageIdHints( + stripEnvelope(stripInboundMetadataBlocks(entry.text)), + ); if (stripped !== entry.text) { next.text = stripped; changed = true; diff --git a/src/shared/chat-envelope.ts b/src/shared/chat-envelope.ts index 8ab53ed9e23..b7b655834c6 100644 --- a/src/shared/chat-envelope.ts +++ b/src/shared/chat-envelope.ts @@ -16,6 +16,18 @@ const ENVELOPE_CHANNELS = [ ]; const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i; +const INBOUND_METADATA_HEADERS = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", +]; +const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; +const INBOUND_METADATA_PREFIX_RE = new RegExp( + `^\\s*(?:${INBOUND_METADATA_HEADERS.map((header) => header.replace(REGEX_ESCAPE_RE, "\\$&")).join("|")})\\r?\\n```json\\r?\\n[\\s\\S]*?\\r?\\n```(?:\\r?\\n)*`, +); function looksLikeEnvelopeHeader(header: string): boolean { if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) { @@ -47,3 +59,15 @@ export function stripMessageIdHints(text: string): string { const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line)); return filtered.length === lines.length ? text : filtered.join("\n"); } + +export function stripInboundMetadataBlocks(text: string): string { + let remaining = text; + for (;;) { + const match = INBOUND_METADATA_PREFIX_RE.exec(remaining); + if (!match) { + break; + } + remaining = remaining.slice(match[0].length).replace(/^\r?\n+/, ""); + } + return remaining.trim(); +}