fix(sandbox): harden EXDEV rename fallback

This commit is contained in:
Peter Steinberger
2026-04-05 22:40:10 +01:00
parent beed40e918
commit 06f9677b5b
4 changed files with 65 additions and 1 deletions

View File

@@ -18,6 +18,14 @@ function runMutation(args: string[], input?: string) {
});
}
function runMutationWithSource(source: string, args: string[], input?: string) {
return spawnSync("python3", ["-c", source, ...args], {
input,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
}
function runWritePlan(args: string[], input?: string, env?: NodeJS.ProcessEnv) {
const plan = buildPinnedWritePlan({
check: {
@@ -52,6 +60,11 @@ const hasAbsolutePythonCandidate = SANDBOX_PINNED_MUTATION_PYTHON_CANDIDATES.som
existsSync(candidate),
);
const FORCED_EXDEV_MUTATION_PYTHON = SANDBOX_PINNED_MUTATION_PYTHON.replace(
" os.rename(src_basename, dst_basename, src_dir_fd=src_parent_fd, dst_dir_fd=dst_parent_fd)",
" raise OSError(errno.EXDEV, 'forced EXDEV for test')\n os.rename(src_basename, dst_basename, src_dir_fd=src_parent_fd, dst_dir_fd=dst_parent_fd)",
);
describe("sandbox pinned mutation helper", () => {
it("writes through a pinned directory fd", async () => {
await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
@@ -249,4 +262,38 @@ describe("sandbox pinned mutation helper", () => {
});
},
);
it.runIf(process.platform !== "win32")(
"rejects hardlinked files during rename EXDEV fallback",
async () => {
await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
const sourceRoot = path.join(root, "source");
const destRoot = path.join(root, "dest");
const outsideRoot = path.join(root, "outside");
await fs.mkdir(sourceRoot, { recursive: true });
await fs.mkdir(destRoot, { recursive: true });
await fs.mkdir(outsideRoot, { recursive: true });
await fs.writeFile(path.join(outsideRoot, "secret.txt"), "classified", "utf8");
await fs.link(path.join(outsideRoot, "secret.txt"), path.join(sourceRoot, "linked.txt"));
const result = runMutationWithSource(FORCED_EXDEV_MUTATION_PYTHON, [
"rename",
sourceRoot,
"",
"linked.txt",
destRoot,
"",
"copied.txt",
"1",
]);
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/hardlinked file/i);
await expect(fs.stat(path.join(destRoot, "copied.txt"))).rejects.toThrow();
await expect(fs.readFile(path.join(outsideRoot, "secret.txt"), "utf8")).resolves.toBe(
"classified",
);
});
},
);
});

View File

@@ -183,6 +183,11 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" temp_fd = None",
" temp_name = None",
" try:",
" src_file_stat = os.fstat(src_fd)",
" if not stat.S_ISREG(src_file_stat.st_mode):",
" raise OSError(errno.EPERM, 'only regular files are allowed', src_basename)",
" if src_file_stat.st_nlink > 1:",
" raise OSError(errno.EPERM, 'hardlinked file is not allowed', src_basename)",
" temp_name, temp_fd = create_temp_file(dst_parent_fd, dst_basename)",
" while True:",
" chunk = os.read(src_fd, 65536)",

View File

@@ -1,6 +1,8 @@
// language=python
export const SANDBOX_PINNED_FS_MUTATION_PYTHON = String.raw`import os
export const SANDBOX_PINNED_FS_MUTATION_PYTHON = String.raw`import errno
import os
import secrets
import stat
import subprocess
import sys
@@ -156,6 +158,15 @@ def run_rename(args):
try:
from_parent_fd = walk_parent(from_root_fd, from_relative_parent, False)
to_parent_fd = walk_parent(to_root_fd, to_relative_parent, True)
src_fd = os.open(from_basename, os.O_RDONLY, dir_fd=from_parent_fd)
try:
src_stat = os.fstat(src_fd)
if not stat.S_ISREG(src_stat.st_mode):
raise OSError(errno.EPERM, "only regular files are allowed", from_basename)
if src_stat.st_nlink > 1:
raise OSError(errno.EPERM, "hardlinked file is not allowed", from_basename)
finally:
os.close(src_fd)
run_command(
[
"mv",