feat(tools): add tool policy system with 4-layer filtering

Implement a flexible tool policy system that supports:
- Tool groups (group:fs, group:runtime, group:web)
- Predefined profiles (minimal, coding, web, full)
- Global allow/deny lists
- Provider-specific rules
- Subagent restrictions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-01-30 21:40:45 +08:00
parent 83ab7ee743
commit 57f31b2f79
3 changed files with 458 additions and 0 deletions

138
src/agent/tools/groups.ts Normal file
View file

@ -0,0 +1,138 @@
/**
* Tool groups and profiles for policy-based filtering.
*
* Groups provide shortcuts for allowing/denying multiple tools at once.
* Profiles are predefined tool sets for common use cases.
*/
export type ToolProfileId = "minimal" | "coding" | "web" | "full";
/**
* Tool name aliases for compatibility.
* Maps alternative names to canonical tool names.
*/
export const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
shell: "exec",
search: "web_search",
fetch: "web_fetch",
};
/**
* Tool groups - shortcuts for multiple tools.
* Use "group:name" in allow/deny lists.
*/
export const TOOL_GROUPS: Record<string, string[]> = {
// File system operations
"group:fs": ["read", "write", "edit", "glob"],
// Runtime/execution tools
"group:runtime": ["exec", "process"],
// Web tools
"group:web": ["web_search", "web_fetch"],
// All core tools
"group:core": [
"read",
"write",
"edit",
"glob",
"exec",
"process",
"web_search",
"web_fetch",
],
};
/**
* Tool profiles - predefined tool sets.
*/
export const TOOL_PROFILES: Record<ToolProfileId, { allow?: string[]; deny?: string[] }> = {
// Minimal: no tools (useful for chat-only agents)
minimal: {
allow: [],
},
// Coding: file system + execution (default for coding tasks)
coding: {
allow: ["group:fs", "group:runtime"],
},
// Web: coding + web access
web: {
allow: ["group:fs", "group:runtime", "group:web"],
},
// Full: no restrictions
full: {},
};
/**
* Default tools denied for subagents.
* Subagents should not have access to session management or system tools.
*/
export const DEFAULT_SUBAGENT_TOOL_DENY: string[] = [
// Future: session management tools
// "sessions_list",
// "sessions_history",
// "sessions_send",
// "sessions_spawn",
// "session_status",
// Future: system tools
// "gateway",
// "agents_list",
];
/**
* Normalize a tool name to its canonical form.
*/
export function normalizeToolName(name: string): string {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
/**
* Normalize a list of tool names.
*/
export function normalizeToolList(list?: string[]): string[] {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
/**
* Expand group references in a tool list.
* "group:fs" -> ["read", "write", "edit", "glob"]
*/
export function expandToolGroups(list?: string[]): string[] {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
/**
* Get the policy for a profile.
*/
export function getProfilePolicy(
profile?: ToolProfileId,
): { allow?: string[]; deny?: string[] } | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile];
if (!resolved) return undefined;
if (!resolved.allow && !resolved.deny) return undefined;
return {
allow: resolved.allow ? [...resolved.allow] : undefined,
deny: resolved.deny ? [...resolved.deny] : undefined,
};
}

34
src/agent/tools/index.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Tools module - provides tool creation and policy-based filtering.
*/
// Tool implementations
export { createExecTool } from "./exec.js";
export { createProcessTool } from "./process.js";
export { createGlobTool } from "./glob.js";
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
// Tool groups and profiles
export {
type ToolProfileId,
TOOL_NAME_ALIASES,
TOOL_GROUPS,
TOOL_PROFILES,
DEFAULT_SUBAGENT_TOOL_DENY,
normalizeToolName,
normalizeToolList,
expandToolGroups,
getProfilePolicy,
} from "./groups.js";
// Tool policy system
export {
type ToolPolicy,
type ToolsConfig,
type FilterToolsOptions,
isToolAllowed,
filterToolsByPolicy,
filterTools,
getSubagentPolicy,
wouldToolBeAllowed,
} from "./policy.js";

286
src/agent/tools/policy.ts Normal file
View file

@ -0,0 +1,286 @@
/**
* Tool policy system for filtering tools based on configuration.
*
* Supports 4 layers of filtering:
* 1. Profile - base tool set (minimal/coding/web/full)
* 2. Global allow/deny - user customization
* 3. Provider-specific - different rules for different LLM providers
* 4. Subagent restrictions - limited tools for spawned agents
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import {
type ToolProfileId,
expandToolGroups,
getProfilePolicy,
normalizeToolName,
DEFAULT_SUBAGENT_TOOL_DENY,
} from "./groups.js";
/**
* Tool policy configuration.
*/
export interface ToolPolicy {
/** Allow list - only these tools are available (supports group:* syntax) */
allow?: string[];
/** Deny list - these tools are blocked (takes precedence over allow) */
deny?: string[];
}
/**
* Full tool configuration from config file.
*/
export interface ToolsConfig {
/** Base profile (minimal/coding/web/full) */
profile?: ToolProfileId;
/** Additional tools to allow */
allow?: string[];
/** Tools to deny */
deny?: string[];
/** Provider-specific overrides */
byProvider?: Record<string, ToolPolicy>;
}
// ============================================================================
// Pattern Matching
// ============================================================================
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = normalizeToolName(pattern);
if (!normalized) return { kind: "exact", value: "" };
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
// Convert wildcard to regex
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return {
kind: "regex",
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
};
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) return [];
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && name === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
}
return false;
}
// ============================================================================
// Policy Matching
// ============================================================================
/**
* Create a matcher function for a policy.
* Returns true if the tool is allowed, false if denied.
*/
function createPolicyMatcher(policy: ToolPolicy): (name: string) => boolean {
const deny = compilePatterns(policy.deny);
const allow = compilePatterns(policy.allow);
// Check if allow was explicitly set (even if empty)
const hasAllowList = Array.isArray(policy.allow);
return (name: string) => {
const normalized = normalizeToolName(name);
// Deny takes precedence
if (matchesAny(normalized, deny)) return false;
// If no allow list configured, allow all
if (!hasAllowList) return true;
// If allow list is empty, deny all (explicit restriction)
if (allow.length === 0) return false;
// Check if in allow list
return matchesAny(normalized, allow);
};
}
/**
* Check if a tool is allowed by a policy.
*/
export function isToolAllowed(name: string, policy?: ToolPolicy): boolean {
if (!policy) return true;
return createPolicyMatcher(policy)(name);
}
/**
* Filter tools by a policy.
*/
export function filterToolsByPolicy<T extends { name: string }>(
tools: T[],
policy?: ToolPolicy,
): T[] {
if (!policy) return tools;
const matcher = createPolicyMatcher(policy);
return tools.filter((tool) => matcher(tool.name));
}
// ============================================================================
// Policy Resolution
// ============================================================================
/**
* Merge allow lists (union).
*/
function mergeAllow(base?: string[], extra?: string[]): string[] | undefined {
if (!extra || extra.length === 0) return base;
if (!base || base.length === 0) return extra;
return Array.from(new Set([...base, ...extra]));
}
/**
* Merge deny lists (union).
*/
function mergeDeny(base?: string[], extra?: string[]): string[] | undefined {
if (!extra || extra.length === 0) return base;
if (!base || base.length === 0) return extra;
return Array.from(new Set([...base, ...extra]));
}
/**
* Resolve provider-specific policy.
*/
function resolveProviderPolicy(
byProvider?: Record<string, ToolPolicy>,
provider?: string,
): ToolPolicy | undefined {
if (!provider || !byProvider) return undefined;
const normalized = provider.trim().toLowerCase();
return byProvider[normalized];
}
/**
* Get subagent tool policy.
*/
export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy {
return {
deny: mergeDeny(DEFAULT_SUBAGENT_TOOL_DENY, extraDeny),
};
}
// ============================================================================
// Main Filter Function
// ============================================================================
export interface FilterToolsOptions {
/** Tool configuration */
config?: ToolsConfig;
/** Current LLM provider (for provider-specific rules) */
provider?: string;
/** Whether this is a subagent (applies subagent restrictions) */
isSubagent?: boolean;
}
/**
* Filter tools through the 4-layer policy system.
*
* Layer 1: Profile (base tool set)
* Layer 2: Global allow/deny
* Layer 3: Provider-specific
* Layer 4: Subagent restrictions
*/
export function filterTools(
tools: AgentTool<any>[],
options: FilterToolsOptions = {},
): AgentTool<any>[] {
const { config, provider, isSubagent } = options;
let filtered = tools;
// Layer 1: Profile
if (config?.profile) {
const profilePolicy = getProfilePolicy(config.profile);
if (profilePolicy) {
filtered = filterToolsByPolicy(filtered, profilePolicy);
}
}
// Layer 2: Global allow/deny
if (config?.allow || config?.deny) {
const globalPolicy: ToolPolicy = {
allow: config.allow,
deny: config.deny,
};
filtered = filterToolsByPolicy(filtered, globalPolicy);
}
// Layer 3: Provider-specific
if (provider && config?.byProvider) {
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
if (providerPolicy) {
filtered = filterToolsByPolicy(filtered, providerPolicy);
}
}
// Layer 4: Subagent restrictions
if (isSubagent) {
const subagentPolicy = getSubagentPolicy();
filtered = filterToolsByPolicy(filtered, subagentPolicy);
}
return filtered;
}
/**
* Check if a specific tool would be allowed given the options.
*/
export function wouldToolBeAllowed(
toolName: string,
options: FilterToolsOptions = {},
): boolean {
const { config, provider, isSubagent } = options;
// Layer 1: Profile
if (config?.profile) {
const profilePolicy = getProfilePolicy(config.profile);
if (profilePolicy && !isToolAllowed(toolName, profilePolicy)) {
return false;
}
}
// Layer 2: Global allow/deny
if (config?.allow || config?.deny) {
const globalPolicy: ToolPolicy = {
allow: config.allow,
deny: config.deny,
};
if (!isToolAllowed(toolName, globalPolicy)) {
return false;
}
}
// Layer 3: Provider-specific
if (provider && config?.byProvider) {
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) {
return false;
}
}
// Layer 4: Subagent restrictions
if (isSubagent) {
const subagentPolicy = getSubagentPolicy();
if (!isToolAllowed(toolName, subagentPolicy)) {
return false;
}
}
return true;
}