Skip to content
Open
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineMcpClientConnection } from "#public/connections/index.js";

export default defineMcpClientConnection({
url: "https://example.com/mcp",
description: "Example MCP service",
});
12 changes: 12 additions & 0 deletions packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts
Original file line number Diff line number Diff line change
@@ -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" }),
}),
},
});
19 changes: 19 additions & 0 deletions packages/eve/extension-contracts/compatibility/tool/v3.ts
Original file line number Diff line number Diff line change
@@ -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,
};
},
});
15 changes: 15 additions & 0 deletions packages/eve/extension-contracts/reports/connection/v3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "connection",
"epoch": 3,
"sha256": "d710e0f9607b6f58b4b488547844c452a3278b36cfbedcbb987c93d5b753fcf2",
"exports": [
"ConnectionAuthorizationFailedError",
"ConnectionAuthorizationRequiredError",
"defineInteractiveAuthorization",
"defineMcpClientConnection",
"defineOpenAPIConnection",
"isConnectionAuthorizationFailedError",
"isConnectionAuthorizationRequiredError"
]
}
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/reports/dynamicTool/v7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicTool",
"epoch": 7,
"sha256": "d902fabb205bcf92a3bd0ab520823df7f255753094f25ba3fe5659e03b4fd79a",
"exports": [
"DynamicToolEntry",
"DynamicToolEvents",
"DynamicToolResult",
"DynamicToolSet",
"defineDynamic"
]
}
21 changes: 21 additions & 0 deletions packages/eve/extension-contracts/reports/tool/v4.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
6 changes: 3 additions & 3 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down
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 { UnstampedMessageStreamEvent, 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
43 changes: 42 additions & 1 deletion packages/eve/src/execution/tool-auth.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TokenResult> {
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;
Expand Down
Loading
Loading