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
1Patches
132a4a47d602eAgents: pin apply-patch workspace mutations (#56016)
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- github.com/openclaw/openclaw/commit/32a4a47d602e0618f87b3e59f94d8c142767f860nvdPatch
- github.com/openclaw/openclaw/security/advisories/GHSA-rm5c-4rmf-vvhwnvdVendor Advisory
- www.vulncheck.com/advisories/openclaw-time-of-check-time-of-use-toctou-vulnerability-in-sandbox-file-operationsnvdThird Party Advisory
News mentions
0No linked articles in our index yet.