From a4e7e952e1433437764c3fb011798f05136ac76f Mon Sep 17 00:00:00 2001 From: Mars <40958792+Mellowambience@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:35:13 -0500 Subject: [PATCH] fix(ui): strip injected inbound metadata from user messages in history (#22142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): strip injected inbound metadata from user messages in history Fixes #21106 Fixes #21109 Fixes #22116 OpenClaw prepends structured metadata blocks ("Conversation info", "Sender:", reply-context) to user messages before sending them to the LLM. These blocks are intentionally AI-context-only and must never reach the chat history that users see. Root cause: `buildInboundUserContextPrefix` in `inbound-meta.ts` prepends the blocks directly to the stored user message content string, so they are persisted verbatim and later shown in webchat, TUI, and every other rendering surface. Fix: • `src/auto-reply/reply/strip-inbound-meta.ts` — new utility with a 6-sentinel fast-path strip (zero-alloc on miss) + 9-test suite. • `src/tui/tui-session-actions.ts` — wraps `chatLog.addUser(...)` with `stripInboundMetadata()` so the TUI never stores the prefix. • `ui/src/ui/chat/message-normalizer.ts` — strips user-role text content items during normalisation so webchat renders clean messages. * fix(ui): strip inbound metadata for user messages in display path * test: fix discord component send test spread typing * fix: strip inbound metadata from mac chat history decode * fix: align Swift metadata stripping parser with TS implementation * fix: normalize line endings in inbound metadata stripper * chore: document Swift/TS metadata-sentinel ownership * chore: update changelog for inbound metadata strip fix * changelog: credit Mellowambience for 22142 --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../ChatMarkdownPreprocessor.swift | 54 +++++++++-- .../OpenClawChatUI/ChatViewModel.swift | 33 +++++++ .../ChatMarkdownPreprocessorTests.swift | 52 +++++++++++ .../OpenClawKitTests/ChatViewModelTests.swift | 29 ++++++ .../reply/strip-inbound-meta.test.ts | 85 ++++++++++++++++++ src/auto-reply/reply/strip-inbound-meta.ts | 89 +++++++++++++++++++ src/discord/send.components.test.ts | 53 +++++++++++ src/tui/tui-session-actions.ts | 3 +- ui/src/ui/chat/message-extract.ts | 23 ++++- ui/src/ui/chat/message-normalizer.ts | 11 +++ 11 files changed, 420 insertions(+), 13 deletions(-) create mode 100644 src/auto-reply/reply/strip-inbound-meta.test.ts create mode 100644 src/auto-reply/reply/strip-inbound-meta.ts create mode 100644 src/discord/send.components.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a67163d253c..06122b92b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204. - Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky. +- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. - Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow. - Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu. - Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift index 1dc1edda778..a96e288d7f4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift @@ -1,6 +1,9 @@ import Foundation enum ChatMarkdownPreprocessor { + // Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts` + // (`INBOUND_META_SENTINELS`), and extend parser expectations in + // `ChatMarkdownPreprocessorTests` when sentinels change. private static let inboundContextHeaders = [ "Conversation info (untrusted metadata):", "Sender (untrusted metadata):", @@ -60,16 +63,49 @@ enum ChatMarkdownPreprocessor { } private static func stripInboundContextBlocks(_ raw: String) -> String { - var output = raw - for header in self.inboundContextHeaders { - let escaped = NSRegularExpression.escapedPattern(for: header) - let pattern = "(?ms)^" + escaped + "\\n```json\\n.*?\\n```\\n?" - output = output.replacingOccurrences( - of: pattern, - with: "", - options: .regularExpression) + guard self.inboundContextHeaders.contains(where: raw.contains) else { + return raw } - return output + + let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") + var outputLines: [String] = [] + var inMetaBlock = false + var inFencedJson = false + + for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) { + let currentLine = String(line) + + if !inMetaBlock && self.inboundContextHeaders.contains(where: currentLine.hasPrefix) { + inMetaBlock = true + inFencedJson = false + continue + } + + if inMetaBlock { + if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" { + inFencedJson = true + continue + } + + if inFencedJson { + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { + inMetaBlock = false + inFencedJson = false + } + continue + } + + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + continue + } + + inMetaBlock = false + } + + outputLines.append(currentLine) + } + + return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression) } private static func stripPrefixedTimestamps(_ raw: String) -> String { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index f0ebc8e5bc2..62cb97a0e2f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -189,10 +189,43 @@ public final class OpenClawChatViewModel { private static func decodeMessages(_ raw: [AnyCodable]) -> [OpenClawChatMessage] { let decoded = raw.compactMap { item in (try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self)) + .map { Self.stripInboundMetadata(from: $0) } } return Self.dedupeMessages(decoded) } + private static func stripInboundMetadata(from message: OpenClawChatMessage) -> OpenClawChatMessage { + guard message.role.lowercased() == "user" else { + return message + } + + let sanitizedContent = message.content.map { content -> OpenClawChatMessageContent in + guard let text = content.text else { return content } + let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned + return OpenClawChatMessageContent( + type: content.type, + text: cleaned, + thinking: content.thinking, + thinkingSignature: content.thinkingSignature, + mimeType: content.mimeType, + fileName: content.fileName, + content: content.content, + id: content.id, + name: content.name, + arguments: content.arguments) + } + + return OpenClawChatMessage( + id: message.id, + role: message.role, + content: sanitizedContent, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !role.isEmpty else { return nil } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift index 00e0e4ea3ec..781a325f3cf 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift @@ -43,6 +43,58 @@ struct ChatMarkdownPreprocessorTests { #expect(result.cleaned == "Razor?") } + @Test func stripsSingleConversationInfoBlock() { + let text = """ + Conversation info (untrusted metadata): + ```json + {"x": 1} + ``` + + User message + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: text) + + #expect(result.cleaned == "User message") + } + + @Test func stripsAllKnownInboundMetadataSentinels() { + let sentinels = [ + "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):", + ] + + for sentinel in sentinels { + let markdown = """ + \(sentinel) + ```json + {"x": 1} + ``` + + User content + """ + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + #expect(result.cleaned == "User content") + } + } + + @Test func preservesNonMetadataJsonFence() { + let markdown = """ + Here is some json: + ```json + {"x": 1} + ``` + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines)) + } + @Test func stripsLeadingTimestampPrefix() { let markdown = """ [Fri 2026-02-20 18:45 GMT+1] How's it going? diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 289cc18177d..147b80e5be1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -647,6 +647,35 @@ extension TestChatTransportState { try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } } + @Test func stripsInboundMetadataFromHistoryMessages() async throws { + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": """ +Conversation info (untrusted metadata): +```json +{ \"sender\": \"openclaw-ios\" } +``` + +Hello? +"""]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } } + + let sanitized = await MainActor.run { vm.messages.first?.content.first?.text } + #expect(sanitized == "Hello?") + } + @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { let sessionId = "sess-main" let history = OpenClawChatHistoryPayload( diff --git a/src/auto-reply/reply/strip-inbound-meta.test.ts b/src/auto-reply/reply/strip-inbound-meta.test.ts new file mode 100644 index 00000000000..807e07a8587 --- /dev/null +++ b/src/auto-reply/reply/strip-inbound-meta.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { stripInboundMetadata } from "./strip-inbound-meta.js"; + +const CONV_BLOCK = `Conversation info (untrusted metadata): +\`\`\`json +{ + "message_id": "msg-abc", + "sender": "+1555000" +} +\`\`\``; + +const SENDER_BLOCK = `Sender (untrusted metadata): +\`\`\`json +{ + "label": "Alice", + "name": "Alice" +} +\`\`\``; + +const REPLY_BLOCK = `Replied message (untrusted, for context): +\`\`\`json +{ + "body": "What time is it?" +} +\`\`\``; + +describe("stripInboundMetadata", () => { + it("fast-path: returns same string when no sentinels present", () => { + const text = "Hello, how are you?"; + expect(stripInboundMetadata(text)).toBe(text); + }); + + it("fast-path: returns empty string unchanged", () => { + expect(stripInboundMetadata("")).toBe(""); + }); + + it("strips a single Conversation info block", () => { + const input = `${CONV_BLOCK}\n\nWhat is the weather today?`; + expect(stripInboundMetadata(input)).toBe("What is the weather today?"); + }); + + it("strips multiple chained metadata blocks", () => { + const input = `${CONV_BLOCK}\n\n${SENDER_BLOCK}\n\nCan you help me?`; + expect(stripInboundMetadata(input)).toBe("Can you help me?"); + }); + + it("strips Replied message block leaving user message intact", () => { + const input = `${REPLY_BLOCK}\n\nGot it, thanks!`; + expect(stripInboundMetadata(input)).toBe("Got it, thanks!"); + }); + + it("strips all six known sentinel types", () => { + const sentinels = [ + "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):", + ]; + for (const sentinel of sentinels) { + const input = `${sentinel}\n\`\`\`json\n{"x": 1}\n\`\`\`\n\nUser message`; + expect(stripInboundMetadata(input)).toBe("User message"); + } + }); + + it("handles metadata block with no user text after it", () => { + expect(stripInboundMetadata(CONV_BLOCK)).toBe(""); + }); + + it("preserves message containing json fences that are not metadata", () => { + const text = `Here is my code:\n\`\`\`json\n{"key": "value"}\n\`\`\``; + expect(stripInboundMetadata(text)).toBe(text); + }); + + it("preserves leading newlines in user content after stripping", () => { + const input = `${CONV_BLOCK}\n\nActual message`; + expect(stripInboundMetadata(input)).toBe("Actual message"); + }); + + it("preserves leading spaces in user content after stripping", () => { + const input = `${CONV_BLOCK}\n\n Indented message`; + expect(stripInboundMetadata(input)).toBe(" Indented message"); + }); +}); diff --git a/src/auto-reply/reply/strip-inbound-meta.ts b/src/auto-reply/reply/strip-inbound-meta.ts new file mode 100644 index 00000000000..775de756408 --- /dev/null +++ b/src/auto-reply/reply/strip-inbound-meta.ts @@ -0,0 +1,89 @@ +/** + * Strips OpenClaw-injected inbound metadata blocks from a user-role message + * text before it is displayed in any UI surface (TUI, webchat, macOS app). + * + * Background: `buildInboundUserContextPrefix` in `inbound-meta.ts` prepends + * structured metadata blocks (Conversation info, Sender info, reply context, + * etc.) directly to the stored user message content so the LLM can access + * them. These blocks are AI-facing only and must never surface in user-visible + * chat history. + */ + +/** + * Sentinel strings that identify the start of an injected metadata block. + * Must stay in sync with `buildInboundUserContextPrefix` in `inbound-meta.ts`. + */ +const INBOUND_META_SENTINELS = [ + "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):", +] as const; + +// Pre-compiled fast-path regex — avoids line-by-line parse when no blocks present. +const SENTINEL_FAST_RE = new RegExp( + INBOUND_META_SENTINELS.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), +); + +/** + * Remove all injected inbound metadata prefix blocks from `text`. + * + * Each block has the shape: + * + * ``` + * + * ```json + * { … } + * ``` + * ``` + * + * Returns the original string reference unchanged when no metadata is present + * (fast path — zero allocation). + */ +export function stripInboundMetadata(text: string): string { + if (!text || !SENTINEL_FAST_RE.test(text)) { + return text; + } + + const lines = text.split("\n"); + const result: string[] = []; + let inMetaBlock = false; + let inFencedJson = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect start of a metadata block. + if (!inMetaBlock && INBOUND_META_SENTINELS.some((s) => line.startsWith(s))) { + inMetaBlock = true; + inFencedJson = false; + continue; + } + + if (inMetaBlock) { + if (!inFencedJson && line.trim() === "```json") { + inFencedJson = true; + continue; + } + if (inFencedJson) { + if (line.trim() === "```") { + inMetaBlock = false; + inFencedJson = false; + } + continue; + } + // Blank separator lines between consecutive blocks are dropped. + if (line.trim() === "") { + continue; + } + // Unexpected non-blank line outside a fence — treat as user content. + inMetaBlock = false; + } + + result.push(line); + } + + return result.join("\n").replace(/^\n+/, ""); +} diff --git a/src/discord/send.components.test.ts b/src/discord/send.components.test.ts new file mode 100644 index 00000000000..5a12fdab824 --- /dev/null +++ b/src/discord/send.components.test.ts @@ -0,0 +1,53 @@ +import { ChannelType } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerDiscordComponentEntries } from "./components-registry.js"; +import { sendDiscordComponentMessage } from "./send.components.js"; +import { makeDiscordRest } from "./send.test-harness.js"; + +const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: (...args: Parameters) => loadConfigMock(...args), + }; +}); + +vi.mock("./components-registry.js", () => ({ + registerDiscordComponentEntries: vi.fn(), +})); + +describe("sendDiscordComponentMessage", () => { + const registerMock = vi.mocked(registerDiscordComponentEntries); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps DM channel targets to direct-session component entries", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + type: ChannelType.DM, + recipients: [{ id: "user-1" }], + }); + postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "dm-1" }); + + await sendDiscordComponentMessage( + "channel:dm-1", + { + blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], + }, + { + rest, + token: "t", + sessionKey: "agent:main:discord:channel:dm-1", + agentId: "main", + }, + ); + + expect(registerMock).toHaveBeenCalledTimes(1); + const args = registerMock.mock.calls[0]?.[0]; + expect(args?.entries[0]?.sessionKey).toBe("agent:main:main"); + }); +}); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 82c6a795dd9..894b59d6f2c 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -1,4 +1,5 @@ import type { TUI } from "@mariozechner/pi-tui"; +import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import type { SessionsPatchResult } from "../gateway/protocol/index.js"; import { normalizeAgentId, @@ -326,7 +327,7 @@ export function createSessionActions(context: SessionActionContext) { if (message.role === "user") { const text = extractTextFromMessage(message); if (text) { - chatLog.addUser(text); + chatLog.addUser(stripInboundMetadata(text)); } continue; } diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index d36ead000f8..2adb5517213 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,3 +1,4 @@ +import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { stripThinkingTags } from "../format.ts"; @@ -7,9 +8,15 @@ const thinkingCache = new WeakMap(); export function extractText(message: unknown): string | null { const m = message as Record; const role = typeof m.role === "string" ? m.role : ""; + const shouldStripInboundMetadata = role.toLowerCase() === "user"; const content = m.content; if (typeof content === "string") { - const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + const processed = + role === "assistant" + ? stripThinkingTags(content) + : shouldStripInboundMetadata + ? stripInboundMetadata(stripEnvelope(content)) + : stripEnvelope(content); return processed; } if (Array.isArray(content)) { @@ -24,12 +31,22 @@ export function extractText(message: unknown): string | null { .filter((v): v is string => typeof v === "string"); if (parts.length > 0) { const joined = parts.join("\n"); - const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + const processed = + role === "assistant" + ? stripThinkingTags(joined) + : shouldStripInboundMetadata + ? stripInboundMetadata(stripEnvelope(joined)) + : stripEnvelope(joined); return processed; } } if (typeof m.text === "string") { - const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + const processed = + role === "assistant" + ? stripThinkingTags(m.text) + : shouldStripInboundMetadata + ? stripInboundMetadata(stripEnvelope(m.text)) + : stripEnvelope(m.text); return processed; } return null; diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index fbc867f664e..9b8f37e87c3 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -2,6 +2,7 @@ * Message normalization utilities for chat rendering. */ +import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts"; /** @@ -50,6 +51,16 @@ export function normalizeMessage(message: unknown): NormalizedMessage { const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now(); const id = typeof m.id === "string" ? m.id : undefined; + // Strip AI-injected metadata prefix blocks from user messages before display. + if (role === "user" || role === "User") { + content = content.map((item) => { + if (item.type === "text" && typeof item.text === "string") { + return { ...item, text: stripInboundMetadata(item.text) }; + } + return item; + }); + } + return { role, content, timestamp, id }; }