diff --git a/.changeset/authorized-eve-approvers.md b/.changeset/authorized-eve-approvers.md new file mode 100644 index 0000000..48cff34 --- /dev/null +++ b/.changeset/authorized-eve-approvers.md @@ -0,0 +1,5 @@ +--- +"@github-tools/sdk": patch +--- + +Eve GitHub tools can now require repository permission from an authenticated GitHub approver, with a separate Vercel Connect user credential for identity proof. diff --git a/packages/github-tools/package.json b/packages/github-tools/package.json index 69e5579..9e69c3b 100644 --- a/packages/github-tools/package.json +++ b/packages/github-tools/package.json @@ -70,7 +70,7 @@ "@vercel/connect": ">=0.3.2", "@workflow/ai": "^4.1.2", "ai": "^6.0.97 || ^7.0.0", - "eve": ">=0.19.0", + "eve": ">=0.34.0", "workflow": "^4.5.0", "zod": "^4.3.6" }, @@ -99,7 +99,7 @@ "@vercel/connect": "^0.4.2", "ai": "^7.0.0", "eslint": "^10.3.0", - "eve": "^0.26.2", + "eve": "^0.34.0", "tsdown": "^0.21.10", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", diff --git a/packages/github-tools/src/connect/approver.test.ts b/packages/github-tools/src/connect/approver.test.ts new file mode 100644 index 0000000..58add19 --- /dev/null +++ b/packages/github-tools/src/connect/approver.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { connect } from '@vercel/connect/eve' +import { connectGithubApproverAuth } from './approver' + +vi.mock('@vercel/connect/eve', () => ({ connect: vi.fn() })) + +const mockedConnect = vi.mocked(connect) + +describe('connectGithubApproverAuth', () => { + it('creates a user-scoped identity provider with read:user by default', () => { + connectGithubApproverAuth('github') + + expect(mockedConnect).toHaveBeenCalledWith({ + connector: 'github', + displayName: 'GitHub', + principalType: 'user', + tokenParams: { scopes: ['read:user'] }, + }) + }) + + it('preserves supplied token parameters but removes repository selection', () => { + connectGithubApproverAuth('github', { repositories: ['vercel/sdk'], scopes: ['user:email'] }) + + expect(mockedConnect).toHaveBeenLastCalledWith(expect.objectContaining({ + tokenParams: { scopes: ['user:email'] }, + })) + }) +}) diff --git a/packages/github-tools/src/connect/approver.ts b/packages/github-tools/src/connect/approver.ts new file mode 100644 index 0000000..b8dce54 --- /dev/null +++ b/packages/github-tools/src/connect/approver.ts @@ -0,0 +1,24 @@ +import { connect } from '@vercel/connect/eve' +import type { ToolAuthProvider } from 'eve/tools' +import type { GithubConnectParams } from './types' + +/** + * Creates the user-scoped GitHub provider used to prove an eve approval + * responder's GitHub identity. It is separate from the app-scoped write token. + */ +export function connectGithubApproverAuth( + connector: string, + params: GithubConnectParams = {}, +): ToolAuthProvider { + const tokenParams = { ...params } + delete tokenParams.repositories + return connect({ + connector, + displayName: 'GitHub', + principalType: 'user', + tokenParams: { + ...tokenParams, + scopes: tokenParams.scopes ?? ['read:user'], + }, + }) +} diff --git a/packages/github-tools/src/connect/index.ts b/packages/github-tools/src/connect/index.ts index 2355848..f76cb8c 100644 --- a/packages/github-tools/src/connect/index.ts +++ b/packages/github-tools/src/connect/index.ts @@ -1,3 +1,4 @@ +export { connectGithubApproverAuth } from './approver' export { PRESET_CONNECT_SCOPES, connectGithubScopesForPreset } from './scopes' export { connectGithubToken } from './token' export { connectGithubTools } from './tools' diff --git a/packages/github-tools/src/eve.ts b/packages/github-tools/src/eve.ts index 4078689..e8554d8 100644 --- a/packages/github-tools/src/eve.ts +++ b/packages/github-tools/src/eve.ts @@ -2,7 +2,9 @@ import { buildEveToolDefinition, createEveGithubToolsDynamic } from './eve/build import type { EveToolFactoryOptions } from './eve/types' export { buildEveToolDefinition, buildEveToolMap, createEveGithubToolsDynamic, listResolvedEveToolNames } from './eve/build' -export type { EveApprovalConfig, EveApprovalValue, EveGithubToolsOptions, EveToolFactoryOptions, EveToolOverrides } from './eve/types' +export { githubRepositoryApprover } from './eve/approver' +export type { GithubRepositoryApproverOptions } from './eve/approver' +export type { EveApprovalConfig, EveApprovalValue, EveGithubToolsOptions, EveResponseApprovalConfig, EveToolFactoryOptions, EveToolOverrides } from './eve/types' export type { GithubToolPreset, PresetToolName, CombinedPresetToolNames } from './core/presets' export type { GithubToolName } from './core/tool-names' export type { GithubWriteToolName } from './core/write-tools' diff --git a/packages/github-tools/src/eve/approval.test.ts b/packages/github-tools/src/eve/approval.test.ts index 3dea614..7b1f39e 100644 --- a/packages/github-tools/src/eve/approval.test.ts +++ b/packages/github-tools/src/eve/approval.test.ts @@ -1,7 +1,7 @@ import type { ApprovalContext } from 'eve/tools' import { describe, expect, it } from 'vitest' import { always, never, once } from 'eve/tools/approval' -import { mapEveApprovalValue, resolveEveApproval } from './approval' +import { mapEveApprovalValue, resolveEveApproval, resolveEveApprovalDefinition } from './approval' const approvalCtx = { session: { id: 's1', auth: {}, turn: 1 }, @@ -48,6 +48,14 @@ describe('resolveEveApproval', () => { }) it('keeps unlisted write tools on always() fail-safe default', () => { - expect(resolveEveApproval('deleteGist', { mergePullRequest: false })!(approvalCtx)).toBe('user-approval') + expect(resolveEveApproval('deleteGist', { mergePullRequest: false })(approvalCtx)).toBe('user-approval') + }) + + it('keeps response authorization when an override replaces the request policy', () => { + const response = async () => ({ status: 'allowed' as const }) + const approval = resolveEveApprovalDefinition('mergePullRequest', undefined, response, 'never') + + expect(approval).toEqual({ request: expect.any(Function), response }) + expect((approval as { request: import('eve/tools').ApprovalPolicy }).request(approvalCtx)).toBe('not-applicable') }) }) diff --git a/packages/github-tools/src/eve/approval.ts b/packages/github-tools/src/eve/approval.ts index e4fc345..c75f8e8 100644 --- a/packages/github-tools/src/eve/approval.ts +++ b/packages/github-tools/src/eve/approval.ts @@ -1,30 +1,53 @@ -import type { Approval } from 'eve/tools' +import type { Approval, ApprovalPolicy, ApprovalResponsePolicy } from 'eve/tools' import type { GithubWriteToolName } from '../core/write-tools' import { getEveApprovalHelpers } from './load-eve' -import type { EveApprovalConfig, EveApprovalValue } from './types' +import type { EveApprovalConfig, EveApprovalValue, EveResponseApprovalConfig } from './types' -export function mapEveApprovalValue(value: EveApprovalValue): Approval { +/** Convert the request-policy shorthand accepted by the public API to eve's policy. */ +export function mapEveApprovalValue(value: EveApprovalValue): ApprovalPolicy { if (typeof value === 'function') return value const { always, never, once } = getEveApprovalHelpers() - if (value === true || value === 'always') return always() - if (value === false || value === 'never') return never() - if (value === 'once') return once() + if (value === true || value === 'always') return always() as ApprovalPolicy + if (value === false || value === 'never') return never() as ApprovalPolicy + if (value === 'once') return once() as ApprovalPolicy - return always() + return always() as ApprovalPolicy } export function resolveEveApproval( toolName: GithubWriteToolName, config: EveApprovalConfig | undefined, -): Approval | undefined { - if (config === undefined) return getEveApprovalHelpers().always() - if (config === true) return getEveApprovalHelpers().always() - if (config === false) return getEveApprovalHelpers().never() + override?: EveApprovalValue, +): ApprovalPolicy { + if (override !== undefined) return mapEveApprovalValue(override) + if (config === undefined || config === true) return getEveApprovalHelpers().always() as ApprovalPolicy + if (config === false) return getEveApprovalHelpers().never() as ApprovalPolicy - const value = config[toolName] - if (value === undefined) return getEveApprovalHelpers().always() + return mapEveApprovalValue(config[toolName] ?? true) +} + +function resolveResponsePolicy( + toolName: GithubWriteToolName, + config: EveResponseApprovalConfig | undefined, +): ApprovalResponsePolicy | undefined { + return typeof config === 'function' ? config : config?.[toolName] +} - return mapEveApprovalValue(value) +/** + * Resolve a write tool's complete approval definition in one place. + * `authorizeApprovalResponse` is intentionally independent of request-policy + * overrides, so changing when approval is requested cannot remove authorization + * of the responder. + */ +export function resolveEveApprovalDefinition( + toolName: GithubWriteToolName, + config: EveApprovalConfig | undefined, + responseConfig: EveResponseApprovalConfig | undefined, + override?: EveApprovalValue, +): Approval { + const request = resolveEveApproval(toolName, config, override) + const response = resolveResponsePolicy(toolName, responseConfig) + return response === undefined ? request : { request, response } } diff --git a/packages/github-tools/src/eve/approver.test.ts b/packages/github-tools/src/eve/approver.test.ts new file mode 100644 index 0000000..3b9c7c9 --- /dev/null +++ b/packages/github-tools/src/eve/approver.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { createOctokit } from '../client' +import { githubRepositoryApprover, normalizeGithubPermission } from './approver' + +vi.mock('../client', () => ({ createOctokit: vi.fn() })) + +const mockedCreateOctokit = vi.mocked(createOctokit) +const permissions = ['none', 'read', 'triage', 'write', 'maintain', 'admin'] as const +const minimumPermissions = ['read', 'triage', 'write', 'maintain', 'admin'] as const +const nextMinimumPermission = { + read: 'triage', + triage: 'write', + write: 'maintain', + maintain: 'admin', +} as const +let responseAuth: { getToken: ReturnType } + +function permissionFlags(permission: typeof permissions[number]) { + return { + ...(permission === 'read' && { pull: true }), + ...(permission === 'triage' && { triage: true }), + ...(permission === 'write' && { push: true }), + ...(permission === 'maintain' && { maintain: true }), + ...(permission === 'admin' && { admin: true }), + } +} + +function setup(permission: typeof permissions[number] = 'write') { + const getToken = vi.fn().mockResolvedValue({ token: 'responder-token' }) + const getAuthenticated = vi.fn().mockResolvedValue({ data: { login: 'octocat' } }) + const getCollaboratorPermissionLevel = vi.fn().mockResolvedValue({ + data: { user: { permissions: permissionFlags(permission) } }, + }) + mockedCreateOctokit.mockImplementation(token => ({ + rest: token === 'responder-token' + ? { users: { getAuthenticated } } + : { repos: { getCollaboratorPermissionLevel } }, + }) as never) + + responseAuth = { getToken } + return { getToken, getAuthenticated, getCollaboratorPermissionLevel } +} + +function respond(approver: ReturnType, toolInput: unknown = { owner: 'vercel', repo: 'sdk' }) { + return approver({ auth: responseAuth, request: { toolInput } } as never) +} + +describe('normalizeGithubPermission', () => { + it.each(permissions)('normalizes %s permission', permission => { + expect(normalizeGithubPermission(permissionFlags(permission))).toBe(permission) + }) + + it('selects the highest permission when GitHub returns multiple flags', () => { + expect(normalizeGithubPermission({ pull: true, push: true, admin: true })).toBe('admin') + }) +}) + +describe('githubRepositoryApprover', () => { + it.each(minimumPermissions)('allows %s when it meets the configured threshold', async minimumPermission => { + setup(minimumPermission) + const result = await respond(githubRepositoryApprover({ auth: {} as never, agentToken: 'agent-token', minimumPermission })) + expect(result).toEqual({ status: 'allowed' }) + }) + + it.each(['read', 'triage', 'write', 'maintain'] as const)('rejects %s when it does not meet the next threshold', async permission => { + const nextPermission = nextMinimumPermission[permission] + setup(permission) + const result = await respond(githubRepositoryApprover({ auth: {} as never, agentToken: 'agent-token', minimumPermission: nextPermission })) + expect(result).toMatchObject({ status: 'rejected' }) + }) + + it.each([undefined, {}, { owner: 1, repo: 'sdk' }, { owner: 'vercel', repo: 1 }])( + 'rejects tool input without string owner and repo', + async toolInput => { + const { getToken } = setup() + const approver = githubRepositoryApprover({ auth: {} as never, agentToken: 'agent-token' }) + const result = await approver({ auth: responseAuth, request: { toolInput } } as never) + expect(result).toMatchObject({ status: 'rejected' }) + expect(getToken).not.toHaveBeenCalled() + }, + ) + + it('uses the user-scoped provider for identity and the agent token for repository policy', async () => { + const { getToken, getCollaboratorPermissionLevel } = setup() + const provider = {} as never + await respond(githubRepositoryApprover({ auth: provider, agentToken: 'agent-token' })) + + expect(getToken).toHaveBeenCalledWith(provider, { authKey: 'github-approver', displayName: 'GitHub' }) + expect(mockedCreateOctokit).toHaveBeenNthCalledWith(1, 'responder-token') + expect(mockedCreateOctokit).toHaveBeenNthCalledWith(2, 'agent-token') + expect(getCollaboratorPermissionLevel).toHaveBeenCalledWith({ owner: 'vercel', repo: 'sdk', username: 'octocat' }) + }) + + it('converts a missing collaborator to a rejection', async () => { + const { getCollaboratorPermissionLevel } = setup() + getCollaboratorPermissionLevel.mockRejectedValue({ status: 404 }) + + await expect(respond(githubRepositoryApprover({ auth: {} as never, agentToken: 'agent-token' }))).resolves.toMatchObject({ status: 'rejected' }) + }) + + it('propagates GitHub failures other than 404', async () => { + const { getCollaboratorPermissionLevel } = setup() + const error = Object.assign(new Error('GitHub unavailable'), { status: 500 }) + getCollaboratorPermissionLevel.mockRejectedValue(error) + + await expect(respond(githubRepositoryApprover({ auth: {} as never, agentToken: 'agent-token' }))).rejects.toThrow(error) + }) +}) diff --git a/packages/github-tools/src/eve/approver.ts b/packages/github-tools/src/eve/approver.ts new file mode 100644 index 0000000..21d70a6 --- /dev/null +++ b/packages/github-tools/src/eve/approver.ts @@ -0,0 +1,91 @@ +import type { + ApprovalResponsePolicy, + ToolAuthProvider, +} from 'eve/tools' +import { createOctokit } from '../client' +import type { GithubTokenInput } from '../core/token' +import { resolveGithubToken } from '../core/token' + +export type GithubRepositoryApproverOptions = { + /** User-scoped provider used only to identify the authenticated responder. */ + auth: ToolAuthProvider + /** Agent credential used to read repository policy. Defaults to GITHUB_TOKEN. */ + agentToken?: GithubTokenInput + /** Minimum GitHub repository permission required to settle an approval. */ + minimumPermission?: 'read' | 'triage' | 'write' | 'maintain' | 'admin' +} + +export const PERMISSION_RANK = { + none: 0, + read: 1, + triage: 2, + write: 3, + maintain: 4, + admin: 5, +} as const + +/** Convert GitHub's collaborator permission flags into the policy permission. */ +export function normalizeGithubPermission(permissions: { + admin?: boolean + maintain?: boolean + push?: boolean + triage?: boolean + pull?: boolean +} | undefined): keyof typeof PERMISSION_RANK { + if (permissions?.admin) return 'admin' + if (permissions?.maintain) return 'maintain' + if (permissions?.push) return 'write' + if (permissions?.triage) return 'triage' + if (permissions?.pull) return 'read' + return 'none' +} + +/** + * Authorizes a response when its GitHub user has the configured repository permission. + * Tool inputs must contain `owner` and `repo`; tools without repository semantics reject. + */ +export function githubRepositoryApprover( + options: GithubRepositoryApproverOptions, +): ApprovalResponsePolicy { + const minimumPermission = options.minimumPermission ?? 'write' + + return async ({ auth, request }) => { + const owner = request.toolInput?.owner + const repo = request.toolInput?.repo + if (typeof owner !== 'string' || typeof repo !== 'string') { + return { + status: 'rejected', + reason: 'This GitHub action does not identify a repository and cannot use repository approver policy.', + } + } + + const { token: userToken } = await auth.getToken(options.auth, { + authKey: 'github-approver', + displayName: 'GitHub', + }) + const userClient = createOctokit(userToken) + const { data: user } = await userClient.rest.users.getAuthenticated() + + const agentClient = createOctokit(await resolveGithubToken(options.agentToken)) + try { + const { data } = await agentClient.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: user.login, + }) + const permission = normalizeGithubPermission(data.user?.permissions) + if (PERMISSION_RANK[permission] >= PERMISSION_RANK[minimumPermission]) { + return { status: 'allowed' } + } + } + catch (error) { + const status = (error as { status?: unknown }).status + if (status !== 404) throw error + } + + return { + status: 'rejected', + reason: `Your GitHub account does not have ${minimumPermission} permission for ${owner}/${repo}.`, + } + } +} diff --git a/packages/github-tools/src/eve/build.test.ts b/packages/github-tools/src/eve/build.test.ts index e26c067..020ac51 100644 --- a/packages/github-tools/src/eve/build.test.ts +++ b/packages/github-tools/src/eve/build.test.ts @@ -11,7 +11,7 @@ describe('createGithubTools eve integration', () => { } }) - it('returns a defineDynamic wrapper with session.started resolver', async () => { + it('resolves tools once when a session starts', async () => { const dynamic = createEveGithubToolsDynamic({ token: 'ghp_test', preset: 'code-review' }) expect(dynamic).toMatchObject({ kind: expect.any(String), events: { 'session.started': expect.any(Function) } }) @@ -60,18 +60,30 @@ describe('createGithubTools eve integration', () => { coreSpy.mockRestore() }) - it('maps approval config onto write tools in the dynamic set', async () => { + it('composes response authorization with global and overridden request policies', () => { + const response = vi.fn() const tools = buildEveToolMap({ token: 'ghp_test', preset: 'issue-triage', - requireApproval: { - createIssue: 'once', - addIssueComment: false, - }, + requireApproval: { createIssue: 'once' }, + authorizeApprovalResponse: response, + overrides: { createIssue: { approval: 'never' } }, }) - expect(tools.createIssue?.approval).toBeDefined() - expect(tools.addIssueComment?.approval).toBeDefined() + expect(tools.createIssue?.approval).toEqual({ + request: expect.any(Function), + response, + }) expect(tools.listIssues?.approval).toBeUndefined() }) + + it('uses a per-tool response authorization policy', () => { + const response = vi.fn() + const tool = buildEveToolDefinition('createIssue', { + token: 'ghp_test', + authorizeApprovalResponse: { createIssue: response }, + }) + + expect(tool.approval).toEqual({ request: expect.any(Function), response }) + }) }) diff --git a/packages/github-tools/src/eve/build.ts b/packages/github-tools/src/eve/build.ts index f80b406..317daf7 100644 --- a/packages/github-tools/src/eve/build.ts +++ b/packages/github-tools/src/eve/build.ts @@ -1,9 +1,10 @@ import type { ToolDefinition } from 'eve/tools' import { resolvePresetTools, type CombinedPresetToolNames, type GithubToolPreset, type PresetToolName } from '../core/presets' import { createGithubTokenResolver } from '../core/token' -import { mapEveApprovalValue, resolveEveApproval } from './approval' +import { resolveEveApprovalDefinition } from './approval' import { getEveTools } from './load-eve' import { ALL_GITHUB_TOOL_NAMES, createToolRegistry, type GithubToolName, type ToolBuildContext } from './registry' +import type { GithubWriteToolName } from '../core/write-tools' import { runGithubToolStep } from './steps' import type { EveGithubToolsOptions, EveToolFactoryOptions, EveToolOverrides } from './types' @@ -20,12 +21,26 @@ function applyOverrides( return { ...tool, ...override.description !== undefined && { description: override.description }, - ...override.approval !== undefined && { approval: mapEveApprovalValue(override.approval) }, ...override.toModelOutput !== undefined && { toModelOutput: override.toModelOutput }, ...override.outputSchema !== undefined && { outputSchema: override.outputSchema }, } } +function resolveToolApproval( + name: GithubToolName, + writeTool: GithubWriteToolName | undefined, + options: BuildOptions, +): ToolDefinition['approval'] | undefined { + if (!writeTool) return undefined + + return resolveEveApprovalDefinition( + writeTool, + options.requireApproval, + options.authorizeApprovalResponse, + options.overrides?.[name]?.approval, + ) +} + export function buildEveToolDefinition( name: GithubToolName, options: BuildOptions = {}, @@ -46,9 +61,7 @@ export function buildEveToolDefinition( const tool = defineTool({ description: entry.description, inputSchema: entry.inputSchema, - ...(entry.writeTool && { - approval: resolveEveApproval(entry.writeTool, options.requireApproval), - }), + ...(entry.writeTool && { approval: resolveToolApproval(name, entry.writeTool, options) }), ...(entry.toModelOutput && { toModelOutput: entry.toModelOutput }), execute: async (input) => runGithubToolStep(name, input as Record, ctx), }) @@ -75,9 +88,7 @@ export function buildEveToolMap(options: EveGithubToolsOptions = {}): EveToolMap const tool = defineTool({ description: entry.description, inputSchema: entry.inputSchema, - ...(entry.writeTool && { - approval: resolveEveApproval(entry.writeTool, options.requireApproval), - }), + ...(entry.writeTool && { approval: resolveToolApproval(entry.name, entry.writeTool, options) }), ...(entry.toModelOutput && { toModelOutput: entry.toModelOutput }), execute: async (input) => runGithubToolStep(entry.name, input as Record, ctx), }) diff --git a/packages/github-tools/src/eve/types.ts b/packages/github-tools/src/eve/types.ts index 456f485..667a00f 100644 --- a/packages/github-tools/src/eve/types.ts +++ b/packages/github-tools/src/eve/types.ts @@ -1,4 +1,4 @@ -import type { Approval, ToolModelOutput } from 'eve/tools' +import type { ApprovalPolicy, ApprovalResponsePolicy, ToolModelOutput } from 'eve/tools' import type { z } from 'zod' import type { GithubToolPreset } from '../core/presets' import type { GithubTokenInput } from '../core/token' @@ -11,15 +11,22 @@ import type { CommitIdentity } from '../types' * - `true` / `'always'` → require approval on every call * - `false` / `'never'` → skip approval * - `'once'` → require approval only the first time per session - * - predicate → input-dependent gate (eve `Approval` shape) + * - predicate → input-dependent request gate (eve `ApprovalPolicy`) * - eve helpers (`always()`, `once()`, `never()`) → passthrough + * + * Response authorization is configured separately with + * `authorizeApprovalResponse`, ensuring request overrides cannot remove it. */ export type EveApprovalValue = | boolean | 'always' | 'once' | 'never' - | Approval + | ApprovalPolicy + +export type EveResponseApprovalConfig = + | ApprovalResponsePolicy + | Partial> export type EveApprovalConfig = | boolean @@ -47,6 +54,8 @@ export type EveGithubToolsOptions = { * @see {@link EveApprovalConfig} for global and per-tool options. */ requireApproval?: EveApprovalConfig + /** Authorize the authenticated responder before an approval is settled. */ + authorizeApprovalResponse?: EveResponseApprovalConfig /** * Per-tool overrides for description, approval, output shaping, and output schema. *