/** * Layer 3 — non-interactive environment hardening (R-BASH-10/11/12/13). * * Pure, no I/O: given an env and a command, produce the hardened pair. Kept * separate from guard.ts so the "user's env wins" rule (R-BASH-13) and the stdin * wrapping are testable without spawning anything. * * `GIT_PAGER=cat` and `GIT_EDITOR=true` alone remove the largest real-world hang * class, so this layer is the highest value-per-line part of BashGuard. It also * applies to the user's own `!` commands (R-BASH-17), where blocking never does. */ /** R-BASH-10's table. Values are strings because that is what a process env holds. */ export const HARDENED_ENV: Readonly> = { // git fails instead of prompting for credentials on a terminal we do not have. GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo", SSH_ASKPASS: "echo", // The two that matter most: no pager on `git log`, no editor on `git commit`. GIT_PAGER: "cat", PAGER: "cat", GIT_EDITOR: "true", EDITOR: "true", VISUAL: "true", DEBIAN_FRONTEND: "noninteractive", CI: "1", NO_COLOR: "1", // Discourages TUI rendering. Also what makes many tools skip progress bars. TERM: "dumb", NPM_CONFIG_YES: "true", // Unbuffered python output is what makes stall detection meaningful (layer 4). PYTHONUNBUFFERED: "1", }; /** * Apply the hardened env over an existing one without overwriting anything the * user already set (R-BASH-13). * * A variable present but empty counts as set: `PAGER=` is a deliberate way to * disable a pager, and overwriting it with `cat` would be a behaviour change the * user did not ask for. */ export function hardenEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = { ...env }; for (const [key, value] of Object.entries(HARDENED_ENV)) { if (out[key] === undefined) out[key] = value; } return out; } /** * Wrap a command so its stdin is /dev/null (R-BASH-11). * * This is the strongest single mitigation: anything that reads stdin gets EOF and * either errors or proceeds with a default, but either way it terminates. pi * already spawns with `stdio[0]: "ignore"` when the shell takes the command on * argv, but when `commandTransport === "stdin"` the child's stdin *is* the pipe * carrying the script, so the explicit redirect is required for correctness. * * The braces are a group, not a subshell, so `cd` and variable assignments still * affect the rest of the script — a subshell would silently change semantics. * The trailing newline before `}` is what makes a command ending in a comment * (`ls # note`) not swallow the closing brace. */ export function closeStdin(command: string, platform: string = process.platform): string { if (command.trim().length === 0) return command; if (platform === "win32") return `${command} < NUL`; return `{ ${command}\n} < /dev/null`; } export interface HardenOptions { /** Apply the R-BASH-10 env table. */ env: boolean; /** Apply the R-BASH-11 stdin redirect. */ stdin: boolean; /** Overridable for tests. */ platform?: string; } export interface HardenTarget { command: string; env: NodeJS.ProcessEnv; } /** * Apply layer 3 to a command/env pair. * * Ordering matters for R-BASH-12: when composed with another extension's * spawnHook, ours runs last so the stdin redirect wraps whatever that hook * produced. Wrapping first would leave the other hook's additions outside the * redirect and reading from the real stdin. */ export function applyHardening(target: HardenTarget, options: HardenOptions): HardenTarget { return { command: options.stdin ? closeStdin(target.command, options.platform) : target.command, env: options.env ? hardenEnv(target.env) : target.env, }; }