diff --git a/specs/github-adapter.feature b/specs/github-adapter.feature index 4f96821..79b50b0 100644 --- a/specs/github-adapter.feature +++ b/specs/github-adapter.feature @@ -140,6 +140,14 @@ Feature: GitHub App adapter And unsupported createPullRequest, addComment, and mergePullRequest mutations use GitHub's installation-compatible REST operations And the GitHub credential is never returned + @journey:github-cross-installation-boundary @entrypoint:http + Scenario: GitHub operations stay inside one selected App installation + Given an Agent selected a GitHub App installation + When GitHub CLI tries to create a pull request in a repository outside that installation + Then the adapter rejects the operation before requesting a write credential + And explains that the target repository must belong to the selected App installation + But it does not request broader user credentials or another OAuth application + @journey:github-git-transport @entrypoint:http Scenario: Native Git uses the GitHub installation through the adapter Given the Agent has approved repository contents authority diff --git a/src/providers/github/adapter.ts b/src/providers/github/adapter.ts index b7d76b8..f6937e4 100644 --- a/src/providers/github/adapter.ts +++ b/src/providers/github/adapter.ts @@ -475,7 +475,7 @@ function repositoryTarget(path: string, installation: GitHubAuthorizationContext if (!match) return const owner = decodeURIComponent(match[1] as string) if (owner.toLowerCase() !== installation.accountLogin.toLowerCase()) { - throw forbidden('The repository owner is outside the selected GitHub installation.') + throw forbidden('The target repository must belong to the selected GitHub App installation.') } const repository = decodeURIComponent(match[2] as string) if ( diff --git a/test/app.test.ts b/test/app.test.ts index f7bd701..7444cd2 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -199,6 +199,41 @@ describe('GitHub adapter contract', () => { }) }) + it('[spec: github-adapter/github-cross-installation-boundary] rejects pull requests outside the selected installation', async () => { + const provider = fakeProvider() + provider.request = vi.fn(async () => Response.json({ data: { node: { nameWithOwner: 'upstream/example' } } })) + const response = await testApp({ + provider, + authenticator: { + authenticate: vi.fn(async () => ({ + ...principal, + scopes: new Set(['metadata:read', 'pull_requests:write']), + })), + }, + }).request('/github/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ + query: + 'mutation PullRequestCreate($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { id url } } }', + variables: { + input: { + repositoryId: 'upstream-repository', + baseRefName: 'main', + headRefName: 'realmroot:codex/fix', + title: 'Fix adapter', + }, + }, + }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + detail: 'The target repository must belong to the selected GitHub App installation.', + }) + expect(provider.installationToken).toHaveBeenCalledTimes(1) + }) + it('[spec: github-adapter/github-graphql-proxy] preserves the GitHub CLI addComment mutation', async () => { const provider = fakeProvider() provider.request = vi