Sandbox: add shared bind-aware fs path resolver

This commit is contained in:
Vignesh Natarajan
2026-02-14 16:53:43 -08:00
parent f18e3fba79
commit eafda6f526
3 changed files with 344 additions and 1 deletions

View File

@@ -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 === "") {

View File

@@ -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>): 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/);
});
});

View File

@@ -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<string>();
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();
}