mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(agents): fail empty explicit tool allowlists
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
75
src/agents/tool-allowlist-guard.test.ts
Normal file
75
src/agents/tool-allowlist-guard.test.ts
Normal 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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
39
src/agents/tool-allowlist-guard.ts
Normal file
39
src/agents/tool-allowlist-guard.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user