From 202dea59bdf5d0bf3bb1b50424ee8a68af9efae8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 00:49:21 -0700 Subject: [PATCH] fix(workboard): grant dispatch workers required tools --- docs/plugins/architecture-internals.md | 2 + docs/plugins/sdk-runtime.md | 3 + extensions/workboard/src/dispatcher.test.ts | 5 + extensions/workboard/src/dispatcher.ts | 2 + extensions/workboard/src/workspace-access.ts | 2 +- ...tools.create-openclaw-coding-tools.test.ts | 38 +++++ src/agents/agent-tools.ring-zero-context.ts | 11 ++ src/agents/agent-tools.ts | 27 ++-- src/agents/command/attempt-execution.ts | 4 +- src/agents/command/types.ts | 3 + .../conversation-capability-profile.test.ts | 32 ++++ src/agents/conversation-capability-profile.ts | 16 +- .../run/attempt-tool-base-prepare.ts | 1 + .../embedded-agent-runner/run/params.ts | 3 + src/agents/openclaw-tools.ts | 6 +- .../agent-run-execution-phase.ts | 9 ++ .../agent.media-and-routing.test-utils.ts | 32 ++++ src/gateway/server-methods/shared-types.ts | 3 + src/gateway/server-plugin-runtime-client.ts | 102 ++++++++++++ src/gateway/server-plugins.test.ts | 146 ++++++++++++++++++ src/gateway/server-plugins.ts | 79 +++------- src/plugins/runtime/tool-grant.ts | 5 + src/plugins/runtime/types.ts | 2 + src/plugins/tool-grant-allowlist.ts | 32 ++++ src/plugins/tools.optional.test.ts | 73 ++++++++- src/plugins/tools.ts | 8 +- 26 files changed, 557 insertions(+), 89 deletions(-) create mode 100644 src/gateway/server-plugin-runtime-client.ts create mode 100644 src/plugins/runtime/tool-grant.ts create mode 100644 src/plugins/tool-grant-allowlist.ts diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index df845d050a85..701fdcf0fa4e 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -579,6 +579,7 @@ Plugins can also launch background subagent runs through `api.runtime.subagent`: const result = await api.runtime.subagent.run({ sessionKey: "agent:main:subagent:search-helper", message: "Expand this query into focused follow-up searches.", + toolsAlsoAllow: ["my_plugin_progress"], provider: "openai", model: "gpt-4.1-mini", deliver: false, @@ -588,6 +589,7 @@ const result = await api.runtime.subagent.run({ Notes: - `provider` and `model` are optional per-run overrides, not persistent session changes. +- `toolsAlsoAllow` accepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names are rejected. It is additive to the normal profile, but operator allowlists and denies remain authoritative. - OpenClaw only honors those override fields for trusted callers. - For plugin-owned fallback runs, operators must opt in with `plugins.entries..subagent.allowModelOverride: true`. - Use `plugins.entries..subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 2487377046c6..1e48fd089428 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -299,6 +299,7 @@ two-party event loops that do not go through the shared inbound reply runner. const { runId } = await api.runtime.subagent.run({ sessionKey: "agent:main:subagent:search-helper", message: "Expand this query into focused follow-up searches.", + toolsAlsoAllow: ["my_plugin_progress"], provider: "openai", // optional override model: "gpt-5.6-sol", // optional override deliver: false, @@ -323,6 +324,8 @@ two-party event loops that do not go through the shared inbound reply runner. Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries..subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected. + `toolsAlsoAllow` adds exact, uniquely owned tools registered by the calling plugin to the worker's normal tool surface. The runtime rejects core tools and names shared with another plugin. Profiles and operator tool policies still apply, including explicit allowlists and denies. + `deleteSession(...)` can delete sessions created by the same plugin through `api.runtime.subagent.run(...)`. Deleting arbitrary user or operator sessions still requires an admin-scoped Gateway request. diff --git a/extensions/workboard/src/dispatcher.test.ts b/extensions/workboard/src/dispatcher.test.ts index 66917a1a18dc..677870ac6c5e 100644 --- a/extensions/workboard/src/dispatcher.test.ts +++ b/extensions/workboard/src/dispatcher.test.ts @@ -779,6 +779,11 @@ describe("dispatchAndStartWorkboardCards", () => { workerLogs: [expect.objectContaining({ message: expect.stringContaining("run-first") })], }, }); + expect(run.mock.calls[0]?.[0]?.toolsAlsoAllow).toEqual([ + "workboard_heartbeat", + "workboard_complete", + "workboard_block", + ]); await expect(store.get(second.id)).resolves.toMatchObject({ status: "ready", metadata: { automation: { dispatchCount: 1 } }, diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 701b60ebeab0..3f1899f954f8 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -14,6 +14,7 @@ import type { WorkboardCard, WorkboardExecution, WorkboardWorkspace } from "./ty import { assertCanonicalWorkboardRootAccess, assertWorkboardWorkspaceSourceAccess, + WORKBOARD_REQUIRED_WORKER_TOOLS, type WorkboardWorkspaceAccess, } from "./workspace-access.js"; @@ -408,6 +409,7 @@ export async function dispatchAndStartWorkboardCards(params: { ownerId, token: claimValue, }), + toolsAlsoAllow: [...WORKBOARD_REQUIRED_WORKER_TOOLS], ...(params.options?.provider ? { provider: params.options.provider } : {}), ...(params.options?.model ? { model: params.options.model } : {}), lane: `workboard:${cardBoardId(card)}:${card.id}`, diff --git a/extensions/workboard/src/workspace-access.ts b/extensions/workboard/src/workspace-access.ts index c29c37cd0ce1..10b88157926a 100644 --- a/extensions/workboard/src/workspace-access.ts +++ b/extensions/workboard/src/workspace-access.ts @@ -62,7 +62,7 @@ export const WORKBOARD_TOOL_NAMES = [ "workboard_move", ] as const; -const WORKBOARD_REQUIRED_WORKER_TOOLS = [ +export const WORKBOARD_REQUIRED_WORKER_TOOLS = [ "workboard_heartbeat", "workboard_complete", "workboard_block", diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index b98a8b9842a2..97bea2b77178 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -22,10 +22,12 @@ import { createMockPluginRegistry } from "../plugins/hooks.test-fixtures.js"; import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; +import { isPluginToolAllowed } from "../plugins/tool-grant-allowlist.js"; import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; import { createOpenClawCodingTools } from "./agent-tools.js"; import { runWithAgentRingZeroTools } from "./agent-tools.ring-zero-context.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js"; import * as openClawPluginTools from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js"; @@ -971,6 +973,42 @@ describe("createOpenClawCodingTools", () => { ]); }); + it("materializes additive runtime tools while preserving normal deny policy", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + const config: OpenClawConfig = { + tools: { + profile: "coding", + deny: ["workboard_block"], + }, + }; + + createOpenClawCodingTools({ + config, + conversationCapabilityProfile: resolveConversationCapabilityProfile({ + config, + runtimePluginToolGrant: { + pluginId: "workboard", + toolNames: ["workboard_heartbeat", "workboard_complete"], + }, + }), + }); + + expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); + expect(latestCreateOpenClawToolsOptions().pluginToolAllowlist).not.toContain( + "workboard_heartbeat", + ); + expect( + isPluginToolAllowed( + new Set(latestCreateOpenClawToolsOptions().pluginToolAllowlist), + "workboard", + "workboard_heartbeat", + ), + ).toBe(true); + expect(latestCreateOpenClawToolsOptions()).not.toHaveProperty("runtimePluginToolGrant"); + expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["workboard_block"]); + }); + it("passes explicit denylist entries to OpenClaw tool factory planning", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/agent-tools.ring-zero-context.ts b/src/agents/agent-tools.ring-zero-context.ts index f41ccc41ea41..af770819fbd5 100644 --- a/src/agents/agent-tools.ring-zero-context.ts +++ b/src/agents/agent-tools.ring-zero-context.ts @@ -71,6 +71,17 @@ export function getActiveAgentRingZeroTools(): readonly AnyAgentTool[] { return scope?.active === true ? scope.tools : []; } +export function mergeAgentRingZeroTools( + ringZeroTools: readonly AnyAgentTool[], + tools: AnyAgentTool[], +): AnyAgentTool[] { + if (ringZeroTools.length === 0) { + return tools; + } + const reservedNames = new Set(ringZeroTools.map((tool) => tool.name)); + return [...ringZeroTools, ...tools.filter((tool) => !reservedNames.has(tool.name))]; +} + /** * Read a host-owned tool fact for the current run. This does not activate or * grant a tool; only the host can bind executable authority to the run scope. diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 031e8cab9818..32016a7b1040 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -18,6 +18,7 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing import { applyExecPolicyLayer } from "../infra/exec-policy.js"; import { logWarn } from "../logger.js"; import type { PluginHookChannelContext } from "../plugins/hook-types.js"; +import { appendRuntimePluginToolGrant } from "../plugins/tool-grant-allowlist.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../security/dangerous-tools.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -45,7 +46,10 @@ import { wrapToolWorkspaceRootGuard, wrapToolWorkspaceRootGuardWithOptions, } from "./agent-tools.read.js"; -import { getActiveAgentRingZeroTools } from "./agent-tools.ring-zero-context.js"; +import { + getActiveAgentRingZeroTools, + mergeAgentRingZeroTools, +} from "./agent-tools.ring-zero-context.js"; import { normalizeToolParameters } from "./agent-tools.schema.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { isApplyPatchAllowedForModel } from "./apply-patch-model-policy.js"; @@ -444,17 +448,6 @@ type OpenClawCodingToolsOptions = { conversationCapabilityProfile?: ResolvedConversationCapabilityProfile; }; -function mergeRingZeroTools( - ringZeroTools: readonly AnyAgentTool[], - openClawTools: AnyAgentTool[], -): AnyAgentTool[] { - if (ringZeroTools.length === 0) { - return openClawTools; - } - const reservedNames = new Set(ringZeroTools.map((tool) => tool.name)); - return [...ringZeroTools, ...openClawTools.filter((tool) => !reservedNames.has(tool.name))]; -} - function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions): AnyAgentTool[] { const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; @@ -527,6 +520,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) senderPolicy, subagentPolicy, inheritedToolPolicy, + runtimePluginToolGrant, } = capabilityProfile.policy; const enableHeartbeatTool = @@ -806,7 +800,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) options?.senderIsOwner === false ? [...GATEWAY_OWNER_ONLY_CORE_TOOLS] : []; const ownerOnlyCoreToolPolicy = ownerOnlyCoreToolDenylist.length > 0 ? { deny: ownerOnlyCoreToolDenylist } : undefined; - const pluginToolAllowlist = capabilityProfile.policy.explicitToolAllowlist; + const pluginToolAllowlist = appendRuntimePluginToolGrant( + capabilityProfile.policy.explicitToolAllowlist, + runtimePluginToolGrant, + ); const pluginToolDenylist = [ ...capabilityProfile.policy.explicitToolDenylist, ...ownerOnlyCoreToolDenylist, @@ -928,7 +925,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) // Channel docking: include channel-defined agent tools (login, etc.). ...(includeChannelTools ? listChannelAgentTools({ cfg: options?.config }) : []), ...(includeOpenClawTools - ? mergeRingZeroTools( + ? mergeAgentRingZeroTools( ringZeroTools, createOpenClawTools({ ...(options?.crestodianTool ? { crestodianTool: options.crestodianTool } : {}), @@ -1104,7 +1101,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) }); // Host-bound ring-zero tools carry their own authority checks. Agent policy // must not deadlock setup, but the tools still receive schema/hook wrappers. - const authorizedTools = mergeRingZeroTools(ringZeroTools, subagentFiltered); + const authorizedTools = mergeAgentRingZeroTools(ringZeroTools, subagentFiltered); if (shouldInheritEffectiveToolAllowlist) { replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, authorizedTools); } diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index e75ac23bdae0..443d9229a254 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -934,14 +934,14 @@ export function runAgentAttempt(params: { runId: params.runId, lifecycleGeneration: params.lifecycleGeneration, lane: params.opts.lane, - // Hidden internal runs have no assistant-event consumer. Visible subagent - // lanes can still feed Control UI, session subscribers, and ACP parent relays. + // Hidden internal runs lack an event consumer; visible lanes still feed UI and parent relays. suppressLiveStreamOutput: shouldSuppressEmbeddedLiveStreamOutput(params), abortSignal: params.opts.abortSignal, extraSystemPrompt: params.opts.extraSystemPrompt, bootstrapContextMode: params.opts.bootstrapContextMode, bootstrapContextRunKind: params.opts.bootstrapContextRunKind, toolsAllow: params.opts.toolsAllow, + runtimePluginToolGrant: params.opts.runtimePluginToolGrant, internalEvents: params.opts.internalEvents, inputProvenance: params.opts.inputProvenance, sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 82c112dc646a..d2baff754ee2 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -9,6 +9,7 @@ import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import type { PluginHookChannelContext } from "../../plugins/hook-types.js"; +import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { UserTurnInput, @@ -113,6 +114,8 @@ export type AgentCommandOpts = { allowModelOverride?: boolean; /** Optional runtime tool allow-list; when set, only these tools are exposed for this run. */ toolsAllow?: string[]; + /** Trusted owner-scoped plugin tool grant; normal policy and deny rules still apply. */ + runtimePluginToolGrant?: RuntimePluginToolGrant; /** Internal marker for an auto-applied cap that CLI runtimes must omit. */ toolsAllowIsDefault?: boolean; /** Preserve the originating run's message-tool policy across internal continuation turns. */ diff --git a/src/agents/conversation-capability-profile.test.ts b/src/agents/conversation-capability-profile.test.ts index f28e9f34a5fc..5667ff4a2194 100644 --- a/src/agents/conversation-capability-profile.test.ts +++ b/src/agents/conversation-capability-profile.test.ts @@ -184,6 +184,38 @@ describe("resolveConversationCapabilityProfile", () => { expect(profile.policy.explicitToolOverrideAllowlist).toEqual(["pdf"]); }); + it("adds runtime tools without replacing the configured tool surface", () => { + const profile = resolveConversationCapabilityProfile({ + config: { + tools: { + profile: "coding", + deny: ["workboard_block"], + }, + }, + runtimePluginToolGrant: { + pluginId: "workboard", + toolNames: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"], + }, + }); + + expect(profile.policy.profileAlsoAllow).toEqual(["workboard_heartbeat", "workboard_complete"]); + expect(profile.policy.providerProfileAlsoAllow).toEqual([ + "workboard_heartbeat", + "workboard_complete", + ]); + expect(profile.policy.explicitToolAllowlist).toEqual(expect.arrayContaining(["read", "exec"])); + expect(profile.policy.explicitToolAllowlist).not.toContain("workboard_heartbeat"); + expect(profile.policy.explicitToolOverrideAllowlist).toEqual([]); + expect(profile.policy.explicitToolDenylist).toEqual(["workboard_block"]); + expect(profile.policy.runtimePluginToolGrant).toEqual({ + pluginId: "workboard", + toolNames: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"], + }); + expect(profile.policy.inheritancePolicies).not.toContainEqual({ + allow: ["workboard_heartbeat", "workboard_complete"], + }); + }); + it("keeps inherited subagent grants out of explicit overrides", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-capability-profile-")); const storePath = path.join(tempDir, "sessions.json"); diff --git a/src/agents/conversation-capability-profile.ts b/src/agents/conversation-capability-profile.ts index 703a44a7d2dc..accc9770baa0 100644 --- a/src/agents/conversation-capability-profile.ts +++ b/src/agents/conversation-capability-profile.ts @@ -3,9 +3,11 @@ * hot paths share. Keep this internal: it prepares existing config/state, not a * new public access-profile config surface. */ +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { ChatType } from "../channels/chat-type.js"; import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import type { SkillSnapshot } from "../skills/types.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { normalizeMessageChannel } from "../utils/message-channel-core.js"; @@ -78,6 +80,7 @@ type ConversationCapabilityProfileParams = { skillsSnapshot?: SkillSnapshot; sandboxToolPolicy?: SandboxToolPolicy; runtimeToolAllowlist?: string[]; + runtimePluginToolGrant?: RuntimePluginToolGrant; }; export type ResolvedConversationCapabilityProfile = { @@ -171,6 +174,7 @@ export type ResolvedConversationCapabilityProfile = { /** Explicit config/runtime grants only; excludes built-in profile expansion. */ explicitToolOverrideAllowlist: string[]; explicitToolDenylist: string[]; + runtimePluginToolGrant?: RuntimePluginToolGrant; }; }; @@ -260,6 +264,13 @@ export function resolveConversationCapabilityProfile( const runtimeToolPolicy = params.runtimeToolAllowlist ? { allow: params.runtimeToolAllowlist } : undefined; + const runtimeToolAlsoAllowlist = uniqueStrings( + (params.runtimePluginToolGrant?.toolNames ?? []).map((entry) => entry.trim()).filter(Boolean), + ); + const mergeRuntimeToolAlsoAllowlist = (configured?: string[]) => { + const merged = uniqueStrings([...(configured ?? []), ...runtimeToolAlsoAllowlist]); + return merged.length > 0 ? merged : undefined; + }; const explicitOverridePolicies = [...configuredOverridePolicies, runtimeToolPolicy]; const inheritancePolicies = [ profilePolicy, @@ -350,8 +361,8 @@ export function resolveConversationCapabilityProfile( providerProfile: effective.providerProfile, profilePolicy, providerProfilePolicy, - profileAlsoAllow: effective.profileAlsoAllow, - providerProfileAlsoAllow: effective.providerProfileAlsoAllow, + profileAlsoAllow: mergeRuntimeToolAlsoAllowlist(effective.profileAlsoAllow), + providerProfileAlsoAllow: mergeRuntimeToolAlsoAllowlist(effective.providerProfileAlsoAllow), globalPolicy: effective.globalPolicy, globalProviderPolicy: effective.globalProviderPolicy, agentPolicy: effective.agentPolicy, @@ -365,6 +376,7 @@ export function resolveConversationCapabilityProfile( explicitToolAllowlist: collectExplicitAllowlist(inheritancePolicies), explicitToolOverrideAllowlist: collectExplicitAllowlist(explicitOverridePolicies), explicitToolDenylist: collectExplicitDenylist(inheritancePolicies), + runtimePluginToolGrant: params.runtimePluginToolGrant, }, }; } diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index b0dd2fb27cb5..f3557f0a86aa 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -168,6 +168,7 @@ export function prepareEmbeddedAttemptToolBase(params: { skillsSnapshot: params.skillsSnapshot, sandboxToolPolicy: params.sandbox?.tools, runtimeToolAllowlist: effectiveToolsAllow, + runtimePluginToolGrant: attempt.runtimePluginToolGrant, }); const localModelLeanEnabled = isLocalModelLeanEnabled({ config: attempt.config, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 911c17c7fb0b..7b79234704e8 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ImageContent } from "../../../llm/types.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import type { PluginHookChannelContext } from "../../../plugins/hook-types.js"; +import type { RuntimePluginToolGrant } from "../../../plugins/runtime/tool-grant.js"; import type { CommandQueueEnqueueFn } from "../../../process/command-queue.types.js"; import type { InputProvenance } from "../../../sessions/input-provenance.js"; import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js"; @@ -223,6 +224,8 @@ export type RunEmbeddedAgentParams = { bootstrapContextRunKind?: BootstrapContextRunKind; /** Optional tool allow-list; when set, only these tools are sent to the model. */ toolsAllow?: string[]; + /** Owner-scoped plugin tool grant; normal policy and deny rules still apply. */ + runtimePluginToolGrant?: RuntimePluginToolGrant; /** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */ bootstrapPromptWarningSignaturesSeen?: string[]; /** Last shown bootstrap truncation warning signature for this session. */ diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 05ccc98f0ff6..1d1f856ce85b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,8 +1,4 @@ -/** - * OpenClaw built-in and plugin tool assembly. - * - * Creates the per-run tool inventory from config, channel context, sandbox policy, auth stores, and plugin tools. - */ +/** Builds the per-run built-in and plugin tool inventory. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SourceReplyDeliveryMode, diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index 3a5cab69a732..387535c85fd2 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -244,6 +244,14 @@ export function startAgentRunExecution(params: { sessionKey: params.requestedSessionKeyRaw, }); } + // Plugin-owned additive grants stay internal to the authenticated in-process run. + // Public agent params cannot supply them, and normal tool policy still filters them. + const runtimePluginToolGrant = + params.client?.internal?.agentRunTracking === "plugin_subagent" && + params.client.internal.pluginRuntimeOwnerId === + params.client.internal.runtimePluginToolGrant?.pluginId + ? params.client.internal.runtimePluginToolGrant + : undefined; dispatchAgentRunFromGateway({ ingressOpts: { @@ -289,6 +297,7 @@ export function startAgentRunExecution(params: { bootstrapContextMode: params.request.bootstrapContextMode, bootstrapContextRunKind: params.effectiveBootstrapContextRunKind, toolsAllow: params.restoredCronContinuation?.toolsAllow, + runtimePluginToolGrant, toolsAllowIsDefault: params.restoredCronContinuation?.toolsAllowIsDefault, requireExplicitMessageTarget: params.restoredCronContinuation?.cliSessionBindingFacts?.requireExplicitMessageTarget, diff --git a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts index 40cc74a39f3e..96ead4662fe1 100644 --- a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts +++ b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts @@ -389,6 +389,38 @@ describe("gateway agent handler", () => { expect(capturedEntry?.pluginOwnerId).toBe("memory-core"); }); + it("forwards plugin-owned additive tools only for tracked plugin subagent runs", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "finish the workboard card", + sessionKey: "agent:main:subagent:workboard-card", + idempotencyKey: "plugin-tools-also-allow", + }, + { + client: { + internal: { + agentRunTracking: "plugin_subagent", + pluginRuntimeOwnerId: "workboard", + runtimePluginToolGrant: { + pluginId: "workboard", + toolNames: ["workboard_heartbeat", "workboard_complete"], + }, + }, + } as never, + }, + ); + + const call = await waitForAgentCommandCall<{ + runtimePluginToolGrant?: { pluginId: string; toolNames: readonly string[] }; + }>(); + expect(call.runtimePluginToolGrant).toEqual({ + pluginId: "workboard", + toolNames: ["workboard_heartbeat", "workboard_complete"], + }); + }); + it("does not claim stale pre-existing sessions for plugin runtime cleanup", async () => { const sessionKey = "agent:main:existing-user-session"; const existingEntry = { diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index affa6acce04e..1a5738f392e6 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -16,6 +16,7 @@ import type { PluginApprovalRequestPayload, } from "../../infra/plugin-approvals.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js"; import type { WizardSession } from "../../wizard/session.js"; import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; @@ -67,6 +68,8 @@ export type GatewayClient = { agentRuntimeIdentity?: AgentRuntimeIdentity; pluginRuntimeOwnerId?: string; agentRunTracking?: "plugin_subagent"; + /** Plugin-owned tools authorized for this internal subagent run. */ + runtimePluginToolGrant?: RuntimePluginToolGrant; }; }; diff --git a/src/gateway/server-plugin-runtime-client.ts b/src/gateway/server-plugin-runtime-client.ts new file mode 100644 index 000000000000..42650835f62f --- /dev/null +++ b/src/gateway/server-plugin-runtime-client.ts @@ -0,0 +1,102 @@ +// Internal client metadata for trusted in-process plugin runtime calls. +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js"; +import { isKnownCoreToolId } from "../agents/tool-catalog.js"; +import { normalizeToolName } from "../agents/tool-policy.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; +import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; +import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; +import type { GatewayRequestOptions } from "./server-methods/types.js"; + +export function createSyntheticPluginRuntimeClient(params?: { + allowModelOverride?: boolean; + agentRunTracking?: "plugin_subagent"; + cronRunContinuation?: boolean; + pluginRuntimeOwnerId?: string; + runtimePluginToolGrant?: RuntimePluginToolGrant; + scopes?: string[]; +}): GatewayRequestOptions["client"] { + const pluginRuntimeOwnerId = + typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim() + ? params.pluginRuntimeOwnerId.trim() + : undefined; + return { + connect: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + version: "internal", + platform: "node", + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + role: "operator", + scopes: params?.scopes ?? [WRITE_SCOPE], + }, + internal: { + allowModelOverride: params?.allowModelOverride === true, + ...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}), + ...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}), + ...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}), + ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + ...(params?.runtimePluginToolGrant + ? { runtimePluginToolGrant: params.runtimePluginToolGrant } + : {}), + }, + }; +} + +export function mergePluginRuntimeClientInternal( + client: GatewayRequestOptions["client"] | undefined, + internal: NonNullable["internal"], +): GatewayRequestOptions["client"] { + if (!client || !internal) { + return client ?? null; + } + return { + ...client, + internal: { + ...client.internal, + ...internal, + }, + }; +} + +export function resolvePluginSubagentToolsAlsoAllow(params: { + pluginId?: string; + toolsAlsoAllow?: string[]; +}): RuntimePluginToolGrant | undefined { + const requested = uniqueStrings( + (params.toolsAlsoAllow ?? []).map((entry) => normalizeToolName(entry.trim())).filter(Boolean), + ); + if (requested.length === 0) { + return undefined; + } + const pluginId = params.pluginId?.trim(); + if (!pluginId) { + throw new Error("toolsAlsoAllow requires plugin identity for subagent runs."); + } + const registry = getActivePluginRegistry(); + for (const toolName of requested) { + if (isKnownCoreToolId(toolName)) { + throw new Error(`plugin "${pluginId}" may not add core tool "${toolName}" to subagent runs.`); + } + const owners = uniqueStrings( + (registry?.tools ?? []) + .filter((registration) => + [...registration.names, ...(registration.declaredNames ?? [])].some( + (registeredName) => normalizeToolName(registeredName) === toolName, + ), + ) + .map((registration) => registration.pluginId), + ); + if (owners.length !== 1 || owners[0] !== pluginId) { + throw new Error(`plugin "${pluginId}" does not uniquely own subagent tool "${toolName}".`); + } + } + return { pluginId, toolNames: requested }; +} diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 1f4c3116f265..796456bd255d 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -324,6 +324,25 @@ async function createSubagentRuntime( return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent; } +function registerActivePluginToolOwnership( + pluginId: string, + names: string[], + declaredNames: string[] = names, +): void { + const registry = runtimeRegistryModule.getActivePluginRegistry(); + if (!registry) { + throw new Error("Expected an active plugin registry"); + } + registry.tools.push({ + pluginId, + factory: () => null, + names, + declaredNames, + optional: true, + source: `/tmp/${pluginId}/index.js`, + }); +} + async function reloadFallbackGatewayContextModule() { // Existing runtimes retain the old module graph; only the process-global state owner // must reload to prove a restarted Gateway can replace their fallback context. @@ -1254,6 +1273,133 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard"); }); + test("forwards exact plugin-owned additive tools through internal run metadata", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + serverPluginsModule.setFallbackGatewayContext(createTestContext("tools-also-allow")); + registerActivePluginToolOwnership("workboard", [ + "workboard_heartbeat", + "workboard_complete", + "workboard_block", + ]); + + await gatewayRequestScopeModule.withPluginRuntimePluginScope( + { pluginId: "workboard", pluginOrigin: "bundled" }, + () => + runtime.run({ + sessionKey: "s-tools-also-allow", + message: "finish the card", + toolsAlsoAllow: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"], + }), + ); + + expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({ + pluginId: "workboard", + toolNames: ["workboard_heartbeat", "workboard_complete"], + }); + expect(getRequiredLastDispatchedParams()).not.toHaveProperty("toolsAlsoAllow"); + }); + + test("rejects additive subagent tools not registered by the calling plugin", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + serverPluginsModule.setFallbackGatewayContext(createTestContext("foreign-tools-also-allow")); + registerActivePluginToolOwnership("workboard", ["workboard_complete"]); + registerActivePluginToolOwnership("other-plugin", ["other_plugin_tool"]); + + await expect( + gatewayRequestScopeModule.withPluginRuntimePluginScope( + { pluginId: "workboard", pluginOrigin: "bundled" }, + () => + runtime.run({ + sessionKey: "s-foreign-tools-also-allow", + message: "finish the card", + toolsAlsoAllow: ["other_plugin_tool"], + }), + ), + ).rejects.toThrow('plugin "workboard" does not uniquely own subagent tool "other_plugin_tool"'); + expect(handleGatewayRequest).not.toHaveBeenCalled(); + }); + + test("accepts additive tools declared by an unnamed plugin factory", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + serverPluginsModule.setFallbackGatewayContext(createTestContext("declared-tools-also-allow")); + registerActivePluginToolOwnership("workboard", [], ["workboard_complete"]); + + await gatewayRequestScopeModule.withPluginRuntimePluginScope( + { pluginId: "workboard", pluginOrigin: "bundled" }, + () => + runtime.run({ + sessionKey: "s-declared-tools-also-allow", + message: "finish the card", + toolsAlsoAllow: ["workboard_complete"], + }), + ); + + expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({ + pluginId: "workboard", + toolNames: ["workboard_complete"], + }); + }); + + test("rejects core and ambiguously-owned additive tool names", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + serverPluginsModule.setFallbackGatewayContext(createTestContext("colliding-tools-also-allow")); + registerActivePluginToolOwnership("workboard", ["exec", "workboard_complete"]); + registerActivePluginToolOwnership("other-plugin", ["workboard_complete"]); + + await expect( + gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () => + runtime.run({ + sessionKey: "s-core-tools-also-allow", + message: "run a command", + toolsAlsoAllow: ["exec"], + }), + ), + ).rejects.toThrow('plugin "workboard" may not add core tool "exec" to subagent runs'); + await expect( + gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () => + runtime.run({ + sessionKey: "s-ambiguous-tools-also-allow", + message: "finish the card", + toolsAlsoAllow: ["workboard_complete"], + }), + ), + ).rejects.toThrow( + 'plugin "workboard" does not uniquely own subagent tool "workboard_complete"', + ); + expect(handleGatewayRequest).not.toHaveBeenCalled(); + }); + + test("clears inherited additive grants when a scoped plugin run requests none", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + const scope = { + context: createTestContext("clear-tools-also-allow"), + client: { + connect: { scopes: ["operator.write"] }, + internal: { + agentRunTracking: "plugin_subagent", + pluginRuntimeOwnerId: "other-plugin", + runtimePluginToolGrant: { + pluginId: "other-plugin", + toolNames: ["other_plugin_tool"], + }, + }, + } as unknown as GatewayRequestOptions["client"], + isWebchatConnect: () => false, + pluginId: "workboard", + pluginOrigin: "bundled" as const, + } satisfies PluginRuntimeGatewayRequestScope; + + await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => + runtime.run({ + sessionKey: "s-clear-tools-also-allow", + message: "do normal work", + }), + ); + + expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard"); + expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toBeUndefined(); + }); + test("forwards lightContext as lightweight bootstrap context on subagent run", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 84920d85d48f..9f9b985425f6 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -5,12 +5,7 @@ import { performance } from "node:perf_hooks"; import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; -import { - GATEWAY_CLIENT_IDS, - GATEWAY_CLIENT_MODES, -} from "../../packages/gateway-protocol/src/client-info.js"; import type { ErrorShape } from "../../packages/gateway-protocol/src/schema/frames.js"; -import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js"; import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -23,14 +18,20 @@ import type { PluginRegistryParams } from "../plugins/registry-types.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; +import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import type { PluginRuntime, RuntimeGatewayRequestOptions } from "../plugins/runtime/types.js"; import type { PluginLogger, PluginOrigin } from "../plugins/types.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; -import { ADMIN_SCOPE, APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; +import { ADMIN_SCOPE } from "./method-scopes.js"; import { normalizeOperatorScopeList, type OperatorScope } from "./operator-scopes.js"; import type { GatewayRequestHandler, GatewayRequestOptions } from "./server-methods/types.js"; import { getFallbackGatewayContext } from "./server-plugin-fallback-context.js"; +import { + createSyntheticPluginRuntimeClient, + mergePluginRuntimeClientInternal, + resolvePluginSubagentToolsAlsoAllow, +} from "./server-plugin-runtime-client.js"; import { projectGatewayRuntimeNodes } from "./server-plugins-node-runtime.js"; export { @@ -189,40 +190,6 @@ function resolveRequestedFallbackModelRef(params: { // ── Internal gateway dispatch for plugin runtime ──────────────────── -function createSyntheticOperatorClient(params?: { - allowModelOverride?: boolean; - agentRunTracking?: "plugin_subagent"; - cronRunContinuation?: boolean; - pluginRuntimeOwnerId?: string; - scopes?: string[]; -}): GatewayRequestOptions["client"] { - const pluginRuntimeOwnerId = - typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim() - ? params.pluginRuntimeOwnerId.trim() - : undefined; - return { - connect: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, - version: "internal", - platform: "node", - mode: GATEWAY_CLIENT_MODES.BACKEND, - }, - role: "operator", - scopes: params?.scopes ?? [WRITE_SCOPE], - }, - internal: { - allowModelOverride: params?.allowModelOverride === true, - ...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}), - ...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}), - ...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}), - ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), - }, - }; -} - function hasAdminScope(client: GatewayRequestOptions["client"] | undefined): boolean { const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; return scopes.includes(ADMIN_SCOPE); @@ -260,22 +227,6 @@ function resolveRuntimeNodeInvokeSyntheticScopes(params: { : undefined; } -function mergeGatewayClientInternal( - client: GatewayRequestOptions["client"] | undefined, - internal: NonNullable["internal"], -): GatewayRequestOptions["client"] { - if (!client || !internal) { - return client ?? null; - } - return { - ...client, - internal: { - ...client.internal, - ...internal, - }, - }; -} - type DispatchGatewayMethodInProcessOptions = { allowSyntheticModelOverride?: boolean; allowSyntheticCronRunContinuation?: boolean; @@ -284,6 +235,7 @@ type DispatchGatewayMethodInProcessOptions = { expectFinal?: boolean; forceSyntheticClient?: boolean; pluginRuntimeOwnerId?: string; + runtimePluginToolGrant?: RuntimePluginToolGrant; requireScopedClient?: boolean; syntheticScopes?: string[]; timeoutMs?: number; @@ -391,19 +343,23 @@ export async function dispatchGatewayMethodInProcessRaw( typeof options?.pluginRuntimeOwnerId === "string" && options.pluginRuntimeOwnerId.trim() ? options.pluginRuntimeOwnerId.trim() : undefined; - const syntheticClient = createSyntheticOperatorClient({ + const syntheticClient = createSyntheticPluginRuntimeClient({ allowModelOverride: options?.allowSyntheticModelOverride === true, agentRunTracking: options?.agentRunTracking, cronRunContinuation: options?.allowSyntheticCronRunContinuation === true, ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + ...(options?.runtimePluginToolGrant + ? { runtimePluginToolGrant: options.runtimePluginToolGrant } + : {}), scopes: options?.syntheticScopes, }); - const scopedClient = mergeGatewayClientInternal( + const scopedClient = mergePluginRuntimeClientInternal( scope?.client, - pluginRuntimeOwnerId || options?.agentRunTracking + pluginRuntimeOwnerId || options?.agentRunTracking || options?.runtimePluginToolGrant ? { ...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}), ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + runtimePluginToolGrant: options?.runtimePluginToolGrant, } : undefined, ); @@ -559,6 +515,10 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { typeof scope?.pluginId === "string" && scope.pluginId.trim() ? scope.pluginId.trim() : undefined; + const runtimePluginToolGrant = resolvePluginSubagentToolsAlsoAllow({ + pluginId, + toolsAlsoAllow: params.toolsAlsoAllow, + }); const overrideRequested = Boolean(params.provider || params.model); const hasRequestScopeClient = Boolean(scope?.client); let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); @@ -600,6 +560,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { allowSyntheticModelOverride, agentRunTracking: "plugin_subagent", ...(pluginId ? { pluginRuntimeOwnerId: pluginId } : {}), + ...(runtimePluginToolGrant ? { runtimePluginToolGrant } : {}), }, ); const runId = payload?.runId; diff --git a/src/plugins/runtime/tool-grant.ts b/src/plugins/runtime/tool-grant.ts new file mode 100644 index 000000000000..15d305a53960 --- /dev/null +++ b/src/plugins/runtime/tool-grant.ts @@ -0,0 +1,5 @@ +/** Owner-scoped additive plugin tools for one trusted agent run. */ +export type RuntimePluginToolGrant = { + pluginId: string; + toolNames: readonly string[]; +}; diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 1fca14621320..3b03526bf7ac 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -15,6 +15,8 @@ type PluginRuntimeChannel = import("./types-channel.js").PluginRuntimeChannel; export type SubagentRunParams = { sessionKey: string; message: string; + /** Add exact tools registered by the calling plugin to the worker's normal tool surface. */ + toolsAlsoAllow?: string[]; provider?: string; model?: string; extraSystemPrompt?: string; diff --git a/src/plugins/tool-grant-allowlist.ts b/src/plugins/tool-grant-allowlist.ts new file mode 100644 index 000000000000..f01d4661d1a9 --- /dev/null +++ b/src/plugins/tool-grant-allowlist.ts @@ -0,0 +1,32 @@ +import { normalizeToolName } from "../agents/tool-policy.js"; +import type { RuntimePluginToolGrant } from "./runtime/tool-grant.js"; + +const RUNTIME_PLUGIN_TOOL_GRANT_PREFIX = "__openclaw_runtime_plugin_tool_grant__"; + +function runtimePluginToolGrantKey(pluginId: string, toolName: string): string { + return `${RUNTIME_PLUGIN_TOOL_GRANT_PREFIX}:${pluginId.trim().toLowerCase()}:${normalizeToolName(toolName)}`; +} + +export function appendRuntimePluginToolGrant( + allowlist: string[], + grant: RuntimePluginToolGrant | undefined, +): string[] { + return grant + ? [ + ...allowlist, + ...grant.toolNames.map((toolName) => runtimePluginToolGrantKey(grant.pluginId, toolName)), + ] + : allowlist; +} + +export function isPluginToolAllowed( + allowlist: Set, + pluginId: string, + toolName: string, +): boolean { + const normalizedToolName = normalizeToolName(toolName); + return ( + allowlist.has(normalizedToolName) || + allowlist.has(runtimePluginToolGrantKey(pluginId, normalizedToolName)) + ); +} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 29a95cca7c68..dfbf14499cdf 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -6,6 +6,7 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { appendRuntimePluginToolGrant } from "./tool-grant-allowlist.js"; type MockRegistryToolEntry = { pluginId: string; @@ -80,10 +81,15 @@ function createResolveToolsParams(params?: { env?: NodeJS.ProcessEnv; suppressNameConflicts?: boolean; allowGatewaySubagentBinding?: boolean; + runtimePluginToolGrant?: { pluginId: string; toolNames: readonly string[] }; }) { + const toolAllowlist = appendRuntimePluginToolGrant( + [...(params?.toolAllowlist ?? [])], + params?.runtimePluginToolGrant, + ); return { context: (params?.context ?? createContext()) as never, - ...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}), + ...(toolAllowlist.length > 0 ? { toolAllowlist } : {}), ...(params?.toolDenylist ? { toolDenylist: [...params.toolDenylist] } : {}), ...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}), ...(params?.env ? { env: params.env } : {}), @@ -1408,6 +1414,71 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(1); }); + it("applies an additive runtime grant only to its owning plugin", () => { + const ownerFactory = vi.fn(() => makeTool("optional_tool")); + const foreignFactory = vi.fn(() => makeTool("optional_tool")); + setRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + names: ["optional_tool"], + factory: ownerFactory, + }, + { + pluginId: "multi", + optional: true, + source: "/tmp/multi.js", + names: ["optional_tool"], + factory: foreignFactory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + runtimePluginToolGrant: { + pluginId: "optional-demo", + toolNames: ["optional_tool"], + }, + }), + ); + + expectResolvedToolNames(tools, ["optional_tool"]); + expect(ownerFactory).toHaveBeenCalledTimes(1); + expect(foreignFactory).not.toHaveBeenCalled(); + }); + + it("uses declared names for an unnamed owner-scoped factory and preserves denies", () => { + const factory = vi.fn(() => makeTool("optional_tool")); + setRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + names: [], + declaredNames: ["optional_tool"], + factory, + }, + ]); + const runtimePluginToolGrant = { + pluginId: "optional-demo", + toolNames: ["optional_tool"], + } as const; + + expectResolvedToolNames( + resolvePluginTools(createResolveToolsParams({ runtimePluginToolGrant })), + ["optional_tool"], + ); + expect( + resolvePluginTools( + createResolveToolsParams({ + runtimePluginToolGrant, + toolDenylist: ["optional_tool"], + }), + ), + ).toHaveLength(0); + }); + it.each([ { name: "allows optional tools by tool name", diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index f245cebff9da..f4640340648a 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -42,6 +42,7 @@ import { type PluginToolDescriptorConfigCacheKeyMemo, writeCachedPluginToolDescriptors, } from "./tool-descriptor-cache.js"; +import { isPluginToolAllowed } from "./tool-grant-allowlist.js"; import type { OpenClawPluginToolContext } from "./types.js"; /** MCP bridge metadata attached to plugin tools surfaced through agent tool lists. */ @@ -352,8 +353,7 @@ function isOptionalToolAllowed(params: { if (params.allowlist.has("*")) { return true; } - const toolName = normalizeToolName(params.toolName); - if (params.allowlist.has(toolName)) { + if (isPluginToolAllowed(params.allowlist, params.pluginId, params.toolName)) { return true; } const pluginKey = normalizeToolName(params.pluginId); @@ -381,7 +381,7 @@ function isOptionalToolEntryPotentiallyAllowed(params: { if (params.names.length === 0) { return true; } - return params.names.some((name) => params.allowlist.has(normalizeToolName(name))); + return params.names.some((name) => isPluginToolAllowed(params.allowlist, params.pluginId, name)); } function readPluginToolName(tool: unknown): string { @@ -563,7 +563,7 @@ function listManifestToolNamesForAllowlist(params: { return [...params.toolNames]; } const matchedToolNames = params.toolNames.filter((name) => - params.allowlist.has(normalizeToolName(name)), + isPluginToolAllowed(params.allowlist, params.pluginId, name), ); if (!allowlistIncludesDefaultPluginTools(params.allowlist)) { return matchedToolNames;