Ctrl K
ComunidadGuías…

mcp/gh.ts

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

Archivo original ↗Preguntar a KiBot
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { formatPermissions, mirrorRolePermissions } from "../utils/roleMirror.ts";
import { filterEnvForUntrustedCode } from "../utils/secrets.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
import { capOutput, runSandboxed, SANDBOX_RESPONSE_HEADROOM_MS } from "./shell.ts";

const GH_TIMEOUT_MS = 120_000;

export const GhParams = type({
  args: type.string
    .array()
    .describe(
      'gh arguments as an argv array, e.g. ["pr", "list", "--json", "number,title"] or ["api", "graphql", "-f", "query=..."]'
    ),
});

/** single-quote one argv element for `bash -c`, closing and reopening around any quote. */
function shellQuote(arg: string): string {
  return `'${arg.replace(/'/g, "'\\''")}'`;
}

/**
 * The GitHub CLI, authenticated with a token scoped to mirror the triggering
 * user's own repository role (see action/utils/roleMirror.ts).
 *
 * Registered only when that mirror yields a token — triage and above. Below
 * that the object-scoped tools are registered instead, because no GitHub
 * permission set can express "act only on the object you authored".
 *
 * There is deliberately NO subcommand allowlist. `gh auth token` prints its own
 * credential by design, `GH_DEBUG=api` dumps the Authorization header, and
 * aliases and extensions are general code execution — so containment of the
 * TOKEN is not achievable and is not attempted; correct scoping plus revocation
 * at run end is what makes a leak survivable.
 *
 * That last property is exactly why this runs through `runSandboxed` rather
 * than spawning directly: `gh alias set x '!<shell>'` is arbitrary code
 * execution, and outside the sandbox it would reach pullfrog-managed on-disk
 * secrets, the runner's workflow-command files and a writable `.git` — none of
 * which the token's scope bounds. Inside it, that capability is no more than
 * the `shell` tool already grants. See wiki/agent-github-access.md.
 */
export function GhTool(ctx: ToolContext) {
  const permissions = mirrorRolePermissions(ctx.payload.event.authorPermission);
  return tool({
    name: "gh",
    // denied to subagents. `shell` is deliberately unmarked because its `.git`
    // and its own `gh` token are read-only, but this `gh` reaches PR and issue
    // state changes — the shape of the 2026-05-18 cross-branch clobber the gate exists for.
    mutates: true,
    timeoutMs: GH_TIMEOUT_MS + SANDBOX_RESPONSE_HEADROOM_MS,
    description:
      `Run the GitHub CLI against ${ctx.repo.owner}/${ctx.repo.name}. ` +
      "Pass argv as an array — there is no shell, so pipes, redirects and `&&` do not work; " +
      "use gh's own `--json`/`--jq` flags instead. `gh api graphql` is available. " +
      `Authenticated with ${permissions ? formatPermissions(permissions) : "no permissions"} on this repo only; anything else 403s. ` +
      "Pushing, merging and ref writes are not available — use the git tools.",
    parameters: GhParams,
    execute: execute(async (params) => {
      // isolate from any gh config the runner already has: a self-hosted runner
      // can carry a real user's ~/.config/gh/hosts.yml, which we must not use.
      const configDir = join(ctx.tmpdir, "gh-config");
      mkdirSync(configDir, { recursive: true });

      const command = ["gh", ...params.args.map(shellQuote)].join(" ");
      const result = await runSandboxed({
        command,
        cwd: process.cwd(),
        timeout: GH_TIMEOUT_MS,
        // `gh` never needs root, and a shell alias would otherwise reach `sudo`
        seal: true,
        env: {
          ...filterEnvForUntrustedCode(),
          GH_TOKEN: ctx.ghToken,
          GH_CONFIG_DIR: configDir,
          GH_PROMPT_DISABLED: "1",
          GH_NO_UPDATE_NOTIFIER: "1",
        },
      });

      log.info(`» gh ${params.args.join(" ")} (exit ${result.exitCode})`);

      return {
        output: capOutput(result.output),
        exit_code: result.exitCode,
        timed_out: result.timedOut,
      };
    }, "gh"),
  });
}