fix(ui): strip injected inbound metadata from user messages in history (#22142)

* 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 <vincentkoc@ieee.org>
This commit is contained in:
Mars
2026-02-20 20:35:13 -05:00
committed by GitHub
parent f555835b09
commit a4e7e952e1
11 changed files with 420 additions and 13 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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?

View File

@@ -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(

View File

@@ -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");
});
});

View File

@@ -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:
*
* ```
* <sentinel-line>
* ```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+/, "");
}

View File

@@ -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<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: (...args: Parameters<typeof actual.loadConfig>) => 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");
});
});

View File

@@ -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;
}

View File

@@ -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<object, string | null>();
export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
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;

View File

@@ -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 };
}