Skip to content

Commit 8c8e400

Browse files
committed
fix(message): strip inbound metadata from outbound sends
1 parent 9e1e9aa commit 8c8e400

3 files changed

Lines changed: 139 additions & 37 deletions

File tree

src/agents/tools/message-tool.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,6 +2345,82 @@ describe("message tool internal-runtime-context sanitization", () => {
23452345
},
23462346
);
23472347

2348+
it("strips inbound metadata and delivery hints from outbound message text before dispatch (#89100)", async () => {
2349+
mockSendResult({ channel: "signal", to: "signal:group-1" });
2350+
2351+
const call = await executeSend({
2352+
action: {
2353+
target: "signal:group-1",
2354+
message: [
2355+
"Delivery: Final assistant text is not automatically delivered in this run. Use the `message` tool to send user-visible output.",
2356+
"",
2357+
"Conversation info (untrusted metadata):",
2358+
"```json",
2359+
'{"chat_id":"group:abc","sender_id":"+15551234567","is_group_chat":true}',
2360+
"```",
2361+
"",
2362+
"Sender (untrusted metadata):",
2363+
"```json",
2364+
'{"label":"Bob (+15551234567)","id":"+15551234567"}',
2365+
"```",
2366+
"",
2367+
"Visible reply only.",
2368+
].join("\n"),
2369+
},
2370+
});
2371+
2372+
expect(call?.params?.message).toBe("Visible reply only.");
2373+
expect(JSON.stringify(call?.params)).not.toContain("sender_id");
2374+
expect(JSON.stringify(call?.params)).not.toContain("+15551234567");
2375+
});
2376+
2377+
it.each([
2378+
{
2379+
name: "delivery hint only",
2380+
message:
2381+
"Delivery: Final assistant text is not automatically delivered in this run. Use the `message` tool to send user-visible output.",
2382+
},
2383+
{
2384+
name: "inbound metadata only",
2385+
message: [
2386+
"Conversation info (untrusted metadata):",
2387+
"```json",
2388+
'{"chat_id":"group:abc","sender_id":"+15551234567"}',
2389+
"```",
2390+
].join("\n"),
2391+
},
2392+
])("suppresses outbound sends that contain only $name (#89100)", async ({ message }) => {
2393+
const { call, result } = await executeSendWithResult({
2394+
action: {
2395+
target: "signal:group-1",
2396+
message,
2397+
},
2398+
});
2399+
2400+
expect(call).toBeUndefined();
2401+
expect(mocks.runMessageAction).not.toHaveBeenCalled();
2402+
expect(result.details).toMatchObject({
2403+
status: "suppressed",
2404+
reason: "inbound_metadata_echo",
2405+
});
2406+
expect(JSON.stringify(result)).not.toContain("sender_id");
2407+
expect(JSON.stringify(result)).not.toContain("+15551234567");
2408+
});
2409+
2410+
it("preserves legitimate outbound messages that start with timestamp-like text", async () => {
2411+
mockSendResult({ channel: "signal", to: "signal:group-1" });
2412+
2413+
const message = "[Wed 2026-03-11 23:51 PDT] Standup starts now";
2414+
const call = await executeSend({
2415+
action: {
2416+
target: "signal:group-1",
2417+
message,
2418+
},
2419+
});
2420+
2421+
expect(call?.params?.message).toBe(message);
2422+
});
2423+
23482424
it("strips internal-runtime-context blocks from poll creation text before dispatch", async () => {
23492425
mockSendResult({ channel: "telegram", to: "telegram:123" });
23502426

src/agents/tools/message-tool.ts

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
GATEWAY_CLIENT_MODES,
77
} from "../../../packages/gateway-protocol/src/client-info.js";
88
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
9+
import {
10+
hasInboundMetadataSentinel,
11+
stripInboundMetadata,
12+
} from "../../auto-reply/reply/strip-inbound-meta.js";
913
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
1014
import {
1115
getChannelPlugin,
@@ -96,75 +100,90 @@ function normalizeEscapedLineBreaksForVisibleText(text: string): string {
96100
return text.replace(/\\r\\n|\\n|\\r/g, "\n");
97101
}
98102

103+
type VisibleTextSuppressionReason = "internal_runtime_context_echo" | "inbound_metadata_echo";
104+
99105
function sanitizeUserVisibleToolTextResult(
100106
text: string,
101107
bootPrompt: string | undefined,
102-
): { text: string; suppressed: boolean } {
108+
): {
109+
text: string;
110+
suppressionReason?: VisibleTextSuppressionReason;
111+
} {
103112
const normalized = normalizeEscapedLineBreaksForVisibleText(text);
104113
const strippedReasoning = stripFormattedReasoningMessage(normalized);
105114
const strippedInternal = stripInternalRuntimeContext(strippedReasoning);
106115
const strippedBoot = stripBootEchoFromOutboundText(strippedInternal, bootPrompt);
116+
const strippedInbound = hasInboundMetadataSentinel(strippedBoot)
117+
? stripInboundMetadata(strippedBoot)
118+
: strippedBoot;
119+
const suppressionReason =
120+
strippedBoot.trim().length === 0 &&
121+
strippedReasoning.trim().length > 0 &&
122+
(strippedInternal !== strippedReasoning || strippedBoot !== strippedInternal)
123+
? "internal_runtime_context_echo"
124+
: strippedInbound.trim().length === 0 &&
125+
strippedBoot.trim().length > 0 &&
126+
strippedInbound !== strippedBoot
127+
? "inbound_metadata_echo"
128+
: undefined;
107129
return {
108-
text: strippedBoot,
109-
suppressed:
110-
strippedBoot.trim().length === 0 &&
111-
strippedReasoning.trim().length > 0 &&
112-
(strippedInternal !== strippedReasoning || strippedBoot !== strippedInternal),
130+
text: strippedInbound,
131+
...(suppressionReason ? { suppressionReason } : {}),
113132
};
114133
}
115134

116135
function sanitizeStringParam(
117136
params: Record<string, unknown>,
118137
field: string,
119138
bootPrompt: string | undefined,
120-
): boolean {
139+
): VisibleTextSuppressionReason | undefined {
121140
if (typeof params[field] !== "string") {
122-
return false;
141+
return undefined;
123142
}
124143
const sanitized = sanitizeUserVisibleToolTextResult(params[field], bootPrompt);
125144
params[field] = sanitized.text;
126-
return sanitized.suppressed;
145+
return sanitized.suppressionReason;
127146
}
128147

129148
function sanitizeStringArrayParam(
130149
params: Record<string, unknown>,
131150
field: string,
132151
bootPrompt: string | undefined,
133-
): boolean {
152+
): VisibleTextSuppressionReason | undefined {
134153
const value = params[field];
135154
if (typeof value === "string") {
136155
const sanitized = sanitizeUserVisibleToolTextResult(value, bootPrompt);
137156
params[field] = sanitized.text;
138-
return sanitized.suppressed;
157+
return sanitized.suppressionReason;
139158
}
140159
if (!Array.isArray(value)) {
141-
return false;
160+
return undefined;
142161
}
143-
let suppressed = false;
162+
let suppressionReason: VisibleTextSuppressionReason | undefined;
144163
params[field] = value.map((entry) => {
145164
if (typeof entry !== "string") {
146165
return entry;
147166
}
148167
const sanitized = sanitizeUserVisibleToolTextResult(entry, bootPrompt);
149-
suppressed ||= sanitized.suppressed;
168+
suppressionReason ??= sanitized.suppressionReason;
150169
return sanitized.text;
151170
});
152-
return suppressed;
171+
return suppressionReason;
153172
}
154173

155174
function sanitizePresentationTextFieldsResult(
156175
value: unknown,
157176
bootPrompt: string | undefined,
158-
): { value: unknown; suppressed: boolean } {
177+
): { value: unknown; suppressionReason?: VisibleTextSuppressionReason } {
159178
if (!value || typeof value !== "object" || Array.isArray(value)) {
160-
return { value, suppressed: false };
179+
return { value };
161180
}
162-
let suppressed = false;
181+
let suppressionReason: VisibleTextSuppressionReason | undefined;
163182
const presentation = { ...(value as Record<string, unknown>) };
164183
if (typeof presentation.title === "string") {
165184
const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt);
166185
presentation.title = sanitized.text;
167-
suppressed ||= sanitized.suppressed;
186+
suppressionReason ??= sanitized.suppressionReason;
168187
}
169188
if (Array.isArray(presentation.blocks)) {
170189
presentation.blocks = presentation.blocks.map((block) => {
@@ -176,7 +195,7 @@ function sanitizePresentationTextFieldsResult(
176195
if (typeof sanitizedBlock[field] === "string") {
177196
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock[field], bootPrompt);
178197
sanitizedBlock[field] = sanitized.text;
179-
suppressed ||= sanitized.suppressed;
198+
suppressionReason ??= sanitized.suppressionReason;
180199
}
181200
}
182201
if (Array.isArray(sanitizedBlock.buttons)) {
@@ -188,7 +207,7 @@ function sanitizePresentationTextFieldsResult(
188207
if (typeof sanitizedButton.label === "string") {
189208
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.label, bootPrompt);
190209
sanitizedButton.label = sanitized.text;
191-
suppressed ||= sanitized.suppressed;
210+
suppressionReason ??= sanitized.suppressionReason;
192211
}
193212
if (typeof sanitizedButton.url === "string") {
194213
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.url, bootPrompt);
@@ -197,7 +216,7 @@ function sanitizePresentationTextFieldsResult(
197216
} else {
198217
delete sanitizedButton.url;
199218
}
200-
suppressed ||= sanitized.suppressed;
219+
suppressionReason ??= sanitized.suppressionReason;
201220
}
202221
for (const webAppField of ["webApp", "web_app"]) {
203222
const webApp = sanitizedButton[webAppField];
@@ -215,7 +234,7 @@ function sanitizePresentationTextFieldsResult(
215234
} else {
216235
delete sanitizedButton[webAppField];
217236
}
218-
suppressed ||= sanitized.suppressed;
237+
suppressionReason ??= sanitized.suppressionReason;
219238
}
220239
return sanitizedButton;
221240
});
@@ -229,15 +248,15 @@ function sanitizePresentationTextFieldsResult(
229248
if (typeof sanitizedOption.label === "string") {
230249
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt);
231250
sanitizedOption.label = sanitized.text;
232-
suppressed ||= sanitized.suppressed;
251+
suppressionReason ??= sanitized.suppressionReason;
233252
}
234253
return sanitizedOption;
235254
});
236255
}
237256
return sanitizedBlock;
238257
});
239258
}
240-
return { value: presentation, suppressed };
259+
return { value: presentation, ...(suppressionReason ? { suppressionReason } : {}) };
241260
}
242261

