VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.2.13, < 2026.3.282026.3.28

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: >=2026.2.13,<2026.3.25

Patches

1
464e2c10a5ed

ACP: sanitize terminal tool titles (#55137)

https://github.com/openclaw/openclawJacob TomlinsonMar 26, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.