Files
flynn/src/tools/policy.ts
T
William Valentin 1c2f54fae3 feat: implement tier 1 quick wins (tool groups, typing, pruning, verbose, think)
Five additive features with no breaking changes:

- Tool groups: group:fs, group:runtime, group:web, group:memory syntactic
  sugar for allow/deny lists in tool policy config
- Typing indicators: Discord sendTyping() and WhatsApp sendStateTyping()
  on message receipt for better UX feedback
- Session pruning: TTL-based auto-cleanup via sessions.ttl config with
  hourly daemon timer and SQLite GROUP BY pruning
- /verbose command: TUI command parser toggle for raw streaming display
- !!think prefix: per-message extended thinking mode wired through
  Anthropic (budget_tokens), OpenAI/GitHub (reasoning_effort), and
  Gemini (thinkingConfig) providers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:35:00 -08:00

255 lines
8.8 KiB
TypeScript

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<ToolProfile, Set<string>> = {
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',
'browser.navigate',
'browser.screenshot',
'browser.click',
'browser.type',
'browser.content',
'browser.eval',
]),
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<string, string[]> = {
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.list'],
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
};
/** 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 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<string> {
// 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);
}
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<string> {
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<string> {
// 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<string>, b: Set<string>): Set<string> {
const result = new Set<string>();
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 };