fix: default and gate apply_patch like write

This commit is contained in:
Peter Steinberger
2026-03-27 01:02:20 +00:00
parent c326083ad8
commit b9c60fd37a
13 changed files with 85 additions and 44 deletions

View File

@@ -36,8 +36,9 @@ The tool accepts a single `input` string that wraps one or more file operations:
- `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.
- `*** End of File` marks an EOF-only insert when needed.
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via
- Available by default for OpenAI and OpenAI Codex models. Set
`tools.exec.applyPatch.enabled: false` to disable it.
- Optionally gate by model via
`tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec`.

View File

@@ -184,16 +184,17 @@ Paste (bracketed by default):
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
```
## apply_patch (experimental)
## apply_patch
`apply_patch` is a subtool of `exec` for structured multi-file edits.
Enable it explicitly:
It is enabled by default for OpenAI and OpenAI Codex models. Use config only
when you want to disable it or restrict it to specific models:
```json5
{
tools: {
exec: {
applyPatch: { enabled: true, workspaceOnly: true, allowModels: ["gpt-5.2"] },
applyPatch: { workspaceOnly: true, allowModels: ["gpt-5.2"] },
},
},
}
@@ -202,6 +203,7 @@ Enable it explicitly:
Notes:
- Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Tool policy still applies; `allow: ["write"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch`.
- `tools.exec.applyPatch.enabled` defaults to `true`; set it to `false` to disable the tool for OpenAI models.
- `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.

View File

@@ -56,12 +56,9 @@ describe("Agent-specific tool filtering", () => {
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
allow: ["read", "write", "exec"],
exec: {
applyPatch: {
enabled: true,
...(opts.workspaceOnly === false ? { workspaceOnly: false } : {}),
},
applyPatch: opts.workspaceOnly === false ? { workspaceOnly: false } : {},
},
},
};
@@ -188,13 +185,10 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).not.toContain("apply_patch");
});
it("should allow apply_patch when exec is allow-listed and applyPatch is enabled", () => {
it("should allow apply_patch for OpenAI models when write is allow-listed", () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: { enabled: true },
},
allow: ["read", "write", "exec"],
},
};
@@ -213,6 +207,30 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).toContain("apply_patch");
});
it("should allow disabling apply_patch explicitly", () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "write", "exec"],
exec: {
applyPatch: { enabled: false },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test",
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("exec");
expect(toolNames).not.toContain("apply_patch");
});
it("defaults apply_patch to workspace-only (blocks traversal)", async () => {
await withApplyPatchEscapeCase({}, async ({ applyPatchTool, escapedPath, patch }) => {
await expect(applyPatchTool.execute("tc1", { input: patch })).rejects.toThrow(

View File

@@ -7,7 +7,11 @@ const defaultTools = createOpenClawCodingTools({ senderIsOwner: true });
describe("createOpenClawCodingTools", () => {
it("preserves action enums in normalized schemas", () => {
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];
const missingNames = toolNames.filter(
(name) => !defaultTools.some((candidate) => candidate.name === name),
);
expect(missingNames).toEqual([]);
const collectActionValues = (schema: unknown, values: Set<string>): void => {
if (!schema || typeof schema !== "object") {
@@ -33,7 +37,6 @@ describe("createOpenClawCodingTools", () => {
for (const name of toolNames) {
const tool = defaultTools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
const parameters = tool?.parameters as {
properties?: Record<string, unknown>;
};
@@ -56,22 +59,34 @@ describe("createOpenClawCodingTools", () => {
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const enabledConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { enabled: true },
},
},
};
const openAiTools = createOpenClawCodingTools({
config: enabledConfig,
modelProvider: "openai",
modelId: "gpt-5.2",
});
expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const codexTools = createOpenClawCodingTools({
modelProvider: "openai-codex",
modelId: "gpt-5.4",
});
expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const disabledConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { enabled: false },
},
},
};
const disabledOpenAiTools = createOpenClawCodingTools({
config: disabledConfig,
modelProvider: "openai",
modelId: "gpt-5.2",
});
expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const anthropicTools = createOpenClawCodingTools({
config: enabledConfig,
config: disabledConfig,
modelProvider: "anthropic",
modelId: "claude-opus-4-5",
});
@@ -80,7 +95,7 @@ describe("createOpenClawCodingTools", () => {
const allowModelsConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { enabled: true, allowModels: ["gpt-5.2"] },
applyPatch: { allowModels: ["gpt-5.2"] },
},
},
};

View File

@@ -30,8 +30,12 @@ describe("pi-tools.policy", () => {
expect(isToolAllowedByPolicyName("web_search", { deny: ["web_*"] })).toBe(false);
});
it("keeps apply_patch when exec is allowlisted", () => {
expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true);
it("keeps apply_patch when write is allowlisted", () => {
expect(isToolAllowedByPolicyName("apply_patch", { allow: ["write"] })).toBe(true);
});
it("blocks apply_patch when write is denylisted", () => {
expect(isToolAllowedByPolicyName("apply_patch", { deny: ["write"] })).toBe(false);
});
});

View File

@@ -91,8 +91,8 @@ describe("tools.fs.workspaceOnly", () => {
workspaceDir: sandboxRoot,
config: {
tools: {
allow: ["read", "exec"],
exec: { applyPatch: { enabled: true } },
allow: ["read", "write", "exec"],
exec: { applyPatch: {} },
},
} as OpenClawConfig,
});
@@ -113,8 +113,8 @@ describe("tools.fs.workspaceOnly", () => {
workspaceDir: sandboxRoot,
config: {
tools: {
allow: ["read", "exec"],
exec: { applyPatch: { enabled: true, workspaceOnly: false } },
allow: ["read", "write", "exec"],
exec: { applyPatch: { workspaceOnly: false } },
},
} as OpenClawConfig,
});

View File

@@ -360,7 +360,7 @@ export function createOpenClawCodingTools(options?: {
// (tools.fs.workspaceOnly is a separate umbrella flag for read/write/edit/apply_patch.)
const applyPatchWorkspaceOnly = workspaceOnly || applyPatchConfig?.workspaceOnly !== false;
const applyPatchEnabled =
!!applyPatchConfig?.enabled &&
applyPatchConfig?.enabled !== false &&
isOpenAIProvider(options?.modelProvider) &&
isApplyPatchAllowedForModel({
modelProvider: options?.modelProvider,

View File

@@ -214,9 +214,7 @@ describe("FS tools with workspaceOnly=false", () => {
config: {
tools: {
exec: {
applyPatch: {
enabled: true,
},
applyPatch: {},
},
},
},

View File

@@ -63,7 +63,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
{
id: "apply_patch",
label: "apply_patch",
description: "Patch files (OpenAI)",
description: "Patch files",
sectionId: "fs",
profiles: ["coding"],
},

View File

@@ -16,13 +16,16 @@ function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
if (matchesAnyGlobPattern(normalized, deny)) {
return false;
}
if (normalized === "apply_patch" && matchesAnyGlobPattern("write", deny)) {
return false;
}
if (allow.length === 0) {
return true;
}
if (matchesAnyGlobPattern(normalized, allow)) {
return true;
}
if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) {
if (normalized === "apply_patch" && matchesAnyGlobPattern("write", allow)) {
return true;
}
return false;

View File

@@ -12358,7 +12358,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
},
"tools.exec.applyPatch.enabled": {
label: "Enable apply_patch",
help: "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
help: "Enable or disable apply_patch for OpenAI and OpenAI Codex models when allowed by tool policy (default: true).",
tags: ["tools"],
},
"tools.exec.applyPatch.workspaceOnly": {

View File

@@ -537,7 +537,7 @@ export const FIELD_HELP: Record<string, string> = {
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
"tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"Enable or disable apply_patch for OpenAI and OpenAI Codex models when allowed by tool policy (default: true).",
"tools.exec.applyPatch.workspaceOnly":
"Restrict apply_patch paths to the workspace directory (default: true). Set false to allow writing outside the workspace (dangerous).",
"tools.exec.applyPatch.allowModels":

View File

@@ -262,9 +262,9 @@ export type ExecToolConfig = {
* Default false to reduce context noise.
*/
notifyOnExitEmptySuccess?: boolean;
/** apply_patch subtool configuration (experimental). */
/** apply_patch subtool configuration. */
applyPatch?: {
/** Enable apply_patch for OpenAI models (default: false). */
/** Enable apply_patch for OpenAI models (default: true; set false to disable). */
enabled?: boolean;
/**
* Restrict apply_patch paths to the workspace directory.