import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { Type } from "@sinclair/typebox"; import path from "node:path"; import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; import type { ProcessSession, SessionStdin } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js"; import { logWarn } from "../logger.js"; import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js"; import { addSession, appendOutput, createSessionSlug, markExited, tail, } from "./bash-process-registry.js"; import { buildDockerExecArgs, chunkString, clampWithDefault, killSession, readEnvInt, } from "./bash-tools.shared.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; // Security: Blocklist of environment variables that could alter execution flow // or inject code when running on non-sandboxed hosts (Gateway/Node). const DANGEROUS_HOST_ENV_VARS = new Set([ "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "NODE_OPTIONS", "NODE_PATH", "PYTHONPATH", "PYTHONHOME", "RUBYLIB", "PERL5LIB", "BASH_ENV", "ENV", "GCONV_PATH", "IFS", "SSLKEYLOGFILE", ]); const DANGEROUS_HOST_ENV_PREFIXES = ["DYLD_", "LD_"]; // Centralized sanitization helper. // Throws an error if dangerous variables or PATH modifications are detected on the host. export function validateHostEnv(env: Record): void { for (const key of Object.keys(env)) { const upperKey = key.toUpperCase(); // 1. Block known dangerous variables (Fail Closed) if (DANGEROUS_HOST_ENV_PREFIXES.some((prefix) => upperKey.startsWith(prefix))) { throw new Error( `Security Violation: Environment variable '${key}' is forbidden during host execution.`, ); } if (DANGEROUS_HOST_ENV_VARS.has(upperKey)) { throw new Error( `Security Violation: Environment variable '${key}' is forbidden during host execution.`, ); } // 2. Strictly block PATH modification on host // Allowing custom PATH on the gateway/node can lead to binary hijacking. if (upperKey === "PATH") { throw new Error( "Security Violation: Custom 'PATH' variable is forbidden during host execution.", ); } } } export const DEFAULT_MAX_OUTPUT = clampWithDefault( readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000, ); export const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault( readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"), 30_000, 1_000, 200_000, ); export const DEFAULT_PATH = process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export const DEFAULT_NOTIFY_TAIL_CHARS = 400; const DEFAULT_NOTIFY_SNIPPET_CHARS = 180; export const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000; export const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130_000; const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10_000; const APPROVAL_SLUG_LENGTH = 8; export const execSchema = Type.Object({ command: Type.String({ description: "Shell command to execute" }), workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })), env: Type.Optional(Type.Record(Type.String(), Type.String())), yieldMs: Type.Optional( Type.Number({ description: "Milliseconds to wait before backgrounding (default 10000)", }), ), background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })), timeout: Type.Optional( Type.Number({ description: "Timeout in seconds (optional, kills process on expiry)", }), ), pty: Type.Optional( Type.Boolean({ description: "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)", }), ), elevated: Type.Optional( Type.Boolean({ description: "Run on the host with elevated permissions (if allowed)", }), ), host: Type.Optional( Type.String({ description: "Exec host (sandbox|gateway|node).", }), ), security: Type.Optional( Type.String({ description: "Exec security mode (deny|allowlist|full).", }), ), ask: Type.Optional( Type.String({ description: "Exec ask mode (off|on-miss|always).", }), ), node: Type.Optional( Type.String({ description: "Node id/name for host=node.", }), ), }); type PtyExitEvent = { exitCode: number; signal?: number }; type PtyListener = (event: T) => void; type PtyHandle = { pid: number; write: (data: string | Buffer) => void; onData: (listener: PtyListener) => void; onExit: (listener: PtyListener) => void; }; type PtySpawn = ( file: string, args: string[] | string, options: { name?: string; cols?: number; rows?: number; cwd?: string; env?: Record; }, ) => PtyHandle; export type ExecProcessOutcome = { status: "completed" | "failed"; exitCode: number | null; exitSignal: NodeJS.Signals | number | null; durationMs: number; aggregated: string; timedOut: boolean; reason?: string; }; export type ExecProcessHandle = { session: ProcessSession; startedAt: number; pid?: number; promise: Promise; kill: () => void; }; export function normalizeExecHost(value?: string | null): ExecHost | null { const normalized = value?.trim().toLowerCase(); if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { return normalized; } return null; } export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { const normalized = value?.trim().toLowerCase(); if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { return normalized; } return null; } export function normalizeExecAsk(value?: string | null): ExecAsk | null { const normalized = value?.trim().toLowerCase(); if (normalized === "off" || normalized === "on-miss" || normalized === "always") { return normalized as ExecAsk; } return null; } export function renderExecHostLabel(host: ExecHost) { return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; } export function normalizeNotifyOutput(value: string) { return value.replace(/\s+/g, " ").trim(); } function compactNotifyOutput(value: string, maxChars = DEFAULT_NOTIFY_SNIPPET_CHARS) { const normalized = normalizeNotifyOutput(value); if (!normalized) { return ""; } if (normalized.length <= maxChars) { return normalized; } const safe = Math.max(1, maxChars - 1); return `${normalized.slice(0, safe)}…`; } export function applyShellPath(env: Record, shellPath?: string | null) { if (!shellPath) { return; } const entries = shellPath .split(path.delimiter) .map((part) => part.trim()) .filter(Boolean); if (entries.length === 0) { return; } const merged = mergePathPrepend(env.PATH, entries); if (merged) { env.PATH = merged; } } function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") { if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) { return; } const sessionKey = session.sessionKey?.trim(); if (!sessionKey) { return; } session.exitNotified = true; const exitLabel = session.exitSignal ? `signal ${session.exitSignal}` : `code ${session.exitCode ?? 0}`; const output = compactNotifyOutput( tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); if (status === "completed" && !output && session.notifyOnExitEmptySuccess !== true) { return; } const summary = output ? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}` : `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`; enqueueSystemEvent(summary, { sessionKey }); requestHeartbeatNow({ reason: `exec:${session.id}:exit` }); } export function createApprovalSlug(id: string) { return id.slice(0, APPROVAL_SLUG_LENGTH); } export function resolveApprovalRunningNoticeMs(value?: number) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_APPROVAL_RUNNING_NOTICE_MS; } if (value <= 0) { return 0; } return Math.floor(value); } export function emitExecSystemEvent( text: string, opts: { sessionKey?: string; contextKey?: string }, ) { const sessionKey = opts.sessionKey?.trim(); if (!sessionKey) { return; } enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey }); requestHeartbeatNow({ reason: "exec-event" }); } export async function runExecProcess(opts: { command: string; // Execute this instead of `command` (which is kept for display/session/logging). // Used to sanitize safeBins execution while preserving the original user input. execCommand?: string; workdir: string; env: Record; sandbox?: BashSandboxConfig; containerWorkdir?: string | null; usePty: boolean; warnings: string[]; maxOutput: number; pendingMaxOutput: number; notifyOnExit: boolean; notifyOnExitEmptySuccess?: boolean; scopeKey?: string; sessionKey?: string; timeoutSec: number; onUpdate?: (partialResult: AgentToolResult) => void; }): Promise { const startedAt = Date.now(); const sessionId = createSessionSlug(); let child: ChildProcessWithoutNullStreams | null = null; let pty: PtyHandle | null = null; let stdin: SessionStdin | undefined; const execCommand = opts.execCommand ?? opts.command; const spawnFallbacks = [ { label: "no-detach", options: { detached: false }, }, ]; const handleSpawnFallback = (err: unknown, fallback: { label: string }) => { const errText = formatSpawnError(err); const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); opts.warnings.push(warning); }; const spawnShellChild = async ( shell: string, shellArgs: string[], ): Promise => { const { child: spawned } = await spawnWithFallback({ argv: [shell, ...shellArgs, execCommand], options: { cwd: opts.workdir, env: opts.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }, fallbacks: spawnFallbacks, onFallback: handleSpawnFallback, }); return spawned as ChildProcessWithoutNullStreams; }; // `exec` does not currently accept tool-provided stdin content. For non-PTY runs, // keeping stdin open can cause commands like `wc -l` (or safeBins-hardened segments) // to block forever waiting for input, leading to accidental backgrounding. // For interactive flows, callers should use `pty: true` (stdin kept open). const maybeCloseNonPtyStdin = () => { if (opts.usePty) { return; } try { // Signal EOF immediately so stdin-only commands can terminate. child?.stdin?.end(); } catch { // ignore stdin close errors } }; if (opts.sandbox) { const { child: spawned } = await spawnWithFallback({ argv: [ "docker", ...buildDockerExecArgs({ containerName: opts.sandbox.containerName, command: execCommand, workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, env: opts.env, tty: opts.usePty, }), ], options: { cwd: opts.workdir, env: process.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }, fallbacks: spawnFallbacks, onFallback: handleSpawnFallback, }); child = spawned as ChildProcessWithoutNullStreams; stdin = child.stdin; maybeCloseNonPtyStdin(); } else if (opts.usePty) { const { shell, args: shellArgs } = getShellConfig(); try { const ptyModule = (await import("@lydell/node-pty")) as unknown as { spawn?: PtySpawn; default?: { spawn?: PtySpawn }; }; const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; if (!spawnPty) { throw new Error("PTY support is unavailable (node-pty spawn not found)."); } pty = spawnPty(shell, [...shellArgs, execCommand], { cwd: opts.workdir, env: opts.env, name: process.env.TERM ?? "xterm-256color", cols: 120, rows: 30, }); stdin = { destroyed: false, write: (data, cb) => { try { pty?.write(data); cb?.(null); } catch (err) { cb?.(err as Error); } }, end: () => { try { const eof = process.platform === "win32" ? "\x1a" : "\x04"; pty?.write(eof); } catch { // ignore EOF errors } }, }; } catch (err) { const errText = String(err); const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); opts.warnings.push(warning); child = await spawnShellChild(shell, shellArgs); stdin = child.stdin; } } else { const { shell, args: shellArgs } = getShellConfig(); child = await spawnShellChild(shell, shellArgs); stdin = child.stdin; maybeCloseNonPtyStdin(); } const session = { id: sessionId, command: opts.command, scopeKey: opts.scopeKey, sessionKey: opts.sessionKey, notifyOnExit: opts.notifyOnExit, notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true, exitNotified: false, child: child ?? undefined, stdin, pid: child?.pid ?? pty?.pid, startedAt, cwd: opts.workdir, maxOutputChars: opts.maxOutput, pendingMaxOutputChars: opts.pendingMaxOutput, totalOutputChars: 0, pendingStdout: [], pendingStderr: [], pendingStdoutChars: 0, pendingStderrChars: 0, aggregated: "", tail: "", exited: false, exitCode: undefined as number | null | undefined, exitSignal: undefined as NodeJS.Signals | number | null | undefined, truncated: false, backgrounded: false, } satisfies ProcessSession; addSession(session); let settled = false; let timeoutTimer: NodeJS.Timeout | null = null; let timeoutFinalizeTimer: NodeJS.Timeout | null = null; let timedOut = false; const timeoutFinalizeMs = 1000; let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null; const settle = (outcome: ExecProcessOutcome) => { if (settled) { return; } settled = true; resolveFn?.(outcome); }; const finalizeTimeout = () => { if (session.exited) { return; } markExited(session, null, "SIGKILL", "failed"); maybeNotifyOnExit(session, "failed"); const aggregated = session.aggregated.trim(); const reason = `Command timed out after ${opts.timeoutSec} seconds`; settle({ status: "failed", exitCode: null, exitSignal: "SIGKILL", durationMs: Date.now() - startedAt, aggregated, timedOut: true, reason: aggregated ? `${aggregated}\n\n${reason}` : reason, }); }; const onTimeout = () => { timedOut = true; killSession(session); if (!timeoutFinalizeTimer) { timeoutFinalizeTimer = setTimeout(() => { finalizeTimeout(); }, timeoutFinalizeMs); } }; if (opts.timeoutSec > 0) { timeoutTimer = setTimeout(() => { onTimeout(); }, opts.timeoutSec * 1000); } const emitUpdate = () => { if (!opts.onUpdate) { return; } const tailText = session.tail || session.aggregated; const warningText = opts.warnings.length ? `${opts.warnings.join("\n")}\n\n` : ""; opts.onUpdate({ content: [{ type: "text", text: warningText + (tailText || "") }], details: { status: "running", sessionId, pid: session.pid ?? undefined, startedAt, cwd: session.cwd, tail: session.tail, }, }); }; const handleStdout = (data: string) => { const str = sanitizeBinaryOutput(data.toString()); for (const chunk of chunkString(str)) { appendOutput(session, "stdout", chunk); emitUpdate(); } }; const handleStderr = (data: string) => { const str = sanitizeBinaryOutput(data.toString()); for (const chunk of chunkString(str)) { appendOutput(session, "stderr", chunk); emitUpdate(); } }; if (pty) { const cursorResponse = buildCursorPositionResponse(); pty.onData((data) => { const raw = data.toString(); const { cleaned, requests } = stripDsrRequests(raw); if (requests > 0) { for (let i = 0; i < requests; i += 1) { pty.write(cursorResponse); } } handleStdout(cleaned); }); } else if (child) { child.stdout.on("data", handleStdout); child.stderr.on("data", handleStderr); } const promise = new Promise((resolve) => { resolveFn = resolve; const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => { if (timeoutTimer) { clearTimeout(timeoutTimer); } if (timeoutFinalizeTimer) { clearTimeout(timeoutFinalizeTimer); } const durationMs = Date.now() - startedAt; const wasSignal = exitSignal != null; const isSuccess = code === 0 && !wasSignal && !timedOut; const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; markExited(session, code, exitSignal, status); maybeNotifyOnExit(session, status); if (!session.child && session.stdin) { session.stdin.destroyed = true; } if (settled) { return; } const aggregated = session.aggregated.trim(); if (!isSuccess) { const reason = timedOut ? `Command timed out after ${opts.timeoutSec} seconds` : wasSignal && exitSignal ? `Command aborted by signal ${exitSignal}` : code === null ? "Command aborted before exit code was captured" : `Command exited with code ${code}`; const message = aggregated ? `${aggregated}\n\n${reason}` : reason; settle({ status: "failed", exitCode: code ?? null, exitSignal: exitSignal ?? null, durationMs, aggregated, timedOut, reason: message, }); return; } settle({ status: "completed", exitCode: code ?? 0, exitSignal: exitSignal ?? null, durationMs, aggregated, timedOut: false, }); }; if (pty) { pty.onExit((event) => { const rawSignal = event.signal ?? null; const normalizedSignal = rawSignal === 0 ? null : rawSignal; handleExit(event.exitCode ?? null, normalizedSignal); }); } else if (child) { child.once("close", (code, exitSignal) => { handleExit(code, exitSignal); }); child.once("error", (err) => { if (timeoutTimer) { clearTimeout(timeoutTimer); } if (timeoutFinalizeTimer) { clearTimeout(timeoutFinalizeTimer); } markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); const aggregated = session.aggregated.trim(); const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); settle({ status: "failed", exitCode: null, exitSignal: null, durationMs: Date.now() - startedAt, aggregated, timedOut, reason: message, }); }); } }); return { session, startedAt, pid: session.pid ?? undefined, promise, kill: () => killSession(session), }; }