From dbba7e5c4bddb6dfa105b453ef3b7386efbe590d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 05:01:27 -0400 Subject: [PATCH] fix(nostr): keep private tool traces out of encrypted messages (#115769) Sanitize both Nostr delivery paths with the shared assistant-visible text contract. Preserve ordinary replies and suppress internal-only messages before Markdown conversion. Related: #90684 Co-authored-by: liyuanbin --- extensions/nostr/src/channel.inbound.test.ts | 88 ++++++++++++++++++- extensions/nostr/src/channel.outbound.test.ts | 41 +++++++++ extensions/nostr/src/gateway.ts | 11 ++- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index 47d0d5379126..1f4843df19a3 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -52,11 +52,12 @@ function createRuntimeHarness() { const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "**Table:** [docs](https://example.com)" }); }); + const convertMarkdownTables = vi.fn((text: string) => text); const runtime = { channel: { text: { resolveMarkdownTableMode: vi.fn(() => "off"), - convertMarkdownTables: vi.fn((text: string) => text), + convertMarkdownTables, }, commands: { shouldComputeCommandAuthorized: vi.fn(() => true), @@ -91,6 +92,7 @@ function createRuntimeHarness() { runtime, recordInboundSession, dispatchReplyWithBufferedBlockDispatcher, + convertMarkdownTables, }; } @@ -227,4 +229,88 @@ describe("nostr inbound gateway path", () => { await cleanup.stop(); }); + + it.each([ + { + name: "strips an internal tool-failure banner", + text: "Done.\n⚠️ 🛠️ `search repos (agent)` failed", + expected: "Done.", + }, + { + name: "strips internal tool-call XML", + text: '{"name":"read","arguments":{"path":"private"}}Done.', + expected: "Done.", + }, + { + name: "strips multiline tool-response scaffolding", + text: [ + "Before", + "", + "private output", + "", + "After", + ].join("\n"), + expected: "Before\n\nAfter", + }, + { + name: "does not send an internal-trace-only reply", + text: "⚠️ 🛠️ `search repos (agent)` failed", + expected: null, + }, + { + name: "preserves ordinary visible prose", + text: "The relay has two active subscriptions.", + expected: "The relay has two active subscriptions.", + }, + ])("$name before sending an inbound Nostr DM reply", async ({ text, expected }) => { + mocks.dispatchInboundDirectDm.mockImplementationOnce( + async (params: Parameters[0]) => { + await params.deliver({ text }); + }, + ); + const { harness, cleanup } = await startGatewayHarness({ + account: buildResolvedNostrAccount({ + publicKey: "bot-pubkey", + config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, + }), + cfg: {}, + }); + const options = mockCallArg(mocks.startNostrBus) as { + onMessage: ( + senderPubkey: string, + text: string, + reply: (text: string) => Promise, + meta: { eventId: string; createdAt: number }, + lifecycle: NostrIngressLifecycle, + ) => Promise; + }; + const sendReply = vi.fn(async (_text: string) => {}); + const lifecycle: NostrIngressLifecycle = { + abortSignal: new AbortController().signal, + onAdopted: vi.fn(async () => {}), + onDeferred: vi.fn(), + onAdoptionFinalizing: vi.fn(), + onAbandoned: vi.fn(async () => {}), + }; + + try { + await options.onMessage( + "sender-pubkey", + "hello from nostr", + sendReply, + { eventId: "event-123", createdAt: 1_710_000_000 }, + lifecycle, + ); + + if (expected === null) { + expect(harness.convertMarkdownTables).not.toHaveBeenCalled(); + expect(sendReply).not.toHaveBeenCalled(); + } else { + expect(harness.convertMarkdownTables).toHaveBeenCalledWith(expected, "off"); + expect(sendReply).toHaveBeenCalledWith(expected); + } + } finally { + await cleanup.stop(); + } + }); }); diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index db873d6b98e4..12fb56045258 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -85,6 +85,47 @@ describe("nostr outbound cfg threading", () => { mocks.startNostrBus.mockReset(); }); + it.each([ + { + name: "strips an internal tool-failure banner", + text: "Done.\n⚠️ 🛠️ `search repos (agent)` failed", + expected: "Done.", + }, + { + name: "strips internal tool-call XML", + text: '{"name":"read","arguments":{"path":"private"}}Done.', + expected: "Done.", + }, + { + name: "strips multiline tool-response scaffolding", + text: [ + "Before", + "", + "private output", + "", + "After", + ].join("\n"), + expected: "Before\n\nAfter", + }, + { + name: "suppresses an internal-trace-only reply", + text: "⚠️ 🛠️ `search repos (agent)` failed", + expected: "", + }, + { + name: "preserves ordinary visible prose", + text: "The relay has two active subscriptions.", + expected: "The relay has two active subscriptions.", + }, + ])("$name through the Nostr outbound sanitizer", ({ text, expected }) => { + const sanitizeText = nostrPlugin.outbound?.sanitizeText; + expect(sanitizeText).toBeTypeOf("function"); + if (!sanitizeText) { + throw new Error("Expected Nostr outbound assistant-visible text sanitizer"); + } + expect(sanitizeText({ text, payload: { text } })).toBe(expected); + }); + it("converts tables before projecting markdown to Nostr plain text", async () => { const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime( vi.fn((text: string) => (text === "***" ? text : "**Table:** [docs](https://example.com)")), diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index 08fc4015e3dd..4cdb37d5597a 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -10,7 +10,7 @@ import { import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; +import { sanitizeAssistantVisibleText, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; import type { ChannelOutboundAdapter, ChannelPlugin } from "./channel-api.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; @@ -26,6 +26,7 @@ type NostrOutboundAdapter = Pick< "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText" > & { sendText: NonNullable; + sanitizeText: NonNullable; }; const activeBuses = new Map(); @@ -194,7 +195,10 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { payload && typeof payload === "object" && "text" in payload ? ((payload as { text?: string }).text ?? "") : ""; - if (!outboundText.trim()) { + // Inbound DM replies bypass the outbound adapter; sanitize before + // Markdown conversion so private tool traces cannot reach a relay. + const sanitizedText = sanitizeAssistantVisibleText(outboundText); + if (!sanitizedText) { return; } const tableMode = runtime.channel.text.resolveMarkdownTableMode({ @@ -203,7 +207,7 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { accountId: account.accountId, }); const message = stripMarkdown( - runtime.channel.text.convertMarkdownTables(outboundText, tableMode), + runtime.channel.text.convertMarkdownTables(sanitizedText, tableMode), ); if (message) { await reply(message); @@ -312,6 +316,7 @@ export const nostrPairingTextAdapter = { export const nostrOutboundAdapter: NostrOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), deliveryCapabilities: { durableFinal: { text: true,