Files
openclaw/src/agents/tool-policy.ts
Agustin Rivera 193fdd6e3b fix(policy): preserve restrictive tool allowlists (#58476)
* fix(policy): preserve restrictive tool allowlists

Co-authored-by: David Silva <david.silva@gendigital.com>

* fix(policy): address review follow-ups

* fix(policy): restore additive alsoAllow semantics

* fix(policy): preserve optional tool opt-ins for allow-all configs

* fix(policy): narrow plugin-only allowlist warnings

* fix(policy): add changelog entry

* Revert "fix(policy): add changelog entry"

This reverts commit 4a996bf4ca.

* chore: add changelog for restrictive tool allowlists

---------

Co-authored-by: David Silva <david.silva@gendigital.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 12:55:36 -06:00

216 lines
5.8 KiB
TypeScript

import {
expandToolGroups,
normalizeToolList,
normalizeToolName,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy-shared.js";
import type { AnyAgentTool } from "./tools/common.js";
export {
expandToolGroups,
normalizeToolList,
normalizeToolName,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy-shared.js";
export type { ToolProfileId } from "./tool-policy-shared.js";
export type OwnerOnlyToolApprovalClass = "control_plane" | "exec_capable" | "interactive";
// Keep tool-policy browser-safe: do not import tools/common at runtime.
function wrapOwnerOnlyToolExecution(tool: AnyAgentTool, senderIsOwner: boolean): AnyAgentTool {
if (tool.ownerOnly !== true || senderIsOwner || !tool.execute) {
return tool;
}
return {
...tool,
execute: async () => {
throw new Error("Tool restricted to owner senders.");
},
};
}
const OWNER_ONLY_TOOL_APPROVAL_CLASS_FALLBACKS = new Map<string, OwnerOnlyToolApprovalClass>([
["whatsapp_login", "interactive"],
["cron", "control_plane"],
["gateway", "control_plane"],
["nodes", "exec_capable"],
]);
export function resolveOwnerOnlyToolApprovalClass(
name: string,
): OwnerOnlyToolApprovalClass | undefined {
return OWNER_ONLY_TOOL_APPROVAL_CLASS_FALLBACKS.get(normalizeToolName(name));
}
export function isOwnerOnlyToolName(name: string) {
return resolveOwnerOnlyToolApprovalClass(name) !== undefined;
}
function isOwnerOnlyTool(tool: AnyAgentTool) {
return tool.ownerOnly === true || isOwnerOnlyToolName(tool.name);
}
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyTool(tool)) {
return tool;
}
return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
});
if (senderIsOwner) {
return withGuard;
}
return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
}
export type ToolPolicyLike = {
allow?: string[];
deny?: string[];
};
export type PluginToolGroups = {
all: string[];
byPlugin: Map<string, string[]>;
};
export type AllowlistResolution = {
policy: ToolPolicyLike | undefined;
unknownAllowlist: string[];
pluginOnlyAllowlist: boolean;
};
export function collectExplicitAllowlist(policies: Array<ToolPolicyLike | undefined>): string[] {
const entries: string[] = [];
for (const policy of policies) {
if (!policy?.allow) {
continue;
}
for (const value of policy.allow) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
entries.push(trimmed);
}
}
}
return entries;
}
export function buildPluginToolGroups<T extends { name: string }>(params: {
tools: T[];
toolMeta: (tool: T) => { pluginId: string } | undefined;
}): PluginToolGroups {
const all: string[] = [];
const byPlugin = new Map<string, string[]>();
for (const tool of params.tools) {
const meta = params.toolMeta(tool);
if (!meta) {
continue;
}
const name = normalizeToolName(tool.name);
all.push(name);
const pluginId = meta.pluginId.trim().toLowerCase();
if (!pluginId) {
continue;
}
const list = byPlugin.get(pluginId) ?? [];
list.push(name);
byPlugin.set(pluginId, list);
}
return { all, byPlugin };
}
export function expandPluginGroups(
list: string[] | undefined,
groups: PluginToolGroups,
): string[] | undefined {
if (!list || list.length === 0) {
return list;
}
const expanded: string[] = [];
for (const entry of list) {
const normalized = normalizeToolName(entry);
if (normalized === "group:plugins") {
if (groups.all.length > 0) {
expanded.push(...groups.all);
} else {
expanded.push(normalized);
}
continue;
}
const tools = groups.byPlugin.get(normalized);
if (tools && tools.length > 0) {
expanded.push(...tools);
continue;
}
expanded.push(normalized);
}
return Array.from(new Set(expanded));
}
export function expandPolicyWithPluginGroups(
policy: ToolPolicyLike | undefined,
groups: PluginToolGroups,
): ToolPolicyLike | undefined {
if (!policy) {
return undefined;
}
return {
allow: expandPluginGroups(policy.allow, groups),
deny: expandPluginGroups(policy.deny, groups),
};
}
export function analyzeAllowlistByToolType(
policy: ToolPolicyLike | undefined,
groups: PluginToolGroups,
coreTools: Set<string>,
): AllowlistResolution {
if (!policy?.allow || policy.allow.length === 0) {
return { policy, unknownAllowlist: [], pluginOnlyAllowlist: false };
}
const normalized = normalizeToolList(policy.allow);
if (normalized.length === 0) {
return { policy, unknownAllowlist: [], pluginOnlyAllowlist: false };
}
const pluginIds = new Set(groups.byPlugin.keys());
const pluginTools = new Set(groups.all);
const unknownAllowlist: string[] = [];
let hasOnlyPluginEntries = true;
for (const entry of normalized) {
if (entry === "*") {
hasOnlyPluginEntries = false;
continue;
}
const isPluginEntry =
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
const expanded = expandToolGroups([entry]);
const isCoreEntry = expanded.some((tool) => coreTools.has(tool));
if (!isPluginEntry) {
hasOnlyPluginEntries = false;
}
if (!isCoreEntry && !isPluginEntry) {
unknownAllowlist.push(entry);
}
}
const pluginOnlyAllowlist = hasOnlyPluginEntries;
return {
policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
pluginOnlyAllowlist,
};
}
export function mergeAlsoAllowPolicy<TPolicy extends { allow?: string[] }>(
policy: TPolicy | undefined,
alsoAllow?: string[],
): TPolicy | undefined {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) {
return policy;
}
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
}