import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { addAllowlistEntry, type ExecAsk, type ExecSecurity, buildEnforcedShellCommand, evaluateShellAllowlist, maxAsk, minSecurity, recordAllowlistUse, requiresExecApproval, resolveAllowAlwaysPatterns, resolveExecApprovals, } from "../infra/exec-approvals.js"; import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, emitExecSystemEvent, normalizeNotifyOutput, runExecProcess, } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; export type ProcessGatewayAllowlistParams = { command: string; workdir: string; env: Record; pty: boolean; timeoutSec?: number; defaultTimeoutSec: number; security: ExecSecurity; ask: ExecAsk; safeBins: Set; safeBinProfiles: Readonly>; agentId?: string; sessionKey?: string; scopeKey?: string; warnings: string[]; notifySessionKey?: string; approvalRunningNoticeMs: number; maxOutput: number; pendingMaxOutput: number; trustedSafeBinDirs?: ReadonlySet; }; export type ProcessGatewayAllowlistResult = { execCommandOverride?: string; pendingResult?: AgentToolResult; }; export async function processGatewayAllowlist( params: ProcessGatewayAllowlistParams, ): Promise { const approvals = resolveExecApprovals(params.agentId, { security: params.security, ask: params.ask, }); const hostSecurity = minSecurity(params.security, approvals.agent.security); const hostAsk = maxAsk(params.ask, approvals.agent.ask); const askFallback = approvals.agent.askFallback; if (hostSecurity === "deny") { throw new Error("exec denied: host=gateway security=deny"); } const allowlistEval = evaluateShellAllowlist({ command: params.command, allowlist: approvals.allowlist, safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, cwd: params.workdir, env: params.env, platform: process.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); const allowlistMatches = allowlistEval.allowlistMatches; const analysisOk = allowlistEval.analysisOk; const allowlistSatisfied = hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; let enforcedCommand: string | undefined; if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) { const enforced = buildEnforcedShellCommand({ command: params.command, segments: allowlistEval.segments, platform: process.platform, }); if (!enforced.ok || !enforced.command) { throw new Error(`exec denied: allowlist execution plan unavailable (${enforced.reason})`); } enforcedCommand = enforced.command; } const obfuscation = detectCommandObfuscation(params.command); if (obfuscation.detected) { logInfo(`exec: obfuscation detected (gateway): ${obfuscation.reasons.join(", ")}`); params.warnings.push(`⚠️ Obfuscated command detected: ${obfuscation.reasons.join("; ")}`); } const recordMatchedAllowlistUse = (resolvedPath?: string) => { if (allowlistMatches.length === 0) { return; } const seen = new Set(); for (const match of allowlistMatches) { if (seen.has(match.pattern)) { continue; } seen.add(match.pattern); recordAllowlistUse(approvals.file, params.agentId, match, params.command, resolvedPath); } }; const hasHeredocSegment = allowlistEval.segments.some((segment) => segment.argv.some((token) => token.startsWith("<<")), ); const requiresHeredocApproval = hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment; const requiresAsk = requiresExecApproval({ ask: hostAsk, security: hostSecurity, analysisOk, allowlistSatisfied, }) || requiresHeredocApproval || obfuscation.detected; if (requiresHeredocApproval) { params.warnings.push( "Warning: heredoc execution requires explicit approval in allowlist mode.", ); } if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; void (async () => { let decision: string | null = null; try { decision = await requestExecApprovalDecisionForHost({ approvalId, command: params.command, workdir: params.workdir, host: "gateway", security: hostSecurity, ask: hostAsk, agentId: params.agentId, resolvedPath, sessionKey: params.sessionKey, }); } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, { sessionKey: params.notifySessionKey, contextKey, }, ); return; } let approvedByAsk = false; let deniedReason: string | null = null; if (decision === "deny") { deniedReason = "user-denied"; } else if (!decision) { if (obfuscation.detected) { deniedReason = "approval-timeout (obfuscation-detected)"; } else if (askFallback === "full") { approvedByAsk = true; } else if (askFallback === "allowlist") { if (!analysisOk || !allowlistSatisfied) { deniedReason = "approval-timeout (allowlist-miss)"; } else { approvedByAsk = true; } } else { deniedReason = "approval-timeout"; } } else if (decision === "allow-once") { approvedByAsk = true; } else if (decision === "allow-always") { approvedByAsk = true; if (hostSecurity === "allowlist") { const patterns = resolveAllowAlwaysPatterns({ segments: allowlistEval.segments, cwd: params.workdir, env: params.env, platform: process.platform, }); for (const pattern of patterns) { if (pattern) { addAllowlistEntry(approvals.file, params.agentId, pattern); } } } } if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) { deniedReason = deniedReason ?? "allowlist-miss"; } if (deniedReason) { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, { sessionKey: params.notifySessionKey, contextKey, }, ); return; } recordMatchedAllowlistUse(resolvedPath ?? undefined); let run: Awaited> | null = null; try { run = await runExecProcess({ command: params.command, execCommand: enforcedCommand, workdir: params.workdir, env: params.env, sandbox: undefined, containerWorkdir: null, usePty: params.pty, warnings: params.warnings, maxOutput: params.maxOutput, pendingMaxOutput: params.pendingMaxOutput, notifyOnExit: false, notifyOnExitEmptySuccess: false, scopeKey: params.scopeKey, sessionKey: params.notifySessionKey, timeoutSec: effectiveTimeout, }); } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, { sessionKey: params.notifySessionKey, contextKey, }, ); return; } markBackgrounded(run.session); let runningTimer: NodeJS.Timeout | null = null; if (params.approvalRunningNoticeMs > 0) { runningTimer = setTimeout(() => { emitExecSystemEvent( `Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`, { sessionKey: params.notifySessionKey, contextKey }, ); }, params.approvalRunningNoticeMs); } const outcome = await run.promise; if (runningTimer) { clearTimeout(runningTimer); } const output = normalizeNotifyOutput( tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; const summary = output ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey }); })(); return { pendingResult: { content: [ { type: "text", text: `${warningText}Approval required (id ${approvalSlug}). ` + "Approve to run; updates will arrive after completion.", }, ], details: { status: "approval-pending", approvalId, approvalSlug, expiresAtMs, host: "gateway", command: params.command, cwd: params.workdir, }, }, }; } if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) { throw new Error("exec denied: allowlist miss"); } recordMatchedAllowlistUse(allowlistEval.segments[0]?.resolution?.resolvedPath); return { execCommandOverride: enforcedCommand }; }