470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import type { ToolResult } from './types.js';
|
|
import type { ToolRegistry } from './registry.js';
|
|
import type { HookEngine } from '../hooks/engine.js';
|
|
import type { ToolPolicyContext } from './policy.js';
|
|
import { resolveAutonomy } from '../hooks/autonomy.js';
|
|
import { auditLogger } from '../audit/index.js';
|
|
import { randomUUID } from 'crypto';
|
|
import { matchesAnyPattern, patternToRegex } from './policy.js';
|
|
import { redactForAudit, containsSecretLikeKeys } from '../audit/redact.js';
|
|
import type { SandboxManager } from '../sandbox/index.js';
|
|
import { createSandboxedProcessStartTool, createSandboxedShellTool } from '../sandbox/index.js';
|
|
|
|
export interface ToolExecutorConfig {
|
|
defaultTimeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
}
|
|
|
|
export class ToolExecutor {
|
|
private registry: ToolRegistry;
|
|
private hooks: HookEngine;
|
|
private defaultTimeoutMs: number;
|
|
private maxOutputBytes: number;
|
|
private sandboxManager?: SandboxManager;
|
|
|
|
constructor(registry: ToolRegistry, hooks: HookEngine, config?: ToolExecutorConfig) {
|
|
this.registry = registry;
|
|
this.hooks = hooks;
|
|
this.defaultTimeoutMs = config?.defaultTimeoutMs ?? 30_000;
|
|
this.maxOutputBytes = config?.maxOutputBytes ?? 51_200;
|
|
}
|
|
|
|
setSandboxManager(manager?: SandboxManager): void {
|
|
this.sandboxManager = manager;
|
|
}
|
|
|
|
private isElevationActive(context?: ToolPolicyContext): boolean {
|
|
const untilMs = context?.elevatedHostUntilMs;
|
|
return typeof untilMs === 'number' && Number.isFinite(untilMs) && untilMs > Date.now();
|
|
}
|
|
|
|
private resolveEffectiveExecutionEnvironment(toolName: string, context?: ToolPolicyContext): 'host' | 'sandbox' {
|
|
const base = context?.executionEnvironment ?? 'host';
|
|
if (this.isHighRiskTool(toolName) && this.isElevationActive(context)) {
|
|
return 'host';
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
|
|
const executionId = randomUUID();
|
|
const executionEnvironment = this.resolveEffectiveExecutionEnvironment(toolName, context);
|
|
const skillName = context?.skillName;
|
|
|
|
const tool = this.registry.getByApiName(toolName);
|
|
if (!tool) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: toolName,
|
|
reason: 'Tool not found',
|
|
denial_type: 'not_found',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return { success: false, output: '', error: `Tool '${toolName}' not found` };
|
|
}
|
|
|
|
const argsRedaction = redactForAudit(args);
|
|
|
|
// Secret scope enforcement
|
|
const requiredScopes = tool.requiredSecretScopes ?? [];
|
|
const allowedScopes = this.resolveAllowedSecretScopes(context);
|
|
if (requiredScopes.length > 0 && !this.hasAllScopes(allowedScopes, requiredScopes)) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: tool.name,
|
|
reason: `Tool requires secret scope(s): ${requiredScopes.join(', ')}`,
|
|
denial_type: 'policy',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `Tool '${tool.name}' denied: missing secret scopes (${requiredScopes.join(', ')})`,
|
|
};
|
|
}
|
|
|
|
// Capability enforcement: filesystem + network constraints
|
|
const capabilityViolation = this.checkCapabilityConstraints(tool.name, args, context, executionEnvironment);
|
|
if (capabilityViolation) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: tool.name,
|
|
reason: capabilityViolation,
|
|
denial_type: 'policy',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return { success: false, output: '', error: `Tool '${tool.name}' denied: ${capabilityViolation}` };
|
|
}
|
|
|
|
// Prompt-injection guard: block obviously unsafe tool calls when untrusted content is present
|
|
const guard = this.evaluatePromptInjectionGuard(tool.name, args, context);
|
|
if (guard) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: tool.name,
|
|
reason: guard,
|
|
denial_type: 'policy',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return { success: false, output: '', error: `Tool '${tool.name}' blocked: ${guard}` };
|
|
}
|
|
|
|
// Policy check (defense in depth — tools should also be filtered at listing time)
|
|
const policy = this.registry.getPolicy();
|
|
if (policy) {
|
|
const allNames = this.registry.list().map(t => t.name);
|
|
if (!policy.isAllowed(toolName, allNames, context)) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: toolName,
|
|
reason: 'Tool not allowed by policy',
|
|
denial_type: 'policy',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `Tool '${toolName}' is not allowed by tool policy`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check hooks with autonomy resolution
|
|
const baseAction = this.hooks.getAction(toolName);
|
|
const autonomyLevel = context?.autonomyLevel ?? 'standard';
|
|
const autonomyDecision = resolveAutonomy(toolName, baseAction, autonomyLevel);
|
|
let finalAction = autonomyDecision.action;
|
|
|
|
// Elevated mode must always require explicit confirmation for host high-risk tool calls.
|
|
if (executionEnvironment === 'host' && this.isHighRiskTool(toolName) && this.isElevationActive(context)) {
|
|
finalAction = 'confirm';
|
|
}
|
|
|
|
// Log autonomy override if applicable
|
|
if (autonomyDecision.overridden) {
|
|
auditLogger?.toolDenied({
|
|
tool_name: toolName,
|
|
reason: `Autonomy override: ${autonomyDecision.reason}`,
|
|
denial_type: 'autonomy_override',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
}
|
|
|
|
if (finalAction === 'confirm') {
|
|
const hookResult = await this.hooks.requestConfirmation(
|
|
toolName,
|
|
args as Record<string, unknown>,
|
|
);
|
|
|
|
auditLogger?.toolApproval({
|
|
tool_name: toolName,
|
|
approved: hookResult.approved,
|
|
reason: hookResult.reason,
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
|
|
if (!hookResult.approved) {
|
|
const denyReason = hookResult.reason ?? 'no reason';
|
|
const detailedReason = autonomyDecision.overridden
|
|
? `${denyReason} (autonomy: ${autonomyDecision.reason})`
|
|
: denyReason;
|
|
auditLogger?.toolDenied({
|
|
tool_name: toolName,
|
|
reason: detailedReason,
|
|
denial_type: 'hook',
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `Tool '${toolName}' denied by user: ${detailedReason}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Execute with timeout
|
|
const startTime = Date.now();
|
|
|
|
auditLogger?.toolStart({
|
|
tool_name: toolName,
|
|
tool_args: argsRedaction.value,
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
channel: context?.channel,
|
|
sender: context?.sender,
|
|
agent_tier: context?.tier,
|
|
});
|
|
|
|
try {
|
|
const result = await Promise.race([
|
|
(async () => {
|
|
if (executionEnvironment === 'sandbox' && this.sandboxManager) {
|
|
const sandboxSessionId = context?.sessionId ?? `${context?.channel ?? 'unknown'}:${context?.sender ?? 'unknown'}`;
|
|
const sandbox = await this.sandboxManager.getOrCreate(sandboxSessionId);
|
|
if (toolName === 'shell.exec') {
|
|
return createSandboxedShellTool(sandbox).execute(args);
|
|
}
|
|
if (toolName === 'process.start') {
|
|
return createSandboxedProcessStartTool(sandbox).execute(args);
|
|
}
|
|
}
|
|
return tool.execute(args);
|
|
})(),
|
|
new Promise<ToolResult>((_, reject) =>
|
|
setTimeout(() => reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`)), this.defaultTimeoutMs),
|
|
),
|
|
]);
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Truncate output if too large
|
|
if (result.output.length > this.maxOutputBytes) {
|
|
result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]';
|
|
}
|
|
|
|
const resultRedaction = redactForAudit(result);
|
|
auditLogger?.toolSuccess({
|
|
tool_name: toolName,
|
|
result: resultRedaction.value as { success: boolean; output: string; error?: string },
|
|
duration_ms: duration,
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions + resultRedaction.redactions,
|
|
session_id: context?.sessionId,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
const errorRedaction = redactForAudit(errorMessage);
|
|
|
|
auditLogger?.toolError({
|
|
tool_name: toolName,
|
|
error: String(errorRedaction.value),
|
|
duration_ms: duration,
|
|
session_id: context?.sessionId,
|
|
execution_id: executionId,
|
|
execution_environment: executionEnvironment,
|
|
skill_name: skillName,
|
|
redactions_applied: argsRedaction.redactions + errorRedaction.redactions,
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: String(errorRedaction.value),
|
|
};
|
|
}
|
|
}
|
|
|
|
private resolveAllowedSecretScopes(context?: ToolPolicyContext): string[] {
|
|
if (context?.allowedSecretScopes) {
|
|
return context.allowedSecretScopes;
|
|
}
|
|
if (context?.skillPermissions?.secrets) {
|
|
return context.skillPermissions.secrets;
|
|
}
|
|
if (context?.skillName) {
|
|
return [];
|
|
}
|
|
return ['*'];
|
|
}
|
|
|
|
private hasAllScopes(allowed: string[], required: string[]): boolean {
|
|
if (allowed.includes('*')) {
|
|
return true;
|
|
}
|
|
return required.every((scope) => allowed.includes(scope));
|
|
}
|
|
|
|
private isHighRiskTool(toolName: string): boolean {
|
|
if (toolName.startsWith('browser.')) {
|
|
return true;
|
|
}
|
|
return [
|
|
'file.write',
|
|
'file.edit',
|
|
'file.patch',
|
|
'shell.exec',
|
|
'process.start',
|
|
'process.kill',
|
|
].includes(toolName);
|
|
}
|
|
|
|
private checkCapabilityConstraints(toolName: string, args: unknown, context: ToolPolicyContext | undefined, effectiveEnv: 'host' | 'sandbox'): string | null {
|
|
const perms = context?.skillPermissions;
|
|
if (!perms) {
|
|
if (context?.skillName && this.isHighRiskTool(toolName)) {
|
|
return 'skill has no permissions manifest; high-risk tool denied by default';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Sandbox enforcement for high-risk tools unless explicitly allowed.
|
|
if (this.isHighRiskTool(toolName)) {
|
|
const requested = perms.execution_environment ?? 'sandbox';
|
|
if (context?.skillName && effectiveEnv === 'host' && requested !== 'host' && !this.isElevationActive(context)) {
|
|
return 'high-risk tool execution on host is not allowed for this skill (requires execution_environment=host)';
|
|
}
|
|
}
|
|
|
|
// FS path enforcement
|
|
const fs = perms.fs;
|
|
if (fs && toolName.startsWith('file.')) {
|
|
const mode: 'read' | 'write' = (toolName === 'file.read' || toolName === 'file.list') ? 'read' : 'write';
|
|
const allowlist = mode === 'read' ? (fs.read ?? []) : (fs.write ?? []);
|
|
if (allowlist.length === 0) {
|
|
return `filesystem ${mode} access not permitted by skill permissions`;
|
|
}
|
|
|
|
const paths = this.extractFilePaths(toolName, args);
|
|
for (const p of paths) {
|
|
if (!this.pathAllowed(p, allowlist)) {
|
|
return `path not allowed by skill permissions (${mode}): ${p}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Network host enforcement (best-effort)
|
|
if (perms.net && perms.net.length > 0 && toolName === 'web.fetch') {
|
|
const url = (args as { url?: unknown } | null)?.url;
|
|
if (typeof url === 'string') {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const host = parsed.hostname;
|
|
const port = parsed.port
|
|
? Number.parseInt(parsed.port, 10)
|
|
: parsed.protocol === 'https:'
|
|
? 443
|
|
: parsed.protocol === 'http:'
|
|
? 80
|
|
: undefined;
|
|
|
|
const allowed = perms.net.some((rule) => {
|
|
if (!matchesAnyPattern(host, [rule.host])) {
|
|
return false;
|
|
}
|
|
if (!rule.ports || rule.ports.length === 0) {
|
|
return true;
|
|
}
|
|
if (!port || !Number.isFinite(port)) {
|
|
return false;
|
|
}
|
|
return rule.ports.includes(port);
|
|
});
|
|
|
|
if (!allowed) {
|
|
return `network access denied by skill permissions: ${host}${port ? `:${port}` : ''}`;
|
|
}
|
|
} catch {
|
|
return 'invalid url for web.fetch';
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private extractFilePaths(toolName: string, args: unknown): string[] {
|
|
const out: string[] = [];
|
|
const record = (args ?? null) as Record<string, unknown> | null;
|
|
if (!record || typeof record !== 'object') {
|
|
return out;
|
|
}
|
|
|
|
if (toolName === 'file.patch') {
|
|
const patches = record.patches;
|
|
if (Array.isArray(patches)) {
|
|
for (const patch of patches) {
|
|
if (patch && typeof patch === 'object') {
|
|
const p = (patch as Record<string, unknown>).path;
|
|
if (typeof p === 'string') {
|
|
out.push(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const p = record.path;
|
|
if (typeof p === 'string') {
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private pathAllowed(pathValue: string, allowlist: string[]): boolean {
|
|
return allowlist.some((pattern) => patternToRegex(pattern).test(pathValue));
|
|
}
|
|
|
|
private evaluatePromptInjectionGuard(toolName: string, args: unknown, context?: ToolPolicyContext): string | null {
|
|
if (!context?.untrustedContent) {
|
|
return null;
|
|
}
|
|
|
|
// When untrusted content is present, forbid passing secrets directly via tool args.
|
|
if ((toolName === 'web.fetch' || toolName === 'web.search') && containsSecretLikeKeys(args)) {
|
|
return 'refusing to pass secret-like fields to a network tool while untrusted content is present';
|
|
}
|
|
|
|
const serialized = JSON.stringify(args ?? {});
|
|
const lower = serialized.toLowerCase();
|
|
|
|
const markers = [
|
|
'ignore previous',
|
|
'ignore all previous',
|
|
'system prompt',
|
|
'exfiltrate',
|
|
'send to',
|
|
'upload',
|
|
'curl ',
|
|
'wget ',
|
|
'powershell',
|
|
'rm -rf',
|
|
'chmod ',
|
|
'ssh ',
|
|
'scp ',
|
|
'BEGIN PRIVATE KEY'.toLowerCase(),
|
|
];
|
|
|
|
if (this.isHighRiskTool(toolName) && markers.some((m) => lower.includes(m))) {
|
|
return 'blocked high-risk tool call due to prompt-injection markers in arguments';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|