Ctrl K
ComunidadGuías…

utils/gitAuth.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * git authentication via GIT_ASKPASS.
 *
 * a localhost HTTP server serves tokens via UUID codes whose lifetime is
 * bounded by the parent $git() invocation: register() makes the code active,
 * the script (and any sibling subprocess — e.g. git-lfs pre-push) can fetch
 * the token any number of times, and $git()'s finally calls revoke() to
 * close the window. each $git() call writes a unique askpass script with
 * the server port+code baked into the file body — no secrets in subprocess
 * env. a replay of a revoked code trips a 409 and revokes the underlying
 * github installation token.
 *
 * see wiki/askpass.md for full security documentation.
 */

import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, realpathSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { log } from "./cli.ts";
import type { GitAuthServer } from "./gitAuthServer.ts";
import { CHECKOUT_MOVED, checkoutMoved } from "./sandbox.ts";
import { filterEnv } from "./secrets.ts";
import { $ } from "./shell.ts";
import { spawn } from "./subprocess.ts";

type SafeGitSubcommand = "fetch" | "push";

/**
 * env for git that rewrites the working tree. it runs with no credentials, so an LFS
 * smudge that has to download dies mid-checkout and leaves dirt every retry refuses on
 * (#1519); write the pointer instead, as `actions/checkout`'s default `lfs: false` does.
 */
export const WORKING_TREE_GIT_ENV = { GIT_LFS_SKIP_SMUDGE: "1" };

const SLOW_FETCH_SECONDS = 30;

type GitAuthOptions = {
  token: string;
  cwd?: string;
  /**
   * re-mint the git token when GitHub hands out one its git edge never accepts.
   * when present, an auth-class failure is retried ONCE with a fresh token.
   */
  refreshGitToken?: ((stale: string) => Promise<string>) | undefined;
  signal?: AbortSignal | undefined;
};

/**
 * installation-token 401s on git. GitHub intermittently mints a token its git
 * edge never accepts, so retrying the SAME token never recovers — only a
 * re-mint does. lives here rather than next to `classifyPushError` in
 * `mcp/git.ts` because the fetch path needs it too and `git.ts` imports this
 * module, not the other way round. see #1115.
 */
export const TRANSIENT_AUTH_PATTERNS: RegExp[] = [
  /Invalid username or token/,
  /Authentication failed for 'https:\/\/github\.com\//,
];

type GitResult = {
  stdout: string;
  stderr: string;
};

// --- git binary resolution and tamper detection ---

type GitBinaryInfo = {
  path: string;
  sha256: string;
};

let gitBinary: GitBinaryInfo | undefined;

function hashFile(path: string): string {
  return createHash("sha256").update(readFileSync(path)).digest("hex");
}

/**
 * resolve and fingerprint the git binary. must be called once at startup
 * (in main()) before any agent code runs, so the path and hash reflect
 * the untampered binary.
 *
 * resolves symlinks via realpath so the hash is of the actual binary.
 * a malicious agent with sudo could replace the binary later, which is
 * caught by verifyGitBinary() before each authenticated call.
 */
export function resolveGit(): void {
  const whichPath = execSync("which git", { encoding: "utf-8" }).trim();
  const resolvedPath = realpathSync(whichPath);
  const sha256 = hashFile(resolvedPath);
  gitBinary = { path: resolvedPath, sha256 };
  log.debug(`» git binary: ${resolvedPath} (sha256: ${sha256.slice(0, 12)}...)`);
}

/**
 * the tamper-checked git for a call that cannot go through the sandbox. a process that
 * never ran `resolveGit` (a unit test, the local CLI) has no agent to defend against.
 */
export function trustedGit(): string {
  return gitBinary ? verifyGitBinary() : "git";
}

