From 25b30c95200d3af384b32878f5c9ed8cbf72fef8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 02:29:36 +0100 Subject: [PATCH] fix: honor embedded runtime tool allowlists (#77609) * fix: honor embedded runtime tool allowlists * fix: preserve plugin allowlist filtering * fix: gate bundled lsp allowlists --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 34 +- src/agents/openclaw-tools.update-plan.test.ts | 68 ++++ .../attempt-tool-construction-plan.test.ts | 303 ++++++++++++++++++ .../run/attempt-tool-construction-plan.ts | 232 ++++++++++++++ .../pi-embedded-runner/run/attempt.test.ts | 64 ---- src/agents/pi-embedded-runner/run/attempt.ts | 284 +++++++--------- ...tools.create-openclaw-coding-tools.test.ts | 68 ++++ src/agents/pi-tools.ts | 106 +++--- 9 files changed, 874 insertions(+), 286 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a2d32cdfd..378947ed5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y. +- Agents/tools: honor narrow runtime tool allowlists when constructing embedded-runner tool families and bundled MCP/LSP runtimes, so cron/subagent runs that request tools such as `update_plan`, `browser`, `x_search`, channel login tools, or `group:plugins` no longer start with missing tools or unrelated bootstrap work. (#77519, #77532) - Codex plugin: mirror the experimental upstream app-server protocol and format generated TypeScript before drift checks, keeping OpenClaw's `experimentalApi` bridge compatible with latest Codex while preserving formatter gates. - Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as ``. Fixes #69793. Thanks @aspalagin. - Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index c9df46a34a5..79f23b22910 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -94,6 +94,17 @@ function isToolAllowedByFactoryPolicy(params: { }); } +function isToolExplicitlyAllowedByFactoryPolicy(params: { + toolName: string; + allowlist?: string[]; + denylist?: string[]; +}): boolean { + if (!params.allowlist?.some((entry) => typeof entry === "string" && entry.trim().length > 0)) { + return false; + } + return isToolAllowedByFactoryPolicy(params); +} + function mergeFactoryPolicyList(...lists: Array): string[] | undefined { const merged = lists.flatMap((list) => (Array.isArray(list) ? list : [])); return merged.length > 0 ? Array.from(new Set(merged)) : undefined; @@ -499,6 +510,19 @@ export function createOpenClawTools( const effectiveCallGateway = embedded ? createEmbeddedCallGateway() : openClawToolsDeps.callGateway; + const includeUpdatePlanTool = + isToolExplicitlyAllowedByFactoryPolicy({ + toolName: "update_plan", + allowlist: mergeFactoryPolicyList(resolvedConfig?.tools?.allow, options?.pluginToolAllowlist), + denylist: mergeFactoryPolicyList(resolvedConfig?.tools?.deny, options?.pluginToolDenylist), + }) || + isUpdatePlanToolEnabledForOpenClawTools({ + config: resolvedConfig, + agentSessionKey: options?.agentSessionKey, + agentId: options?.requesterAgentIdOverride, + modelProvider: options?.modelProvider, + modelId: options?.modelId, + }); const tools: AnyAgentTool[] = [ ...(embedded ? [] @@ -539,15 +563,7 @@ export function createOpenClawTools( agentSessionKey: options?.agentSessionKey, requesterAgentIdOverride: options?.requesterAgentIdOverride, }), - ...(isUpdatePlanToolEnabledForOpenClawTools({ - config: resolvedConfig, - agentSessionKey: options?.agentSessionKey, - agentId: options?.requesterAgentIdOverride, - modelProvider: options?.modelProvider, - modelId: options?.modelId, - }) - ? [createUpdatePlanTool()] - : []), + ...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index 3d49ab95314..2b3a6b43d00 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import { isUpdatePlanToolEnabledForOpenClawTools } from "./openclaw-tools.registration.js"; import { createUpdatePlanTool } from "./tools/update-plan-tool.js"; @@ -31,6 +32,25 @@ describe("openclaw-tools update_plan gating", () => { expectUpdatePlanEnabled({ config: {} as OpenClawConfig }, false); }); + it("does not expose update_plan from default tool construction", () => { + const defaultTools = createOpenClawTools({ + config: {} as OpenClawConfig, + disablePluginTools: true, + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + const emptyAllowlistTools = createOpenClawTools({ + config: {} as OpenClawConfig, + disablePluginTools: true, + pluginToolAllowlist: [], + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + + expect(defaultTools.some((tool) => tool.name === "update_plan")).toBe(false); + expect(emptyAllowlistTools.some((tool) => tool.name === "update_plan")).toBe(false); + }); + it("registers update_plan when explicitly enabled", () => { const config = { tools: { @@ -44,6 +64,54 @@ describe("openclaw-tools update_plan gating", () => { expect(createUpdatePlanTool().displaySummary).toBe("Track a short structured work plan."); }); + it("registers update_plan when the runtime allowlist explicitly requests it", () => { + const tools = createOpenClawTools({ + config: {} as OpenClawConfig, + disablePluginTools: true, + pluginToolAllowlist: ["update_plan"], + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + + expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + }); + + it("registers update_plan when a config allowlist group includes it", () => { + const tools = createOpenClawTools({ + config: { tools: { allow: ["group:agents"] } } as OpenClawConfig, + disablePluginTools: true, + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + + expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + }); + + it("registers update_plan when a runtime allowlist group includes it", () => { + const tools = createOpenClawTools({ + config: {} as OpenClawConfig, + disablePluginTools: true, + pluginToolAllowlist: ["group:agents"], + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + + expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + }); + + it("respects deny policy while constructing update_plan for grouped allowlists", () => { + const tools = createOpenClawTools({ + config: {} as OpenClawConfig, + disablePluginTools: true, + pluginToolAllowlist: ["group:agents"], + pluginToolDenylist: ["update_plan"], + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + + expect(tools.some((tool) => tool.name === "update_plan")).toBe(false); + }); + it("auto-enables update_plan for unconfigured GPT-5 openai runs", () => { // Criterion 1 of the GPT-5.4 parity gate ("no stalls after planning") is // universal, not opt-in. Unspecified executionContract on a supported diff --git a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts new file mode 100644 index 00000000000..b0c28859f78 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vitest"; +import { + applyEmbeddedAttemptToolsAllow, + resolveEmbeddedAttemptToolConstructionPlan, + shouldBuildCoreCodingToolsForAllowlist, + shouldCreateBundleLspRuntimeForAttempt, + shouldCreateBundleMcpRuntimeForAttempt, +} from "./attempt-tool-construction-plan.js"; + +describe("applyEmbeddedAttemptToolsAllow", () => { + it("keeps explicit toolsAllow authoritative after force-added tools are built", () => { + const tools = [{ name: "exec" }, { name: "read" }, { name: "message" }]; + + expect( + applyEmbeddedAttemptToolsAllow(tools, ["exec", "read"]).map((tool) => tool.name), + ).toEqual(["exec", "read"]); + }); + + it("normalizes explicit toolsAllow entries before filtering", () => { + const tools = [{ name: "cron" }, { name: "read" }, { name: "message" }]; + + expect( + applyEmbeddedAttemptToolsAllow(tools, [" cron ", "READ"]).map((tool) => tool.name), + ).toEqual(["cron", "read"]); + }); + + it("honors wildcard and group allowlists in the final filter", () => { + const tools = [{ name: "exec" }, { name: "read" }, { name: "message" }]; + + expect(applyEmbeddedAttemptToolsAllow(tools, ["*"]).map((tool) => tool.name)).toEqual([ + "exec", + "read", + "message", + ]); + expect(applyEmbeddedAttemptToolsAllow(tools, ["group:fs"]).map((tool) => tool.name)).toEqual([ + "read", + ]); + }); + + it("keeps plugin-only allowlists on the shared tool policy path", () => { + const tools = [{ name: "memory_search" }, { name: "plugin_extra" }]; + + expect(shouldBuildCoreCodingToolsForAllowlist(["memory_search"])).toBe(false); + expect( + applyEmbeddedAttemptToolsAllow(tools, ["memory_search"]).map((tool) => tool.name), + ).toEqual(["memory_search"]); + }); + + it("expands plugin group and plugin-id allowlists before the final filter", () => { + const tools = [ + { name: "exec" }, + { name: "memory_search" }, + { name: "memory_get" }, + { name: "browser" }, + ]; + const toolMeta = (tool: { name: string }) => { + if (tool.name.startsWith("memory_")) { + return { pluginId: "active-memory" }; + } + if (tool.name === "browser") { + return { pluginId: "browser" }; + } + return undefined; + }; + + expect( + applyEmbeddedAttemptToolsAllow(tools, ["group:plugins"], { toolMeta }).map( + (tool) => tool.name, + ), + ).toEqual(["memory_search", "memory_get", "browser"]); + expect( + applyEmbeddedAttemptToolsAllow(tools, ["active-memory"], { toolMeta }).map( + (tool) => tool.name, + ), + ).toEqual(["memory_search", "memory_get"]); + }); + + it("treats an explicit empty toolsAllow as no tools", () => { + const tools = [{ name: "exec" }, { name: "read" }, { name: "message" }]; + + expect(applyEmbeddedAttemptToolsAllow(tools, []).map((tool) => tool.name)).toEqual([]); + expect(shouldBuildCoreCodingToolsForAllowlist([])).toBe(false); + }); +}); + +describe("resolveEmbeddedAttemptToolConstructionPlan", () => { + it("builds all tool families when no runtime allowlist is present", () => { + expect(resolveEmbeddedAttemptToolConstructionPlan({})).toMatchObject({ + constructTools: true, + includeCoreTools: true, + codingToolConstructionPlan: { + includeBaseCodingTools: true, + includeShellTools: true, + includeChannelTools: true, + includeOpenClawTools: true, + includePluginTools: true, + }, + }); + }); + + it("short-circuits all local tool construction for explicit no-tools runs", () => { + expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: [] })).toMatchObject({ + constructTools: false, + includeCoreTools: false, + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + }); + + it("materializes only plugin candidates for plugin-only allowlists", () => { + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["memory_search"] }), + ).toMatchObject({ + constructTools: true, + includeCoreTools: false, + runtimeToolAllowlist: ["memory_search"], + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + }); + + it("limits known core allowlists to the matching local families", () => { + expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["read"] })).toMatchObject({ + constructTools: true, + includeCoreTools: true, + codingToolConstructionPlan: { + includeBaseCodingTools: true, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["exec"] })).toMatchObject({ + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: true, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["session_status"] }), + ).toMatchObject({ + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: false, + }, + }); + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["update_plan"] }), + ).toMatchObject({ + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: false, + }, + }); + }); + + it("keeps plugin-owned catalog tools on the plugin construction path", () => { + expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["browser"] })).toMatchObject({ + constructTools: true, + includeCoreTools: false, + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["code_execution"] }), + ).toMatchObject({ + constructTools: true, + includeCoreTools: false, + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["x_search"] })).toMatchObject({ + includeCoreTools: false, + codingToolConstructionPlan: { + includeChannelTools: true, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + }); + + it("keeps channel tools available for narrow channel-owned allowlists", () => { + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["whatsapp_login"] }), + ).toMatchObject({ + constructTools: true, + includeCoreTools: false, + codingToolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + }); + + it("skips local construction when only bundled tool runtimes can match", () => { + expect( + resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: ["strict__strict_probe"] }), + ).toMatchObject({ + constructTools: false, + includeCoreTools: false, + }); + }); +}); + +describe("shouldCreateBundleMcpRuntimeForAttempt", () => { + it("skips bundle MCP runtime when tools are disabled", () => { + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: false })).toBe(false); + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, disableTools: true })).toBe( + false, + ); + }); + + it("creates bundle MCP only when the allowlist can reach bundle MCP tool names", () => { + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })).toBe(true); + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, toolsAllow: ["*"] })).toBe( + true, + ); + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, toolsAllow: [] })).toBe( + false, + ); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["memory_search", "memory_get"], + }), + ).toBe(false); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["group:plugins"], + }), + ).toBe(true); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["bundle-mcp"], + }), + ).toBe(true); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["strict__strict_probe"], + }), + ).toBe(true); + }); +}); + +describe("shouldCreateBundleLspRuntimeForAttempt", () => { + it("skips bundle LSP startup when runtime allowlists cannot reach LSP tools", () => { + expect(shouldCreateBundleLspRuntimeForAttempt({ toolsEnabled: true })).toBe(true); + expect(shouldCreateBundleLspRuntimeForAttempt({ toolsEnabled: true, toolsAllow: ["*"] })).toBe( + true, + ); + expect(shouldCreateBundleLspRuntimeForAttempt({ toolsEnabled: true, toolsAllow: [] })).toBe( + false, + ); + expect( + shouldCreateBundleLspRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["memory_search"], + }), + ).toBe(false); + expect( + shouldCreateBundleLspRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["lsp_hover_typescript"], + }), + ).toBe(true); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts new file mode 100644 index 00000000000..91e7d623a3b --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts @@ -0,0 +1,232 @@ +import { TOOL_NAME_SEPARATOR } from "../../pi-bundle-mcp-names.js"; +import type { OpenClawCodingToolConstructionPlan } from "../../pi-tools.js"; +import { isToolAllowedByPolicyName } from "../../tool-policy-match.js"; +import { + buildPluginToolGroups, + expandPolicyWithPluginGroups, + expandToolGroups, + normalizeToolName, +} from "../../tool-policy.js"; + +const BASE_CODING_TOOL_FACTORY_NAMES = new Set(["edit", "read", "write"]); + +const SHELL_CODING_TOOL_FACTORY_NAMES = new Set(["apply_patch", "exec", "process"]); + +// Names here must be emitted directly by createOpenClawTools(). Catalog entries +// backed by plugin registration, such as browser/x_search/code_execution, stay +// out of this set so narrow allowlists still materialize plugin tools. +const OPENCLAW_TOOL_FACTORY_NAMES = new Set([ + "agents_list", + "canvas", + "cron", + "gateway", + "heartbeat_respond", + "heartbeat_response", + "image", + "image_generate", + "message", + "music_generate", + "nodes", + "pdf", + "session_status", + "sessions_history", + "sessions_list", + "sessions_send", + "sessions_spawn", + "sessions_yield", + "subagents", + "tts", + "update_plan", + "video_generate", + "web_fetch", + "web_search", +]); + +const ALL_CODING_TOOL_CONSTRUCTION_PLAN: OpenClawCodingToolConstructionPlan = { + includeBaseCodingTools: true, + includeShellTools: true, + includeChannelTools: true, + includeOpenClawTools: true, + includePluginTools: true, +}; + +const NO_CODING_TOOL_CONSTRUCTION_PLAN: OpenClawCodingToolConstructionPlan = { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, +}; + +function cloneCodingToolConstructionPlan( + plan: OpenClawCodingToolConstructionPlan, +): OpenClawCodingToolConstructionPlan { + return { ...plan }; +} + +function isBundleMcpAllowlistName(normalized: string): boolean { + return normalized === "bundle-mcp" || normalized.includes(TOOL_NAME_SEPARATOR); +} + +function isPluginGroupAllowlistName(normalized: string): boolean { + return normalized === "group:plugins"; +} + +function hasWildcardToolAllowlist(toolsAllow: string[]): boolean { + return toolsAllow.some((entry) => normalizeToolName(entry) === "*"); +} + +function isKnownLocalCodingToolName(normalized: string): boolean { + return ( + BASE_CODING_TOOL_FACTORY_NAMES.has(normalized) || + SHELL_CODING_TOOL_FACTORY_NAMES.has(normalized) || + OPENCLAW_TOOL_FACTORY_NAMES.has(normalized) + ); +} + +export function applyEmbeddedAttemptToolsAllow( + tools: T[], + toolsAllow?: string[], + options?: { + toolMeta?: (tool: T) => { pluginId: string } | undefined; + }, +): T[] { + if (!toolsAllow) { + return tools; + } + if (toolsAllow.length === 0) { + return []; + } + if (hasWildcardToolAllowlist(toolsAllow)) { + return tools; + } + const pluginGroups = options?.toolMeta + ? buildPluginToolGroups({ tools, toolMeta: options.toolMeta }) + : undefined; + const policy = pluginGroups + ? expandPolicyWithPluginGroups({ allow: toolsAllow }, pluginGroups) + : { allow: toolsAllow }; + return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); +} + +function resolveCodingToolConstructionPlanForAllowlist( + toolsAllow?: string[], +): OpenClawCodingToolConstructionPlan { + if (!toolsAllow) { + return cloneCodingToolConstructionPlan(ALL_CODING_TOOL_CONSTRUCTION_PLAN); + } + if (toolsAllow.length === 0) { + return cloneCodingToolConstructionPlan(NO_CODING_TOOL_CONSTRUCTION_PLAN); + } + if (hasWildcardToolAllowlist(toolsAllow)) { + return cloneCodingToolConstructionPlan(ALL_CODING_TOOL_CONSTRUCTION_PLAN); + } + const expanded = expandToolGroups(toolsAllow); + const normalized = expanded.map((entry) => normalizeToolName(entry)).filter(Boolean); + const includeBaseCodingTools = normalized.some((name) => + BASE_CODING_TOOL_FACTORY_NAMES.has(name), + ); + const includeShellTools = normalized.some((name) => SHELL_CODING_TOOL_FACTORY_NAMES.has(name)); + const includeOpenClawTools = normalized.some((name) => OPENCLAW_TOOL_FACTORY_NAMES.has(name)); + const includePluginTools = normalized.some( + (name) => + name === "group:plugins" || + (!isBundleMcpAllowlistName(name) && !isKnownLocalCodingToolName(name)), + ); + const includeChannelTools = includePluginTools; + + return { + includeBaseCodingTools, + includeShellTools, + includeChannelTools, + includeOpenClawTools, + includePluginTools, + }; +} + +export function resolveEmbeddedAttemptToolConstructionPlan(params: { + disableTools?: boolean; + isRawModelRun?: boolean; + toolsAllow?: string[]; +}): { + constructTools: boolean; + includeCoreTools: boolean; + runtimeToolAllowlist?: string[]; + codingToolConstructionPlan: OpenClawCodingToolConstructionPlan; +} { + if (params.disableTools === true || params.isRawModelRun === true) { + return { + constructTools: false, + includeCoreTools: false, + codingToolConstructionPlan: cloneCodingToolConstructionPlan(NO_CODING_TOOL_CONSTRUCTION_PLAN), + }; + } + const codingToolConstructionPlan = resolveCodingToolConstructionPlanForAllowlist( + params.toolsAllow, + ); + const includeCoreTools = + codingToolConstructionPlan.includeBaseCodingTools || + codingToolConstructionPlan.includeShellTools || + codingToolConstructionPlan.includeOpenClawTools; + const constructTools = + includeCoreTools || + codingToolConstructionPlan.includeChannelTools || + codingToolConstructionPlan.includePluginTools; + + return { + constructTools, + includeCoreTools, + ...(params.toolsAllow ? { runtimeToolAllowlist: params.toolsAllow } : {}), + codingToolConstructionPlan, + }; +} + +export function shouldBuildCoreCodingToolsForAllowlist(toolsAllow?: string[]): boolean { + return resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow }).includeCoreTools; +} + +export function shouldCreateBundleMcpRuntimeForAttempt(params: { + toolsEnabled: boolean; + disableTools?: boolean; + toolsAllow?: string[]; +}): boolean { + if (!params.toolsEnabled || params.disableTools === true) { + return false; + } + if (!params.toolsAllow) { + return true; + } + if (params.toolsAllow.length === 0) { + return false; + } + if (hasWildcardToolAllowlist(params.toolsAllow)) { + return true; + } + return params.toolsAllow.some((toolName) => { + const normalized = normalizeToolName(toolName); + return isBundleMcpAllowlistName(normalized) || isPluginGroupAllowlistName(normalized); + }); +} + +export function shouldCreateBundleLspRuntimeForAttempt(params: { + toolsEnabled: boolean; + disableTools?: boolean; + toolsAllow?: string[]; +}): boolean { + if (!params.toolsEnabled || params.disableTools === true) { + return false; + } + if (!params.toolsAllow) { + return true; + } + if (params.toolsAllow.length === 0) { + return false; + } + if (hasWildcardToolAllowlist(params.toolsAllow)) { + return true; + } + return params.toolsAllow.some((toolName) => { + const normalized = normalizeToolName(toolName); + return normalized.startsWith("lsp_"); + }); +} diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 79f601d5dbd..8fe42145637 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -10,7 +10,6 @@ import { buildAfterTurnRuntimeContextFromUsage, composeSystemPromptWithHookContext, decodeHtmlEntitiesInObject, - applyEmbeddedAttemptToolsAllow, isPrimaryBootstrapRun, mergeOrphanedTrailingUserPrompt, normalizeMessagesForLlmBoundary, @@ -21,8 +20,6 @@ import { resolveAttemptFsWorkspaceOnly, resolveEmbeddedAgentStreamFn, resolveUnknownToolGuardThreshold, - shouldCreateBundleMcpRuntimeForAttempt, - shouldBuildCoreCodingToolsForAllowlist, resolveAttemptToolPolicyMessageProvider, resolvePromptBuildHookResult, resolvePromptModeForSession, @@ -66,33 +63,6 @@ async function invokeWrappedTestStream( return await Promise.resolve(wrappedFn({} as never, {} as never, {} as never)); } -describe("applyEmbeddedAttemptToolsAllow", () => { - it("keeps explicit toolsAllow authoritative after force-added tools are built", () => { - const tools = [{ name: "exec" }, { name: "read" }, { name: "message" }]; - - expect( - applyEmbeddedAttemptToolsAllow(tools, ["exec", "read"]).map((tool) => tool.name), - ).toEqual(["exec", "read"]); - }); - - it("normalizes explicit toolsAllow entries before filtering", () => { - const tools = [{ name: "cron" }, { name: "read" }, { name: "message" }]; - - expect( - applyEmbeddedAttemptToolsAllow(tools, [" cron ", "READ"]).map((tool) => tool.name), - ).toEqual(["cron", "read"]); - }); - - it("keeps plugin-only allowlists on the shared tool policy path", () => { - const tools = [{ name: "memory_search" }, { name: "plugin_extra" }]; - - expect(shouldBuildCoreCodingToolsForAllowlist(["memory_search"])).toBe(false); - expect( - applyEmbeddedAttemptToolsAllow(tools, ["memory_search"]).map((tool) => tool.name), - ).toEqual(["memory_search"]); - }); -}); - describe("buildEmbeddedAttemptToolRunContext", () => { it("carries runtime toolsAllow into coding tool construction", () => { expect( @@ -181,40 +151,6 @@ describe("normalizeMessagesForLlmBoundary", () => { }); }); -describe("shouldCreateBundleMcpRuntimeForAttempt", () => { - it("skips bundle MCP when tools are disabled or unavailable", () => { - expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: false })).toBe(false); - expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, disableTools: true })).toBe( - false, - ); - }); - - it("creates bundle MCP only when the allowlist can reach bundle MCP tool names", () => { - expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })).toBe(true); - expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, toolsAllow: [] })).toBe( - true, - ); - expect( - shouldCreateBundleMcpRuntimeForAttempt({ - toolsEnabled: true, - toolsAllow: ["memory_search", "memory_get"], - }), - ).toBe(false); - expect( - shouldCreateBundleMcpRuntimeForAttempt({ - toolsEnabled: true, - toolsAllow: ["bundle-mcp"], - }), - ).toBe(true); - expect( - shouldCreateBundleMcpRuntimeForAttempt({ - toolsEnabled: true, - toolsAllow: ["strict__strict_probe"], - }), - ).toBe(true); - }); -}); - describe("resolveAttemptToolPolicyMessageProvider", () => { it("prefers explicit tool-policy provider over transport channel", () => { expect( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 28ec416c98b..d1187f131e6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -88,7 +88,6 @@ import { supportsModelTools } from "../../model-tool-support.js"; import { releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; -import { TOOL_NAME_SEPARATOR } from "../../pi-bundle-mcp-names.js"; import { getOrCreateSessionMcpRuntime, materializeBundleMcpToolsForRun, @@ -162,7 +161,6 @@ import { collectExplicitToolAllowlistSources, } from "../../tool-allowlist-guard.js"; import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js"; -import { normalizeToolName } from "../../tool-policy.js"; import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js"; import { normalizeUsage, type NormalizedUsage } from "../../usage.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; @@ -234,6 +232,12 @@ import { mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; import { abortable as abortableWithSignal } from "./abortable.js"; import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js"; +import { + applyEmbeddedAttemptToolsAllow, + resolveEmbeddedAttemptToolConstructionPlan, + shouldCreateBundleLspRuntimeForAttempt, + shouldCreateBundleMcpRuntimeForAttempt, +} from "./attempt-tool-construction-plan.js"; export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js"; import { rotateTranscriptAfterCompaction, @@ -483,65 +487,6 @@ function summarizeSessionContext(messages: AgentMessage[]): { }; } -export function applyEmbeddedAttemptToolsAllow( - tools: T[], - toolsAllow?: string[], -): T[] { - if (!toolsAllow || toolsAllow.length === 0) { - return tools; - } - const allowSet = new Set(toolsAllow.map((name) => normalizeToolName(name))); - return tools.filter((tool) => allowSet.has(normalizeToolName(tool.name))); -} - -const CORE_CODING_TOOL_ALLOWLIST_NAMES = new Set([ - "agents_list", - "apply_patch", - "bash", - "canvas", - "cron", - "edit", - "exec", - "gateway", - "heartbeat_response", - "image", - "image_generate", - "message", - "music_generate", - "nodes", - "pdf", - "read", - "session_status", - "sessions_history", - "sessions_list", - "sessions_send", - "sessions_spawn", - "sessions_yield", - "subagents", - "tts", - "update_plan", - "video_generate", - "web_fetch", - "web_search", - "write", -]); - -export function shouldBuildCoreCodingToolsForAllowlist(toolsAllow?: string[]): boolean { - if (!toolsAllow || toolsAllow.length === 0) { - return true; - } - return toolsAllow.some((toolName) => { - const normalized = normalizeToolName(toolName); - return ( - normalized === "*" || - normalized.startsWith("group:") || - normalized === "bundle-mcp" || - normalized.includes(TOOL_NAME_SEPARATOR) || - CORE_CODING_TOOL_ALLOWLIST_NAMES.has(normalized) - ); - }); -} - export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] { const normalized = stripToolResultDetails(normalizeAssistantReplayContent(messages)); return stripHistoricalRuntimeContextCustomMessages(normalized); @@ -598,22 +543,6 @@ function removeTrailingMidTurnPrecheckAssistantError(params: { mutableSessionManager._rewriteFile(); } -export function shouldCreateBundleMcpRuntimeForAttempt(params: { - toolsEnabled: boolean; - disableTools?: boolean; - toolsAllow?: string[]; -}): boolean { - if (!params.toolsEnabled || params.disableTools === true) { - return false; - } - if (!params.toolsAllow || params.toolsAllow.length === 0) { - return true; - } - return params.toolsAllow.some( - (toolName) => toolName === "bundle-mcp" || toolName.includes(TOOL_NAME_SEPARATOR), - ); -} - export function resolveAttemptToolPolicyMessageProvider(params: { messageProvider?: string; messageChannel?: string; @@ -857,91 +786,98 @@ export async function runEmbeddedAttempt( }); }; const corePluginToolStages = createEmbeddedRunStageTracker(); - const toolsRaw = - params.disableTools || isRawModelRun - ? [] - : (() => { - const allTools = createOpenClawCodingTools({ - agentId: sessionAgentId, - ...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }), - exec: { - ...params.execOverrides, - elevated: params.bashElevated, - }, + const toolConstructionPlan = resolveEmbeddedAttemptToolConstructionPlan({ + disableTools: params.disableTools, + isRawModelRun, + toolsAllow: params.toolsAllow, + }); + const toolsRaw = !toolConstructionPlan.constructTools + ? [] + : (() => { + const allTools = createOpenClawCodingTools({ + agentId: sessionAgentId, + ...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }), + exec: { + ...params.execOverrides, + elevated: params.bashElevated, + }, + sandbox, + messageProvider: resolveAttemptToolPolicyMessageProvider(params), + agentAccountId: params.agentAccountId, + messageTo: params.messageTo, + messageThreadId: params.messageThreadId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + memberRoleIds: params.memberRoleIds, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, + sessionKey: sandboxSessionKey, + // When sandboxSessionKey differs from the real run session key (e.g. Telegram + // direct peer key vs agent:main:main), pass the live key so session_status + // "current" resolves to the active run session, not the stale sandbox key. + runSessionKey: + params.sessionKey && params.sessionKey !== sandboxSessionKey + ? params.sessionKey + : undefined, + sessionId: params.sessionId, + runId: params.runId, + agentDir, + workspaceDir: effectiveWorkspace, + // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points + // at the sandbox copy. Spawned subagents should inherit the real workspace instead. + spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({ sandbox, - messageProvider: resolveAttemptToolPolicyMessageProvider(params), - agentAccountId: params.agentAccountId, - messageTo: params.messageTo, - messageThreadId: params.messageThreadId, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - memberRoleIds: params.memberRoleIds, - spawnedBy: params.spawnedBy, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - senderIsOwner: params.senderIsOwner, - ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist, - allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, - sessionKey: sandboxSessionKey, - // When sandboxSessionKey differs from the real run session key (e.g. Telegram - // direct peer key vs agent:main:main), pass the live key so session_status - // "current" resolves to the active run session, not the stale sandbox key. - runSessionKey: - params.sessionKey && params.sessionKey !== sandboxSessionKey - ? params.sessionKey - : undefined, - sessionId: params.sessionId, - runId: params.runId, - agentDir, + resolvedWorkspace, + }), + config: params.config, + abortSignal: runAbortController.signal, + modelProvider: params.provider, + modelId: params.modelId, + modelCompat: extractModelCompat(params.model), + modelApi: params.model.api, + modelContextWindowTokens: params.model.contextWindow, + modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, { workspaceDir: effectiveWorkspace, - // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points - // at the sandbox copy. Spawned subagents should inherit the real workspace instead. - spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({ - sandbox, - resolvedWorkspace, - }), - config: params.config, - abortSignal: runAbortController.signal, - modelProvider: params.provider, - modelId: params.modelId, - modelCompat: extractModelCompat(params.model), - modelApi: params.model.api, - modelContextWindowTokens: params.model.contextWindow, - modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, { - workspaceDir: effectiveWorkspace, - }), - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - includeCoreTools: shouldBuildCoreCodingToolsForAllowlist(params.toolsAllow), - replyToMode: params.replyToMode, - hasRepliedRef: params.hasRepliedRef, - modelHasVision: params.model.input?.includes("image") ?? false, - requireExplicitMessageTarget: - params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), - disableMessageTool: params.disableMessageTool, - forceMessageTool: params.forceMessageTool, - enableHeartbeatTool: params.enableHeartbeatTool, - forceHeartbeatTool: params.forceHeartbeatTool, - authProfileStore: params.authProfileStore, - recordToolPrepStage: (name) => corePluginToolStages.mark(name), - onToolOutcome: params.onToolOutcome, - onYield: (message) => { - yieldDetected = true; - yieldMessage = message; - queueYieldInterruptForSession?.(); - runAbortController.abort("sessions_yield"); - abortSessionForYield?.(); - }, - }); - corePluginToolStages.mark("attempt:create-openclaw-coding-tools"); - const filteredTools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow); - corePluginToolStages.mark("attempt:tools-allow"); - return filteredTools; - })(); + }), + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + includeCoreTools: toolConstructionPlan.includeCoreTools, + toolConstructionPlan: toolConstructionPlan.codingToolConstructionPlan, + replyToMode: params.replyToMode, + hasRepliedRef: params.hasRepliedRef, + modelHasVision: params.model.input?.includes("image") ?? false, + requireExplicitMessageTarget: + params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), + disableMessageTool: params.disableMessageTool, + forceMessageTool: params.forceMessageTool, + enableHeartbeatTool: params.enableHeartbeatTool, + forceHeartbeatTool: params.forceHeartbeatTool, + authProfileStore: params.authProfileStore, + recordToolPrepStage: (name) => corePluginToolStages.mark(name), + onToolOutcome: params.onToolOutcome, + onYield: (message) => { + yieldDetected = true; + yieldMessage = message; + queueYieldInterruptForSession?.(); + runAbortController.abort("sessions_yield"); + abortSessionForYield?.(); + }, + }); + corePluginToolStages.mark("attempt:create-openclaw-coding-tools"); + const filteredTools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, { + toolMeta: (tool) => getPluginToolMeta(tool), + }); + corePluginToolStages.mark("attempt:tools-allow"); + return filteredTools; + })(); prepStages.mark("core-plugin-tools"); emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot()); const toolsEnabled = supportsModelTools(params.model); @@ -1104,18 +1040,22 @@ export async function runEmbeddedAttempt( ], }) : undefined; - const bundleLspRuntime = - toolsEnabled && !isRawModelRun - ? await createBundleLspToolRuntime({ - workspaceDir: effectiveWorkspace, - cfg: params.config, - reservedToolNames: [ - ...tools.map((tool) => tool.name), - ...(clientTools?.map((tool) => tool.function.name) ?? []), - ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), - ], - }) - : undefined; + const bundleLspEnabled = shouldCreateBundleLspRuntimeForAttempt({ + toolsEnabled, + disableTools: params.disableTools || isRawModelRun, + toolsAllow: params.toolsAllow, + }); + const bundleLspRuntime = bundleLspEnabled + ? await createBundleLspToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(clientTools?.map((tool) => tool.function.name) ?? []), + ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), + ], + }) + : undefined; const filteredBundledTools = applyFinalEffectiveToolPolicy({ bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], config: params.config, diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 83c7cb2481c..0898441519b 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -180,6 +180,74 @@ describe("createOpenClawCodingTools", () => { ); }); + it("skips unrelated tool families when construction is planned from a narrow allowlist", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + + const tools = createOpenClawCodingTools({ + config: testConfig, + toolConstructionPlan: { + includeBaseCodingTools: true, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + const names = new Set(tools.map((tool) => tool.name)); + + expect(createOpenClawToolsMock).not.toHaveBeenCalled(); + expect(names.has("read")).toBe(true); + expect(names.has("write")).toBe(true); + expect(names.has("edit")).toBe(true); + expect(names.has("exec")).toBe(false); + expect(names.has("process")).toBe(false); + expect(names.has("apply_patch")).toBe(false); + expect(names.has("message")).toBe(false); + }); + + it("passes plugin suppression into OpenClaw tool construction plans", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + + createOpenClawCodingTools({ + config: testConfig, + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: false, + }, + }); + + expect(createOpenClawToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + disablePluginTools: true, + }), + ); + }); + + it("keeps plugin-only construction off the OpenClaw core factory", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + + createOpenClawCodingTools({ + config: testConfig, + includeCoreTools: false, + runtimeToolAllowlist: ["memory_search"], + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: true, + }, + }); + + expect(createOpenClawToolsMock).not.toHaveBeenCalled(); + }); + it("uses tools.alsoAllow for optional plugin discovery without widening to all plugins", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 89cd59827f8..633a4f5e30a 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -243,6 +243,14 @@ export const __testing = { applyModelProviderToolPolicy, } as const; +export type OpenClawCodingToolConstructionPlan = { + includeBaseCodingTools: boolean; + includeShellTools: boolean; + includeChannelTools: boolean; + includeOpenClawTools: boolean; + includePluginTools: boolean; +}; + export function createOpenClawCodingTools(options?: { agentId?: string; exec?: ExecToolDefaults & ProcessToolDefaults; @@ -343,6 +351,8 @@ export function createOpenClawCodingTools(options?: { forceHeartbeatTool?: boolean; /** If false, build plugin tools only while preserving the shared policy pipeline. */ includeCoreTools?: boolean; + /** Limits which tool families are materialized before the shared policy pipeline runs. */ + toolConstructionPlan?: OpenClawCodingToolConstructionPlan; /** Whether the sender is an owner (required for owner-only tools). */ senderIsOwner?: boolean; /** @@ -466,6 +476,18 @@ export function createOpenClawCodingTools(options?: { const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const workspaceRoot = resolveWorkspaceRoot(options?.workspaceDir); const includeCoreTools = options?.includeCoreTools !== false; + const toolConstructionPlan = options?.toolConstructionPlan ?? { + includeBaseCodingTools: includeCoreTools, + includeShellTools: includeCoreTools, + includeChannelTools: includeCoreTools, + includeOpenClawTools: includeCoreTools, + includePluginTools: true, + }; + const includeBaseCodingTools = includeCoreTools && toolConstructionPlan.includeBaseCodingTools; + const includeShellTools = includeCoreTools && toolConstructionPlan.includeShellTools; + const includeOpenClawTools = includeCoreTools && toolConstructionPlan.includeOpenClawTools; + const includeChannelTools = toolConstructionPlan.includeChannelTools; + const includePluginTools = toolConstructionPlan.includePluginTools; const workspaceOnly = fsPolicy.workspaceOnly; const applyPatchConfig = execConfig.applyPatch; // Secure by default: apply_patch is workspace-contained unless explicitly disabled. @@ -486,7 +508,7 @@ export function createOpenClawCodingTools(options?: { const imageSanitization = resolveImageSanitizationLimits(options?.config); options?.recordToolPrepStage?.("workspace-policy"); - const base = includeCoreTools + const base = includeBaseCodingTools ? (createCodingTools(workspaceRoot) as unknown as AnyAgentTool[]).flatMap((tool) => { if (tool.name === "read") { if (sandboxRoot) { @@ -533,7 +555,7 @@ export function createOpenClawCodingTools(options?: { : []; options?.recordToolPrepStage?.("base-coding-tools"); const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {}; - const execTool = includeCoreTools + const execTool = includeShellTools ? createLazyExecTool({ ...execDefaults, host: options?.exec?.host ?? execConfig.host, @@ -574,14 +596,14 @@ export function createOpenClawCodingTools(options?: { : undefined, }) : null; - const processTool = includeCoreTools + const processTool = includeShellTools ? createLazyProcessTool({ cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs, scopeKey, }) : null; const applyPatchTool = - !applyPatchEnabled || (sandboxRoot && !allowWorkspaceWrites) + !includeShellTools || !applyPatchEnabled || (sandboxRoot && !allowWorkspaceWrites) ? null : createApplyPatchTool({ cwd: sandboxRoot ?? workspaceRoot, @@ -615,42 +637,43 @@ export function createOpenClawCodingTools(options?: { sandboxToolPolicy, subagentPolicy, ]); - const pluginToolsOnly = includeCoreTools - ? [] - : resolveOpenClawPluginToolsForOptions({ - options: { - agentSessionKey: options?.sessionKey, - agentChannel: resolveGatewayMessageChannel(options?.messageProvider), - agentAccountId: options?.agentAccountId, - agentTo: options?.messageTo, - agentThreadId: options?.messageThreadId, - agentDir: options?.agentDir, - workspaceDir: workspaceRoot, - config: options?.config, - fsPolicy, - requesterSenderId: options?.senderId, - senderIsOwner: options?.senderIsOwner, - sessionId: options?.sessionId, - sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, - allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, - sandboxed: !!sandbox, - pluginToolAllowlist, - pluginToolDenylist, - currentChannelId: options?.currentChannelId, - currentThreadTs: options?.currentThreadTs, - currentMessageId: options?.currentMessageId, - modelProvider: options?.modelProvider, - modelHasVision: options?.modelHasVision, - requireExplicitMessageTarget: options?.requireExplicitMessageTarget, - disableMessageTool: options?.disableMessageTool, - requesterAgentIdOverride: agentId, - allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, - }, - resolvedConfig: options?.config, - }); + const pluginToolsOnly = + includeOpenClawTools || !includePluginTools + ? [] + : resolveOpenClawPluginToolsForOptions({ + options: { + agentSessionKey: options?.sessionKey, + agentChannel: resolveGatewayMessageChannel(options?.messageProvider), + agentAccountId: options?.agentAccountId, + agentTo: options?.messageTo, + agentThreadId: options?.messageThreadId, + agentDir: options?.agentDir, + workspaceDir: workspaceRoot, + config: options?.config, + fsPolicy, + requesterSenderId: options?.senderId, + senderIsOwner: options?.senderIsOwner, + sessionId: options?.sessionId, + sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, + allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, + sandboxed: !!sandbox, + pluginToolAllowlist, + pluginToolDenylist, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, + modelProvider: options?.modelProvider, + modelHasVision: options?.modelHasVision, + requireExplicitMessageTarget: options?.requireExplicitMessageTarget, + disableMessageTool: options?.disableMessageTool, + requesterAgentIdOverride: agentId, + allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, + }, + resolvedConfig: options?.config, + }); const tools: AnyAgentTool[] = [ ...base, - ...(includeCoreTools && sandboxRoot + ...(includeBaseCodingTools && sandboxRoot ? allowWorkspaceWrites ? [ workspaceOnly @@ -674,12 +697,12 @@ export function createOpenClawCodingTools(options?: { ] : [] : []), - ...(includeCoreTools && applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []), + ...(includeShellTools && applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []), ...(execTool ? [execTool as unknown as AnyAgentTool] : []), ...(processTool ? [processTool as unknown as AnyAgentTool] : []), // Channel docking: include channel-defined agent tools (login, etc.). - ...(includeCoreTools ? listChannelAgentTools({ cfg: options?.config }) : []), - ...(includeCoreTools + ...(includeChannelTools ? listChannelAgentTools({ cfg: options?.config }) : []), + ...(includeOpenClawTools ? createOpenClawTools({ sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, @@ -717,6 +740,7 @@ export function createOpenClawCodingTools(options?: { requireExplicitMessageTarget: options?.requireExplicitMessageTarget, disableMessageTool: options?.disableMessageTool, enableHeartbeatTool, + disablePluginTools: !includePluginTools, ...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}), requesterAgentIdOverride: agentId, requesterSenderId: options?.senderId,