Ctrl K
ComunidadGuías…

utils/shell.ts

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

Archivo original ↗Preguntar a KiBot
import { spawnSync } from "node:child_process";
import { basename } from "node:path";
import { getSandboxMethod, spawnGitSandboxedSync } from "./sandbox.ts";
import { type EnvMode, resolveEnv } from "./secrets.ts";

interface ShellOptions {
  cwd?: string;
  encoding?:
    | "utf-8"
    | "utf8"
    | "ascii"
    | "base64"
    | "base64url"
    | "hex"
    | "latin1"
    | "ucs-2"
    | "ucs2"
    | "utf16le";
  log?: boolean;
  /**
   * env mode: "restricted" (default) filters secrets, "inherit" passes full env,
   * or provide a custom env object (merged with restricted base)
   */
  env?: EnvMode;
  onError?: (result: { status: number; stdout: string; stderr: string }) => void;
}

/**
 * Node's `spawnSync` default is 1MB, and hitting it is silent: the child is
 * SIGTERM'd, `status` comes back `null`, and the truncated buffer looks like
 * ordinary output. A large `git diff` therefore surfaced as a bogus
 * "exit code -1" whose detail was ~1MB of raw diff, which then went straight
 * into the model's context. 32MB is past any git output we expect, so the
 * common case now SUCCEEDS and flows through `spillGitOutput`'s file-backed
 * truncation instead of the failure path. see #1113.
 */
const MAX_BUFFER_BYTES = 32 * 1024 * 1024;

/**
 * Hard cap on the error detail handed back to a caller (and, through
 * `handleToolError`, to the model). Comfortably above a real git diagnostic,
 * far below anything that would blow a context window.
 */
export const MAX_ERROR_DETAIL_CHARS = 20_000;

/**
 * Execute a shell command safely using spawnSync with argument arrays.
 * Prevents shell injection by avoiding string interpolation in shell commands.
 *
 * SECURITY: by default, env vars are filtered to remove secrets (tokens, keys, passwords).
 * this prevents malicious code (git hooks, npm scripts, etc.) from exfiltrating credentials.
 * use env: "inherit" only when absolutely necessary.
 *
 * @param cmd - The command to execute
 * @param args - Array of arguments to pass to the command
 * @param options - Optional configuration (cwd, encoding, onError)
 * @returns The trimmed stdout output
 * @throws Error if command fails and no onError handler is provided
 */
export function $(cmd: string, args: string[], options?: ShellOptions): string {
  const encoding = options?.encoding ?? "utf-8";
  const env = resolveEnv(options?.env);

  // route git through the PID+mount-namespace sandbox: a repo-planted hook /
  // filter / config (core.fsmonitor, core.hooksPath, filter.*.clean, diff.external)
  // that fires on the MCP server's own git call then runs in a FRESH PID namespace —
  // it cannot read the action process's /proc/<pid>/environ (OIDC mint ticket, job
  // token, customer `env:` secrets) and sees only the already-filtered env. where no
  // namespace is available (local dev, CI=false, a runner without unshare) there is
  // no shell for the agent to plant such config either, so fall back to a plain spawn.
  // non-git commands keep the plain path. $git() (authenticated fetch/push) does not
  // go through here — see action/utils/gitAuth.ts.
  //
  // CRITICAL: use "ignore" for stdin instead of "inherit" to avoid breaking MCP transport
  // when running inside an MCP server, stdin is used for JSON-RPC protocol
  const sandboxMethod = getSandboxMethod();
  const result =
    basename(cmd) === "git" && sandboxMethod !== "none"
      ? spawnGitSandboxedSync({
          cmd,
          args,
          env,
          cwd: options?.cwd,
          encoding,
          maxBuffer: MAX_BUFFER_BYTES,
          method: sandboxMethod,
        })
      : spawnSync(cmd, args, {
          stdio: ["ignore", "pipe", "pipe"],
          encoding,
          cwd: options?.cwd,
          env,
          maxBuffer: MAX_BUFFER_BYTES,
        });

  const stdout = result.stdout ?? "";
  const stderr = result.stderr ?? "";

  // Write output to process streams so it behaves like stdio: "inherit"
  // CRITICAL: when running inside an MCP server, stdout is used for JSON-RPC protocol
  // so we must write to stderr instead to avoid corrupting the protocol
  // Only log if log option is not explicitly set to false
  if (options?.log !== false) {
    // if stdout is a TTY, it's safe to write to it; otherwise it's likely a pipe used for JSON-RPC
    const canWriteToStdout = process.stdout.isTTY === true;
    if (stdout) {
      if (canWriteToStdout) {
        process.stdout.write(stdout);
      } else {
        // stdout is a pipe (MCP context) - write to stderr instead
        process.stderr.write(stdout);
      }
    }
    if (stderr) {
      process.stderr.write(stderr);
    }
  }

  // Handle errors
  if (result.status !== 0) {
    // ENOBUFS means the child was killed for exceeding MAX_BUFFER_BYTES, so
    // `status` is null and there is no exit code to report. say what actually
    // happened rather than inventing "exit code -1" and pasting the truncated
    // buffer as if it were a diagnostic.
    if (result.error && "code" in result.error && result.error.code === "ENOBUFS") {
      throw new Error(
        `Command produced more than ${MAX_BUFFER_BYTES / 1024 / 1024}MB of output and was terminated: ${cmd} ${args.join(" ")}`
      );
    }

    const errorResult = {
      status: result.status ?? -1,
      stdout,
      stderr,
    };

    if (options?.onError) {
      options.onError(errorResult);
      return stdout.trim();
    }

    // many git subcommands write context-bearing diagnostics to stdout, not
    // stderr (merge conflicts, cherry-pick rejections, diff --exit-code,
    // ls-files --error-unmatch). Falling back to "Unknown error" robbed the
    // agent of any signal and forced an extra MCP round-trip. see #766.
    // capped because this string reaches the model verbatim via handleToolError.
    // a spawn that never started (ENOENT) has no output, only `result.error`.
    const detail = [stderr, stdout, result.error?.message ?? ""]
      .map((s) => s.trim())
      .filter(Boolean)
      .join("\n")
      .slice(0, MAX_ERROR_DETAIL_CHARS);
    throw new Error(
      `Command failed with exit code ${errorResult.status}: ${detail || "Unknown error"}`
    );
  }

  return stdout.trim();
}