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 <li.yuanbin1@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-29 05:01:27 -04:00
committed by GitHub
parent ed17774f3f
commit dbba7e5c4b
3 changed files with 136 additions and 4 deletions
+87 -1
View File
@@ -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: '<tool_call>{"name":"read","arguments":{"path":"private"}}</tool_call>Done.',
expected: "Done.",
},
{
name: "strips multiline tool-response scaffolding",
text: [
"Before",
"<function_response>",
"private output",
"</function_response>",
"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<typeof DispatchInboundDirectDm>[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<void>,
meta: { eventId: string; createdAt: number },
lifecycle: NostrIngressLifecycle,
) => Promise<void>;
};
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();
}
});
});
@@ -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: '<tool_call>{"name":"read","arguments":{"path":"private"}}</tool_call>Done.',
expected: "Done.",
},
{
name: "strips multiline tool-response scaffolding",
text: [
"Before",
"<function_response>",
"private output",
"</function_response>",
"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)")),
+8 -3
View File
@@ -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<ChannelOutboundAdapter["sendText"]>;
sanitizeText: NonNullable<ChannelOutboundAdapter["sanitizeText"]>;
};
const activeBuses = new Map<string, NostrBusHandle>();
@@ -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,