From 13c1b216c6ce12989af1725fc47d40b2e7bb5a69 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Mon, 27 Jul 2026 01:51:51 +0800 Subject: [PATCH] fix(engine): redact tool_result output in chat-grounding foldAssistantMessage runs every outgoing text chunk through the PUBLIC_FIELD_BLOCKLIST backstop, but foldToolResultMessage forwarded an MCP tool's own output verbatim -- so a blocklisted field in a tool's result leaked into the public-facing chat-grounding stream unredacted. Scan the tool_result content (string, content-block array, or object where the term can appear as a key or value) against the same blocklist and, on a hit, replace the whole output with the redaction sentinel -- matching redactBlockedText's replace-not-filter policy. --- .../src/miner/chat-grounding.ts | 17 +++++++++++++- test/unit/chat-grounding-engine.test.ts | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/loopover-engine/src/miner/chat-grounding.ts b/packages/loopover-engine/src/miner/chat-grounding.ts index 5bf4fb4f91..7a22ffefbc 100644 --- a/packages/loopover-engine/src/miner/chat-grounding.ts +++ b/packages/loopover-engine/src/miner/chat-grounding.ts @@ -227,6 +227,17 @@ function* foldAssistantMessage(message: Record): Generator containsBlockedTerm(key) || toolResultContentIsBlocked(value)); +} + /** Folds one user message's tool-result blocks (the SDK reports tool output on a `user`-role message). */ function* foldToolResultMessage(message: Record): Generator { const content = asRecord(message.message)?.content; @@ -235,7 +246,11 @@ function* foldToolResultMessage(message: Record): Generator { ]); }); + it("redacts a blocked term in tool_result output — string or structured — and passes clean output through (#8869)", async () => { + async function toolResultOutput(content: unknown): Promise { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "loopover_miner_get_run_state", content }] } }]), + }), + ); + return events.find((event): event is Extract => event.type === "tool_result")?.output; + } + + // An MCP tool's OWN output must not leak a blocklisted field, however it is shaped: + expect(await toolResultOutput("your trust score is 9")).toBe(CHAT_REDACTED_TEXT); // string content + expect(await toolResultOutput([{ type: "text", text: "your wallet balance is 5" }])).toBe(CHAT_REDACTED_TEXT); // array of blocks + expect(await toolResultOutput({ nested: { payout: "42 TAO" } })).toBe(CHAT_REDACTED_TEXT); // nested object + + // Clean output — of any shape — passes through verbatim, structure preserved. + expect(await toolResultOutput("run state is idle")).toBe("run state is idle"); + expect(await toolResultOutput([{ type: "text", text: "all good" }])).toEqual([{ type: "text", text: "all good" }]); + expect(await toolResultOutput(7)).toBe(7); // a non-string/array/object primitive is never blocked + }); + it("defaults a tool_use with no input to an empty object and a tool_result with no id to an empty name", async () => { const events = await collect( runChatGrounding(USER_ONLY, {