VYPR
Medium severity5.0NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41338

CVE-2026-41338

Description

OpenClaw before 2026.3.31 contains a time-of-check-time-of-use vulnerability in sandbox file operations that allows attackers to bypass fd-based defenses. Attackers can exploit check-then-act patterns in apply_patch, remove, and mkdir operations to manipulate files between validation and execution.

Affected products

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

Patches

1
32a4a47d602e

Agents: pin apply-patch workspace mutations (#56016)

https://github.com/openclaw/openclawJacob TomlinsonMar 30, 2026via nvd-ref
5 files changed · +660 29
  • src/agents/apply-patch.test.ts+88 10 modified
    @@ -2,6 +2,10 @@ import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     import { describe, expect, it, vi } from "vitest";
    +import {
    +  createRebindableDirectoryAlias,
    +  withRealpathSymlinkRebindRace,
    +} from "../test-utils/symlink-rebind-race.js";
     import { applyPatch } from "./apply-patch.js";
     
     async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
    @@ -147,22 +151,17 @@ describe("applyPatch", () => {
         });
       });
     
    -  it("resolves delete targets before calling fs.rm", async () => {
    +  it("deletes the resolved target path", async () => {
         await withTempDir(async (dir) => {
           const target = path.join(dir, "delete-me.txt");
           await fs.writeFile(target, "x\n", "utf8");
    -      const rmSpy = vi.spyOn(fs, "rm");
    -
    -      try {
    -        const patch = `*** Begin Patch
    +      const patch = `*** Begin Patch
     *** Delete File: delete-me.txt
     *** End Patch`;
     
    -        await applyPatch(patch, { cwd: dir });
    -        expect(rmSpy).toHaveBeenCalledWith(target);
    -      } finally {
    -        rmSpy.mockRestore();
    -      }
    +      const result = await applyPatch(patch, { cwd: dir });
    +      expect(result.summary.deleted).toEqual(["delete-me.txt"]);
    +      await expect(fs.stat(target)).rejects.toBeDefined();
         });
       });
     
    @@ -364,6 +363,85 @@ describe("applyPatch", () => {
         });
       });
     
    +  it.runIf(process.platform !== "win32")(
    +    "does not delete out-of-root files when a checked directory is rebound before remove",
    +    async () => {
    +      await withTempDir(async (dir) => {
    +        const inside = path.join(dir, "inside");
    +        const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-outside-"));
    +        const slot = path.join(dir, "slot");
    +        await fs.mkdir(inside, { recursive: true });
    +        await fs.writeFile(path.join(inside, "target.txt"), "inside\n", "utf8");
    +        const outsideTarget = path.join(outside, "target.txt");
    +        await fs.writeFile(outsideTarget, "outside\n", "utf8");
    +        await createRebindableDirectoryAlias({
    +          aliasPath: slot,
    +          targetPath: inside,
    +        });
    +
    +        const patch = `*** Begin Patch
    +*** Delete File: slot/target.txt
    +*** End Patch`;
    +
    +        try {
    +          await withRealpathSymlinkRebindRace({
    +            shouldFlip: (realpathInput) => realpathInput.endsWith(path.join("slot")),
    +            symlinkPath: slot,
    +            symlinkTarget: outside,
    +            timing: "before-realpath",
    +            run: async () => {
    +              await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(
    +                /symlink escapes sandbox root|under root|not found/i,
    +              );
    +            },
    +          });
    +          await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("outside\n");
    +        } finally {
    +          await fs.rm(outside, { recursive: true, force: true });
    +        }
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "does not create out-of-root directories when a checked directory is rebound before mkdir",
    +    async () => {
    +      await withTempDir(async (dir) => {
    +        const inside = path.join(dir, "inside");
    +        const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-outside-"));
    +        const slot = path.join(dir, "slot");
    +        await fs.mkdir(inside, { recursive: true });
    +        await createRebindableDirectoryAlias({
    +          aliasPath: slot,
    +          targetPath: inside,
    +        });
    +
    +        const patch = `*** Begin Patch
    +*** Add File: slot/nested/deep/file.txt
    ++safe
    +*** End Patch`;
    +
    +        try {
    +          await withRealpathSymlinkRebindRace({
    +            shouldFlip: (realpathInput) =>
    +              realpathInput.endsWith(path.join("slot", "nested", "deep", "file.txt")),
    +            symlinkPath: slot,
    +            symlinkTarget: outside,
    +            timing: "before-realpath",
    +            run: async () => {
    +              await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/under root/i);
    +            },
    +          });
    +          await expect(fs.stat(path.join(outside, "nested"))).rejects.toMatchObject({
    +            code: "ENOENT",
    +          });
    +        } finally {
    +          await fs.rm(outside, { recursive: true, force: true });
    +        }
    +      });
    +    },
    +  );
    +
       it("uses container paths when the sandbox bridge has no local host path", async () => {
         const files = new Map<string, string>([["/sandbox/source.txt", "before\n"]]);
         const bridge = {
    
  • src/agents/apply-patch.ts+22 17 modified
    @@ -4,7 +4,11 @@ import path from "node:path";
     import type { AgentTool } from "@mariozechner/pi-agent-core";
     import { Type } from "@sinclair/typebox";
     import { openBoundaryFile, type BoundaryFileOpenResult } from "../infra/boundary-file-read.js";
    -import { writeFileWithinRoot } from "../infra/fs-safe.js";
    +import {
    +  mkdirPathWithinRoot,
    +  removePathWithinRoot,
    +  writeFileWithinRoot,
    +} from "../infra/fs-safe.js";
     import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js";
     import { applyUpdateHunk } from "./apply-patch-update.js";
     import { toRelativeSandboxPath, resolvePathFromInput } from "./path-policy.js";
    @@ -271,26 +275,27 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
           });
         },
         remove: async (filePath) => {
    -      if (workspaceOnly) {
    -        await assertSandboxPath({
    -          filePath,
    -          cwd: options.cwd,
    -          root: options.cwd,
    -          allowFinalSymlinkForUnlink: true,
    -          allowFinalHardlinkForUnlink: true,
    -        });
    +      if (!workspaceOnly) {
    +        await fs.rm(filePath);
    +        return;
           }
    -      await fs.rm(filePath);
    +      const relative = toRelativeSandboxPath(options.cwd, filePath);
    +      await removePathWithinRoot({
    +        rootDir: options.cwd,
    +        relativePath: relative,
    +      });
         },
         mkdirp: async (dir) => {
    -      if (workspaceOnly) {
    -        await assertSandboxPath({
    -          filePath: dir,
    -          cwd: options.cwd,
    -          root: options.cwd,
    -        });
    +      if (!workspaceOnly) {
    +        await fs.mkdir(dir, { recursive: true });
    +        return;
           }
    -      await fs.mkdir(dir, { recursive: true });
    +      const relative = toRelativeSandboxPath(options.cwd, dir, { allowRoot: true });
    +      await mkdirPathWithinRoot({
    +        rootDir: options.cwd,
    +        relativePath: relative,
    +        allowRoot: true,
    +      });
         },
       };
     }
    
  • src/infra/fs-pinned-path-helper.ts+168 0 added
    @@ -0,0 +1,168 @@
    +import { spawn } from "node:child_process";
    +import fsSync from "node:fs";
    +
    +const LOCAL_PINNED_PATH_PYTHON = [
    +  "import errno",
    +  "import os",
    +  "import stat",
    +  "import sys",
    +  "",
    +  "operation = sys.argv[1]",
    +  "root_path = sys.argv[2]",
    +  "relative_path = sys.argv[3]",
    +  "",
    +  "DIR_FLAGS = os.O_RDONLY",
    +  "if hasattr(os, 'O_DIRECTORY'):",
    +  "    DIR_FLAGS |= os.O_DIRECTORY",
    +  "if hasattr(os, 'O_NOFOLLOW'):",
    +  "    DIR_FLAGS |= os.O_NOFOLLOW",
    +  "",
    +  "def open_dir(path_value, dir_fd=None):",
    +  "    return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
    +  "",
    +  "def split_segments(relative_path):",
    +  "    return [part for part in relative_path.split('/') if part and part != '.']",
    +  "",
    +  "def validate_segment(segment):",
    +  "    if segment == '..':",
    +  "        raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
    +  "",
    +  "def walk_existing_path(root_fd, segments):",
    +  "    current_fd = os.dup(root_fd)",
    +  "    try:",
    +  "        for segment in segments:",
    +  "            validate_segment(segment)",
    +  "            next_fd = open_dir(segment, dir_fd=current_fd)",
    +  "            os.close(current_fd)",
    +  "            current_fd = next_fd",
    +  "        return current_fd",
    +  "    except Exception:",
    +  "        os.close(current_fd)",
    +  "        raise",
    +  "",
    +  "def mkdirp_within_root(root_fd, segments):",
    +  "    current_fd = os.dup(root_fd)",
    +  "    try:",
    +  "        for segment in segments:",
    +  "            validate_segment(segment)",
    +  "            try:",
    +  "                next_fd = open_dir(segment, dir_fd=current_fd)",
    +  "            except FileNotFoundError:",
    +  "                os.mkdir(segment, 0o777, dir_fd=current_fd)",
    +  "                next_fd = open_dir(segment, dir_fd=current_fd)",
    +  "            os.close(current_fd)",
    +  "            current_fd = next_fd",
    +  "    finally:",
    +  "        os.close(current_fd)",
    +  "",
    +  "def remove_within_root(root_fd, segments):",
    +  "    if not segments:",
    +  "        raise OSError(errno.EPERM, 'refusing to remove root path')",
    +  "    parent_segments = segments[:-1]",
    +  "    basename = segments[-1]",
    +  "    validate_segment(basename)",
    +  "    parent_fd = walk_existing_path(root_fd, parent_segments)",
    +  "    try:",
    +  "        target_stat = os.lstat(basename, dir_fd=parent_fd)",
    +  "        if stat.S_ISDIR(target_stat.st_mode) and not stat.S_ISLNK(target_stat.st_mode):",
    +  "            os.rmdir(basename, dir_fd=parent_fd)",
    +  "        else:",
    +  "            os.unlink(basename, dir_fd=parent_fd)",
    +  "    finally:",
    +  "        os.close(parent_fd)",
    +  "",
    +  "root_fd = open_dir(root_path)",
    +  "try:",
    +  "    segments = split_segments(relative_path)",
    +  "    if operation == 'mkdirp':",
    +  "        mkdirp_within_root(root_fd, segments)",
    +  "    elif operation == 'remove':",
    +  "        remove_within_root(root_fd, segments)",
    +  "    else:",
    +  "        raise RuntimeError(f'unknown pinned path operation: {operation}')",
    +  "finally:",
    +  "    os.close(root_fd)",
    +].join("\n");
    +
    +const PINNED_PATH_PYTHON_CANDIDATES = [
    +  process.env.OPENCLAW_PINNED_PYTHON,
    +  // Keep the write-specific alias for backwards compatibility.
    +  process.env.OPENCLAW_PINNED_WRITE_PYTHON,
    +  "/usr/bin/python3",
    +  "/opt/homebrew/bin/python3",
    +  "/usr/local/bin/python3",
    +].filter((value): value is string => Boolean(value));
    +
    +let cachedPinnedPathPython = "";
    +
    +function canExecute(binPath: string): boolean {
    +  try {
    +    fsSync.accessSync(binPath, fsSync.constants.X_OK);
    +    return true;
    +  } catch {
    +    return false;
    +  }
    +}
    +
    +function resolvePinnedPathPython(): string {
    +  if (cachedPinnedPathPython) {
    +    return cachedPinnedPathPython;
    +  }
    +  for (const candidate of PINNED_PATH_PYTHON_CANDIDATES) {
    +    if (canExecute(candidate)) {
    +      cachedPinnedPathPython = candidate;
    +      return cachedPinnedPathPython;
    +    }
    +  }
    +  cachedPinnedPathPython = "python3";
    +  return cachedPinnedPathPython;
    +}
    +
    +function buildPinnedPathError(stderr: string, code: number | null, signal: NodeJS.Signals | null) {
    +  return new Error(
    +    stderr.trim() || `Pinned path helper failed with code ${code ?? "null"} (${signal ?? "?"})`,
    +  );
    +}
    +
    +export function isPinnedPathHelperSpawnError(error: unknown): boolean {
    +  if (!(error instanceof Error)) {
    +    return false;
    +  }
    +
    +  const maybeErrno = error as NodeJS.ErrnoException;
    +  if (typeof maybeErrno.syscall !== "string" || !maybeErrno.syscall.startsWith("spawn")) {
    +    return false;
    +  }
    +
    +  return ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? "");
    +}
    +
    +export async function runPinnedPathHelper(params: {
    +  operation: "mkdirp" | "remove";
    +  rootPath: string;
    +  relativePath: string;
    +}): Promise<void> {
    +  const child = spawn(
    +    resolvePinnedPathPython(),
    +    ["-c", LOCAL_PINNED_PATH_PYTHON, params.operation, params.rootPath, params.relativePath],
    +    {
    +      stdio: ["ignore", "ignore", "pipe"],
    +    },
    +  );
    +
    +  let stderr = "";
    +  child.stderr.setEncoding?.("utf8");
    +  child.stderr.on("data", (chunk: string) => {
    +    stderr += chunk;
    +  });
    +
    +  const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>(
    +    (resolve, reject) => {
    +      child.once("error", reject);
    +      child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
    +    },
    +  );
    +  if (code !== 0) {
    +    throw buildPinnedPathError(stderr, code, signal);
    +  }
    +}
    
  • src/infra/fs-safe.test.ts+206 1 modified
    @@ -1,6 +1,6 @@
     import fs from "node:fs/promises";
     import path from "node:path";
    -import { afterEach, describe, expect, it } from "vitest";
    +import { afterEach, describe, expect, it, vi } from "vitest";
     import {
       createRebindableDirectoryAlias,
       withRealpathSymlinkRebindRace,
    @@ -10,11 +10,13 @@ import {
       appendFileWithinRoot,
       copyFileWithinRoot,
       createRootScopedReadFile,
    +  mkdirPathWithinRoot,
       SafeOpenError,
       openFileWithinRoot,
       readFileWithinRoot,
       readPathWithinRoot,
       readLocalFileSafely,
    +  removePathWithinRoot,
       writeFileWithinRoot,
       writeFileFromPathWithinRoot,
     } from "./fs-safe.js";
    @@ -277,6 +279,147 @@ describe("fs-safe", () => {
         );
       });
     
    +  it("removes a file within root safely", async () => {
    +    const root = await tempDirs.make("openclaw-fs-safe-root-");
    +    const targetPath = path.join(root, "nested", "out.txt");
    +    await fs.mkdir(path.dirname(targetPath), { recursive: true });
    +    await fs.writeFile(targetPath, "hello");
    +
    +    await removePathWithinRoot({
    +      rootDir: root,
    +      relativePath: "nested/out.txt",
    +    });
    +
    +    await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
    +  });
    +
    +  it("creates directories within root safely", async () => {
    +    const root = await tempDirs.make("openclaw-fs-safe-root-");
    +
    +    await mkdirPathWithinRoot({
    +      rootDir: root,
    +      relativePath: "nested/deeper",
    +    });
    +
    +    const stat = await fs.stat(path.join(root, "nested", "deeper"));
    +    expect(stat.isDirectory()).toBe(true);
    +  });
    +
    +  it.runIf(process.platform !== "win32")(
    +    "creates directories through in-root symlink parents",
    +    async () => {
    +      const root = await tempDirs.make("openclaw-fs-safe-root-");
    +      const realDir = path.join(root, "real");
    +      const aliasDir = path.join(root, "alias");
    +      await fs.mkdir(realDir, { recursive: true });
    +      await fs.symlink(realDir, aliasDir);
    +
    +      await mkdirPathWithinRoot({
    +        rootDir: root,
    +        relativePath: path.join("alias", "nested", "deeper"),
    +      });
    +
    +      await expect(fs.stat(path.join(realDir, "nested", "deeper"))).resolves.toMatchObject({
    +        isDirectory: expect.any(Function),
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "removes files through in-root symlink parents",
    +    async () => {
    +      const root = await tempDirs.make("openclaw-fs-safe-root-");
    +      const realDir = path.join(root, "real");
    +      const aliasDir = path.join(root, "alias");
    +      await fs.mkdir(realDir, { recursive: true });
    +      await fs.symlink(realDir, aliasDir);
    +      await fs.writeFile(path.join(realDir, "target.txt"), "hello");
    +
    +      await removePathWithinRoot({
    +        rootDir: root,
    +        relativePath: path.join("alias", "target.txt"),
    +      });
    +
    +      await expect(fs.stat(path.join(realDir, "target.txt"))).rejects.toMatchObject({
    +        code: "ENOENT",
    +      });
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "falls back to legacy remove when the pinned helper cannot spawn",
    +    async () => {
    +      vi.resetModules();
    +      vi.doMock("./fs-pinned-path-helper.js", async () => {
    +        const actual = await vi.importActual<typeof import("./fs-pinned-path-helper.js")>(
    +          "./fs-pinned-path-helper.js",
    +        );
    +        const error = new Error("spawn missing python ENOENT") as NodeJS.ErrnoException;
    +        error.code = "ENOENT";
    +        error.syscall = "spawn python3";
    +        return {
    +          ...actual,
    +          runPinnedPathHelper: vi.fn(async () => {
    +            throw error;
    +          }),
    +        };
    +      });
    +
    +      const { removePathWithinRoot: removePathWithinRootWithFallback } =
    +        await import("./fs-safe.js");
    +
    +      const root = await tempDirs.make("openclaw-fs-safe-root-");
    +      const targetPath = path.join(root, "nested", "out.txt");
    +      await fs.mkdir(path.dirname(targetPath), { recursive: true });
    +      await fs.writeFile(targetPath, "hello");
    +
    +      await removePathWithinRootWithFallback({
    +        rootDir: root,
    +        relativePath: "nested/out.txt",
    +      });
    +
    +      await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
    +      vi.doUnmock("./fs-pinned-path-helper.js");
    +      vi.resetModules();
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "falls back to legacy mkdir when the pinned helper cannot spawn",
    +    async () => {
    +      vi.resetModules();
    +      vi.doMock("./fs-pinned-path-helper.js", async () => {
    +        const actual = await vi.importActual<typeof import("./fs-pinned-path-helper.js")>(
    +          "./fs-pinned-path-helper.js",
    +        );
    +        const error = new Error("spawn missing python ENOENT") as NodeJS.ErrnoException;
    +        error.code = "ENOENT";
    +        error.syscall = "spawn python3";
    +        return {
    +          ...actual,
    +          runPinnedPathHelper: vi.fn(async () => {
    +            throw error;
    +          }),
    +        };
    +      });
    +
    +      const { mkdirPathWithinRoot: mkdirPathWithinRootWithFallback } = await import("./fs-safe.js");
    +
    +      const root = await tempDirs.make("openclaw-fs-safe-root-");
    +
    +      await mkdirPathWithinRootWithFallback({
    +        rootDir: root,
    +        relativePath: "nested/deeper",
    +      });
    +
    +      await expect(fs.stat(path.join(root, "nested", "deeper"))).resolves.toMatchObject({
    +        isDirectory: expect.any(Function),
    +      });
    +      vi.doUnmock("./fs-pinned-path-helper.js");
    +      vi.resetModules();
    +    },
    +  );
    +
       it("enforces maxBytes when copying into root", async () => {
         const root = await tempDirs.make("openclaw-fs-safe-root-");
         const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
    @@ -399,6 +542,68 @@ describe("fs-safe", () => {
         await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
       });
     
    +  it.runIf(process.platform !== "win32")(
    +    "does not unlink out-of-root file when symlink retarget races remove",
    +    async () => {
    +      const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
    +        seedInsideTarget: true,
    +      });
    +
    +      await withRealpathSymlinkRebindRace({
    +        shouldFlip: (realpathInput) => realpathInput.endsWith(path.join("slot")),
    +        symlinkPath: slot,
    +        symlinkTarget: outside,
    +        timing: "before-realpath",
    +        run: async () => {
    +          await expect(
    +            removePathWithinRoot({
    +              rootDir: root,
    +              relativePath: path.join("slot", "target.txt"),
    +            }),
    +          ).rejects.toMatchObject({
    +            code: expect.stringMatching(/invalid-path|not-found/),
    +          });
    +        },
    +      });
    +
    +      await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "does not create out-of-root directories when symlink retarget races mkdir",
    +    async () => {
    +      const root = await tempDirs.make("openclaw-fs-safe-root-");
    +      const inside = path.join(root, "inside");
    +      const outside = await tempDirs.make("openclaw-fs-safe-outside-");
    +      const slot = path.join(root, "slot");
    +      await fs.mkdir(inside, { recursive: true });
    +      await createRebindableDirectoryAlias({
    +        aliasPath: slot,
    +        targetPath: inside,
    +      });
    +
    +      await withRealpathSymlinkRebindRace({
    +        shouldFlip: (realpathInput) => realpathInput.endsWith(path.join("slot")),
    +        symlinkPath: slot,
    +        symlinkTarget: outside,
    +        timing: "before-realpath",
    +        run: async () => {
    +          await expect(
    +            mkdirPathWithinRoot({
    +              rootDir: root,
    +              relativePath: path.join("slot", "nested", "deep"),
    +            }),
    +          ).rejects.toMatchObject({
    +            code: "invalid-path",
    +          });
    +        },
    +      });
    +
    +      await expect(fs.stat(path.join(outside, "nested"))).rejects.toMatchObject({ code: "ENOENT" });
    +    },
    +  );
    +
       it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => {
         const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture();
         const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
    
  • src/infra/fs-safe.ts+176 1 modified
    @@ -6,10 +6,12 @@ import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     import { logWarn } from "../logger.js";
    +import { resolveBoundaryPath } from "./boundary-path.js";
     import { sameFileIdentity } from "./file-identity.js";
    +import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./fs-pinned-path-helper.js";
     import { runPinnedWriteHelper } from "./fs-pinned-write-helper.js";
     import { expandHomePrefix } from "./home-dir.js";
    -import { assertNoPathAliasEscape } from "./path-alias-guards.js";
    +import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-alias-guards.js";
     import {
       hasNodeErrorCode,
       isNotFoundPathError,
    @@ -544,6 +546,55 @@ export async function appendFileWithinRoot(params: {
       }
     }
     
    +export async function removePathWithinRoot(params: {
    +  rootDir: string;
    +  relativePath: string;
    +}): Promise<void> {
    +  const resolved = await resolvePinnedRemovePathWithinRoot(params);
    +  if (process.platform === "win32") {
    +    await removePathWithinRootLegacy(resolved);
    +    return;
    +  }
    +  try {
    +    await runPinnedPathHelper({
    +      operation: "remove",
    +      rootPath: resolved.rootReal,
    +      relativePath: resolved.relativePosix,
    +    });
    +  } catch (error) {
    +    if (isPinnedPathHelperSpawnError(error)) {
    +      await removePathWithinRootLegacy(resolved);
    +      return;
    +    }
    +    throw normalizePinnedPathError(error);
    +  }
    +}
    +
    +export async function mkdirPathWithinRoot(params: {
    +  rootDir: string;
    +  relativePath: string;
    +  allowRoot?: boolean;
    +}): Promise<void> {
    +  const resolved = await resolvePinnedPathWithinRoot(params);
    +  if (process.platform === "win32") {
    +    await mkdirPathWithinRootLegacy(resolved);
    +    return;
    +  }
    +  try {
    +    await runPinnedPathHelper({
    +      operation: "mkdirp",
    +      rootPath: resolved.rootReal,
    +      relativePath: resolved.relativePosix,
    +    });
    +  } catch (error) {
    +    if (isPinnedPathHelperSpawnError(error)) {
    +      await mkdirPathWithinRootLegacy(resolved);
    +      return;
    +    }
    +    throw normalizePinnedPathError(error);
    +  }
    +}
    +
     export async function writeFileWithinRoot(params: {
       rootDir: string;
       relativePath: string;
    @@ -724,6 +775,96 @@ async function resolvePinnedWriteTargetWithinRoot(params: {
       };
     }
     
    +async function resolvePinnedPathWithinRoot(params: {
    +  rootDir: string;
    +  relativePath: string;
    +  allowRoot?: boolean;
    +}): Promise<{ rootReal: string; resolved: string; relativePosix: string }> {
    +  const resolved = await resolvePinnedBoundaryPathWithinRoot({
    +    rootDir: params.rootDir,
    +    relativePath: params.relativePath,
    +    policy: PATH_ALIAS_POLICIES.strict,
    +  });
    +  const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
    +  if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
    +    return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
    +  }
    +  if (
    +    relativeResolved === "" ||
    +    relativeResolved === "." ||
    +    relativeResolved.startsWith("..") ||
    +    path.isAbsolute(relativeResolved)
    +  ) {
    +    throw new SafeOpenError("outside-workspace", "file is outside workspace root");
    +  }
    +
    +  const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
    +  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
    +    throw new SafeOpenError("outside-workspace", "file is outside workspace root");
    +  }
    +
    +  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
    +}
    +
    +async function resolvePinnedRemovePathWithinRoot(params: {
    +  rootDir: string;
    +  relativePath: string;
    +}): Promise<{ rootReal: string; resolved: string; relativePosix: string }> {
    +  const resolved = await resolvePinnedBoundaryPathWithinRoot({
    +    rootDir: params.rootDir,
    +    relativePath: params.relativePath,
    +    policy: PATH_ALIAS_POLICIES.unlinkTarget,
    +  });
    +  const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
    +  if (
    +    relativeResolved === "" ||
    +    relativeResolved === "." ||
    +    relativeResolved.startsWith("..") ||
    +    path.isAbsolute(relativeResolved)
    +  ) {
    +    throw new SafeOpenError("outside-workspace", "file is outside workspace root");
    +  }
    +  const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
    +  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
    +    throw new SafeOpenError("outside-workspace", "file is outside workspace root");
    +  }
    +
    +  const parentRelative = path.posix.dirname(relativePosix);
    +  if (parentRelative === "." || parentRelative === "") {
    +    return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
    +  }
    +  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
    +}
    +
    +async function resolvePinnedBoundaryPathWithinRoot(params: {
    +  rootDir: string;
    +  relativePath: string;
    +  policy: (typeof PATH_ALIAS_POLICIES)[keyof typeof PATH_ALIAS_POLICIES];
    +}): Promise<{ rootReal: string; rootWithSep: string; canonicalPath: string }> {
    +  const { rootReal } = await resolvePathWithinRoot({
    +    rootDir: params.rootDir,
    +    relativePath: ".",
    +  });
    +  let resolved;
    +  try {
    +    resolved = await resolveBoundaryPath({
    +      absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)),
    +      rootPath: rootReal,
    +      rootCanonicalPath: rootReal,
    +      boundaryLabel: "root",
    +      policy: params.policy,
    +    });
    +  } catch (err) {
    +    throw new SafeOpenError("invalid-path", "path alias escape blocked", { cause: err });
    +  }
    +  const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
    +  return {
    +    rootReal: resolved.rootCanonicalPath,
    +    rootWithSep,
    +    canonicalPath: resolved.canonicalPath,
    +  };
    +}
    +
     function normalizePinnedWriteError(error: unknown): Error {
       if (error instanceof SafeOpenError) {
         return error;
    @@ -733,6 +874,40 @@ function normalizePinnedWriteError(error: unknown): Error {
       });
     }
     
    +function normalizePinnedPathError(error: unknown): Error {
    +  if (error instanceof SafeOpenError) {
    +    return error;
    +  }
    +  if (error instanceof Error) {
    +    const message = error.message;
    +    if (/No such file or directory/i.test(message)) {
    +      return new SafeOpenError("not-found", "file not found", { cause: error });
    +    }
    +    if (/Not a directory|symbolic link|Too many levels of symbolic links/i.test(message)) {
    +      return new SafeOpenError("invalid-path", "path is not under root", { cause: error });
    +    }
    +    if (/Directory not empty/i.test(message)) {
    +      return new SafeOpenError("invalid-path", "directory is not empty", { cause: error });
    +    }
    +    if (/Is a directory|Operation not permitted|Permission denied/i.test(message)) {
    +      return new SafeOpenError("invalid-path", "path is not removable under root", {
    +        cause: error,
    +      });
    +    }
    +  }
    +  return new SafeOpenError("invalid-path", "path is not under root", {
    +    cause: error instanceof Error ? error : undefined,
    +  });
    +}
    +
    +async function removePathWithinRootLegacy(resolved: { resolved: string }): Promise<void> {
    +  await fs.rm(resolved.resolved);
    +}
    +
    +async function mkdirPathWithinRootLegacy(resolved: { resolved: string }): Promise<void> {
    +  await fs.mkdir(resolved.resolved, { recursive: true });
    +}
    +
     async function writeFileWithinRootLegacy(params: {
       rootDir: string;
       relativePath: string;
    

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

3

News mentions

0

No linked articles in our index yet.