diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f780838574..26814eb820f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Exec/allowlist: reject POSIX parameter expansion forms such as `$VAR`, `$?`, `$$`, `$1`, and `$@` inside unquoted heredocs during shell approval analysis, so these heredocs no longer pass allowlist review as plain text. (#69795) Thanks @drobison00. - Gateway/MCP loopback: derive owner-only tool visibility from distinct authenticated owner vs non-owner loopback bearers instead of the caller-controlled owner header, so non-owner MCP child processes cannot recover owner access by spoofing request metadata. (#69796) - GitHub Copilot: update the default Opus model from `claude-opus-4.6` to `claude-opus-4.7` after GitHub removed Copilot support for 4.6. (#69818) Thanks @shakkernerd. +- OpenShell: pin host-side sandbox writes under the mounted root so symlink-parent rebinds cannot redirect `writeFile` outside the workspace during local mirror updates. (#69797) Thanks @drobison00. ## 2026.4.20 diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 84c2cf409f2..45c3237d475 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -6,6 +6,7 @@ import type { SandboxResolvedPath, } from "openclaw/plugin-sdk/sandbox"; import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox"; +import { writeFileWithinRoot } from "openclaw/plugin-sdk/infra-runtime"; import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js"; import { movePathWithCopyFallback } from "./mirror.js"; @@ -78,16 +79,12 @@ class OpenShellFsBridge implements SandboxFsBridge { const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); - const parentDir = path.dirname(hostPath); - if (params.mkdir !== false) { - await fsPromises.mkdir(parentDir, { recursive: true }); - } - const tempPath = path.join( - parentDir, - `.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`, - ); - await fsPromises.writeFile(tempPath, buffer); - await fsPromises.rename(tempPath, hostPath); + await writeFileWithinRoot({ + rootDir: target.mountHostRoot, + relativePath: path.relative(target.mountHostRoot, hostPath), + data: buffer, + mkdir: params.mkdir, + }); await this.backend.syncLocalPathToRemote(hostPath, target.containerPath); } diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index fba55b96fdb..b5273f82c29 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -248,6 +248,65 @@ describe("openshell fs bridges", () => { ); }); + it("rejects symlink-parent writes instead of escaping the local mount root", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + await fs.symlink(outsideDir, path.join(workspaceDir, "alias")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect( + bridge.writeFile({ + filePath: "alias/escape.txt", + data: "owned", + mkdir: true, + }), + ).rejects.toThrow(); + await expect(fs.stat(path.join(outsideDir, "escape.txt"))).rejects.toThrow(); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + expect(backend.syncLocalPathToRemote).not.toHaveBeenCalled(); + }); + + it("rejects writes whose final target is a symlink inside the local mount root", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const linkedTarget = path.join(workspaceDir, "existing.txt"); + await fs.writeFile(linkedTarget, "keep", "utf8"); + await fs.symlink("existing.txt", path.join(workspaceDir, "link.txt")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect( + bridge.writeFile({ + filePath: "link.txt", + data: "owned", + mkdir: true, + }), + ).rejects.toThrow(); + await expect(fs.readlink(path.join(workspaceDir, "link.txt"))).resolves.toBe("existing.txt"); + await expect(fs.readFile(linkedTarget, "utf8")).resolves.toBe("keep"); + expect(backend.syncLocalPathToRemote).not.toHaveBeenCalled(); + }); + it("maps agent mount paths when the sandbox workspace is read-only", async () => { const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-");