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/apps/docs/content/docs/4.guide/5.vercel-connect.md b/apps/docs/content/docs/4.guide/5.vercel-connect.md index e258741..19f8bb5 100644 --- a/apps/docs/content/docs/4.guide/5.vercel-connect.md +++ b/apps/docs/content/docs/4.guide/5.vercel-connect.md @@ -89,6 +89,23 @@ export default connectGithubTools('github/my-connector', { }) ``` +To authorize approval responders by repository permission, use a separate user-scoped credential for identity proof: + +```ts [agent/tools/github.ts] +import { connectGithubApproverAuth, connectGithubTools } from '@github-tools/sdk/connect/eve' +import { githubRepositoryApprover } from '@github-tools/sdk/eve-runtime' + +export default connectGithubTools('github/my-connector', { + preset: 'maintainer', + authorizeApprovalResponse: githubRepositoryApprover({ + auth: connectGithubApproverAuth('github/my-connector'), + minimumPermission: 'write', + }), +}) +``` + +The agent’s app-scoped token checks repository permission; the user-scoped credential only identifies the responder. `minimumPermission` accepts `read`, `triage`, `write` (default), `maintain`, or `admin`. + `connectGithubTools` mints the Connect token **lazily** (inside each tool `execute`). Do not `await getToken(...)` at module top level in `agent/tools/`, that runs at import/build time and fails without the Vercel OIDC header. There is no dedicated starter for this path; new agents should use the [eve extension](#eve-extension) above. diff --git a/apps/docs/content/docs/5.api/2.reference.md b/apps/docs/content/docs/5.api/2.reference.md index dc8b222..7c24559 100644 --- a/apps/docs/content/docs/5.api/2.reference.md +++ b/apps/docs/content/docs/5.api/2.reference.md @@ -312,6 +312,7 @@ type EveGithubToolsOptions = { include?: GithubToolName[] exclude?: GithubToolName[] requireApproval?: boolean | Partial> + authorizeApprovalResponse?: EveResponseApprovalConfig overrides?: EveToolOverrides author?: CommitIdentity committer?: CommitIdentity @@ -323,9 +324,15 @@ type EveApprovalValue = | 'always' | 'once' | 'never' - | Approval // from eve/tools + | ApprovalPolicy // from eve/tools + +type EveResponseApprovalConfig = + | ApprovalResponsePolicy + | Partial> ``` +`githubRepositoryApprover(options)` from `@github-tools/sdk/eve-runtime` creates an `ApprovalResponsePolicy` that checks the authenticated responder’s repository permission. Its `minimumPermission` is `read`, `triage`, `write` (default), `maintain`, or `admin`. Pair it with `connectGithubApproverAuth(connector, params?)` from `@github-tools/sdk/connect/eve`, which creates the separate user-scoped credential used to identify the responder. + `include` is a set of tool names. Pass it alone to hand-pick an exact set, or alongside `preset` to add tools the preset is missing (the effective set is the **union** of both). `exclude` removes tool names from that resolved `preset` + `include` set, use it to drop a couple of tools from a larger preset. Also exports individual eve tool factories (`listPullRequests()`, `createIssue()`, …) for one-tool-per-file layouts. Approval supports `once`, predicates, and eve helper passthrough. Unlike the Workflow subpath, approval **is enforced** at runtime. ## `connectGithubTools(connector, options?)` diff --git a/apps/docs/content/docs/6.deprecated/1.eve.md b/apps/docs/content/docs/6.deprecated/1.eve.md index 71cd625..34d11d7 100644 --- a/apps/docs/content/docs/6.deprecated/1.eve.md +++ b/apps/docs/content/docs/6.deprecated/1.eve.md @@ -149,6 +149,27 @@ This is eve's headline advantage over the boolean `needsApproval` on the AI SDK Default (no `requireApproval`): all write tools → `always()`. Unlisted write tools keep the `always()` fail-safe default. Read tools never require approval. +### Authorize approval responders + +Use `authorizeApprovalResponse` to require repository permission from the authenticated person responding to an approval: + +```ts [agent/tools/github.ts] +import { connectGithubApproverAuth } from '@github-tools/sdk/connect/eve' +import { createGithubTools } from '@github-tools/sdk/eve' +import { githubRepositoryApprover } from '@github-tools/sdk/eve-runtime' + +export default createGithubTools({ + authorizeApprovalResponse: githubRepositoryApprover({ + auth: connectGithubApproverAuth('github/my-connector'), + minimumPermission: 'write', + }), +}) +``` + +`connectGithubApproverAuth` uses a separate, user-scoped Connect credential to identify the responder. `githubRepositoryApprover` uses the agent token (`GITHUB_TOKEN` by default) to verify the responder’s permission for the tool input’s `owner` and `repo`. `minimumPermission` accepts `read`, `triage`, `write` (default), `maintain`, or `admin`. + +Pass a response policy directly to apply it to every write tool, or pass a partial map keyed by write-tool name. Tools without string `owner` and `repo` inputs are rejected by the repository policy, so use a per-tool map when only selected repository tools should require it. + For durable HITL with the standard boolean/per-tool config, use [durable Workflow agents](/frameworks/vercel-workflow) with `WorkflowAgent`. See also [Control write safety](/guide/approval-control) for the AI SDK surface. ## Cherry-pick one tool per file @@ -174,6 +195,7 @@ All presets (`code-review`, `issue-triage`, `repo-explorer`, `ci-ops`, `security | `include` | Tool names to add on top of `preset` (union), or the full set standalone | | `exclude` | Tool names to remove from the resolved `preset` + `include` set | | `requireApproval` | Global, per-tool, or predicate approval (eve) | +| `authorizeApprovalResponse` | Global or per-write-tool responder authorization policy | | `overrides` | Per-tool `description`, `approval`, `toModelOutput`, `outputSchema` | | `author` / `committer` / `coAuthors` | Commit attribution for file/merge tools | diff --git a/apps/docs/skills/github-tools-agents/references/eve-agents.md b/apps/docs/skills/github-tools-agents/references/eve-agents.md index 5579c17..833d526 100644 --- a/apps/docs/skills/github-tools-agents/references/eve-agents.md +++ b/apps/docs/skills/github-tools-agents/references/eve-agents.md @@ -38,6 +38,8 @@ Tool names in the dynamic map match the AI SDK package (`listPullRequests`, `cre - Default: write tools → `always()` - `'once'`, predicates, `always()` / `never()` passthrough +- `authorizeApprovalResponse` accepts a global or per-write-tool response policy +- `githubRepositoryApprover` from `@github-tools/sdk/eve-runtime` checks the authenticated responder's repository permission; pair it with `connectGithubApproverAuth` from `@github-tools/sdk/connect/eve` - Unlike `createDurableGithubAgent`, eve approval **works durably** ## Cherry-pick diff --git a/packages/github-tools/README.md b/packages/github-tools/README.md index 6aeab93..d14146a 100644 --- a/packages/github-tools/README.md +++ b/packages/github-tools/README.md @@ -409,6 +409,22 @@ Dynamic tools are named by their **bare map key**: the model sees `listPullReque Default (no `requireApproval`): all write tools → `always()`. Unlisted write tools keep the `always()` fail-safe default. +Use `authorizeApprovalResponse` to restrict who can settle approvals. The repository policy identifies the responder with a user-scoped Connect credential, then checks that user’s repository permission with the agent token: + +```ts +import { connectGithubApproverAuth } from '@github-tools/sdk/connect/eve' +import { githubRepositoryApprover } from '@github-tools/sdk/eve-runtime' + +export default createGithubTools({ + authorizeApprovalResponse: githubRepositoryApprover({ + auth: connectGithubApproverAuth('github/my-connector'), + minimumPermission: 'write', + }), +}) +``` + +`minimumPermission` accepts `read`, `triage`, `write` (default), `maintain`, or `admin`. The policy requires string `owner` and `repo` tool inputs. Pass a per-tool map to `authorizeApprovalResponse` when only selected repository tools should use it. + Unlike the Workflow SDK subpath, eve approval **works durably**: gated tools pause the session until a human approves. #### Cherry-picking (one tool per file) diff --git a/packages/github-tools/package.json b/packages/github-tools/package.json index 0741ceb..676ffa4 100644 --- a/packages/github-tools/package.json +++ b/packages/github-tools/package.json @@ -74,7 +74,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" }, diff --git a/packages/github-tools/src/connect/eve-approver.test.ts b/packages/github-tools/src/connect/eve-approver.test.ts new file mode 100644 index 0000000..1ec3080 --- /dev/null +++ b/packages/github-tools/src/connect/eve-approver.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { connect } from '@vercel/connect/eve' +import { connectGithubApproverAuth } from './eve-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/eve-approver.ts b/packages/github-tools/src/connect/eve-approver.ts new file mode 100644 index 0000000..b8dce54 --- /dev/null +++ b/packages/github-tools/src/connect/eve-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/eve.ts b/packages/github-tools/src/connect/eve.ts index 0b7655b..fa50c0e 100644 --- a/packages/github-tools/src/connect/eve.ts +++ b/packages/github-tools/src/connect/eve.ts @@ -1,42 +1,28 @@ import { createGithubTools as createEveGithubTools } from '../eve' -import type { GithubConnectorInput } from './connector' +import { connectGithubApproverAuth } from './eve-approver' import { connectGithubToken } from './token' import type { ConnectGithubEveToolsOptions } from './types' +export { connectGithubApproverAuth } + /** * Register eve GitHub tools backed by a Vercel Connect connector. - * Scopes are derived from `preset`, or from the resolved `include`/`exclude` - * tool set when those are set, unless overridden in `connect.scopes`. - * - * `connector` may be a static name or a resolver function — e.g. to pick a - * different connector per environment (production vs. preview) or tenant. - * - * @deprecated Use the mountable `@github-tools/eve-extension` instead and pass `connector` - * directly to `githubExtension(...)` — no separate Connect import is needed. This direct - * import is also **not durable** under multi-turn eve Workflow replay (`defineTool` inside - * `node_modules` is not hoisted); mount `@github-tools/eve-extension` instead - * (see https://github.com/vercel-labs/github-tools/issues/51 and - * https://github-tools.com/frameworks/eve-extension). - * - * Shared runtime helpers used by the extension are on `@github-tools/sdk/eve-runtime` - * (not deprecated). + * Scopes are derived from `preset` unless overridden in `connect.scopes`. * * TODO(eve-connect-bundle): eve's authored-module bundler inlines workspace-linked * SDK code and code-splits `@vercel/connect` unless the agent sets - * `build.externalDependencies: ['@vercel/connect']` in `agent.ts`. Prefer the - * eve extension (pre-built) so that workaround is unnecessary. + * `build.externalDependencies: ['@vercel/connect']` in `agent.ts`. Remove that + * requirement when upstream eve externalizes this path. */ export function connectGithubTools( - connector: GithubConnectorInput, + connector: string, options: ConnectGithubEveToolsOptions = {}, ) { - const { connect, preset, include, exclude, ...rest } = options + const { connect, preset, ...rest } = options return createEveGithubTools({ ...rest, preset, - include, - exclude, - token: connectGithubToken(connector, { preset, include, exclude, params: connect }), + token: connectGithubToken(connector, { preset, params: connect }), }) } diff --git a/packages/github-tools/src/eve-runtime.ts b/packages/github-tools/src/eve-runtime.ts index 55b6b67..1208132 100644 --- a/packages/github-tools/src/eve-runtime.ts +++ b/packages/github-tools/src/eve-runtime.ts @@ -14,10 +14,13 @@ export { executeGithubEveTool, } from './eve/build' export { mapEveApprovalValue, resolveEveApproval, resolveEveToolApproval, isEveApprovalDisabled } from './eve/approval' +export { githubRepositoryApprover } from './eve/approver' +export type { GithubRepositoryApproverOptions } from './eve/approver' export type { EveApprovalConfig, EveApprovalValue, EveGithubToolsOptions, + EveResponseApprovalConfig, EveToolFactoryOptions, EveToolOverrides, } from './eve/types' diff --git a/packages/github-tools/src/eve/approval.ts b/packages/github-tools/src/eve/approval.ts index 709ab98..0537ff7 100644 --- a/packages/github-tools/src/eve/approval.ts +++ b/packages/github-tools/src/eve/approval.ts @@ -1,60 +1,65 @@ -import type { Approval, ApprovalPolicy } 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' -/** - * `false` / `'never'` should omit the tool `approval` field entirely. - * eve treats a missing `approval` like `never()`; attaching `never()` is - * redundant and has caused approval UI noise on some channels. - */ export function isEveApprovalDisabled(value: EveApprovalValue | undefined): boolean { return value === false || value === 'never' } +/** 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() as ApprovalPolicy + if (value === false || value === 'never') return never() as ApprovalPolicy + if (value === 'once') return once() as ApprovalPolicy + return always() as ApprovalPolicy +} - if (value === true || value === 'always') return always() - if (value === false || value === 'never') return never() - if (value === 'once') return once() - - return always() +function resolveRequestPolicy( + toolName: GithubWriteToolName, + config: EveApprovalConfig | undefined, + 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 + return mapEveApprovalValue(config[toolName] ?? true) } export function resolveEveApproval( toolName: GithubWriteToolName, config: EveApprovalConfig | undefined, ): ApprovalPolicy { - if (config === undefined) return getEveApprovalHelpers().always() - if (config === true) return getEveApprovalHelpers().always() - if (config === false) return getEveApprovalHelpers().never() - - const value = config[toolName] - if (value === undefined) return getEveApprovalHelpers().always() + return resolveRequestPolicy(toolName, config) +} - return mapEveApprovalValue(value) +function resolveResponsePolicy( + toolName: GithubWriteToolName, + config: EveResponseApprovalConfig | undefined, +): ApprovalResponsePolicy | undefined { + return typeof config === 'function' ? config : config?.[toolName] } -/** - * Approval to attach on a write tool, or `undefined` to omit the field. - * Prefer this when building `defineTool` values so `false` / `'never'` do not - * attach a redundant `never()` handler. - */ +/** Resolve the complete approval definition without allowing request overrides to drop responder authorization. */ export function resolveEveToolApproval( toolName: GithubWriteToolName, config: EveApprovalConfig | undefined, override?: EveApprovalValue, + responseConfig?: EveResponseApprovalConfig, ): Approval | undefined { - if (override !== undefined) { - if (isEveApprovalDisabled(override)) return undefined - return mapEveApprovalValue(override) - } - if (config === false) return undefined - if (typeof config === 'object' && config !== null && isEveApprovalDisabled(config[toolName])) { - return undefined - } - return resolveEveApproval(toolName, config) + const response = resolveResponsePolicy(toolName, responseConfig) + const disabled = override !== undefined + ? isEveApprovalDisabled(override) + : config === false || (typeof config === 'object' && config !== null && isEveApprovalDisabled(config[toolName])) + + // Omit disabled approvals unless a response policy was explicitly configured. + // In that case retain the complete definition so request-policy overrides do + // not silently discard responder authorization. + if (disabled && response === undefined) return undefined + + const request = resolveRequestPolicy(toolName, config, override) + 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 8abc805..51b840c 100644 --- a/packages/github-tools/src/eve/build.test.ts +++ b/packages/github-tools/src/eve/build.test.ts @@ -11,11 +11,11 @@ describe('createGithubTools eve integration', () => { } }) - it('returns a defineDynamic wrapper with step.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: { 'step.started': expect.any(Function) } }) + expect(dynamic).toMatchObject({ kind: expect.any(String), events: { 'session.started': expect.any(Function) } }) - const tools = await dynamic.events['step.started']!({}, {} as never) + const tools = await dynamic.events['session.started']!({}, {} as never) expect(Object.keys(tools!).sort()).toEqual([...PRESET_TOOLS['code-review']].sort()) }) @@ -60,70 +60,40 @@ describe('createGithubTools eve integration', () => { coreSpy.mockRestore() }) - it('restricts to an exact allow-list via `include`', () => { + it('composes response authorization with global and overridden request policies', () => { + const response = vi.fn() const tools = buildEveToolMap({ token: 'ghp_test', - include: ['getRepository', 'mergePullRequest'], + preset: 'issue-triage', + requireApproval: { createIssue: 'once' }, + authorizeApprovalResponse: response, + overrides: { createIssue: { approval: 'never' } }, }) - expect(Object.keys(tools).sort()).toEqual(['getRepository', 'mergePullRequest']) - }) - - it('unions `preset` and `include` when both are provided', () => { - const tools = buildEveToolMap({ - token: 'ghp_test', - preset: 'code-review', - // mergePullRequest is not part of the code-review preset — `include` adds it. - include: ['mergePullRequest'], + expect(tools.createIssue?.approval).toEqual({ + request: expect.any(Function), + response, }) - - expect(Object.keys(tools).sort()).toEqual([...PRESET_TOOLS['code-review'], 'mergePullRequest'].sort()) + expect(tools.listIssues?.approval).toBeUndefined() }) - it('removes tools via `exclude`, applied after `preset` + `include`', () => { - const tools = buildEveToolMap({ + it('uses a per-tool response authorization policy', () => { + const response = vi.fn() + const tool = buildEveToolDefinition('createIssue', { token: 'ghp_test', - preset: 'code-review', - include: ['mergePullRequest'], - exclude: ['getBlame', 'mergePullRequest'], + authorizeApprovalResponse: { createIssue: response }, }) - const expected = [...PRESET_TOOLS['code-review'], 'mergePullRequest'] - .filter(name => !['getBlame', 'mergePullRequest'].includes(name)) - - expect(Object.keys(tools).sort()).toEqual(expected.sort()) + expect(tool.approval).toEqual({ request: expect.any(Function), response }) }) - it('resolves the same `include` allow-list via listResolvedEveToolNames', () => { - expect(listResolvedEveToolNames({ include: ['getRepository', 'mergePullRequest'] }).sort()) - .toEqual(['getRepository', 'mergePullRequest']) - }) - - it('unions preset + include and applies exclude via listResolvedEveToolNames', () => { - const names = listResolvedEveToolNames({ - preset: 'code-review', - include: ['mergePullRequest'], - exclude: ['getBlame', 'mergePullRequest'], - }) - - const expected = [...PRESET_TOOLS['code-review'], 'mergePullRequest'] - .filter(name => !['getBlame', 'mergePullRequest'].includes(name)) - - expect(names.sort()).toEqual(expected.sort()) - }) - - it('maps approval config onto write tools in the dynamic set', async () => { - const tools = buildEveToolMap({ + it('keeps explicit approval overrides on read tools', () => { + const approval = vi.fn() + const tool = buildEveToolDefinition('getFileContent', { token: 'ghp_test', - preset: 'issue-triage', - requireApproval: { - createIssue: 'once', - addIssueComment: false, - }, + overrides: { getFileContent: { approval } }, }) - expect(tools.createIssue?.approval).toBeDefined() - expect(tools.addIssueComment?.approval).toBeUndefined() - expect(tools.listIssues?.approval).toBeUndefined() + expect(tool.approval).toBe(approval) }) }) diff --git a/packages/github-tools/src/eve/build.ts b/packages/github-tools/src/eve/build.ts index 5c5c267..2c67c0c 100644 --- a/packages/github-tools/src/eve/build.ts +++ b/packages/github-tools/src/eve/build.ts @@ -1,7 +1,7 @@ import type { ToolDefinition } from 'eve/tools' import { resolvePresetTools, type CombinedPresetToolNames, type GithubToolPreset, type PresetToolName } from '../core/presets' import { createGithubTokenResolver } from '../core/token' -import { isEveApprovalDisabled, mapEveApprovalValue, resolveEveToolApproval } from './approval' +import { mapEveApprovalValue, resolveEveToolApproval } from './approval' import { getEveTools } from './load-eve' import { ALL_GITHUB_TOOL_NAMES, createToolRegistry, type GithubToolName, type ToolBuildContext } from './registry' import { runGithubToolStep } from './steps' @@ -16,9 +16,6 @@ function resolveAllowedToolNames( const presetAllowed = options.preset ? resolvePresetTools(options.preset) : null const includeAllowed = options.include ? new Set(options.include) : null const excluded = options.exclude ? new Set(options.exclude) : null - - // `preset` and `include` compose as a union (add tools a preset is missing); - // `exclude` always subtracts from that combined set afterward. const allowed = presetAllowed && includeAllowed ? new Set([...presetAllowed, ...includeAllowed]) : presetAllowed ?? includeAllowed @@ -33,86 +30,66 @@ function applyOverrides( ): T { const override = overrides?.[name] if (!override) return tool - - const next: T = { + return { ...tool, ...override.description !== undefined && { description: override.description }, ...override.toModelOutput !== undefined && { toModelOutput: override.toModelOutput }, ...override.outputSchema !== undefined && { outputSchema: override.outputSchema }, } - - if (override.approval === undefined) return next - if (isEveApprovalDisabled(override.approval)) { - const rest = { ...next } - delete (rest as { approval?: unknown }).approval - return rest - } - return { ...next, approval: mapEveApprovalValue(override.approval) } } -export function buildEveToolDefinition( - name: GithubToolName, - options: BuildOptions = {}, -): ToolDefinition { - const { defineTool } = getEveTools() - const ctx: ToolBuildContext = { +function createBuildContext(options: BuildOptions): ToolBuildContext { + return { token: createGithubTokenResolver(options.token), context: options.context, author: options.author, committer: options.committer, coAuthors: options.coAuthors, } +} +function defineGithubTool( + name: GithubToolName, + ctx: ToolBuildContext, + options: BuildOptions, +): ToolDefinition { + const { defineTool } = getEveTools() const entry = createToolRegistry(ctx).find(tool => tool.name === name) - if (!entry) { - throw new Error(`Unknown GitHub tool: ${name}`) - } + if (!entry) throw new Error(`Unknown GitHub tool: ${name}`) const approval = entry.writeTool - ? resolveEveToolApproval(entry.writeTool, options.requireApproval) - : undefined - - const tool = defineTool({ + ? resolveEveToolApproval( + entry.writeTool, + options.requireApproval, + options.overrides?.[name]?.approval, + options.authorizeApprovalResponse, + ) + : options.overrides?.[name]?.approval === undefined + ? undefined + : mapEveApprovalValue(options.overrides[name]!.approval!) + + return defineTool({ description: entry.description, inputSchema: entry.inputSchema, - ...(approval && { approval }), + ...(approval !== undefined && { approval }), ...(entry.toModelOutput && { toModelOutput: entry.toModelOutput }), - execute: async (input) => runGithubToolStep(name, input as Record, ctx), + execute: async input => runGithubToolStep(name, input as Record, ctx), }) +} - return applyOverrides(tool, name, options.overrides) +export function buildEveToolDefinition(name: GithubToolName, options: BuildOptions = {}): ToolDefinition { + const ctx = createBuildContext(options) + return applyOverrides(defineGithubTool(name, ctx, options), name, options.overrides) } export function buildEveToolMap(options: EveGithubToolsOptions = {}): EveToolMap { - const { defineTool } = getEveTools() - const ctx: ToolBuildContext = { - token: createGithubTokenResolver(options.token), - context: options.context, - author: options.author, - committer: options.committer, - coAuthors: options.coAuthors, - } - + const ctx = createBuildContext(options) const isAllowed = resolveAllowedToolNames(options) - const registry = createToolRegistry(ctx) const tools = {} as EveToolMap - for (const entry of registry) { - if (!isAllowed(entry.name)) continue - - const approval = entry.writeTool - ? resolveEveToolApproval(entry.writeTool, options.requireApproval) - : undefined - - const tool = defineTool({ - description: entry.description, - inputSchema: entry.inputSchema, - ...(approval && { approval }), - ...(entry.toModelOutput && { toModelOutput: entry.toModelOutput }), - execute: async (input) => runGithubToolStep(entry.name, input as Record, ctx), - }) - - tools[entry.name] = applyOverrides(tool, entry.name, options.overrides) + for (const { name } of createToolRegistry(ctx)) { + if (!isAllowed(name)) continue + tools[name] = applyOverrides(defineGithubTool(name, ctx, options), name, options.overrides) } return tools @@ -120,12 +97,9 @@ export function buildEveToolMap(options: EveGithubToolsOptions = {}): EveToolMap export function createEveGithubToolsDynamic(options: EveGithubToolsOptions = {}) { const { defineDynamic } = getEveTools() - - // TODO(eve-auth): resolve token from ctx.getToken('github') when eve-managed auth lands. - // Deferred — eve does not yet expose managed GitHub tokens on the session context. return defineDynamic({ events: { - 'step.started': async () => buildEveToolMap(options), + 'session.started': async () => buildEveToolMap(options), }, }) } @@ -135,23 +109,11 @@ export function listResolvedEveToolNames

