fix(sandbox): anchor fs-bridge destructive ops

This commit is contained in:
Peter Steinberger
2026-03-08 00:40:41 +00:00
parent 9d2b292998
commit f195af0b22
3 changed files with 77 additions and 5 deletions

View File

@@ -46,6 +46,12 @@ function findCallByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment));
}
function findCallsByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.filter(([args]) =>
getDockerScript(args).includes(fragment),
);
}
function dockerExecResult(stdout: string) {
return {
stdout: Buffer.from(stdout),
@@ -244,6 +250,40 @@ describe("sandbox fs bridge shell compatibility", () => {
expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).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");
});
it("re-validates target before final rename and cleans temp file on failure", async () => {
mockedOpenBoundaryFile
.mockImplementationOnce(async () => ({ ok: false, reason: "path" }))

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../../infra/path-alias-guards.js";
import type { SafeOpenSyncAllowedType } from "../../infra/safe-open-sync.js";
@@ -31,6 +32,11 @@ type PathSafetyCheck = {
options: PathSafetyOptions;
};
type AnchoredSandboxEntry = {
canonicalParentPath: string;
basename: string;
};
export type SandboxResolvedPath = {
hostPath: string;
relativePath: string;
@@ -175,6 +181,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "remove files");
const anchoredTarget = await this.resolveAnchoredSandboxEntry(target);
const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter(
Boolean,
);
@@ -191,8 +198,8 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
},
],
recheckBeforeCommand: true,
script: `set -eu; ${rmCommand} -- "$1"`,
args: [target.containerPath],
script: `set -eu\ncd -- "$1"\n${rmCommand} -- "$2"`,
args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
signal: params.signal,
});
}
@@ -207,6 +214,8 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
const to = this.resolveResolvedPath({ filePath: params.to, cwd: params.cwd });
this.ensureWriteAccess(from, "rename files");
this.ensureWriteAccess(to, "rename files");
const anchoredFrom = await this.resolveAnchoredSandboxEntry(from);
const anchoredTo = await this.resolveAnchoredSandboxEntry(to);
await this.runCheckedCommand({
checks: [
{
@@ -226,9 +235,13 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
},
],
recheckBeforeCommand: true,
script:
'set -eu; dir=$(dirname -- "$2"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; mv -- "$1" "$2"',
args: [from.containerPath, to.containerPath],
script: ["set -eu", 'mkdir -p -- "$2"', 'cd -- "$1"', 'mv -- "$3" "$2/$4"'].join("\n"),
args: [
anchoredFrom.canonicalParentPath,
anchoredTo.canonicalParentPath,
anchoredFrom.basename,
anchoredTo.basename,
],
signal: params.signal,
});
}
@@ -416,6 +429,24 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
return normalizeContainerPath(canonical);
}
private async resolveAnchoredSandboxEntry(
target: SandboxResolvedFsPath,
): Promise<AnchoredSandboxEntry> {
const basename = path.posix.basename(target.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
}
const parentPath = normalizeContainerPath(path.posix.dirname(target.containerPath));
const canonicalParentPath = await this.resolveCanonicalContainerPath({
containerPath: parentPath,
allowFinalSymlinkForUnlink: false,
});
return {
canonicalParentPath,
basename,
};
}
private async writeFileToTempPath(params: {
targetContainerPath: string;
mkdir: boolean;