feat: add tool allow/deny profiles with per-agent and per-provider filtering
Implements configurable tool filtering with four built-in profiles (minimal, messaging, coding, full), global and per-agent/per-provider allow/deny lists with glob pattern support, and defense-in-depth enforcement at both tool listing and execution time. New: src/tools/policy.ts (ToolPolicy engine), src/tools/policy.test.ts (37 tests) Modified: config schema, tool registry, tool executor, NativeAgent, AgentOrchestrator, daemon wiring, gateway tool handler, test mocks
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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',
|
||||
]),
|
||||
full: new Set(), // Special: matches everything
|
||||
};
|
||||
|
||||
// ── 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)
|
||||
if (this.config.allow.length > 0) {
|
||||
for (const name of allToolNames) {
|
||||
if (matchesAnyPattern(name, this.config.allow)) {
|
||||
allowed.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Apply global deny (removes tools)
|
||||
if (this.config.deny.length > 0) {
|
||||
allowed = new Set(
|
||||
[...allowed].filter(name => !matchesAnyPattern(name, this.config.deny)),
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (override.allow.length > 0) {
|
||||
for (const name of allToolNames) {
|
||||
if (matchesAnyPattern(name, override.allow)) {
|
||||
allowed.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply override deny (deny always wins)
|
||||
if (override.deny.length > 0) {
|
||||
allowed = new Set(
|
||||
[...allowed].filter(name => !matchesAnyPattern(name, override.deny)),
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user