(options: { export function listResolvedEveToolNames

(options: { preset: P, include?: undefined, exclude?: undefined }): CombinedPresetToolNames

[] export function listResolvedEveToolNames(options: Pick): GithubToolName[] export function listResolvedEveToolNames(options: Pick = {}): GithubToolName[] { - const isAllowed = resolveAllowedToolNames(options) - return ALL_GITHUB_TOOL_NAMES.filter(isAllowed) + return ALL_GITHUB_TOOL_NAMES.filter(resolveAllowedToolNames(options)) } -/** - * Tool descriptors (no `execute`) for authored eve `defineTool` loops. - * Prefer this when registering tools outside the SDK package so durable transforms can hoist inline execute. - */ export function listEveToolDescriptors(options: EveGithubToolsOptions = {}) { - const ctx: ToolBuildContext = { - token: createGithubTokenResolver(options.token), - context: options.context, - author: options.author, - committer: options.committer, - coAuthors: options.coAuthors, - } - + const ctx = createBuildContext(options) const isAllowed = resolveAllowedToolNames(options) return createToolRegistry(ctx) .filter(entry => isAllowed(entry.name)) @@ -164,21 +126,10 @@ export function listEveToolDescriptors(options: EveGithubToolsOptions = {}) { })) } -/** - * Execute a GitHub tool by name with the given eve options (token/context/attribution). - * Used by `@github-tools/eve-extension` so `execute` only closes over a serializable tool name. - */ export async function executeGithubEveTool( name: GithubToolName, input: Record, options: EveGithubToolsOptions = {}, ) { - const ctx: ToolBuildContext = { - token: createGithubTokenResolver(options.token), - context: options.context, - author: options.author, - committer: options.committer, - coAuthors: options.coAuthors, - } - return runGithubToolStep(name, input, ctx) + return runGithubToolStep(name, input, createBuildContext(options)) } diff --git a/packages/github-tools/src/eve/registry.ts b/packages/github-tools/src/eve/registry.ts index 0aafa3c..a6dc2a2 100644 --- a/packages/github-tools/src/eve/registry.ts +++ b/packages/github-tools/src/eve/registry.ts @@ -36,7 +36,7 @@ export type ToolBuildContext = { coAuthors?: CommitIdentity[] } -type ToolRegistryEntry = { +export type ToolRegistryEntry = { name: GithubToolName writeTool?: GithubWriteToolName description: string diff --git a/packages/github-tools/src/eve/types.ts b/packages/github-tools/src/eve/types.ts index 39f02a8..bccb9d8 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 { GithubToolsContext } from '../core/context' import type { GithubToolPreset } from '../core/presets' @@ -12,15 +12,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 @@ -61,6 +68,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. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f3f3e0..a5b0561 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: '@ai-sdk/provider-utils': 5.0.28 + '@ai-sdk/gateway@3>@ai-sdk/provider-utils': 4.0.41 importers: @@ -415,6 +416,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.41': + resolution: {integrity: sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.28': resolution: {integrity: sha512-TnHUyd/rCYQqHg5RuiOaz/hUql6U+kbUaBW0Rp+0N5UhnAInA9CzzV0HXvuAAPwppDsK6fAx9Rd+tRawpJ/3pg==} engines: {node: '>=22'} @@ -1558,6 +1565,10 @@ packages: '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@fingerprintjs/botd@2.0.0': resolution: {integrity: sha512-yhuz23NKEcBDTHmGz/ULrXlGnbHenO+xZmVwuBkuqHUkqvaZ5TAA0kAgcRy4Wyo5dIBdkIf57UXX8/c9UlMLJg==} @@ -11070,6 +11081,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.28.0: resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} @@ -11999,7 +12014,7 @@ snapshots: '@ai-sdk/gateway@3.0.143(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 5.0.28(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) '@vercel/oidc': 3.2.0 zod: 4.3.6 optional: true @@ -12007,7 +12022,7 @@ snapshots: '@ai-sdk/gateway@3.0.164(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.14 - '@ai-sdk/provider-utils': 5.0.28(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) '@vercel/oidc': 3.2.0 zod: 4.3.6 optional: true @@ -12015,7 +12030,7 @@ snapshots: '@ai-sdk/gateway@3.0.164(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.14 - '@ai-sdk/provider-utils': 5.0.28(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.41(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 @@ -12082,6 +12097,23 @@ snapshots: zod: 4.3.6 optional: true + '@ai-sdk/provider-utils@4.0.41(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 5.29.0 + zod: 4.3.6 + optional: true + + '@ai-sdk/provider-utils@4.0.41(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 5.29.0 + zod: 4.4.3 + '@ai-sdk/provider-utils@5.0.28(zod@4.3.6)': dependencies: '@ai-sdk/provider': 4.0.7 @@ -13203,6 +13235,8 @@ snapshots: '@fastify/accept-negotiator@2.0.1': optional: true + '@fastify/busboy@2.1.1': {} + '@fingerprintjs/botd@2.0.0': {} '@floating-ui/core@1.8.0': @@ -26179,6 +26213,10 @@ snapshots: undici-types@8.3.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.28.0: {} undici@7.26.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e636173..8bc484f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,4 +34,8 @@ minimumReleaseAgeExclude: overrides: '@ai-sdk/provider-utils': 5.0.28 - + # @ai-sdk/gateway@3 requires provider-utils v4 (it imports + # createProviderToolFactoryWithOutputSchema, which was removed in v5). + # Scope the override so gateway v3's subtree keeps v4 while first-party + # ai v7 packages continue to dedupe on v5. + '@ai-sdk/gateway@3>@ai-sdk/provider-utils': 4.0.41