From 57f31b2f79d4db557c0b6cdecffa3f1307d57049 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 30 Jan 2026 21:40:45 +0800 Subject: [PATCH] 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 --- src/agent/tools/groups.ts | 138 ++++++++++++++++++ src/agent/tools/index.ts | 34 +++++ src/agent/tools/policy.ts | 286 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/agent/tools/groups.ts create mode 100644 src/agent/tools/index.ts create mode 100644 src/agent/tools/policy.ts diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts new file mode 100644 index 00000000..04909716 --- /dev/null +++ b/src/agent/tools/groups.ts @@ -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 = { + 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 = { + // 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 = { + // 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, + }; +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts new file mode 100644 index 00000000..1e6f6334 --- /dev/null +++ b/src/agent/tools/index.ts @@ -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"; diff --git a/src/agent/tools/policy.ts b/src/agent/tools/policy.ts new file mode 100644 index 00000000..91a1ec08 --- /dev/null +++ b/src/agent/tools/policy.ts @@ -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; +} + +// ============================================================================ +// 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( + 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, + 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[], + options: FilterToolsOptions = {}, +): AgentTool[] { + 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; +}