fix(boot): suppress fallback BOOT.md echoes

Suppress BOOT.md/internal-runtime-context echoes in fallback boot sends.

Wrap boot prompts as internal runtime context, track the active boot prompt during boot runs, and sanitize message-tool visible payloads before dispatch so fallback models cannot deliver copied BOOT.md instructions or leak them through raw-params errors. Preserves media/presentation sends that still contain non-text payload content after sanitization.

Fixes #53732.

Co-authored-by: stainlu <stainlu@newtype-ai.org>
This commit is contained in:
stain lu
2026-05-31 19:25:41 +08:00
committed by GitHub
parent a76db8cff3
commit 95b2f9c6f9
6 changed files with 1046 additions and 19 deletions

View File

@@ -370,6 +370,14 @@ async function executeSend(params: {
action: Record<string, unknown>;
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
toolCallId?: string;
}) {
return (await executeSendWithResult(params)).call;
}
async function executeSendWithResult(params: {
action: Record<string, unknown>;
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
toolCallId?: string;
}) {
const { config, getRuntimeConfig, ...toolOptions } = params.toolOptions ?? {};
const tool = createMessageTool({
@@ -377,11 +385,11 @@ async function executeSend(params: {
runMessageAction: mocks.runMessageAction as never,
...toolOptions,
});
await tool.execute(params.toolCallId ?? "1", {
const result = await tool.execute(params.toolCallId ?? "1", {
action: "send",
...params.action,
});
return lastRunMessageActionInput();
return { call: lastRunMessageActionInput(), result };
}
describe("message tool gateway timeout", () => {
@@ -1972,6 +1980,459 @@ describe("message tool reasoning tag sanitization", () => {
],
});
});
it("strips internal runtime context from visible presentation fields before sending (#53732)", async () => {
mockSendResult({ channel: "slack", to: "slack:C123" });
const internalContext =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const call = await executeSend({
action: {
target: "slack:C123",
presentation: {
title: `Deploy ready\n${internalContext}`,
blocks: [
{ type: "text", text: `Ship it\n${internalContext}` },
{
type: "input",
placeholder: `Pick a lane\n${internalContext}`,
},
{
type: "buttons",
buttons: [
{
label: `Approve\n${internalContext}`,
value: "approve",
},
],
},
{
type: "select",
options: [
{
label: `Main\n${internalContext}`,
value: "main",
},
],
},
],
},
},
});
expect(call?.params?.presentation).toEqual({
title: "Deploy ready",
blocks: [
{ type: "text", text: "Ship it" },
{ type: "input", placeholder: "Pick a lane" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
{
type: "select",
options: [{ label: "Main", value: "main" }],
},
],
});
});
});
describe("message tool boot-echo guard", () => {
const longBootPrompt = [
"You are running a boot check. Follow BOOT.md instructions exactly.",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
"BOOT.md:",
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel and report the active project status with three concrete bullet points.",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
].join("\n");
let setBootEchoContextForSession: typeof import("../../gateway/boot-echo-guard.js").setBootEchoContextForSession;
let resetBootEchoContextForTests: typeof import("../../gateway/boot-echo-guard.js").resetBootEchoContextForTests;
beforeAll(async () => {
({ setBootEchoContextForSession, resetBootEchoContextForTests } =
await import("../../gateway/boot-echo-guard.js"));
});
afterEach(() => {
resetBootEchoContextForTests();
});
it("suppresses text-only sends that echo a substantial chunk of the registered boot prompt without preserving the wrapper markers (#53732)", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
// The model is paraphrasing out the wrapper but copying the BOOT.md
// sentence verbatim — exactly the leak vector clawsweeper called out
// on #75128 that the marker-only strip would miss.
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const { call, result } = await executeSendWithResult({
action: {
target: "telegram:123",
text: echoedText,
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call).toBeUndefined();
expect(mocks.runMessageAction).not.toHaveBeenCalled();
expect(result.details).toMatchObject({
status: "suppressed",
reason: "internal_runtime_context_echo",
});
expect(JSON.stringify(result)).not.toContain("thoughtful greeting");
});
it("sanitizes boot echo text and still sends when media content remains", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "telegram:123",
text: echoedText,
mediaUrl: "file:///tmp/status.png",
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.text).toBe("");
expect(call?.params?.mediaUrl).toBe("file:///tmp/status.png");
});
it("sanitizes boot echo text and still sends when snake_case media content remains", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "telegram:123",
text: echoedText,
media_url: "file:///tmp/status.png",
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.text).toBe("");
expect(call?.params?.media_url).toBe("file:///tmp/status.png");
});
it("sanitizes boot echo text and still sends when snake_case media arrays remain", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "telegram:123",
text: echoedText,
media_urls: ["file:///tmp/one.png", "file:///tmp/two.png"],
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.text).toBe("");
expect(call?.params?.media_urls).toEqual(["file:///tmp/one.png", "file:///tmp/two.png"]);
});
it("sanitizes boot echo text and still sends when structured attachments remain", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "telegram:123",
message: echoedText,
attachments: [{ media: "file:///tmp/status.png" }],
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.message).toBe("");
expect(call?.params?.attachments).toEqual([{ media: "file:///tmp/status.png" }]);
});
it("sanitizes boot echo text and still sends when structured attachment aliases remain", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const echoedText =
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "telegram:123",
message: echoedText,
attachments: [{ file_path: "/tmp/status.png" }],
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.message).toBe("");
expect(call?.params?.attachments).toEqual([{ file_path: "/tmp/status.png" }]);
});
it("preserves a short legitimate BOOT.md-directed send that does not reproduce a long boot-prompt chunk", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "telegram", to: "telegram:123" });
const call = await executeSend({
action: {
target: "telegram:123",
text: "Good morning! Project status looks healthy today.",
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.text).toBe("Good morning! Project status looks healthy today.");
});
it("does not affect outbound text when no boot prompt is registered for the session", async () => {
mockSendResult({ channel: "telegram", to: "telegram:123" });
const call = await executeSend({
action: {
target: "telegram:123",
text: "Any message goes through unchanged.",
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.text).toBe("Any message goes through unchanged.");
});
it("collapses presentation fields that echo a substantial chunk of the registered boot prompt (#53732)", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "slack", to: "slack:C123" });
const echoedBootText =
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
const call = await executeSend({
action: {
target: "slack:C123",
mediaUrl: "file:///tmp/proof.png",
presentation: {
title: echoedBootText,
blocks: [
{ type: "text", text: echoedBootText },
{
type: "buttons",
buttons: [{ label: echoedBootText, value: "approve" }],
},
{
type: "select",
placeholder: echoedBootText,
options: [{ label: echoedBootText, value: "main" }],
},
],
},
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.presentation).toEqual({
title: "",
blocks: [
{ type: "text", text: "" },
{
type: "buttons",
buttons: [{ label: "", value: "approve" }],
},
{
type: "select",
placeholder: "",
options: [{ label: "", value: "main" }],
},
],
});
});
it("sanitizes boot echo text from presentation button links before dispatch", async () => {
setBootEchoContextForSession("agent:main", longBootPrompt);
mockSendResult({ channel: "slack", to: "slack:C123" });
const echoedText =
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel and report the active project status";
const call = await executeSend({
action: {
target: "slack:C123",
message: "Visible",
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "Status", url: echoedText },
{ label: "App", webApp: { url: echoedText }, web_app: { url: echoedText } },
],
},
],
},
},
toolOptions: { agentSessionKey: "agent:main" },
});
expect(call?.params?.message).toBe("Visible");
expect(call?.params?.presentation).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Status" }, { label: "App" }],
},
],
});
});
});
describe("message tool internal-runtime-context sanitization", () => {
it.each([
{
field: "text",
input:
"Here is the boot info:\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nThis context is runtime-generated, not user-authored. Keep internal details private.\n\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nDone.",
expected: "Here is the boot info:\n\nDone.",
target: "signal:+15551234567",
channel: "signal",
},
{
field: "content",
input:
"Before\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nleaked\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nAfter",
expected: "Before\n\nAfter",
target: "discord:123",
channel: "discord",
},
{
field: "message",
input:
"Here is the boot info:\\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\\nBOOT.md:\\nWake up and report.\\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\\nDone.",
expected: "Here is the boot info:\n\nDone.",
target: "telegram:123",
channel: "telegram",
},
{
field: "SendMessage",
input:
"Alias\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nDone.",
expected: "Alias\n\nDone.",
target: "telegram:123",
channel: "telegram",
},
])(
"strips internal-runtime-context blocks in $field before sending so verbatim boot-prompt echoes do not leak (#53732)",
async ({ channel, target, field, input, expected }) => {
mockSendResult({ channel, to: target });
const call = await executeSend({
action: {
target,
[field]: input,
},
});
expect(call?.params?.[field]).toBe(expected);
},
);
it("strips internal-runtime-context blocks from poll creation text before dispatch", async () => {
mockSendResult({ channel: "telegram", to: "telegram:123" });
const internalContext =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const call = await executeSend({
action: {
action: "poll",
target: "telegram:123",
pollQuestion: `Choose one\n${internalContext}`,
pollOption: [`Yes\n${internalContext}`, "No"],
},
});
expect(call?.params?.pollQuestion).toBe("Choose one");
expect(call?.params?.pollOption).toEqual(["Yes", "No"]);
});
it("strips internal-runtime-context blocks from quote text before dispatch", async () => {
mockSendResult({ channel: "telegram", to: "telegram:123" });
const internalContext =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const call = await executeSend({
action: {
target: "telegram:123",
message: "Visible",
quoteText: `Quoted\n${internalContext}`,
},
});
expect(call?.params?.quoteText).toBe("Quoted");
});
it("parses and sanitizes stringified presentation and interactive payloads before dispatch", async () => {
mockSendResult({ channel: "slack", to: "slack:C123" });
const internalContext =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const call = await executeSend({
action: {
target: "slack:C123",
message: "Visible",
presentation: JSON.stringify({
title: `Presentation\n${internalContext}`,
blocks: [{ type: "text", text: `Block\n${internalContext}` }],
}),
interactive: JSON.stringify({
blocks: [{ type: "text", text: `Legacy\n${internalContext}` }],
}),
},
});
expect(call?.params?.presentation).toEqual({
title: "Presentation",
blocks: [{ type: "text", text: "Block" }],
});
expect(call?.params?.interactive).toEqual({
blocks: [{ type: "text", text: "Legacy" }],
});
});
it("suppresses pure internal-runtime-context sends before generic raw-params logging can see original args", async () => {
const { call, result } = await executeSendWithResult({
action: {
target: "discord:123",
content:
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
},
});
expect(call).toBeUndefined();
expect(mocks.runMessageAction).not.toHaveBeenCalled();
expect(result.details).toMatchObject({
status: "suppressed",
reason: "internal_runtime_context_echo",
});
expect(JSON.stringify(result)).not.toContain("BOOT.md");
expect(JSON.stringify(result)).not.toContain("Wake up and report");
});
it("sanitizes every visible text alias even after an earlier field is fully suppressed", async () => {
mockSendResult({ channel: "telegram", to: "telegram:123" });
const internalOnly =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const call = await executeSend({
action: {
target: "telegram:123",
text: internalOnly,
message: `Visible\n${internalOnly}`,
mediaUrl: "file:///tmp/status.png",
},
});
expect(call?.params?.text).toBe("");
expect(call?.params?.message).toBe("Visible");
});
});
describe("message tool sandbox passthrough", () => {

View File

@@ -27,8 +27,17 @@ import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
getBootEchoContextForSession,
stripBootEchoFromOutboundText,
} from "../../gateway/boot-echo-guard.js";
import {
parseInteractiveParam,
parseJsonMessageParam,
} from "../../infra/outbound/message-action-params.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { resolveAllowedMessageActions } from "../../infra/outbound/outbound-policy.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
import {
@@ -40,6 +49,7 @@ import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reas
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js";
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
import {
channelTargetSchema,
channelTargetsSchema,
@@ -48,7 +58,7 @@ import {
stringEnum,
} from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import { jsonResult, readStringArrayParam, readStringParam } from "./common.js";
import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js";
import { readGatewayCallOptions, resolveGatewayOptions } from "./gateway.js";
@@ -77,13 +87,84 @@ function normalizeToolCallIdForIdempotencyKey(toolCallId: unknown): string | und
return value.replace(/[^A-Za-z0-9._:-]+/gu, "_");
}
function sanitizePresentationTextFields(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
function normalizeEscapedLineBreaksForVisibleText(text: string): string {
if (!text.includes("\\")) {
return text;
}
// The send path turns literal "\n" sequences into line breaks later; match
// that before privacy stripping so escaped delimiter lines cannot bypass it.
return text.replace(/\\r\\n|\\n|\\r/g, "\n");
}
function sanitizeUserVisibleToolTextResult(
text: string,
bootPrompt: string | undefined,
): { text: string; suppressed: boolean } {
const normalized = normalizeEscapedLineBreaksForVisibleText(text);
const strippedReasoning = stripFormattedReasoningMessage(normalized);
const strippedInternal = stripInternalRuntimeContext(strippedReasoning);
const strippedBoot = stripBootEchoFromOutboundText(strippedInternal, bootPrompt);
return {
text: strippedBoot,
suppressed:
strippedBoot.trim().length === 0 &&
strippedReasoning.trim().length > 0 &&
(strippedInternal !== strippedReasoning || strippedBoot !== strippedInternal),
};
}
function sanitizeStringParam(
params: Record<string, unknown>,
field: string,
bootPrompt: string | undefined,
): boolean {
if (typeof params[field] !== "string") {
return false;
}
const sanitized = sanitizeUserVisibleToolTextResult(params[field], bootPrompt);
params[field] = sanitized.text;
return sanitized.suppressed;
}
function sanitizeStringArrayParam(
params: Record<string, unknown>,
field: string,
bootPrompt: string | undefined,
): boolean {
const value = params[field];
if (typeof value === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(value, bootPrompt);
params[field] = sanitized.text;
return sanitized.suppressed;
}
if (!Array.isArray(value)) {
return false;
}
let suppressed = false;
params[field] = value.map((entry) => {
if (typeof entry !== "string") {
return entry;
}
const sanitized = sanitizeUserVisibleToolTextResult(entry, bootPrompt);
suppressed ||= sanitized.suppressed;
return sanitized.text;
});
return suppressed;
}
function sanitizePresentationTextFieldsResult(
value: unknown,
bootPrompt: string | undefined,
): { value: unknown; suppressed: boolean } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { value, suppressed: false };
}
let suppressed = false;
const presentation = { ...(value as Record<string, unknown>) };
if (typeof presentation.title === "string") {
presentation.title = stripFormattedReasoningMessage(presentation.title);
const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt);
presentation.title = sanitized.text;
suppressed ||= sanitized.suppressed;
}
if (Array.isArray(presentation.blocks)) {
presentation.blocks = presentation.blocks.map((block) => {
@@ -93,7 +174,9 @@ function sanitizePresentationTextFields(value: unknown): unknown {
const sanitizedBlock = { ...(block as Record<string, unknown>) };
for (const field of ["text", "placeholder"]) {
if (typeof sanitizedBlock[field] === "string") {
sanitizedBlock[field] = stripFormattedReasoningMessage(sanitizedBlock[field]);
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock[field], bootPrompt);
sanitizedBlock[field] = sanitized.text;
suppressed ||= sanitized.suppressed;
}
}
if (Array.isArray(sanitizedBlock.buttons)) {
@@ -103,7 +186,36 @@ function sanitizePresentationTextFields(value: unknown): unknown {
}
const sanitizedButton = { ...(button as Record<string, unknown>) };
if (typeof sanitizedButton.label === "string") {
sanitizedButton.label = stripFormattedReasoningMessage(sanitizedButton.label);
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.label, bootPrompt);
sanitizedButton.label = sanitized.text;
suppressed ||= sanitized.suppressed;
}
if (typeof sanitizedButton.url === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.url, bootPrompt);
if (sanitized.text) {
sanitizedButton.url = sanitized.text;
} else {
delete sanitizedButton.url;
}
suppressed ||= sanitized.suppressed;
}
for (const webAppField of ["webApp", "web_app"]) {
const webApp = sanitizedButton[webAppField];
if (!webApp || typeof webApp !== "object" || Array.isArray(webApp)) {
continue;
}
const sanitizedWebApp = { ...(webApp as Record<string, unknown>) };
if (typeof sanitizedWebApp.url !== "string") {
continue;
}
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedWebApp.url, bootPrompt);
if (sanitized.text) {
sanitizedWebApp.url = sanitized.text;
sanitizedButton[webAppField] = sanitizedWebApp;
} else {
delete sanitizedButton[webAppField];
}
suppressed ||= sanitized.suppressed;
}
return sanitizedButton;
});
@@ -115,7 +227,9 @@ function sanitizePresentationTextFields(value: unknown): unknown {
}
const sanitizedOption = { ...(option as Record<string, unknown>) };
if (typeof sanitizedOption.label === "string") {
sanitizedOption.label = stripFormattedReasoningMessage(sanitizedOption.label);
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt);
sanitizedOption.label = sanitized.text;
suppressed ||= sanitized.suppressed;
}
return sanitizedOption;
});
@@ -123,7 +237,58 @@ function sanitizePresentationTextFields(value: unknown): unknown {
return sanitizedBlock;
});
}
return presentation;
return { value: presentation, suppressed };
}
function readFirstStringParam(params: Record<string, unknown>, keys: readonly string[]): string {
for (const key of keys) {
const value = readStringParam(params, key);
if (value) {
return value;
}
}
return "";
}
function readStructuredAttachmentMediaParams(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const values: string[] = [];
for (const attachment of value) {
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
continue;
}
const record = attachment as Record<string, unknown>;
for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl", "url"]) {
const candidate = readStringParam(record, key);
if (candidate) {
values.push(candidate);
}
}
}
return values;
}
function hasSanitizedSendPayloadContent(params: Record<string, unknown>): boolean {
const text = ["message", "text", "content", "caption", "SendMessage"]
.map((field) => (typeof params[field] === "string" ? params[field] : ""))
.filter((value) => value.trim())
.join("\n");
const mediaUrls = [
...(readStringArrayParam(params, "mediaUrls") ?? []),
...readStructuredAttachmentMediaParams(params.attachments),
];
return hasReplyPayloadContent(
{
text,
mediaUrl: readFirstStringParam(params, ["media", "mediaUrl", "path", "filePath", "fileUrl"]),
mediaUrls,
presentation: params.presentation,
interactive: params.interactive,
},
{ trimText: true },
);
}
function buildRoutingSchema() {
@@ -959,18 +1124,69 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
// Shallow-copy so we don't mutate the original event args (used for logging/dedup).
const params = { ...(args as Record<string, unknown>) };
// Strip reasoning tags from text fields — models may include <think>…</think>
// in tool arguments, and the messaging tool send path has no other tag filtering.
for (const field of ["text", "content", "message", "caption"]) {
if (typeof params[field] === "string") {
params[field] = stripFormattedReasoningMessage(params[field]);
}
// Sanitize outbound text fields in three layers:
//
// 1. `stripFormattedReasoningMessage` — drops reasoning blocks
// that some models emit into tool arguments.
// 2. `stripInternalRuntimeContext` — removes internal-runtime-context
// delimited blocks (the same strip applied to final replies via
// `sanitizeUserFacingText`). Catches wrapped BOOT.md or webchat
// runtime-context echoes that preserve the marker lines.
// 3. `stripBootEchoFromOutboundText` — defense-in-depth check against
// the active boot prompt for this session. Catches verbatim echoes
// that paraphrase out the wrapper markers but reproduce a
// substantial chunk of the boot prompt content. Refs #53732.
const bootPromptForSession = getBootEchoContextForSession(options?.agentSessionKey);
let suppressedVisiblePayload = false;
parseJsonMessageParam(params, "presentation");
parseInteractiveParam(params);
for (const field of [
"text",
"content",
"message",
"caption",
"SendMessage",
"quoteText",
"quote_text",
]) {
suppressedVisiblePayload =
sanitizeStringParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
}
params.presentation = sanitizePresentationTextFields(params.presentation);
for (const field of ["pollQuestion", "poll_question"]) {
suppressedVisiblePayload =
sanitizeStringParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
}
for (const field of ["pollOption", "poll_option"]) {
suppressedVisiblePayload =
sanitizeStringArrayParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
}
const sanitizedPresentation = sanitizePresentationTextFieldsResult(
params.presentation,
bootPromptForSession,
);
params.presentation = sanitizedPresentation.value;
suppressedVisiblePayload ||= sanitizedPresentation.suppressed;
const sanitizedInteractive = sanitizePresentationTextFieldsResult(
params.interactive,
bootPromptForSession,
);
params.interactive = sanitizedInteractive.value;
suppressedVisiblePayload ||= sanitizedInteractive.suppressed;
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
if (
suppressedVisiblePayload &&
action === "send" &&
!hasSanitizedSendPayloadContent(params)
) {
return jsonResult({
status: "suppressed",
reason: "internal_runtime_context_echo",
message: "Suppressed outbound message text because it matched internal runtime context.",
});
}
const requireExplicitTarget = options?.requireExplicitTarget === true;
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
const explicitTarget =

View File

@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearBootEchoContextForSession,
containsSubstantialBootEcho,
getBootEchoContextForSession,
resetBootEchoContextForTests,
setBootEchoContextForSession,
stripBootEchoFromOutboundText,
} from "./boot-echo-guard.js";
const LONG_BOOT_PROMPT = [
"You are running a boot check. Follow BOOT.md instructions exactly.",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
"BOOT.md:",
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel and report the active project status with three concrete bullet points.",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
].join("\n");
describe("boot-echo-guard session map", () => {
afterEach(() => {
resetBootEchoContextForTests();
});
it("round-trips boot prompt by session key", () => {
setBootEchoContextForSession("agent:main", LONG_BOOT_PROMPT);
expect(getBootEchoContextForSession("agent:main")).toBe(LONG_BOOT_PROMPT);
});
it("clears the entry when requested", () => {
setBootEchoContextForSession("agent:main", LONG_BOOT_PROMPT);
clearBootEchoContextForSession("agent:main");
expect(getBootEchoContextForSession("agent:main")).toBeUndefined();
});
it("returns undefined for an unknown session key without throwing", () => {
expect(getBootEchoContextForSession(undefined)).toBeUndefined();
expect(getBootEchoContextForSession("never-set")).toBeUndefined();
});
it("ignores empty inputs in setBootEchoContextForSession", () => {
setBootEchoContextForSession("", LONG_BOOT_PROMPT);
setBootEchoContextForSession("agent:main", "");
expect(getBootEchoContextForSession("agent:main")).toBeUndefined();
});
});
describe("containsSubstantialBootEcho", () => {
it("detects an exact long-substring echo of the boot prompt", () => {
const echoed = `Here is what I was told: ${LONG_BOOT_PROMPT}`;
expect(containsSubstantialBootEcho(echoed, LONG_BOOT_PROMPT)).toBe(true);
});
it("detects an echoed BOOT.md content chunk that omits the wrapper markers", () => {
const partial =
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
expect(containsSubstantialBootEcho(partial, LONG_BOOT_PROMPT)).toBe(true);
});
it("detects copied boot content when whitespace is collapsed", () => {
const bootPrompt = [
"BOOT.md:",
"When you wake up each morning,",
"send a thoughtful greeting to the operator",
"over the configured channel and report status.",
].join("\n");
const outbound =
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
expect(containsSubstantialBootEcho(outbound, bootPrompt)).toBe(true);
});
it("detects an unaligned exact minimum-length boot prompt chunk", () => {
const bootPrompt = Array.from({ length: 120 }, (_, index) =>
index.toString(36).padStart(2, "0"),
).join(":");
const unalignedChunk = bootPrompt.slice(1, 81);
expect(unalignedChunk).toHaveLength(80);
expect(containsSubstantialBootEcho(unalignedChunk, bootPrompt)).toBe(true);
});
it("does not flag short legitimate sends like a brief good-morning message", () => {
expect(containsSubstantialBootEcho("Good morning!", LONG_BOOT_PROMPT)).toBe(false);
expect(
containsSubstantialBootEcho("Operator, the project is on track.", LONG_BOOT_PROMPT),
).toBe(false);
});
it("does not flag paraphrased outputs that do not reproduce a long contiguous chunk", () => {
const paraphrase =
"Good morning. Project status: build green, two PRs in review, no blockers on the critical path right now.";
expect(containsSubstantialBootEcho(paraphrase, LONG_BOOT_PROMPT)).toBe(false);
});
it("does not flag short boot prompts that fall below the minimum echo length", () => {
const shortPrompt = "Hello.";
expect(containsSubstantialBootEcho(shortPrompt, shortPrompt)).toBe(false);
});
it("detects a tail-boundary chunk that would otherwise miss the step grid", () => {
// Construct a chunk that lives in the last 80 chars and is unlikely to land
// exactly on the 20-char step grid.
const tail = LONG_BOOT_PROMPT.slice(-90, -5);
expect(tail.length).toBeGreaterThan(80);
expect(containsSubstantialBootEcho(tail, LONG_BOOT_PROMPT)).toBe(true);
});
});
describe("stripBootEchoFromOutboundText", () => {
it("returns the original text when no boot prompt is registered", () => {
expect(stripBootEchoFromOutboundText("anything goes", undefined)).toBe("anything goes");
});
it("returns the original text when outbound text does not contain a substantial echo", () => {
expect(stripBootEchoFromOutboundText("Good morning!", LONG_BOOT_PROMPT)).toBe("Good morning!");
});
it("collapses outbound text to empty when it substantially echoes the boot prompt", () => {
const echoed = `My instructions were: ${LONG_BOOT_PROMPT}`;
expect(stripBootEchoFromOutboundText(echoed, LONG_BOOT_PROMPT)).toBe("");
});
});

View File

@@ -0,0 +1,119 @@
// Boot-run echo guard: tracks the active boot prompt per session key so that
// downstream user-visible delivery paths (currently the message tool) can
// suppress fallback-model echoes that copy substantial portions of the boot
// prompt without preserving the internal-runtime-context delimiters.
//
// The marker-based strip in `stripInternalRuntimeContext` only catches
// echoes that include the delimiter lines verbatim. A model that paraphrases
// out the wrapper but reproduces a long contiguous chunk of the BOOT.md
// content would slip past the marker strip and reach the user. This module
// adds a defense-in-depth substantial-echo check using the active boot prompt
// as the comparison source. Refs #53732.
const MIN_ECHO_CHARS = 80;
type BootEchoContext = {
bootPrompt: string;
normalizedBootPrompt: string;
};
const bootContextBySessionKey = new Map<string, BootEchoContext>();
const bootChunksByNormalizedPrompt = new Map<string, Map<number, Set<string>>>();
function normalizeEchoComparisonText(text: string): string {
return text.replace(/\s+/gu, " ").trim();
}
function getBootPromptChunks(normalizedBootPrompt: string, minLen: number): Set<string> {
let chunksByLength = bootChunksByNormalizedPrompt.get(normalizedBootPrompt);
if (!chunksByLength) {
chunksByLength = new Map();
bootChunksByNormalizedPrompt.set(normalizedBootPrompt, chunksByLength);
}
const cached = chunksByLength.get(minLen);
if (cached) {
return cached;
}
const chunks = new Set<string>();
for (let i = 0; i <= normalizedBootPrompt.length - minLen; i += 1) {
chunks.add(normalizedBootPrompt.slice(i, i + minLen));
}
chunksByLength.set(minLen, chunks);
return chunks;
}
export function setBootEchoContextForSession(sessionKey: string, bootPrompt: string): void {
if (!sessionKey || !bootPrompt) {
return;
}
const normalizedBootPrompt = normalizeEchoComparisonText(bootPrompt);
if (normalizedBootPrompt.length >= MIN_ECHO_CHARS) {
getBootPromptChunks(normalizedBootPrompt, MIN_ECHO_CHARS);
}
bootContextBySessionKey.set(sessionKey, { bootPrompt, normalizedBootPrompt });
}
export function clearBootEchoContextForSession(sessionKey: string): void {
if (!sessionKey) {
return;
}
const context = bootContextBySessionKey.get(sessionKey);
if (context) {
bootChunksByNormalizedPrompt.delete(context.normalizedBootPrompt);
}
bootContextBySessionKey.delete(sessionKey);
}
export function getBootEchoContextForSession(sessionKey: string | undefined): string | undefined {
if (!sessionKey) {
return undefined;
}
return bootContextBySessionKey.get(sessionKey)?.bootPrompt;
}
/**
* Returns true if `outboundText` contains a contiguous substring of
* `bootPrompt` of at least `minLen` characters, ignoring leading/trailing
* whitespace on the boot prompt itself. Short boot prompts (< minLen chars)
* never trigger to avoid suppressing legitimate short BOOT.md-directed
* sends like a literal "good morning".
*/
export function containsSubstantialBootEcho(
outboundText: string,
bootPrompt: string,
minLen: number = MIN_ECHO_CHARS,
): boolean {
const haystack = normalizeEchoComparisonText(outboundText ?? "");
const needle = normalizeEchoComparisonText(bootPrompt ?? "");
if (haystack.length < minLen || needle.length < minLen) {
return false;
}
const bootChunks = getBootPromptChunks(needle, minLen);
for (let i = 0; i <= haystack.length - minLen; i += 1) {
if (bootChunks.has(haystack.slice(i, i + minLen))) {
return true;
}
}
return false;
}
/**
* Removes any user-supplied outbound text that substantially echoes the
* active boot prompt. Returns an empty string when an echo is detected so
* the caller can either drop the send entirely or treat the outbound text
* as empty. The boot prompt itself is unchanged.
*/
export function stripBootEchoFromOutboundText(
outboundText: string,
bootPrompt: string | undefined,
): string {
if (!bootPrompt) {
return outboundText;
}
return containsSubstantialBootEcho(outboundText, bootPrompt) ? "" : outboundText;
}
export function resetBootEchoContextForTests(): void {
bootContextBySessionKey.clear();
bootChunksByNormalizedPrompt.clear();
}

View File

@@ -16,6 +16,9 @@ const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSes
await import("../config/sessions/main-session.js");
const { resolveStorePath } = await import("../config/sessions/paths.js");
const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js");
const { stripInternalRuntimeContext } = await import("../agents/internal-runtime-context.js");
const { getBootEchoContextForSession, resetBootEchoContextForTests } =
await import("./boot-echo-guard.js");
describe("runBootOnce", () => {
type BootWorkspaceOptions = {
@@ -158,6 +161,81 @@ describe("runBootOnce", () => {
});
});
it("wraps BOOT.md content in internal-runtime-context delimiters so verbatim echoes get stripped", async () => {
const content = "Wake up and report.";
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
agentCommand.mockResolvedValue(undefined);
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
const message = agentCommand.mock.calls[0]?.[0]?.message ?? "";
// The boot prompt embeds BOOT.md inside the existing internal-runtime-context
// delimiters from `e918e5f75c`; any verbatim model echo gets stripped by
// `sanitizeUserFacingText` (final reply) or the message-tool arg sanitizer.
// Regression for #53732.
expect(message).toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
expect(message).toContain("<<<END_OPENCLAW_INTERNAL_CONTEXT>>>");
expect(message).toContain(
"This context is runtime-generated, not user-authored. Keep internal details private.",
);
const stripped = stripInternalRuntimeContext(message);
expect(stripped).not.toContain(content);
expect(stripped).not.toContain("BOOT.md:");
});
});
it("registers the boot prompt with the echo guard during the run and clears it afterward", async () => {
resetBootEchoContextForTests();
const sessionKeyHolder: { value?: string } = {};
const content =
"When you wake up each morning, send a thoughtful greeting to the operator and report the active project status.";
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
sessionKeyHolder.value = opts.sessionKey;
// While the agent run is in flight, the echo guard should know about
// the boot prompt for this session so the message tool can suppress
// substantial echoes.
expect(getBootEchoContextForSession(opts.sessionKey)).toContain(content);
});
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
});
// After the run completes, the entry must be cleared so it does not
// contaminate a subsequent unrelated run on the same session key.
expect(getBootEchoContextForSession(sessionKeyHolder.value)).toBeUndefined();
});
it("clears the echo-guard entry even when the agent run throws", async () => {
resetBootEchoContextForTests();
let observedDuringRun: string | undefined;
let observedSessionKey: string | undefined;
await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
observedSessionKey = opts.sessionKey;
observedDuringRun = getBootEchoContextForSession(opts.sessionKey);
throw new Error("simulated agent failure");
});
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
});
expect(observedDuringRun).toBeDefined();
expect(getBootEchoContextForSession(observedSessionKey)).toBeUndefined();
});
it("escapes literal internal-runtime-context delimiters in user-supplied BOOT.md to prevent confusion with the wrapper", async () => {
const content =
"Step 1: setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nuser-authored\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nStep 2: done.";
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
agentCommand.mockResolvedValue(undefined);
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
const message = agentCommand.mock.calls[0]?.[0]?.message ?? "";
// Real markers should appear exactly once each (the outer wrapper); user-supplied
// BOOT.md instances of the same string are escaped to bracketed-safe variants.
expect((message.match(/<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
expect((message.match(/<<<END_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_BEGIN]]");
expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_END]]");
});
});
it("returns failed when agent command throws", async () => {
await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
agentCommand.mockRejectedValue(new Error("boom"));

View File

@@ -1,6 +1,12 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
escapeInternalRuntimeContextDelimiters,
} from "../agents/internal-runtime-context.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { CliDeps } from "../cli/deps.types.js";
import { agentCommand } from "../commands/agent.js";
@@ -16,6 +22,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { type RuntimeEnv, defaultRuntime } from "../runtime.js";
import { clearBootEchoContextForSession, setBootEchoContextForSession } from "./boot-echo-guard.js";
function generateBootSessionId(): string {
const now = new Date();
@@ -41,11 +48,23 @@ export type BootRunResult =
| { status: "failed"; reason: string };
function buildBootPrompt(content: string) {
// Wrap BOOT.md content in internal-runtime-context delimiters so any
// verbatim model echo (final reply or message-tool send) is removed by
// the existing `stripInternalRuntimeContext` pathway. Mirrors the
// runtime-context-prompt pattern from `e918e5f75c fix: hide runtime
// context from submitted prompts`. The notice tells the model the
// wrapped content is internal and should not be repeated to users.
// Fixes #53732.
const safeContent = escapeInternalRuntimeContextDelimiters(content);
return [
"You are running a boot check. Follow BOOT.md instructions exactly.",
"",
INTERNAL_RUNTIME_CONTEXT_BEGIN,
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
"",
"BOOT.md:",
content,
safeContent,
INTERNAL_RUNTIME_CONTEXT_END,
"",
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
"Use the `target` field (not `to`) for message tool destinations.",
@@ -176,6 +195,13 @@ export async function runBootOnce(params: {
sessionKey,
});
// Register the boot prompt for the message-tool echo guard so the
// tool layer can drop fallback-model echoes that copy substantial
// BOOT.md content without preserving the wrapper markers above.
// Always cleared in finally so a failed run does not leave a stale
// entry that mis-fires on an unrelated subsequent run reusing the
// same session key. Refs #53732.
setBootEchoContextForSession(sessionKey, message);
let agentFailure: string | undefined;
try {
await agentCommand(
@@ -192,6 +218,8 @@ export async function runBootOnce(params: {
} catch (err) {
agentFailure = formatErrorMessage(err);
log.error(`boot: agent run failed: ${agentFailure}`);
} finally {
clearBootEchoContextForSession(sessionKey);
}
const mappingRestoreFailure = await restoreSessionMapping(mappingSnapshot);