From eafda6f52671197b7b03a3a457f61c7c4c517926 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 16:53:43 -0800 Subject: [PATCH] Sandbox: add shared bind-aware fs path resolver --- src/agents/sandbox-paths.ts | 6 +- src/agents/sandbox/fs-paths.test.ts | 108 +++++++++++++ src/agents/sandbox/fs-paths.ts | 231 ++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/agents/sandbox/fs-paths.test.ts create mode 100644 src/agents/sandbox/fs-paths.ts diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 2d39572e39d..176854b0073 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -30,11 +30,15 @@ function resolveToCwd(filePath: string, cwd: string): string { return path.resolve(cwd, expanded); } +export function resolveSandboxInputPath(filePath: string, cwd: string): string { + return resolveToCwd(filePath, cwd); +} + export function resolveSandboxPath(params: { filePath: string; cwd: string; root: string }): { resolved: string; relative: string; } { - const resolved = resolveToCwd(params.filePath, params.cwd); + const resolved = resolveSandboxInputPath(params.filePath, params.cwd); const rootResolved = path.resolve(params.root); const relative = path.relative(rootResolved, resolved); if (!relative || relative === "") { diff --git a/src/agents/sandbox/fs-paths.test.ts b/src/agents/sandbox/fs-paths.test.ts new file mode 100644 index 00000000000..cecef5d5c73 --- /dev/null +++ b/src/agents/sandbox/fs-paths.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { SandboxContext } from "./types.js"; +import { + buildSandboxFsMounts, + parseSandboxBindMount, + resolveSandboxFsPathWithMounts, +} from "./fs-paths.js"; + +function createSandbox(overrides?: Partial): SandboxContext { + return { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + workspaceAccess: "rw", + containerName: "openclaw-sbx-test", + containerWorkdir: "/workspace", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + network: "none", + user: "1000:1000", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + capDrop: [], + seccompProfile: "", + apparmorProfile: "", + setupCommand: "", + binds: [], + dns: [], + extraHosts: [], + pidsLimit: 0, + }, + tools: { allow: ["*"], deny: [] }, + browserAllowHostControl: false, + ...overrides, + }; +} + +describe("parseSandboxBindMount", () => { + it("parses bind mode and writeability", () => { + expect(parseSandboxBindMount("/tmp/a:/workspace-a:ro")).toEqual({ + hostRoot: "/tmp/a", + containerRoot: "/workspace-a", + writable: false, + }); + expect(parseSandboxBindMount("/tmp/b:/workspace-b:rw")).toEqual({ + hostRoot: "/tmp/b", + containerRoot: "/workspace-b", + writable: true, + }); + }); +}); + +describe("resolveSandboxFsPathWithMounts", () => { + it("maps mounted container absolute paths to host paths", () => { + const sandbox = createSandbox({ + docker: { + ...createSandbox().docker, + binds: ["/tmp/workspace-two:/workspace-two:ro"], + }, + }); + const mounts = buildSandboxFsMounts(sandbox); + const resolved = resolveSandboxFsPathWithMounts({ + filePath: "/workspace-two/docs/AGENTS.md", + cwd: sandbox.workspaceDir, + defaultWorkspaceRoot: sandbox.workspaceDir, + defaultContainerRoot: sandbox.containerWorkdir, + mounts, + }); + + expect(resolved.hostPath).toBe("/tmp/workspace-two/docs/AGENTS.md"); + expect(resolved.containerPath).toBe("/workspace-two/docs/AGENTS.md"); + expect(resolved.relativePath).toBe("/workspace-two/docs/AGENTS.md"); + expect(resolved.writable).toBe(false); + }); + + it("keeps workspace-relative display paths for default workspace files", () => { + const sandbox = createSandbox(); + const mounts = buildSandboxFsMounts(sandbox); + const resolved = resolveSandboxFsPathWithMounts({ + filePath: "src/index.ts", + cwd: sandbox.workspaceDir, + defaultWorkspaceRoot: sandbox.workspaceDir, + defaultContainerRoot: sandbox.containerWorkdir, + mounts, + }); + expect(resolved.hostPath).toBe("/tmp/workspace/src/index.ts"); + expect(resolved.containerPath).toBe("/workspace/src/index.ts"); + expect(resolved.relativePath).toBe("src/index.ts"); + expect(resolved.writable).toBe(true); + }); + + it("preserves legacy sandbox-root error for outside paths", () => { + const sandbox = createSandbox(); + const mounts = buildSandboxFsMounts(sandbox); + expect(() => + resolveSandboxFsPathWithMounts({ + filePath: "/etc/passwd", + cwd: sandbox.workspaceDir, + defaultWorkspaceRoot: sandbox.workspaceDir, + defaultContainerRoot: sandbox.containerWorkdir, + mounts, + }), + ).toThrow(/Path escapes sandbox root/); + }); +}); diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts new file mode 100644 index 00000000000..6b09682b1d6 --- /dev/null +++ b/src/agents/sandbox/fs-paths.ts @@ -0,0 +1,231 @@ +import path from "node:path"; +import type { SandboxContext } from "./types.js"; +import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js"; +import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; + +export type SandboxFsMount = { + hostRoot: string; + containerRoot: string; + writable: boolean; + source: "workspace" | "agent" | "bind"; +}; + +export type SandboxResolvedFsPath = { + hostPath: string; + relativePath: string; + containerPath: string; + writable: boolean; +}; + +type ParsedBindMount = { + hostRoot: string; + containerRoot: string; + writable: boolean; +}; + +export function parseSandboxBindMount(spec: string): ParsedBindMount | null { + const trimmed = spec.trim(); + if (!trimmed) { + return null; + } + const parts = trimmed.split(":"); + if (parts.length < 2) { + return null; + } + const hostToken = (parts[0] ?? "").trim(); + const containerToken = (parts[1] ?? "").trim(); + if (!hostToken || !containerToken || !path.posix.isAbsolute(containerToken)) { + return null; + } + const optionsToken = parts.slice(2).join(":").trim().toLowerCase(); + const optionParts = optionsToken + ? optionsToken + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + : []; + const writable = !optionParts.includes("ro"); + return { + hostRoot: path.resolve(hostToken), + containerRoot: normalizeContainerPath(containerToken), + writable, + }; +} + +export function buildSandboxFsMounts(sandbox: SandboxContext): SandboxFsMount[] { + const mounts: SandboxFsMount[] = [ + { + hostRoot: path.resolve(sandbox.workspaceDir), + containerRoot: normalizeContainerPath(sandbox.containerWorkdir), + writable: sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + + if ( + sandbox.workspaceAccess !== "none" && + path.resolve(sandbox.agentWorkspaceDir) !== path.resolve(sandbox.workspaceDir) + ) { + mounts.push({ + hostRoot: path.resolve(sandbox.agentWorkspaceDir), + containerRoot: SANDBOX_AGENT_WORKSPACE_MOUNT, + writable: sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + + for (const bind of sandbox.docker.binds ?? []) { + const parsed = parseSandboxBindMount(bind); + if (!parsed) { + continue; + } + mounts.push({ + hostRoot: parsed.hostRoot, + containerRoot: parsed.containerRoot, + writable: parsed.writable, + source: "bind", + }); + } + + return dedupeMounts(mounts); +} + +export function resolveSandboxFsPathWithMounts(params: { + filePath: string; + cwd: string; + defaultWorkspaceRoot: string; + defaultContainerRoot: string; + mounts: SandboxFsMount[]; +}): SandboxResolvedFsPath { + const mountsByContainer = [...params.mounts].toSorted( + (a, b) => b.containerRoot.length - a.containerRoot.length, + ); + const mountsByHost = [...params.mounts].toSorted((a, b) => b.hostRoot.length - a.hostRoot.length); + const input = params.filePath; + const inputPosix = normalizePosixInput(input); + + if (path.posix.isAbsolute(inputPosix)) { + const containerMount = findMountByContainerPath(mountsByContainer, inputPosix); + if (containerMount) { + const rel = path.posix.relative(containerMount.containerRoot, inputPosix); + const hostPath = rel + ? path.resolve(containerMount.hostRoot, ...toHostSegments(rel)) + : containerMount.hostRoot; + return { + hostPath, + containerPath: rel + ? path.posix.join(containerMount.containerRoot, rel) + : containerMount.containerRoot, + relativePath: toDisplayRelative({ + containerPath: rel + ? path.posix.join(containerMount.containerRoot, rel) + : containerMount.containerRoot, + defaultContainerRoot: params.defaultContainerRoot, + }), + writable: containerMount.writable, + }; + } + } + + const hostResolved = resolveSandboxInputPath(input, params.cwd); + const hostMount = findMountByHostPath(mountsByHost, hostResolved); + if (hostMount) { + const relHost = path.relative(hostMount.hostRoot, hostResolved); + const relPosix = relHost ? relHost.split(path.sep).join(path.posix.sep) : ""; + const containerPath = relPosix + ? path.posix.join(hostMount.containerRoot, relPosix) + : hostMount.containerRoot; + return { + hostPath: hostResolved, + containerPath, + relativePath: toDisplayRelative({ + containerPath, + defaultContainerRoot: params.defaultContainerRoot, + }), + writable: hostMount.writable, + }; + } + + // Preserve legacy error wording for out-of-sandbox paths. + resolveSandboxPath({ + filePath: input, + cwd: params.cwd, + root: params.defaultWorkspaceRoot, + }); + throw new Error(`Path escapes sandbox root (${params.defaultWorkspaceRoot}): ${input}`); +} + +function dedupeMounts(mounts: SandboxFsMount[]): SandboxFsMount[] { + const seen = new Set(); + const deduped: SandboxFsMount[] = []; + for (const mount of mounts) { + const key = `${mount.hostRoot}=>${mount.containerRoot}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(mount); + } + return deduped; +} + +function findMountByContainerPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null { + for (const mount of mounts) { + if (isPathInsidePosix(mount.containerRoot, target)) { + return mount; + } + } + return null; +} + +function findMountByHostPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null { + for (const mount of mounts) { + if (isPathInsideHost(mount.hostRoot, target)) { + return mount; + } + } + return null; +} + +function isPathInsidePosix(root: string, target: string): boolean { + const rel = path.posix.relative(root, target); + if (!rel) { + return true; + } + return !(rel.startsWith("..") || path.posix.isAbsolute(rel)); +} + +function isPathInsideHost(root: string, target: string): boolean { + const rel = path.relative(root, target); + if (!rel) { + return true; + } + return !(rel.startsWith("..") || path.isAbsolute(rel)); +} + +function toHostSegments(relativePosix: string): string[] { + return relativePosix.split("/").filter(Boolean); +} + +function toDisplayRelative(params: { + containerPath: string; + defaultContainerRoot: string; +}): string { + const rel = path.posix.relative(params.defaultContainerRoot, params.containerPath); + if (!rel) { + return ""; + } + if (!rel.startsWith("..") && !path.posix.isAbsolute(rel)) { + return rel; + } + return params.containerPath; +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value); + return normalized === "." ? "/" : normalized; +} + +function normalizePosixInput(value: string): string { + return value.replace(/\\/g, "/").trim(); +}