diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 51e33e0c5485..af81f2679cff 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1967,7 +1967,7 @@ src/agents/tools/in-process-gateway.ts 1 src/agents/tools/media-generate-tool-actions-shared.ts 1 src/agents/tools/message-tool-discovery.ts 3 src/agents/tools/message-tool-execution.ts 3 -src/agents/tools/message-tool-visible-content.ts 9 +src/agents/tools/message-tool-visible-content.ts 6 src/agents/tools/mobile-ui-tool.ts 5 src/agents/tools/music-generate-tool.ts 2 src/agents/tools/nodes-tool.ts 2 diff --git a/src/agents/tools/message-tool-visible-content.ts b/src/agents/tools/message-tool-visible-content.ts index 8a0aa73e7cc7..199a04c789e2 100644 --- a/src/agents/tools/message-tool-visible-content.ts +++ b/src/agents/tools/message-tool-visible-content.ts @@ -106,6 +106,18 @@ function sanitizePresentationTextFieldsResult( return { value }; } let suppressionReason: VisibleTextSuppressionReason | undefined; + const sanitizeRecordArray = (entries: unknown[], field: "label" | "name") => + entries.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return entry; + } + // SAFETY: The guard establishes a non-null, non-array record before cloning. + const sanitized = { ...(entry as Record) }; + // Keep sanitizing after suppression; the first reason only labels the outcome. + const reason = sanitizeStringParam(sanitized, field, bootPrompt); + suppressionReason ??= reason; + return sanitized; + }); const presentation = { ...(value as Record) }; if (typeof presentation.title === "string") { const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt); @@ -229,18 +241,7 @@ function sanitizePresentationTextFieldsResult( }); } if (Array.isArray(sanitizedBlock.options)) { - sanitizedBlock.options = sanitizedBlock.options.map((option) => { - if (!option || typeof option !== "object" || Array.isArray(option)) { - return option; - } - const sanitizedOption = { ...(option as Record) }; - if (typeof sanitizedOption.label === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt); - sanitizedOption.label = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedOption; - }); + sanitizedBlock.options = sanitizeRecordArray(sanitizedBlock.options, "label"); } if (Array.isArray(sanitizedBlock.categories)) { sanitizedBlock.categories = sanitizedBlock.categories.map((category) => { @@ -253,32 +254,10 @@ function sanitizePresentationTextFieldsResult( }); } if (Array.isArray(sanitizedBlock.segments)) { - sanitizedBlock.segments = sanitizedBlock.segments.map((segment) => { - if (!segment || typeof segment !== "object" || Array.isArray(segment)) { - return segment; - } - const sanitizedSegment = { ...(segment as Record) }; - if (typeof sanitizedSegment.label === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSegment.label, bootPrompt); - sanitizedSegment.label = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedSegment; - }); + sanitizedBlock.segments = sanitizeRecordArray(sanitizedBlock.segments, "label"); } if (Array.isArray(sanitizedBlock.series)) { - sanitizedBlock.series = sanitizedBlock.series.map((series) => { - if (!series || typeof series !== "object" || Array.isArray(series)) { - return series; - } - const sanitizedSeries = { ...(series as Record) }; - if (typeof sanitizedSeries.name === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSeries.name, bootPrompt); - sanitizedSeries.name = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedSeries; - }); + sanitizedBlock.series = sanitizeRecordArray(sanitizedBlock.series, "name"); } return sanitizedBlock; }); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 5ddcdf03915c..0b80485a6e93 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -41,6 +41,7 @@ import { readEmbeddedMessageDeliveryFact } from "../embedded-agent-message-deliv import { createOpenClawTools } from "../openclaw-tools.js"; import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { createMessageTool } from "./message-tool-execution.js"; +import { sanitizeMessageToolVisiblePayload } from "./message-tool-visible-content.js"; import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js"; type CreateMessageTool = typeof createMessageTool; @@ -4553,6 +4554,12 @@ describe("message tool reasoning tag sanitization", () => { xLabel: "axis rationaleDay", yLabel: "axis rationaleMilliseconds", }, + { + type: "chart", + chartType: "pie", + title: "Traffic", + segments: [{ label: "segment rationalePrimary", value: 1 }], + }, ], }, }, @@ -4586,10 +4593,86 @@ describe("message tool reasoning tag sanitization", () => { xLabel: "Day", yLabel: "Milliseconds", }, + { + type: "chart", + chartType: "pie", + title: "Traffic", + segments: [{ label: "Primary", value: 1 }], + }, ], }); }); + it.each([true, false])( + "sanitizes every presentation record array while retaining the first reason (option suppressed: %s)", + (suppressOption) => { + const internalContext = + "<<>>\nBOOT.md:\nWake up and report.\n<<>>"; + const inboundContext = [ + markInboundContextLabel("Conversation info:"), + "```json", + '{"chat_id":"group:test","sender_id":"test-sender"}', + "```", + ].join("\n"); + const metadata = { retained: true }; + const option = { label: suppressOption ? internalContext : " Choice ", metadata }; + const nonString = { label: 7 }; + const invalidArray = ["unchanged"]; + const presentation = { + blocks: [ + { + options: [option, null, invalidArray, nonString], + categories: [inboundContext], + segments: [ + { label: internalContext, value: 1 }, + { label: "segment rationaleSlice", value: 2 }, + ], + series: [ + { name: internalContext, values: [1] }, + { name: "series rationaleTrend", values: [2] }, + ], + }, + ], + }; + const original = structuredClone(presentation); + const params = { presentation }; + + expect(sanitizeMessageToolVisiblePayload(params)).toBe( + suppressOption ? "internal_runtime_context_echo" : "inbound_metadata_echo", + ); + + const block = params.presentation.blocks[0]; + expect(block).toEqual({ + options: [ + { label: suppressOption ? "" : " Choice ", metadata }, + null, + invalidArray, + nonString, + ], + categories: [""], + segments: [ + { label: "", value: 1 }, + { label: "Slice", value: 2 }, + ], + series: [ + { name: "", values: [1] }, + { name: "Trend", values: [2] }, + ], + }); + expect(params.presentation).not.toBe(presentation); + expect(block).not.toBe(presentation.blocks[0]); + for (const field of ["options", "segments", "series"] as const) { + expect(block?.[field]).not.toBe(presentation.blocks[0]?.[field]); + } + expect(block?.options[0]).not.toBe(option); + expect(block?.options[2]).toBe(invalidArray); + expect(block?.options[3]).not.toBe(nonString); + expect(block?.segments[0]).not.toBe(presentation.blocks[0]?.segments[0]); + expect(block?.series[0]).not.toBe(presentation.blocks[0]?.series[0]); + expect(presentation).toEqual(original); + }, + ); + it("strips internal runtime context from visible presentation fields before sending (#53732)", async () => { mockSendResult({ channel: "slack", to: "slack:C123" });