From 06f9677b5b7ca7dd14810196ba178f71d46ebe75 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 22:40:10 +0100 Subject: [PATCH] fix(sandbox): harden EXDEV rename fallback --- CHANGELOG.md | 1 + .../sandbox/fs-bridge-mutation-helper.test.ts | 47 +++++++++++++++++++ .../sandbox/fs-bridge-mutation-helper.ts | 5 ++ .../fs-bridge-mutation-python-source.ts | 13 ++++- 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcfb298785..221d508cdc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index c2a4e9d8e78..597fa66f702 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -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", + ); + }); + }, + ); }); diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts index 4de5a6fd8d6..7fb39d894f7 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts @@ -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)", diff --git a/src/agents/sandbox/fs-bridge-mutation-python-source.ts b/src/agents/sandbox/fs-bridge-mutation-python-source.ts index d0653e6ae41..20b508993ec 100644 --- a/src/agents/sandbox/fs-bridge-mutation-python-source.ts +++ b/src/agents/sandbox/fs-bridge-mutation-python-source.ts @@ -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",