// the PID+mount-namespace sandbox primitives, shared by the async shell path // (action/mcp/shell.ts `spawnShell` / `runSandboxed`) and the sync MCP git path // (action/utils/shell.ts `$`). changes here should be reflected in // wiki/security.md and docs/security.mdx. import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, statSync, symlinkSync, } from "node:fs"; import { devNull, userInfo } from "node:os"; import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import { AGENT_BIN_DIR_RELPATH, HOOK_ASSET_TMPDIR_RELPATHS, OPENCODE_BIN_DIR_RELPATH, } from "../agents/nativeFsDenies.ts"; import { log } from "./log.ts"; export type SandboxMethod = "unshare" | "sudo-unshare" | "userns-unshare" | "none"; /** `setpriv` args that leave a root process no capabilities and no way to regain one. */ const DROP_ALL_PRIVS = [ "--no-new-privs", "--bounding-set", "-all", "--inh-caps", "-all", "--ambient-caps", "-all", ]; /** cached result of sandbox capability check */ let detectedSandboxMethod: SandboxMethod | undefined; /** stderr from the userns probe, surfaced in the CI-gate error so an operator sees * which wall they actually hit rather than our guess at one. */ let usernsProbeError: string | undefined; /** the userns probe's own stderr, for the CI-gate error assembled in spawnShell. */ export function getUsernsProbeError(): string | undefined { return usernsProbeError; } /** get the current sandbox method (for testing/diagnostics) */ export function getSandboxMethod(): SandboxMethod { return detectSandboxMethod(); } /** * true when only root can change what `path` names: it, every parent and any symlink on the way * are root-owned and writable by nobody else. a sticky directory still protects its root-owned * entries, which is how `/nix/store` (1775) is laid out. */ function isRootControlled(path: string): boolean { try { const stat = lstatSync(path); if (stat.uid !== 0) return false; if (stat.isSymbolicLink()) { return ( isRootControlled(dirname(path)) && isRootControlled(resolve(dirname(path), readlinkSync(path))) ); } const sticky = stat.isDirectory() && (stat.mode & 0o1000) !== 0; if ((stat.mode & 0o022) !== 0 && !sticky) return false; return dirname(path) === path || isRootControlled(dirname(path)); } catch { return false; } } let rootControlledDirs: string[] | undefined; /** * the PATH directories the sandboxed command cannot write, from the PATH the run started with. a * sticky parent is fine, but anyone can add a name to a sticky entry, and the setup shell looks * `mount` and `su` up on these, so the entry itself must be writable by root alone. */ function getRootControlledDirs(): string[] { rootControlledDirs ??= (process.env.PATH ?? "") .split(delimiter) .filter( (dir) => isAbsolute(dir) && isRootControlled(dir) && (statSync(dir).mode & 0o022) === 0 ); return rootControlledDirs; } /** * the absolute path of `name`, for anything run outside the namespace once the agent may have * planted a file. the sandboxed command can write PATH directories that sit ahead of `/usr/bin` * (`/usr/local/bin` is 777 on a GitHub-hosted runner), so a bare name runs whatever it put there. * resolving early is not enough, since the file can be replaced later: only location holds. * returns the path as found, not its realpath: a multicall binary dispatches on its own name. */ export function trustedBinary(name: string): string { const path = getRootControlledDirs() .map((dir) => join(dir, name)) .find((candidate) => isRootControlled(candidate)); if (!path) throw new Error(`no \`${name}\` on PATH in a directory only root can write`); return path; } /** the full PATH, restored for sandboxed commands, and the narrowed one it was swapped for. */ let fullPath: string | undefined; let narrowedPath: string | undefined; /** * from here on this process, and everything it starts outside the namespace, finds tools only where * the sandboxed command cannot write. a harness finds `git` and `rg` by name, Claude's gate and the * askpass script find `node`, and an install finds `tar`, all with the secrets in reach, so a planted * file in a writable PATH directory would run with them. sandboxed commands get the full PATH back * in `buildSandboxInvocation`, so they can still install there, as `pip install` does on most runners. * * `agent-bin` holds what may otherwise live only in such a directory, and every CLI we install. `node` * is a copy: a symlink follows whatever later replaces the original, and a hard link shares its inode, * leaving only the kernel's refusal to write to a running binary. `npm` and `npx` serve installs that * finish before the agent starts and a repo's own MCP servers, whose commands are not ours to pin. this * runs before the first sandboxed command, which could otherwise create these paths itself. */ export function narrowProcessPath(tempDir: string): void { if (process.env.CI !== "true" || process.platform !== "linux") return; const bin = join(tempDir, AGENT_BIN_DIR_RELPATH); mkdirSync(bin, { recursive: true }); mkdirSync(join(tempDir, OPENCODE_BIN_DIR_RELPATH), { recursive: true }); copyFileSync(process.execPath, join(bin, "node")); const nodeDir = dirname(process.execPath); for (const name of ["npm", "npx"]) { if (existsSync(join(nodeDir, name))) symlinkSync(join(nodeDir, name), join(bin, name)); } fullPath ??= process.env.PATH; narrowedPath = [bin, ...getRootControlledDirs()].join(delimiter); process.env.PATH = narrowedPath; // the same door by config: a harness's own `git` would run a hook a planted ~/.gitconfig names. // the sandbox's environment does not carry this, so a sandboxed git still reads the customer's. process.env.GIT_CONFIG_GLOBAL = devNull; // and by rc file: a `bash -c` on a socket stdin sources ~/.bashrc unless it is nested, and Claude // starts its gate that way on every tool call. process.env.SHLVL = "2"; } /** the PATH a sandboxed command runs with: `path` with the narrowed part put back to the full one. */ function sandboxedPath(path: string): string { return narrowedPath && fullPath ? path.replace(narrowedPath, () => fullPath ?? "") : path; } /** `dir` first on sandboxed commands' PATH alone, for a tool only they run. */ export function prependSandboxedPath(dir: string): void { const path = fullPath ?? process.env.PATH ?? ""; if (path.split(delimiter).includes(dir)) return; if (fullPath) fullPath = `${dir}${delimiter}${path}`; else process.env.PATH = `${dir}${delimiter}${path}`; } /** run `method`'s exact invocation, so a probe cannot pass where the real spawn would fail. */ function probeMethod(method: Exclude) { const invocation = buildSandboxInvocation({ command: "true", seal: true, env: { PATH: process.env.PATH }, method, // the `.git` bind would pin `resolveRepoRoot` before the workspace is settled gitWritable: true, }); return spawnSync(invocation.bin, invocation.args, { env: invocation.env, timeout: 10_000, stdio: ["ignore", "ignore", "pipe"], }); } /** detect which sandbox method is available on this system */ export function detectSandboxMethod(): SandboxMethod { if (detectedSandboxMethod !== undefined) { return detectedSandboxMethod; } // only attempt in CI environments - sandbox has overhead and is primarily for untrusted code if (process.env.CI !== "true") { detectedSandboxMethod = "none"; log.debug("sandbox disabled (CI !== true)"); return "none"; } // try unprivileged unshare first (works where the job already runs as root). // each probe runs its branch's exact invocation, so a runner missing any part of // it falls through to the next method or the actionable CI-gate error instead of // failing every command. try { if (probeMethod("unshare").status === 0) { detectedSandboxMethod = "unshare"; log.debug("PID namespace isolation enabled (unprivileged unshare)"); return "unshare"; } } catch { // continue to try sudo } // sudo unshare (works on GHA runners) try { if (probeMethod("sudo-unshare").status === 0) { detectedSandboxMethod = "sudo-unshare"; log.debug("PID namespace isolation enabled (sudo unshare)"); return "sudo-unshare"; } } catch { // continue to try userns } // userns-nested PID namespace: the fallback for containerized / Kubernetes // self-hosted runners (no host CAP_SYS_ADMIN, no passwordless sudo). creating a // user namespace first grants CAP_SYS_ADMIN scoped to that userns — enough for the // nested --pid and the mount namespace — without any host capability. // // deliberately NOT --mount-proc: every OCI runtime masks paths under /proc, which // makes the visible procfs not "fully visible", so mnt_already_visible() refuses a // fresh procfs from a non-initial userns. that is true of EVERY k8s pod, so // --mount-proc would fail-close this path everywhere it is meant to help. // it is not needed: two independent locks block the /proc//environ read this // sandbox exists to stop, and only the second survives in a pod — // 1. the PID namespace (needs the procfs remount, impossible here) // 2. the userns credential boundary — a process in a child userns lacks // CAP_SYS_PTRACE in the target's userns, so ptrace_may_access denies the read // even for the same uid on a fully visible target // PROC_CLEANUP still runs in spawnShell as a best-effort attempt at lock 1, which // succeeds on runners whose /proc is unmasked. see wiki/userns-sandbox.md. // // the probe exercises the FULL sealed pipeline including the `setpriv` cap-drop that // spawnShell exec's unconditionally — so a runner with capable unshare but a // missing/incapable setpriv (e.g. busybox on Alpine images, which lacks // --ambient-caps) fails over to "none" + the actionable error rather than caching // userns-unshare and then breaking every shell command on the unrunnable setpriv. try { const result = probeMethod("userns-unshare"); if (result.status === 0) { detectedSandboxMethod = "userns-unshare"; log.debug("PID namespace isolation enabled (unprivileged userns unshare)"); return "userns-unshare"; } // keep the probe's own words: the walls (seccomp / CAP_SETFCAP / node kernel / // a missing binary) are indistinguishable from the outside, so a guessed cause // in the CI-gate error sends operators to change a setting that cannot help them. // spawnSync does not throw on ENOENT — it returns status null with no stderr and // the reason in `error`, which is the whole clue when unshare/setpriv is absent. usernsProbeError = result.stderr?.toString().trim() || result.error?.message; } catch (error) { // no sandbox available: a required binary is missing or sits somewhere writable usernsProbeError = error instanceof Error ? error.message : String(error); } detectedSandboxMethod = "none"; log.info("PID namespace isolation not available"); return "none"; } // strip inherited proc mount that sits underneath --mount-proc's overlay. // --mount-proc mounts fresh proc on top, but `umount /proc` peels it off and exposes the // host's proc with all host PIDs — allowing /proc//environ exfiltration. // drain every proc layer, then a clean mount gives only sandbox PIDs. `-R` is // load-bearing: GitHub runners carry a binfmt_misc submount under the host's proc, // so a plain `umount /proc` on that layer fails EBUSY and leaves it in place. // on unprivileged systems where umount fails, --mount-proc still provides isolation // (the agent also can't umount in that case). const PROC_CLEANUP = "for i in 1 2 3 4; do grep -q ' - proc ' /proc/self/mountinfo 2>/dev/null || break; " + "umount -R /proc 2>/dev/null || break; done; mount -t proc proc /proc 2>/dev/null;"; // block container-runtime sockets that would otherwise grant a PID-namespace // escape: `docker run --pid=host --privileged busybox cat /proc//environ` // reads the parent action process's env (which contains user secrets) even // though the sandbox itself is unsharing PIDs. GHA `ubuntu-latest` puts the // `runner` user in the `docker` group by default, so the socket is reachable // without sudo. bind-mounting /dev/null on top inside the sandbox's mount // namespace makes the socket unreachable from sandboxed shells without // touching the host runner (so it doesn't break user workflow steps that // run before/after pullfrog and legitimately need docker). same trick for // podman/containerd/cri-o sockets — all silent-fail if the path is missing. const SOCKET_CLEANUP = [ "/var/run/docker.sock", "/run/docker.sock", "/var/run/podman/podman.sock", "/run/podman/podman.sock", "/run/containerd/containerd.sock", "/var/run/crio/crio.sock", ] .map((path) => `mount --bind /dev/null ${path} 2>/dev/null;`) .join(" "); // extend the mount-namespace isolation that PROC_CLEANUP and SOCKET_CLEANUP // already establish. these mounts hide pullfrog-managed on-disk secrets, // block env-injection into subsequent workflow steps, and make git's // code-execution config read-only inside the bash subprocess. // // 1. tmpfs over /var/lib/pullfrog/ — codex auth.json and any future // pullfrog-managed on-disk secret live here (see action/utils/codexHome.ts // PULLFROG_DATA_DIR). opencode's internal auth module runs in the agent // process outside this namespace and reads the real file via bypass of // external_directory; bash sees an empty tmpfs. mkdir -p the path // first so the tmpfs always engages — without that, runs without // CODEX_AUTH_JSON wouldn't have bootstrapped the dir, the mountpoint // wouldn't exist, and `mount -t tmpfs` would silent-fail. precreate // keeps the overlay active for any future on-disk secret that lands // under /var/lib/pullfrog regardless of which install path created it. // 2. tmpfs over $RUNNER_TEMP/_runner_file_commands/ — anything bash writes // to $GITHUB_ENV / $GITHUB_PATH / $GITHUB_OUTPUT / $GITHUB_STATE lands in // a per-namespace tmpfs that the GHA runner never sees. our own action // process writes core.setOutput / core.saveState outside the namespace, // so legitimate outputs are unaffected. requires RUNNER_TEMP to be set. // 3. self-bind + remount-ro on the ENTIRE /.git directory. // A blanket ro-bind is free: nothing legitimately writes .git from the // agent's bash — the MCP git tool's commits go through `$()`, which runs in // its OWN gitWritable sandbox (see below), not this shell namespace, so it's // unaffected; the agent's bash `git` can only read. It // robustly covers every code-exec surface an enumerated list would miss // — .git/config, .git/config.worktree, .git/modules/*/config (all carry // core.hooksPath / filter / alias / credential.helper exec vectors), // plus .git/hooks/* and .git/info/attributes. Prevents agent-planted git // filters / hooks from firing in downstream workflow steps (the threat // survives ASKPASS because hooks fire after auth and our $git() uses -c // core.hooksPath to override its own hooks, but downstream `git` // invocations in later steps DON'T get that protection — see // wiki/security.md "Filesystem Sandbox"). CONSEQUENCE: .git/info/exclude // (a legit per-repo ignore file) is now read-only too — accepted, the // narrow earlier bind list left it writable. Does not cover `~/.gitconfig` // or `/etc/gitconfig` — see "Scope and Limitations" in wiki/security.md. // the `gitWritable` profile OMITS this bind: the MCP git path (action/utils/ // shell.ts `$`) and the dependency installers run sandboxed so a commit/ // checkout/merge, or husky's `prepare`, can write .git, and their isolation // comes from the PID namespace + filtered env, not a read-only .git. a // planted hook/filter still fires there, but in a fresh PID namespace with // no action PID to read and no secret in its env. // 4. every directory from the tmpdir down to a sealed path, bound onto itself // (`sealedParentMounts`), then `cd "$PWD"`. the shell starts in the cwd // it inherited, which stays on the mount underneath anything bound over it // or above it, so without the `cd` a relative path walks past these binds. // // these mounts run as root inside the namespace (before `exec su -p` drops // to runner). after the drop, runner has no CAP_SYS_ADMIN in the host, so // can't undo from outside. intra-namespace sudo CAN undo them — a sealed // command (`no_new_privs`, see SpawnParams.seal) cannot regain root; `enabled` // mode keeps sudo on trust. see wiki/security.md "sudo inside the sandbox". // // in the unprivileged-unshare path (a job running as root: Docker --privileged // test environments and root self-hosted images), the command holds // CAP_SYS_ADMIN and could `umount` these unless sealed. seals: sudo-unshare via // the `su -p` drop plus no_new_privs, and unshare / userns-unshare via the // `setpriv` cap-drop (plus MNT_LOCKED on every inherited mount for userns). // // repoDir is interpolated by the action process from resolveRepoRoot() — // NOT $PWD — because spawnShell's cwd is agent-controllable via // `working_directory` AND the action's process.cwd() may have been chdir'd // to payload.cwd (monorepo subdir support in main.ts). using either would // let the agent bypass the .git/* binds. resolveRepoRoot pins the actual // repo root once at startup via $GITHUB_WORKSPACE or `git rev-parse`. function buildFsMounts(options: { gitWritable: boolean; shimWritable: boolean }): string { return [ sealedParentMounts(), `mkdir -p /var/lib/pullfrog 2>/dev/null;`, `mount -t tmpfs tmpfs /var/lib/pullfrog 2>/dev/null;`, `[ -n "$RUNNER_TEMP" ] && [ -d "$RUNNER_TEMP/_runner_file_commands" ] && mount -t tmpfs tmpfs "$RUNNER_TEMP/_runner_file_commands" 2>/dev/null;`, // the .git ro-bind (point 3 above). omitted for `gitWritable` so sandboxed // MCP git can write .git; isolation there is the PID namespace, not a ro .git. options.gitWritable ? "" : gitReadOnlyMount(), // the corepack shim dir sits FIRST on PATH (utils/packageManager.ts) and // PATH survives filterEnv, so a writable pm-bin lets sandboxed code plant // a `git`/`pnpm` that a later UNSANDBOXED spawn resolves with the parent // env. read-only closes that for every sandboxed command at once. // // the path is interpolated HERE rather than read as `$PULLFROG_TEMP_DIR` // inside the sandbox: that name is not on the `filterEnv` safe list, so in // restricted mode it expands to empty and the mount silently no-ops in the // one tier it exists to protect. `$RUNNER_TEMP` above is safe only because // `RUNNER_` is an allowed prefix. options.shimWritable ? "" : shimMount(), hookMounts(), `cd "$PWD" 2>/dev/null;`, ] .filter(Boolean) .join(" "); } /** pullfrog-managed hook/gate scripts live in the per-run HOME (tmpdir) and * execute with the agent's full environment. the agent runs the sandboxed * shell as the uid that OWNS them, so the no_new_privs seal does not stop it * rewriting one to capture that env — a leak a token-exfil run found in July. * ro-bind each (the names are deterministic under PULLFROG_TEMP_DIR; the action * writes them before the first shell call) so the sandboxed shell cannot. the * action process writes them OUTSIDE this namespace, so our own writes are * unaffected. native FS tools are denied separately (claude settings deny, * opencode external_directory deny) since they run outside this sandbox. * `sealedParentMounts` stops the agent renaming an owned ancestor dir away. */ function hookMounts(): string { const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) return ""; return HOOK_ASSET_TMPDIR_RELPATHS.map((rel) => { const path = join(tempDir, rel).replace(/'/g, "'\\''"); return `[ -e '${path}' ] && mount --bind '${path}' '${path}' 2>/dev/null && mount -o remount,bind,ro '${path}' 2>/dev/null;`; }).join(" "); } const SHIM_DIR_RELPATH = "pm-bin"; /** * a read-only bind does not stop the sealed command renaming a directory above it: the mount goes * with the rename, and the action process, in the other namespace, then finds whatever was put at * the old path. a mountpoint cannot be renamed or removed (EBUSY), so every directory down to a * sealed path is bound onto itself, parents first, from the first one whose parent only root can * write: the tmpdir under /tmp, higher where $TMPDIR is $RUNNER_TEMP. --rbind keeps what is mounted * below, and a userns refuses a plain bind of a tree holding locked mounts. */ function sealedParentMounts(): string { const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) return ""; const parents = [SHIM_DIR_RELPATH, ...HOOK_ASSET_TMPDIR_RELPATHS].flatMap((rel) => { const parts = rel.split("/"); return parts.map((_, depth) => join(tempDir, ...parts.slice(0, depth))); }); for (let dir = tempDir; dirname(dir) !== "/" && !isRootControlled(dirname(dir)); ) { dir = dirname(dir); parents.push(dir); } return [...new Set(parents)] .sort((a, b) => a.length - b.length) .map(shQuote) .map((dir) => `[ -d ${dir} ] && mount --rbind ${dir} ${dir} 2>/dev/null;`) .join(" "); } /** the `.git` read-only bind. resolves the repo root only here: `resolveRepoRoot` memoizes * its first answer, and a git call needing no bind can run before the workspace is settled * (`runFixture` sets it after cloning), which pinned the wrong root and left `.git` writable. */ function gitReadOnlyMount(): string { // shell-escape via single-quote wrap; bash interprets \' as the escape // for a single quote inside a single-quoted string by closing-and-reopening. // repoDir paths in practice are GHA workspace paths (no quotes), but the // escape keeps us correct against arbitrary user-configured workspaces. const escaped = resolveRepoRoot().replace(/'/g, "'\\''"); const bind = `[ -e '${escaped}/.git' ] && mount --bind '${escaped}/.git' '${escaped}/.git' 2>/dev/null && mount -o remount,bind,ro '${escaped}/.git' 2>/dev/null;`; if (!gitDirId) return bind; // after the bind, which pins whatever it found: once a directory is mounted, a rename takes the mount with it return `${bind} [ "$(stat -L -c %d:%i '${escaped}/.git' 2>/dev/null)" = ${gitDirId} ] || { echo ${shQuote(CHECKOUT_MOVED)} >&2; exit 1; };`; } export const CHECKOUT_MOVED = "the checkout or a directory above it was moved during this run, so its .git can no longer be trusted"; let gitDirId: string | undefined; function readGitDirId(): string | undefined { const gitDir = join(resolveRepoRoot(), ".git"); const result = spawnSync("stat", ["-L", "-c", "%d:%i", gitDir], { encoding: "utf-8" }); return result.status === 0 ? result.stdout.trim() : undefined; } /** called once the checkout exists, ahead of the setup hook and installs, which run repo code. */ export function recordGitDir(): void { gitDirId ??= readGitDirId(); } /** * the `.git` bind goes with a rename of any directory above it, leaving the next shell free to write * the real one, and whatever is then put at the old path is what `$git` and a harness's own `git` * find. binding those directories too would stop the rename, but it makes the checkout its own mount, * and pnpm then moves its store into the checkout (wiki/security.md). so what uses `.git` next refuses * one that is no longer the one recorded. */ export function checkoutMoved(): boolean { return gitDirId !== undefined && readGitDirId() !== gitDirId; } /** read-only bind for the corepack shim dir, or "" when this run has no tmpdir. */ function shimMount(): string { const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) return ""; const dir = join(tempDir, SHIM_DIR_RELPATH).replace(/'/g, "'\\''"); return `[ -d '${dir}' ] && mount --bind '${dir}' '${dir}' 2>/dev/null && mount -o remount,bind,ro '${dir}' 2>/dev/null;`; } /** locate the repo root once at action startup. process.cwd() is unreliable * because main.ts may `process.chdir(payload.cwd)` for monorepo subdirs; * the agent's `working_directory` shell param also moves spawn cwd. we need * the actual git working tree root for the .git/* binds. memoized for the * lifetime of the action process. */ let _repoRoot: string | undefined; export function resolveRepoRoot(): string { if (_repoRoot) return _repoRoot; const fromEnv = process.env.GITHUB_WORKSPACE; if (fromEnv) { _repoRoot = fromEnv; return _repoRoot; } // fallback: `git rev-parse --show-toplevel` from process.cwd(). only used // outside GHA (local dev, custom runners). swallow errors and fall back // to process.cwd() so we never throw from the shell-tool init path. try { _repoRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd(), stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8", }).stdout?.trim(); } catch { // intentionally empty — fall through to process.cwd() } if (!_repoRoot) _repoRoot = process.cwd(); return _repoRoot; } /** shell-escape one argument by single-quote wrapping (close-and-reopen on `'`). */ function shQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } /** `exec`-prefixed shell command that runs `cmd args` as the sandbox's final * process, so the inner process's exit status propagates out through the * unshare/sudo/bash exec chain unchanged. */ function execCommand(cmd: string, args: string[]): string { return `exec ${shQuote(cmd)} ${args.map(shQuote).join(" ")}`; } /** * Assemble the `(bin, args, env)` that launches `command` inside the detected * PID+mount-namespace sandbox. Shared by `spawnShell` (async `spawn`, the shell * tool + lifecycle hooks) and `spawnGitSandboxedSync` (sync `spawnSync`, the MCP * git path) so both reuse ONE copy of the security-critical invocation. * * `seal` sets no_new_privs + drops caps so a root command inside the namespace * cannot re-elevate and unmount the protective mounts (false only for trusted * `shell: enabled`). `gitWritable` drops the `.git` read-only bind so sandboxed * `git` can write `.git`. */ export function buildSandboxInvocation(params: { command: string; seal: boolean; env: Record; method: Exclude; gitWritable: boolean; shimWritable?: boolean; }): { bin: string; args: string[]; env: Record } { const bash = trustedBinary("bash"); const setpriv = shQuote(trustedBinary("setpriv")); const unshare = trustedBinary("unshare"); // everything ahead of the seal runs on PATH directories the command cannot write. stdin closes // first because bash sources ~/.bashrc when stdin is a socket, as node's default pipe is, and // `su` starts a bash of its own that cannot be handed --norc. const setup = `exec /environ` (the OIDC mint ticket, the job * token, customer `env:` secrets) and sees only the caller's already-filtered * env. The git profile omits the `.git` read-only bind so commits still write * `.git`. The caller passes the detected `method` (already known non-"none") so * we never sandbox when no namespace is available. */ export function spawnGitSandboxedSync(params: { cmd: string; args: string[]; env: Record; cwd: string | undefined; encoding: BufferEncoding; maxBuffer: number; method: Exclude; }): SpawnSyncReturns { const invocation = buildSandboxInvocation({ command: execCommand(params.cmd, params.args), seal: true, env: params.env, method: params.method, gitWritable: true, }); return spawnSync(invocation.bin, invocation.args, { stdio: ["ignore", "pipe", "pipe"], encoding: params.encoding, cwd: params.cwd, env: invocation.env, maxBuffer: params.maxBuffer, }); } /** * Build the `spawn` options for an ASYNC caller (e.g. `action/utils/subprocess.ts` * `spawn`) that run `cmd args` inside the sandbox, or `cmd args` themselves when no PID * namespace is available (where there is no namespace there is also no agent shell to * plant the config the sandbox defends against). `shimWritable` lifts the read-only bind * on the corepack shim dir for `corepack enable`, the one command that writes it. * The dependency installer routes its `pnpm/npm install` through this so * repo `postinstall` scripts run in a fresh PID namespace (cannot read the action * process's /proc) with the already-filtered env. `.git` stays writable: hook * managers (husky's `prepare`, lefthook, `pre-commit install`) write `.git/config` * or `.git/hooks`, and the read-only bind failed them silently (npm still exits 0). */ export function buildSandboxedCommand(params: { cmd: string; args: string[]; env: Record; shimWritable?: boolean; }): { cmd: string; args: string[]; env: Record } { const method = detectSandboxMethod(); if (method === "none") { const path = params.env.PATH; return { cmd: params.cmd, args: params.args, env: path ? { ...params.env, PATH: sandboxedPath(path) } : params.env, }; } const invocation = buildSandboxInvocation({ command: execCommand(params.cmd, params.args), seal: true, env: params.env, method, gitWritable: true, shimWritable: params.shimWritable ?? false, }); return { cmd: invocation.bin, args: invocation.args, env: invocation.env }; }