From 2c0f630df2b563edef0ec29985b24bf13b5dd95a Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 14 Aug 2026 23:08:53 -0400 Subject: [PATCH] fix(github): prefer repository permission alternatives --- README.md | 3 +++ specs/github-adapter.feature | 1 + src/providers/github/operation-permissions.ts | 13 +++++++++---- test/providers/github-operation-permissions.test.ts | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5811dd..a06541c 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,9 @@ discovery publishes the subset GitHub documents for installation access tokens, preserving alternative permission sets as OR and each set's required permissions as AND. For every request, the adapter resolves the original method and path and mints only one least-privileged permission set satisfied by the Realmroot token. +When GitHub documents a metadata-only alternative, it is used only as a fallback; +an available satisfied repository-domain permission is preferred so private +repository operations are not attempted with public-metadata authority. GitHub requires both `contents:write` and `workflows:write` when the Contents API writes under `.github/workflows`; the adapter enforces that condition from the diff --git a/specs/github-adapter.feature b/specs/github-adapter.feature index 79b50b0..9eb32b0 100644 --- a/specs/github-adapter.feature +++ b/specs/github-adapter.feature @@ -93,6 +93,7 @@ Feature: GitHub App adapter Given an Agent token satisfies one or more permission alternatives for a GitHub REST operation When the Agent calls the original GitHub REST path through the adapter Then the adapter selects one least-privileged satisfied permission set for that method and path + But a metadata-only alternative is a fallback when no satisfied domain permission alternative exists And the short-lived GitHub installation credential contains only that selected permission set And unrelated scopes in the Agent token are not minted into the GitHub credential And slash-delimited Git reference names resolve to their documented operation diff --git a/src/providers/github/operation-permissions.ts b/src/providers/github/operation-permissions.ts index e982e1c..db1a128 100644 --- a/src/providers/github/operation-permissions.ts +++ b/src/providers/github/operation-permissions.ts @@ -37,10 +37,11 @@ export function resolveGitHubOperationPermissions(input: { if (available.length === 0) { throw forbidden('The connected GitHub App does not grant the permissions required for this operation.') } - const satisfied = available - .filter((requirement) => requirement.every((scope) => scopeSatisfies(scope, input.scopes))) - .sort(compareRequirements) - const selected = satisfied[0] + const satisfied = available.filter((requirement) => requirement.every((scope) => scopeSatisfies(scope, input.scopes))) + const domainSatisfied = satisfied.filter((requirement) => !metadataOnly(requirement)) + const candidates = domainSatisfied.length > 0 ? domainSatisfied : satisfied + candidates.sort(compareRequirements) + const selected = candidates[0] if (!selected) { throw insufficientScope( 'The Agent token does not grant the permissions required for this operation.', @@ -50,6 +51,10 @@ export function resolveGitHubOperationPermissions(input: { return scopesToPermissions(new Set(selected), input.available) } +function metadataOnly(requirement: readonly string[]) { + return requirement.length > 0 && requirement.every((scope) => parseScope(scope).permission === 'metadata') +} + function workflowFileRequirements( method: string, template: string, diff --git a/test/providers/github-operation-permissions.test.ts b/test/providers/github-operation-permissions.test.ts index a588e90..7c58659 100644 --- a/test/providers/github-operation-permissions.test.ts +++ b/test/providers/github-operation-permissions.test.ts @@ -34,6 +34,17 @@ describe('GitHub operation permissions', () => { ).toEqual({ issues: 'write' }) }) + it('[spec: github-adapter/github-operation-authority] prefers repository-domain authority over the metadata fallback', () => { + expect( + resolveGitHubOperationPermissions({ + method: 'GET', + path: '/repos/saltbo/wakatoken/pulls', + scopes: new Set(['metadata:read', 'pull_requests:write']), + available: { metadata: 'read', pull_requests: 'write' }, + }), + ).toEqual({ pull_requests: 'read' }) + }) + it('[spec: github-adapter/github-native-tool-scope-challenge] reports every available scope alternative', () => { try { resolveGitHubOperationPermissions({