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/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 0f68218e5..1ff532f9d 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 8905064eb..5e34603af 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 { HandleMessageStreamEvent, 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/harness/tools.test.ts b/packages/eve/src/harness/tools.test.ts index e2d665d42..b943062df 100644 --- a/packages/eve/src/harness/tools.test.ts +++ b/packages/eve/src/harness/tools.test.ts @@ -17,6 +17,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"; @@ -874,7 +875,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 4431365e9..f0ad0220e 100644 --- a/packages/eve/src/harness/tools.ts +++ b/packages/eve/src/harness/tools.ts @@ -13,7 +13,7 @@ import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search. import { isObject } from "#shared/guards.js"; import { parseJsonValue, type JsonValue } from "#shared/json.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"; @@ -311,7 +311,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 efc0ee235..cc35710f0 100644 --- a/packages/eve/src/public/tools/index.ts +++ b/packages/eve/src/public/tools/index.ts @@ -18,7 +18,19 @@ export { type ToolContext, type ToolModelOutput, } from "#public/definitions/tool.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/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; } /**