Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/services/gh-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,33 @@ export type GhRunner = (args: Array<string>) => Promise<GhRunResult>
// 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<string>): 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<GhRunResult>((resolve) => {
execFile(
"gh",
args,
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions tests/gh-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading