import type { ToolsConfig, ToolProfile } from '../config/schema.js'; import type { Tool } from './types.js'; // ── Profile definitions ───────────────────────────────────────────── /** Built-in tool name sets for each profile level. Profiles are cumulative. */ const PROFILE_TOOLS: Record> = { minimal: new Set([ 'file.read', 'file.list', 'web.fetch', ]), messaging: new Set([ 'file.read', 'file.list', 'web.fetch', 'memory.read', 'memory.write', 'memory.search', 'web.search', ]), coding: new Set([ 'file.read', 'file.list', 'web.fetch', 'memory.read', 'memory.write', 'memory.search', 'web.search', 'file.write', 'file.edit', 'shell.exec', 'process.start', 'process.status', 'process.output', 'process.kill', 'process.list', ]), full: new Set(), // Special: matches everything }; // ── Glob matching ─────────────────────────────────────────────────── /** * Convert a simple glob pattern to a regex. * Supports `*` (matches any characters) and `.` is escaped. * Same algorithm as HookEngine.patternToRegex for consistency. */ function patternToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*'); return new RegExp(`^${escaped}$`); } function matchesAnyPattern(toolName: string, patterns: string[]): boolean { return patterns.some(p => patternToRegex(p).test(toolName)); } // ── Policy context ────────────────────────────────────────────────── /** Identifies the runtime context for tool policy resolution. */ export interface ToolPolicyContext { /** Model tier name (e.g. 'fast', 'default', 'complex', 'local'). */ agent?: string; /** Provider name (e.g. 'ollama', 'anthropic'). */ provider?: string; } // ── ToolPolicy engine ─────────────────────────────────────────────── /** * Resolves which tools are permitted for a given runtime context. * * Resolution order: * 1. Start with profile's tool set (or all tools for 'full') * 2. Apply global allow list (adds tools back in) * 3. Apply global deny list (removes tools) * 4. If agent/provider overrides exist, compute their resolved sets * and intersect with the global set * 5. Deny always wins over allow at every level */ export class ToolPolicy { private config: ToolsConfig; constructor(config: ToolsConfig) { this.config = config; } /** * Return the list of tools permitted for the given context. * This is the primary API — filters an array of Tool objects. */ filterTools(tools: Tool[], context?: ToolPolicyContext): Tool[] { const allowed = this.resolveAllowedNames( tools.map(t => t.name), context, ); return tools.filter(t => allowed.has(t.name)); } /** * Check whether a specific tool name is permitted in the given context. * Used for runtime enforcement in the executor (defense in depth). */ isAllowed(toolName: string, allToolNames: string[], context?: ToolPolicyContext): boolean { const allowed = this.resolveAllowedNames(allToolNames, context); return allowed.has(toolName); } /** * Resolve the full set of allowed tool names given an array of all * registered tool names and an optional context. */ resolveAllowedNames(allToolNames: string[], context?: ToolPolicyContext): Set { // Step 1: Start from global profile let allowed = this.applyProfile(this.config.profile, allToolNames); // Step 2: Apply global allow (adds tools) if (this.config.allow.length > 0) { for (const name of allToolNames) { if (matchesAnyPattern(name, this.config.allow)) { allowed.add(name); } } } // Step 3: Apply global deny (removes tools) if (this.config.deny.length > 0) { allowed = new Set( [...allowed].filter(name => !matchesAnyPattern(name, this.config.deny)), ); } // Step 4: Apply agent override if present if (context?.agent && this.config.agents[context.agent]) { const agentOverride = this.config.agents[context.agent]; const agentAllowed = this.resolveOverride(agentOverride, allToolNames); allowed = intersect(allowed, agentAllowed); } // Step 5: Apply provider override if present if (context?.provider && this.config.providers[context.provider]) { const providerOverride = this.config.providers[context.provider]; const providerAllowed = this.resolveOverride(providerOverride, allToolNames); allowed = intersect(allowed, providerAllowed); } return allowed; } /** * Get the effective profile for a given context. * Used for informational/debugging purposes. */ getEffectiveProfile(context?: ToolPolicyContext): ToolProfile { // Check agent override first, then provider, then global if (context?.agent && this.config.agents[context.agent]?.profile) { return this.config.agents[context.agent].profile!; } if (context?.provider && this.config.providers[context.provider]?.profile) { return this.config.providers[context.provider].profile!; } return this.config.profile; } // ── Private helpers ───────────────────────────────────────────────── /** * Resolve the tool set for a profile. * 'full' means all tools; other profiles return their defined set, * filtered to only include actually-registered tools. */ private applyProfile(profile: ToolProfile, allToolNames: string[]): Set { if (profile === 'full') { return new Set(allToolNames); } const profileSet = PROFILE_TOOLS[profile]; return new Set(allToolNames.filter(name => profileSet.has(name))); } /** * Resolve an override block (agent or provider) to a set of allowed names. * An override inherits from the global profile if it doesn't specify its own. */ private resolveOverride( override: { profile?: ToolProfile; allow: string[]; deny: string[] }, allToolNames: string[], ): Set { // Start from the override's profile, or inherit global const baseProfile = override.profile ?? this.config.profile; let allowed = this.applyProfile(baseProfile, allToolNames); // Apply override allow if (override.allow.length > 0) { for (const name of allToolNames) { if (matchesAnyPattern(name, override.allow)) { allowed.add(name); } } } // Apply override deny (deny always wins) if (override.deny.length > 0) { allowed = new Set( [...allowed].filter(name => !matchesAnyPattern(name, override.deny)), ); } return allowed; } } // ── Utility ───────────────────────────────────────────────────────── function intersect(a: Set, b: Set): Set { const result = new Set(); for (const item of a) { if (b.has(item)) { result.add(item); } } return result; } /** * Exported for testing and for use in HookEngine (DRY). */ export { patternToRegex, matchesAnyPattern, PROFILE_TOOLS };