mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:58:38 +00:00
refactor(sandbox): clarify fs bridge read and shell plans
This commit is contained in:
@@ -49,19 +49,17 @@ export class SandboxFsPathGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async assertPathSafety(target: SandboxResolvedFsPath, options: PathSafetyOptions) {
|
async assertPathSafety(target: SandboxResolvedFsPath, options: PathSafetyOptions) {
|
||||||
const lexicalMount = this.resolveRequiredMount(target.containerPath, options.action);
|
const guarded = await this.openBoundaryWithinRequiredMount(target, options.action, {
|
||||||
await this.assertPathSafetyWithinMount(target, options, lexicalMount);
|
aliasPolicy: options.aliasPolicy,
|
||||||
|
allowedType: options.allowedType,
|
||||||
|
});
|
||||||
|
await this.assertGuardedPathSafety(target, options, guarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openReadableFile(
|
async openReadableFile(
|
||||||
target: SandboxResolvedFsPath,
|
target: SandboxResolvedFsPath,
|
||||||
): Promise<BoundaryFileOpenResult & { ok: true }> {
|
): Promise<BoundaryFileOpenResult & { ok: true }> {
|
||||||
const lexicalMount = this.resolveRequiredMount(target.containerPath, "read files");
|
const opened = await this.openBoundaryWithinRequiredMount(target, "read files");
|
||||||
const opened = await openBoundaryFile({
|
|
||||||
absolutePath: target.hostPath,
|
|
||||||
rootPath: lexicalMount.hostRoot,
|
|
||||||
boundaryLabel: "sandbox mount root",
|
|
||||||
});
|
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
throw opened.error instanceof Error
|
throw opened.error instanceof Error
|
||||||
? opened.error
|
? opened.error
|
||||||
@@ -78,18 +76,11 @@ export class SandboxFsPathGuard {
|
|||||||
return lexicalMount;
|
return lexicalMount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertPathSafetyWithinMount(
|
private async assertGuardedPathSafety(
|
||||||
target: SandboxResolvedFsPath,
|
target: SandboxResolvedFsPath,
|
||||||
options: PathSafetyOptions,
|
options: PathSafetyOptions,
|
||||||
lexicalMount: SandboxFsMount,
|
guarded: BoundaryFileOpenResult,
|
||||||
) {
|
) {
|
||||||
const guarded = await openBoundaryFile({
|
|
||||||
absolutePath: target.hostPath,
|
|
||||||
rootPath: lexicalMount.hostRoot,
|
|
||||||
boundaryLabel: "sandbox mount root",
|
|
||||||
aliasPolicy: options.aliasPolicy,
|
|
||||||
allowedType: options.allowedType,
|
|
||||||
});
|
|
||||||
if (!guarded.ok) {
|
if (!guarded.ok) {
|
||||||
if (guarded.reason !== "path") {
|
if (guarded.reason !== "path") {
|
||||||
const canFallbackToDirectoryStat =
|
const canFallbackToDirectoryStat =
|
||||||
@@ -110,12 +101,7 @@ export class SandboxFsPathGuard {
|
|||||||
containerPath: target.containerPath,
|
containerPath: target.containerPath,
|
||||||
allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true,
|
allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true,
|
||||||
});
|
});
|
||||||
const canonicalMount = this.resolveMountByContainerPath(canonicalContainerPath);
|
const canonicalMount = this.resolveRequiredMount(canonicalContainerPath, options.action);
|
||||||
if (!canonicalMount) {
|
|
||||||
throw new Error(
|
|
||||||
`Sandbox path escapes allowed mounts; cannot ${options.action}: ${target.containerPath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.requireWritable && !canonicalMount.writable) {
|
if (options.requireWritable && !canonicalMount.writable) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Sandbox path is read-only; cannot ${options.action}: ${target.containerPath}`,
|
`Sandbox path is read-only; cannot ${options.action}: ${target.containerPath}`,
|
||||||
@@ -123,6 +109,25 @@ export class SandboxFsPathGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async openBoundaryWithinRequiredMount(
|
||||||
|
target: SandboxResolvedFsPath,
|
||||||
|
action: string,
|
||||||
|
options?: {
|
||||||
|
aliasPolicy?: PathAliasPolicy;
|
||||||
|
allowedType?: SafeOpenSyncAllowedType;
|
||||||
|
},
|
||||||
|
): Promise<BoundaryFileOpenResult> {
|
||||||
|
const lexicalMount = this.resolveRequiredMount(target.containerPath, action);
|
||||||
|
const guarded = await openBoundaryFile({
|
||||||
|
absolutePath: target.hostPath,
|
||||||
|
rootPath: lexicalMount.hostRoot,
|
||||||
|
boundaryLabel: "sandbox mount root",
|
||||||
|
aliasPolicy: options?.aliasPolicy,
|
||||||
|
allowedType: options?.allowedType,
|
||||||
|
});
|
||||||
|
return guarded;
|
||||||
|
}
|
||||||
|
|
||||||
async resolveAnchoredSandboxEntry(target: SandboxResolvedFsPath): Promise<AnchoredSandboxEntry> {
|
async resolveAnchoredSandboxEntry(target: SandboxResolvedFsPath): Promise<AnchoredSandboxEntry> {
|
||||||
const basename = path.posix.basename(target.containerPath);
|
const basename = path.posix.basename(target.containerPath);
|
||||||
if (!basename || basename === "." || basename === "/") {
|
if (!basename || basename === "." || basename === "/") {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
createSandbox,
|
createSandbox,
|
||||||
@@ -6,60 +8,113 @@ import {
|
|||||||
findCallsByScriptFragment,
|
findCallsByScriptFragment,
|
||||||
getDockerArg,
|
getDockerArg,
|
||||||
installFsBridgeTestHarness,
|
installFsBridgeTestHarness,
|
||||||
|
mockedExecDockerRaw,
|
||||||
|
withTempDir,
|
||||||
} from "./fs-bridge.test-helpers.js";
|
} from "./fs-bridge.test-helpers.js";
|
||||||
|
|
||||||
describe("sandbox fs bridge anchored ops", () => {
|
describe("sandbox fs bridge anchored ops", () => {
|
||||||
installFsBridgeTestHarness();
|
installFsBridgeTestHarness();
|
||||||
|
|
||||||
it("anchors mkdirp operations on canonical parent + basename", async () => {
|
const pinnedReadCases = [
|
||||||
|
{
|
||||||
|
name: "workspace reads use pinned file descriptors",
|
||||||
|
filePath: "notes/todo.txt",
|
||||||
|
contents: "todo",
|
||||||
|
setup: async (workspaceDir: string) => {
|
||||||
|
await fs.mkdir(path.join(workspaceDir, "notes"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(workspaceDir, "notes", "todo.txt"), "todo");
|
||||||
|
},
|
||||||
|
sandbox: (workspaceDir: string) =>
|
||||||
|
createSandbox({
|
||||||
|
workspaceDir,
|
||||||
|
agentWorkspaceDir: workspaceDir,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bind-mounted reads use pinned file descriptors",
|
||||||
|
filePath: "/workspace-two/README.md",
|
||||||
|
contents: "bind-read",
|
||||||
|
setup: async (workspaceDir: string, stateDir: string) => {
|
||||||
|
const bindRoot = path.join(stateDir, "workspace-two");
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await fs.mkdir(bindRoot, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(bindRoot, "README.md"), "bind-read");
|
||||||
|
},
|
||||||
|
sandbox: (workspaceDir: string, stateDir: string) =>
|
||||||
|
createSandbox({
|
||||||
|
workspaceDir,
|
||||||
|
agentWorkspaceDir: workspaceDir,
|
||||||
|
docker: {
|
||||||
|
...createSandbox().docker,
|
||||||
|
binds: [`${path.join(stateDir, "workspace-two")}:/workspace-two:ro`],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it.each(pinnedReadCases)("$name", async (testCase) => {
|
||||||
|
await withTempDir("openclaw-fs-bridge-contract-read-", async (stateDir) => {
|
||||||
|
const workspaceDir = path.join(stateDir, "workspace");
|
||||||
|
await testCase.setup(workspaceDir, stateDir);
|
||||||
|
const bridge = createSandboxFsBridge({
|
||||||
|
sandbox: testCase.sandbox(workspaceDir, stateDir),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bridge.readFile({ filePath: testCase.filePath })).resolves.toEqual(
|
||||||
|
Buffer.from(testCase.contents),
|
||||||
|
);
|
||||||
|
expect(mockedExecDockerRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchoredCases = [
|
||||||
|
{
|
||||||
|
name: "mkdirp anchors parent + basename",
|
||||||
|
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
|
||||||
|
bridge.mkdirp({ filePath: "nested/leaf" }),
|
||||||
|
scriptFragment: 'mkdir -p -- "$2"',
|
||||||
|
expectedArgs: ["/workspace/nested", "leaf"],
|
||||||
|
forbiddenArgs: ["/workspace/nested/leaf"],
|
||||||
|
canonicalProbe: "/workspace/nested",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove anchors parent + basename",
|
||||||
|
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
|
||||||
|
bridge.remove({ filePath: "nested/file.txt" }),
|
||||||
|
scriptFragment: 'rm -f -- "$2"',
|
||||||
|
expectedArgs: ["/workspace/nested", "file.txt"],
|
||||||
|
forbiddenArgs: ["/workspace/nested/file.txt"],
|
||||||
|
canonicalProbe: "/workspace/nested",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rename anchors both parents + basenames",
|
||||||
|
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
|
||||||
|
bridge.rename({ from: "from.txt", to: "nested/to.txt" }),
|
||||||
|
scriptFragment: 'mv -- "$3" "$2/$4"',
|
||||||
|
expectedArgs: ["/workspace", "/workspace/nested", "from.txt", "to.txt"],
|
||||||
|
forbiddenArgs: ["/workspace/from.txt", "/workspace/nested/to.txt"],
|
||||||
|
canonicalProbe: "/workspace/nested",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it.each(anchoredCases)("$name", async (testCase) => {
|
||||||
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
|
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
|
||||||
|
|
||||||
await bridge.mkdirp({ filePath: "nested/leaf" });
|
await testCase.invoke(bridge);
|
||||||
|
|
||||||
const mkdirCall = findCallByScriptFragment('mkdir -p -- "$2"');
|
const opCall = findCallByScriptFragment(testCase.scriptFragment);
|
||||||
expect(mkdirCall).toBeDefined();
|
expect(opCall).toBeDefined();
|
||||||
const args = mkdirCall?.[0] ?? [];
|
const args = opCall?.[0] ?? [];
|
||||||
expect(getDockerArg(args, 1)).toBe("/workspace/nested");
|
testCase.expectedArgs.forEach((value, index) => {
|
||||||
expect(getDockerArg(args, 2)).toBe("leaf");
|
expect(getDockerArg(args, index + 1)).toBe(value);
|
||||||
expect(args).not.toContain("/workspace/nested/leaf");
|
});
|
||||||
|
testCase.forbiddenArgs.forEach((value) => {
|
||||||
|
expect(args).not.toContain(value);
|
||||||
|
});
|
||||||
|
|
||||||
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
|
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
|
||||||
expect(
|
expect(
|
||||||
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === "/workspace/nested"),
|
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === testCase.canonicalProbe),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("anchors remove operations on canonical parent + basename", async () => {
|
|
||||||
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
|
|
||||||
|
|
||||||
await bridge.remove({ filePath: "nested/file.txt" });
|
|
||||||
|
|
||||||
const removeCall = findCallByScriptFragment('rm -f -- "$2"');
|
|
||||||
expect(removeCall).toBeDefined();
|
|
||||||
const args = removeCall?.[0] ?? [];
|
|
||||||
expect(getDockerArg(args, 1)).toBe("/workspace/nested");
|
|
||||||
expect(getDockerArg(args, 2)).toBe("file.txt");
|
|
||||||
expect(args).not.toContain("/workspace/nested/file.txt");
|
|
||||||
|
|
||||||
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
|
|
||||||
expect(
|
|
||||||
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === "/workspace/nested"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("anchors rename operations on canonical parents + basenames", async () => {
|
|
||||||
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
|
|
||||||
|
|
||||||
await bridge.rename({ from: "from.txt", to: "nested/to.txt" });
|
|
||||||
|
|
||||||
const renameCall = findCallByScriptFragment('mv -- "$3" "$2/$4"');
|
|
||||||
expect(renameCall).toBeDefined();
|
|
||||||
const args = renameCall?.[0] ?? [];
|
|
||||||
expect(getDockerArg(args, 1)).toBe("/workspace");
|
|
||||||
expect(getDockerArg(args, 2)).toBe("/workspace/nested");
|
|
||||||
expect(getDockerArg(args, 3)).toBe("from.txt");
|
|
||||||
expect(getDockerArg(args, 4)).toBe("to.txt");
|
|
||||||
expect(args).not.toContain("/workspace/from.txt");
|
|
||||||
expect(args).not.toContain("/workspace/nested/to.txt");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
|
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
|
||||||
|
import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
|
||||||
import {
|
import {
|
||||||
buildMkdirpPlan,
|
buildMkdirpPlan,
|
||||||
buildRemovePlan,
|
buildRemovePlan,
|
||||||
@@ -7,8 +8,7 @@ import {
|
|||||||
buildStatPlan,
|
buildStatPlan,
|
||||||
buildWriteCommitPlan,
|
buildWriteCommitPlan,
|
||||||
type SandboxFsCommandPlan,
|
type SandboxFsCommandPlan,
|
||||||
} from "./fs-bridge-command-plans.js";
|
} from "./fs-bridge-shell-command-plans.js";
|
||||||
import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
|
|
||||||
import {
|
import {
|
||||||
buildSandboxFsMounts,
|
buildSandboxFsMounts,
|
||||||
resolveSandboxFsPathWithMounts,
|
resolveSandboxFsPathWithMounts,
|
||||||
@@ -99,12 +99,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<Buffer> {
|
}): Promise<Buffer> {
|
||||||
const target = this.resolveResolvedPath(params);
|
const target = this.resolveResolvedPath(params);
|
||||||
const opened = await this.pathGuard.openReadableFile(target);
|
return this.readPinnedFile(target);
|
||||||
try {
|
|
||||||
return fs.readFileSync(opened.fd);
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(opened.fd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeFile(params: {
|
async writeFile(params: {
|
||||||
@@ -239,6 +234,15 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readPinnedFile(target: SandboxResolvedFsPath): Promise<Buffer> {
|
||||||
|
const opened = await this.pathGuard.openReadableFile(target);
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(opened.fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(opened.fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async runCheckedCommand(
|
private async runCheckedCommand(
|
||||||
plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal },
|
plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal },
|
||||||
): Promise<ExecDockerRawResult> {
|
): Promise<ExecDockerRawResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user