VYPR
High severity8.2NVD Advisory· Published Apr 21, 2026· Updated Apr 27, 2026

CVE-2026-41296

CVE-2026-41296

Description

OpenClaw before 2026.3.31 contains a time-of-check-time-of-use race condition in the remote filesystem bridge readFile function that allows sandbox escape. Attackers can exploit the separate path validation and file read operations to bypass sandbox restrictions and read arbitrary files.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
121870a08583

fix(sandbox): pin remote fs bridge reads (#58016)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
5 files changed · +259 15
  • CHANGELOG.md+1 0 modified
    @@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai
     - Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
     - Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
     - Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss.
    +- Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc.
     - Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.
     
     ## 2026.3.28
    
  • src/agents/sandbox/fs-bridge-mutation-helper.test.ts+36 0 modified
    @@ -60,6 +60,42 @@ describe("sandbox pinned mutation helper", () => {
         });
       });
     
    +  it.runIf(process.platform !== "win32")(
    +    "reads through a pinned directory fd and rejects hardlinked files",
    +    async () => {
    +      await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
    +        const workspace = path.join(root, "workspace");
    +        const nested = path.join(workspace, "nested");
    +        await fs.mkdir(nested, { recursive: true });
    +        await fs.writeFile(path.join(workspace, "read.txt"), "hello", "utf8");
    +
    +        const readResult = runMutation(["read", workspace, "", "read.txt"]);
    +        expect(readResult.status).toBe(0);
    +        expect(readResult.stdout).toBe("hello");
    +
    +        const hardlinkedFile = path.join(nested, "hardlinked.txt");
    +        await fs.link(path.join(workspace, "read.txt"), hardlinkedFile);
    +
    +        const hardlinkResult = runMutation(["read", workspace, "nested", "hardlinked.txt"]);
    +        expect(hardlinkResult.status).not.toBe(0);
    +        expect(hardlinkResult.stderr).toMatch(/hardlinked file/i);
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")("rejects non-regular files while reading", async () => {
    +    await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
    +      const workspace = path.join(root, "workspace");
    +      await fs.mkdir(workspace, { recursive: true });
    +      await fs.mkdir(path.join(workspace, "folder"), { recursive: true });
    +
    +      const result = runMutation(["read", workspace, "", "folder"]);
    +
    +      expect(result.status).not.toBe(0);
    +      expect(result.stderr).toMatch(/only regular files are allowed/i);
    +    });
    +  });
    +
       it.runIf(process.platform !== "win32")(
         "preserves stdin payload bytes when the pinned write plan runs through sh",
         async () => {
    
  • src/agents/sandbox/fs-bridge-mutation-helper.ts+26 0 modified
    @@ -107,6 +107,22 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
       "            except FileNotFoundError:",
       "                pass",
       "",
    +  "def read_file(parent_fd, basename):",
    +  "    file_fd = os.open(basename, READ_FLAGS, dir_fd=parent_fd)",
    +  "    try:",
    +  "        file_stat = os.fstat(file_fd)",
    +  "        if not stat.S_ISREG(file_stat.st_mode):",
    +  "            raise OSError(errno.EPERM, 'only regular files are allowed', basename)",
    +  "        if file_stat.st_nlink > 1:",
    +  "            raise OSError(errno.EPERM, 'hardlinked file is not allowed', basename)",
    +  "        while True:",
    +  "            chunk = os.read(file_fd, 65536)",
    +  "            if not chunk:",
    +  "                break",
    +  "            os.write(1, chunk)",
    +  "    finally:",
    +  "        os.close(file_fd)",
    +  "",
       "def remove_tree(parent_fd, basename):",
       "    entry_stat = os.lstat(basename, dir_fd=parent_fd)",
       "    if not stat.S_ISDIR(entry_stat.st_mode) or stat.S_ISLNK(entry_stat.st_mode):",
    @@ -198,6 +214,16 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
       "        if parent_fd is not None:",
       "            os.close(parent_fd)",
       "        os.close(root_fd)",
    +  "elif operation == 'read':",
    +  "    root_fd = open_dir(sys.argv[2])",
    +  "    parent_fd = None",
    +  "    try:",
    +  "        parent_fd = walk_dir(root_fd, sys.argv[3], False)",
    +  "        read_file(parent_fd, sys.argv[4])",
    +  "    finally:",
    +  "        if parent_fd is not None:",
    +  "            os.close(parent_fd)",
    +  "        os.close(root_fd)",
       "elif operation == 'mkdirp':",
       "    root_fd = open_dir(sys.argv[2])",
       "    target_fd = None",
    
  • src/agents/sandbox/remote-fs-bridge.test.ts+178 0 added
    @@ -0,0 +1,178 @@
    +import { spawnSync } from "node:child_process";
    +import fs from "node:fs/promises";
    +import path from "node:path";
    +import { describe, expect, it } from "vitest";
    +import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
    +import { createSandbox } from "./fs-bridge.test-helpers.js";
    +import {
    +  createRemoteShellSandboxFsBridge,
    +  type RemoteShellSandboxHandle,
    +} from "./remote-fs-bridge.js";
    +
    +function createLocalRemoteRuntime(params: {
    +  remoteWorkspaceDir: string;
    +  remoteAgentWorkspaceDir: string;
    +}) {
    +  const calls: Array<Parameters<RemoteShellSandboxHandle["runRemoteShellScript"]>[0]> = [];
    +  const runtime: RemoteShellSandboxHandle = {
    +    remoteWorkspaceDir: params.remoteWorkspaceDir,
    +    remoteAgentWorkspaceDir: params.remoteAgentWorkspaceDir,
    +    runRemoteShellScript: async (command) => {
    +      calls.push(command);
    +      const result = command.script.includes("python3 /dev/fd/3 \"$@\" 3<<'PY'")
    +        ? spawnSync("python3", ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], {
    +            input: command.stdin,
    +            encoding: "buffer",
    +            stdio: ["pipe", "pipe", "pipe"],
    +          })
    +        : spawnSync("sh", ["-c", command.script, "openclaw-sandbox-fs", ...(command.args ?? [])], {
    +            input: command.stdin,
    +            encoding: "buffer",
    +            stdio: ["pipe", "pipe", "pipe"],
    +          });
    +      const stdout = Buffer.isBuffer(result.stdout)
    +        ? result.stdout
    +        : Buffer.from(result.stdout ?? []);
    +      const stderr = Buffer.isBuffer(result.stderr)
    +        ? result.stderr
    +        : Buffer.from(result.stderr ?? []);
    +      const code = result.status ?? (result.signal ? 128 : 1);
    +      if (result.error) {
    +        throw result.error;
    +      }
    +      if (code !== 0 && !command.allowFailure) {
    +        throw Object.assign(
    +          new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`),
    +          { code, stdout, stderr },
    +        );
    +      }
    +      return { stdout, stderr, code };
    +    },
    +  };
    +  return { calls, runtime };
    +}
    +
    +describe("remote sandbox fs bridge", () => {
    +  it.runIf(process.platform !== "win32")(
    +    "reads files with the pinned mutation helper",
    +    async () => {
    +      await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
    +        const workspaceDir = path.join(stateDir, "workspace");
    +        await fs.mkdir(workspaceDir, { recursive: true });
    +        await fs.writeFile(path.join(workspaceDir, "note.txt"), "hello", "utf8");
    +
    +        const { calls, runtime } = createLocalRemoteRuntime({
    +          remoteWorkspaceDir: workspaceDir,
    +          remoteAgentWorkspaceDir: workspaceDir,
    +        });
    +        const bridge = createRemoteShellSandboxFsBridge({
    +          sandbox: createSandbox({
    +            workspaceDir,
    +            agentWorkspaceDir: workspaceDir,
    +          }),
    +          runtime,
    +        });
    +
    +        await expect(bridge.readFile({ filePath: "note.txt" })).resolves.toEqual(
    +          Buffer.from("hello"),
    +        );
    +        expect(calls).toHaveLength(1);
    +        expect(calls[0]?.args?.[0]).toBe("read");
    +        expect(calls[0]?.script).toContain("python3 /dev/fd/3 \"$@\" 3<<'PY'");
    +        expect(calls[0]?.script).toContain("read_file(parent_fd, basename)");
    +        expect(calls[0]?.script).not.toContain('cat -- "$1"');
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "rejects mount-root reads before invoking the mutation helper",
    +    async () => {
    +      await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
    +        const workspaceDir = path.join(stateDir, "workspace");
    +        await fs.mkdir(workspaceDir, { recursive: true });
    +
    +        const { calls, runtime } = createLocalRemoteRuntime({
    +          remoteWorkspaceDir: workspaceDir,
    +          remoteAgentWorkspaceDir: workspaceDir,
    +        });
    +        const bridge = createRemoteShellSandboxFsBridge({
    +          sandbox: createSandbox({
    +            workspaceDir,
    +            agentWorkspaceDir: workspaceDir,
    +          }),
    +          runtime,
    +        });
    +
    +        await expect(bridge.readFile({ filePath: "." })).rejects.toThrow(
    +          /Invalid sandbox entry target/,
    +        );
    +        expect(calls).toHaveLength(0);
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")("rejects symlink escapes while reading", async () => {
    +    await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
    +      const workspaceDir = path.join(stateDir, "workspace");
    +      const outsideDir = path.join(stateDir, "outside");
    +      await fs.mkdir(workspaceDir, { recursive: true });
    +      await fs.mkdir(outsideDir, { recursive: true });
    +      await fs.writeFile(path.join(outsideDir, "secret.txt"), "classified", "utf8");
    +      await fs.symlink(path.join(outsideDir, "secret.txt"), path.join(workspaceDir, "link.txt"));
    +
    +      const { runtime } = createLocalRemoteRuntime({
    +        remoteWorkspaceDir: workspaceDir,
    +        remoteAgentWorkspaceDir: workspaceDir,
    +      });
    +      const bridge = createRemoteShellSandboxFsBridge({
    +        sandbox: createSandbox({
    +          workspaceDir,
    +          agentWorkspaceDir: workspaceDir,
    +        }),
    +        runtime,
    +      });
    +
    +      await expect(bridge.readFile({ filePath: "link.txt" })).rejects.toThrow(
    +        /symbolic links|too many levels|ELOOP/i,
    +      );
    +    });
    +  });
    +
    +  it.runIf(process.platform !== "win32")(
    +    "rejects final-component symlinks even when they stay inside the workspace",
    +    async () => {
    +      await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
    +        const workspaceDir = path.join(stateDir, "workspace");
    +        await fs.mkdir(workspaceDir, { recursive: true });
    +        await fs.writeFile(path.join(workspaceDir, "note.txt"), "hello", "utf8");
    +        await fs.symlink("note.txt", path.join(workspaceDir, "link.txt"));
    +
    +        const { runtime } = createLocalRemoteRuntime({
    +          remoteWorkspaceDir: workspaceDir,
    +          remoteAgentWorkspaceDir: workspaceDir,
    +        });
    +        const bridge = createRemoteShellSandboxFsBridge({
    +          sandbox: createSandbox({
    +            workspaceDir,
    +            agentWorkspaceDir: workspaceDir,
    +          }),
    +          runtime,
    +        });
    +
    +        await expect(bridge.readFile({ filePath: "link.txt" })).rejects.toThrow(
    +          /symbolic links|too many levels|ELOOP/i,
    +        );
    +      });
    +    },
    +  );
    +});
    +
    +async function withTempDir<T>(prefix: string, run: (stateDir: string) => Promise<T>): Promise<T> {
    +  const stateDir = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", prefix));
    +  try {
    +    return await run(stateDir);
    +  } finally {
    +    await fs.rm(stateDir, { recursive: true, force: true });
    +  }
    +}
    
  • src/agents/sandbox/remote-fs-bridge.ts+18 15 modified
    @@ -60,19 +60,22 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
         signal?: AbortSignal;
       }): Promise<Buffer> {
         const target = this.resolveTarget(params);
    -    const canonical = await this.resolveCanonicalPath({
    -      containerPath: target.containerPath,
    -      action: "read files",
    -      signal: params.signal,
    -    });
    -    await this.assertNoHardlinkedFile({
    -      containerPath: canonical,
    -      action: "read files",
    -      signal: params.signal,
    -    });
    -    const result = await this.runRemoteScript({
    -      script: 'set -eu\ncat -- "$1"',
    -      args: [canonical],
    +    const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
    +    if (
    +      relativePath === "" ||
    +      relativePath === "." ||
    +      relativePath.startsWith("..") ||
    +      path.posix.isAbsolute(relativePath)
    +    ) {
    +      throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
    +    }
    +    const result = await this.runMutation({
    +      args: [
    +        "read",
    +        target.mountRootPath,
    +        path.posix.dirname(relativePath) === "." ? "" : path.posix.dirname(relativePath),
    +        path.posix.basename(relativePath),
    +      ],
           signal: params.signal,
         });
         return result.stdout;
    @@ -471,8 +474,8 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
         stdin?: Buffer | string;
         signal?: AbortSignal;
         allowFailure?: boolean;
    -  }) {
    -    await this.runRemoteScript({
    +  }): Promise<SandboxBackendCommandResult> {
    +    return await this.runRemoteScript({
           script: [
             "set -eu",
             "python3 /dev/fd/3 \"$@\" 3<<'PY'",
    

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

6

News mentions

0

No linked articles in our index yet.