Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
957605f18f fix: sanitize assistant session text (#1456) (thanks @zerone0x) 2026-01-23 07:01:29 +00:00
zerone0x
2d032e207d fix: sanitize tool call text in sessions-helpers extractAssistantText
Adds sanitization to extractAssistantText in sessions-helpers.ts to
prevent tool call text from leaking to users. Previously, messages
retrieved from chat history via sessions-helpers.ts could expose:

- Minimax XML tool calls (<invoke>...</invoke>)
- Downgraded tool call markers ([Tool Call: name (ID: ...)])
- Thinking tags (<think>...</think>)

This fix:
- Exports the stripping functions from pi-embedded-utils.ts
- Adds a new sanitizeTextContent helper in sessions-helpers.ts
- Updates extractAssistantText to sanitize before returning
- Updates extractMessageText in commands-subagents.ts to sanitize

Fixes #1269

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 06:56:23 +00:00
6 changed files with 93 additions and 10 deletions

View File

@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. - Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok. - Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Agents: surface concrete API error details instead of generic AI service errors. - Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484) - Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)

View File

@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
* - <invoke name="...">...</invoke> blocks * - <invoke name="...">...</invoke> blocks
* - </minimax:tool_call> closing tags * - </minimax:tool_call> closing tags
*/ */
function stripMinimaxToolCallXml(text: string): string { export function stripMinimaxToolCallXml(text: string): string {
if (!text) return text; if (!text) return text;
if (!/minimax:tool_call/i.test(text)) return text; if (!/minimax:tool_call/i.test(text)) return text;
@ -28,7 +28,7 @@ function stripMinimaxToolCallXml(text: string): string {
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should * downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
* not be shown to users. * not be shown to users.
*/ */
function stripDowngradedToolCallText(text: string): string { export function stripDowngradedToolCallText(text: string): string {
if (!text) return text; if (!text) return text;
if (!/\[Tool (?:Call|Result)/i.test(text)) return text; if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
@ -165,7 +165,7 @@ function stripDowngradedToolCallText(text: string): string {
* This is a safety net for cases where the model outputs <think> tags * This is a safety net for cases where the model outputs <think> tags
* that slip through other filtering mechanisms. * that slip through other filtering mechanisms.
*/ */
function stripThinkingTagsFromText(text: string): string { export function stripThinkingTagsFromText(text: string): string {
if (!text) return text; if (!text) return text;
// Quick check to avoid regex overhead when no tags present. // Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text; if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
describe("sanitizeTextContent", () => {
it("strips minimax tool call XML and downgraded markers", () => {
const input =
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
"[Tool Call: foo (ID: 1)] world";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Hello world");
expect(result).not.toContain("invoke");
expect(result).not.toContain("Tool Call");
});
it("strips thinking tags", () => {
const input = "Before <think>secret</think> after";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Before after");
});
});
describe("extractAssistantText", () => {
it("sanitizes blocks without injecting newlines", () => {
const message = {
role: "assistant",
content: [
{ type: "text", text: "Hi " },
{ type: "text", text: "<think>secret</think>there" },
],
};
expect(extractAssistantText(message)).toBe("Hi there");
});
});

View File

@ -1,4 +1,10 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
import {
stripDowngradedToolCallText,
stripMinimaxToolCallXml,
stripThinkingTagsFromText,
} from "../pi-embedded-utils.js";
import { normalizeMainKey } from "../../routing/session-key.js"; import { normalizeMainKey } from "../../routing/session-key.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
@ -100,6 +106,15 @@ export function stripToolMessages(messages: unknown[]): unknown[] {
}); });
} }
/**
* Sanitize text content to strip tool call markers and thinking tags.
* This ensures user-facing text doesn't leak internal tool representations.
*/
export function sanitizeTextContent(text: string): string {
if (!text) return text;
return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text)));
}
export function extractAssistantText(message: unknown): string | undefined { export function extractAssistantText(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined; if (!message || typeof message !== "object") return undefined;
if ((message as { role?: unknown }).role !== "assistant") return undefined; if ((message as { role?: unknown }).role !== "assistant") return undefined;
@ -110,10 +125,13 @@ export function extractAssistantText(message: unknown): string | undefined {
if (!block || typeof block !== "object") continue; if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue; if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) { if (typeof text === "string") {
chunks.push(text); const sanitized = sanitizeTextContent(text);
if (sanitized.trim()) {
chunks.push(sanitized);
}
} }
} }
const joined = chunks.join("").trim(); const joined = chunks.join("").trim();
return joined ? joined : undefined; return joined ? sanitizeUserFacingText(joined) : undefined;
} }

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { extractMessageText } from "./commands-subagents.js";
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});

View File

@ -7,6 +7,7 @@ import {
extractAssistantText, extractAssistantText,
resolveInternalSessionKey, resolveInternalSessionKey,
resolveMainSessionAlias, resolveMainSessionAlias,
sanitizeTextContent,
stripToolMessages, stripToolMessages,
} from "../../agents/tools/sessions-helpers.js"; } from "../../agents/tools/sessions-helpers.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
@ -106,11 +107,14 @@ function normalizeMessageText(text: string) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
function extractMessageText(message: ChatMessage): { role: string; text: string } | null { export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : ""; const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const content = message.content; const content = message.content;
if (typeof content === "string") { if (typeof content === "string") {
const normalized = normalizeMessageText(content); const normalized = normalizeMessageText(
shouldSanitize ? sanitizeTextContent(content) : content,
);
return normalized ? { role, text: normalized } : null; return normalized ? { role, text: normalized } : null;
} }
if (!Array.isArray(content)) return null; if (!Array.isArray(content)) return null;
@ -119,8 +123,11 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
if (!block || typeof block !== "object") continue; if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue; if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) { if (typeof text === "string") {
chunks.push(text); const value = shouldSanitize ? sanitizeTextContent(text) : text;
if (value.trim()) {
chunks.push(value);
}
} }
} }
const joined = normalizeMessageText(chunks.join(" ")); const joined = normalizeMessageText(chunks.join(" "));