358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import type { AutonomyLevel, ToolsConfig, ToolProfile } from '../config/schema.js';
|
|
import type { Tool } from './types.js';
|
|
import type { SkillPermissions } from '../skills/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',
|
|
'system.info',
|
|
]),
|
|
messaging: new Set([
|
|
'file.read',
|
|
'file.list',
|
|
'web.fetch',
|
|
'system.info',
|
|
'memory.read',
|
|
'memory.write',
|
|
'memory.search',
|
|
'web.search',
|
|
'gmail.list',
|
|
'gmail.search',
|
|
'gmail.read',
|
|
'calendar.today',
|
|
'calendar.list',
|
|
'calendar.search',
|
|
'docs.list',
|
|
'docs.search',
|
|
'docs.read',
|
|
'drive.list',
|
|
'drive.search',
|
|
'drive.read',
|
|
'tasks.lists',
|
|
'tasks.list',
|
|
'cron.list',
|
|
'cron.trigger',
|
|
'cron.create',
|
|
'cron.delete',
|
|
]),
|
|
coding: new Set([
|
|
'file.read',
|
|
'file.list',
|
|
'web.fetch',
|
|
'system.info',
|
|
'memory.read',
|
|
'memory.write',
|
|
'memory.search',
|
|
'web.search',
|
|
'gmail.list',
|
|
'gmail.search',
|
|
'gmail.read',
|
|
'calendar.today',
|
|
'calendar.list',
|
|
'calendar.search',
|
|
'docs.list',
|
|
'docs.search',
|
|
'docs.read',
|
|
'drive.list',
|
|
'drive.search',
|
|
'drive.read',
|
|
'tasks.lists',
|
|
'tasks.list',
|
|
'cron.list',
|
|
'cron.trigger',
|
|
'cron.create',
|
|
'cron.delete',
|
|
'file.write',
|
|
'file.edit',
|
|
'file.patch',
|
|
'shell.exec',
|
|
'process.start',
|
|
'process.status',
|
|
'process.output',
|
|
'process.kill',
|
|
'process.list',
|
|
'browser.navigate',
|
|
'browser.screenshot',
|
|
'browser.click',
|
|
'browser.type',
|
|
'browser.content',
|
|
'browser.eval',
|
|
]),
|
|
full: new Set(), // Special: matches everything
|
|
};
|
|
|
|
// ── Tool groups ─────────────────────────────────────────────────────
|
|
|
|
/** Named groups for use in allow/deny lists (e.g. 'group:fs'). */
|
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
|
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
|
|
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
|
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
|
|
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
|
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
|
|
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
|
'group:gdocs': ['docs.list', 'docs.search', 'docs.read'],
|
|
'group:gdrive': ['drive.list', 'drive.search', 'drive.read'],
|
|
'group:gtasks': ['tasks.lists', 'tasks.list'],
|
|
'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'],
|
|
};
|
|
|
|
/** Expand group references in a list of tool names/patterns. */
|
|
function expandGroups(names: string[]): string[] {
|
|
return names.flatMap(n => TOOL_GROUPS[n] ?? [n]);
|
|
}
|
|
|
|
// ── 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;
|
|
/** Session ID for audit logging. */
|
|
sessionId?: string;
|
|
/** Channel name for audit logging. */
|
|
channel?: string;
|
|
/** Sender ID for audit logging. */
|
|
sender?: string;
|
|
/** Model tier for audit logging. */
|
|
tier?: string;
|
|
/** Autonomy level for tool execution (affects confirmation requirements). */
|
|
autonomyLevel?: AutonomyLevel;
|
|
|
|
/** Optional active skill name (for capability scoping and audit). */
|
|
skillName?: string;
|
|
/** Optional active skill permissions (capability declarations). */
|
|
skillPermissions?: SkillPermissions;
|
|
|
|
/** Execution environment for high-risk operations. */
|
|
executionEnvironment?: 'host' | 'sandbox';
|
|
|
|
/** Secret scopes allowed for this context (used by executor). */
|
|
allowedSecretScopes?: string[];
|
|
|
|
/** True when untrusted content has been introduced in this run. */
|
|
untrustedContent?: boolean;
|
|
}
|
|
|
|
function resolveSkillAllowedNames(allToolNames: string[], permissions?: SkillPermissions): Set<string> | null {
|
|
if (!permissions) {
|
|
return null;
|
|
}
|
|
|
|
const explicitTools = Array.isArray(permissions.tools) ? permissions.tools : undefined;
|
|
const toolGroups = Array.isArray(permissions.tool_groups) ? permissions.tool_groups : undefined;
|
|
|
|
const patterns = (explicitTools && explicitTools.length > 0)
|
|
? expandGroups(explicitTools)
|
|
: (toolGroups && toolGroups.length > 0)
|
|
? expandGroups(toolGroups)
|
|
: [];
|
|
|
|
if (patterns.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return new Set(allToolNames.filter((name) => matchesAnyPattern(name, patterns)));
|
|
}
|
|
|
|
// ── 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) — expand groups first
|
|
const globalAllow = expandGroups(this.config.allow);
|
|
if (globalAllow.length > 0) {
|
|
for (const name of allToolNames) {
|
|
if (matchesAnyPattern(name, globalAllow)) {
|
|
allowed.add(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 3: Apply global deny (removes tools) — expand groups first
|
|
const globalDeny = expandGroups(this.config.deny);
|
|
if (globalDeny.length > 0) {
|
|
allowed = new Set(
|
|
[...allowed].filter(name => !matchesAnyPattern(name, globalDeny)),
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Step 6: Apply skill capability restrictions (intersection)
|
|
// Safe-by-default: if a skill is active but has no permissions manifest, deny all tools.
|
|
if (context?.skillName && !context.skillPermissions) {
|
|
allowed = new Set();
|
|
} else {
|
|
const skillAllowed = resolveSkillAllowedNames(allToolNames, context?.skillPermissions);
|
|
if (skillAllowed) {
|
|
allowed = intersect(allowed, skillAllowed);
|
|
}
|
|
}
|
|
|
|
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 — expand groups first
|
|
const overrideAllow = expandGroups(override.allow);
|
|
if (overrideAllow.length > 0) {
|
|
for (const name of allToolNames) {
|
|
if (matchesAnyPattern(name, overrideAllow)) {
|
|
allowed.add(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply override deny (deny always wins) — expand groups first
|
|
const overrideDeny = expandGroups(override.deny);
|
|
if (overrideDeny.length > 0) {
|
|
allowed = new Set(
|
|
[...allowed].filter(name => !matchesAnyPattern(name, overrideDeny)),
|
|
);
|
|
}
|
|
|
|
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, expandGroups };
|