Skip to content

Commit e5ec14a

Browse files
authored
fix(plugins): discover alsoAllow plugin tools
Summary: - Discover optional plugin tools named in tools.alsoAllow without treating additive alsoAllow as a restrictive plugin-tool allowlist. - Preserve explicit alsoAllow wildcards and keep default non-optional plugin tools visible. - Document llm-task and lobster enablement and add changelog coverage. Verification: - pnpm test src/agents/tool-policy.test.ts src/gateway/tools-invoke-http.test.ts src/agents/pi-tools.create-openclaw-coding-tools.test.ts src/plugins/tools.optional.test.ts - pnpm exec oxfmt --check --threads=1 src/agents/sandbox-tool-policy.ts src/agents/tool-policy.ts src/agents/tool-policy.test.ts src/agents/pi-tools.create-openclaw-coding-tools.test.ts src/gateway/tools-invoke-http.test.ts src/plugins/tools.ts src/plugins/tools.optional.test.ts - git diff --check - Blacksmith Testbox tbx_01kqr05924hz9kw50myxrqmsf9: pnpm check:changed Fixes #76616
1 parent 0bf19f5 commit e5ec14a

10 files changed

Lines changed: 126 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc.
5959
- Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX.
6060
- Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001.
61+
- Plugins/tools: honor `tools.alsoAllow` as an optional plugin tool discovery hint without treating its internal allow-all default as permission to load every optional plugin tool. Fixes #76616.
6162
- Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc.
6263
- CLI/plugins: reject unowned command roots such as `openclaw foo` before managed proxy startup and full plugin CLI runtime loading while preserving manifest-owned and CLI-metadata-owned plugin commands. Fixes #75287. Thanks @neilofneils404.
6364
- CLI/message: skip local configured-channel plugin preload for explicit gateway-owned message actions, letting normalized CLI delivery delegate to the gateway without initializing channel runtime in the short-lived CLI process. Fixes #75477.

