fix(openshell): pin host writes to sandbox root (#69797)

* fix(openshell): pin host writes to sandbox root

* fix(openshell): use plugin sdk infra runtime

* fix(openshell): reject symlink write targets

* chore(changelog): note openshell sandbox write fix
This commit is contained in:
Devin Robison
2026-04-21 15:18:28 -06:00
committed by GitHub
parent ae4c5cd460
commit 7be82d4fd1
3 changed files with 67 additions and 10 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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-");