Skip to content

Commit ce48bdb

Browse files
committed
fix: sanitize block reply previews
1 parent cda7c30 commit ce48bdb

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5615,6 +5615,68 @@ describe("dispatchReplyFromConfig", () => {
56155615
expect(blockReplySettled).toBe(true);
56165616
});
56175617

5618+
5619+
it("sanitizes internal runtime context before queued and dispatched block replies", async () => {
5620+
setNoAbort();
5621+
const dispatcher = createDispatcher();
5622+
const ctx = buildTestCtx({ Provider: "whatsapp" });
5623+
const queuedTexts: string[] = [];
5624+
const dispatchedTexts: string[] = [];
5625+
const internalBlock = [
5626+
"Visible intro.",
5627+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
5628+
"OpenClaw runtime event.",
5629+
"This context is runtime-generated, not user-authored. Keep internal details private.",
5630+
"",
5631+
"[Internal task completion event]",
5632+
"source: subagent",
5633+
"task: LEAK_SENTINEL_STRICT_BOUNDARIES",
5634+
"## Active Subagents",
5635+
"task_json=should-not-appear",
5636+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
5637+
"Visible outro.",
5638+
].join("\n");
5639+
const replyResolver = async (
5640+
_ctx: MsgContext,
5641+
opts?: GetReplyOptions,
5642+
): Promise<ReplyPayload | undefined> => {
5643+
await opts?.onBlockReply?.({ text: internalBlock });
5644+
return undefined;
5645+
};
5646+
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
5647+
(payload: ReplyPayload) => {
5648+
if (payload.text) {
5649+
dispatchedTexts.push(payload.text);
5650+
}
5651+
return true;
5652+
},
5653+
);
5654+
5655+
await dispatchReplyFromConfig({
5656+
ctx,
5657+
cfg: emptyConfig,
5658+
dispatcher,
5659+
replyResolver,
5660+
replyOptions: {
5661+
onBlockReplyQueued: (payload) => {
5662+
if (payload.text) {
5663+
queuedTexts.push(payload.text);
5664+
}
5665+
},
5666+
},
5667+
});
5668+
5669+
expect(queuedTexts).toEqual(["Visible intro.\n\nVisible outro."]);
5670+
expect(dispatchedTexts).toEqual(["Visible intro.\n\nVisible outro."]);
5671+
for (const text of [...queuedTexts, ...dispatchedTexts]) {
5672+
expect(text).not.toContain("LEAK_SENTINEL_STRICT_BOUNDARIES");
5673+
expect(text).not.toContain("## Active Subagents");
5674+
expect(text).not.toContain("task_json=");
5675+
expect(text).not.toContain("Internal task completion event");
5676+
expect(text).not.toContain("source: subagent");
5677+
}
5678+
});
5679+
56185680
it("forwards payload metadata into onBlockReplyQueued context", async () => {
56195681
setNoAbort();
56205682
const dispatcher = createDispatcher();

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveSessionAgentId,
1111
} from "../../agents/agent-scope.js";
1212
import { selectAgentHarness } from "../../agents/harness/selection.js";
13+
import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js";
1314
import {
1415
buildModelAliasIndex,
1516
resolveDefaultModelForAgent,
@@ -1154,6 +1155,15 @@ export async function dispatchReplyFromConfig(
11541155
return await normalizeReplyMediaPayloadPaths(payload);
11551156
};
11561157

1158+
const sanitizeVisibleBlockPayload = (payload: ReplyPayload): ReplyPayload | null => {
1159+
if (!payload.text) {
1160+
return payload;
1161+
}
1162+
const text = sanitizeUserFacingText(payload.text, { errorContext: Boolean(payload.isError) });
1163+
const nextPayload = { ...payload, text: text.trim() ? text : undefined };
1164+
return hasOutboundReplyContent(nextPayload, { trimText: true }) ? nextPayload : null;
1165+
};
1166+
11571167
const routeReplyToOriginating = async (
11581168
payload: ReplyPayload,
11591169
options?: { abortSignal?: AbortSignal; mirror?: boolean },
@@ -2245,30 +2255,34 @@ export async function dispatchReplyFromConfig(
22452255
if (payload.isReasoning === true) {
22462256
return;
22472257
}
2258+
const sanitizedPayload = sanitizeVisibleBlockPayload(payload);
2259+
if (!sanitizedPayload) {
2260+
return;
2261+
}
22482262
// Accumulate block text for TTS generation after streaming.
22492263
// Exclude status notices — they are informational UI signals
22502264
// and must not be synthesised into the spoken reply.
2251-
const isStatusNotice = isReplyPayloadStatusNotice(payload);
2252-
if (payload.text && !isStatusNotice) {
2265+
const isStatusNotice = isReplyPayloadStatusNotice(sanitizedPayload);
2266+
if (sanitizedPayload.text && !isStatusNotice) {
22532267
const joinsBufferedTtsDirective =
22542268
cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true;
22552269
if (accumulatedBlockText.length > 0) {
22562270
accumulatedBlockText += "\n";
22572271
}
2258-
accumulatedBlockText += payload.text;
2272+
accumulatedBlockText += sanitizedPayload.text;
22592273
if (accumulatedBlockTtsText.length > 0 && !joinsBufferedTtsDirective) {
22602274
accumulatedBlockTtsText += "\n";
22612275
}
2262-
accumulatedBlockTtsText += payload.text;
2276+
accumulatedBlockTtsText += sanitizedPayload.text;
22632277
blockCount++;
22642278
}
22652279
const visiblePayload =
2266-
payload.text && cleanBlockTtsDirectiveText && !isStatusNotice
2280+
sanitizedPayload.text && cleanBlockTtsDirectiveText && !isStatusNotice
22672281
? (() => {
2268-
const text = cleanBlockTtsDirectiveText.push(payload.text);
2269-
return { ...payload, text: text.trim() ? text : undefined };
2282+
const text = cleanBlockTtsDirectiveText.push(sanitizedPayload.text);
2283+
return { ...sanitizedPayload, text: text.trim() ? text : undefined };
22702284
})()
2271-
: payload;
2285+
: sanitizedPayload;
22722286
if (!hasOutboundReplyContent(visiblePayload, { trimText: true })) {
22732287
return;
22742288
}

0 commit comments

Comments
 (0)