From 4fd29a35bb85a1898ebff518364c467058b50e14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 13:19:48 +0100 Subject: [PATCH] fix: block broken-symlink sandbox path escapes --- src/agents/apply-patch.test.ts | 36 ++++++++++++++ src/infra/path-alias-guards.test.ts | 76 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/infra/path-alias-guards.test.ts diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 79d0aa0c0..575f3f21d 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -13,6 +13,15 @@ async function withTempDir(fn: (dir: string) => Promise) { } } +async function withWorkspaceTempDir(fn: (dir: string) => Promise) { + const dir = await fs.mkdtemp(path.join(process.cwd(), "openclaw-patch-workspace-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + function buildAddFilePatch(targetPath: string): string { return `*** Begin Patch *** Add File: ${targetPath} @@ -159,6 +168,33 @@ describe("applyPatch", () => { }); }); + it("rejects broken final symlink targets outside cwd by default", async () => { + if (process.platform === "win32") { + return; + } + await withWorkspaceTempDir(async (dir) => { + const outsideDir = path.join(path.dirname(dir), `outside-broken-link-${Date.now()}`); + const outsideFile = path.join(outsideDir, "owned.txt"); + const linkPath = path.join(dir, "jump"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink(outsideFile, linkPath); + + const patch = `*** Begin Patch +*** Add File: jump ++pwned +*** End Patch`; + + try { + await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow( + /Symlink escapes sandbox root/, + ); + await expect(fs.readFile(outsideFile, "utf8")).rejects.toBeDefined(); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + it("rejects hardlink alias escapes by default", async () => { if (process.platform === "win32") { return; diff --git a/src/infra/path-alias-guards.test.ts b/src/infra/path-alias-guards.test.ts new file mode 100644 index 000000000..abc16c488 --- /dev/null +++ b/src/infra/path-alias-guards.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertNoPathAliasEscape } from "./path-alias-guards.js"; + +async function withTempRoot(run: (root: string) => Promise): Promise { + const base = await fs.mkdtemp(path.join(process.cwd(), "openclaw-path-alias-")); + const root = path.join(base, "root"); + await fs.mkdir(root, { recursive: true }); + try { + return await run(root); + } finally { + await fs.rm(base, { recursive: true, force: true }); + } +} + +describe("assertNoPathAliasEscape", () => { + it.runIf(process.platform !== "win32")( + "rejects broken final symlink targets outside root", + async () => { + await withTempRoot(async (root) => { + const outside = path.join(path.dirname(root), "outside"); + await fs.mkdir(outside, { recursive: true }); + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join(outside, "owned.txt"), linkPath); + + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/); + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "allows broken final symlink targets that remain inside root", + async () => { + await withTempRoot(async (root) => { + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath); + + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).resolves.toBeUndefined(); + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects broken targets that traverse via an in-root symlink alias", + async () => { + await withTempRoot(async (root) => { + const outside = path.join(path.dirname(root), "outside"); + await fs.mkdir(outside, { recursive: true }); + await fs.symlink(outside, path.join(root, "hop")); + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath); + + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/); + }); + }, + ); +});