243262
function readFirstStringParam(params: Record<string, unknown>, keys: readonly string[]): string {
@@ -1150,7 +1169,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
11501169
// that paraphrase out the wrapper markers but reproduce a
11511170
// substantial chunk of the boot prompt content. Refs #53732.
11521171
const bootPromptForSession = getBootEchoContextForSession(options?.agentSessionKey);
1153-
let suppressedVisiblePayload = false;
1172+
let suppressedVisiblePayloadReason: VisibleTextSuppressionReason | undefined;
11541173
parseJsonMessageParam(params, "presentation");
11551174
parseInteractiveParam(params);
11561175
for (const field of [
@@ -1162,42 +1181,45 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
11621181
"quoteText",
11631182
"quote_text",
11641183
]) {
1165-
suppressedVisiblePayload =
1166-
sanitizeStringParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
1184+
const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession);
1185+
suppressedVisiblePayloadReason ??= suppressionReason;
11671186
}
11681187
for (const field of ["pollQuestion", "poll_question"]) {
1169-
suppressedVisiblePayload =
1170-
sanitizeStringParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
1188+
const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession);
1189+
suppressedVisiblePayloadReason ??= suppressionReason;
11711190
}
11721191
for (const field of ["pollOption", "poll_option"]) {
1173-
suppressedVisiblePayload =
1174-
sanitizeStringArrayParam(params, field, bootPromptForSession) || suppressedVisiblePayload;
1192+
const suppressionReason = sanitizeStringArrayParam(params, field, bootPromptForSession);
1193+
suppressedVisiblePayloadReason ??= suppressionReason;
11751194
}
11761195
const sanitizedPresentation = sanitizePresentationTextFieldsResult(
11771196
params.presentation,
11781197
bootPromptForSession,
11791198
);
11801199
params.presentation = sanitizedPresentation.value;
1181-
suppressedVisiblePayload ||= sanitizedPresentation.suppressed;
1200+
suppressedVisiblePayloadReason ??= sanitizedPresentation.suppressionReason;
11821201
const sanitizedInteractive = sanitizePresentationTextFieldsResult(
11831202
params.interactive,
11841203
bootPromptForSession,
11851204
);
11861205
params.interactive = sanitizedInteractive.value;
1187-
suppressedVisiblePayload ||= sanitizedInteractive.suppressed;
1206+
suppressedVisiblePayloadReason ??= sanitizedInteractive.suppressionReason;
11881207

