fix(security): default apply_patch workspace containment

This commit is contained in:
Peter Steinberger
2026-02-15 01:21:07 +01:00
parent 68c78c4b43
commit 4a44da7d91
9 changed files with 191 additions and 39 deletions

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -5,6 +8,10 @@ import type { SandboxDockerConfig } from "./sandbox.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
type ToolWithExecute = {
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<unknown>;
};
describe("Agent-specific tool filtering", () => {
const sandboxFsBridgeStub: SandboxFsBridge = {
resolvePath: () => ({
@@ -110,6 +117,99 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).toContain("apply_patch");
});
it("defaults apply_patch to workspace-only (blocks traversal)", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-"));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: { enabled: true },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch");
if (!applyPatchTool) {
throw new Error("apply_patch tool missing");
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await expect(
(applyPatchTool as unknown as ToolWithExecute).execute("tc1", { input: patch }),
).rejects.toThrow(/Path escapes sandbox root/);
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("allows disabling apply_patch workspace-only via config (dangerous)", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-"));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-allow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: { enabled: true, workspaceOnly: false },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch");
if (!applyPatchTool) {
throw new Error("apply_patch tool missing");
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await (applyPatchTool as unknown as ToolWithExecute).execute("tc2", { input: patch });
const contents = await fs.readFile(escapedPath, "utf8");
expect(contents).toBe("escaped\n");
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("should apply agent-specific tool policy", () => {
const cfg: OpenClawConfig = {
tools: {