diff --git a/README.md b/README.md index 341cb30..52369d4 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,54 @@ npx tsx examples/01-single-agent.ts | `file_edit` | Edit a file by replacing an exact string match. | | `grep` | Search file contents with regex. Uses ripgrep when available, falls back to Node.js. | +## Tool Configuration + +Agents can be configured with fine-grained tool access control using presets, allowlists, and denylists. + +### Tool Presets + +Predefined tool sets for common use cases: + +```typescript +const readonlyAgent: AgentConfig = { + name: 'reader', + model: 'claude-sonnet-4-6', + toolPreset: 'readonly', // file_read, grep, glob +} + +const readwriteAgent: AgentConfig = { + name: 'editor', + model: 'claude-sonnet-4-6', + toolPreset: 'readwrite', // file_read, file_write, file_edit, grep, glob +} + +const fullAgent: AgentConfig = { + name: 'executor', + model: 'claude-sonnet-4-6', + toolPreset: 'full', // file_read, file_write, file_edit, grep, glob, bash +} +``` + +### Advanced Filtering + +Combine presets with allowlists and denylists for precise control: + +```typescript +const customAgent: AgentConfig = { + name: 'custom', + model: 'claude-sonnet-4-6', + toolPreset: 'readwrite', // Start with: file_read, file_write, file_edit, grep, glob + tools: ['file_read', 'grep'], // Allowlist: intersect with preset = file_read, grep + disallowedTools: ['grep'], // Denylist: subtract = file_read only +} +``` + +**Resolution order:** preset → allowlist → denylist → framework safety rails. + +### Custom Tools + +Tools added via `agent.addTool()` are always available regardless of filtering. + ## Supported Providers | Provider | Config | Env var | Status | diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 8b02641..8c1007c 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -146,7 +146,9 @@ export class Agent { maxTurns: this.config.maxTurns, maxTokens: this.config.maxTokens, temperature: this.config.temperature, + toolPreset: this.config.toolPreset, allowedTools: this.config.tools, + disallowedTools: this.config.disallowedTools, agentName: this.name, agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant', loopDetection: this.config.loopDetection, @@ -261,7 +263,7 @@ export class Agent { * The tool becomes available to the next LLM call — no restart required. */ addTool(tool: FrameworkToolDefinition): void { - this._toolRegistry.register(tool) + this._toolRegistry.register(tool, { runtimeAdded: true }) } /** diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 4ca0975..81155e8 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -28,6 +28,7 @@ import type { TraceEvent, LoopDetectionConfig, LoopDetectionInfo, + LLMToolDef, } from '../types.js' import { TokenBudgetExceededError } from '../errors.js' import { LoopDetector } from './loop-detector.js' @@ -35,6 +36,22 @@ import { emitTrace } from '../utils/trace.js' import type { ToolRegistry } from '../tool/framework.js' import type { ToolExecutor } from '../tool/executor.js' +// --------------------------------------------------------------------------- +// Tool presets +// --------------------------------------------------------------------------- + +/** Predefined tool sets for common agent use cases. */ +export const TOOL_PRESETS = { + readonly: ['file_read', 'grep', 'glob'], + readwrite: ['file_read', 'file_write', 'file_edit', 'grep', 'glob'], + full: ['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'], +} as const satisfies Record + +/** Framework-level disallowed tools for safety rails. */ +export const AGENT_FRAMEWORK_DISALLOWED: readonly string[] = [ + // Empty for now, infrastructure for future built-in tools +] + // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- @@ -60,11 +77,15 @@ export interface RunnerOptions { /** AbortSignal that cancels any in-flight adapter call and stops the loop. */ readonly abortSignal?: AbortSignal /** - * Whitelist of tool names this runner is allowed to use. - * When provided, only tools whose name appears in this list are sent to the - * LLM. When omitted, all registered tools are available. + * Tool access control configuration. + * - `toolPreset`: Predefined tool sets for common use cases + * - `allowedTools`: Whitelist of tool names (allowlist) + * - `disallowedTools`: Blacklist of tool names (denylist) + * Tools are resolved in order: preset → allowlist → denylist */ + readonly toolPreset?: 'readonly' | 'readwrite' | 'full' readonly allowedTools?: readonly string[] + readonly disallowedTools?: readonly string[] /** Display name of the agent driving this runner (used in tool context). */ readonly agentName?: string /** Short role description of the agent (used in tool context). */ @@ -180,6 +201,67 @@ export class AgentRunner { this.maxTurns = options.maxTurns ?? 10 } + // ------------------------------------------------------------------------- + // Tool resolution + // ------------------------------------------------------------------------- + + /** + * Resolve the final set of tools available to this agent based on the + * three-layer configuration: preset → allowlist → denylist → framework safety. + * + * Returns LLMToolDef[] for direct use with LLM adapters. + */ + private resolveTools(): LLMToolDef[] { + // Validate configuration for contradictions + if (this.options.toolPreset && this.options.allowedTools) { + console.warn( + 'AgentRunner: both toolPreset and allowedTools are set. ' + + 'Final tool access will be the intersection of both.' + ) + } + + if (this.options.allowedTools && this.options.disallowedTools) { + const overlap = this.options.allowedTools.filter(tool => + this.options.disallowedTools!.includes(tool) + ) + if (overlap.length > 0) { + console.warn( + `AgentRunner: tools [${overlap.map(name => `"${name}"`).join(', ')}] appear in both allowedTools and disallowedTools. ` + + 'This is contradictory and may lead to unexpected behavior.' + ) + } + } + + const allTools = this.toolRegistry.toToolDefs() + const runtimeCustomTools = this.toolRegistry.toRuntimeToolDefs() + const runtimeCustomToolNames = new Set(runtimeCustomTools.map(t => t.name)) + let filteredTools = allTools.filter(t => !runtimeCustomToolNames.has(t.name)) + + // 1. Apply preset filter if set + if (this.options.toolPreset) { + const presetTools = new Set(TOOL_PRESETS[this.options.toolPreset] as readonly string[]) + filteredTools = filteredTools.filter(t => presetTools.has(t.name)) + } + + // 2. Apply allowlist filter if set + if (this.options.allowedTools) { + filteredTools = filteredTools.filter(t => this.options.allowedTools!.includes(t.name)) + } + + // 3. Apply denylist filter if set + if (this.options.disallowedTools) { + const denied = new Set(this.options.disallowedTools) + filteredTools = filteredTools.filter(t => !denied.has(t.name)) + } + + // 4. Apply framework-level safety rails + const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED) + filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name)) + + // Runtime-added custom tools stay available regardless of filtering rules. + return [...filteredTools, ...runtimeCustomTools] + } + // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- @@ -241,12 +323,8 @@ export class AgentRunner { let budgetExceeded = false // Build the stable LLM options once; model / tokens / temp don't change. - // toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches - // LLMChatOptions.tools from types.ts directly. - const allDefs = this.toolRegistry.toToolDefs() - const toolDefs = this.options.allowedTools - ? allDefs.filter(d => this.options.allowedTools!.includes(d.name)) - : allDefs + // resolveTools() returns LLMToolDef[] with three-layer filtering applied. + const toolDefs = this.resolveTools() // Per-call abortSignal takes precedence over the static one. const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal diff --git a/src/tool/framework.ts b/src/tool/framework.ts index 6b6a574..3b25c97 100644 --- a/src/tool/framework.ts +++ b/src/tool/framework.ts @@ -93,13 +93,17 @@ export function defineTool(config: { export class ToolRegistry { // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly tools = new Map>() + private readonly runtimeToolNames = new Set() /** * Add a tool to the registry. Throws if a tool with the same name has * already been registered — prevents silent overwrites. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - register(tool: ToolDefinition): void { + register( + tool: ToolDefinition, + options?: { runtimeAdded?: boolean }, + ): void { if (this.tools.has(tool.name)) { throw new Error( `ToolRegistry: a tool named "${tool.name}" is already registered. ` + @@ -107,6 +111,9 @@ export class ToolRegistry { ) } this.tools.set(tool.name, tool) + if (options?.runtimeAdded === true) { + this.runtimeToolNames.add(tool.name) + } } /** Return a tool by name, or `undefined` if not found. */ @@ -147,11 +154,12 @@ export class ToolRegistry { */ unregister(name: string): void { this.tools.delete(name) + this.runtimeToolNames.delete(name) } /** Alias for {@link unregister} — available for symmetry with `register`. */ deregister(name: string): void { - this.tools.delete(name) + this.unregister(name) } /** @@ -170,6 +178,14 @@ export class ToolRegistry { }) } + /** + * Return only tools that were added dynamically at runtime (e.g. via + * `agent.addTool()`), in LLM definition format. + */ + toRuntimeToolDefs(): LLMToolDef[] { + return this.toToolDefs().filter(tool => this.runtimeToolNames.has(tool.name)) + } + /** * Convert all registered tools to the Anthropic-style `input_schema` * format. Prefer {@link toToolDefs} for normal use; this method is exposed diff --git a/src/types.ts b/src/types.ts index e43bfbd..24f0e5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,6 +207,10 @@ export interface AgentConfig { readonly systemPrompt?: string /** Names of tools (from the tool registry) available to this agent. */ readonly tools?: readonly string[] + /** Names of tools explicitly disallowed for this agent. */ + readonly disallowedTools?: readonly string[] + /** Predefined tool preset for common use cases. */ + readonly toolPreset?: 'readonly' | 'readwrite' | 'full' readonly maxTurns?: number readonly maxTokens?: number /** Maximum cumulative tokens (input + output) allowed for this run. */ diff --git a/tests/tool-filtering.test.ts b/tests/tool-filtering.test.ts new file mode 100644 index 0000000..4f3b6f0 --- /dev/null +++ b/tests/tool-filtering.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { AgentRunner, TOOL_PRESETS } from '../src/agent/runner.js' +import { ToolRegistry, defineTool } from '../src/tool/framework.js' +import { ToolExecutor } from '../src/tool/executor.js' +import { z } from 'zod' +import type { LLMAdapter, LLMResponse, LLMToolDef } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Mock adapter +// --------------------------------------------------------------------------- + +const mockAdapter: LLMAdapter = { + name: 'mock', + async chat() { + return { + id: 'mock-1', + content: [{ type: 'text', text: 'response' }], + model: 'mock-model', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + } satisfies LLMResponse + }, + async *stream() { + // Not used in these tests + }, +} + +// --------------------------------------------------------------------------- +// Test tools +// --------------------------------------------------------------------------- + +function createTestTools() { + const registry = new ToolRegistry() + + // Register test tools that match our presets + registry.register(defineTool({ + name: 'file_read', + description: 'Read file', + inputSchema: z.object({ path: z.string() }), + execute: async () => ({ data: 'content', isError: false }), + })) + + registry.register(defineTool({ + name: 'file_write', + description: 'Write file', + inputSchema: z.object({ path: z.string(), content: z.string() }), + execute: async () => ({ data: 'ok', isError: false }), + })) + + registry.register(defineTool({ + name: 'file_edit', + description: 'Edit file', + inputSchema: z.object({ path: z.string(), oldString: z.string(), newString: z.string() }), + execute: async () => ({ data: 'ok', isError: false }), + })) + + registry.register(defineTool({ + name: 'grep', + description: 'Search text', + inputSchema: z.object({ pattern: z.string(), path: z.string() }), + execute: async () => ({ data: 'matches', isError: false }), + })) + + registry.register(defineTool({ + name: 'bash', + description: 'Run shell command', + inputSchema: z.object({ command: z.string() }), + execute: async () => ({ data: 'output', isError: false }), + })) + + // Extra tool not in any preset + registry.register(defineTool({ + name: 'custom_tool', + description: 'Custom tool', + inputSchema: z.object({ input: z.string() }), + execute: async () => ({ data: 'custom', isError: false }), + }), { runtimeAdded: true }) + + return registry +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Tool filtering', () => { + const registry = createTestTools() + const executor = new ToolExecutor(registry) + + describe('TOOL_PRESETS', () => { + it('readonly preset has correct tools', () => { + expect(TOOL_PRESETS.readonly).toEqual(['file_read', 'grep', 'glob']) + }) + + it('readwrite preset has correct tools', () => { + expect(TOOL_PRESETS.readwrite).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob']) + }) + + it('full preset has correct tools', () => { + expect(TOOL_PRESETS.full).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash']) + }) + }) + + describe('resolveTools - no filtering', () => { + it('returns all tools when no filters are set', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['bash', 'custom_tool', 'file_edit', 'file_read', 'file_write', 'grep']) + }) + }) + + describe('resolveTools - preset filtering', () => { + it('readonly preset filters correctly', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readonly', + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['custom_tool', 'file_read', 'grep']) + }) + + it('readwrite preset filters correctly', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readwrite', + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['custom_tool', 'file_edit', 'file_read', 'file_write', 'grep']) + }) + + it('full preset filters correctly', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'full', + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['bash', 'custom_tool', 'file_edit', 'file_read', 'file_write', 'grep']) + }) + }) + + describe('resolveTools - allowlist filtering', () => { + it('allowlist filters correctly', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + allowedTools: ['file_read', 'bash'], + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['bash', 'custom_tool', 'file_read']) + }) + + it('empty allowlist returns no tools', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + allowedTools: [], + }) + + const tools = (runner as any).resolveTools() + expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool']) + }) + }) + + describe('resolveTools - denylist filtering', () => { + it('denylist filters correctly', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + disallowedTools: ['bash', 'custom_tool'], + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['custom_tool', 'file_edit', 'file_read', 'file_write', 'grep']) + }) + + it('empty denylist returns all tools', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + disallowedTools: [], + }) + + const tools = (runner as any).resolveTools() + expect(tools).toHaveLength(6) // All registered tools + }) + }) + + describe('resolveTools - combined filtering (preset + allowlist + denylist)', () => { + it('preset + allowlist + denylist work together', () => { + // Start with readwrite preset: ['file_read', 'file_write', 'file_edit', 'grep'] + // Then allowlist: intersect with ['file_read', 'file_write', 'grep'] = ['file_read', 'file_write', 'grep'] + // Then denylist: subtract ['file_write'] = ['file_read', 'grep'] + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readwrite', + allowedTools: ['file_read', 'file_write', 'grep'], + disallowedTools: ['file_write'], + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['custom_tool', 'file_read', 'grep']) + }) + + it('preset filters first, then allowlist intersects, then denylist subtracts', () => { + // Start with readonly preset: ['file_read', 'grep'] + // Allowlist intersect with ['file_read', 'bash']: ['file_read'] + // Denylist subtract ['file_read']: [] + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readonly', + allowedTools: ['file_read', 'bash'], + disallowedTools: ['file_read'], + }) + + const tools = (runner as any).resolveTools() + expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool']) + }) + }) + + describe('resolveTools - custom tool behavior', () => { + it('always includes custom tools regardless of filtering', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readonly', + allowedTools: ['file_read'], + disallowedTools: ['file_read', 'bash', 'grep'], + }) + + const tools = (runner as any).resolveTools() as LLMToolDef[] + const toolNames = tools.map((t: LLMToolDef) => t.name).sort() + + expect(toolNames).toEqual(['custom_tool']) + }) + + it('runtime-added tools bypass filtering regardless of tool name', () => { + const runtimeBuiltinNamedRegistry = new ToolRegistry() + runtimeBuiltinNamedRegistry.register(defineTool({ + name: 'file_read', + description: 'Runtime override', + inputSchema: z.object({ path: z.string() }), + execute: async () => ({ data: 'runtime', isError: false }), + }), { runtimeAdded: true }) + + const runtimeBuiltinNamedRunner = new AgentRunner( + mockAdapter, + runtimeBuiltinNamedRegistry, + new ToolExecutor(runtimeBuiltinNamedRegistry), + { + model: 'test-model', + disallowedTools: ['file_read'], + }, + ) + + const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[] + expect(tools.map(t => t.name)).toEqual(['file_read']) + }) + }) + + describe('resolveTools - validation warnings', () => { + let consoleWarnSpy: any + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + it('warns when tool appears in both allowedTools and disallowedTools', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + allowedTools: ['file_read', 'bash'], + disallowedTools: ['bash', 'grep'], + }) + + ;(runner as any).resolveTools() + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('tools ["bash"] appear in both allowedTools and disallowedTools') + ) + }) + + it('warns when both toolPreset and allowedTools are set', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + toolPreset: 'readonly', + allowedTools: ['file_read', 'bash'], + }) + + ;(runner as any).resolveTools() + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('both toolPreset and allowedTools are set') + ) + }) + + it('does not warn when no overlap between allowedTools and disallowedTools', () => { + const runner = new AgentRunner(mockAdapter, registry, executor, { + model: 'test-model', + allowedTools: ['file_read'], + disallowedTools: ['bash'], + }) + + ;(runner as any).resolveTools() + + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + }) +}) + diff --git a/tests/trace.test.ts b/tests/trace.test.ts index 00e8330..a7a5257 100644 --- a/tests/trace.test.ts +++ b/tests/trace.test.ts @@ -451,3 +451,4 @@ describe('Agent trace events', () => { expect(llmTraces[1]!.turn).toBe(2) }) }) +