feat: add tool allowlist, denylist, preset list (#83)
* feat: add allowlist denylist and preset list for tools * feat: update readme and add AGENT_FRAMEWORK_DISALLOWED * fix: update filtering logic to allow custom tools * fix: enhance tool registration and filtering for runtime-added tools --------- Co-authored-by: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com>
This commit is contained in:
parent
48fbec6659
commit
97c39b316c
48
README.md
48
README.md
|
|
@ -187,6 +187,54 @@ npx tsx examples/01-single-agent.ts
|
||||||
| `file_edit` | Edit a file by replacing an exact string match. |
|
| `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. |
|
| `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
|
## Supported Providers
|
||||||
|
|
||||||
| Provider | Config | Env var | Status |
|
| Provider | Config | Env var | Status |
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,9 @@ export class Agent {
|
||||||
maxTurns: this.config.maxTurns,
|
maxTurns: this.config.maxTurns,
|
||||||
maxTokens: this.config.maxTokens,
|
maxTokens: this.config.maxTokens,
|
||||||
temperature: this.config.temperature,
|
temperature: this.config.temperature,
|
||||||
|
toolPreset: this.config.toolPreset,
|
||||||
allowedTools: this.config.tools,
|
allowedTools: this.config.tools,
|
||||||
|
disallowedTools: this.config.disallowedTools,
|
||||||
agentName: this.name,
|
agentName: this.name,
|
||||||
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
|
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
|
||||||
loopDetection: this.config.loopDetection,
|
loopDetection: this.config.loopDetection,
|
||||||
|
|
@ -261,7 +263,7 @@ export class Agent {
|
||||||
* The tool becomes available to the next LLM call — no restart required.
|
* The tool becomes available to the next LLM call — no restart required.
|
||||||
*/
|
*/
|
||||||
addTool(tool: FrameworkToolDefinition): void {
|
addTool(tool: FrameworkToolDefinition): void {
|
||||||
this._toolRegistry.register(tool)
|
this._toolRegistry.register(tool, { runtimeAdded: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
TraceEvent,
|
TraceEvent,
|
||||||
LoopDetectionConfig,
|
LoopDetectionConfig,
|
||||||
LoopDetectionInfo,
|
LoopDetectionInfo,
|
||||||
|
LLMToolDef,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
import { TokenBudgetExceededError } from '../errors.js'
|
import { TokenBudgetExceededError } from '../errors.js'
|
||||||
import { LoopDetector } from './loop-detector.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 { ToolRegistry } from '../tool/framework.js'
|
||||||
import type { ToolExecutor } from '../tool/executor.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<string, readonly string[]>
|
||||||
|
|
||||||
|
/** 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
|
// Public interfaces
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,11 +77,15 @@ export interface RunnerOptions {
|
||||||
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
|
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
|
||||||
readonly abortSignal?: AbortSignal
|
readonly abortSignal?: AbortSignal
|
||||||
/**
|
/**
|
||||||
* Whitelist of tool names this runner is allowed to use.
|
* Tool access control configuration.
|
||||||
* When provided, only tools whose name appears in this list are sent to the
|
* - `toolPreset`: Predefined tool sets for common use cases
|
||||||
* LLM. When omitted, all registered tools are available.
|
* - `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 allowedTools?: readonly string[]
|
||||||
|
readonly disallowedTools?: readonly string[]
|
||||||
/** Display name of the agent driving this runner (used in tool context). */
|
/** Display name of the agent driving this runner (used in tool context). */
|
||||||
readonly agentName?: string
|
readonly agentName?: string
|
||||||
/** Short role description of the agent (used in tool context). */
|
/** Short role description of the agent (used in tool context). */
|
||||||
|
|
@ -180,6 +201,67 @@ export class AgentRunner {
|
||||||
this.maxTurns = options.maxTurns ?? 10
|
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
|
// Public API
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -241,12 +323,8 @@ export class AgentRunner {
|
||||||
let budgetExceeded = false
|
let budgetExceeded = false
|
||||||
|
|
||||||
// Build the stable LLM options once; model / tokens / temp don't change.
|
// Build the stable LLM options once; model / tokens / temp don't change.
|
||||||
// toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches
|
// resolveTools() returns LLMToolDef[] with three-layer filtering applied.
|
||||||
// LLMChatOptions.tools from types.ts directly.
|
const toolDefs = this.resolveTools()
|
||||||
const allDefs = this.toolRegistry.toToolDefs()
|
|
||||||
const toolDefs = this.options.allowedTools
|
|
||||||
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
|
|
||||||
: allDefs
|
|
||||||
|
|
||||||
// Per-call abortSignal takes precedence over the static one.
|
// Per-call abortSignal takes precedence over the static one.
|
||||||
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
|
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
|
||||||
|
|
|
||||||
|
|
@ -93,13 +93,17 @@ export function defineTool<TInput>(config: {
|
||||||
export class ToolRegistry {
|
export class ToolRegistry {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private readonly tools = new Map<string, ToolDefinition<any>>()
|
private readonly tools = new Map<string, ToolDefinition<any>>()
|
||||||
|
private readonly runtimeToolNames = new Set<string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a tool to the registry. Throws if a tool with the same name has
|
* Add a tool to the registry. Throws if a tool with the same name has
|
||||||
* already been registered — prevents silent overwrites.
|
* already been registered — prevents silent overwrites.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
register(tool: ToolDefinition<any>): void {
|
register(
|
||||||
|
tool: ToolDefinition<any>,
|
||||||
|
options?: { runtimeAdded?: boolean },
|
||||||
|
): void {
|
||||||
if (this.tools.has(tool.name)) {
|
if (this.tools.has(tool.name)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ToolRegistry: a tool named "${tool.name}" is already registered. ` +
|
`ToolRegistry: a tool named "${tool.name}" is already registered. ` +
|
||||||
|
|
@ -107,6 +111,9 @@ export class ToolRegistry {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.tools.set(tool.name, tool)
|
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. */
|
/** Return a tool by name, or `undefined` if not found. */
|
||||||
|
|
@ -147,11 +154,12 @@ export class ToolRegistry {
|
||||||
*/
|
*/
|
||||||
unregister(name: string): void {
|
unregister(name: string): void {
|
||||||
this.tools.delete(name)
|
this.tools.delete(name)
|
||||||
|
this.runtimeToolNames.delete(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alias for {@link unregister} — available for symmetry with `register`. */
|
/** Alias for {@link unregister} — available for symmetry with `register`. */
|
||||||
deregister(name: string): void {
|
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`
|
* Convert all registered tools to the Anthropic-style `input_schema`
|
||||||
* format. Prefer {@link toToolDefs} for normal use; this method is exposed
|
* format. Prefer {@link toToolDefs} for normal use; this method is exposed
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,10 @@ export interface AgentConfig {
|
||||||
readonly systemPrompt?: string
|
readonly systemPrompt?: string
|
||||||
/** Names of tools (from the tool registry) available to this agent. */
|
/** Names of tools (from the tool registry) available to this agent. */
|
||||||
readonly tools?: readonly string[]
|
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 maxTurns?: number
|
||||||
readonly maxTokens?: number
|
readonly maxTokens?: number
|
||||||
/** Maximum cumulative tokens (input + output) allowed for this run. */
|
/** Maximum cumulative tokens (input + output) allowed for this run. */
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -451,3 +451,4 @@ describe('Agent trace events', () => {
|
||||||
expect(llmTraces[1]!.turn).toBe(2)
|
expect(llmTraces[1]!.turn).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue