VYPR
Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026

OpenClaw < 2026.2.23 - Sandbox Boundary Bypass via Image Tool workspaceOnly Bypass

CVE-2026-32002

Description

OpenClaw versions prior to 2026.2.23 contain a sandbox bypass vulnerability in the sandboxed image tool that fails to enforce tools.fs.workspaceOnly restrictions on mounted sandbox paths, allowing attackers to read out-of-workspace files. Attackers can load restricted mounted images and exfiltrate them through vision model provider requests to bypass sandbox confidentiality controls.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.232026.2.23

Affected products

1

Patches

1
dd9d9c1c609d

fix(security): enforce workspaceOnly for sandbox image tool

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
5 files changed · +129 2
  • CHANGELOG.md+1 0 modified
    @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
     - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
     - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
     - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
    +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
     
     ## 2026.2.23 (Unreleased)
     
    
  • src/agents/openclaw-tools.ts+2 0 modified
    @@ -41,6 +41,7 @@ export function createOpenClawTools(options?: {
       agentDir?: string;
       sandboxRoot?: string;
       sandboxFsBridge?: SandboxFsBridge;
    +  workspaceOnly?: boolean;
       workspaceDir?: string;
       sandboxed?: boolean;
       config?: OpenClawConfig;
    @@ -78,6 +79,7 @@ export function createOpenClawTools(options?: {
               options?.sandboxRoot && options?.sandboxFsBridge
                 ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
                 : undefined,
    +        workspaceOnly: options?.workspaceOnly,
             modelHasVision: options?.modelHasVision,
           })
         : null;
    
  • src/agents/pi-tools.ts+1 0 modified
    @@ -458,6 +458,7 @@ export function createOpenClawCodingTools(options?: {
           agentDir: options?.agentDir,
           sandboxRoot,
           sandboxFsBridge,
    +      workspaceOnly,
           workspaceDir: workspaceRoot,
           sandboxed: !!sandbox,
           config: options?.config,
    
  • src/agents/tools/image-tool.test.ts+103 1 modified
    @@ -6,7 +6,13 @@ import type { OpenClawConfig } from "../../config/config.js";
     import type { ModelDefinitionConfig } from "../../config/types.models.js";
     import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
     import { createOpenClawCodingTools } from "../pi-tools.js";
    -import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
    +import type { SandboxContext } from "../sandbox.js";
    +import type { SandboxFsBridge, SandboxResolvedPath } from "../sandbox/fs-bridge.js";
    +import {
    +  createHostSandboxFsBridge,
    +  createSandboxFsBridgeFromResolver,
    +} from "../test-helpers/host-sandbox-fs-bridge.js";
    +import { createPiToolsSandboxContext } from "../test-helpers/pi-tools-sandbox-context.js";
     import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
     
     async function writeAuthProfiles(agentDir: string, profiles: unknown) {
    @@ -46,6 +52,58 @@ async function withTempWorkspacePng(
       }
     }
     
    +function createUnsafeMountedBridge(params: {
    +  root: string;
    +  agentHostRoot: string;
    +  workspaceContainerRoot?: string;
    +}): SandboxFsBridge {
    +  const root = path.resolve(params.root);
    +  const agentHostRoot = path.resolve(params.agentHostRoot);
    +  const workspaceContainerRoot = params.workspaceContainerRoot ?? "/workspace";
    +
    +  const resolvePath = (filePath: string, cwd?: string): SandboxResolvedPath => {
    +    const hostPath =
    +      filePath === "/agent" || filePath === "/agent/" || filePath.startsWith("/agent/")
    +        ? path.join(
    +            agentHostRoot,
    +            filePath === "/agent" || filePath === "/agent/" ? "" : filePath.slice("/agent/".length),
    +          )
    +        : path.isAbsolute(filePath)
    +          ? filePath
    +          : path.resolve(cwd ?? root, filePath);
    +
    +    const relFromRoot = path.relative(root, hostPath);
    +    const relativePath =
    +      relFromRoot && !relFromRoot.startsWith("..") && !path.isAbsolute(relFromRoot)
    +        ? relFromRoot.split(path.sep).filter(Boolean).join(path.posix.sep)
    +        : filePath.replace(/\\/g, "/");
    +
    +    const containerPath = filePath.startsWith("/")
    +      ? filePath.replace(/\\/g, "/")
    +      : relativePath
    +        ? path.posix.join(workspaceContainerRoot, relativePath)
    +        : workspaceContainerRoot;
    +
    +    return { hostPath, relativePath, containerPath };
    +  };
    +
    +  return createSandboxFsBridgeFromResolver(resolvePath);
    +}
    +
    +function createSandbox(params: {
    +  sandboxRoot: string;
    +  agentRoot: string;
    +  fsBridge: SandboxFsBridge;
    +}): SandboxContext {
    +  return createPiToolsSandboxContext({
    +    workspaceDir: params.sandboxRoot,
    +    agentWorkspaceDir: params.agentRoot,
    +    workspaceAccess: "rw",
    +    fsBridge: params.fsBridge,
    +    tools: { allow: [], deny: [] },
    +  });
    +}
    +
     function stubMinimaxOkFetch() {
       const fetch = vi.fn().mockResolvedValue({
         ok: true,
    @@ -503,6 +561,50 @@ describe("image tool implicit imageModel config", () => {
         );
       });
     
    +  it("applies tools.fs.workspaceOnly to image paths in sandbox mode", async () => {
    +    const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
    +    const agentDir = path.join(stateDir, "agent");
    +    const sandboxRoot = path.join(stateDir, "sandbox");
    +    await fs.mkdir(agentDir, { recursive: true });
    +    await fs.mkdir(sandboxRoot, { recursive: true });
    +    await fs.writeFile(path.join(agentDir, "secret.png"), Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
    +
    +    const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentDir });
    +    const sandbox = createSandbox({ sandboxRoot, agentRoot: agentDir, fsBridge: bridge });
    +    const fetch = stubMinimaxOkFetch();
    +    const cfg: OpenClawConfig = {
    +      ...createMinimaxImageConfig(),
    +      tools: { fs: { workspaceOnly: true } },
    +    };
    +
    +    try {
    +      const tools = createOpenClawCodingTools({
    +        config: cfg,
    +        agentDir,
    +        sandbox,
    +        workspaceDir: sandboxRoot,
    +      });
    +      const readTool = tools.find((candidate) => candidate.name === "read");
    +      if (!readTool) {
    +        throw new Error("expected read tool");
    +      }
    +      const imageTool = requireImageTool(tools.find((candidate) => candidate.name === "image"));
    +
    +      await expect(readTool.execute("t1", { path: "/agent/secret.png" })).rejects.toThrow(
    +        /Path escapes sandbox root/i,
    +      );
    +      await expect(
    +        imageTool.execute("t2", {
    +          prompt: "Describe the image.",
    +          image: "/agent/secret.png",
    +        }),
    +      ).rejects.toThrow(/Path escapes sandbox root/i);
    +      expect(fetch).not.toHaveBeenCalled();
    +    } finally {
    +      await fs.rm(stateDir, { recursive: true, force: true });
    +    }
    +  });
    +
       it("rewrites inbound absolute paths into sandbox media/inbound", async () => {
         const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
         const agentDir = path.join(stateDir, "agent");
    
  • src/agents/tools/image-tool.ts+22 1 modified
    @@ -12,6 +12,7 @@ import { runWithImageModelFallback } from "../model-fallback.js";
     import { resolveConfiguredModelRef } from "../model-selection.js";
     import { ensureOpenClawModelsJson } from "../models-config.js";
     import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
    +import { assertSandboxPath } from "../sandbox-paths.js";
     import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
     import { normalizeWorkspaceDir } from "../workspace-dir.js";
     import type { AnyAgentTool } from "./common.js";
    @@ -207,6 +208,7 @@ function buildImageContext(
     type ImageSandboxConfig = {
       root: string;
       bridge: SandboxFsBridge;
    +  workspaceOnly?: boolean;
     };
     
     async function resolveSandboxedImagePath(params: {
    @@ -220,6 +222,13 @@ async function resolveSandboxedImagePath(params: {
           filePath,
           cwd: params.sandbox.root,
         });
    +    if (params.sandbox.workspaceOnly) {
    +      await assertSandboxPath({
    +        filePath: resolved.hostPath,
    +        cwd: params.sandbox.root,
    +        root: params.sandbox.root,
    +      });
    +    }
         return { resolved: resolved.hostPath };
       } catch (err) {
         const name = path.basename(filePath);
    @@ -239,6 +248,13 @@ async function resolveSandboxedImagePath(params: {
           filePath: candidateRel,
           cwd: params.sandbox.root,
         });
    +    if (params.sandbox.workspaceOnly) {
    +      await assertSandboxPath({
    +        filePath: out.hostPath,
    +        cwd: params.sandbox.root,
    +        root: params.sandbox.root,
    +      });
    +    }
         return { resolved: out.hostPath, rewrittenFrom: filePath };
       }
     }
    @@ -336,6 +352,7 @@ export function createImageTool(options?: {
       agentDir?: string;
       workspaceDir?: string;
       sandbox?: ImageSandboxConfig;
    +  workspaceOnly?: boolean;
       /** If true, the model has native vision capability and images in the prompt are auto-injected */
       modelHasVision?: boolean;
     }): AnyAgentTool | null {
    @@ -444,7 +461,11 @@ export function createImageTool(options?: {
     
           const sandboxConfig =
             options?.sandbox && options?.sandbox.root.trim()
    -          ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge }
    +          ? {
    +              root: options.sandbox.root.trim(),
    +              bridge: options.sandbox.bridge,
    +              workspaceOnly: options.workspaceOnly === true,
    +            }
               : null;
     
           // MARK: - Load and resolve each image
    

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.