用.NET实现逐级收权的Agent委托链
文章提供C#方案,以签名委托块记录每次交接,并要求下游权限只能收窄,由YARP网关检查每次工具调用。设计还涉及共享预算、不可信结果处理及Dapr Workflow持久化编排。
AI 深度解读
Agent A 接到用户任务,再把一部分交给 Agent B,最后由 B 调用工具。链路跑通了,权限却未必跟得上:工具怎么确认用户同意过这次操作,中间的 Agent 有没有扩大授权?原文给出一套 .NET 参考设计,把用户签名、逐级收窄的授权链和网关校验串起来。
它最值得借鉴的结论是:Agent 可以负责提出行动,执行边界必须由模型之外的代码强制检查。 但这仍是简化实现,签名不等于整条业务链路已经安全,重试、撤销和请求绑定还有细节要补。
为什么记录了委派链,还不算完成授权
作者指出,OAuth Token Exchange(RFC 8693)可以通过嵌套的 act 声明记录代理关系,但接收方的授权判断依据是顶层声明和当前执行者。历史链条可以用于审计,不会自动变成逐级约束。MCP 和 A2A 解决连接问题,也不能替业务决定谁有权做什么。
这套方案从用户签署的根票据开始。用户通过 passkey 或 Entra ID 确认自然语言范围,后续每个委派块都通过哈希链接到前一个块。块中的 HolderKey 指定当前持有人,下一跳必须由这个持有人的私钥签署。
每次交接都计算三方交集:
有效范围 = 父级范围 ∩ 本次申请范围 ∩ 接收方自身权限
例如,支付 Agent 平时最多能处理 10,000 美元,不代表它可以把用户批准的 1,200 美元提高到这个额度。这是在防止「混淆代理」(confused deputy):攻击者借用一个权限更大的服务,完成自己无权执行的操作。
范围除了动作,还包括收款人、金额、委派深度和过期时间。下面这段代码展示了委派时怎样计算交集,并消耗一层深度:
public DelegationChain Delegate(ECDsa holderKey, Scope requested, Scope delegateeOwn,
string subtask, string delegateeKey)
{
var parent = Blocks[^1].Block;
if (parent.Scope.MaxDepth <= 0)
throw new InvalidOperationException("Delegation depth exhausted.");
var scope = parent.Scope.Intersect(requested).Intersect(delegateeOwn)
with { MaxDepth = Math.Min(Math.Min(requested.MaxDepth, delegateeOwn.MaxDepth),
parent.Scope.MaxDepth - 1) };
var block = new Block(scope, subtask, delegateeKey, Crypto.Hash(parent));
return new([.. Blocks, new SignedBlock(block, Crypto.Sign(holderKey, block))]);
}
要看的是 parent.Scope.MaxDepth - 1:子范围不能只在动作和金额上不越界,还必须减少可继续转交的次数。
原文用 ECDsa(P-256)签名、SHA-256 哈希。Scope.Any 对应的 * 只允许出现在 Agent 的常驻策略里,不能出现在用户票据里。这样,Agent 可以拥有「任意客户」的业务权限,用户仍然只批准某个具体客户。
真正拦住越权的是网关,不是提示词
ASP.NET Core + YARP 网关放在工具 API 和其他 Agent 前面。每次请求都必须经过它,依次检查签名链、当前持有人的签名、具体动作和共享预算,通过后才转发。
ChainVerifier.Verify 逐块检查签名、过期时间、PrevHash 和 Scope.IsWithin。请求通过 X-User、X-Delegation、X-Agent-Proof 携带用户标识、授权链和持有人证明;DelegationHandler 从 request.Options 取出这次调用对应的链,并签署请求正文。
预算按 RootId 共享,而非按单个 Token 分配。否则,同一个根任务分出几个 Agent,每个分支都可能把额度花一遍。作者要求 IBudgetStore.TryConsumeAsync 在 Redis 或 SQL 中原子扣减,初始额度取根票据的 MaxAmount。
另一条边界针对工具返回值。Semantic Kernel 的 IFunctionInvocationFilter 把工具和子 Agent 的结果包装成 <untrusted>,提示模型把内容当数据,不执行里面的指令。
作者也承认,这层包装只能提醒模型。真正的兜底仍是网关:返回内容即便诱导模型更改收款人或金额,只要超出签名范围,就会被拒绝。对读取不可信内容后的高风险动作,作者还建议用多个改写版本重新推导操作,结果不一致就阻断,但没有给出效果对照。
Dapr Workflow 解决等待和恢复,不能代替权限检查
退款示例把边界串得很清楚:用户批准给订单 #4471 的客户 C-881 退款,最多 1,200 美元,根票据有效期 1 小时,MaxDepth: 1。客服 Agent 转交支付 Agent 后,委派深度变为 0,有效期收窄到 10 分钟。支付 Agent 自身策略虽有 10,000 美元、30 天的范围,也不能放大这张票据。
安装 Dapr.Workflow 的命令是 dotnet add package Dapr.Workflow。编排先申请审批,通过 WaitForExternalEventAsync 等待 UserApproved,最多 15 分钟;再执行委派活动,启动 PaymentsHop 子工作流,调用网关,最后记录不可信结果。
这里有两个工程约束。
第一,工作流会根据历史重放,所以代码必须确定。签名、随机数、读取当前时间和 HTTP 调用都应放进 Activity。工作流内部需要时间时,使用 ctx.CurrentUtcDateTime。
第二,Activity 在崩溃恢复后可能重复执行。示例用 ${ctx.InstanceId}:refund 的插值形式构造幂等标识,实际 C# 写法为 $"{ctx.InstanceId}:refund",通过 Idempotency-Key 发送。网关需要保存这个键和此前结果,重复请求直接返回旧结果,否则重试可能重复退款。
Dapr 的 mTLS 和访问控制负责确认调用服务的身份,例如只允许 support-agent 对 payments-agent 的 /refunds 发起 POST。签名链负责证明哪个用户批准了什么,两层都要保留。
工作流历史还会持久化输入、输出和事件,包括提示词与授权链。作者要求加密状态存储,私钥不能进入工作流输入。
我的判断
我认可把授权放在统一网关执行,也认可预算必须覆盖整棵委派树。相比让模型自查,这些规则能被明确验证;相比给所有 Agent 一份宽权限凭证,它也更适合分工调用。
但原文没有攻击测试、性能开销或替代方案的对照数据。它提供的是设计推演和参考代码,不能据此认定安全效果已经得到实测验证。
几个缺口尤其影响落地。首先,示例只签了请求正文,没有绑定 HTTP 方法、路径和防重放信息。同一正文能否被换个接口重用,需要补充约束。幂等键也尚未纳入示例签名和网关去重实现。
其次,Task 虽然签了「退款给订单 #4471」,ToolAction 却只有动作、收款人和金额,没有订单字段。签进票据的自然语言意图,不会自动变成业务校验。 订单约束需要结构化,并由执行端核对。
再者,Delegate 接收并计算了接收方策略,但 ChainVerifier 没有独立查询策略验证。工作流里的可信注册表改善了交接来源,是否能约束被攻陷的上游持有人,仍需说明。
原文还要求签名回执,示例 Receipt 和 IReceiptLedger 却没有展示签名;宣称短有效期配合停止续签即可撤销,代码也没实现续签协议。根票据示例有效 1 小时,也与「块在几分钟内过期」的规则不完全一致。取消后已经发出的票据不会立刻失效。
这些都说明它适合作为设计起点。作者也明确建议生产环境采用经过审计、支持权限收窄的 Biscuit 等令牌格式,把密钥放进 HSM 或 Key Vault,并进行安全评审。
能拿来做什么
- 给日常 AI 工具调用先列出结构化权限字段,包括允许的动作、目标、额度、有效期和委派深度。像「处理收件箱」这样的模糊任务,可以按原文思路拆成安全范围和高风险动作的分级票据。
- 如果用 Node 或 .NET 搭建 Agent 服务,可以借鉴统一网关的检查顺序,但要确保工具端不能被绕过,并把方法、路径、业务对象和幂等标识纳入请求约束。
- 给会重试的副作用操作同时设计预算扣减和幂等处理。重复请求应复用此前结果,不能再次扣预算或再次执行付款。
- 把子 Agent 和工具输出当作不可信数据处理。返回内容可以展示或记录,不能直接据此选择下一次高风险调用,仍要经过同样的授权检查。