docs/tools/llm-task.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,18 @@ without writing custom OpenClaw code for each workflow.
2626
}
2727
```
2828

29-
2. Allowlist the tool (it is registered with `optional: true`):
29+
2. Allow the optional tool:
3030

3131
```json
3232
{
33-
"agents": {
34-
"list": [
35-
{
36-
"id": "main",
37-
"tools": { "allow": ["llm-task"] }
38-
}
39-
]
33+
"tools": {
34+
"alsoAllow": ["llm-task"]
4035
}
4136
}
4237
```
4338

39+
Use `tools.allow` only when you want restrictive allowlist mode.
40+
4441
## Config (optional)
4542

4643
```json

docs/tools/lobster.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ Or per-agent:
191191
Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
192192

193193
<Note>
194-
Allowlists are opt-in for optional plugins. If your allowlist only names plugin tools (like `lobster`), OpenClaw keeps core tools enabled. To restrict core tools, include the core tools or groups you want in the allowlist too.
194+
Allowlists are opt-in for optional plugins. `alsoAllow` enables only the named optional plugin tools while preserving the normal core tool set. To restrict core tools, use `tools.allow` with the core tools or groups you want.
195195
</Note>
196196

197197
## Example: Email triage

src/agents/pi-tools.create-openclaw-coding-tools.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js"
1919
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
2020
import { providerAliasCases } from "./test-helpers/provider-alias-cases.js";
2121
import { buildEmptyExplicitToolAllowlistError } from "./tool-allowlist-guard.js";
22-
import { normalizeToolName } from "./tool-policy.js";
22+
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "./tool-policy.js";
2323

2424
const tinyPngBuffer = Buffer.from(
2525
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=",
@@ -180,6 +180,21 @@ describe("createOpenClawCodingTools", () => {
180180
);
181181
});
182182

183+
it("uses tools.alsoAllow for optional plugin discovery without widening to all plugins", () => {
184+
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
185+
createOpenClawToolsMock.mockClear();
186+
187+
createOpenClawCodingTools({
188+
config: { tools: { alsoAllow: ["lobster"] } },
189+
});
190+
191+
expect(createOpenClawToolsMock).toHaveBeenCalledWith(
192+
expect.objectContaining({
193+
pluginToolAllowlist: ["lobster", DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY],
194+
}),
195+
);
196+
});
197+
183198
it("passes explicit denylist entries to OpenClaw tool factory planning", () => {
184199
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
185200
createOpenClawToolsMock.mockClear();

src/agents/sandbox-tool-policy.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { SandboxToolPolicy } from "./sandbox/types.js";
22

3+
export const IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW = Symbol.for(
4+
"openclaw.toolPolicy.implicitAllowAllFromAlsoAllow",
5+
);
6+
37
type SandboxToolPolicyConfig = {
48
allow?: string[];
59
alsoAllow?: string[];
@@ -19,12 +23,21 @@ function unionAllow(base?: string[], extra?: string[]): string[] | undefined {
1923
return Array.from(new Set([...base, ...extra]));
2024
}
2125

26+
function hasExplicitAllowAll(list?: string[]): boolean {
27+
return Array.isArray(list) && list.some((entry) => entry.trim() === "*");
28+
}
29+
2230
export function pickSandboxToolPolicy(
2331
config?: SandboxToolPolicyConfig,
2432
): SandboxToolPolicy | undefined {
2533
if (!config) {
2634
return undefined;
2735
}
36+
const allowFromAlsoAllowOnly =
37+
!Array.isArray(config.allow) &&
38+
Array.isArray(config.alsoAllow) &&
39+
config.alsoAllow.length > 0 &&
40+
!hasExplicitAllowAll(config.alsoAllow);
2841
const allow = Array.isArray(config.allow)
2942
? unionAllow(config.allow, config.alsoAllow)
3043
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
@@ -34,5 +47,13 @@ export function pickSandboxToolPolicy(
3447
if (!allow && !deny) {
3548
return undefined;
3649
}
37-
return { allow, deny };
50+
const policy = { allow, deny } as SandboxToolPolicy & {
51+
[IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW]?: true;
52+
};
53+
if (allowFromAlsoAllowOnly) {
54+
Object.defineProperty(policy, IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW, {
55+
value: true,
56+
});
57+
}
58+
return policy;
3859
}

src/agents/tool-policy.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { describe, expect, it } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
33
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
4+
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
45
import { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js";
56
import type { SandboxToolPolicy } from "./sandbox/types.js";
67
import { isToolAllowedByPolicyName } from "./tool-policy-match.js";
78
import { TOOL_POLICY_CONFORMANCE } from "./tool-policy.conformance.js";
89
import {
910
applyOwnerOnlyToolPolicy,
1011
collectExplicitAllowlist,
12+
DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY,
1113
expandToolGroups,
1214
isOwnerOnlyToolName,
1315
normalizeToolName,
@@ -141,7 +143,7 @@ describe("tool-policy", () => {
141143
expect(applyOwnerOnlyToolPolicy(tools, true)).toHaveLength(1);
142144
});
143145

144-
it("preserves explicit alsoAllow hints when allow is empty", () => {
146+
it("collects explicit allowlist entries", () => {
145147
expect(
146148
collectExplicitAllowlist([
147149
{
@@ -151,6 +153,23 @@ describe("tool-policy", () => {
151153
).toContain("optional-demo");
152154
});
153155

156+
it("uses alsoAllow entries for plugin discovery without the synthetic allow-all", () => {
157+
expect(collectExplicitAllowlist([pickSandboxToolPolicy({ alsoAllow: ["lobster"] })])).toEqual([
158+
"lobster",
159+
DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY,
160+
]);
161+
expect(
162+
collectExplicitAllowlist([pickSandboxToolPolicy({ allow: [], alsoAllow: ["lobster"] })]),
163+
).toEqual(["*", "lobster"]);
164+
});
165+
166+
it("preserves explicit alsoAllow wildcards for plugin discovery", () => {
167+
expect(collectExplicitAllowlist([pickSandboxToolPolicy({ alsoAllow: ["*"] })])).toEqual(["*"]);
168+
expect(collectExplicitAllowlist([pickSandboxToolPolicy({ alsoAllow: [" * "] })])).toEqual([
169+
"*",
170+
]);
171+
});
172+
154173
it("strips nodes for non-owner senders via fallback policy", () => {
155174
const tools = [
156175
{

src/agents/tool-policy.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
2+
import { IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW } from "./sandbox-tool-policy.js";
23
import {
34
expandToolGroups,
45
normalizeToolList,
@@ -80,6 +81,7 @@ export function applyOwnerOnlyToolPolicy(
8081
export type ToolPolicyLike = {
8182
allow?: string[];
8283
deny?: string[];
84+
[IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW]?: true;
8385
};
8486

8587
export type PluginToolGroups = {
@@ -93,6 +95,8 @@ export type AllowlistResolution = {
9395
pluginOnlyAllowlist: boolean;
9496
};
9597

98+
export const DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY = "__openclaw_default_plugin_tools__";
99+
96100
export function collectExplicitAllowlist(policies: Array<ToolPolicyLike | undefined>): string[] {
97101
const entries: string[] = [];
98102
for (const policy of policies) {
@@ -104,12 +108,18 @@ export function collectExplicitAllowlist(policies: Array<ToolPolicyLike | undefi
104108
continue;
105109
}
106110
const trimmed = value.trim();
111+
if (trimmed === "*" && policy[IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW] === true) {
112+
continue;
113+
}
107114
if (trimmed) {
108115
entries.push(trimmed);
109116
}
110117
}
118+
if (policy[IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW] === true) {
119+
entries.push(DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY);
120+
}
111121
}
112-
return entries;
122+
return Array.from(new Set(entries));
113123
}
114124

115125
export function collectExplicitDenylist(policies: Array<ToolPolicyLike | undefined>): string[] {

src/gateway/tools-invoke-http.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,26 @@ describe("POST /tools/invoke", () => {
494494
);
495495
});
496496

497+
it("uses tools.alsoAllow for optional plugin discovery without loading every plugin tool", async () => {
498+
cfg = {
499+
...cfg,
500+
agents: { list: [{ id: "main", default: true }] },
501+
tools: { alsoAllow: ["plugin_doctor"] },
502+
};
503+
504+
const res = await invokeToolAuthed({
505+
tool: "plugin_doctor",
506+
sessionKey: "main",
507+
});
508+
509+
const body = await expectOkInvokeResponse(res);
510+
expect(body.result).toMatchObject({ ok: true, permissionFlow: true });
511+
expect(lastCreateOpenClawToolsContext?.pluginToolAllowlist).toEqual(
512+
expect.arrayContaining(["plugin_doctor"]),
513+
);
514+
expect(lastCreateOpenClawToolsContext?.pluginToolAllowlist).not.toContain("*");
515+
});
516+
497517
it("blocks tool execution when before_tool_call rejects the invoke", async () => {
498518
setMainAllowedTools({ allow: ["tools_invoke_test"] });
499519
hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({

src/plugins/tools.optional.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY } from "../agents/tool-policy.js";
23
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
34
import { loggingState } from "../logging/state.js";
45
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
@@ -945,6 +946,26 @@ describe("resolvePluginTools optional tools", () => {
945946
expectResolvedToolNames(tools, ["optional_tool"]);
946947
});
947948

949+
it("keeps default non-optional plugin tools when alsoAllow opts into optional tools", () => {
950+
const defaultEntry: MockRegistryToolEntry = {
951+
pluginId: "multi",
952+
optional: false,
953+
source: "/tmp/multi.js",
954+
names: ["other_tool"],
955+
declaredNames: ["other_tool"],
956+
factory: () => makeTool("other_tool"),
957+
};
958+
setRegistry([defaultEntry, createOptionalDemoEntry()]);
959+
960+
const tools = resolvePluginTools(
961+
createResolveToolsParams({
962+
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
963+
}),
964+
);
965+
966+
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
967+
});
968+
948969
it("rejects plugin id collisions with core tool names", () => {
949970
const registry = setRegistry([
950971
{

src/plugins/tools.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { normalizeToolName } from "../agents/tool-policy.js";
1+
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "../agents/tool-policy.js";
22
import type { AnyAgentTool } from "../agents/tools/common.js";
33
import { createSubsystemLogger } from "../logging/subsystem.js";
44
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
@@ -86,6 +86,10 @@ function normalizeAllowlist(list?: string[]) {
8686
return new Set((list ?? []).map(normalizeToolName).filter(Boolean));
8787
}
8888

89+
function allowlistIncludesDefaultPluginTools(allowlist: Set<string>): boolean {
90+
return allowlist.size === 0 || allowlist.has(DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY);
91+
}
92+
8993
function isOptionalToolAllowed(params: {
9094
toolName: string;
9195
pluginId: string;
@@ -289,8 +293,8 @@ function pluginToolNamesMatchAllowlist(params: {
289293
optional: boolean;
290294
allowlist: Set<string>;
291295
}): boolean {
292-
if (params.allowlist.size === 0) {
293-
return !params.optional;
296+
if (!params.optional && allowlistIncludesDefaultPluginTools(params.allowlist)) {
297+
return true;
294298
}
295299
return isOptionalToolEntryPotentiallyAllowed(params);
296300
}
@@ -303,7 +307,7 @@ function manifestToolContractMatchesAllowlist(params: {
303307
if (params.toolNames.length === 0) {
304308
return false;
305309
}
306-
if (params.allowlist.size === 0) {
310+
if (allowlistIncludesDefaultPluginTools(params.allowlist)) {
307311
return true;
308312
}
309313
if (params.allowlist.has("*") || params.allowlist.has("group:plugins")) {
@@ -322,7 +326,7 @@ function listManifestToolNamesForAvailability(params: {
322326
allowlist: Set<string>;
323327
}): string[] {
324328
if (
325-
params.allowlist.size === 0 ||
329+
allowlistIncludesDefaultPluginTools(params.allowlist) ||
326330
params.allowlist.has("*") ||
327331
params.allowlist.has("group:plugins")
328332
) {

0 commit comments

Comments
 (0)