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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/approval-response-api.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 32 additions & 2 deletions packages/eve/src/context/build-dynamic-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
};
}

/**
Expand Down
66 changes: 63 additions & 3 deletions packages/eve/src/context/dynamic-tool-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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<unknown> => ({ 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 = {
Expand Down
21 changes: 19 additions & 2 deletions packages/eve/src/context/dynamic-tool-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -311,6 +327,7 @@ async function resolveToolsFromEvent(
entryKey,
executeStepFnName,
approvalStepFnName,
approvalResponseStepFnName,
closureVars: serializedClosureVars,
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/context/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export interface DurableDynamicToolMetadata {
readonly entryKey: string;
readonly executeStepFnName?: string;
readonly approvalStepFnName?: string;
readonly approvalResponseStepFnName?: string;
readonly closureVars?: Record<string, unknown>;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/eve/src/harness/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -874,7 +875,7 @@ describe("buildToolSet", () => {
});

it("passes the active caller and session context into approval", async () => {
let capturedCtx: Parameters<NonNullable<HarnessToolDefinition["approval"]>>[0] | undefined;
let capturedCtx: ApprovalContext | undefined;
const tools: HarnessToolMap = new Map<string, HarnessToolDefinition>([
[
"delete_project",
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/harness/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions packages/eve/src/public/definitions/approval.test.ts
Original file line number Diff line number Diff line change
@@ -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<ApprovalResponseContext>().toMatchTypeOf<{
auth: {
getToken: (...args: any[]) => Promise<unknown>;
requireAuth: (...args: any[]) => never;
};
request: { callId: string; requestId: string; toolName: string };
responder: { principalId: string };
session: { id: string; initiator: unknown; turn: unknown };
}>();
expectTypeOf<ApprovalResponseContext["auth"]>().not.toHaveProperty("getSandbox");
expectTypeOf<ApprovalResponseContext["auth"]>().not.toHaveProperty("getSkill");
});
});
80 changes: 64 additions & 16 deletions packages/eve/src/public/definitions/approval.ts
Original file line number Diff line number Diff line change
@@ -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> = TInput extends object ? Readonly<TInput> : 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<TInput = Record<string, unknown>> extends SessionContext {
readonly approvedTools: ReadonlySet<string>;
Expand All @@ -22,12 +19,7 @@ export interface ApprovalContext<TInput = Record<string, unknown>> 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
Expand All @@ -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<TInput = Record<string, unknown>> = (
/** Request-time approval policy shared by authored tools and connections. */
export type ApprovalPolicy<TInput = Record<string, unknown>> = (
ctx: ApprovalContext<TInput>,
) => ApprovalStatus | Promise<ApprovalStatus>;

/** Stable tool request passed to a response authorizer. */
export interface ApprovalResponseRequest<TInput = Record<string, unknown>> {
readonly callId: string;
readonly requestId: string;
readonly toolInput?: ApprovalToolInput<TInput>;
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<TokenResult>;
requireAuth(provider: ToolAuthProvider, options?: ToolAuthOptions): never;
}

/** Context passed to a response-time approval authorizer. */
export interface ApprovalResponseContext<TInput = Record<string, unknown>> {
readonly auth: ApprovalResponseAuth;
readonly request: ApprovalResponseRequest<TInput>;
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<TInput = Record<string, unknown>> = (
ctx: ApprovalResponseContext<TInput>,
) => ApprovalResponseAuthorization | Promise<ApprovalResponseAuthorization>;

/** Approval definition with request-time policy and optional responder authorization. */
export interface ApprovalConfiguration<TInput = Record<string, unknown>> {
readonly authorizeResponse?: ApprovalResponseAuthorizer<TInput>;
readonly policy: ApprovalPolicy<TInput>;
}

/** Shared approval definition used by authored tools and connections. */
export type Approval<TInput = Record<string, unknown>> =
| ApprovalPolicy<TInput>
| ApprovalConfiguration<TInput>;

/** Returns the request-time policy from either approval authoring shape. */
export function resolveApprovalPolicy<TInput>(approval: Approval<TInput>): ApprovalPolicy<TInput> {
return typeof approval === "function" ? approval : approval.policy;
}
Loading