Ctrl K
ComunidadGuías…

utils/sandbox.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<SandboxMethod, "none">) {
  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/<pid>/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/<pid>/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/<pid>/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 <repoRoot>/.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<string, string | undefined>;
  method: Exclude<SandboxMethod, "none">;
  gitWritable: boolean;
  shimWritable?: boolean;
}): { bin: string; args: string[]; env: Record<string, string | undefined> } {
  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 </dev/null; export PATH=${shQuote(getRootControlledDirs().join(delimiter))}; ` +
    `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${buildFsMounts({ gitWritable: params.gitWritable, shimWritable: params.shimWritable ?? false })}`;
  // the command's own PATH comes back only inside the seal
  const command = params.env.PATH
    ? `export PATH=${shQuote(sandboxedPath(params.env.PATH))}; ${params.command}`
    : params.command;
  const sealedBash = `${shQuote(bash)} -c ${shQuote(command)}`;

  if (params.method === "unshare") {
    // the job itself is root here, so sealing must drop capabilities, not just
    // block setuid — the same seal as the userns branch below.
    const sealed = params.seal
      ? `exec ${setpriv} ${DROP_ALL_PRIVS.join(" ")} ${sealedBash}`
      : command;
    return {
      bin: unshare,
      args: ["--pid", "--fork", "--mount-proc", bash, "--norc", "-c", `${setup} ${sealed}`],
      env: params.env,
    };
  }

  if (params.method === "userns-unshare") {
    // --user --map-root-user creates a user namespace and makes us root inside it,
    // which grants the in-userns CAP_SYS_ADMIN that the nested --pid and --mount
    // require — no host capability needed. the setup (PROC_CLEANUP / SOCKET_CLEANUP /
    // fsMounts) runs with that in-userns root, then `setpriv` drops the entire
    // bounding set + sets no_new_privs before the untrusted command (the userns
    // analog of the sudo path's `su -p` seal). uid 0-in-userns maps to the action's
    // real uid outside, so created files are owned correctly and no su drop is needed.
    //
    // TWO mechanisms keep the untrusted command off the mounts, and the cap-drop is
    // only the first. it does NOT survive a nested userns: the kernel hands out a
    // full capability set on every CLONE_NEWUSER regardless of the bounding set, so
    // the sealed command can re-acquire CAP_SYS_ADMIN in a userns of its own. what
    // stops it there is that ns_capable() only walks ancestor-ward — a descendant
    // userns confers nothing over the mount namespace its parent owns — and that
    // `unshare --mount` marks every inherited mount MNT_LOCKED. the cap-drop still
    // earns its place: without it the command holds CAP_SYS_ADMIN in the userns that
    // OWNS this mount namespace, where the binds we just made are not locked.
    //
    // --mount is EXPLICIT and load-bearing: it is normally implied by --mount-proc,
    // which detectSandboxMethod deliberately omits (see there). without it there is
    // no mount namespace at all and fsMounts silently no-ops — the tmpfs over
    // /var/lib/pullfrog and the .git ro-bind just vanish, taking the filesystem
    // sandbox with them while every other guarantee still appears to hold.
    // --map-current-user is NOT a substitute for --map-root-user: mapping a non-zero
    // uid makes execve use non-root capability rules, so the setup lands with an
    // empty effective set and every mount fails.
    return {
      bin: unshare,
      args: [
        "--user",
        "--map-root-user",
        "--pid",
        "--fork",
        "--mount",
        bash,
        "--norc",
        "-c",
        `${setup} exec ${setpriv} ${DROP_ALL_PRIVS.join(" ")} ${sealedBash}`,
      ],
      env: params.env,
    };
  }

  // sudo-unshare
  const envArgs: string[] = [];
  for (const [k, v] of Object.entries(params.env)) {
    if (v !== undefined) {
      envArgs.push(`${k}=${v}`);
    }
  }
  // drop back to original user after PROC_CLEANUP / FS_MOUNTS so files aren't
  // owned by root. sudo is only needed for unshare + the mount setup; the
  // actual command should run as the normal user to avoid ownership
  // mismatches with files created by the Node.js parent process.
  const username = userInfo().username;
  // after `su`, which needs its own setuid privileges; the unprivileged user
  // cannot drop the bounding set, but no_new_privs alone blocks every setuid path.
  const sealed = params.seal ? `exec ${setpriv} --no-new-privs ${sealedBash}` : command;
  return {
    bin: trustedBinary("sudo"),
    args: [
      trustedBinary("env"),
      ...envArgs,
      unshare,
      "--pid",
      "--fork",
      "--mount-proc",
      bash,
      "--norc",
      "-c",
      `${setup} exec su -p -s ${shQuote(bash)} ${username} -c ${shQuote(sealed)}`,
    ],
    // env passes through `sudo env K=V`, and every binary is absolute, so sudo needs no PATH.
    env: {},
  };
}

/**
 * Run `git` (or a caller-chosen git binary) through the PID+mount-namespace
 * sandbox, synchronously, so a repo-planted hook / filter / config that fires on
 * the MCP server's own git call runs in a FRESH PID namespace — it cannot read
 * the action process's `/proc/<pid>/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<string, string | undefined>;
  cwd: string | undefined;
  encoding: BufferEncoding;
  maxBuffer: number;
  method: Exclude<SandboxMethod, "none">;
}): SpawnSyncReturns<string> {
  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<string, string | undefined>;
  shimWritable?: boolean;
}): { cmd: string; args: string[]; env: Record<string, string | undefined> } {
  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 };
}