import type { AutonomyLevel, ToolsConfig, ToolProfile } from '../config/schema.js'; import type { Tool } from './types.js'; import type { SkillPermissions } from '../skills/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', 'system.info', ]), messaging: new Set([ 'file.read', 'file.list', 'web.fetch', 'system.info', 'memory.read', 'memory.write', 'memory.search', 'web.search', 'web.search.news', 'gmail.list', 'gmail.search', 'gmail.read', 'calendar.today', 'calendar.list', 'calendar.search', 'docs.list', 'docs.search', 'docs.read', 'drive.list', 'drive.search', 'drive.read', 'tasks.lists', 'tasks.list', 'cron.list', 'cron.trigger', 'cron.create', 'cron.delete', 'minio.share', 'minio.ingest', 'minio.sync', 'k8s.pods', 'k8s.deployments', 'k8s.logs', 'agent.delegate', 'agents.list', 'council.run', ]), coding: new Set([ 'file.read', 'file.list', 'web.fetch', 'system.info', 'memory.read', 'memory.write', 'memory.search', 'web.search', 'web.search.news', 'gmail.list', 'gmail.search', 'gmail.read', 'calendar.today', 'calendar.list', 'calendar.search', 'docs.list', 'docs.search', 'docs.read', 'drive.list', 'drive.search', 'drive.read', 'tasks.lists', 'tasks.list', 'cron.list', 'cron.trigger', 'cron.create', 'cron.delete', 'minio.share', 'minio.ingest', 'minio.sync', 'k8s.pods', 'k8s.deployments', 'k8s.logs', 'file.write', 'file.edit', 'file.patch', 'shell.exec', 'process.start', 'process.status', 'process.output', 'process.kill', 'process.list', 'screen.capture', 'camera.capture', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate', 'agent.delegate', 'agents.list', 'council.run', ]), full: new Set(), // Special: matches everything }; // ── Tool groups ───────────────────────────────────────────────────── /** Named groups for use in allow/deny lists (e.g. 'group:fs'). */ export const TOOL_GROUPS: Record = { 'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'], 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'], 'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'], 'group:gdocs': ['docs.list', 'docs.search', 'docs.read'], 'group:gdrive': ['drive.list', 'drive.search', 'drive.read'], 'group:gtasks': ['tasks.lists', 'tasks.list'], 'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'], 'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'], 'group:k8s': ['k8s.pods', 'k8s.deployments', 'k8s.logs'], 'group:agents': ['agent.delegate', 'agents.list', 'council.run'], }; /** Expand group references in a list of tool names/patterns. */ function expandGroups(names: string[]): string[] { return names.flatMap(n => TOOL_GROUPS[n] ?? [n]); } // ── 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 type SensitiveMode = 'deny_without_elevation' | 'confirm_without_elevation'; export interface ImmutableDenyRule { /** Tool name glob pattern (e.g. shell.exec, process.*). */ tool: string; /** Optional case-insensitive substring matched against serialized args. */ argsPattern?: string; /** Optional human-readable denial reason. */ reason?: string; } export interface ToolPolicyContext { /** Model tier name (e.g. 'fast', 'default', 'complex', 'local'). */ agent?: string; /** Provider name (e.g. 'ollama', 'anthropic'). */ provider?: string; /** Session ID for audit logging. */ sessionId?: string; /** Channel name for audit logging. */ channel?: string; /** Sender ID for audit logging. */ sender?: string; /** Model tier for audit logging. */ tier?: string; /** Autonomy level for tool execution (affects confirmation requirements). */ autonomyLevel?: AutonomyLevel; /** Optional active skill name (for capability scoping and audit). */ skillName?: string; /** Optional active skill permissions (capability declarations). */ skillPermissions?: SkillPermissions; /** Execution environment for high-risk operations. */ executionEnvironment?: 'host' | 'sandbox'; /** Secret scopes allowed for this context (used by executor). */ allowedSecretScopes?: string[]; /** True when untrusted content has been introduced in this run. */ untrustedContent?: boolean; /** Elevated mode (break-glass): allow host execution for high-risk tools until this epoch millis. */ elevatedHostUntilMs?: number; /** User-supplied reason for elevation (audited separately). */ elevatedHostReason?: string; /** Correlation id for elevation window. */ elevatedHostId?: string; /** Sensitive operation mode for host-executed sensitive tools. */ sensitiveMode?: SensitiveMode; /** Immutable denylist enforced before hooks/autonomy checks. */ immutableDenylist?: ImmutableDenyRule[]; } function resolveSkillAllowedNames(allToolNames: string[], permissions?: SkillPermissions): Set | null { if (!permissions) { return null; } const explicitTools = Array.isArray(permissions.tools) ? permissions.tools : undefined; const toolGroups = Array.isArray(permissions.tool_groups) ? permissions.tool_groups : undefined; const patterns = (explicitTools && explicitTools.length > 0) ? expandGroups(explicitTools) : (toolGroups && toolGroups.length > 0) ? expandGroups(toolGroups) : []; if (patterns.length === 0) { return null; } return new Set(allToolNames.filter((name) => matchesAnyPattern(name, patterns))); } // ── 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) — expand groups first const globalAllow = expandGroups(this.config.allow); if (globalAllow.length > 0) { for (const name of allToolNames) { if (matchesAnyPattern(name, globalAllow)) { allowed.add(name); } } } // Step 3: Apply global deny (removes tools) — expand groups first const globalDeny = expandGroups(this.config.deny); if (globalDeny.length > 0) { allowed = new Set( [...allowed].filter(name => !matchesAnyPattern(name, globalDeny)), ); } // 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); } // Step 6: Apply skill capability restrictions (intersection) // Safe-by-default: if a skill is active but has no permissions manifest, deny all tools. if (context?.skillName && !context.skillPermissions) { allowed = new Set(); } else { const skillAllowed = resolveSkillAllowedNames(allToolNames, context?.skillPermissions); if (skillAllowed) { allowed = intersect(allowed, skillAllowed); } } 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 const agentProfile = context?.agent ? this.config.agents[context.agent]?.profile : undefined; if (agentProfile) { return agentProfile; } const providerProfile = context?.provider ? this.config.providers[context.provider]?.profile : undefined; if (providerProfile) { return providerProfile; } 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 — expand groups first const overrideAllow = expandGroups(override.allow); if (overrideAllow.length > 0) { for (const name of allToolNames) { if (matchesAnyPattern(name, overrideAllow)) { allowed.add(name); } } } // Apply override deny (deny always wins) — expand groups first const overrideDeny = expandGroups(override.deny); if (overrideDeny.length > 0) { allowed = new Set( [...allowed].filter(name => !matchesAnyPattern(name, overrideDeny)), ); } 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, expandGroups };