function verifyGitBinary(): string {
  if (!gitBinary) {
    throw new Error("git binary not initialized — call resolveGit() at startup");
  }
  const currentHash = hashFile(gitBinary.path);
  if (currentHash !== gitBinary.sha256) {
    throw new Error(
      `git binary tampered: expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
        `path: ${gitBinary.path}`
    );
  }
  return gitBinary.path;
}

// --- hooks isolation ---

const hooksDirCache = new Map<string, string>();

/**
 * resolve the repo's REAL hooks dir (`<git-common-dir>/hooks`) so every
 * authenticated `$git()` call can pin `core.hooksPath` to it.
 *
 * a `pre-push` hook fires inside `$git push` while GIT_ASKPASS is live, so an
 * agent-controlled hook would receive the installation token (the token isn't
 * in env, but a hook can ask the loopback auth server for it just like git
 * does — and the replay trap only catches use after the call returns). the
 * agent can't write the real `.git/hooks` (RO bind-mount in the shell sandbox +
 * native-tool `.git` write deny), but it CAN redirect `core.hooksPath` to a dir
 * it controls via `~/.gitconfig`, husky's tracked `.husky/`, or repo
 * `.git/config`. pinning `core.hooksPath` on the command line (highest config
 * precedence) ignores every such redirect while still firing legit hooks in the
 * sealed `.git/hooks` — notably git-lfs `pre-push`, which is why we pin to the
 * dir rather than `/dev/null` (empty/`/dev/null` disables ALL hooks and breaks
 * LFS).
 *
 * derived from `--git-common-dir`, which is structural and NOT influenced by
 * `core.hooksPath` (unlike `--git-path hooks`, which honors the override).
 * memoized per cwd.
 *
 * runs the tamper-verified git binary (the value it returns becomes the pinned
 * `core.hooksPath`, so resolving `git` from PATH here would let a substituted
 * binary choose the hooks dir and reopen the very hole this pin closes).
 */
function resolveHooksDir(cwd: string, gitPath: string): string {
  const cached = hooksDirCache.get(cwd);
  if (cached) return cached;
  const commonDir = $(gitPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
    cwd,
    log: false,
  }).trim();
  const hooksDir = join(commonDir, "hooks");
  hooksDirCache.set(cwd, hooksDir);
  return hooksDir;
}

// --- auth server ---

let authServer: GitAuthServer | undefined;

export function setGitAuthServer(server: GitAuthServer): void {
  authServer = server;
}

/**
 * execute authenticated git command via ASKPASS.
 *
 * subcommand is restricted to "fetch" | "push" — operations that talk to
 * a remote and need credentials. working-tree operations (checkout, merge)
 * use $() from shell.ts which has no token.
 *
 * per call: registers a code with the auth server (valid for the lifetime
 * of this invocation), writes a unique askpass script with port+code baked
 * in, spawns git with GIT_ASKPASS pointing to the script. on completion,
 * revokes the code and deletes the script in finally. multiple sibling
 * askpass calls within one invocation (e.g. git itself + git-lfs pre-push)
 * all see a valid code; replay attempts after finally trip a 409 and the
 * server revokes the underlying github token as a tamper signal.
 *
 * @example
 * await $git("fetch", ["origin", "main"], { token });
 * await $git("push", ["-u", "origin", "feature"], { token });
 */
export async function $git(
  subcommand: SafeGitSubcommand,
  args: string[],
  options: GitAuthOptions
): Promise<GitResult> {
  if (checkoutMoved()) throw new Error(CHECKOUT_MOVED);
  const gitPath = verifyGitBinary();

  if (!authServer) {
    throw new Error("git auth server not initialized — call setGitAuthServer() at startup");
  }

  const cwd = options.cwd ?? process.cwd();

  const code = authServer.register(options.token);
  const scriptPath = authServer.writeAskpassScript(code);

  // -c flags override local .git/config — defense-in-depth against
  // agent-set config that could spawn subprocesses before ASKPASS runs.
  // core.hooksPath is pinned to the repo's real hooks dir so an
  // agent-redirected hooksPath (~/.gitconfig, husky, .git/config) can't run
  // attacker code with the token live — see resolveHooksDir.
  const fullArgs = [
    "-c",
    "core.fsmonitor=false",
    "-c",
    "credential.helper=",
    "-c",
    "protocol.file.allow=never",
    "-c",
    "core.sshCommand=ssh",
    "-c",
    `core.hooksPath=${resolveHooksDir(cwd, gitPath)}`,
    subcommand,
    ...args,
  ];

  log.debug(`git ${fullArgs.join(" ")}`);

  const startedAt = Date.now();
  try {
    const result = await spawn({
      cmd: gitPath,
      args: fullArgs,
      cwd,
      env: {
        ...filterEnv(),
        GIT_ASKPASS: scriptPath,
        GIT_TERMINAL_PROMPT: "0",
        // blocks env-based git config injection from outer processes.
        // GIT_CONFIG_COUNT=0 blocks the newer KEY_n/VALUE_n mechanism.
        // GIT_CONFIG_PARAMETERS="" clears the legacy quoted-list mechanism.
        // both are needed — they are independent systems.
        GIT_CONFIG_COUNT: "0",
        GIT_CONFIG_PARAMETERS: "",
        // transport allowlist (overrides config): refuses non-https transports, so a
        // planted `~/.gitconfig` url.*.insteadOf → ext:: / remote-helper / ssh can't
        // exec on this unsandboxed fetch. all callers use github.com https. see
        // wiki/askpass.md "-c hardening".
        GIT_ALLOW_PROTOCOL: "https",
      },
      activityTimeout: 0,
      signal: options.signal,
    });

    if (result.stderr.includes("askpass-compromised")) {
      log.info("askpass code was replayed after revoke — token has been revoked");
      throw new Error("git auth failed — askpass code was replayed after revoke, token revoked");
    }

    if (result.exitCode !== 0) {
      const stderr = result.stderr.trim();
      const stdout = result.stdout.trim();
      // stderr is the primary channel for git diagnostics, but in rare cases
      // (e.g. some HTTPS smart-protocol failures) the only useful detail is
      // on stdout — without it the agent / operator sees an empty error.
      // include exit code so we can distinguish e.g. signal-killed (1 with
      // empty output) from a genuine git-level rejection.
      const detail =
        stderr && stdout
          ? `${stderr}\n--- stdout ---\n${stdout}`
          : stderr || stdout || "(no output)";
      const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
      log.info(message);
      throw new Error(message);
    }

    return {
      stdout: result.stdout.trim(),
      stderr: result.stderr.trim(),
    };
  } finally {
    const seconds = Math.round((Date.now() - startedAt) / 1000);
    // the one line that names which fetch ate a checkout's deadline (#1531); args never hold the token
    if (subcommand === "fetch" && seconds > SLOW_FETCH_SECONDS) {
      log.info(`» git fetch ${args.join(" ")} took ${seconds}s`);
    }
    authServer.revoke(code);
    try {
      unlinkSync(scriptPath);
    } catch {
      // script may already be gone (e.g. tmpdir cleanup raced us)
    }
  }
}

/**
 * shallow-clone unreachable: when an existing local depth is too shallow for
 * git to traverse to the requested ref's ancestry, the remote walk fails with
 * one of these wordings (git emits the full OID via oid_to_hex, so the bound
 * is 40 for SHA-1 or 64 for SHA-256). detecting both lets a single deepen
 * retry recover before the error reaches the agent — see issue #564 for the
 * original `git_fetch` precedent and #656 for the `checkout_pr` follow-up.
 */
export const SHALLOW_UNREACHABLE_PATTERNS: RegExp[] = [
  /Could not read [a-f0-9]{40,64}/,
  /remote did not send all necessary objects/,
];

/**
 * large enough to clear the merge base on most real-world PRs without
 * downloading the full history; matches the fallback used by
 * `checkoutPrBranch` when the GitHub compare API is unavailable.
 */
export const DEEPEN_RETRY_DEPTH = 1000;

/**
 * authenticated `git fetch` that recovers from shallow-unreachable errors
 * by retrying once with `--deepen=1000`. callers pass the same args they
 * would to `$git("fetch", ...)`; on shallow-unreachable failures in a
 * shallow repo, the second attempt prepends `--deepen=N` and strips any
 * caller-supplied `--depth=` (the two flags are mutually exclusive, and
 * the caller's depth is what got us into this mess).
 *
 * non-shallow-unreachable errors and non-shallow repos rethrow unchanged,
 * so this is safe to wrap any fetch without changing fast-path behavior.
 */
export async function $gitFetchWithDeepen(
  args: string[],
  options: GitAuthOptions,
  label?: string
): Promise<GitResult> {
  try {
    return await $git("fetch", args, options);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    // the re-mint cure was wired only into `pushWithRetry`, so a token GitHub's
    // git edge rejects killed `checkout_pr` unrecoverably (92 occurrences, 20
    // runs, 0 re-mints). ONE retry, not the push path's five: the mint revokes
    // the superseded token, and a repo running 60+ concurrent jobs would
    // otherwise multiply mint/revoke traffic against an installation that is
    // already rate-limited. a re-fetch of the same refspec is idempotent. #1115
    if (TRANSIENT_AUTH_PATTERNS.some((p) => p.test(msg)) && options.refreshGitToken) {
      log.info(
        `» ${label ?? "git fetch"} hit an auth error, re-minting the git token and retrying`
      );
      const fresh = await options.refreshGitToken(options.token);
      // recurse rather than calling `$git` directly: auth is negotiated before
      // ref/object negotiation, so the realistic ordering is "token rejected →
      // re-mint → the now-authenticated fetch discovers the shallow clone can't
      // reach the ref". a bare `$git` here would skip the `--deepen` recovery
      // below entirely. clearing `refreshGitToken` keeps the one-retry bound.
      return await $gitFetchWithDeepen(
        args,
        { ...options, token: fresh, refreshGitToken: undefined },
        label
      );
    }
    const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
    if (!isShallowUnreachable) throw err;
    const isShallow =
      $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
    if (!isShallow) throw err;
    log.info(
      `» ${label ?? "git fetch"} hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`
    );
    const retryArgs = args.filter((a) => !a.startsWith("--depth="));
    return await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, ...retryArgs], options);
  }
}