Skip to content

Commit 97c39b3

Browse files
authored
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>
1 parent 48fbec6 commit 97c39b3

7 files changed

Lines changed: 489 additions & 12 deletions

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,54 @@ npx tsx examples/01-single-agent.ts
187187
| `file_edit` | Edit a file by replacing an exact string match. |
188188
| `grep` | Search file contents with regex. Uses ripgrep when available, falls back to Node.js. |
189189

190+
## Tool Configuration
191+
192+
Agents can be configured with fine-grained tool access control using presets, allowlists, and denylists.
193+
194+
### Tool Presets
195+
196+
Predefined tool sets for common use cases:
197+
198+
```typescript
199+
const readonlyAgent: AgentConfig = {
200+
name: 'reader',
201+
model: 'claude-sonnet-4-6',
202+
toolPreset: 'readonly', // file_read, grep, glob
203+
}
204+
205+
const readwriteAgent: AgentConfig = {
206+
name: 'editor',
207+
model: 'claude-sonnet-4-6',
208+
toolPreset: 'readwrite', // file_read, file_write, file_edit, grep, glob
209+
}
210+
211+
const fullAgent: AgentConfig = {
212+
name: 'executor',
213+
model: 'claude-sonnet-4-6',
214+
toolPreset: 'full', // file_read, file_write, file_edit, grep, glob, bash
215+
}
216+
```
217+
218+
### Advanced Filtering
219+
220+
Combine presets with allowlists and denylists for precise control:
221+
222+
```typescript
223+
const customAgent: AgentConfig = {
224+
name: 'custom',
225+
model: 'claude-sonnet-4-6',
226+
toolPreset: 'readwrite', // Start with: file_read, file_write, file_edit, grep, glob
227+
tools: ['file_read', 'grep'], // Allowlist: intersect with preset = file_read, grep
228+
disallowedTools: ['grep'], // Denylist: subtract = file_read only
229+
}
230+
```
231+
232+
**Resolution order:** preset → allowlist → denylist → framework safety rails.
233+
234+
### Custom Tools
235+
236+
Tools added via `agent.addTool()` are always available regardless of filtering.
237+
190238
## Supported Providers
191239

192240
| Provider | Config | Env var | Status |

