- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
eve 的approval字段是一个异步策略钩子:每次工具调用前,eve 会把当前会话(session)、限定后的工具名(qualified tool name)、工具输入以及此前已批准的工具集合交给你的策略函数,由它决定当前租户应当"允许(allow)"、"拒绝(deny)"还是"要求人工确认(require human confirmation)"。本文基于 docs/patterns/multi-tenant-approvals.md 展开,结合 eve 源码中的类型定义与执行链路,给出一个可在自建工具(authored tool)、OpenAPI 连接与 MCP 连接上复用同一套租户审批适配器的完整实现,并说明如何在 HTTP 边界上保护审批响应、实现四眼(four-eyes)审批等进阶场景。读完后你将掌握:如何把应用侧的租户策略存储(PostgreSQL 列、策略服务、授权引擎或 KV 存储)映射为 eve 的审批决策,以及如何让模型永远接触不到租户选择权与凭据。
模式总览:一个适配器,三种载体
多租户应用里,不同客户组织(tenant)对同一工具的操作往往有完全不同的许可规则。eve 的解法不是内置一套租户对象或策略数据库,而是把决策权完整交还给你:
- 一个适配器:把 eve 的审批上下文(
ApprovalContext)翻译成应用自己的"策略请求"(tenantId、userId、resource、input),并返回 eve 的审批状态; - 复用:自建工具(
defineTool的approval字段)、OpenAPI 连接(defineOpenAPIConnection)、MCP 连接(defineMcpClientConnection)全部引用同一个适配器。
从源码结构看,这正对应 eve 把approval设计为三种定义载体共享同一类型:Approval<TInput> = ApprovalPolicy<TInput> | ApprovalConfiguration<TInput>(见 packages/eve/src/approval/definition.ts),而defineTool、defineOpenAPIConnection、defineMcpClientConnection的approval字段类型均来自同一个Approval类型(见 packages/eve/src/public/definitions/approval.ts、packages/eve/src/tools/definition.ts、openapi.ts、mcp.ts)。
该模式通常与多租户外向认证(multi-tenant outbound auth)配合使用:当你的应用用自己的 API Key、JWT 或应用会话确立租户身份,并从自己的凭据库(而非 OAuth)中选择连接凭据时,审批与凭据选择都依据同一份经过验证的会话身份,模型始终不需要看见或选择租户与密钥。
租户策略的存储始终是你的责任。它可以是 PostgreSQL 里的几列、一个策略服务、一个授权引擎,或持久化 KV 存储中的配置。eve 只关心"你的策略函数返回什么",不关心规则存在哪里。
把租户策略适配成 eve 审批
核心适配器在agent/lib/tenant-approval.ts。它利用会话上同时可用的当前调用者(session.auth.current)与发起调用者(session.auth.initiator):只有两者属于同一租户才继续查询策略,否则直接拒绝。
import type { ApprovalContext, ApprovalStatus } from "eve/tools/approval"; import { approvalPolicies } from "./approval-policies"; type Surface = "connection" | "tool"; function tenantIdOf(auth: ApprovalContext["session"]["auth"]["current"]): string | null { const tenantId = auth?.attributes.tenantId; return typeof tenantId === "string" ? tenantId : null; } export async function decideTenantApproval( surface: Surface, ctx: ApprovalContext, ): Promise<ApprovalStatus> { const current = ctx.session.auth.current; const tenantId = tenantIdOf(current); const initiatorTenantId = tenantIdOf(ctx.session.auth.initiator); if (current?.principalType !== "user" || !tenantId || tenantId !== initiatorTenantId) { return { type: "denied", reason: "The session is not pinned to one tenant user." }; } const input = ctx.toolInput as Record<string, unknown> | undefined; if (typeof input?.tenantId === "string" && input.tenantId !== tenantId) { return { type: "denied", reason: "Tool input cannot select another tenant." }; } const policy = await approvalPolicies.decide({ tenantId, userId: current.principalId, resource: `${surface}:${ctx.toolName}`, input, }); switch (policy.decision) { case "allow": return { type: "approved", reason: policy.reason }; case "require-approval": return "user-approval"; case "deny": return { type: "denied", reason: policy.reason }; } }这个适配器体现了三个关键防线:
- 会话钉扎(session pinning):
current与initiator的租户不一致时拒绝——防止共享会话被另一个租户的调用者"借壳"使用; - 输入不可越权:即便模型在
toolInput里试图传入tenantId,只要与当前租户不符就直接拒绝,租户只能由经过验证的会话身份决定; - 资源限定:策略请求中的
resource形如tool:transfer_funds或connection:billing__updateSubscription,规则引擎可以按名称精确匹配,也可以做连接级通配、按角色/金额/环境匹配。
ApprovalContext与ApprovalStatus的类型细节
适配器依赖的ApprovalContext在 packages/eve/src/approval/definition.ts 中定义,它扩展了SessionContext,因此策略可以直接读取活跃会话、当前调用者与轮次信息,还额外携带:
| 字段 | 含义 |
|---|---|
abortSignal | 当前模型生成被取消时同步取消策略工作;异步策略务必用它做取消传播 |
approvedTools | 本会话中已批准的工具名集合(只读Set<string>) |
callId | 本次工具调用的稳定 ID |
toolInput | 本次调用的输入,可能为undefined,访问前必须判空 |
toolName | 工具名:自建工具为路径推导名(如transfer_funds),连接工具为限定名(如billing__updateSubscription) |
返回类型ApprovalStatus支持字符串缩写与带reason的对象形式:"approved"、"denied"、"user-approval"、"not-applicable",以及{ type: "approved" | "denied", reason? }等(见 definition.ts)。为兼容旧的谓词形态,布尔值true等价于"user-approval",false等价于"not-applicable"。
注意:适配器刻意不把
approvedTools当作会话级的一揽子授权——每一次调用都会被重新评估。如果你的策略支持"批准一次"行为,应该在先钉住会话租户之后,再显式查询ctx.approvedTools。eve 内置的once()助手正是这样实现的:approvedTools.has(toolName) ? "not-applicable" : "user-approval"(见 packages/eve/src/tools/approval/policies.ts),且只有显式批准才会记录,拒绝或忽略都不会记入。
应用到自建工具:审批是闸门,不是授权
对自建工具而言,approval在execute之前运行。源码执行链路也印证了这一点:runCall先解析definition.approval并调用策略(含abortSignal、approvedTools、callId、toolInput、toolName上下文),得到"denied"就返回失败、得到"user-approval"就返回approval-required,只有通过后才进入execute(见 packages/eve/src/execution/invoke-tool.ts)。
审批与授权是两个层面:审批只决定"这次调用要不要人看",而执行器必须再次从会话推导并强制租户,因为审批完成到执行之间,租户成员关系或策略可能已经变化。示例工具:
import { defineTool } from "eve/tools"; import { z } from "zod"; import { transferFunds } from "../../lib/payments"; import { decideTenantApproval } from "../lib/tenant-approval"; export default defineTool({ description: "Transfer funds from the current tenant's account.", inputSchema: z.object({ destinationAccountId: z.string().min(1), amount: z.number().positive(), currency: z.string().length(3), }), approval: (ctx) => decideTenantApproval("tool", ctx), async execute(input, ctx) { const tenantId = ctx.session.auth.current?.attributes.tenantId; if (typeof tenantId !== "string") { throw new Error("An authenticated tenant is required."); } return await transferFunds({ ...input, tenantId, idempotencyKey: `${ctx.session.id}:${ctx.session.turn.id}`, }); }, });注意两点实战细节:
- 幂等键来自应用:副作用操作(转账、发信、扣款)必须使用应用侧幂等键(这里组合了
session.id与turn.id)。人工审批与重放安全解决的是不同问题——审批解决"谁允许",幂等解决"同一操作会不会被重复执行"; - 租户不来自参数:
execute里tenantId只能取自ctx.session.auth.current?.attributes.tenantId,而非工具输入。这正是多租户外向认证模式中"租户来自经过验证的路由认证,绝不来自提示词、工具参数或远端响应"的延续。
常用审批助手与approvalKey
不需要完整策略时,可以直接用eve/tools/approval导出的助手(实现见 packages/eve/src/tools/approval/policies.ts):
| 助手 | 行为 |
|---|---|
never() | 永不要求审批(省略approval字段时的默认行为) |
once() | 会话内第一次调用需审批,之后自动放行(按裸工具名记录) |
always() | 每次调用都需审批 |
auto() | 用决策模型判断本次调用是clear还是caution,caution/失败/超长输入则转人工审批 |
auto()默认使用typesafe-ai/jev决策模型,可传入model、instructions、criteria定制分类文本;输入超过 64KiB 或策略抛错时,会兜底转为user-approval(见 policies.ts)。
此外,defineTool还支持approvalKey:一个(input) => string的函数,用于为相同输入合并/区分审批请求(见 packages/eve/src/tools/definition.ts)。eve/tools/approval导出的Approval、ApprovalContext、ApprovalStatus等类型均由 packages/eve/src/public/definitions/approval.ts 重新导出。
应用到 OpenAPI 连接:按限定操作名区分读写
同一个适配器可以给每个生成的 OpenAPI 操作加闸。关键点是:连接的运行时名来自文件名(如agent/connections/billing.ts注册为"billing"),操作工具名以billing__updateSubscription这样的限定形式呈现(见 openapi.ts),因此你的策略服务可以按读写区分——比如listInvoices是读、updateSubscription是写,规则可以截然不同。
import { defineOpenAPIConnection } from "eve/connections"; import { decideTenantApproval } from "../lib/tenant-approval"; export default defineOpenAPIConnection({ spec: "https://billing.example.com/openapi.json", description: "Billing operations for the authenticated tenant.", operations: { allow: ["listInvoices", "updateSubscription"] }, headers: async (ctx) => { const tenantId = ctx.session.auth.current?.attributes.tenantId; if (typeof tenantId !== "string") throw new Error("Tenant is required."); return { "X-Service-Token": process.env.BILLING_SERVICE_TOKEN!, "X-Tenant-Id": tenantId, }; }, approval: (ctx) => decideTenantApproval("connection", ctx), });allow-list 与审批是两道独立闸门:operations: { allow: [...] }只限制模型能发现哪些操作(eve__search与eve__tool只返回/调用通过过滤的名字);审批则独立决定一个已被发现的操作是否允许运行。发现能力不等于执行许可,这是纵深防御的关键。
应用到 MCP 连接:同样的回调,同样的限定名
MCP 连接使用完全一致的接入方式(定义见 packages/eve/src/public/definitions/connections/mcp.ts),approval字段类型与 OpenAPI 连接完全相同:
import { defineMcpClientConnection } from "eve/connections"; import { decideTenantApproval } from "../lib/tenant-approval"; export default defineMcpClientConnection({ url: "https://support.example.com/mcp", description: "Support tickets for the authenticated tenant.", tools: { allow: ["search_tickets", "add_internal_note"] }, headers: async (ctx) => { const tenantId = ctx.session.auth.current?.attributes.tenantId; if (typeof tenantId !== "string") throw new Error("Tenant is required."); return { "X-Service-Token": process.env.SUPPORT_SERVICE_TOKEN!, "X-Tenant-Id": tenantId, }; }, approval: (ctx) => decideTenantApproval("connection", ctx), });此时策略收到的resource是connection:support__search_tickets或connection:support__add_internal_note。MCP 连接的tools: { allow: [...] }过滤器同样只影响发现,不替代审批(见 mcp.ts)。
提供策略适配器:eve 只认这一个接口
eve 侧代码只需面对一个极简的应用契约。把接口与实现放在agent/lib/approval-policies.ts,让 eve 相关文件与你的策略存储解耦:
export interface ApprovalPolicyRequest { tenantId: string; userId: string; resource: string; input?: Record<string, unknown>; } export interface ApprovalPolicyDecision { decision: "allow" | "deny" | "require-approval"; reason?: string; } export interface ApprovalPolicyProvider { decide(request: ApprovalPolicyRequest): Promise<ApprovalPolicyDecision>; } export { approvalPolicies } from "../../lib/approval-policies";approvalPolicies的具体实现由你决定策略模型。一个常见的实现模式是:
- 校验调用者仍是该租户的活跃成员;
- 先查找精确的资源规则(如
connection:billing__updateSubscription),找不到再回退到连接级通配规则; - 评估角色与输入阈值(如金额上限、环境白名单);
- 默认
deny(fail-closed)。
把这些选择留在应用代码里,而不是把数据库设计编码进 agent 定义。两条硬性纪律:
- 策略查询失败必须抛出异常或返回 deny,绝不能静默放行——审批是安全闸门,兜底必须是拒绝;
- 副作用执行器内部要再次做授权检查——成员关系或策略可能在会话停放期间发生变化,审批时通过不代表执行时仍有权。
保护审批响应:HTTP 边界与四眼审批
审批会持久化地暂停会话,之后的某个请求再恢复它。因此你的 HTTP 边界必须保证:一个调用者不能继续(continue)或流式读取(stream)属于其他租户的会话。请在应用中持久化会话归属(session ownership),并在代理前检查:
POST /eve/v1/session/:sessionId,包括inputResponses;GET /eve/v1/session/:sessionId/stream。
即使请求来自已认证用户,也只有会话 ACL 认可的人能访问对应 session id。
限制谁能应答:approval response policy
eve 内置的审批只确认"有权限访问该会话的人批准了这次调用"。要限制谁可以响应,可以给approval配置对象形态的response策略(详见 docs/tools/human-in-the-loop.md)。它收到:
response.principal:提交响应的人(含principalId、principalType、authenticator、attributes);request.principal:发起这次调用的人(审批请求被创建时捕获,之后无论多少人继续会话都保持不变,匿名/未认证时为null)。
该策略对Approve 和 Cancel 都会执行。实现四眼(four-eyes)工作流——必须由不同的人或角色来批准——的做法是:按response.decision分支:
- 允许
cancel,这样请求者本人仍可以撤回自己的调用; - 对
approve,拒绝response.principal与request.principal匹配的响应,并检查应答者的角色。
approval: { request: (ctx) => decideTenantApproval("tool", ctx), response: ({ request, response }) => { if (response.decision === "cancel") return { status: "allowed" }; if (request.principal !== null && samePrincipal(response.principal, request.principal)) { return { status: "rejected", reason: "A different person must approve." }; } return isApprover(response.principal) ? { status: "allowed" } : { status: "rejected", reason: "Not an approver." }; }, }其中samePrincipal应比较authenticator、issuer、principalType、principalId四元组(human-in-the-loop.md 提供了完整示例)。response策略的类型定义在 packages/eve/src/approval/definition.ts:返回{ status: "allowed" }接受该响应;返回{ status: "rejected", reason }则请求保持 pending,其他合格应答者仍可继续处理。类型测试 packages/eve/src/approval/definition.test.ts 验证了响应授权上下文只暴露getToken/requireAuth等窄能力,不暴露沙箱能力。
总结:职责边界
完整的 eve 集成就是一个异步适配器,被自建工具和两种连接协议(OpenAPI、MCP)复用;而租户的规则存储与治理模型始终属于应用。eve 负责把经过验证的会话身份、工具名与输入准时送到你的策略函数面前,并持久化地暂停/恢复会话;你负责回答"这个租户的这个人,能不能对这个资源做这件事",以及在 HTTP 边界上守护会话归属。两者合起来,就构成了一条从"认证 → 租户钉扎 → 策略决策 → 执行复核 → 响应授权"的完整链路。
建议继续阅读:多租户外向认证(确立租户身份的配套模式)、Human-in-the-Loop(暂停/恢复协议与响应授权细节)、认证与路由保护(自定义会话与 OIDC 示例)。
- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
相关推荐
go-admin多租户隔离原理:按Host路由数据库连接与租户级Casbin的实现
go admin多租户隔离原理:按Host路由数据库连接与租户级Casbin的实现 go admin 是基于 Gin + Vue 的前后端分离权限管理系统脚手架
后端认证鉴权mpv 章节导航完全指南:4 个场景搞定 DVD 章节、EDL 拼接与虚拟课程
mpv 章节导航完全指南:4 个场景搞定 DVD 章节、EDL 拼接与虚拟课程 mpv 的章节导航能处理三类常见需求:按章节跳转、把散落的分P拼成一集、直接播
音视频视频音频Cuba微框架入门:如何用Ruby快速构建轻量级Web应用
Cuba微框架入门:如何用Ruby快速构建轻量级Web应用 Cuba是一个基于Ruby的轻量级Web微框架,以其简洁的设计和高效的性能成为开发者构建小型Web应
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考