/** Bounded, argv-based local process execution for the Gateway. */ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { lstat } from "node:fs/promises"; import type { GatewayCommandSecurityConfig } from "../config.ts"; import type { GatewayPrincipal, GatewayResult } from "../contracts.ts"; import { createLocalGatewayPrincipal } from "../principal.ts"; import { GatewayPolicy, GatewayPolicyError } from "../policy.ts"; import { gatewayError, gatewayOk } from "../result.ts"; import { canonicalizeWorkspaceChild, canonicalizeWorkspacePath, utf8Bytes } from "../state-paths.ts"; import { parseGatewayPrincipal, parseGatewayResult } from "../validation.ts"; const DEFAULT_OUTPUT_BYTES = 1024 * 1024; const TERMINATION_GRACE_MS = 150; const TERMINATION_FORCE_MS = 500; const DEFAULT_COMMAND_POLICY: GatewayCommandSecurityConfig = { default: "allow", allow: [], confirm: [], deny: [], autoAllowReadonly: null, }; class GatewayExecServiceError extends Error { readonly code: string; constructor(code: string, message: string) { super(message); this.name = "GatewayExecServiceError"; this.code = code; } } export interface ExecServiceOptions { policy?: GatewayPolicy; commandPolicy?: Partial; security?: { commands?: Partial }; workspaceRoot?: string; principal?: GatewayPrincipal; maxOutputBytes?: number; trustedFullAccess?: boolean; } export interface ExecRunInput { command?: string; argv?: readonly string[]; args?: readonly string[]; cwd?: string; workspaceId?: string; workspace?: string; timeoutMs?: number; maxOutputBytes?: number; env?: NodeJS.ProcessEnv; readonly?: boolean; signal?: AbortSignal; principal?: GatewayPrincipal; requestId?: string; } export interface ExecRunData { command: string; args: string[]; cwd: string; stdout: string; stderr: string; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean; outputTruncated: boolean; } export interface ExecActionRequest extends ExecRunInput { action: "run"; } export type GatewayCommandDecision = "allow" | "confirm" | "deny"; function mergeCommandPolicy(input: Partial | undefined): GatewayCommandSecurityConfig { return { ...DEFAULT_COMMAND_POLICY, ...(input ?? {}), allow: [...(input?.allow ?? DEFAULT_COMMAND_POLICY.allow)], confirm: [...(input?.confirm ?? DEFAULT_COMMAND_POLICY.confirm)], deny: [...(input?.deny ?? DEFAULT_COMMAND_POLICY.deny)], }; } function globMatches(pattern: string, value: string): boolean { let expression = "^"; for (const character of pattern) { if (character === "*") expression += ".*"; else if (character === "?") expression += "."; else expression += character.replace(/[\\^$.*+()[\]{}|]/g, "\\$&"); } try { return new RegExp(`${expression}$`, "s").test(value); } catch { return false; } } function ruleMatches(pattern: string, identity: string): boolean { const trimmed = pattern.trim(); if (!trimmed) return false; if (trimmed === identity) return true; if (trimmed.includes("*") && !trimmed.includes(".*")) return globMatches(trimmed, identity); try { return new RegExp(trimmed, "s").test(identity); } catch { return globMatches(trimmed, identity); } } export function evaluateCommandPolicy( identity: string, policy: Partial | undefined, readonly = false, ): GatewayCommandDecision { const config = mergeCommandPolicy(policy); if (config.deny.some((pattern) => ruleMatches(pattern, identity))) return "deny"; if (config.confirm.some((pattern) => ruleMatches(pattern, identity))) return "confirm"; if (config.allow.some((pattern) => ruleMatches(pattern, identity))) return "allow"; if (readonly && config.autoAllowReadonly === true) return "allow"; return config.default; } function requestOptions(principal: GatewayPrincipal, requestId: string | undefined, startedAt: number) { return { requestId: requestId && requestId.trim() ? requestId : randomUUID(), principalId: principal.id, startedAt: new Date(startedAt).toISOString(), durationMs: Math.max(0, Date.now() - startedAt), }; } function errorCode(error: unknown): string { if (error instanceof GatewayPolicyError) return error.code; if (error instanceof Error && error.name === "GatewayValidationError") return "invalid_principal"; if (error instanceof Error && /escapes the registered workspace|outside the registered workspace/.test(error.message)) return "policy_denied"; const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined; if (typeof code === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(code)) return code.toLowerCase(); return "internal_error"; } function errorResult( error: unknown, principal: GatewayPrincipal, requestId: string | undefined, startedAt: number, data?: T, status: "failed" | "cancelled" | "lost" = "failed", ): GatewayResult { const base = gatewayError({ code: errorCode(error), message: error instanceof Error ? error.message : String(error), }, { ...requestOptions(principal, requestId, startedAt), status }); return data === undefined ? base as GatewayResult : parseGatewayResult({ ...base, data }); } function appendOutput(buffers: Buffer[], chunk: unknown, state: { bytes: number }, maximum: number): boolean { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); const remaining = Math.max(0, maximum - state.bytes); if (remaining === 0) return buffer.byteLength > 0; const accepted = buffer.byteLength <= remaining ? buffer : buffer.subarray(0, remaining); if (accepted.byteLength > 0) buffers.push(accepted); state.bytes += accepted.byteLength; return accepted.byteLength < buffer.byteLength; } function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function processGroupRunning(pid: number): boolean { if (pid <= 0) return false; try { process.kill(-pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } } async function terminateProcessTree(child: ChildProcess): Promise { const pid = child.pid; if (!pid) return; if (process.platform === "win32") { await new Promise((resolve) => { let settled = false; const finish = () => { if (!settled) { settled = true; resolve(); } }; let killer: ChildProcess; try { killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { finish(); return; } killer.once("error", finish); killer.once("close", finish); const timer = setTimeout(() => { try { killer.kill(); } catch { /* taskkill already exited */ } finish(); }, TERMINATION_FORCE_MS); timer.unref?.(); }); if (child.exitCode === null) { try { child.kill("SIGKILL"); } catch { /* the leader already exited */ } } return; } try { process.kill(-pid, "SIGTERM"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") { try { child.kill("SIGTERM"); } catch { /* process exited during cleanup */ } } } await delay(TERMINATION_GRACE_MS); if (processGroupRunning(pid)) { try { process.kill(-pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") { try { child.kill("SIGKILL"); } catch { /* process exited during cleanup */ } } } } else if (child.exitCode === null) { try { child.kill("SIGKILL"); } catch { /* process exited during cleanup */ } } await delay(TERMINATION_FORCE_MS); } function validateArgv(input: ExecRunInput): { command: string; args: string[] } { if (input.argv !== undefined && input.command !== undefined) throw new GatewayExecServiceError("invalid_input", "provide either argv or command, not both"); const argv = input.argv === undefined ? input.command === undefined ? [] : [input.command, ...(input.args ?? [])] : [...input.argv]; if (argv.length === 0 || typeof argv[0] !== "string" || argv[0].trim() === "") throw new GatewayExecServiceError("invalid_input", "command is required"); if (input.argv !== undefined && input.args !== undefined) throw new GatewayExecServiceError("invalid_input", "args cannot be combined with argv"); for (const value of argv) { if (typeof value !== "string" || value.includes("\0")) throw new GatewayExecServiceError("invalid_input", "command arguments must be strings without NUL bytes"); } return { command: argv[0]!, args: argv.slice(1) }; } export class ExecService { readonly policy?: GatewayPolicy; readonly commandPolicy: GatewayCommandSecurityConfig; private readonly workspaceRoot?: string; private readonly defaultPrincipal: GatewayPrincipal; private readonly configuredMaxOutputBytes: number; private readonly trustedFullAccess: boolean; constructor(options: ExecServiceOptions = {}) { this.policy = options.policy; this.commandPolicy = mergeCommandPolicy(options.commandPolicy ?? options.security?.commands); this.trustedFullAccess = options.trustedFullAccess === true; this.workspaceRoot = options.workspaceRoot === undefined ? undefined : canonicalizeWorkspacePath(options.workspaceRoot); this.defaultPrincipal = options.principal === undefined ? createLocalGatewayPrincipal("gateway-exec", this.workspaceRoot ? { workspacePath: this.workspaceRoot } : {}) : parseGatewayPrincipal(options.principal); const policyMaximum = options.policy?.limits.maxOutputBytes ?? DEFAULT_OUTPUT_BYTES; const configured = options.maxOutputBytes ?? policyMaximum; if (!Number.isSafeInteger(configured) || configured < 1 || configured > policyMaximum) throw new GatewayExecServiceError("invalid_input", `maxOutputBytes must be in [1, ${policyMaximum}]`); this.configuredMaxOutputBytes = configured; } async run(input: ExecRunInput): Promise> { const startedAt = Date.now(); let principal: GatewayPrincipal; try { principal = this.principal(input.principal); } catch (error) { const fallback = this.defaultPrincipal; return errorResult(error, fallback, input.requestId, startedAt); } try { const { command, args } = validateArgv(input); const identity = [command, ...args].join(" "); const maximumCommand = this.policy?.limits.maxCommandBytes ?? 64 * 1024; if (utf8Bytes(identity) > maximumCommand) throw new GatewayExecServiceError("bounds_exceeded", `command exceeds ${maximumCommand} bytes`); if (this.policy) this.policy.checkCommand(identity); const requestedCwdForPolicy = input.cwd ?? input.workspace ?? principal.workspacePath ?? this.workspaceRoot ?? process.cwd(); const workspaceForPolicy = input.workspaceId ?? input.workspace ?? this.workspaceRoot ?? principal.workspaceId ?? principal.workspacePath ?? requestedCwdForPolicy; const trustedDefault = this.trustedFullAccess && this.policy?.isTrustedWorkspace(workspaceForPolicy) ? { ...this.commandPolicy, default: "allow" as const } : this.commandPolicy; const decision = evaluateCommandPolicy(identity, trustedDefault, input.readonly === true); if (decision === "deny") throw new GatewayPolicyError("command is denied by Gateway policy", "command_denied"); if (decision === "confirm") throw new GatewayPolicyError("command requires confirmation", "confirmation_required"); const timeoutMs = input.timeoutMs ?? this.policy?.limits.maxExecTimeoutMs ?? 5 * 60 * 1000; if (this.policy) this.policy.checkTimeout(timeoutMs); else if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new GatewayExecServiceError("invalid_input", "timeoutMs must be a positive safe integer"); const maximumOutput = input.maxOutputBytes ?? this.configuredMaxOutputBytes; if (!Number.isSafeInteger(maximumOutput) || maximumOutput < 1 || maximumOutput > this.configuredMaxOutputBytes) { throw new GatewayExecServiceError("bounds_exceeded", `maxOutputBytes must be in [1, ${this.configuredMaxOutputBytes}]`); } const requestedCwd = input.cwd ?? input.workspace ?? (input.workspaceId === undefined ? principal.workspacePath ?? this.workspaceRoot ?? process.cwd() : "."); const workspace = input.workspaceId ?? input.workspace ?? this.workspaceRoot ?? principal.workspaceId ?? principal.workspacePath ?? requestedCwd; const cwd = this.policy ? await this.policy.assertPath(principal, workspace, requestedCwd, "exec") : canonicalizeWorkspaceChild(workspace, requestedCwd); const cwdStat = await lstat(cwd); if (!cwdStat.isDirectory()) throw new GatewayExecServiceError("invalid_input", "execution cwd must be a directory"); if (input.signal?.aborted) return errorResult(new GatewayExecServiceError("cancelled", "execution aborted"), principal, input.requestId, startedAt, undefined, "cancelled"); const outputState = { bytes: 0 }; const stdoutBuffers: Buffer[] = []; const stderrBuffers: Buffer[] = []; const data = (): ExecRunData => ({ command, args: [...args], cwd, stdout: Buffer.concat(stdoutBuffers).toString("utf8"), stderr: Buffer.concat(stderrBuffers).toString("utf8"), exitCode: null, signal: null, timedOut: false, outputTruncated: false, }); return await new Promise>((resolve) => { let child: ChildProcess; try { child = spawn(command, args, { cwd, env: input.env === undefined ? process.env : { ...process.env, ...input.env }, shell: false, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); } catch (error) { resolve(errorResult(new GatewayExecServiceError("spawn_error", error instanceof Error ? error.message : String(error)), principal, input.requestId, startedAt, data())); return; } let settled = false; let timedOut = false; let outputTruncated = false; let cancellationRequested = false; let spawnError: unknown; let termination: Promise | undefined; const timer = setTimeout(() => { if (settled) return; timedOut = true; termination = terminateProcessTree(child); void termination; }, timeoutMs); timer.unref?.(); const onAbort = () => { if (settled) return; cancellationRequested = true; termination = terminateProcessTree(child); void termination; }; input.signal?.addEventListener("abort", onAbort, { once: true }); const settle = async (code: number | null, signal: NodeJS.Signals | null): Promise => { if (settled) return; settled = true; clearTimeout(timer); input.signal?.removeEventListener("abort", onAbort); if (termination) { try { await termination; } catch (error) { if (spawnError === undefined) spawnError = new GatewayExecServiceError("cleanup_failed", error instanceof Error ? error.message : String(error)); } } const resultData = data(); resultData.exitCode = code; resultData.signal = signal; resultData.timedOut = timedOut; resultData.outputTruncated = outputTruncated; if (cancellationRequested) { resolve(errorResult(new GatewayExecServiceError("cancelled", "execution cancelled"), principal, input.requestId, startedAt, resultData, "cancelled")); } else if (timedOut) { resolve(errorResult(new GatewayExecServiceError("timeout", `execution timed out after ${timeoutMs}ms`), principal, input.requestId, startedAt, resultData)); } else if (outputTruncated) { resolve(errorResult(new GatewayExecServiceError("output_limit", `execution output exceeded ${maximumOutput} bytes`), principal, input.requestId, startedAt, resultData)); } else if (spawnError !== undefined) { resolve(errorResult(spawnError, principal, input.requestId, startedAt, resultData)); } else if (code === 0) { resolve(gatewayOk(resultData, requestOptions(principal, input.requestId, startedAt))); } else { resolve(errorResult(new GatewayExecServiceError("exit_code", `command exited with code ${String(code)}`), principal, input.requestId, startedAt, resultData)); } }; const terminate = () => { if (!termination) termination = terminateProcessTree(child); void termination; }; child.stdout?.on("data", (chunk: unknown) => { if (appendOutput(stdoutBuffers, chunk, outputState, maximumOutput)) { outputTruncated = true; terminate(); } }); child.stderr?.on("data", (chunk: unknown) => { if (appendOutput(stderrBuffers, chunk, outputState, maximumOutput)) { outputTruncated = true; terminate(); } }); child.once("error", (error) => { spawnError = error; }); child.once("close", (code, signal) => { void settle(code, signal); }); }); } catch (error) { return errorResult(error, principal, input.requestId, startedAt); } } runSync(input: ExecRunInput): Promise> { return this.run(input); } handle(request: ExecActionRequest): Promise> { return this.run(request); } private principal(value: GatewayPrincipal | undefined): GatewayPrincipal { return value === undefined ? this.defaultPrincipal : parseGatewayPrincipal(value); } } export const GatewayExecService = ExecService; export const createExecService = (options?: ExecServiceOptions): ExecService => new ExecService(options); export const createGatewayExecService = createExecService;