import { readFile } from "node:fs/promises"; import type { RiskAllowlistOption, RiskDirectoryScopeOption, RiskPatternScopeOption, } from "@vellumai/gateway-client"; import { getConfig } from "../config/loader.js"; import { classifyRisk, type RiskClassificationWithMeta, } from "../permissions/checker.js"; import { PermissionPrompter } from "../permissions/prompter.js"; import { runInPluginContext, runOutsidePluginContext, } from "../plugins/plugin-execution-context.js"; import { TokenExpiredError } from "../security/token-manager.js"; import { recordToolError, recordToolExecuted, } from "../telemetry/tool-audit.js"; import { type AbortReason, isAbortReason } from "../util/abort-reasons.js"; import { PermissionDeniedError, ToolError } from "../util/errors.js"; import { pathExists, safeStatSync } from "../util/fs.js"; import { truncateForLog } from "../util/logger.js"; import { callerOwnsWorkflowRun, manifestGrantsSideEffects, } from "../workflows/capabilities.js"; import { getWorkflowRunManager } from "../workflows/run-manager.js"; import { executeWithTimeout, safeTimeoutMs } from "./execution-timeout.js"; import { fileEditInputSchema } from "./filesystem/edit.js"; import { fileWriteInputSchema } from "./filesystem/write.js"; import { PermissionChecker } from "./permission-checker.js"; import { getToolOwner } from "./registry.js"; import { extractAndSanitize } from "./sensitive-output-placeholders.js"; import { applyEdit } from "./shared/filesystem/edit-engine.js"; import { sandboxPolicy } from "./shared/filesystem/path-policy.js"; import { MAX_FILE_SIZE_BYTES } from "./shared/filesystem/size-guard.js"; import { ToolApprovalHandler } from "./tool-approval-handler.js"; import { resolveToolInvocationAlias } from "./tool-name-aliases.js"; import { recordToolCompletion } from "./tool-profiler.js"; import { RISK_LEVEL_UNCLASSIFIED } from "./tool-types.js"; import { type ToolContext, type ToolExecutionResult } from "./types.js"; export class ToolExecutor { private prompter: PermissionPrompter; private permissionChecker: PermissionChecker; private approvalHandler: ToolApprovalHandler; constructor(prompter: PermissionPrompter) { this.prompter = prompter; this.permissionChecker = new PermissionChecker(prompter); this.approvalHandler = new ToolApprovalHandler(); } async execute( name: string, input: Record, context: ToolContext, ): Promise { const { name: executionName, input: executionInput } = resolveToolInvocationAlias(name, input, context.allowedToolNames); return this.executeInternal(executionName, executionInput, context); } private async executeInternal( name: string, input: Record, context: ToolContext, ): Promise { const startTime = Date.now(); let decision = "allow"; // The invocation's one classification, taken before the gates so every // audit row this call can produce (gate denial, gate error, grant-consumed // execution, permission decision) records the risk of what the model asked // for, then handed down to `checkPermission`. A classification that does // not complete (aborted, gateway unreachable) is recorded as such rather // than as a level; the permission check still requires one and fails // closed on its own. let classification: RiskClassificationWithMeta | undefined; try { classification = await classifyRisk( name, input, context.workingDir, undefined, undefined, context.signal, ); } catch { // Stays undefined; the audit row records RISK_LEVEL_UNCLASSIFIED. } let riskLevel: string = classification?.level ?? RISK_LEVEL_UNCLASSIFIED; let permRiskMeta: | { riskLevel: string; riskReason: string; riskScopeOptions: RiskPatternScopeOption[]; riskAllowlistOptions?: RiskAllowlistOption[]; riskDirectoryScopeOptions?: RiskDirectoryScopeOption[]; isContainerized?: boolean; } | undefined; let permMatchedTrustRuleId: string | undefined; let permApprovalMode: string | undefined; let permApprovalReason: string | undefined; let permRiskThreshold: string | undefined; // Whether THIS invocation was interactively prompted. Distinct from the // turn-level `context.approvedViaPrompt` (seeded from // `approvedViaPromptThisTurn`, which stays true for later auto-approved // tools in the same turn): the `permission_decided` telemetry must reflect // a prompt for this specific call, not any prompt earlier in the turn. let wasPromptedThisInvocation = false; // Run pre-execution approval gates (abort, guardian policy, // allowed-tool-set, task-run preflight, tool registry lookup). The gate // resolves the tool's sandbox/host target internally. const gateResult = await this.approvalHandler.checkPreExecutionGates( name, input, context, riskLevel, startTime, ); if (!gateResult.allowed) { return gateResult.result; } const tool = gateResult.tool; // The pre-execution gate parsed model-generated input against the tool's // registered Zod schema (`TOOL_INPUT_SCHEMAS`) before any grant was // consumed; substitute the parsed value (with `.catch()` recoveries // applied) so validation and execution see the same input. if (gateResult.parsedInput) { // The permission decision is about the input that runs, so a parse that // reshaped it (a `.catch()` recovery) gets its own classification. if (!Bun.deepEquals(input, gateResult.parsedInput)) { classification = undefined; } input = gateResult.parsedInput; } try { // A workflow run whose capability manifest grants side-effecting tools or // host functions (beyond the read-only baseline) must prompt at LAUNCH. // The manifest is authored and declared by the model, and the run's // leaves execute granted tools DIRECTLY (no per-call permission check) - // so the launch is the single point at which the user can consent to the // grant, which would otherwise bypass the gate those tools hit when the // main agent calls them. requireFreshApproval promotes the otherwise // low-risk launch to an interactive prompt that cached grants/trust rules // cannot silently bypass (run_workflow is not itself a SIDE_EFFECT tool, // so forcePromptSideEffects would not fire — requireFreshApproval is the // self-sufficient promotion). Read-only runs stay low-risk and silent. if ( name === "run_workflow" && manifestGrantsSideEffects(input.capabilities) ) { context.requireFreshApproval = true; } // Creating a workflow-mode schedule whose capability manifest grants // side-effecting tools or host functions persists that grant for an // unattended future run — so the user consents to it at CREATION, the // single interactive point in the flow (a triggered run later fires with // no live conversation). Same rationale as run_workflow above: the // manifest is model-declared and the eventual run's leaves execute granted // tools directly (no per-call prompt). Read-only or absent manifests stay // low-risk and silent. `schedule_create` is not a SIDE_EFFECT tool, so // requireFreshApproval is the self-sufficient promotion. if ( name === "schedule_create" && input.mode === "workflow" && manifestGrantsSideEffects(input.capabilities) ) { context.requireFreshApproval = true; } // Resuming a workflow whose STORED manifest granted side-effecting tools / // host functions restarts unfinished leaves that perform those side // effects. The original consent was given at LAUNCH (run_workflow above), // but resume is reachable by any actor who can list or guess the run id — // so re-require a fresh interactive approval when the target run's stored // manifest grants side effects. The other manage_workflows actions // (status/abort/list_runs) and resumes of read-only runs stay low-risk and // silent. `manage_workflows` is not a SIDE_EFFECT tool, so (like // run_workflow) requireFreshApproval is the self-sufficient promotion. if (name === "manage_workflows" && input.action === "resume") { const targetRunId = typeof input.run_id === "string" ? input.run_id : undefined; const targetRun = targetRunId ? getWorkflowRunManager().status(targetRunId) : null; // Only promote to fresh approval for a run the caller actually OWNS. The // tool hides others' runs as not-found, so prompting here for a // non-owned run would both leak that the run exists and nag the guardian // for a resume that will return not-found. Uses the same ownership scope // the tool applies (callerOwnsWorkflowRun) so the gate and the tool agree. if ( targetRun && callerOwnsWorkflowRun(targetRun, context) && manifestGrantsSideEffects(targetRun.capabilities) ) { context.requireFreshApproval = true; } } // A consumed scoped grant is a complete authorization - skip the // interactive permission/prompt flow so non-interactive sessions // don't auto-deny prompt-gated tools and burn the one-time grant. // Exception: requireFreshApproval tools always go through the // permission check even when a grant was consumed - the grant does // not substitute for an interactive human review. if (!gateResult.grantConsumed || context.requireFreshApproval) { // Check permissions via the extracted PermissionChecker const permResult = await this.permissionChecker.checkPermission( name, input, tool, context, startTime, computePreviewDiff, classification, ); riskLevel = permResult.riskLevel; decision = permResult.decision; permRiskMeta = permResult.riskMeta; permMatchedTrustRuleId = permResult.matchedTrustRuleId; permApprovalMode = permResult.approvalMode; permApprovalReason = permResult.approvalReason; permRiskThreshold = permResult.riskThreshold; if (!permResult.allowed) { return { content: permResult.content, isError: true, riskLevel: permRiskMeta?.riskLevel, riskReason: permRiskMeta?.riskReason, riskScopeOptions: permRiskMeta?.riskScopeOptions, riskAllowlistOptions: permRiskMeta?.riskAllowlistOptions, riskDirectoryScopeOptions: permRiskMeta?.riskDirectoryScopeOptions, isContainerized: permRiskMeta?.isContainerized, matchedTrustRuleId: permMatchedTrustRuleId, approvalMode: permApprovalMode, approvalReason: permApprovalReason, riskThreshold: permRiskThreshold, }; } if (permResult.wasPrompted) { context.approvedViaPrompt = true; wasPromptedThisInvocation = true; } } else { // Grant consumed — permission check was skipped. Set provenance explicitly // so the record shows how this execution was authorized. permApprovalMode = "auto"; permApprovalReason = "grant_scoped_consumed"; } // Execute the tool. Tools that forward to an external resolver // (computer-use, ui-surface, apps, meet) handle that dispatch in // their own `execute()` body — the executor no longer special-cases // proxy mode here. const toolTimeoutMs = computePerToolTimeoutMs(name, input); const execContext = context; // Mark the owning plugin as in context (via AsyncLocalStorage) so host // APIs the tool reaches, e.g. resolveCredential, can scope to it. The // context must be established around the `execute()` call itself so the // returned promise carries the binding across its awaits. A non-plugin // tool (default/skill/mcp/workspace) runs with the context explicitly // cleared rather than merely unset: the turn may have been started by a // plugin (a route handler calling `runConversationTurn`), and this tool // is host code that must not inherit that plugin's identity. const owner = getToolOwner(name); const execPromise = owner?.kind === "plugin" ? runInPluginContext(owner.id, () => tool.execute(input, execContext)) : runOutsidePluginContext(() => tool.execute(input, execContext)); let execResult: ToolExecutionResult = await executeWithTimeout( execPromise, toolTimeoutMs, name, ); // Sized from the RAW pre-sanitization result — sensitive-output // extraction below strips directives and swaps raw values for // placeholders, which changes the content length, and telemetry must // report the true payload size. Only the size leaves the device, // never the payload. Measured here because only this site sees the // content before extractAndSanitize() rewrites it. const rawResultBytes = Buffer.byteLength(execResult.content, "utf8"); // Sensitive output extraction: strip directives, replace raw values // with placeholders, and attach bindings for agent-loop substitution. const { sanitizedContent, bindings } = extractAndSanitize( execResult.content, ); if (bindings.length > 0) { execResult = { ...execResult, content: sanitizedContent, sensitiveBindings: bindings, }; } const durationMs = Date.now() - startTime; // Strip sensitiveBindings before auditing to prevent raw values leaking. const { sensitiveBindings: _sb, ...safeResult } = execResult; recordToolExecuted({ conversationId: context.conversationId, toolName: name, input, resultContent: safeResult.content, resultBytes: rawResultBytes, decision, riskLevel, matchedTrustRuleId: permMatchedTrustRuleId, durationMs, attribution: context.attribution ?? null, wasPrompted: wasPromptedThisInvocation, }); recordToolCompletion( context.conversationId, name, durationMs, safeResult.isError, ); // Merge the classification's risk metadata onto the // tool result so downstream consumers (AgentEvent → handleToolResult → // ToolResult SSE message) can forward it to the client. if (permRiskMeta) { execResult = { ...execResult, riskLevel: permRiskMeta.riskLevel, riskReason: permRiskMeta.riskReason, riskScopeOptions: permRiskMeta.riskScopeOptions, riskAllowlistOptions: permRiskMeta.riskAllowlistOptions, riskDirectoryScopeOptions: permRiskMeta.riskDirectoryScopeOptions, isContainerized: permRiskMeta.isContainerized, }; } if (permMatchedTrustRuleId) { execResult = { ...execResult, matchedTrustRuleId: permMatchedTrustRuleId, }; } if (permApprovalMode) { execResult = { ...execResult, approvalMode: permApprovalMode }; } if (permApprovalReason) { execResult = { ...execResult, approvalReason: permApprovalReason }; } if (permRiskThreshold) { execResult = { ...execResult, riskThreshold: permRiskThreshold }; } return execResult; } catch (err) { // Extract classified risk level if the PermissionChecker attached it // before re-throwing. This preserves audit accuracy for high-risk // tool attempts that fail mid-permission-evaluation. if ( err instanceof Error && typeof (err as Error & { riskLevel?: string }).riskLevel === "string" ) { riskLevel = (err as Error & { riskLevel?: string }).riskLevel!; } const durationMs = Date.now() - startTime; // Daemon-owned aborts surface as a tagged AbortReason — a plain object // thrown verbatim by `AbortSignal.throwIfAborted()`, carried on // `error.reason`, or stamped on a provider wrapper's `abortReason`. // Recognize it before the generic stringification below, which would // render it "[object Object]" and misfile the cancellation as an // unexpected failure. const abortReason = extractAbortReason(err); const msg = abortReason ? `Tool execution was cancelled (${abortReason.kind}).` : err instanceof Error ? err.message : describeThrownValue(err); const isAbort = abortReason !== undefined || (err instanceof Error && err.name === "AbortError"); const isExpected = isAbort || err instanceof PermissionDeniedError || err instanceof ToolError || err instanceof TokenExpiredError; recordToolError({ conversationId: context.conversationId, requestId: context.requestId, toolName: name, input, errorMessage: msg, isExpected, errorName: err instanceof Error ? err.name : undefined, errorStack: err instanceof Error ? err.stack : undefined, riskLevel, matchedTrustRuleId: permMatchedTrustRuleId, durationMs, attribution: context.attribution ?? null, }); recordToolCompletion(context.conversationId, name, durationMs, true); if (isExpected) { return { content: msg, isError: true }; } return { content: `Tool "${name}" encountered an unexpected error: ${msg}`, isError: true, }; } } } /** * Extract the tagged {@link AbortReason} from a thrown value: the value * itself, its `reason` (an `AbortError` carrying the signal's reason), or a * provider wrapper's `abortReason`. Returns `undefined` for anything that is * not a daemon-owned abort. */ function extractAbortReason(err: unknown): AbortReason | undefined { if (isAbortReason(err)) { return err; } const reason = (err as { reason?: unknown } | null)?.reason; if (isAbortReason(reason)) { return reason; } const abortReason = (err as { abortReason?: unknown } | null)?.abortReason; if (isAbortReason(abortReason)) { return abortReason; } return undefined; } /** * Render a thrown non-Error value for the error result and audit trail. * Objects are JSON-rendered so a thrown `{status: 429}` reads as itself * rather than "[object Object]", bounded so a large payload cannot flood * the audit row; cyclic or non-serializable values fall back to String(). */ function describeThrownValue(err: unknown): string { if (typeof err === "string") { return err; } try { const json = JSON.stringify(err); if (typeof json === "string") { return truncateForLog(json, 500); } } catch { // Cyclic or otherwise non-serializable — fall through to String(). } return String(err); } // Re-export from the canonical source so existing consumers of // `executor.ts` continue to work without changing their imports. export { isSideEffectTool } from "./side-effects.js"; /** * Compute the effective per-tool execution timeout in milliseconds. * * Shell tools (`bash`, `host_bash`) manage their own timeouts with SIGKILL * on expiry. We add a 5s buffer so the shell's own deadline fires first and * handles cleanup before the executor wrapper trips. * * `ask_question` blocks on user input inside `execute()` via `QuestionPrompter`, * which waits up to `questionResponseTimeoutSec`. We give the wrapper the same * 5s buffer over that deadline so the prompter's own timeout fires first and * returns its clean "User did not respond within timeout" result — otherwise * the shorter generic budget trips first, orphaning the still-pending prompt * behind the confusing "may still be running in the background" error. * * All other tools use the generic `toolExecutionTimeoutSec` configuration value. * * Consumed by `executeInternal` via `executeWithTimeout`, which is the * sole enforcer of the per-tool budget. */ export function computePerToolTimeoutMs( name: string, input: Record, ): number { if (name === "bash" || name === "host_bash") { const { shellDefaultTimeoutSec, shellMaxTimeoutSec } = getConfig().timeouts; const requestedSec = typeof input.timeout_seconds === "number" ? input.timeout_seconds : shellDefaultTimeoutSec; const shellTimeoutSec = Math.max( 1, Math.min(requestedSec, shellMaxTimeoutSec), ); return (shellTimeoutSec + 5) * 1000; } if (name === "ask_question") { const { questionResponseTimeoutSec } = getConfig().timeouts; return (questionResponseTimeoutSec + 5) * 1000; } const rawTimeoutSec = getConfig().timeouts.toolExecutionTimeoutSec; return safeTimeoutMs(rawTimeoutSec); } /** * Compute a preview diff for file tools so the confirmation prompt can show * what will change. Returns undefined for non-file tools or on any error. * Out-of-workspace targets deliberately produce no preview (strict * sandboxPolicy): the preview runs before the user answers the prompt, and * external file content must not be read — let alone shipped in the * confirmation payload — ahead of approval. Host file tools have no preview * for the same reason. */ async function computePreviewDiff( toolName: string, input: Record, workingDir: string, ): Promise< | { filePath: string; oldContent: string; newContent: string; isNewFile: boolean; } | undefined > { try { if (toolName === "file_write") { // Parse with the tool's own schema so the preview reads the same shape // the executor will (a call the schema rejects gets no preview). const parsed = fileWriteInputSchema.safeParse(input); if (!parsed.success) { return undefined; } const { path: rawPath, content } = parsed.data; const pathCheck = sandboxPolicy(rawPath, workingDir, { mustExist: false, }); if (!pathCheck.ok) { return undefined; } const filePath = pathCheck.resolved; const isNewFile = !pathExists(filePath); if (!isNewFile) { const stat = safeStatSync(filePath); if (!stat || stat.size > MAX_FILE_SIZE_BYTES) { return undefined; } } const oldContent = isNewFile ? "" : await readFile(filePath, "utf-8"); return { filePath, oldContent, newContent: content, isNewFile }; } if (toolName === "file_edit") { const parsed = fileEditInputSchema.safeParse(input); if (!parsed.success) { return undefined; } const { path: rawPath, old_string: oldString, new_string: newString, } = parsed.data; const pathCheck = sandboxPolicy(rawPath, workingDir); if (!pathCheck.ok) { return undefined; } const filePath = pathCheck.resolved; const stat = safeStatSync(filePath); if (!stat) { return undefined; } if (stat.size > MAX_FILE_SIZE_BYTES) { return undefined; } const content = await readFile(filePath, "utf-8"); const replaceAll = parsed.data.replace_all === true; const result = applyEdit(content, oldString, newString, replaceAll); if (!result.ok) { return undefined; } return { filePath, oldContent: content, newContent: result.updatedContent, isNewFile: false, }; } } catch { // Preview is best-effort - don't block the prompt on errors } return undefined; }