diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts index 4b7a16b4d92..0cdc572c448 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/pi-tools.policy.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { filterToolsByPolicy, isToolAllowedByPolicyName, + resolveEffectiveToolPolicy, resolveSubagentToolPolicy, } from "./pi-tools.policy.js"; import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; @@ -176,3 +177,59 @@ describe("resolveSubagentToolPolicy depth awareness", () => { expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); }); }); + +describe("resolveEffectiveToolPolicy", () => { + it("implicitly re-exposes exec and process when tools.exec is configured", () => { + const cfg = { + tools: { + profile: "messaging", + exec: { host: "sandbox" }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["exec", "process"]); + }); + + it("implicitly re-exposes read, write, and edit when tools.fs is configured", () => { + const cfg = { + tools: { + profile: "messaging", + fs: { workspaceOnly: false }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]); + }); + + it("merges explicit alsoAllow with implicit tool-section exposure", () => { + const cfg = { + tools: { + profile: "messaging", + alsoAllow: ["web_search"], + exec: { host: "sandbox" }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["web_search", "exec", "process"]); + }); + + it("uses agent tool sections when resolving implicit exposure", () => { + const cfg = { + tools: { + profile: "messaging", + }, + agents: { + list: [ + { + id: "coder", + tools: { + fs: { workspaceOnly: true }, + }, + }, + ], + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg, agentId: "coder" }); + expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]); + }); +}); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index db9a367552e..61d037dd9f3 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -2,6 +2,7 @@ import { getChannelDock } from "../channels/dock.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; @@ -196,6 +197,37 @@ function resolveProviderToolPolicy(params: { return undefined; } +function resolveExplicitProfileAlsoAllow(tools?: OpenClawConfig["tools"]): string[] | undefined { + return Array.isArray(tools?.alsoAllow) ? tools.alsoAllow : undefined; +} + +function hasExplicitToolSection(section: unknown): boolean { + return section !== undefined && section !== null; +} + +function resolveImplicitProfileAlsoAllow(params: { + globalTools?: OpenClawConfig["tools"]; + agentTools?: AgentToolsConfig; +}): string[] | undefined { + const implicit = new Set(); + if ( + hasExplicitToolSection(params.agentTools?.exec) || + hasExplicitToolSection(params.globalTools?.exec) + ) { + implicit.add("exec"); + implicit.add("process"); + } + if ( + hasExplicitToolSection(params.agentTools?.fs) || + hasExplicitToolSection(params.globalTools?.fs) + ) { + implicit.add("read"); + implicit.add("write"); + implicit.add("edit"); + } + return implicit.size > 0 ? Array.from(implicit) : undefined; +} + export function resolveEffectiveToolPolicy(params: { config?: OpenClawConfig; sessionKey?: string; @@ -226,6 +258,15 @@ export function resolveEffectiveToolPolicy(params: { modelProvider: params.modelProvider, modelId: params.modelId, }); + const explicitProfileAlsoAllow = + resolveExplicitProfileAlsoAllow(agentTools) ?? resolveExplicitProfileAlsoAllow(globalTools); + const implicitProfileAlsoAllow = resolveImplicitProfileAlsoAllow({ globalTools, agentTools }); + const profileAlsoAllow = + explicitProfileAlsoAllow || implicitProfileAlsoAllow + ? Array.from( + new Set([...(explicitProfileAlsoAllow ?? []), ...(implicitProfileAlsoAllow ?? [])]), + ) + : undefined; return { agentId, globalPolicy: pickSandboxToolPolicy(globalTools), @@ -235,11 +276,7 @@ export function resolveEffectiveToolPolicy(params: { profile, providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile, // alsoAllow is applied at the profile stage (to avoid being filtered out early). - profileAlsoAllow: Array.isArray(agentTools?.alsoAllow) - ? agentTools?.alsoAllow - : Array.isArray(globalTools?.alsoAllow) - ? globalTools?.alsoAllow - : undefined, + profileAlsoAllow, providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow) ? agentProviderPolicy?.alsoAllow : Array.isArray(providerPolicy?.alsoAllow) diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 47101c771cd..ebb5d366868 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { + normalizePluginsConfig, + resolveEffectiveEnableState, + resolveEnableState, +} from "./config-state.js"; describe("normalizePluginsConfig", () => { it("uses default memory slot when not specified", () => { @@ -111,3 +115,34 @@ describe("resolveEffectiveEnableState", () => { expect(state).toEqual({ enabled: false, reason: "disabled in config" }); }); }); + +describe("resolveEnableState", () => { + it("keeps the selected memory slot plugin enabled even when omitted from plugins.allow", () => { + const state = resolveEnableState( + "memory-core", + "bundled", + normalizePluginsConfig({ + allow: ["telegram"], + slots: { memory: "memory-core" }, + }), + ); + expect(state).toEqual({ enabled: true }); + }); + + it("keeps explicit disable authoritative for the selected memory slot plugin", () => { + const state = resolveEnableState( + "memory-core", + "bundled", + normalizePluginsConfig({ + allow: ["telegram"], + slots: { memory: "memory-core" }, + entries: { + "memory-core": { + enabled: false, + }, + }, + }), + ); + expect(state).toEqual({ enabled: false, reason: "disabled in config" }); + }); +}); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 2a70033bad2..e671aae7e2e 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -197,19 +197,19 @@ export function resolveEnableState( if (config.deny.includes(id)) { return { enabled: false, reason: "blocked by denylist" }; } - if (config.allow.length > 0 && !config.allow.includes(id)) { - return { enabled: false, reason: "not in allowlist" }; + const entry = config.entries[id]; + if (entry?.enabled === false) { + return { enabled: false, reason: "disabled in config" }; } if (config.slots.memory === id) { return { enabled: true }; } - const entry = config.entries[id]; + if (config.allow.length > 0 && !config.allow.includes(id)) { + return { enabled: false, reason: "not in allowlist" }; + } if (entry?.enabled === true) { return { enabled: true }; } - if (entry?.enabled === false) { - return { enabled: false, reason: "disabled in config" }; - } if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { return { enabled: true }; }