Medium severity4.3NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026
CVE-2026-35651
CVE-2026-35651
Description
OpenClaw versions 2026.2.13 through 2026.3.24 contain an ANSI escape sequence injection vulnerability in approval prompts that allows attackers to spoof terminal output. Untrusted tool metadata can carry ANSI control sequences into approval prompts and permission logs, enabling attackers to manipulate displayed information through malicious tool titles.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.2.13, < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
1464e2c10a5edACP: sanitize terminal tool titles (#55137)
7 files changed · +70 −20
src/acp/client.test.ts+36 −1 modified@@ -10,7 +10,11 @@ import { resolvePermissionRequest, shouldStripProviderAuthEnvVarsForAcpServer, } from "./client.js"; -import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; +import { + extractAttachmentsFromPrompt, + extractTextFromPrompt, + formatToolTitle, +} from "./event-mapper.js"; const envVar = (...parts: string[]) => parts.join("_"); @@ -625,6 +629,27 @@ describe("resolvePermissionRequest", () => { expect(prompt).not.toHaveBeenCalled(); expect(res).toEqual({ outcome: { outcome: "cancelled" } }); }); + + it("sanitizes tool titles before logging and prompting", async () => { + const prompt = vi.fn(async () => false); + const log = vi.fn(); + const res = await resolvePermissionRequest( + makePermissionRequest({ + toolCall: { + toolCallId: "tool-ansi", + title: 'exec: \u001b[2K\u001b[1A\u001b[2K[permission] Allow "safe"? (y/N) \nnext', + status: "pending", + }, + }), + { prompt, log }, + ); + + expect(prompt).toHaveBeenCalledWith("exec", 'exec: [permission] Allow "safe"? (y/N) \\nnext'); + expect(log).toHaveBeenCalledWith( + '\n[permission requested] exec: [permission] Allow "safe"? (y/N) \\nnext (exec) [other]', + ); + expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } }); + }); }); describe("acp event mapper", () => { @@ -750,4 +775,14 @@ describe("acp event mapper", () => { }, ]); }); + + it("escapes inline control characters in tool titles", () => { + const title = formatToolTitle("exec", { + command: '\u001b[2K\u001b[1A\u001b[2K[permission] Allow "safe"? (y/N) \nnext', + }); + + expect(title).toBe( + 'exec: command: \\x1b[2K\\x1b[1A\\x1b[2K[permission] Allow "safe"? (y/N) \\nnext', + ); + }); });
src/acp/client.ts+2 −1 modified@@ -24,6 +24,7 @@ import { omitEnvKeysCaseInsensitive, } from "../secrets/provider-env-vars.js"; import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]); const TRUSTED_SAFE_TOOL_ALIASES = new Set(["search"]); @@ -294,7 +295,7 @@ export async function resolvePermissionRequest( const prompt = deps.prompt ?? promptUserPermission; const cwd = deps.cwd ?? process.cwd(); const options = params.options ?? []; - const toolTitle = params.toolCall?.title ?? "tool"; + const toolTitle = sanitizeTerminalText(params.toolCall?.title ?? "tool"); const toolName = resolveToolNameForPermission(params); const toolKind = resolveToolKindForPermission(toolName);
src/acp/event-mapper.ts+3 −1 modified@@ -306,7 +306,9 @@ export function formatToolTitle( const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw; return `${key}: ${safe}`; }); - return `${base}: ${parts.join(", ")}`; + // Sanitize at the source so session updates and permission requests never + // inherit raw control bytes from untrusted tool arguments. + return escapeInlineControlChars(`${base}: ${parts.join(", ")}`); } export function inferToolKind(name?: string): ToolKind {
src/infra/restart.test.ts+20 −14 modified@@ -64,24 +64,27 @@ afterEach(() => { describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => { it("parses lsof output and filters non-openclaw/current processes", () => { + const gatewayPidA = process.pid + 1000; + const gatewayPidB = process.pid + 2000; + const foreignPid = process.pid + 3000; spawnSyncMock.mockReturnValue({ error: undefined, status: 0, stdout: [ `p${process.pid}`, "copenclaw", - "p4100", + `p${gatewayPidA}`, "copenclaw-gateway", - "p4200", + `p${foreignPid}`, "cnode", - "p4300", + `p${gatewayPidB}`, "cOpenClaw", ].join("\n"), }); const pids = findGatewayPidsOnPortSync(18789); - expect(pids).toEqual([4100, 4300]); + expect(pids).toEqual([gatewayPidA, gatewayPidB]); expect(spawnSyncMock).toHaveBeenCalledWith( "/usr/sbin/lsof", ["-nP", "-iTCP:18789", "-sTCP:LISTEN", "-Fpc"], @@ -103,11 +106,13 @@ describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", () => { it("kills stale gateway pids discovered on the gateway port", () => { + const stalePidA = process.pid + 1000; + const stalePidB = process.pid + 2000; spawnSyncMock .mockReturnValueOnce({ error: undefined, status: 0, - stdout: ["p6001", "copenclaw", "p6002", "copenclaw-gateway"].join("\n"), + stdout: [`p${stalePidA}`, "copenclaw", `p${stalePidB}`, "copenclaw-gateway"].join("\n"), }) .mockReturnValue({ error: undefined, @@ -118,20 +123,21 @@ describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", ( const killed = cleanStaleGatewayProcessesSync(); - expect(killed).toEqual([6001, 6002]); + expect(killed).toEqual([stalePidA, stalePidB]); expect(resolveGatewayPortMock).toHaveBeenCalledWith(undefined, process.env); - expect(killSpy).toHaveBeenCalledWith(6001, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(6002, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(6001, "SIGKILL"); - expect(killSpy).toHaveBeenCalledWith(6002, "SIGKILL"); + expect(killSpy).toHaveBeenCalledWith(stalePidA, "SIGTERM"); + expect(killSpy).toHaveBeenCalledWith(stalePidB, "SIGTERM"); + expect(killSpy).toHaveBeenCalledWith(stalePidA, "SIGKILL"); + expect(killSpy).toHaveBeenCalledWith(stalePidB, "SIGKILL"); }); it("uses explicit port override when provided", () => { + const stalePid = process.pid + 1000; spawnSyncMock .mockReturnValueOnce({ error: undefined, status: 0, - stdout: ["p7001", "copenclaw"].join("\n"), + stdout: [`p${stalePid}`, "copenclaw"].join("\n"), }) .mockReturnValue({ error: undefined, @@ -142,15 +148,15 @@ describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", ( const killed = cleanStaleGatewayProcessesSync(19999); - expect(killed).toEqual([7001]); + expect(killed).toEqual([stalePid]); expect(resolveGatewayPortMock).not.toHaveBeenCalled(); expect(spawnSyncMock).toHaveBeenCalledWith( "/usr/sbin/lsof", ["-nP", "-iTCP:19999", "-sTCP:LISTEN", "-Fpc"], expect.objectContaining({ encoding: "utf8", timeout: 2000 }), ); - expect(killSpy).toHaveBeenCalledWith(7001, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(7001, "SIGKILL"); + expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGTERM"); + expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGKILL"); }); it("returns empty when no stale listeners are found", () => {
src/terminal/ansi.test.ts+1 −0 modified@@ -4,6 +4,7 @@ import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi. describe("terminal ansi helpers", () => { it("strips ANSI and OSC8 sequences", () => { expect(stripAnsi("\u001B[31mred\u001B[0m")).toBe("red"); + expect(stripAnsi("\u001B[2K\u001B[1Ared")).toBe("red"); expect(stripAnsi("\u001B]8;;https://openclaw.ai\u001B\\link\u001B]8;;\u001B\\")).toBe("link"); });
src/terminal/ansi.ts+4 −3 modified@@ -1,16 +1,17 @@ -const ANSI_SGR_PATTERN = "\\x1b\\[[0-9;]*m"; +// Full CSI: ESC [ <params> <final byte> covers cursor movement, erase, and SGR. +const ANSI_CSI_PATTERN = "\\x1b\\[[\\x20-\\x3f]*[\\x40-\\x7e]"; // OSC-8 hyperlinks: ESC ] 8 ; ; url ST ... ESC ] 8 ; ; ST const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\"; -const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g"); +const ANSI_CSI_REGEX = new RegExp(ANSI_CSI_PATTERN, "g"); const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g"); const graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null; export function stripAnsi(input: string): string { - return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, ""); + return input.replace(OSC8_REGEX, "").replace(ANSI_CSI_REGEX, ""); } export function splitGraphemes(input: string): string[] {
src/terminal/safe-text.test.ts+4 −0 modified@@ -6,6 +6,10 @@ describe("sanitizeTerminalText", () => { expect(sanitizeTerminalText("a\u009bb\u0085c")).toBe("abc"); }); + it("strips cursor and erase ANSI sequences", () => { + expect(sanitizeTerminalText("\u001b[2K\u001b[1Arewritten")).toBe("rewritten"); + }); + it("escapes line controls while preserving printable text", () => { expect(sanitizeTerminalText("a\tb\nc\rd")).toBe("a\\tb\\nc\\rd"); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/464e2c10a5edceb380d815adb6ff56e1a4c50f60nvdPatchWEB
- github.com/advisories/GHSA-4hmj-39m8-jwc7ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-4hmj-39m8-jwc7nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35651ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-ansi-escape-sequence-injection-in-approval-promptnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.