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

@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
- Sandbox/SSH: reject hardlinked files during cross-device rename fallback so EXDEV file copies preserve the same pinned file-boundary checks as direct reads.
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.

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",