diff --git a/src/services/gh-cli.ts b/src/services/gh-cli.ts index b26f169..c299b6a 100644 --- a/src/services/gh-cli.ts +++ b/src/services/gh-cli.ts @@ -45,8 +45,33 @@ export type GhRunner = (args: Array) => Promise // can't hang the settings request. const GH_TIMEOUT_MS = 5000 -const defaultRunner: GhRunner = (args) => - new Promise((resolve) => { +/** + * The ONLY gh subcommands maximal may run — all strictly read-only. + * Enforced in the real runner so a future caller can never invoke a MUTATING + * gh command (`auth login`/`logout`/`switch`/`refresh`/`setup-git`, + * `config set`, `api -X POST`, …). maximal must never change what gh is signed + * into or otherwise touch gh's state; this turns that prose contract into a + * gate. `gh auth status` and `gh auth token` only READ; `--version` is inert. + */ +export function isReadOnlyGhArgs(args: Array): boolean { + if (args.length === 1 && args[0] === "--version") return true + if (args[0] === "auth" && (args[1] === "status" || args[1] === "token")) { + return true + } + return false +} + +const defaultRunner: GhRunner = (args) => { + if (!isReadOnlyGhArgs(args)) { + // Defense-in-depth: never shell out a non-read-only gh command, even if a + // future caller passes one. Reject rather than execute. + return Promise.reject( + new Error( + `Refusing to run a non-read-only gh command: gh ${args.join(" ")}`, + ), + ) + } + return new Promise((resolve) => { execFile( "gh", args, @@ -64,6 +89,7 @@ const defaultRunner: GhRunner = (args) => }, ) }) +} // "gh version 2.92.0 (2026-04-28)\n..." -> "2.92.0" function parseVersion(stdout: string): string | null { diff --git a/tests/gh-cli.test.ts b/tests/gh-cli.test.ts index 4673a33..aa43286 100644 --- a/tests/gh-cli.test.ts +++ b/tests/gh-cli.test.ts @@ -4,6 +4,7 @@ import { detectGhCli, getGhAccountToken, type GhRunner, + isReadOnlyGhArgs, } from "~/services/gh-cli" // Build a runner that returns canned results keyed by the gh subcommand, so @@ -154,6 +155,40 @@ describe("detectGhCli", () => { }) }) +describe("isReadOnlyGhArgs", () => { + test("allows the read-only commands maximal uses", () => { + expect(isReadOnlyGhArgs(["--version"])).toBe(true) + expect(isReadOnlyGhArgs(["auth", "status", "--json", "hosts"])).toBe(true) + expect( + isReadOnlyGhArgs([ + "auth", + "token", + "--hostname", + "github.com", + "--user", + "x", + ]), + ).toBe(true) + }) + + test("rejects every mutating / unknown gh command", () => { + for (const args of [ + ["auth", "login"], + ["auth", "logout"], + ["auth", "switch"], + ["auth", "refresh"], + ["auth", "setup-git"], + ["config", "set", "x", "y"], + ["api", "user", "-X", "POST"], + ["repo", "delete", "x"], + ["version"], // not the "--version" form + [], + ]) { + expect(isReadOnlyGhArgs(args)).toBe(false) + } + }) +}) + describe("getGhAccountToken", () => { test("returns the trimmed token on success", async () => { const token = await getGhAccountToken(