From 774d5204a151bbe1216e6f99aa8f658157777b67 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Sat, 24 Jan 2026 17:03:44 -0800 Subject: [PATCH] feat: send additional_permissions in token exchange request Parse the ADDITIONAL_PERMISSIONS env var and send it as a JSON body in the OIDC token exchange request. Permissions are merged on top of the standard defaults (contents: write, pull_requests: write, issues: write). --- docs/configuration.md | 6 ++- src/github/token.ts | 63 +++++++++++++++++++--- test/parse-permissions.test.ts | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 test/parse-permissions.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 46c2687c..e756a238 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -172,9 +172,11 @@ jobs: **Important Notes**: -- The GitHub token must have the `actions: read` permission in your workflow +- The GitHub token must have the corresponding permission in your workflow - If the permission is missing, Claude will warn you and suggest adding it -- Currently, only `actions: read` is supported, but the format allows for future extensions +- Any GitHub App permission can be requested (e.g. `actions: read`, `workflows: write`, `deployments: read`) +- The GitHub App installation must have the requested permission enabled for it to take effect +- Standard permissions (`contents: write`, `pull_requests: write`, `issues: write`) are always included and do not need to be specified ## Custom Environment Variables diff --git a/src/github/token.ts b/src/github/token.ts index 6cb9079c..54948d19 100644 --- a/src/github/token.ts +++ b/src/github/token.ts @@ -16,15 +16,60 @@ async function getOidcToken(): Promise { } } -async function exchangeForAppToken(oidcToken: string): Promise { +const DEFAULT_PERMISSIONS: Record = { + contents: "write", + pull_requests: "write", + issues: "write", +}; + +export function parseAdditionalPermissions(): + | Record + | undefined { + const raw = process.env.ADDITIONAL_PERMISSIONS; + if (!raw || !raw.trim()) { + return undefined; + } + + const additional: Record = {}; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) continue; + const key = trimmed.slice(0, colonIndex).trim(); + const value = trimmed.slice(colonIndex + 1).trim(); + if (key && value) { + additional[key] = value; + } + } + + if (Object.keys(additional).length === 0) { + return undefined; + } + + return { ...DEFAULT_PERMISSIONS, ...additional }; +} + +async function exchangeForAppToken( + oidcToken: string, + permissions?: Record, +): Promise { + const headers: Record = { + Authorization: `Bearer ${oidcToken}`, + }; + const fetchOptions: RequestInit = { + method: "POST", + headers, + }; + + if (permissions) { + headers["Content-Type"] = "application/json"; + fetchOptions.body = JSON.stringify({ permissions }); + } + const response = await fetch( "https://api.anthropic.com/api/github/github-app-token-exchange", - { - method: "POST", - headers: { - Authorization: `Bearer ${oidcToken}`, - }, - }, + fetchOptions, ); if (!response.ok) { @@ -89,9 +134,11 @@ export async function setupGitHubToken(): Promise { const oidcToken = await retryWithBackoff(() => getOidcToken()); console.log("OIDC token successfully obtained"); + const permissions = parseAdditionalPermissions(); + console.log("Exchanging OIDC token for app token..."); const appToken = await retryWithBackoff(() => - exchangeForAppToken(oidcToken), + exchangeForAppToken(oidcToken, permissions), ); console.log("App token successfully obtained"); diff --git a/test/parse-permissions.test.ts b/test/parse-permissions.test.ts new file mode 100644 index 00000000..a13f9234 --- /dev/null +++ b/test/parse-permissions.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { parseAdditionalPermissions } from "../src/github/token"; + +describe("parseAdditionalPermissions", () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.ADDITIONAL_PERMISSIONS; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ADDITIONAL_PERMISSIONS; + } else { + process.env.ADDITIONAL_PERMISSIONS = originalEnv; + } + }); + + test("returns undefined when env var is not set", () => { + delete process.env.ADDITIONAL_PERMISSIONS; + expect(parseAdditionalPermissions()).toBeUndefined(); + }); + + test("returns undefined when env var is empty string", () => { + process.env.ADDITIONAL_PERMISSIONS = ""; + expect(parseAdditionalPermissions()).toBeUndefined(); + }); + + test("returns undefined when env var is only whitespace", () => { + process.env.ADDITIONAL_PERMISSIONS = " \n \n "; + expect(parseAdditionalPermissions()).toBeUndefined(); + }); + + test("parses single permission and merges with defaults", () => { + process.env.ADDITIONAL_PERMISSIONS = "actions: read"; + expect(parseAdditionalPermissions()).toEqual({ + contents: "write", + pull_requests: "write", + issues: "write", + actions: "read", + }); + }); + + test("parses multiple permissions", () => { + process.env.ADDITIONAL_PERMISSIONS = "actions: read\nworkflows: write"; + expect(parseAdditionalPermissions()).toEqual({ + contents: "write", + pull_requests: "write", + issues: "write", + actions: "read", + workflows: "write", + }); + }); + + test("additional permissions can override defaults", () => { + process.env.ADDITIONAL_PERMISSIONS = "contents: read"; + expect(parseAdditionalPermissions()).toEqual({ + contents: "read", + pull_requests: "write", + issues: "write", + }); + }); + + test("handles extra whitespace around keys and values", () => { + process.env.ADDITIONAL_PERMISSIONS = " actions : read "; + expect(parseAdditionalPermissions()).toEqual({ + contents: "write", + pull_requests: "write", + issues: "write", + actions: "read", + }); + }); + + test("skips empty lines", () => { + process.env.ADDITIONAL_PERMISSIONS = + "actions: read\n\n\nworkflows: write\n\n"; + expect(parseAdditionalPermissions()).toEqual({ + contents: "write", + pull_requests: "write", + issues: "write", + actions: "read", + workflows: "write", + }); + }); + + test("skips lines without colons", () => { + process.env.ADDITIONAL_PERMISSIONS = + "actions: read\ninvalid line\nworkflows: write"; + expect(parseAdditionalPermissions()).toEqual({ + contents: "write", + pull_requests: "write", + issues: "write", + actions: "read", + workflows: "write", + }); + }); +});