Skip to content

Commit e8ab8a4

Browse files
committed
fix(agents): don't echo raw rejected attachment name in diagnostic + test
Besides detecting C1 controls (0x80-0x9f) in attachment names, the rejection diagnostic (attachments_invalid_name) interpolated the raw untrusted name, re-introducing the same control bytes (e.g. U+009B CSI) into an error string that can reach a terminal. Escape C0/DEL/C1 to visible \xNN and cap the name by code point before embedding it. Add a regression test driving the real resolveAcpSessionsSpawnImageAttachments path: a C1-bearing name is rejected with the byte escaped (never raw) across the full 0x80-0x9f range, and an ordinary image name still succeeds. Signed-off-by: lsr911 <[email protected]>
1 parent 00d7401 commit e8ab8a4

2 files changed

Lines changed: 95 additions & 3 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Regression tests for attachment-name validation, focused on C1 control
2+
// rejection and on not leaking raw control bytes back into the diagnostic.
3+
import { describe, expect, it } from "vitest";
4+
import type { OpenClawConfig } from "../config/types.openclaw.js";
5+
import { resolveAcpSessionsSpawnImageAttachments } from "./subagent-attachments.js";
6+
7+
const CONFIG = {
8+
tools: { sessions_spawn: { attachments: { enabled: true } } },
9+
} as unknown as OpenClawConfig;
10+
11+
// base64 for "hello" (5 bytes), well under the default per-file limit.
12+
const HELLO_B64 = "aGVsbG8=";
13+
14+
function errorOf(result: ReturnType<typeof resolveAcpSessionsSpawnImageAttachments>): string {
15+
return result && result.status === "error" ? result.error : "";
16+
}
17+
18+
describe("resolveAcpSessionsSpawnImageAttachments name validation", () => {
19+
it("rejects a C1-bearing name and escapes it in the diagnostic", () => {
20+
// U+009B is the C1 CSI introducer (alternative ANSI escape prefix, ESC [).
21+
const CSI = String.fromCharCode(0x9b);
22+
const result = resolveAcpSessionsSpawnImageAttachments({
23+
config: CONFIG,
24+
attachments: [
25+
{
26+
name: `bad${CSI}name.png`,
27+
content: HELLO_B64,
28+
encoding: "base64",
29+
mimeType: "image/png",
30+
},
31+
],
32+
});
33+
34+
expect(result?.status).toBe("error");
35+
const error = errorOf(result);
36+
expect(error).toContain("attachments_invalid_name");
37+
// The rejected name is echoed with the C1 byte escaped, not raw.
38+
expect(error).toContain("\\x9b");
39+
expect(error).not.toContain(CSI);
40+
});
41+
42+
it("rejects every byte across the full C1 range 0x80-0x9f", () => {
43+
for (let code = 0x80; code <= 0x9f; code += 1) {
44+
const result = resolveAcpSessionsSpawnImageAttachments({
45+
config: CONFIG,
46+
attachments: [
47+
{
48+
name: `n${String.fromCharCode(code)}m.png`,
49+
content: HELLO_B64,
50+
encoding: "base64",
51+
mimeType: "image/png",
52+
},
53+
],
54+
});
55+
expect(result?.status).toBe("error");
56+
expect(errorOf(result)).toContain("attachments_invalid_name");
57+
// No raw C1 byte survives into the returned diagnostic.
58+
expect(errorOf(result)).not.toContain(String.fromCharCode(code));
59+
}
60+
});
61+
62+
it("accepts an ordinary image attachment name", () => {
63+
const result = resolveAcpSessionsSpawnImageAttachments({
64+
config: CONFIG,
65+
attachments: [
66+
{
67+
name: "photo.png",
68+
content: HELLO_B64,
69+
encoding: "base64",
70+
mimeType: "image/png",
71+
},
72+
],
73+
});
74+
expect(result?.status).toBe("ok");
75+
});
76+
});

src/agents/subagent-attachments.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,23 +152,39 @@ function failAttachment(error: string): never {
152152
throw new Error(error);
153153
}
154154

155+
// Renders a rejected, untrusted attachment name for diagnostics without
156+
// leaking raw control bytes: C0 (0x00-0x1f), DEL (0x7f), and C1 (0x80-0x9f)
157+
// are escaped to visible \xNN sequences (C1 includes the U+009B CSI
158+
// introducer), and the result is capped by code point so it stays single-line
159+
// and bounded even for adversarial names.
160+
function describeRejectedName(name: string): string {
161+
const chars = Array.from(name, (char) => {
162+
const code = char.codePointAt(0) ?? 0;
163+
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
164+
return `\\x${code.toString(16).padStart(2, "0")}`;
165+
}
166+
return char;
167+
});
168+
return chars.length > 80 ? `${chars.slice(0, 80).join("")}...` : chars.join("");
169+
}
170+
155171
function validateAttachmentName(name: string): void {
156172
if (!name) {
157173
failAttachment("attachments_invalid_name (empty)");
158174
}
159175
if (name.includes("/") || name.includes("\\") || name.includes("\u0000")) {
160-
failAttachment(`attachments_invalid_name (${name})`);
176+
failAttachment(`attachments_invalid_name (${describeRejectedName(name)})`);
161177
}
162178
if (
163179
Array.from(name).some((char) => {
164180
const code = char.codePointAt(0) ?? 0;
165181
return code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
166182
})
167183
) {
168-
failAttachment(`attachments_invalid_name (${name})`);
184+
failAttachment(`attachments_invalid_name (${describeRejectedName(name)})`);
169185
}
170186
if (name === "." || name === ".." || name === ".manifest.json") {
171-
failAttachment(`attachments_invalid_name (${name})`);
187+
failAttachment(`attachments_invalid_name (${describeRejectedName(name)})`);
172188
}
173189
}
174190

0 commit comments

Comments
 (0)