fix(agents): fail empty explicit tool allowlists

This commit is contained in:
Peter Steinberger
2026-04-25 01:11:14 +01:00
parent 107d2b7a09
commit 61ee67aecc
6 changed files with 245 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<T extends { name: string }>(
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<EmbeddedRunAttemptResult> {
@@ -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.

View File

@@ -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"] },
]);
});
});

View File

@@ -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.`,
);
}