Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/outpost/ai/src/github-evidence-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
});

Expand Down Expand Up @@ -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',
}),
});

Expand Down Expand Up @@ -859,15 +860,18 @@ 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');
elapse(60_000);
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
Expand Down
24 changes: 19 additions & 5 deletions packages/outpost/ai/src/github-evidence-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -205,6 +216,8 @@ export type InstallationTokenSource = (options: {
type: 'installation';
installationId: number;
permissions: Record<string, string>;
/** 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
Expand Down Expand Up @@ -388,6 +401,7 @@ function installationAuth(
type: 'installation',
installationId: credentials.installationId,
permissions: EVIDENCE_PERMISSIONS,
repositoryNames: [...EVIDENCE_REPOSITORIES],
...(skipCache && { refresh: true }),
});
});
Expand Down
Loading