11891208
const action = readStringParam(params, "action", {
11901209
required: true,
11911210
}) as ChannelMessageActionName;
11921211
if (
1193-
suppressedVisiblePayload &&
1212+
suppressedVisiblePayloadReason &&
11941213
action === "send" &&
11951214
!hasSanitizedSendPayloadContent(params)
11961215
) {
11971216
return jsonResult({
11981217
status: "suppressed",
1199-
reason: "internal_runtime_context_echo",
1200-
message: "Suppressed outbound message text because it matched internal runtime context.",
1218+
reason: suppressedVisiblePayloadReason,
1219+
message:
1220+
suppressedVisiblePayloadReason === "inbound_metadata_echo"
1221+
? "Suppressed outbound message text because it matched inbound runtime metadata."
1222+
: "Suppressed outbound message text because it matched internal runtime context.",
12011223
});
12021224
}
12031225
const requireExplicitTarget = options?.requireExplicitTarget === true;

src/auto-reply/reply/strip-inbound-meta.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ const SENTINEL_FAST_RE = new RegExp(
4646
.join("|"),
4747
);
4848

49+
export function hasInboundMetadataSentinel(text: string): boolean {
50+
return Boolean(text && SENTINEL_FAST_RE.test(text));
51+
}
52+
4953
function isMessageToolDeliveryHintLine(line: string): boolean {
5054
const trimmed = line.trim();
5155
return MESSAGE_TOOL_DELIVERY_HINTS.some((hint) => hint === trimmed);

0 commit comments

Comments
 (0)