agent-safe-pipeline 不可越权的 Agent 执行管线
agent-safe-pipeline 把 AI agent 的动作拆成 proposal、immutable intent、policy verdict、human approval、single-use grant 和 SafeExecutor 六段,核心原则是 agent 可以提出动作,但不能自行授权执行。
它解决什么,凭什么值得拆
这个仓库的主线可以从 packages/pipeline/src/intent/ExecutionIntent.ts:48 读起:ExecutionIntentSchema 把 action、target、parameters、downstreamTarget 和 idempotencyKey 固定成严格 schema;packages/pipeline/src/intent/IntentCapture.ts:32 再把 agent proposal 和可信上下文合并成 captured intent;packages/pipeline/src/intent/CanonicalIntentHasher.ts:26 用 canonical JSON 和 sha256 做绑定。换句话说,agent 只能提交“想做什么”,它不能绕过 packages/pipeline/src/decision/DecionisGate.ts:63 的 authority evaluate,也不能跳过 packages/pipeline/src/execution/SafeExecutor.ts:35 的 SafeExecutor.run。
最值得拆的是双重校验链。packages/pipeline/src/decision/DecionisGate.ts:93 会拒绝 authority 返回的 action_hash 与 captured hash 不一致,packages/pipeline/src/execution/AuthorizationVerifier.ts:64 会在执行前消费 token 并核对 decision_id、dossier_id、exp 与 intent_hash,packages/pipeline/src/execution/SafeExecutor.ts:48 和 packages/pipeline/src/execution/SafeExecutor.ts:72 还会在消费 grant 前后各重算一次 conformance。这些点让它不像一个普通 SDK,而像给高风险 agent action 准备的最小安全参考架构。
▸核心数据
深度阅读 → 入口展开。
不可变意图先落盘
packages/pipeline/src/intent/ExecutionIntent.ts:12 到 packages/pipeline/src/intent/ExecutionIntent.ts:64 把不可信 agent proposal 和可信运行上下文分开建模。这一步的关键不是生成 UUID,而是让后续 policy authority 拿到一个字段顺序稳定、可哈希、可审计的 binding。
▸系统拓扑
▸模块清单
核心模块不多,但边界清楚:packages/pipeline/src/intent/IntentCapture.ts:19 负责捕获,packages/pipeline/src/intent/CanonicalIntentHasher.ts:99 负责生成 authority binding,packages/pipeline/src/decision/DecionisGate.ts:47 实现远端 policy authority,packages/pipeline/src/execution/AuthorizationVerifier.ts:48 消费执行 token,packages/pipeline/src/execution/ActionRegistry.ts:21 把可执行动作白名单化,packages/pipeline/src/execution/ReplayStore.ts:5 防止 grant 重放。
packages/pipeline/src/intent/IntentCapture.ts:32 开始先做 bounded check,再 parse proposal/context,最后设置 capturedAt/expiresAt。packages/pipeline/src/intent/CanonicalIntentHasher.ts:35 先 canonical stringify,再由 packages/pipeline/src/intent/CanonicalIntentHasher.ts:38 计算 sha256。packages/pipeline/src/execution/SafeExecutor.ts:39 先拒绝非 ALLOW,packages/pipeline/src/execution/SafeExecutor.ts:77 才真正调用 action registry。▸关键数据结构
48export const ExecutionIntentSchema = z 49 .object({ 50 version: z.literal("agent-safe.intent/1"), 51 intentId: z.string().uuid(), 52 tenantId: z.string().uuid(), 53 capturedAt: z.string().datetime(), 54 expiresAt: z.string().datetime(), 55 actor: IntentActorSchema, 56 action: actionName, 57 target: z.string().trim().min(1).max(500), 58 parameters: JsonObjectSchema, 59 downstreamTarget: DownstreamTargetSchema, 60 context: JsonObjectSchema, 61 correlationId: boundedId.optional(), 62 idempotencyKey: z.string().trim().min(1).max(180), 63 }) 64 .strict();
授权与执行拆成两道门
▸IntentCapture 把提议变成短 TTL 事实
packages/pipeline/src/intent/IntentCapture.ts:32 的 capture 方法先调用 packages/pipeline/src/intent/IntentCapture.ts:33 和 packages/pipeline/src/intent/IntentCapture.ts:34 做输入复杂度限制,再在 packages/pipeline/src/intent/IntentCapture.ts:39 到 packages/pipeline/src/intent/IntentCapture.ts:53 组装 ExecutionIntent。注意 packages/pipeline/src/intent/IntentCapture.ts:29 把 ttlSeconds clamp 在 1 到 300 秒,意味着授权窗口被设计成短时有效,而不是长期 bearer permission。
32 public capture(proposalInput: AgentProposal, trustedInput: TrustedIntentContext): CapturedIntent { 33 this.hasher.assertInputBounded(proposalInput); 34 this.hasher.assertInputBounded(trustedInput); 35 const proposal = AgentProposalSchema.parse(proposalInput); 36 const trusted = TrustedIntentContextSchema.parse(trustedInput); 37 const capturedAt = this.clock(); 38 const expiresAt = new Date(capturedAt.valueOf() + this.ttlSeconds * 1_000); 39 const intent = ExecutionIntentSchema.parse({ 40 version: "agent-safe.intent/1", 41 intentId: this.createId(), 42 tenantId: trusted.tenantId, 43 capturedAt: capturedAt.toISOString(), 44 expiresAt: expiresAt.toISOString(), 45 actor: trusted.actor, 46 action: proposal.action, 47 target: proposal.target, 48 parameters: proposal.parameters, 49 downstreamTarget: trusted.downstreamTarget, 50 context: trusted.context, 51 ...(trusted.correlationId === undefined ? {} : { correlationId: trusted.correlationId }), 52 idempotencyKey: trusted.idempotencyKey, 53 }); 54 return this.hasher.capture(intent); 55 }
▸Authority gate 只承认绑定到 intentHash 的授权
packages/pipeline/src/decision/DecionisGate.ts:70 访问 /v1/authority/enforce-and-bind,body 里同时发送 canonical binding 与 packages/pipeline/src/decision/DecionisGate.ts:79 的 intent_hash。返回后,packages/pipeline/src/decision/DecionisGate.ts:92 用 zod 校验响应,packages/pipeline/src/decision/DecionisGate.ts:93 要求 action_hash 精确等于 captured intentHash,packages/pipeline/src/decision/DecionisGate.ts:102 到 packages/pipeline/src/decision/DecionisGate.ts:111 则把缺少 dossier/token/expiry 的 ALLOW 变成 fail-closed。
63 public async evaluate( 64 captured: CapturedIntent, 65 evidence?: DecisionEvidence, 66 ): Promise<GateDecision> { 67 const controller = new AbortController(); 68 const timeout = setTimeout(() => controller.abort(), this.timeoutMs); 69 try { 70 const response = await this.fetchImpl(`${this.baseUrl}/v1/authority/enforce-and-bind`, { 71 method: "POST", 72 headers: { 73 authorization: `Bearer ${this.apiKey}`, 74 "content-type": "application/json", 75 "idempotency-key": captured.intent.idempotencyKey, 76 }, 77 body: JSON.stringify({ 78 ...CanonicalIntentHasher.bindingOf(captured.intent), 79 intent_hash: captured.intentHash, 80 mode: "ENFORCEMENT", 81 ...(evidence === undefined ? {} : { evidence }), 82 }), 83 signal: controller.signal, 84 }); 85 if (!response.ok) { 86 return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED"); 87 } 88 const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES); 89 if (text === null) { 90 return FailClosedDecision.create(captured.intentHash, "AUTHORITY_RESPONSE_TOO_LARGE"); 91 } 92 const parsed = AuthorityResponseSchema.parse(JSON.parse(text)); 93 if (parsed.action_hash !== captured.intentHash) { 94 return FailClosedDecision.create(captured.intentHash, "AUTHORITY_BINDING_MISMATCH"); 95 } 96 const verdict = 97 parsed.status === "ALLOW" 98 ? "ALLOW" 99 : parsed.status === "ESCALATE" || parsed.status === "REVIEW_REQUIRED" 100 ? "ESCALATE" 101 : "BLOCK"; 102 const canExecute = 103 verdict === "ALLOW" && 104 parsed.should_execute && 105 parsed.dossier_id !== null && 106 parsed.execution_token !== null && 107 parsed.execution_token_expires_at !== null && 108 Date.parse(parsed.execution_token_expires_at) > Date.now() && 109 Date.parse(parsed.execution_token_expires_at) <= Date.parse(captured.intent.expiresAt); 110 if (verdict === "ALLOW" && !canExecute) { 111 return FailClosedDecision.create(captured.intentHash, "AUTHORITY_GRANT_MISSING");
从提议到一次性 grant
关键流程不是“问 policy 能不能做”,而是“让 policy 绑定一个具体 intent”。packages/pipeline/src/decision/DecisionAuthority.ts:16 的 GateDecision 保存 verdict、decisionId、dossierId、intentHash 和 authorization;packages/pipeline/src/decision/DecisionAuthority.ts:33 的 FailClosedDecision 把 authority 不可用也表示成 BLOCK。
▸执行路径
▸阻断路径
测试把 fail-closed 写死
测试文件不是装饰,它证明安全边界会在常见攻击面 fail closed。packages/pipeline/test/execution/SafeExecutor.test.ts:91 测 approval swapping,packages/pipeline/test/execution/SafeExecutor.test.ts:101 测 forged captured envelope,packages/pipeline/test/execution/SafeExecutor.test.ts:189 用 100 个并发执行证明 grant 只能消费一次。
▸测试覆盖
packages/pipeline/test/execution/SafeExecutor.test.ts:62 先确认正常 ALLOW 路径会执行,随后从 packages/pipeline/test/execution/SafeExecutor.test.ts:81 到 packages/pipeline/test/execution/SafeExecutor.test.ts:213 覆盖 BLOCK/ESCALATE、hash mismatch、conformance 破坏、缺 grant、并发 replay 和参数 schema 失败。
189 it("allows exactly one execution under one grant across 100 concurrent claims", async () => { 190 const { execute, pair, executor } = setup(); 191 const intent = captured(); 192 const decision = await pair.authority.evaluate(intent); 193 194 const results = await Promise.all( 195 Array.from({ length: 100 }, async () => executor.run(intent, decision)), 196 ); 197 198 expect(results.filter((result) => result.executed)).toHaveLength(1); 199 expect(execute).toHaveBeenCalledTimes(1); 200 expect( 201 results.filter((result) => !result.executed && result.reason === "AUTHORIZATION_INVALID"), 202 ).toHaveLength(99); 203 }); 204 205 it("does not consume a grant before action parameters pass the trusted schema", async () => { 206 const { execute, pair, executor } = setup(); 207 const invalid = captured(-1); 208 const decision = await pair.authority.evaluate(invalid); 209 210 await expect(executor.run(invalid, decision)).rejects.toThrow("ACTION_PARAMETERS_INVALID"); 211 expect(execute).not.toHaveBeenCalled(); 212 expect(await pair.verifier.verifyAndConsume(invalid, decision)).not.toBeNull(); 213 });
▸已知边界 / 失败模式
packages/pipeline/src/intent/CanonicalIntentHasher.ts:65拒绝过深对象,packages/pipeline/src/intent/CanonicalIntentHasher.ts:66拒绝循环引用,防止 intent hash 被复杂对象拖垮。packages/pipeline/src/execution/ReplayStore.ts:23已 claim 的 grantId 直接返回 false,避免 token 重放。packages/pipeline/src/execution/SafeExecutor.ts:75到packages/pipeline/src/execution/SafeExecutor.ts:80只把执行 handler 异常转成 ACTION_EXECUTION_FAILED,不把它伪装成授权失败。
值得借鉴的工程亮点
packages/pipeline/src/intent/CanonicalIntentHasher.ts:99 到 packages/pipeline/src/intent/CanonicalIntentHasher.ts:127 避免把运行时对象泄漏进授权材料。packages/pipeline/src/execution/SafeExecutor.ts:48 与 packages/pipeline/src/execution/SafeExecutor.ts:72 两次重算,能发现 grant 消费期间 captured envelope 被篡改。64 public async verifyAndConsume( 65 captured: CapturedIntent, 66 decision: GateDecision, 67 ): Promise<VerifiedAuthorization | null> { 68 if ( 69 decision.verdict !== "ALLOW" || 70 decision.failClosed || 71 decision.authorization === null || 72 decision.dossierId === null || 73 decision.intentHash !== captured.intentHash 74 ) { 75 return null; 76 } 77 const controller = new AbortController(); 78 const timeout = setTimeout(() => controller.abort(), this.timeoutMs); 79 try { 80 const response = await this.fetchImpl(`${this.baseUrl}/v1/execution/consume-token`, { 81 method: "POST", 82 headers: { 83 authorization: `Bearer ${this.apiKey}`, 84 "content-type": "application/json", 85 }, 86 body: JSON.stringify({ 87 execution_token: decision.authorization.token, 88 intent_hash: captured.intentHash, 89 intent: CanonicalIntentHasher.bindingOf(captured.intent), 90 consumed_by: captured.intent.actor.id, 91 }), 92 signal: controller.signal, 93 }); 94 if (!response.ok) return null; 95 const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES); 96 if (text === null) return null; 97 const parsed = ConsumeResponseSchema.parse(JSON.parse(text)); 98 const claimExpiryMs = parsed.claims.exp * 1_000; 99 const decisionExpirySeconds = Math.floor( 100 Date.parse(decision.authorization.expiresAt) / 1_000, 101 ); 102 if ( 103 parsed.claims.binding.intent_hash !== captured.intentHash || 104 parsed.claims.decision_id !== decision.decisionId || 105 parsed.claims.dossier_id !== decision.dossierId || 106 parsed.claims.exp !== decisionExpirySeconds || 107 claimExpiryMs <= Date.now() || 108 claimExpiryMs > Date.parse(captured.intent.expiresAt) 109 ) { 110 return null; 111 } 112 return Object.freeze({ 113 decisionId: parsed.claims.decision_id, 114 dossierId: parsed.claims.dossier_id, 115 grantId: parsed.claims.jti, 116 intentHash: parsed.claims.binding.intent_hash, 117 expiresAt: new Date(parsed.claims.exp * 1_000).toISOString(), 118 });
技术亮点之外,别忽略这些
仓库是参考架构,不是完整生产系统。packages/pipeline/src/shadow/ShadowPipeline.ts:10 的 shadow mode 只有 compare 骨架,packages/pipeline/src/execution/ReplayStore.ts:5 的 InMemoryReplayStore 也明确只是进程内防重放。真实部署还需要持久化、审计、密钥轮换和 authority 高可用。
packages/pipeline/src/decision/DecionisGate.ts:127 catch 后 fail closed,这很安全,但也意味着 authority 不可用会阻断业务,需要 SLO 和降级策略。packages/pipeline/src/execution/ReplayStore.ts:6 用 Map 保存 claims,单进程有效;多实例部署必须换成共享原子存储。14 public async claim(grantId: string, expiresAt: Date): Promise<boolean> { 15 const now = Date.now(); 16 const expiry = expiresAt.valueOf(); 17 if (grantId.length === 0 || grantId.length > 200 || !Number.isFinite(expiry) || expiry <= now) { 18 return false; 19 } 20 for (const [key, expiry] of this.claims) { 21 if (expiry <= now) this.claims.delete(key); 22 } 23 if (this.claims.has(grantId)) return false; 24 if (this.claims.size >= this.maxEntries) throw new Error("REPLAY_STORE_CAPACITY_EXCEEDED"); 25 this.claims.set(grantId, expiry); 26 return true; 27 }