src/agent/agent.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ export class Agent {
146146
maxTurns: this.config.maxTurns,
147147
maxTokens: this.config.maxTokens,
148148
temperature: this.config.temperature,
149+
toolPreset: this.config.toolPreset,
149150
allowedTools: this.config.tools,
151+
disallowedTools: this.config.disallowedTools,
150152
agentName: this.name,
151153
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
152154
loopDetection: this.config.loopDetection,
@@ -261,7 +263,7 @@ export class Agent {
261263
* The tool becomes available to the next LLM call — no restart required.
262264
*/
263265
addTool(tool: FrameworkToolDefinition): void {
264-
this._toolRegistry.register(tool)
266+
this._toolRegistry.register(tool, { runtimeAdded: true })
265267
}
266268

267269
/**

src/agent/runner.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,30 @@ import type {
2828
TraceEvent,
2929
LoopDetectionConfig,
3030
LoopDetectionInfo,
31+
LLMToolDef,
3132
} from '../types.js'
3233
import { TokenBudgetExceededError } from '../errors.js'
3334
import { LoopDetector } from './loop-detector.js'
3435
import { emitTrace } from '../utils/trace.js'
3536
import type { ToolRegistry } from '../tool/framework.js'
3637
import type { ToolExecutor } from '../tool/executor.js'
3738

39+
// ---------------------------------------------------------------------------
40+
// Tool presets
41+
// ---------------------------------------------------------------------------
42+
43+
/** Predefined tool sets for common agent use cases. */
44+
export const TOOL_PRESETS = {
45+
readonly: ['file_read', 'grep', 'glob'],
46+
readwrite: ['file_read', 'file_write', 'file_edit', 'grep', 'glob'],
47+
full: ['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'],
48+
} as const satisfies Record<string, readonly string[]>
49+
50+
/** Framework-level disallowed tools for safety rails. */
51+
export const AGENT_FRAMEWORK_DISALLOWED: readonly string[] = [
52+
// Empty for now, infrastructure for future built-in tools
53+
]
54+
3855
// ---------------------------------------------------------------------------
3956
// Public interfaces
4057
// ---------------------------------------------------------------------------
@@ -60,11 +77,15 @@ export interface RunnerOptions {
6077
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
6178
readonly abortSignal?: AbortSignal
6279
/**
63-
* Whitelist of tool names this runner is allowed to use.
64-
* When provided, only tools whose name appears in this list are sent to the
65-
* LLM. When omitted, all registered tools are available.
80+
* Tool access control configuration.
81+
* - `toolPreset`: Predefined tool sets for common use cases
82+
* - `allowedTools`: Whitelist of tool names (allowlist)
83+
* - `disallowedTools`: Blacklist of tool names (denylist)
84+
* Tools are resolved in order: preset → allowlist → denylist
6685
*/
86+
readonly toolPreset?: 'readonly' | 'readwrite' | 'full'
6787
readonly allowedTools?: readonly string[]
88+
readonly disallowedTools?: readonly string[]
6889
/** Display name of the agent driving this runner (used in tool context). */
6990
readonly agentName?: string
7091
/** Short role description of the agent (used in tool context). */
@@ -180,6 +201,67 @@ export class AgentRunner {
180201
this.maxTurns = options.maxTurns ?? 10
181202
}
182203

204+
// -------------------------------------------------------------------------
205+
// Tool resolution
206+
// -------------------------------------------------------------------------
207+
208+
/**
209+
* Resolve the final set of tools available to this agent based on the
210+
* three-layer configuration: preset → allowlist → denylist → framework safety.
211+
*
212+
* Returns LLMToolDef[] for direct use with LLM adapters.
213+
*/
214+
private resolveTools(): LLMToolDef[] {
215+
// Validate configuration for contradictions
216+
if (this.options.toolPreset && this.options.allowedTools) {
217+
console.warn(
218+
'AgentRunner: both toolPreset and allowedTools are set. ' +
219+
'Final tool access will be the intersection of both.'
220+
)
221+
}
222+
223+
if (this.options.allowedTools && this.options.disallowedTools) {
224+
const overlap = this.options.allowedTools.filter(tool =>
225+
this.options.disallowedTools!.includes(tool)
226+
)
227+
if (overlap.length > 0) {
228+
console.warn(
229+
`AgentRunner: tools [${overlap.map(name => `"${name}"`).join(', ')}] appear in both allowedTools and disallowedTools. ` +
230+
'This is contradictory and may lead to unexpected behavior.'
231+
)
232+
}
233+
}
234+
235+
const allTools = this.toolRegistry.toToolDefs()
236+
const runtimeCustomTools = this.toolRegistry.toRuntimeToolDefs()
237+
const runtimeCustomToolNames = new Set(runtimeCustomTools.map(t => t.name))
238+
let filteredTools = allTools.filter(t => !runtimeCustomToolNames.has(t.name))
239+
240+
// 1. Apply preset filter if set
241+
if (this.options.toolPreset) {
242+
const presetTools = new Set(TOOL_PRESETS[this.options.toolPreset] as readonly string[])
243+
filteredTools = filteredTools.filter(t => presetTools.has(t.name))
244+
}
245+
246+
// 2. Apply allowlist filter if set
247+
if (this.options.allowedTools) {
248+
filteredTools = filteredTools.filter(t => this.options.allowedTools!.includes(t.name))
249+
}
250+
251+
// 3. Apply denylist filter if set
252+
if (this.options.disallowedTools) {
253+
const denied = new Set(this.options.disallowedTools)
254+
filteredTools = filteredTools.filter(t => !denied.has(t.name))
255+
}
256+
257+
// 4. Apply framework-level safety rails
258+
const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED)
259+
filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name))
260+
261+
// Runtime-added custom tools stay available regardless of filtering rules.
262+
return [...filteredTools, ...runtimeCustomTools]
263+
}
264+
183265
// -------------------------------------------------------------------------
184266
// Public API
185267
// -------------------------------------------------------------------------
@@ -241,12 +323,8 @@ export class AgentRunner {
241323
let budgetExceeded = false
242324

243325
// Build the stable LLM options once; model / tokens / temp don't change.
244-
// toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches
245-
// LLMChatOptions.tools from types.ts directly.
246-
const allDefs = this.toolRegistry.toToolDefs()
247-
const toolDefs = this.options.allowedTools
248-
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
249-
: allDefs
326+
// resolveTools() returns LLMToolDef[] with three-layer filtering applied.
327+
const toolDefs = this.resolveTools()
250328

251329
// Per-call abortSignal takes precedence over the static one.
252330
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal

src/tool/framework.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,27 @@ export function defineTool<TInput>(config: {
9393
export class ToolRegistry {
9494
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9595
private readonly tools = new Map<string, ToolDefinition<any>>()
96+
private readonly runtimeToolNames = new Set<string>()
9697

9798
/**
9899
* Add a tool to the registry. Throws if a tool with the same name has
99100
* already been registered — prevents silent overwrites.
100101
*/
101102
// eslint-disable-next-line @typescript-eslint/no-explicit-any
102-
register(tool: ToolDefinition<any>): void {
103+
register(
104+
tool: ToolDefinition<any>,
105+
options?: { runtimeAdded?: boolean },
106+
): void {
103107
if (this.tools.has(tool.name)) {
104108
throw new Error(
105109
`ToolRegistry: a tool named "${tool.name}" is already registered. ` +
106110
'Use a unique name or deregister the existing one first.',
107111
)
108112
}
109113
this.tools.set(tool.name, tool)
114+
if (options?.runtimeAdded === true) {
115+
this.runtimeToolNames.add(tool.name)
116+
}
110117
}
111118

112119
/** Return a tool by name, or `undefined` if not found. */
@@ -147,11 +154,12 @@ export class ToolRegistry {
147154
*/
148155
unregister(name: string): void {
149156
this.tools.delete(name)
157+
this.runtimeToolNames.delete(name)
150158
}
151159

152160
/** Alias for {@link unregister} — available for symmetry with `register`. */
153161
deregister(name: string): void {
154-
this.tools.delete(name)
162+
this.unregister(name)
155163
}
156164

157165
/**
@@ -170,6 +178,14 @@ export class ToolRegistry {
170178
})
171179
}
172180

181+
/**
182+
* Return only tools that were added dynamically at runtime (e.g. via
183+
* `agent.addTool()`), in LLM definition format.
184+
*/
185+
toRuntimeToolDefs(): LLMToolDef[] {
186+
return this.toToolDefs().filter(tool => this.runtimeToolNames.has(tool.name))
187+
}
188+
173189
/**
174190
* Convert all registered tools to the Anthropic-style `input_schema`
175191
* format. Prefer {@link toToolDefs} for normal use; this method is exposed

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ export interface AgentConfig {
207207
readonly systemPrompt?: string
208208
/** Names of tools (from the tool registry) available to this agent. */
209209
readonly tools?: readonly string[]
210+
/** Names of tools explicitly disallowed for this agent. */
211+
readonly disallowedTools?: readonly string[]
212+
/** Predefined tool preset for common use cases. */
213+
readonly toolPreset?: 'readonly' | 'readwrite' | 'full'
210214
readonly maxTurns?: number
211215
readonly maxTokens?: number
212216
/** Maximum cumulative tokens (input + output) allowed for this run. */

0 commit comments

Comments
 (0)