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 }), }); });