/** * IPC route definitions for risk classification. * * Exposes classify_risk to the assistant daemon over the IPC socket. The * assistant sends tool invocation parameters; the handler dispatches to the * appropriate classifier and returns a complete ClassificationResult. */ import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { z } from "zod"; import { parseArgs } from "../risk/arg-parser.js"; import { bashRiskClassifier, getWrappedProgramWithArgs, } from "../risk/bash-risk-classifier.js"; import { DEFAULT_COMMAND_REGISTRY } from "../risk/command-registry/index.js"; import { generateDirectoryScopeOptions } from "../risk/directory-scope.js"; import { fileRiskClassifier, isPathWithinRoot, type FileClassificationContext, } from "../risk/file-risk-classifier.js"; import type { CommandRiskSpec, DirectoryScopeOption, } from "../risk/risk-types.js"; import { scheduleRiskClassifier } from "../risk/schedule-risk-classifier.js"; import { analyzeShellCommand, cachedParse, deriveShellActionKeys, } from "../risk/shell-identity.js"; import { skillLoadRiskClassifier } from "../risk/skill-risk-classifier.js"; import { webRiskClassifier } from "../risk/web-risk-classifier.js"; import type { IpcRoute } from "./server.js"; // ── Zod schema ────────────────────────────────────────────────────────────── const ClassifyRiskSchema = z.object({ tool: z.string().min(1), command: z.string().optional(), url: z.string().optional(), path: z.string().optional(), // Symlink-resolved target path for file tools, canonicalized by the daemon. // Used for security escalation checks so a symlink cannot mask the real // target. Falls back to lexical resolution of `path` when absent. resolvedPath: z.string().optional(), // Symlink-resolved sandbox working dir, canonicalized by the daemon. Paired // with resolvedPath for the workspace-boundary check so a symlinked // workspace prefix does not read as an escape. resolvedWorkingDir: z.string().optional(), skill: z.string().optional(), mode: z.string().optional(), script: z.string().optional(), workingDir: z.string().optional(), allowPrivateNetwork: z.boolean().optional(), networkMode: z.string().optional(), isContainerized: z.boolean().optional(), workspaceRoot: z.string().optional(), // File classifier context (pre-resolved by assistant) fileContext: z .object({ protectedDir: z.string(), deprecatedDir: z.string(), hooksDir: z.string(), pluginsDir: z.string().optional(), toolsDir: z.string().optional(), routesDir: z.string().optional(), workflowsDir: z.string().optional(), monitoringDir: z.string().optional(), actorTokenSigningKeyPath: z.string(), skillSourceDirs: z.array(z.string()), }) .optional(), // Skill classifier context (pre-resolved by assistant) skillMetadata: z .object({ skillId: z.string(), selector: z.string(), versionHash: z.string(), transitiveHash: z.string().optional(), hasInlineExpansions: z.boolean(), isDynamic: z.boolean(), }) .optional(), /** Tool registry default risk level for unknown tools. */ registryDefaultRisk: z.string().optional(), /** Number of credential references attached to this tool invocation. */ credentialRefCount: z.number().int().nonnegative().optional(), /** * For host_file_transfer to_sandbox: the workspace-side destination path * and the sandbox working directory it resolves against. Lets the classifier * escalate writes that land an executable file in a code-injection sink * (tools/routes/hooks/plugins/skills) even though `path` carries the * host-side source. */ transferSandboxDestPath: z.string().optional(), transferSandboxWorkingDir: z.string().optional(), // Symlink-resolved to_sandbox destination, canonicalized by the daemon. resolvedTransferDestPath: z.string().optional(), }); type ClassifyRiskParams = z.infer; // ── Result type ───────────────────────────────────────────────────────────── interface ClassificationResult { risk: string; reason: string; scopeOptions: Array<{ pattern: string; label: string }>; allowlistOptions?: Array<{ label: string; description: string; pattern: string; }>; actionKeys?: string[]; commandCandidates?: string[]; dangerousPatterns?: Array<{ type: string; description: string; text: string; }>; opaqueConstructs?: boolean; isComplexSyntax?: boolean; sandboxAutoApprove?: boolean; /** * Lexically-resolved path arguments from sandbox-auto-approve-eligible * segments. The daemon resolves these through symlinks and re-checks * against the workspace root to catch symlink-based escapes that the * gateway's lexical check cannot detect. */ sandboxPathArgs?: string[]; directoryScopeOptions?: DirectoryScopeOption[]; resolvedPaths?: string[]; matchType: string; } // ── Registry spec lookup ──────────────────────────────────────────────────── /** * Look up a `CommandRiskSpec` by program name, stripping any path prefix * (e.g. `/usr/bin/rm` → `rm`). Uses `Object.hasOwn` so prototype entries * like `toString` don't spuriously match. */ function lookupSpec(program: string): CommandRiskSpec | undefined { const name = program.split("/").pop() ?? program; return Object.hasOwn(DEFAULT_COMMAND_REGISTRY, name) ? DEFAULT_COMMAND_REGISTRY[name as keyof typeof DEFAULT_COMMAND_REGISTRY] : undefined; } // ── Sandbox auto-approve ──────────────────────────────────────────────────── interface SandboxAutoApproveResult { approved: boolean; /** * Absolute path arguments extracted from all auto-approve-eligible segments, * lexically resolved against workingDir. The daemon resolves these through * symlinks (which the gateway cannot do — it has no filesystem access) and * re-checks against the workspace root to catch symlink-based escapes. * Only populated for non-containerized environments. */ pathArgs: string[]; } async function computeSandboxAutoApprove( command: string, workingDir: string, workspaceRoot: string, isContainerized: boolean, ): Promise { const parsed = await cachedParse(command); if (parsed.segments.length === 0) return { approved: false, pathArgs: [] }; if (parsed.hasOpaqueConstructs) return { approved: false, pathArgs: [] }; if (parsed.dangerousPatterns.length > 0) return { approved: false, pathArgs: [] }; const collectedPathArgs: string[] = []; const approved = parsed.segments.every((seg) => { const name = seg.program.split("/").pop() ?? seg.program; const spec: CommandRiskSpec | undefined = Object.hasOwn( DEFAULT_COMMAND_REGISTRY, name, ) ? DEFAULT_COMMAND_REGISTRY[name as keyof typeof DEFAULT_COMMAND_REGISTRY] : undefined; if (!spec?.sandboxAutoApprove) return false; // Containerized: entire fs is workspace, skip path checks if (isContainerized) return true; // Non-containerized: parse args and check all path args against workspace const schema = spec.argSchema ?? {}; const parsedArgs = parseArgs(seg.args, schema); // If no path args, auto-approve (operating on cwd/stdin which is workspace) if (parsedArgs.pathArgs.length === 0) return true; // All path args must resolve within workspace (lexical check — // the daemon re-checks with symlink resolution) return parsedArgs.pathArgs.every((p) => { let resolved: string; if (p === "~" || p.startsWith("~/")) { resolved = p === "~" ? homedir() : join(homedir(), p.slice(2)); } else if (p.startsWith("~")) { return false; } else { resolved = p.startsWith("/") ? p : resolve(workingDir, p); } collectedPathArgs.push(resolved); return isPathWithinRoot(resolved, workspaceRoot); }); }); return { approved, pathArgs: collectedPathArgs }; } // ── Handler ───────────────────────────────────────────────────────────────── export async function handleClassifyRisk( params: ClassifyRiskParams, ): Promise { const tool = params.tool; switch (tool) { // ── Bash / host_bash ────────────────────────────────────────────────── case "bash": case "host_bash": { const command = params.command ?? ""; const workingDir = params.workingDir ?? process.cwd(); const isContainerized = params.isContainerized ?? false; const assessment = await bashRiskClassifier.classify({ command, toolName: tool, workingDir, }); // Derive action keys and build command candidates for trust rule matching. // Command candidates include the raw command, the canonical primary // segment (if different), and the action keys themselves. const analysis = await analyzeShellCommand(command); const actionResult = deriveShellActionKeys(analysis); const actionKeys = actionResult.keys.map((k) => k.key); const candidateSet = new Set(); if (command.trim()) candidateSet.add(command.trim()); if (actionResult.isSimpleAction && actionResult.primarySegment) { const canonical = actionResult.primarySegment.command; if (canonical !== command.trim()) candidateSet.add(canonical); } for (const key of actionKeys) { candidateSet.add(key); } const commandCandidates = [...candidateSet]; // Compute sandbox auto-approve for "bash" tool only let sandboxAutoApprove = false; let sandboxPathArgs: string[] | undefined; if (tool === "bash") { const wsRoot = params.workspaceRoot ?? workingDir; const autoApproveResult = await computeSandboxAutoApprove( command, workingDir, wsRoot, isContainerized, ); sandboxAutoApprove = autoApproveResult.approved; // Return lexically-resolved path args so the daemon can re-check // with symlink resolution (the gateway has no filesystem access). // Only needed for non-containerized — containerized skips path checks. if (!isContainerized && autoApproveResult.pathArgs.length > 0) { sandboxPathArgs = autoApproveResult.pathArgs; } } // Detect complex syntax and collect filesystem-op path args for the // directory scope ladder. Walks every segment left-to-right, tracking // a per-segment "current cwd" that advances through simple `cd ` // segments so relative path args in later segments resolve against // the right directory (e.g. `cd /tmp && rm foo` scopes `foo` to // `/tmp/foo`, not `/foo`). // // For bare filesystem-op segments (filesystemOp=true with no resolved // pathArgs, e.g. a lone `ls`), capture the segment's current cwd as a // pathArg so the emitted scope reflects where that segment actually // ran — not where a later `cd` segment moved to. This keeps // `ls && cd /tmp` scoped to the original workingDir rather than /tmp. // // Wrapper segments (e.g. `sudo rm -rf foo`, `env cp a b`) are unwrapped // before the filesystem-op check so the inner command's spec and // argSchema are used. Wrappers in non-exec modes (e.g. `command -v rm`, // `timeout --help rm`) are NOT unwrapped — they look up or print help // for the inner program rather than executing it, so the inner // program's filesystemOp flag must not apply. const parsed = await cachedParse(command); let isComplexSyntax = false; let hasFilesystemOp = false; const fsPathArgs = new Set(); let trackedCwd = workingDir; for (const seg of parsed.segments) { // Unwrap wrappers iteratively so `sudo sudo rm foo` and // `env sudo rm foo` both resolve to the innermost `rm`. // // Mirror `classifySegment` in bash-risk-classifier.ts: if the // wrapper's first arg is in its `nonExecFlags`, the wrapper is in // lookup/help mode and does NOT execute the inner command — stop // unwrapping so the inner program's spec (and filesystemOp flag) // does not bleed through. let effectiveProgram = seg.program; let effectiveArgs = seg.args; let effectiveSpec = lookupSpec(effectiveProgram); // Use a depth guard instead of a visited set so repeated wrappers // (e.g. `sudo sudo rm foo`, `env env cp a b`) are fully unwrapped. const MAX_WRAPPER_DEPTH = 10; let depth = 0; while (effectiveSpec?.isWrapper && depth < MAX_WRAPPER_DEPTH) { depth++; const isNonExecMode = effectiveSpec.nonExecFlags !== undefined && effectiveArgs.length > 0 && effectiveSpec.nonExecFlags.includes(effectiveArgs[0]!); if (isNonExecMode) break; const inner = getWrappedProgramWithArgs({ program: effectiveProgram, args: effectiveArgs, }); if (!inner) break; effectiveProgram = inner.program; effectiveArgs = inner.args; effectiveSpec = lookupSpec(effectiveProgram); } if (effectiveSpec?.complexSyntax) { isComplexSyntax = true; } if (effectiveSpec?.filesystemOp === true) { hasFilesystemOp = true; const parsedArgs = parseArgs( effectiveArgs, effectiveSpec.argSchema ?? {}, ); if (parsedArgs.pathArgs.length === 0) { // Bare filesystem-op segment (e.g. `ls`, `pwd`). Anchor the // scope to the cwd AS-OF THIS SEGMENT so a later `cd` doesn't // shift the scope away from where the segment actually ran. fsPathArgs.add(trackedCwd); } else { for (const p of parsedArgs.pathArgs) { // Pre-resolve all path args to absolute paths so that // resolvedPaths (used by directory-scoped rule matching) // always contains absolute paths that can be compared // against scope prefixes. if (p === "~") { fsPathArgs.add(homedir()); } else if (p.startsWith("~/")) { fsPathArgs.add(join(homedir(), p.slice(2))); } else if (isAbsolute(p)) { fsPathArgs.add(resolve(p)); } else { fsPathArgs.add(resolve(trackedCwd, p)); } } } } // Advance tracked cwd for simple `cd ` segments. Bail out // (keep the current tracked cwd) for `cd` with no args or `cd -` // (requires runtime state we don't have). Expand `~` and `~/...` // to homedir for consistency with how we resolve path args above. if (effectiveProgram === "cd") { const positionals = effectiveArgs.filter((a) => !a.startsWith("-")); if (positionals.length === 1) { const target = positionals[0]!; if (target === "-") { // Unsupported — requires OLDPWD runtime state. } else if (target === "~") { trackedCwd = homedir(); } else if (target.startsWith("~/")) { trackedCwd = join(homedir(), target.slice(2)); } else if (isAbsolute(target)) { trackedCwd = resolve(target); } else { trackedCwd = resolve(trackedCwd, target); } } } } // Emit directory scope ladder only when at least one segment is a // filesystem op. Pass the ORIGINAL `workingDir` (not the final // `trackedCwd`) — each filesystem-op segment has already contributed // its effective cwd into `fsPathArgs` (bare segments pushed their // at-the-time cwd; non-bare segments pre-resolved relative args against // their at-the-time cwd), so the generator does not need the final // trackedCwd to reflect per-segment cd progress. let directoryScopeOptions: DirectoryScopeOption[] | undefined; if (hasFilesystemOp) { directoryScopeOptions = generateDirectoryScopeOptions({ pathArgs: [...fsPathArgs], workingDir, workspaceRoot: params.workspaceRoot, }); } // Proxied bash risk classification: // - When credentials are attached (credentialRefCount > 0), escalate to // high risk regardless of the underlying assessment. Credentialed // proxied shell sessions carry elevated risk and must not be // downgraded by the general proxied-bash cap. // - For non-credentialed proxied bash, cap High → Medium so proxied // commands don't trigger unnecessary prompts. // Only applies to sandboxed "bash" — host_bash runs on the host machine // and should not have its risk capped. let finalRisk = assessment.riskLevel; let finalReason = assessment.reason; const credentialRefCount = params.credentialRefCount ?? 0; if (tool === "bash" && params.networkMode === "proxied") { if (credentialRefCount > 0) { finalRisk = "high"; finalReason = "Proxied credential session — shell has access to injected credentials"; } else if (finalRisk === "high") { finalRisk = "medium"; } } // Collect resolved paths for directory-scoped rule enforcement. // These are the same resolved args used for scope generation — the // assistant threads them into `findHighestPriorityRule` so scoped // rules match against actual target paths, not just cwd. const resolvedPaths = hasFilesystemOp && fsPathArgs.size > 0 ? [...fsPathArgs] : undefined; return { risk: finalRisk, reason: finalReason, scopeOptions: assessment.scopeOptions, allowlistOptions: assessment.allowlistOptions, actionKeys, commandCandidates, dangerousPatterns: analysis.dangerousPatterns, opaqueConstructs: analysis.hasOpaqueConstructs, isComplexSyntax, sandboxAutoApprove, sandboxPathArgs, directoryScopeOptions, resolvedPaths, matchType: assessment.matchType, }; } // ── File tools ──────────────────────────────────────────────────────── case "file_read": case "file_write": case "file_edit": case "host_file_read": case "host_file_write": case "host_file_edit": case "host_file_transfer": { const filePath = params.path ?? ""; const workingDir = params.workingDir ?? process.cwd(); // Build FileClassificationContext from the IPC params. // When fileContext is not provided, use impossible sentinel paths so the // classifier never produces false-positive escalations (an empty string // for hooksDir would normalize to "/" and match every path). const SENTINEL = "/__vellum_no_context__"; const fileCtx = params.fileContext; const context: FileClassificationContext = { protectedDir: fileCtx?.protectedDir ?? SENTINEL, deprecatedDir: fileCtx?.deprecatedDir ?? SENTINEL, hooksDir: fileCtx?.hooksDir ?? SENTINEL, pluginsDir: fileCtx?.pluginsDir ?? SENTINEL, toolsDir: fileCtx?.toolsDir ?? SENTINEL, routesDir: fileCtx?.routesDir ?? SENTINEL, workflowsDir: fileCtx?.workflowsDir ?? SENTINEL, monitoringDir: fileCtx?.monitoringDir ?? SENTINEL, skillSourceDirs: fileCtx?.skillSourceDirs ?? [], }; const assessment = await fileRiskClassifier.classify( { toolName: tool, filePath, workingDir, resolvedPath: params.resolvedPath, resolvedWorkingDir: params.resolvedWorkingDir, isContainerized: params.isContainerized, transferSandboxDestPath: params.transferSandboxDestPath, transferSandboxWorkingDir: params.transferSandboxWorkingDir, resolvedTransferDestPath: params.resolvedTransferDestPath, }, context, ); // File tools always emit a directory scope ladder: either the filePath's // parent (when provided) or the working directory as the ancestor. const directoryScopeOptions = generateDirectoryScopeOptions({ pathArgs: filePath ? [filePath] : [], workingDir, workspaceRoot: params.workspaceRoot, }); return { risk: assessment.riskLevel, reason: assessment.reason, scopeOptions: assessment.scopeOptions, allowlistOptions: assessment.allowlistOptions, directoryScopeOptions, resolvedPaths: filePath ? [ filePath === "~" ? homedir() : filePath.startsWith("~/") ? join(homedir(), filePath.slice(2)) : isAbsolute(filePath) ? resolve(filePath) : resolve(workingDir, filePath), ] : undefined, matchType: assessment.matchType, }; } // ── Web tools ───────────────────────────────────────────────────────── case "web_fetch": case "network_request": case "web_search": { const assessment = await webRiskClassifier.classify({ toolName: tool, url: params.url, allowPrivateNetwork: params.allowPrivateNetwork, }); return { risk: assessment.riskLevel, reason: assessment.reason, scopeOptions: assessment.scopeOptions, allowlistOptions: assessment.allowlistOptions, matchType: assessment.matchType, }; } // ── Skill tools ─────────────────────────────────────────────────────── case "skill_load": case "scaffold_managed_skill": case "delete_managed_skill": { const assessment = await skillLoadRiskClassifier.classify({ toolName: tool, skillSelector: params.skill, resolvedMetadata: params.skillMetadata ? { skillId: params.skillMetadata.skillId, selector: params.skillMetadata.selector, versionHash: params.skillMetadata.versionHash, transitiveHash: params.skillMetadata.transitiveHash, hasInlineExpansions: params.skillMetadata.hasInlineExpansions, isDynamic: params.skillMetadata.isDynamic, } : undefined, }); return { risk: assessment.riskLevel, reason: assessment.reason, scopeOptions: assessment.scopeOptions, allowlistOptions: assessment.allowlistOptions, matchType: assessment.matchType, }; } // ── Schedule tools ──────────────────────────────────────────────────── case "schedule_create": case "schedule_update": { const assessment = await scheduleRiskClassifier.classify({ toolName: tool, mode: params.mode, script: params.script, }); return { risk: assessment.riskLevel, reason: assessment.reason, scopeOptions: assessment.scopeOptions, allowlistOptions: assessment.allowlistOptions, matchType: assessment.matchType, }; } // ── Fallback — registered tools use their registry default, truly // unknown tools get the "Unknown tool" warning. ────────────────── default: { const hasRegistryDefault = params.registryDefaultRisk != null; return { risk: params.registryDefaultRisk ?? "medium", reason: hasRegistryDefault ? "" : `Unknown tool: ${tool}`, scopeOptions: [], matchType: hasRegistryDefault ? "registry" : "unknown", }; } } } // ── Route export ──────────────────────────────────────────────────────────── export const riskClassificationRoutes: IpcRoute[] = [ { method: "classify_risk", schema: ClassifyRiskSchema, handler: (params?: Record) => { return handleClassifyRisk(params as ClassifyRiskParams); }, }, ];