From 2d4762f913ffe078b5d02ba98ed12fd18b8769fc Mon Sep 17 00:00:00 2001 From: benpankow Date: Thu, 30 Jul 2026 12:42:30 -0700 Subject: [PATCH] feat(eve): add approval response authorization API Signed-off-by: benpankow --- .changeset/approval-response-api.md | 5 ++ .../compatibility/connection/v2.ts | 6 ++ .../compatibility/dynamicTool/v6.ts | 12 +++ .../compatibility/tool/v3.ts | 19 +++++ .../reports/connection/v3.json | 15 ++++ .../reports/dynamicTool/v7.json | 13 +++ .../extension-contracts/reports/tool/v4.json | 21 +++++ .../src/compiler/extension-compatibility.ts | 6 +- .../eve/src/context/build-dynamic-tools.ts | 34 +++++++- .../context/dynamic-tool-lifecycle.test.ts | 66 ++++++++++++++- .../eve/src/context/dynamic-tool-lifecycle.ts | 21 ++++- packages/eve/src/context/keys.ts | 1 + .../execution/tool-auth.integration.test.ts | 43 +++++++++- packages/eve/src/execution/tool-auth.ts | 53 ++++++++++++ packages/eve/src/harness/tools.test.ts | 3 +- packages/eve/src/harness/tools.ts | 4 +- .../src/public/definitions/approval.test.ts | 40 ++++++++++ .../eve/src/public/definitions/approval.ts | 80 +++++++++++++++---- .../public/tools/approval/approval-helpers.ts | 8 +- packages/eve/src/public/tools/index.ts | 14 +++- .../eve/src/runtime/connections/principal.ts | 11 ++- .../connections/scoped-authorization.ts | 33 +++++--- packages/eve/src/runtime/connections/types.ts | 5 ++ 23 files changed, 467 insertions(+), 46 deletions(-) create mode 100644 .changeset/approval-response-api.md create mode 100644 packages/eve/extension-contracts/compatibility/connection/v2.ts create mode 100644 packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts create mode 100644 packages/eve/extension-contracts/compatibility/tool/v3.ts create mode 100644 packages/eve/extension-contracts/reports/connection/v3.json create mode 100644 packages/eve/extension-contracts/reports/dynamicTool/v7.json create mode 100644 packages/eve/extension-contracts/reports/tool/v4.json create mode 100644 packages/eve/src/public/definitions/approval.test.ts diff --git a/.changeset/approval-response-api.md b/.changeset/approval-response-api.md new file mode 100644 index 000000000..03bd4d2c0 --- /dev/null +++ b/.changeset/approval-response-api.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Tools and connections can now define an optional response-time approval authorizer alongside their request-time approval policy, and authorization token results can expose a stable provider subject. diff --git a/packages/eve/extension-contracts/compatibility/connection/v2.ts b/packages/eve/extension-contracts/compatibility/connection/v2.ts new file mode 100644 index 000000000..e188ef179 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/connection/v2.ts @@ -0,0 +1,6 @@ +import { defineMcpClientConnection } from "#public/connections/index.js"; + +export default defineMcpClientConnection({ + url: "https://example.com/mcp", + description: "Example MCP service", +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts b/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts new file mode 100644 index 000000000..624876617 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts @@ -0,0 +1,12 @@ +import { defineDynamic, defineTool } from "#public/tools/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineTool({ + description: "Return the current status.", + inputSchema: { type: "object", properties: {} }, + execute: () => ({ status: "ready" }), + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/tool/v3.ts b/packages/eve/extension-contracts/compatibility/tool/v3.ts new file mode 100644 index 000000000..801d59638 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v3.ts @@ -0,0 +1,19 @@ +import { defineTool } from "#public/tools/index.js"; + +export default defineTool({ + description: "Summarize a report for the model", + inputSchema: { type: "object", properties: {} }, + async execute(_input, ctx) { + return { + internal: "details", + sessionId: ctx.session.id, + summary: "Report generated", + }; + }, + toModelOutput(output) { + return { + type: "text", + value: (output as { summary: string }).summary, + }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/connection/v3.json b/packages/eve/extension-contracts/reports/connection/v3.json new file mode 100644 index 000000000..4e277fb57 --- /dev/null +++ b/packages/eve/extension-contracts/reports/connection/v3.json @@ -0,0 +1,15 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "connection", + "epoch": 3, + "sha256": "d710e0f9607b6f58b4b488547844c452a3278b36cfbedcbb987c93d5b753fcf2", + "exports": [ + "ConnectionAuthorizationFailedError", + "ConnectionAuthorizationRequiredError", + "defineInteractiveAuthorization", + "defineMcpClientConnection", + "defineOpenAPIConnection", + "isConnectionAuthorizationFailedError", + "isConnectionAuthorizationRequiredError" + ] +} diff --git a/packages/eve/extension-contracts/reports/dynamicTool/v7.json b/packages/eve/extension-contracts/reports/dynamicTool/v7.json new file mode 100644 index 000000000..7fff81809 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicTool/v7.json @@ -0,0 +1,13 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicTool", + "epoch": 7, + "sha256": "d902fabb205bcf92a3bd0ab520823df7f255753094f25ba3fe5659e03b4fd79a", + "exports": [ + "DynamicToolEntry", + "DynamicToolEvents", + "DynamicToolResult", + "DynamicToolSet", + "defineDynamic" + ] +} diff --git a/packages/eve/extension-contracts/reports/tool/v4.json b/packages/eve/extension-contracts/reports/tool/v4.json new file mode 100644 index 000000000..653d24108 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v4.json @@ -0,0 +1,21 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 4, + "sha256": "89d078c450aee7df093ea8ec955015e392b18c98af7b440f0d96945918112d1a", + "exports": [ + "defineBashTool", + "defineGlobTool", + "defineGrepTool", + "defineReadFileTool", + "defineTool", + "defineWriteFileTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom" + ] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 704f06ac6..7b1ee3e69 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -21,9 +21,9 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, - tool: { current: 3, supported: [1, 2, 3], dropped: {} }, - dynamicTool: { current: 6, supported: [1, 2, 3, 4, 5, 6], dropped: {} }, - connection: { current: 2, supported: [1, 2], dropped: {} }, + tool: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, + dynamicTool: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} }, + connection: { current: 3, supported: [1, 2, 3], dropped: {} }, hook: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, skill: { current: 1, supported: [1], dropped: {} }, dynamicSkill: { current: 3, supported: [1, 2, 3], dropped: {} }, diff --git a/packages/eve/src/context/build-dynamic-tools.ts b/packages/eve/src/context/build-dynamic-tools.ts index 4df436eac..ac8a4e2aa 100644 --- a/packages/eve/src/context/build-dynamic-tools.ts +++ b/packages/eve/src/context/build-dynamic-tools.ts @@ -8,7 +8,12 @@ import { import type { DurableDynamicToolMetadata } from "#context/keys.js"; import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; import { createLogger } from "#internal/logging.js"; -import type { ApprovalContext, ApprovalStatus } from "#public/definitions/approval.js"; +import type { + ApprovalContext, + ApprovalResponseAuthorization, + ApprovalResponseContext, + ApprovalStatus, +} from "#public/definitions/approval.js"; import { toInputSchema, toOutputSchema } from "#shared/tool-schema.js"; const log = createLogger("dynamic-tools"); @@ -79,8 +84,33 @@ function buildReplayedApproval( return () => "user-approval"; } - return async (approvalCtx: ApprovalContext) => + const policy = async (approvalCtx: ApprovalContext) => (await approvalStepFn(metadata.closureVars ?? {}, approvalCtx)) as ApprovalStatus; + if (metadata.approvalResponseStepFnName === undefined) return policy; + + const responseStepFn = lookupStepFunction(metadata.approvalResponseStepFnName); + if (responseStepFn === null) { + log.warn( + `Dynamic tool "${metadata.name}" references response authorizer ` + + `"${metadata.approvalResponseStepFnName}" which is not registered — rejecting responses.`, + ); + return { + policy, + authorizeResponse: async () => ({ + safeReason: "Approval response authorization is temporarily unavailable.", + status: "rejected" as const, + }), + }; + } + + return { + policy, + authorizeResponse: async (responseCtx: ApprovalResponseContext) => + (await responseStepFn( + metadata.closureVars ?? {}, + responseCtx, + )) as ApprovalResponseAuthorization, + }; } /** diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts index 335cadf61..e96cec46a 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { DynamicToolEntry } from "#shared/dynamic-tool-definition.js"; import type { DurableDynamicToolMetadata } from "#context/keys.js"; -import type { ApprovalContext } from "#public/definitions/approval.js"; +import { resolveApprovalPolicy, type ApprovalContext } from "#public/definitions/approval.js"; import { defineTool, type ToolContext } from "#public/definitions/tool.js"; import { serializeOutputSchema, type ToolSchema } from "#shared/tool-schema.js"; @@ -1144,7 +1144,7 @@ describe("framework dynamic tools (no bundler transform)", () => { expect(tools[0]!.name).toBe("risky"); expect(tools[0]!.approval).toBe(approvalFn); const approvalCtx = createApprovalContext({ toolName: "risky" }); - expect(tools[0]!.approval!(approvalCtx)).toBe("user-approval"); + expect(resolveApprovalPolicy(tools[0]!.approval!)(approvalCtx)).toBe("user-approval"); expect(testRegistry.has("eve:framework-dynamic:connection:risky")).toBe(false); expect(testRegistry.has("eve:dynamic-tool-approval:connection:risky")).toBe(false); }); @@ -1178,10 +1178,70 @@ describe("framework dynamic tools (no bundler transform)", () => { toolInput: { draftId: "draft_123" }, toolName: "guarded", }); - await expect(tools[0]!.approval!(approvalCtx)).resolves.toBe("user-approval"); + await expect(resolveApprovalPolicy(tools[0]!.approval!)(approvalCtx)).resolves.toBe( + "user-approval", + ); expect(approvalFn).toHaveBeenCalledExactlyOnceWith(approvalCtx); }); + it("replays response authorization from session-scoped dynamic tools", async () => { + const ctx = createCtx(); + const authorizeResponse = vi.fn(async () => "allowed" as const); + const entry: DynamicToolEntry = { + approval: { + authorizeResponse, + policy: async () => "user-approval" as const, + }, + description: "destructive op", + execute: async (): Promise => ({ ok: true }), + inputSchema: { type: "object" }, + }; + const resolver = createResolver("session_guard", ["session.started"], () => ({ + guarded: entry, + })); + + await dispatchDynamicToolEvent({ + ctx, + event: makeEvent("session.started"), + messages: [], + resolvers: [resolver], + }); + ctx.clearVirtualContext(); + + const approval = buildDynamicTools(ctx)[0]!.approval; + if (approval === undefined || typeof approval === "function") { + throw new Error("Expected replayed approval configuration."); + } + const responseCtx = { + auth: { + getToken: vi.fn(), + requireAuth: () => { + throw new Error("not implemented"); + }, + }, + request: { + callId: "call_1", + requestId: "approval_1", + toolInput: { owner: "vercel", repo: "eve" }, + toolName: "guarded", + }, + responder: { + attributes: {}, + authenticator: "slack", + principalId: "U123", + principalType: "user", + }, + session: { + id: "test-session", + initiator: null, + turn: { id: "test-turn", sequence: 0 }, + }, + }; + + await expect(approval.authorizeResponse!(responseCtx)).resolves.toBe("allowed"); + expect(authorizeResponse).toHaveBeenCalledExactlyOnceWith(responseCtx); + }); + it("propagates outputSchema from dynamic entries into harness tools and metadata", async () => { const ctx = createCtx(); const outputSchema = { diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.ts b/packages/eve/src/context/dynamic-tool-lifecycle.ts index 5b05c591b..a4d006d32 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.ts @@ -1,7 +1,11 @@ import type { ModelMessage } from "ai"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; -import type { ApprovalContext } from "#public/definitions/approval.js"; +import { + resolveApprovalPolicy, + type ApprovalContext, + type ApprovalResponseContext, +} from "#public/definitions/approval.js"; import type { DynamicToolEntry } from "#shared/dynamic-tool-definition.js"; import type { UnstampedMessageStreamEvent, SessionStartedStreamEvent } from "#protocol/message.js"; import { @@ -294,12 +298,24 @@ async function resolveToolsFromEvent( } let approvalStepFnName: string | undefined; + let approvalResponseStepFnName: string | undefined; if (entry.approval !== undefined) { approvalStepFnName = `eve:dynamic-tool-approval:${resolver.slug}:${entryKey}`; - const originalApproval = entry.approval.bind(entry); + const originalApproval = resolveApprovalPolicy(entry.approval).bind(entry); registerStepFunction(approvalStepFnName, (_closureVars: unknown, approvalCtx: unknown) => originalApproval(approvalCtx as ApprovalContext), ); + + const authorizeResponse = + typeof entry.approval === "function" ? undefined : entry.approval.authorizeResponse; + if (authorizeResponse !== undefined) { + approvalResponseStepFnName = `eve:dynamic-tool-approval-response:${resolver.slug}:${entryKey}`; + registerStepFunction( + approvalResponseStepFnName, + (_closureVars: unknown, responseCtx: unknown) => + authorizeResponse(responseCtx as ApprovalResponseContext), + ); + } } metadata.push({ @@ -311,6 +327,7 @@ async function resolveToolsFromEvent( entryKey, executeStepFnName, approvalStepFnName, + approvalResponseStepFnName, closureVars: serializedClosureVars, }); } diff --git a/packages/eve/src/context/keys.ts b/packages/eve/src/context/keys.ts index a198e1db6..083055f0f 100644 --- a/packages/eve/src/context/keys.ts +++ b/packages/eve/src/context/keys.ts @@ -126,6 +126,7 @@ export interface DurableDynamicToolMetadata { readonly entryKey: string; readonly executeStepFnName?: string; readonly approvalStepFnName?: string; + readonly approvalResponseStepFnName?: string; readonly closureVars?: Record; } diff --git a/packages/eve/src/execution/tool-auth.integration.test.ts b/packages/eve/src/execution/tool-auth.integration.test.ts index f83f7d17b..89fc1813e 100644 --- a/packages/eve/src/execution/tool-auth.integration.test.ts +++ b/packages/eve/src/execution/tool-auth.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; +import { buildApprovalResponseAuth, createToolExecuteWithAuth } from "#execution/tool-auth.js"; import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js"; import { loadContext } from "#context/container.js"; import { AuthKey, SessionIdKey } from "#context/keys.js"; @@ -71,6 +71,47 @@ function authoredTool(input: { }; } +describe("approval response authorization", () => { + it("resolves user tokens for the explicitly bound responder instead of ambient auth", async () => { + let resolvedPrincipal: ConnectionPrincipal | undefined; + const provider: AuthorizationDefinition = { + principalType: "user", + async getToken({ principal }): Promise { + resolvedPrincipal = principal; + return { token: "bound-responder-token" }; + }, + }; + const runtime = createTestRuntime({ tools: [] }); + + await runtime.runAsSession(undefined, async () => { + loadContext().set(AuthKey, { + attributes: {}, + authenticator: "test-idp", + principalId: "ambient-U2", + principalType: "user", + }); + return await buildApprovalResponseAuth({ + responder: { + attributes: { role: ["approver"] }, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "bound-U1", + principalType: "user", + subject: "bound-subject-U1", + }, + scope: "candidate-1", + }).getToken(provider); + }); + + expect(resolvedPrincipal).toMatchObject({ + attributes: { role: ["approver"] }, + id: "bound-U1", + issuer: "test-idp", + type: "user", + }); + }); +}); + describe("tool-hosted authorization", () => { it("resolves and caches the bearer through ctx.getToken(provider)", async () => { let calls = 0; diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index d040ff465..8f6744a3e 100644 --- a/packages/eve/src/execution/tool-auth.ts +++ b/packages/eve/src/execution/tool-auth.ts @@ -14,11 +14,13 @@ */ import { buildBaseToolContext } from "#context/build-base-tool-context.js"; +import type { SessionAuthContext } from "#channel/types.js"; import { ConnectionAuthorizationFailedError, ConnectionAuthorizationRequiredError, isConnectionAuthorizationRequiredError, } from "#public/connections/errors.js"; +import type { ApprovalResponseAuth } from "#public/definitions/approval.js"; import type { ToolAuthOptions, ToolAuthProvider, ToolContext } from "#public/definitions/tool.js"; import { type AuthorizationChallenge, requestAuthorization } from "#harness/authorization.js"; import { @@ -80,6 +82,54 @@ export function createToolExecuteWithAuth(input: { }; } +/** Builds the narrow token capability used by approval response authorizers. */ +export function buildApprovalResponseAuth(input: { + readonly responder: SessionAuthContext; + readonly scope: string; +}): ApprovalResponseAuth { + const inlineAuthState: InlineAuthState = {}; + const justAuthorizedScopes = new Set(); + return { + async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { + if (provider === undefined) throw missingProviderError("ctx.getToken"); + return await resolveInlineToken({ + boundResponder: input.responder, + inlineAuthState, + justAuthorizedScopes, + options: namespaceApprovalAuthOptions(input.scope, options), + provider, + toolScope: input.scope, + }); + }, + requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { + if (provider === undefined) throw missingProviderError("ctx.requireAuth"); + const scoped = buildInlineScopedAuthorization({ + boundResponder: input.responder, + inlineAuthState, + options: namespaceApprovalAuthOptions(input.scope, options), + provider, + toolScope: input.scope, + }); + throw new ToolAuthorizationRequiredError([ + { justAuthorized: justAuthorizedScopes.has(scoped.scope), scoped }, + ]); + }, + }; +} + +function namespaceApprovalAuthOptions( + scope: string, + options: ToolAuthOptions | undefined, +): ToolAuthOptions { + return { ...options, authKey: `${scope}:${options?.authKey ?? "inline-auth"}` }; +} + +/** Starts authorization requested by an approval response authorizer. */ +export async function handleApprovalResponseAuthorizationError(error: unknown): Promise { + if (!isToolAuthorizationRequiredError(error)) throw error; + return await handleAuthorizationRequests(error.requests); +} + function buildToolContext(input: { readonly options: ToolExecuteOptions; readonly scope: string; @@ -119,6 +169,7 @@ function buildToolContext(input: { } async function resolveInlineToken(input: { + readonly boundResponder?: SessionAuthContext; readonly toolScope: string; readonly provider: ToolAuthProvider; readonly options?: ToolAuthOptions; @@ -211,6 +262,7 @@ async function handleAuthorizationRequests( } function buildInlineScopedAuthorization(input: { + readonly boundResponder?: SessionAuthContext; readonly toolScope: string; readonly provider: ToolAuthProvider; readonly options?: ToolAuthOptions; @@ -219,6 +271,7 @@ function buildInlineScopedAuthorization(input: { const authorization = normalizeInlineProvider(input.provider, input.options); return { authorization, + boundResponder: input.boundResponder, connection: input.options?.connection ?? { url: "" }, scope: input.options?.authKey === undefined diff --git a/packages/eve/src/harness/tools.test.ts b/packages/eve/src/harness/tools.test.ts index 0cba032bd..614961f7e 100644 --- a/packages/eve/src/harness/tools.test.ts +++ b/packages/eve/src/harness/tools.test.ts @@ -18,6 +18,7 @@ import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { buildToolApproval, buildToolSet, buildToolSetWithProviderTools } from "#harness/tools.js"; import type { HarnessToolMap } from "#harness/types.js"; import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; +import type { ApprovalContext } from "#public/definitions/approval.js"; import type { ToolContext } from "#public/definitions/tool.js"; import type { ToolExecuteOptions } from "#shared/tool-definition.js"; @@ -920,7 +921,7 @@ describe("buildToolSet", () => { }); it("passes the active caller and session context into approval", async () => { - let capturedCtx: Parameters>[0] | undefined; + let capturedCtx: ApprovalContext | undefined; const tools: HarnessToolMap = new Map([ [ "delete_project", diff --git a/packages/eve/src/harness/tools.ts b/packages/eve/src/harness/tools.ts index 46cbf6267..af9abe2fd 100644 --- a/packages/eve/src/harness/tools.ts +++ b/packages/eve/src/harness/tools.ts @@ -6,7 +6,7 @@ import { ASK_QUESTION_TOOL_NAME } from "#runtime/framework-tools/ask-question.js import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js"; import { isObject } from "#shared/guards.js"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; -import type { ApprovalStatus } from "#public/definitions/approval.js"; +import { resolveApprovalPolicy, type ApprovalStatus } from "#public/definitions/approval.js"; import { resolveWebSearchBackend, resolveWebSearchProviderTool } from "#harness/provider-tools.js"; import type { HarnessToolMap } from "#harness/types.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; @@ -243,7 +243,7 @@ function buildApprovalFn( const toolInputRecord = isObject(toolInput) ? toolInput : undefined; - const status = await definition.approval({ + const status = await resolveApprovalPolicy(definition.approval)({ ...buildCallbackContext(), approvedTools: input.approvedTools ?? new Set(), callId, diff --git a/packages/eve/src/public/definitions/approval.test.ts b/packages/eve/src/public/definitions/approval.test.ts new file mode 100644 index 000000000..9aa89c06c --- /dev/null +++ b/packages/eve/src/public/definitions/approval.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + resolveApprovalPolicy, + type Approval, + type ApprovalResponseContext, +} from "#public/definitions/approval.js"; + +describe("approval definitions", () => { + it("preserves request-time function shorthand", async () => { + const policy: Approval = () => "user-approval"; + + expect(await resolveApprovalPolicy(policy)({} as never)).toBe("user-approval"); + }); + + it("resolves request policy from the object form", async () => { + const policy = vi.fn(() => "not-applicable" as const); + const approval: Approval = { + authorizeResponse: () => "allowed", + policy, + }; + + expect(await resolveApprovalPolicy(approval)({} as never)).toBe("not-applicable"); + expect(policy).toHaveBeenCalledOnce(); + }); + + it("keeps response authorization context capability narrow", () => { + expectTypeOf().toMatchTypeOf<{ + auth: { + getToken: (...args: any[]) => Promise; + requireAuth: (...args: any[]) => never; + }; + request: { callId: string; requestId: string; toolName: string }; + responder: { principalId: string }; + session: { id: string; initiator: unknown; turn: unknown }; + }>(); + expectTypeOf().not.toHaveProperty("getSandbox"); + expectTypeOf().not.toHaveProperty("getSkill"); + }); +}); diff --git a/packages/eve/src/public/definitions/approval.ts b/packages/eve/src/public/definitions/approval.ts index b008eee5d..ca1a87208 100644 --- a/packages/eve/src/public/definitions/approval.ts +++ b/packages/eve/src/public/definitions/approval.ts @@ -1,19 +1,16 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { SessionParent, SessionTurn } from "#context/keys.js"; +import type { ToolAuthOptions, ToolAuthProvider } from "#public/definitions/tool.js"; import type { SessionContext } from "#public/definitions/callback-context.js"; +import type { TokenResult } from "#runtime/connections/types.js"; type ApprovalToolInput = TInput extends object ? Readonly : TInput; /** - * Context passed to an {@link Approval} function. + * Context passed to an {@link ApprovalPolicy} function. * * Extends {@link SessionContext} so approval policies can make decisions from * the active session, current caller, and turn. - * - * `approvedTools` is the set of tool names (or compound approval keys) - * already approved at least once in the current session. `toolName` is the - * runtime name of the tool being evaluated. `toolInput` is the raw input the - * model passed, available for input-aware decisions. `callId` is the id of - * the call being evaluated — the same `callId` carried by the call's stream - * events and its `execute` context. */ export interface ApprovalContext> extends SessionContext { readonly approvedTools: ReadonlySet; @@ -22,12 +19,7 @@ export interface ApprovalContext> extends Sessi readonly toolName: string; } -/** - * Approval decision returned by an {@link Approval} function. - * - * AI SDK 7 statuses are accepted directly. For compatibility, `true` maps to - * `"user-approval"` and `false` maps to `"not-applicable"`. - */ +/** Request-time approval decision returned by an {@link ApprovalPolicy}. */ export type ApprovalStatus = | undefined | boolean @@ -40,7 +32,63 @@ export type ApprovalStatus = | { readonly type: "denied"; readonly reason?: string } | { readonly type: "user-approval"; readonly reason?: never }; -/** Shared approval policy used by authored tools and connections. */ -export type Approval> = ( +/** Request-time approval policy shared by authored tools and connections. */ +export type ApprovalPolicy> = ( ctx: ApprovalContext, ) => ApprovalStatus | Promise; + +/** Stable tool request passed to a response authorizer. */ +export interface ApprovalResponseRequest> { + readonly callId: string; + readonly requestId: string; + readonly toolInput?: ApprovalToolInput; + readonly toolName: string; +} + +/** Read-only session identity and lineage available to a response authorizer. */ +export interface ApprovalResponseSession { + readonly id: string; + readonly initiator: SessionAuthContext | null; + readonly parent?: SessionParent; + readonly turn: SessionTurn; +} + +/** Narrow authorization capability available while validating a responder. */ +export interface ApprovalResponseAuth { + getToken(provider: ToolAuthProvider, options?: ToolAuthOptions): Promise; + requireAuth(provider: ToolAuthProvider, options?: ToolAuthOptions): never; +} + +/** Context passed to a response-time approval authorizer. */ +export interface ApprovalResponseContext> { + readonly auth: ApprovalResponseAuth; + readonly request: ApprovalResponseRequest; + readonly responder: SessionAuthContext; + readonly session: ApprovalResponseSession; +} + +/** Response authorization outcome. Rejection keeps the shared request pending. */ +export type ApprovalResponseAuthorization = + | "allowed" + | { readonly safeReason: string; readonly status: "rejected" }; + +/** Authorizes whether the authenticated responder may approve one request. */ +export type ApprovalResponseAuthorizer> = ( + ctx: ApprovalResponseContext, +) => ApprovalResponseAuthorization | Promise; + +/** Approval definition with request-time policy and optional responder authorization. */ +export interface ApprovalConfiguration> { + readonly authorizeResponse?: ApprovalResponseAuthorizer; + readonly policy: ApprovalPolicy; +} + +/** Shared approval definition used by authored tools and connections. */ +export type Approval> = + | ApprovalPolicy + | ApprovalConfiguration; + +/** Returns the request-time policy from either approval authoring shape. */ +export function resolveApprovalPolicy(approval: Approval): ApprovalPolicy { + return typeof approval === "function" ? approval : approval.policy; +} diff --git a/packages/eve/src/public/tools/approval/approval-helpers.ts b/packages/eve/src/public/tools/approval/approval-helpers.ts index 1d2906654..200a40455 100644 --- a/packages/eve/src/public/tools/approval/approval-helpers.ts +++ b/packages/eve/src/public/tools/approval/approval-helpers.ts @@ -1,10 +1,10 @@ -import type { Approval } from "#public/definitions/approval.js"; +import type { ApprovalPolicy } from "#public/definitions/approval.js"; /** * Returns an `approval` callback that always requires user approval before * the tool executes. */ -export function always(): Approval { +export function always(): ApprovalPolicy { return () => "user-approval"; } @@ -12,7 +12,7 @@ export function always(): Approval { * Returns an `approval` callback that never requires user approval before * the tool executes. */ -export function never(): Approval { +export function never(): ApprovalPolicy { return () => "not-applicable"; } @@ -23,7 +23,7 @@ export function never(): Approval { * responding) leaves it unrecorded, so the next call prompts again. Keys off * the bare tool name, so it ignores compound approval keys. */ -export function once(): Approval { +export function once(): ApprovalPolicy { return ({ approvedTools, toolName }) => approvedTools.has(toolName) ? "not-applicable" : "user-approval"; } diff --git a/packages/eve/src/public/tools/index.ts b/packages/eve/src/public/tools/index.ts index 35bd0f198..a7b9a2d29 100644 --- a/packages/eve/src/public/tools/index.ts +++ b/packages/eve/src/public/tools/index.ts @@ -20,7 +20,19 @@ export { type ToolModelOutputPart, } from "#public/definitions/tool.js"; export { toolOutput, toolOutputPart } from "#public/tools/output-builders.js"; -export type { Approval, ApprovalContext, ApprovalStatus } from "#public/definitions/approval.js"; +export type { + Approval, + ApprovalConfiguration, + ApprovalContext, + ApprovalPolicy, + ApprovalResponseAuth, + ApprovalResponseAuthorization, + ApprovalResponseAuthorizer, + ApprovalResponseContext, + ApprovalResponseRequest, + ApprovalResponseSession, + ApprovalStatus, +} from "#public/definitions/approval.js"; export type { DynamicToolEntry, DynamicEvents, diff --git a/packages/eve/src/runtime/connections/principal.ts b/packages/eve/src/runtime/connections/principal.ts index 13cbef558..c63bc9d45 100644 --- a/packages/eve/src/runtime/connections/principal.ts +++ b/packages/eve/src/runtime/connections/principal.ts @@ -82,12 +82,21 @@ export function resolveConnectionPrincipal( connectionName: string, authorization: AuthorizationDefinition, ctx: AlsContext | undefined = contextStorage.getStore(), +): ConnectionPrincipal { + return resolveConnectionPrincipalFromAuth(connectionName, authorization, ctx?.get(AuthKey), ctx); +} + +/** Resolves a connection principal from an explicitly bound session identity. */ +export function resolveConnectionPrincipalFromAuth( + connectionName: string, + authorization: AuthorizationDefinition, + current: SessionAuthContext | null | undefined, + ctx?: AlsContext, ): ConnectionPrincipal { if (authorization.principalType === "app") { return { type: "app" }; } - const current = ctx?.get(AuthKey); if (current === null || current === undefined || current.principalType !== "user") { throw new ConnectionAuthorizationFailedError(connectionName, { message: buildUserPrincipalRequiredMessage(connectionName, authorization, ctx, current), diff --git a/packages/eve/src/runtime/connections/scoped-authorization.ts b/packages/eve/src/runtime/connections/scoped-authorization.ts index abbd1a94b..5ecbee211 100644 --- a/packages/eve/src/runtime/connections/scoped-authorization.ts +++ b/packages/eve/src/runtime/connections/scoped-authorization.ts @@ -24,7 +24,12 @@ import { readCachedToken, writeCachedToken, } from "#runtime/connections/authorization-tokens.js"; -import { principalKey, resolveConnectionPrincipal } from "#runtime/connections/principal.js"; +import { + principalKey, + resolveConnectionPrincipal, + resolveConnectionPrincipalFromAuth, +} from "#runtime/connections/principal.js"; +import type { SessionAuthContext } from "#context/keys.js"; import { type AuthorizationDefinition, type ConnectionAuthorizationContext, @@ -42,6 +47,7 @@ const LOCAL_HTTP_VERCEL_CONNECT_HOSTNAMES: ReadonlySet = new Set(["127.0 * {@link ConnectionAuthorizationContext} handed to every callback. */ export interface ScopedAuthorization { + readonly boundResponder?: SessionAuthContext; readonly scope: string; readonly authorization: Readonly; readonly connection: ConnectionAuthorizationContext; @@ -68,7 +74,7 @@ export async function resolveScopedToken(input: ScopedAuthorization): Promise; const ctx: AlsContext = loadContext(); - const principal = resolveConnectionPrincipal(scope, interactive, ctx); + const principal = resolveScopedPrincipal(input, ctx); const token = await interactive.completeAuthorization({ callbackUrl: result.hookUrl, connection, @@ -171,7 +177,7 @@ export async function startScopedAuthorization( if (hookUrl === undefined) return undefined; const interactive = authorization as InteractiveAuthorizationDefinition; - const principal = resolveConnectionPrincipal(scope, interactive); + const principal = resolveScopedPrincipal(input); const callbackUrl = resolveAuthorizationCallbackUrl({ authorization, callbackUrl: hookUrl }); const { challenge, resume } = await interactive.startAuthorization({ callbackUrl, @@ -188,11 +194,18 @@ export async function startScopedAuthorization( ]); } -/** - * Vercel Connect accepts local HTTP callback URLs only when their hostname is - * literally `localhost`. Other authorization providers keep the framework's - * original callback URL so their registered redirect URI remains unchanged. - */ +function resolveScopedPrincipal(input: ScopedAuthorization, ctx?: AlsContext) { + return input.boundResponder === undefined + ? resolveConnectionPrincipal(input.scope, input.authorization, ctx) + : resolveConnectionPrincipalFromAuth( + input.scope, + input.authorization, + input.boundResponder, + ctx, + ); +} + +/** Normalizes callback URLs for providers with localhost requirements. */ export function resolveAuthorizationCallbackUrl(input: { readonly authorization: Readonly; readonly callbackUrl: string; diff --git a/packages/eve/src/runtime/connections/types.ts b/packages/eve/src/runtime/connections/types.ts index 33c1b40d2..535f19a1c 100644 --- a/packages/eve/src/runtime/connections/types.ts +++ b/packages/eve/src/runtime/connections/types.ts @@ -23,10 +23,15 @@ import type { ResolvedConnectionDefinition } from "#runtime/types.js"; * `expiresAt` is an optional absolute expiration in **milliseconds since * the Unix epoch** ({@link Date.now}). Advisory: the runtime may refresh a * cached token before the next call based on it, but is not required to. + * + * `providerSubject` is the stable subject of the provider account represented + * by this credential. It is scoped to the provider and must not be compared + * across providers without provider context. */ export interface TokenResult { readonly token: string; readonly expiresAt?: number; + readonly providerSubject?: string; } /**