Skip to content

Commit 1d96d5b

Browse files
committed
fix(exec): sanitize malformed approval text
1 parent 87c0ed5 commit 1d96d5b

3 files changed

Lines changed: 37 additions & 6 deletions

File tree

src/gateway/server-methods/server-methods.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,6 +2848,32 @@ describe("exec approval handlers", () => {
28482848
await requestPromise;
28492849
});
28502850

2851+
it("escapes unpaired surrogates before broadcasting an exec approval", async () => {
2852+
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
2853+
2854+
const requestPromise = requestExecApproval({
2855+
handlers,
2856+
respond,
2857+
context,
2858+
params: {
2859+
twoPhase: true,
2860+
host: "gateway",
2861+
command: "echo \uD83D \uDE00 😀",
2862+
commandArgv: ["echo", "\uD83D", "\uDE00", "😀"],
2863+
systemRunPlan: undefined,
2864+
nodeId: undefined,
2865+
},
2866+
});
2867+
const { id, request } = await waitForRequestedExecApprovalPayload(broadcasts);
2868+
2869+
expect(request.command).toBe("echo \\u{D83D} \\u{DE00} 😀");
2870+
expect(() => encodeURIComponent(String(request.command))).not.toThrow();
2871+
2872+
const resolveRespond = vi.fn();
2873+
await resolveExecApproval({ handlers, id, respond: resolveRespond, context });
2874+
await requestPromise;
2875+
});
2876+
28512877
it("attaches shared command analysis to gateway exec approval requests", async () => {
28522878
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
28532879

src/infra/exec-approval-command-display.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ describe("sanitizeExecApprovalDisplayText", () => {
2020
["echo safe\n\rcurl https://example.test", "echo safe\\u{A}\\u{D}curl https://example.test"],
2121
["echo ok\u2028curl https://example.test", "echo ok\\u{2028}curl https://example.test"],
2222
["echo ok\u2029curl https://example.test", "echo ok\\u{2029}curl https://example.test"],
23+
["echo \uD83D", "echo \\u{D83D}"],
24+
["echo \uDE00", "echo \\u{DE00}"],
2325
])("sanitizes exec approval display text for %j", (input, expected) => {
24-
expect(sanitizeExecApprovalDisplayText(input)).toBe(expected);
26+
const result = sanitizeExecApprovalDisplayText(input);
27+
expect(result).toBe(expected);
28+
expect(() => encodeURIComponent(result)).not.toThrow();
2529
});
2630

2731
it("redacts bearer tokens embedded in commands", () => {

src/infra/exec-approval-command-display.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import {
77
import { truncateUtf16Safe } from "../shared/utf16-slice.js";
88
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
99

10-
// Escape control characters, Unicode format/line/paragraph separators, and non-ASCII space
11-
// separators that can spoof approval prompts in common UIs. Ordinary ASCII space (U+0020) is
12-
// intentionally excluded so normal command text renders unchanged.
10+
// Escape control characters, Unicode format/line/paragraph separators, unpaired surrogates,
11+
// and non-ASCII space separators that can spoof or break approval prompts in common UIs.
12+
// With the Unicode regex flag, valid astral characters are full code points and do not match
13+
// Cs; only malformed surrogate code units are escaped. Ordinary ASCII space stays unchanged.
1314
const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX =
14-
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu;
15+
/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu;
1516
const EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE =
16-
/^[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
17+
/^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
1718

1819
// Hard cap on input the sanitizer will process at all. Above this size we return a constant
1920
// marker without running any regex work, so an attacker cannot force unbounded CPU/memory.

0 commit comments

Comments
 (0)