From 0145c657d9c5ad3df790cfffe1a2bd6c279248a9 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 28 Oct 2026 10:31:13 -0700 Subject: [PATCH] fix(ai): mint evidence tokens with metadata:read on CopilotKit only The support agent asked GitHub for an installation token narrowed to contents:read. The Outpost App does not hold Contents, so GitHub refused every exchange with HTTP 422 and every read_source/read_release call reported auth_unavailable. Both allowlisted evidence repositories are public, and GitHub serves commits, contents and releases of a public repository to any token. Ask for metadata:read, which the App already holds, and narrow the token to the CopilotKit repository. ag-ui-protocol/ag-ui stays readable as a public repository. The App is not granted Contents on purpose. --- .../ai/src/github-evidence-auth.test.ts | 16 ++++++++----- .../outpost/ai/src/github-evidence-auth.ts | 24 +++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/outpost/ai/src/github-evidence-auth.test.ts b/packages/outpost/ai/src/github-evidence-auth.test.ts index 0635965..86df5fa 100644 --- a/packages/outpost/ai/src/github-evidence-auth.test.ts +++ b/packages/outpost/ai/src/github-evidence-auth.test.ts @@ -51,14 +51,15 @@ describe('GitHub evidence authentication', () => { }); }); - it('authorizes evidence requests with an installation token narrowed to contents:read', async () => { + it('authorizes evidence requests with a metadata:read token narrowed to the CopilotKit repository', async () => { const { create, auth } = fakeAppAuth(); const resolver = githubEvidenceAuthFromEnv(CREDENTIALS, create); expect(await resolver.authorization()).toBe(`Bearer ${TOKEN}`); expect(auth).toHaveBeenCalledWith({ type: 'installation', installationId: 7890, - permissions: { contents: 'read' }, + permissions: { metadata: 'read' }, + repositoryNames: ['CopilotKit'], }); }); @@ -803,8 +804,8 @@ describe('GitHub evidence authentication over the real @octokit/auth-app', () => body: JSON.stringify({ token, expires_at: new Date(Date.now() + 3_600_000).toISOString().replace(/\.\d+Z$/, 'Z'), - permissions: { contents: 'read', metadata: 'read' }, - repository_selection: 'all', + permissions: { metadata: 'read' }, + repository_selection: 'selected', }), }); @@ -859,7 +860,7 @@ describe('GitHub evidence authentication over the real @octokit/auth-app', () => }); const elapse = (ms: number) => vi.setSystemTime(Date.now() + ms); - it('mints once, signed as the App and narrowed to contents:read, then serves the SDK cache', async () => { + it('mints once, signed as the App and narrowed to metadata:read on CopilotKit, then serves the SDK cache', async () => { const exchanges = tokenEndpoint(); const auth = resolver(); expect(await auth.authorization()).toBe('Bearer ghs_syntheticexchange1'); @@ -867,7 +868,10 @@ describe('GitHub evidence authentication over the real @octokit/auth-app', () => expect(await auth.authorization()).toBe('Bearer ghs_syntheticexchange1'); expect(exchanges).toHaveLength(1); expect(exchanges[0]?.authorization).toMatch(/^bearer [\w-]+\.[\w-]+\.[\w-]+$/); - expect(exchanges[0]?.body).toEqual({ permissions: { contents: 'read' } }); + expect(exchanges[0]?.body).toEqual({ + permissions: { metadata: 'read' }, + repositories: ['CopilotKit'], + }); }); // Each of these is a 2xx the SDK returns as a success and caches on the strategy this diff --git a/packages/outpost/ai/src/github-evidence-auth.ts b/packages/outpost/ai/src/github-evidence-auth.ts index 2259ed0..07a741b 100644 --- a/packages/outpost/ai/src/github-evidence-auth.ts +++ b/packages/outpost/ai/src/github-evidence-auth.ts @@ -16,12 +16,23 @@ import { createAppAuth } from '@octokit/auth-app'; import { Octokit } from '@octokit/rest'; /** - * Evidence reads fetch commits, file contents and release metadata. `contents:read` - * covers all three; `metadata:read` is implicit in every installation token. Asking - * for a narrowed token means a leaked one cannot write, and can read nothing the App - * is installed for beyond repository contents and that implicit repository metadata. + * Evidence reads fetch commits, file contents and release metadata, and only from the two + * public repositories `support-agent.ts` allowlists. GitHub serves all three for a public + * repository to any token, whatever its permissions, so the token asks for nothing beyond + * `metadata:read`, which the App already holds. It must not ask for `contents:read`: the App + * is not granted it, and GitHub refuses the whole exchange with a 422 rather than narrowing. + * The App is deliberately not granted it either: it is installed on every org repository, + * private ones included, and reads untrusted issue text. */ -const EVIDENCE_PERMISSIONS = { contents: 'read' } as const; +const EVIDENCE_PERMISSIONS = { metadata: 'read' } as const; + +/** + * The token is narrowed to the one allowlisted repository this installation covers, so a + * leaked one cannot even list metadata of the org's other repositories. `ag-ui-protocol/ag-ui` + * belongs to another org and cannot be named here; as a public repository it is readable with + * this token all the same. + */ +const EVIDENCE_REPOSITORIES: readonly string[] = ['CopilotKit']; /** * Installation ids are positive decimal integers; `Number` alone would accept `1e3` and `0x1f`. @@ -205,6 +216,8 @@ export type InstallationTokenSource = (options: { type: 'installation'; installationId: number; permissions: Record; + /** Repository names, within the installation's owner, the token is narrowed to. */ + repositoryNames?: string[]; /** * Skip the strategy's cache and exchange again (`dist-node/index.js:182`). A call that * overlaps another for the same installation and permissions still shares that call's @@ -388,6 +401,7 @@ function installationAuth( type: 'installation', installationId: credentials.installationId, permissions: EVIDENCE_PERMISSIONS, + repositoryNames: [...EVIDENCE_REPOSITORIES], ...(skipCache && { refresh: true }), }); });