feat(tools): add tool policy system with 4-layer filtering
Implement a flexible tool policy system that supports: - Tool groups (group:fs, group:runtime, group:web) - Predefined profiles (minimal, coding, web, full) - Global allow/deny lists - Provider-specific rules - Subagent restrictions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
83ab7ee743
commit
57f31b2f79
3 changed files with 458 additions and 0 deletions
138
src/agent/tools/groups.ts
Normal file
138
src/agent/tools/groups.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Tool groups and profiles for policy-based filtering.
|
||||
*
|
||||
* Groups provide shortcuts for allowing/denying multiple tools at once.
|
||||
* Profiles are predefined tool sets for common use cases.
|
||||
*/
|
||||
|
||||
export type ToolProfileId = "minimal" | "coding" | "web" | "full";
|
||||
|
||||
/**
|
||||
* Tool name aliases for compatibility.
|
||||
* Maps alternative names to canonical tool names.
|
||||
*/
|
||||
export const TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
bash: "exec",
|
||||
shell: "exec",
|
||||
search: "web_search",
|
||||
fetch: "web_fetch",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool groups - shortcuts for multiple tools.
|
||||
* Use "group:name" in allow/deny lists.
|
||||
*/
|
||||
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
// File system operations
|
||||
"group:fs": ["read", "write", "edit", "glob"],
|
||||
|
||||
// Runtime/execution tools
|
||||
"group:runtime": ["exec", "process"],
|
||||
|
||||
// Web tools
|
||||
"group:web": ["web_search", "web_fetch"],
|
||||
|
||||
// All core tools
|
||||
"group:core": [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"glob",
|
||||
"exec",
|
||||
"process",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool profiles - predefined tool sets.
|
||||
*/
|
||||
export const TOOL_PROFILES: Record<ToolProfileId, { allow?: string[]; deny?: string[] }> = {
|
||||
// Minimal: no tools (useful for chat-only agents)
|
||||
minimal: {
|
||||
allow: [],
|
||||
},
|
||||
|
||||
// Coding: file system + execution (default for coding tasks)
|
||||
coding: {
|
||||
allow: ["group:fs", "group:runtime"],
|
||||
},
|
||||
|
||||
// Web: coding + web access
|
||||
web: {
|
||||
allow: ["group:fs", "group:runtime", "group:web"],
|
||||
},
|
||||
|
||||
// Full: no restrictions
|
||||
full: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default tools denied for subagents.
|
||||
* Subagents should not have access to session management or system tools.
|
||||
*/
|
||||
export const DEFAULT_SUBAGENT_TOOL_DENY: string[] = [
|
||||
// Future: session management tools
|
||||
// "sessions_list",
|
||||
// "sessions_history",
|
||||
// "sessions_send",
|
||||
// "sessions_spawn",
|
||||
// "session_status",
|
||||
|
||||
// Future: system tools
|
||||
// "gateway",
|
||||
// "agents_list",
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize a tool name to its canonical form.
|
||||
*/
|
||||
export function normalizeToolName(name: string): string {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return TOOL_NAME_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list of tool names.
|
||||
*/
|
||||
export function normalizeToolList(list?: string[]): string[] {
|
||||
if (!list) return [];
|
||||
return list.map(normalizeToolName).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand group references in a tool list.
|
||||
* "group:fs" -> ["read", "write", "edit", "glob"]
|
||||
*/
|
||||
export function expandToolGroups(list?: string[]): string[] {
|
||||
const normalized = normalizeToolList(list);
|
||||
const expanded: string[] = [];
|
||||
|
||||
for (const value of normalized) {
|
||||
const group = TOOL_GROUPS[value];
|
||||
if (group) {
|
||||
expanded.push(...group);
|
||||
continue;
|
||||
}
|
||||
expanded.push(value);
|
||||
}
|
||||
|
||||
return Array.from(new Set(expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy for a profile.
|
||||
*/
|
||||
export function getProfilePolicy(
|
||||
profile?: ToolProfileId,
|
||||
): { allow?: string[]; deny?: string[] } | undefined {
|
||||
if (!profile) return undefined;
|
||||
const resolved = TOOL_PROFILES[profile];
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved.allow && !resolved.deny) return undefined;
|
||||
return {
|
||||
allow: resolved.allow ? [...resolved.allow] : undefined,
|
||||
deny: resolved.deny ? [...resolved.deny] : undefined,
|
||||
};
|
||||
}
|
||||
34
src/agent/tools/index.ts
Normal file
34
src/agent/tools/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Tools module - provides tool creation and policy-based filtering.
|
||||
*/
|
||||
|
||||
// Tool implementations
|
||||
export { createExecTool } from "./exec.js";
|
||||
export { createProcessTool } from "./process.js";
|
||||
export { createGlobTool } from "./glob.js";
|
||||
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
|
||||
|
||||
// Tool groups and profiles
|
||||
export {
|
||||
type ToolProfileId,
|
||||
TOOL_NAME_ALIASES,
|
||||
TOOL_GROUPS,
|
||||
TOOL_PROFILES,
|
||||
DEFAULT_SUBAGENT_TOOL_DENY,
|
||||
normalizeToolName,
|
||||
normalizeToolList,
|
||||
expandToolGroups,
|
||||
getProfilePolicy,
|
||||
} from "./groups.js";
|
||||
|
||||
// Tool policy system
|
||||
export {
|
||||
type ToolPolicy,
|
||||
type ToolsConfig,
|
||||
type FilterToolsOptions,
|
||||
isToolAllowed,
|
||||
filterToolsByPolicy,
|
||||
filterTools,
|
||||
getSubagentPolicy,
|
||||
wouldToolBeAllowed,
|
||||
} from "./policy.js";
|
||||
286
src/agent/tools/policy.ts
Normal file
286
src/agent/tools/policy.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* Tool policy system for filtering tools based on configuration.
|
||||
*
|
||||
* Supports 4 layers of filtering:
|
||||
* 1. Profile - base tool set (minimal/coding/web/full)
|
||||
* 2. Global allow/deny - user customization
|
||||
* 3. Provider-specific - different rules for different LLM providers
|
||||
* 4. Subagent restrictions - limited tools for spawned agents
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type ToolProfileId,
|
||||
expandToolGroups,
|
||||
getProfilePolicy,
|
||||
normalizeToolName,
|
||||
DEFAULT_SUBAGENT_TOOL_DENY,
|
||||
} from "./groups.js";
|
||||
|
||||
/**
|
||||
* Tool policy configuration.
|
||||
*/
|
||||
export interface ToolPolicy {
|
||||
/** Allow list - only these tools are available (supports group:* syntax) */
|
||||
allow?: string[];
|
||||
/** Deny list - these tools are blocked (takes precedence over allow) */
|
||||
deny?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full tool configuration from config file.
|
||||
*/
|
||||
export interface ToolsConfig {
|
||||
/** Base profile (minimal/coding/web/full) */
|
||||
profile?: ToolProfileId;
|
||||
/** Additional tools to allow */
|
||||
allow?: string[];
|
||||
/** Tools to deny */
|
||||
deny?: string[];
|
||||
/** Provider-specific overrides */
|
||||
byProvider?: Record<string, ToolPolicy>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pattern Matching
|
||||
// ============================================================================
|
||||
|
||||
type CompiledPattern =
|
||||
| { kind: "all" }
|
||||
| { kind: "exact"; value: string }
|
||||
| { kind: "regex"; value: RegExp };
|
||||
|
||||
function compilePattern(pattern: string): CompiledPattern {
|
||||
const normalized = normalizeToolName(pattern);
|
||||
if (!normalized) return { kind: "exact", value: "" };
|
||||
if (normalized === "*") return { kind: "all" };
|
||||
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
|
||||
|
||||
// Convert wildcard to regex
|
||||
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return {
|
||||
kind: "regex",
|
||||
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
|
||||
};
|
||||
}
|
||||
|
||||
function compilePatterns(patterns?: string[]): CompiledPattern[] {
|
||||
if (!Array.isArray(patterns)) return [];
|
||||
return expandToolGroups(patterns)
|
||||
.map(compilePattern)
|
||||
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
|
||||
}
|
||||
|
||||
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.kind === "all") return true;
|
||||
if (pattern.kind === "exact" && name === pattern.value) return true;
|
||||
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy Matching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a matcher function for a policy.
|
||||
* Returns true if the tool is allowed, false if denied.
|
||||
*/
|
||||
function createPolicyMatcher(policy: ToolPolicy): (name: string) => boolean {
|
||||
const deny = compilePatterns(policy.deny);
|
||||
const allow = compilePatterns(policy.allow);
|
||||
// Check if allow was explicitly set (even if empty)
|
||||
const hasAllowList = Array.isArray(policy.allow);
|
||||
|
||||
return (name: string) => {
|
||||
const normalized = normalizeToolName(name);
|
||||
|
||||
// Deny takes precedence
|
||||
if (matchesAny(normalized, deny)) return false;
|
||||
|
||||
// If no allow list configured, allow all
|
||||
if (!hasAllowList) return true;
|
||||
|
||||
// If allow list is empty, deny all (explicit restriction)
|
||||
if (allow.length === 0) return false;
|
||||
|
||||
// Check if in allow list
|
||||
return matchesAny(normalized, allow);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is allowed by a policy.
|
||||
*/
|
||||
export function isToolAllowed(name: string, policy?: ToolPolicy): boolean {
|
||||
if (!policy) return true;
|
||||
return createPolicyMatcher(policy)(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tools by a policy.
|
||||
*/
|
||||
export function filterToolsByPolicy<T extends { name: string }>(
|
||||
tools: T[],
|
||||
policy?: ToolPolicy,
|
||||
): T[] {
|
||||
if (!policy) return tools;
|
||||
const matcher = createPolicyMatcher(policy);
|
||||
return tools.filter((tool) => matcher(tool.name));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy Resolution
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Merge allow lists (union).
|
||||
*/
|
||||
function mergeAllow(base?: string[], extra?: string[]): string[] | undefined {
|
||||
if (!extra || extra.length === 0) return base;
|
||||
if (!base || base.length === 0) return extra;
|
||||
return Array.from(new Set([...base, ...extra]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge deny lists (union).
|
||||
*/
|
||||
function mergeDeny(base?: string[], extra?: string[]): string[] | undefined {
|
||||
if (!extra || extra.length === 0) return base;
|
||||
if (!base || base.length === 0) return extra;
|
||||
return Array.from(new Set([...base, ...extra]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve provider-specific policy.
|
||||
*/
|
||||
function resolveProviderPolicy(
|
||||
byProvider?: Record<string, ToolPolicy>,
|
||||
provider?: string,
|
||||
): ToolPolicy | undefined {
|
||||
if (!provider || !byProvider) return undefined;
|
||||
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
return byProvider[normalized];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subagent tool policy.
|
||||
*/
|
||||
export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy {
|
||||
return {
|
||||
deny: mergeDeny(DEFAULT_SUBAGENT_TOOL_DENY, extraDeny),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Filter Function
|
||||
// ============================================================================
|
||||
|
||||
export interface FilterToolsOptions {
|
||||
/** Tool configuration */
|
||||
config?: ToolsConfig;
|
||||
/** Current LLM provider (for provider-specific rules) */
|
||||
provider?: string;
|
||||
/** Whether this is a subagent (applies subagent restrictions) */
|
||||
isSubagent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tools through the 4-layer policy system.
|
||||
*
|
||||
* Layer 1: Profile (base tool set)
|
||||
* Layer 2: Global allow/deny
|
||||
* Layer 3: Provider-specific
|
||||
* Layer 4: Subagent restrictions
|
||||
*/
|
||||
export function filterTools(
|
||||
tools: AgentTool<any>[],
|
||||
options: FilterToolsOptions = {},
|
||||
): AgentTool<any>[] {
|
||||
const { config, provider, isSubagent } = options;
|
||||
|
||||
let filtered = tools;
|
||||
|
||||
// Layer 1: Profile
|
||||
if (config?.profile) {
|
||||
const profilePolicy = getProfilePolicy(config.profile);
|
||||
if (profilePolicy) {
|
||||
filtered = filterToolsByPolicy(filtered, profilePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Global allow/deny
|
||||
if (config?.allow || config?.deny) {
|
||||
const globalPolicy: ToolPolicy = {
|
||||
allow: config.allow,
|
||||
deny: config.deny,
|
||||
};
|
||||
filtered = filterToolsByPolicy(filtered, globalPolicy);
|
||||
}
|
||||
|
||||
// Layer 3: Provider-specific
|
||||
if (provider && config?.byProvider) {
|
||||
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
|
||||
if (providerPolicy) {
|
||||
filtered = filterToolsByPolicy(filtered, providerPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: Subagent restrictions
|
||||
if (isSubagent) {
|
||||
const subagentPolicy = getSubagentPolicy();
|
||||
filtered = filterToolsByPolicy(filtered, subagentPolicy);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tool would be allowed given the options.
|
||||
*/
|
||||
export function wouldToolBeAllowed(
|
||||
toolName: string,
|
||||
options: FilterToolsOptions = {},
|
||||
): boolean {
|
||||
const { config, provider, isSubagent } = options;
|
||||
|
||||
// Layer 1: Profile
|
||||
if (config?.profile) {
|
||||
const profilePolicy = getProfilePolicy(config.profile);
|
||||
if (profilePolicy && !isToolAllowed(toolName, profilePolicy)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Global allow/deny
|
||||
if (config?.allow || config?.deny) {
|
||||
const globalPolicy: ToolPolicy = {
|
||||
allow: config.allow,
|
||||
deny: config.deny,
|
||||
};
|
||||
if (!isToolAllowed(toolName, globalPolicy)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Provider-specific
|
||||
if (provider && config?.byProvider) {
|
||||
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
|
||||
if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: Subagent restrictions
|
||||
if (isSubagent) {
|
||||
const subagentPolicy = getSubagentPolicy();
|
||||
if (!isToolAllowed(toolName, subagentPolicy)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue