diff --git a/CHANGELOG.md b/CHANGELOG.md index 42904fcc1ef..3debcbe4e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Browser control: scope standalone loopback auth to the resolved active gateway credential and fail closed when password mode lacks a resolved password, so inactive tokens or passwords no longer authorize browser routes. Fixes #65626. (#65639) Thanks @coygeek. - Control UI/Codex harness: emit native Codex app-server assistant and lifecycle completion events so live webchat runs stop spinning without needing a transcript reload fallback. (#70815) Thanks @lesaai. - Agents/sessions: persist the runtime-resolved context budget from embedded agent runs, so Codex GPT-5.5 sessions keep the catalog/runtime context cap instead of falling back to the generic 200k status value. Fixes #71294. Thanks @tud0r. +- Agents/tools: fail runs before model submission when explicit tool allowlists resolve to no callable tools, preventing text-only hallucinated tool results for missing tools such as plugin commands that were not registered. Fixes #71292. - Discord/replies: run `message_sending` plugin hooks for Discord reply delivery, including DM targets, so plugins can transform or cancel outbound Discord replies consistently with other channels. Fixes #59350. (#71094) Thanks @wei840222. - Control UI/commands: carry provider-owned thinking option ids/labels in session rows and defaults so fresh sessions show and accept dynamic modes such as `adaptive`, `xhigh`, and `max`. Fixes #71269. Thanks @Young-Khalil. - Image generation: make explicit `model=` overrides exact-only so failed `openai/gpt-image-2` requests no longer fall through to Gemini or other configured providers, and update `image_generate list` to mention OpenAI Codex OAuth as valid auth for `openai/gpt-image-2`. Fixes #71290 and #71231. Thanks @Young-Khalil and @steipete. diff --git a/docs/tools/index.md b/docs/tools/index.md index 07f53cc1896..16c154fe328 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -125,6 +125,12 @@ config. Deny always wins over allow. } ``` +OpenClaw fails closed when an explicit allowlist resolves to no callable tools. +For example, `tools.allow: ["query_db"]` only works if a loaded plugin actually +registers `query_db`. If no built-in, plugin, or bundled MCP tool matches the +allowlist, the run stops before the model call instead of continuing as a +text-only run that could hallucinate tool results. + ### Tool profiles `tools.profile` sets a base allowlist before `allow`/`deny` is applied. diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index 1995fab656d..a3711ed327e 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -207,6 +207,12 @@ If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent. Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`). +If any explicit allowlist in that chain leaves the run with no callable tools, +OpenClaw stops before submitting the prompt to the model. This is intentional: +an agent configured with a missing tool such as +`agents.list[].tools.allow: ["query_db"]` should fail loudly until the plugin +that registers `query_db` is enabled, not continue as a text-only agent. + Tool policies support `group:*` shorthands that expand to multiple tools. See [Tool groups](/gateway/sandbox-vs-tool-policy-vs-elevated#tool-groups-shorthands) for the full list. Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated Mode](/tools/elevated) for details. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f3ed025fa00..b125e3d6235 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -108,6 +108,11 @@ import { toClientToolDefinitions, } from "../../pi-tool-definition-adapter.js"; import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveSubagentToolPolicyForSession, +} from "../../pi-tools.policy.js"; import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; import { registerProviderStreamForModel } from "../../provider-stream.js"; @@ -126,10 +131,18 @@ import { applySkillEnvOverridesFromSnapshot, resolveSkillsPromptForRun, } from "../../skills.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../../subagent-capabilities.js"; import { resolveSystemPromptOverride } from "../../system-prompt-override.js"; import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { resolveAgentTimeoutMs } from "../../timeout.js"; +import { + buildEmptyExplicitToolAllowlistError, + collectExplicitToolAllowlistSources, +} from "../../tool-allowlist-guard.js"; import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js"; import { resolveTranscriptPolicy, @@ -451,6 +464,79 @@ export function applyEmbeddedAttemptToolsAllow( return tools.filter((tool) => allowSet.has(tool.name)); } +function collectAttemptExplicitToolAllowlistSources(params: { + config?: EmbeddedRunAttemptParams["config"]; + sessionKey?: string; + sandboxSessionKey?: string; + agentId?: string; + modelProvider?: string; + modelId?: string; + messageProvider?: string; + agentAccountId?: string | null; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; + sandboxToolPolicy?: { allow?: string[]; deny?: string[] }; + toolsAllow?: string[]; +}) { + const { agentId, globalPolicy, globalProviderPolicy, agentPolicy, agentProviderPolicy } = + resolveEffectiveToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + agentId: params.agentId, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + const groupPolicy = resolveGroupToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + spawnedBy: params.spawnedBy, + messageProvider: params.messageProvider, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.agentAccountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + const subagentStore = resolveSubagentCapabilityStore(params.sandboxSessionKey, { + cfg: params.config, + }); + const subagentPolicy = + params.sandboxSessionKey && + isSubagentEnvelopeSession(params.sandboxSessionKey, { + cfg: params.config, + store: subagentStore, + }) + ? resolveSubagentToolPolicyForSession(params.config, params.sandboxSessionKey, { + store: subagentStore, + }) + : undefined; + return collectExplicitToolAllowlistSources([ + { label: "tools.allow", allow: globalPolicy?.allow }, + { label: "tools.byProvider.allow", allow: globalProviderPolicy?.allow }, + { + label: agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow", + allow: agentPolicy?.allow, + }, + { + label: agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow", + allow: agentProviderPolicy?.allow, + }, + { label: "group tools.allow", allow: groupPolicy?.allow }, + { label: "sandbox tools.allow", allow: params.sandboxToolPolicy?.allow }, + { label: "subagent tools.allow", allow: subagentPolicy?.allow }, + { label: "runtime toolsAllow", allow: params.toolsAllow }, + ]); +} + export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { @@ -802,6 +888,32 @@ export async function runEmbeddedAttempt( tools: effectiveTools, clientTools, }); + const explicitToolAllowlistSources = collectAttemptExplicitToolAllowlistSources({ + config: params.config, + sessionKey: params.sessionKey, + sandboxSessionKey, + agentId: sessionAgentId, + modelProvider: params.provider, + modelId: params.modelId, + messageProvider: params.messageChannel ?? params.messageProvider, + agentAccountId: params.agentAccountId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + sandboxToolPolicy: sandbox?.tools, + toolsAllow: params.toolsAllow, + }); + const emptyExplicitToolAllowlistError = buildEmptyExplicitToolAllowlistError({ + sources: explicitToolAllowlistSources, + callableToolNames: effectiveTools.map((tool) => tool.name), + toolsEnabled, + disableTools: params.disableTools, + }); if (params.runtimePlan) { params.runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext); } else { @@ -2031,6 +2143,12 @@ export async function runEmbeddedAttempt( let skipPromptSubmission = false; try { const promptStartedAt = Date.now(); + if (emptyExplicitToolAllowlistError) { + promptError = emptyExplicitToolAllowlistError; + promptErrorSource = "precheck"; + skipPromptSubmission = true; + log.warn(`[tools] ${emptyExplicitToolAllowlistError.message}`); + } // Run before_prompt_build hooks to allow plugins to inject prompt context. // Legacy compatibility: before_agent_start is also checked for context fields. diff --git a/src/agents/tool-allowlist-guard.test.ts b/src/agents/tool-allowlist-guard.test.ts new file mode 100644 index 00000000000..89e5f60e463 --- /dev/null +++ b/src/agents/tool-allowlist-guard.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + buildEmptyExplicitToolAllowlistError, + collectExplicitToolAllowlistSources, +} from "./tool-allowlist-guard.js"; + +describe("tool allowlist guard", () => { + it("fails closed when explicit allowlists resolve to no callable tools", () => { + const error = buildEmptyExplicitToolAllowlistError({ + sources: [{ label: "tools.allow", entries: [" query_db "] }], + callableToolNames: [], + toolsEnabled: true, + }); + + expect(error?.message).toContain("No callable tools remain"); + expect(error?.message).toContain("tools.allow: query_db"); + expect(error?.message).toContain("no registered tools matched"); + }); + + it("fails closed for runtime toolsAllow when tools are disabled", () => { + const error = buildEmptyExplicitToolAllowlistError({ + sources: [{ label: "runtime toolsAllow", entries: ["query_db"] }], + callableToolNames: [], + toolsEnabled: true, + disableTools: true, + }); + + expect(error?.message).toContain("runtime toolsAllow: query_db"); + expect(error?.message).toContain("tools are disabled for this run"); + }); + + it("fails closed when the selected model cannot use requested tools", () => { + const error = buildEmptyExplicitToolAllowlistError({ + sources: [{ label: "agents.db.tools.allow", entries: ["query_db"] }], + callableToolNames: [], + toolsEnabled: false, + }); + + expect(error?.message).toContain("agents.db.tools.allow: query_db"); + expect(error?.message).toContain("the selected model does not support tools"); + }); + + it("allows text-only runs without explicit allowlists", () => { + expect( + buildEmptyExplicitToolAllowlistError({ + sources: [], + callableToolNames: [], + toolsEnabled: true, + }), + ).toBeNull(); + }); + + it("allows explicit allowlists when at least one callable tool remains", () => { + expect( + buildEmptyExplicitToolAllowlistError({ + sources: [{ label: "tools.allow", entries: ["read", "missing_tool"] }], + callableToolNames: ["read"], + toolsEnabled: true, + }), + ).toBeNull(); + }); + + it("keeps source labels for config and runtime allowlists", () => { + const sources = collectExplicitToolAllowlistSources([ + { label: "tools.allow", allow: [" read ", ""] }, + { label: "runtime toolsAllow", allow: ["query_db"] }, + { label: "tools.byProvider.allow" }, + ]); + + expect(sources).toEqual([ + { label: "tools.allow", entries: ["read"] }, + { label: "runtime toolsAllow", entries: ["query_db"] }, + ]); + }); +}); diff --git a/src/agents/tool-allowlist-guard.ts b/src/agents/tool-allowlist-guard.ts new file mode 100644 index 00000000000..33c356a4f43 --- /dev/null +++ b/src/agents/tool-allowlist-guard.ts @@ -0,0 +1,39 @@ +import { normalizeToolName } from "./tool-policy.js"; + +export type ExplicitToolAllowlistSource = { + label: string; + entries: string[]; +}; + +export function collectExplicitToolAllowlistSources( + sources: Array<{ label: string; allow?: string[] }>, +): ExplicitToolAllowlistSource[] { + return sources.flatMap((source) => { + const entries = (source.allow ?? []).map((entry) => entry.trim()).filter(Boolean); + return entries.length ? [{ label: source.label, entries }] : []; + }); +} + +export function buildEmptyExplicitToolAllowlistError(params: { + sources: ExplicitToolAllowlistSource[]; + callableToolNames: string[]; + toolsEnabled: boolean; + disableTools?: boolean; +}): Error | null { + const callableToolNames = params.callableToolNames.map(normalizeToolName).filter(Boolean); + if (params.sources.length === 0 || callableToolNames.length > 0) { + return null; + } + const requested = params.sources + .map((source) => `${source.label}: ${source.entries.map(normalizeToolName).join(", ")}`) + .join("; "); + const reason = + params.disableTools === true + ? "tools are disabled for this run" + : params.toolsEnabled + ? "no registered tools matched" + : "the selected model does not support tools"; + return new Error( + `No callable tools remain after resolving explicit tool allowlist (${requested}); ${reason}. Fix the allowlist or enable the plugin that registers the requested tool.`, + ); +}