mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user