VYPR
Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026

OpenClaw < 2026.2.23 - ACP Permission Auto-Approval Bypass via Untrusted Tool Metadata

CVE-2026-32898

Description

OpenClaw versions prior to 2026.2.23 contain an authorization bypass vulnerability in the ACP client that auto-approves tool calls based on untrusted toolCall.kind metadata and permissive name heuristics. Attackers can bypass interactive approval prompts for read-class operations by spoofing tool metadata or using non-core read-like names to reach auto-approve paths.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.232026.2.23

Affected products

1

Patches

2
63dcd28ae0be

fix(acp): harden permission tool-name validation

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
2 files changed · +129 2
  • src/acp/client.test.ts+122 0 modified
    @@ -87,6 +87,75 @@ describe("resolvePermissionRequest", () => {
         expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
       });
     
    +  it("auto-approves read when rawInput path resolves inside cwd", async () => {
    +    const prompt = vi.fn(async () => true);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-read-inside-cwd",
    +          title: "read: ignored-by-raw-input",
    +          status: "pending",
    +          rawInput: { path: "docs/security.md" },
    +        },
    +      }),
    +      { prompt, log: () => {}, cwd: "/tmp/openclaw-acp-cwd" },
    +    );
    +    expect(prompt).not.toHaveBeenCalled();
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
    +  });
    +
    +  it("auto-approves read when rawInput file URL resolves inside cwd", async () => {
    +    const prompt = vi.fn(async () => true);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-read-inside-cwd-file-url",
    +          title: "read: ignored-by-raw-input",
    +          status: "pending",
    +          rawInput: { path: "file:///tmp/openclaw-acp-cwd/docs/security.md" },
    +        },
    +      }),
    +      { prompt, log: () => {}, cwd: "/tmp/openclaw-acp-cwd" },
    +    );
    +    expect(prompt).not.toHaveBeenCalled();
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
    +  });
    +
    +  it("prompts for read when rawInput path escapes cwd via traversal", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-read-escape-cwd",
    +          title: "read: ignored-by-raw-input",
    +          status: "pending",
    +          rawInput: { path: "../.ssh/id_rsa" },
    +        },
    +      }),
    +      { prompt, log: () => {}, cwd: "/tmp/openclaw-acp-cwd/workspace" },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith("read", "read: ignored-by-raw-input");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
    +  it("prompts for read when scoped path is missing", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-read-no-path",
    +          title: "read",
    +          status: "pending",
    +        },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith("read", "read");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
       it("prompts for non-core read-like tool names", async () => {
         const prompt = vi.fn(async () => false);
         const res = await resolvePermissionRequest(
    @@ -176,6 +245,59 @@ describe("resolvePermissionRequest", () => {
         expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
       });
     
    +  it("prompts when metadata tool name contains invalid characters", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-invalid-meta",
    +          title: "read: src/index.ts",
    +          status: "pending",
    +          _meta: { toolName: "read.*" },
    +        },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith(undefined, "read: src/index.ts");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
    +  it("prompts when raw input tool name exceeds max length", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-long-raw",
    +          title: "read: src/index.ts",
    +          status: "pending",
    +          rawInput: { toolName: "r".repeat(129) },
    +        },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith(undefined, "read: src/index.ts");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
    +  it("prompts when title tool name contains non-allowed characters", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-bad-title-name",
    +          title: "read🚀: src/index.ts",
    +          status: "pending",
    +        },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith(undefined, "read🚀: src/index.ts");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
       it("returns cancelled when no permission options are present", async () => {
         const prompt = vi.fn(async () => true);
         const res = await resolvePermissionRequest(makePermissionRequest({ options: [] }), {
    
  • src/acp/client.ts+7 2 modified
    @@ -20,6 +20,8 @@ import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
     const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
     const TRUSTED_SAFE_TOOL_ALIASES = new Set(["search"]);
     const READ_TOOL_PATH_KEYS = ["path", "file_path", "filePath"];
    +const TOOL_NAME_MAX_LENGTH = 128;
    +const TOOL_NAME_PATTERN = /^[a-z0-9._-]+$/;
     const TOOL_KIND_BY_ID = new Map<string, string>([
       ["read", "read"],
       ["search", "search"],
    @@ -59,7 +61,10 @@ function readFirstStringValue(
     
     function normalizeToolName(value: string): string | undefined {
       const normalized = value.trim().toLowerCase();
    -  if (!normalized) {
    +  if (!normalized || normalized.length > TOOL_NAME_MAX_LENGTH) {
    +    return undefined;
    +  }
    +  if (!TOOL_NAME_PATTERN.test(normalized)) {
         return undefined;
       }
       return normalized;
    @@ -70,7 +75,7 @@ function parseToolNameFromTitle(title: string | undefined | null): string | unde
         return undefined;
       }
       const head = title.split(":", 1)[0]?.trim();
    -  if (!head || !/^[a-zA-Z0-9._-]+$/.test(head)) {
    +  if (!head) {
         return undefined;
       }
       return normalizeToolName(head);
    
12cc754332f9

fix(acp): harden permission auto-approval policy

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
5 files changed · +178 48
  • CHANGELOG.md+1 0 modified
    @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
     - Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
     - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
     - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc.
    +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt.
     - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x.
     - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc.
     - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise.
    
  • docs/cli/acp.md+7 0 modified
    @@ -49,6 +49,13 @@ openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/
     openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001
     ```
     
    +Permission model (client debug mode):
    +
    +- Auto-approval is allowlist-based and only applies to trusted core tool IDs.
    +- `read` auto-approval is scoped to the current working directory (`--cwd` when set).
    +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval.
    +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source).
    +
     ## How to use this
     
     Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
    
  • src/acp/client.test.ts+44 0 modified
    @@ -74,6 +74,32 @@ describe("resolvePermissionRequest", () => {
         expect(prompt).not.toHaveBeenCalled();
       });
     
    +  it("prompts for read outside cwd scope", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: { toolCallId: "tool-r", title: "read: ~/.ssh/id_rsa", status: "pending" },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith("read", "read: ~/.ssh/id_rsa");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
    +  it("prompts for non-core read-like tool names", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: { toolCallId: "tool-fr", title: "fs_read: ~/.ssh/id_rsa", status: "pending" },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith("fs_read", "fs_read: ~/.ssh/id_rsa");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
       it.each([
         {
           caseName: "prompts for fetch even when tool name is known",
    @@ -100,6 +126,24 @@ describe("resolvePermissionRequest", () => {
         expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
       });
     
    +  it("prompts when kind is spoofed as read", async () => {
    +    const prompt = vi.fn(async () => false);
    +    const res = await resolvePermissionRequest(
    +      makePermissionRequest({
    +        toolCall: {
    +          toolCallId: "tool-kind-spoof",
    +          title: "thread: reply",
    +          status: "pending",
    +          kind: "read",
    +        },
    +      }),
    +      { prompt, log: () => {} },
    +    );
    +    expect(prompt).toHaveBeenCalledTimes(1);
    +    expect(prompt).toHaveBeenCalledWith("thread", "thread: reply");
    +    expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
    +  });
    +
       it("uses allow_always and reject_always when once options are absent", async () => {
         const options: RequestPermissionRequest["options"] = [
           { kind: "allow_always", name: "Always allow", optionId: "allow-always" },
    
  • src/acp/client.ts+122 48 modified
    @@ -1,5 +1,6 @@
     import { spawn, type ChildProcess } from "node:child_process";
     import fs from "node:fs";
    +import { homedir } from "node:os";
     import path from "node:path";
     import * as readline from "node:readline";
     import { Readable, Writable } from "node:stream";
    @@ -12,16 +13,26 @@ import {
       type RequestPermissionResponse,
       type SessionNotification,
     } from "@agentclientprotocol/sdk";
    +import { isKnownCoreToolId } from "../agents/tool-catalog.js";
     import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
     import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
     
    -const SAFE_AUTO_APPROVE_KINDS = new Set(["read", "search"]);
    +const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
    +const TRUSTED_SAFE_TOOL_ALIASES = new Set(["search"]);
    +const READ_TOOL_PATH_KEYS = ["path", "file_path", "filePath"];
    +const TOOL_KIND_BY_ID = new Map<string, string>([
    +  ["read", "read"],
    +  ["search", "search"],
    +  ["web_search", "search"],
    +  ["memory_search", "search"],
    +]);
     
     type PermissionOption = RequestPermissionRequest["options"][number];
     
     type PermissionResolverDeps = {
       prompt?: (toolName: string | undefined, toolTitle?: string) => Promise<boolean>;
       log?: (line: string) => void;
    +  cwd?: string;
     };
     
     function asRecord(value: unknown): Record<string, unknown> | undefined {
    @@ -65,63 +76,125 @@ function parseToolNameFromTitle(title: string | undefined | null): string | unde
       return normalizeToolName(head);
     }
     
    -function resolveToolKindForPermission(
    -  params: RequestPermissionRequest,
    -  toolName: string | undefined,
    -): string | undefined {
    -  const toolCall = params.toolCall as unknown as { kind?: unknown; title?: unknown } | undefined;
    -  const kindRaw = typeof toolCall?.kind === "string" ? toolCall.kind.trim().toLowerCase() : "";
    -  if (kindRaw) {
    -    return kindRaw;
    -  }
    -  const name =
    -    toolName ??
    -    parseToolNameFromTitle(typeof toolCall?.title === "string" ? toolCall.title : undefined);
    -  if (!name) {
    +function resolveToolKindForPermission(toolName: string | undefined): string | undefined {
    +  if (!toolName) {
         return undefined;
       }
    -  const normalized = name.toLowerCase();
    +  return TOOL_KIND_BY_ID.get(toolName) ?? "other";
    +}
     
    -  const hasToken = (token: string) => {
    -    // Tool names tend to be snake_case. Avoid substring heuristics (ex: "thread" contains "read").
    -    const re = new RegExp(`(?:^|[._-])${token}(?:$|[._-])`);
    -    return re.test(normalized);
    -  };
    +function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
    +  const toolCall = params.toolCall;
    +  const toolMeta = asRecord(toolCall?._meta);
    +  const rawInput = asRecord(toolCall?.rawInput);
    +
    +  const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
    +  const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
    +  const fromTitle = parseToolNameFromTitle(toolCall?.title);
    +  return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
    +}
     
    -  // Prefer a conservative classifier: only classify safe kinds when confident.
    -  if (normalized === "read" || hasToken("read")) {
    -    return "read";
    +function extractPathFromToolTitle(
    +  toolTitle: string | undefined,
    +  toolName: string | undefined,
    +): string | undefined {
    +  if (!toolTitle) {
    +    return undefined;
       }
    -  if (normalized === "search" || hasToken("search") || hasToken("find")) {
    -    return "search";
    +  const separator = toolTitle.indexOf(":");
    +  if (separator < 0) {
    +    return undefined;
       }
    -  if (normalized.includes("fetch") || normalized.includes("http")) {
    -    return "fetch";
    +  const tail = toolTitle.slice(separator + 1).trim();
    +  if (!tail) {
    +    return undefined;
    +  }
    +  const keyedMatch = tail.match(/(?:^|,\s*)(?:path|file_path|filePath)\s*:\s*([^,]+)/);
    +  if (keyedMatch?.[1]) {
    +    return keyedMatch[1].trim();
       }
    -  if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) {
    -    return "edit";
    +  if (toolName === "read") {
    +    return tail;
       }
    -  if (normalized.includes("delete") || normalized.includes("remove")) {
    -    return "delete";
    +  return undefined;
    +}
    +
    +function resolveToolPathCandidate(
    +  params: RequestPermissionRequest,
    +  toolName: string | undefined,
    +  toolTitle: string | undefined,
    +): string | undefined {
    +  const rawInput = asRecord(params.toolCall?.rawInput);
    +  const fromRawInput = readFirstStringValue(rawInput, READ_TOOL_PATH_KEYS);
    +  const fromTitle = extractPathFromToolTitle(toolTitle, toolName);
    +  return fromRawInput ?? fromTitle;
    +}
    +
    +function resolveAbsoluteScopedPath(value: string, cwd: string): string | undefined {
    +  let candidate = value.trim();
    +  if (!candidate) {
    +    return undefined;
       }
    -  if (normalized.includes("move") || normalized.includes("rename")) {
    -    return "move";
    +  if (candidate.startsWith("file://")) {
    +    try {
    +      const parsed = new URL(candidate);
    +      candidate = decodeURIComponent(parsed.pathname || "");
    +    } catch {
    +      return undefined;
    +    }
       }
    -  if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
    -    return "execute";
    +  if (candidate === "~") {
    +    candidate = homedir();
    +  } else if (candidate.startsWith("~/")) {
    +    candidate = path.join(homedir(), candidate.slice(2));
       }
    -  return "other";
    +  const absolute = path.isAbsolute(candidate)
    +    ? path.normalize(candidate)
    +    : path.resolve(cwd, candidate);
    +  return absolute;
     }
     
    -function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
    -  const toolCall = params.toolCall;
    -  const toolMeta = asRecord(toolCall?._meta);
    -  const rawInput = asRecord(toolCall?.rawInput);
    +function isPathWithinRoot(candidatePath: string, root: string): boolean {
    +  const relative = path.relative(root, candidatePath);
    +  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
    +}
     
    -  const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
    -  const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
    -  const fromTitle = parseToolNameFromTitle(toolCall?.title);
    -  return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
    +function isReadToolCallScopedToCwd(
    +  params: RequestPermissionRequest,
    +  toolName: string | undefined,
    +  toolTitle: string | undefined,
    +  cwd: string,
    +): boolean {
    +  if (toolName !== "read") {
    +    return false;
    +  }
    +  const rawPath = resolveToolPathCandidate(params, toolName, toolTitle);
    +  if (!rawPath) {
    +    return false;
    +  }
    +  const absolutePath = resolveAbsoluteScopedPath(rawPath, cwd);
    +  if (!absolutePath) {
    +    return false;
    +  }
    +  return isPathWithinRoot(absolutePath, path.resolve(cwd));
    +}
    +
    +function shouldAutoApproveToolCall(
    +  params: RequestPermissionRequest,
    +  toolName: string | undefined,
    +  toolTitle: string | undefined,
    +  cwd: string,
    +): boolean {
    +  const isTrustedToolId =
    +    typeof toolName === "string" &&
    +    (isKnownCoreToolId(toolName) || TRUSTED_SAFE_TOOL_ALIASES.has(toolName));
    +  if (!toolName || !isTrustedToolId || !SAFE_AUTO_APPROVE_TOOL_IDS.has(toolName)) {
    +    return false;
    +  }
    +  if (toolName === "read") {
    +    return isReadToolCallScopedToCwd(params, toolName, toolTitle, cwd);
    +  }
    +  return true;
     }
     
     function pickOption(
    @@ -191,10 +264,11 @@ export async function resolvePermissionRequest(
     ): Promise<RequestPermissionResponse> {
       const log = deps.log ?? ((line: string) => console.error(line));
       const prompt = deps.prompt ?? promptUserPermission;
    +  const cwd = deps.cwd ?? process.cwd();
       const options = params.options ?? [];
       const toolTitle = params.toolCall?.title ?? "tool";
       const toolName = resolveToolNameForPermission(params);
    -  const toolKind = resolveToolKindForPermission(params, toolName);
    +  const toolKind = resolveToolKindForPermission(toolName);
     
       if (options.length === 0) {
         log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
    @@ -203,8 +277,8 @@ export async function resolvePermissionRequest(
     
       const allowOption = pickOption(options, ["allow_once", "allow_always"]);
       const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
    -  const isSafeKind = Boolean(toolKind && SAFE_AUTO_APPROVE_KINDS.has(toolKind));
    -  const promptRequired = !toolName || !isSafeKind || DANGEROUS_ACP_TOOLS.has(toolName);
    +  const autoApproveAllowed = shouldAutoApproveToolCall(params, toolName, toolTitle, cwd);
    +  const promptRequired = !toolName || !autoApproveAllowed || DANGEROUS_ACP_TOOLS.has(toolName);
     
       if (!promptRequired) {
         const option = allowOption ?? options[0];
    @@ -350,7 +424,7 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
             printSessionUpdate(params);
           },
           requestPermission: async (params: RequestPermissionRequest) => {
    -        return resolvePermissionRequest(params);
    +        return resolvePermissionRequest(params, { cwd });
           },
         }),
         stream,
    
  • src/agents/tool-catalog.ts+4 0 modified
    @@ -320,3 +320,7 @@ export function resolveCoreToolProfiles(toolId: string): ToolProfileId[] {
       }
       return [...tool.profiles];
     }
    +
    +export function isKnownCoreToolId(toolId: string): boolean {
    +  return CORE_TOOL_BY_ID.has(toolId);
    +}
    

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

7

News mentions

0

No linked articles in our index yet.