mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
fix(security): default apply_patch workspace containment
This commit is contained in:
@@ -579,7 +579,7 @@ We may add a single `readOnlyMode` flag later to simplify this configuration.
|
|||||||
|
|
||||||
Additional hardening options:
|
Additional hardening options:
|
||||||
|
|
||||||
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off.
|
- `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace.
|
||||||
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail).
|
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail).
|
||||||
|
|
||||||
### 5) Secure baseline (copy/paste)
|
### 5) Secure baseline (copy/paste)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ The tool accepts a single `input` string that wraps one or more file operations:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Patch paths support relative paths (from the workspace directory) and absolute paths.
|
- Patch paths support relative paths (from the workspace directory) and absolute paths.
|
||||||
- Optional: set `tools.exec.applyPatch.workspaceOnly: true` to restrict patch paths to the workspace directory (recommended when untrusted users can trigger tool execution).
|
- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
|
||||||
- Use `*** Move to:` within an `*** Update File:` hunk to rename files.
|
- Use `*** Move to:` within an `*** Update File:` hunk to rename files.
|
||||||
- `*** End of File` marks an EOF-only insert when needed.
|
- `*** End of File` marks an EOF-only insert when needed.
|
||||||
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
|
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
|
||||||
|
|||||||
@@ -178,4 +178,4 @@ Notes:
|
|||||||
- Only available for OpenAI/OpenAI Codex models.
|
- Only available for OpenAI/OpenAI Codex models.
|
||||||
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
|
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
|
||||||
- Config lives under `tools.exec.applyPatch`.
|
- Config lives under `tools.exec.applyPatch`.
|
||||||
- Optional: set `tools.exec.applyPatch.workspaceOnly: true` to restrict patch paths to the workspace directory (recommended when untrusted users can trigger tool execution).
|
- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ Optional plugin tools:
|
|||||||
|
|
||||||
Apply structured patches across one or more files. Use for multi-hunk edits.
|
Apply structured patches across one or more files. Use for multi-hunk edits.
|
||||||
Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only).
|
Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only).
|
||||||
Optional: restrict patch paths to the workspace directory with `tools.exec.applyPatch.workspaceOnly: true`.
|
`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
|
||||||
|
|
||||||
### `exec`
|
### `exec`
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ describe("applyPatch", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects path traversal outside cwd", async () => {
|
it("rejects path traversal outside cwd by default", async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const escapedPath = path.join(path.dirname(dir), "escaped.txt");
|
const escapedPath = path.join(
|
||||||
|
path.dirname(dir),
|
||||||
|
`escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
|
||||||
|
);
|
||||||
const relativeEscape = path.relative(dir, escapedPath);
|
const relativeEscape = path.relative(dir, escapedPath);
|
||||||
|
|
||||||
const patch = `*** Begin Patch
|
const patch = `*** Begin Patch
|
||||||
@@ -81,14 +84,16 @@ describe("applyPatch", () => {
|
|||||||
+escaped
|
+escaped
|
||||||
*** End Patch`;
|
*** End Patch`;
|
||||||
|
|
||||||
await expect(applyPatch(patch, { cwd: dir, workspaceOnly: true })).rejects.toThrow(
|
try {
|
||||||
/Path escapes sandbox root/,
|
await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/Path escapes sandbox root/);
|
||||||
);
|
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
|
||||||
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
|
} finally {
|
||||||
|
await fs.rm(escapedPath, { force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects absolute paths outside cwd", async () => {
|
it("rejects absolute paths outside cwd by default", async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const escapedPath = path.join(os.tmpdir(), `openclaw-apply-patch-${Date.now()}.txt`);
|
const escapedPath = path.join(os.tmpdir(), `openclaw-apply-patch-${Date.now()}.txt`);
|
||||||
|
|
||||||
@@ -98,9 +103,7 @@ describe("applyPatch", () => {
|
|||||||
*** End Patch`;
|
*** End Patch`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(applyPatch(patch, { cwd: dir, workspaceOnly: true })).rejects.toThrow(
|
await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/Path escapes sandbox root/);
|
||||||
/Path escapes sandbox root/,
|
|
||||||
);
|
|
||||||
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
|
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(escapedPath, { force: true });
|
await fs.rm(escapedPath, { force: true });
|
||||||
@@ -108,7 +111,7 @@ describe("applyPatch", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows absolute paths within cwd", async () => {
|
it("allows absolute paths within cwd by default", async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const target = path.join(dir, "nested", "inside.txt");
|
const target = path.join(dir, "nested", "inside.txt");
|
||||||
const patch = `*** Begin Patch
|
const patch = `*** Begin Patch
|
||||||
@@ -116,13 +119,13 @@ describe("applyPatch", () => {
|
|||||||
+inside
|
+inside
|
||||||
*** End Patch`;
|
*** End Patch`;
|
||||||
|
|
||||||
await applyPatch(patch, { cwd: dir, workspaceOnly: true });
|
await applyPatch(patch, { cwd: dir });
|
||||||
const contents = await fs.readFile(target, "utf8");
|
const contents = await fs.readFile(target, "utf8");
|
||||||
expect(contents).toBe("inside\n");
|
expect(contents).toBe("inside\n");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects symlink escape attempts", async () => {
|
it("rejects symlink escape attempts by default", async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const outside = path.join(path.dirname(dir), "outside-target.txt");
|
const outside = path.join(path.dirname(dir), "outside-target.txt");
|
||||||
const linkPath = path.join(dir, "link.txt");
|
const linkPath = path.join(dir, "link.txt");
|
||||||
@@ -136,16 +139,14 @@ describe("applyPatch", () => {
|
|||||||
+pwned
|
+pwned
|
||||||
*** End Patch`;
|
*** End Patch`;
|
||||||
|
|
||||||
await expect(applyPatch(patch, { cwd: dir, workspaceOnly: true })).rejects.toThrow(
|
await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/Symlink escapes sandbox root/);
|
||||||
/Symlink escapes sandbox root/,
|
|
||||||
);
|
|
||||||
const outsideContents = await fs.readFile(outside, "utf8");
|
const outsideContents = await fs.readFile(outside, "utf8");
|
||||||
expect(outsideContents).toBe("initial\n");
|
expect(outsideContents).toBe("initial\n");
|
||||||
await fs.rm(outside, { force: true });
|
await fs.rm(outside, { force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows symlinks that resolve within cwd", async () => {
|
it("allows symlinks that resolve within cwd by default", async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const target = path.join(dir, "target.txt");
|
const target = path.join(dir, "target.txt");
|
||||||
const linkPath = path.join(dir, "link.txt");
|
const linkPath = path.join(dir, "link.txt");
|
||||||
@@ -159,9 +160,60 @@ describe("applyPatch", () => {
|
|||||||
+updated
|
+updated
|
||||||
*** End Patch`;
|
*** End Patch`;
|
||||||
|
|
||||||
await applyPatch(patch, { cwd: dir, workspaceOnly: true });
|
await applyPatch(patch, { cwd: dir });
|
||||||
const contents = await fs.readFile(target, "utf8");
|
const contents = await fs.readFile(target, "utf8");
|
||||||
expect(contents).toBe("updated\n");
|
expect(contents).toBe("updated\n");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects delete path traversal via symlink directories by default", async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const outsideDir = path.join(path.dirname(dir), `outside-dir-${process.pid}-${Date.now()}`);
|
||||||
|
const outsideFile = path.join(outsideDir, "victim.txt");
|
||||||
|
await fs.mkdir(outsideDir, { recursive: true });
|
||||||
|
await fs.writeFile(outsideFile, "victim\n", "utf8");
|
||||||
|
|
||||||
|
const linkDir = path.join(dir, "linkdir");
|
||||||
|
await fs.symlink(outsideDir, linkDir);
|
||||||
|
|
||||||
|
const patch = `*** Begin Patch
|
||||||
|
*** Delete File: linkdir/victim.txt
|
||||||
|
*** End Patch`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(
|
||||||
|
/Symlink escapes sandbox root/,
|
||||||
|
);
|
||||||
|
const stillThere = await fs.readFile(outsideFile, "utf8");
|
||||||
|
expect(stillThere).toBe("victim\n");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(outsideFile, { force: true });
|
||||||
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows path traversal when workspaceOnly is explicitly disabled", async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const escapedPath = path.join(
|
||||||
|
path.dirname(dir),
|
||||||
|
`escaped-allow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
|
||||||
|
);
|
||||||
|
const relativeEscape = path.relative(dir, escapedPath);
|
||||||
|
|
||||||
|
const patch = `*** Begin Patch
|
||||||
|
*** Add File: ${relativeEscape}
|
||||||
|
+escaped
|
||||||
|
*** End Patch`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await applyPatch(patch, { cwd: dir, workspaceOnly: false });
|
||||||
|
expect(result.summary.added.length).toBe(1);
|
||||||
|
const contents = await fs.readFile(escapedPath, "utf8");
|
||||||
|
expect(contents).toBe("escaped\n");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(escapedPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||||
import { applyUpdateHunk } from "./apply-patch-update.js";
|
import { applyUpdateHunk } from "./apply-patch-update.js";
|
||||||
import { assertSandboxPath, resolveSandboxPath } from "./sandbox-paths.js";
|
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||||
|
|
||||||
const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
||||||
const END_PATCH_MARKER = "*** End Patch";
|
const END_PATCH_MARKER = "*** End Patch";
|
||||||
@@ -67,7 +67,7 @@ type SandboxApplyPatchConfig = {
|
|||||||
type ApplyPatchOptions = {
|
type ApplyPatchOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
sandbox?: SandboxApplyPatchConfig;
|
sandbox?: SandboxApplyPatchConfig;
|
||||||
/** When true, restrict patch paths to the workspace root (cwd). Default: false. */
|
/** Restrict patch paths to the workspace root (cwd). Default: true. Set false to opt out. */
|
||||||
workspaceOnly?: boolean;
|
workspaceOnly?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
@@ -83,7 +83,7 @@ export function createApplyPatchTool(
|
|||||||
): AgentTool<typeof applyPatchSchema, ApplyPatchToolDetails> {
|
): AgentTool<typeof applyPatchSchema, ApplyPatchToolDetails> {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const sandbox = options.sandbox;
|
const sandbox = options.sandbox;
|
||||||
const workspaceOnly = options.workspaceOnly === true;
|
const workspaceOnly = options.workspaceOnly !== false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
@@ -155,7 +155,7 @@ export async function applyPatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hunk.kind === "delete") {
|
if (hunk.kind === "delete") {
|
||||||
const target = await resolvePatchPath(hunk.path, options, "unlink");
|
const target = await resolvePatchPath(hunk.path, options);
|
||||||
await fileOps.remove(target.resolved);
|
await fileOps.remove(target.resolved);
|
||||||
recordSummary(summary, seen, "deleted", target.display);
|
recordSummary(summary, seen, "deleted", target.display);
|
||||||
continue;
|
continue;
|
||||||
@@ -254,7 +254,6 @@ async function ensureDir(filePath: string, ops: PatchFileOps) {
|
|||||||
async function resolvePatchPath(
|
async function resolvePatchPath(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
options: ApplyPatchOptions,
|
options: ApplyPatchOptions,
|
||||||
purpose: "readWrite" | "unlink" = "readWrite",
|
|
||||||
): Promise<{ resolved: string; display: string }> {
|
): Promise<{ resolved: string; display: string }> {
|
||||||
if (options.sandbox) {
|
if (options.sandbox) {
|
||||||
const resolved = options.sandbox.bridge.resolvePath({
|
const resolved = options.sandbox.bridge.resolvePath({
|
||||||
@@ -267,16 +266,15 @@ async function resolvePatchPath(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = options.workspaceOnly
|
const workspaceOnly = options.workspaceOnly !== false;
|
||||||
? purpose === "unlink"
|
const resolved = workspaceOnly
|
||||||
? resolveSandboxPath({ filePath, cwd: options.cwd, root: options.cwd }).resolved
|
? (
|
||||||
: (
|
await assertSandboxPath({
|
||||||
await assertSandboxPath({
|
filePath,
|
||||||
filePath,
|
cwd: options.cwd,
|
||||||
cwd: options.cwd,
|
root: options.cwd,
|
||||||
root: options.cwd,
|
})
|
||||||
})
|
).resolved
|
||||||
).resolved
|
|
||||||
: resolvePathFromCwd(filePath, options.cwd);
|
: resolvePathFromCwd(filePath, options.cwd);
|
||||||
return {
|
return {
|
||||||
resolved,
|
resolved,
|
||||||
|
|||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { OpenClawConfig } from "../config/config.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 type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
type ToolWithExecute = {
|
||||||
|
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
describe("Agent-specific tool filtering", () => {
|
describe("Agent-specific tool filtering", () => {
|
||||||
const sandboxFsBridgeStub: SandboxFsBridge = {
|
const sandboxFsBridgeStub: SandboxFsBridge = {
|
||||||
resolvePath: () => ({
|
resolvePath: () => ({
|
||||||
@@ -110,6 +117,99 @@ describe("Agent-specific tool filtering", () => {
|
|||||||
expect(toolNames).toContain("apply_patch");
|
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", () => {
|
it("should apply agent-specific tool policy", () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
tools: {
|
tools: {
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
||||||
const workspaceOnly = fsConfig.workspaceOnly === true;
|
const workspaceOnly = fsConfig.workspaceOnly === true;
|
||||||
const applyPatchConfig = execConfig.applyPatch;
|
const applyPatchConfig = execConfig.applyPatch;
|
||||||
const applyPatchWorkspaceOnly = workspaceOnly || applyPatchConfig?.workspaceOnly === true;
|
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.
|
||||||
|
// (tools.fs.workspaceOnly is a separate umbrella flag for read/write/edit/apply_patch.)
|
||||||
|
const applyPatchWorkspaceOnly = workspaceOnly || applyPatchConfig?.workspaceOnly !== false;
|
||||||
const applyPatchEnabled =
|
const applyPatchEnabled =
|
||||||
!!applyPatchConfig?.enabled &&
|
!!applyPatchConfig?.enabled &&
|
||||||
isOpenAIProvider(options?.modelProvider) &&
|
isOpenAIProvider(options?.modelProvider) &&
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"tools.exec.applyPatch.enabled":
|
"tools.exec.applyPatch.enabled":
|
||||||
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
|
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
|
||||||
"tools.exec.applyPatch.workspaceOnly":
|
"tools.exec.applyPatch.workspaceOnly":
|
||||||
"Restrict apply_patch paths to the workspace directory (default: false).",
|
"Restrict apply_patch paths to the workspace directory (default: true). Set false to allow writing outside the workspace (dangerous).",
|
||||||
"tools.exec.applyPatch.allowModels":
|
"tools.exec.applyPatch.allowModels":
|
||||||
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
|
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
|
||||||
"tools.exec.notifyOnExit":
|
"tools.exec.notifyOnExit":
|
||||||
|
|||||||
Reference in New Issue
Block a user