agent-safe-pipelineSafe
§ 00 · Source-level Observatory

agent-safe-pipeline 不可越权的 Agent 执行管线

agent-safe-pipeline 把 AI agent 的动作拆成 proposal、immutable intent、policy verdict、human approval、single-use grant 和 SafeExecutor 六段,核心原则是 agent 可以提出动作,但不能自行授权执行。

★ 486
GitHub Stars
1.8K
LOC
56
Files
today
Last commit
Agent proposal → intent hash → Decionis policy → human evidence → one-use grant → SafeExecutor
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个仓库的主线可以从 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 准备的最小安全参考架构。

▸核心数据

架构模式
Capture → bind → decide → approve → consume → execute;执行权被延迟到 SafeExecutor 末端。
核心抽象
ExecutionIntent + intentHash + GateDecision + VerifiedAuthorization。
性能/精度数据
Intent TTL 限制在 1-300 秒,authority timeout 限制在 1-15 秒,response body 限制 100KB。
下游依赖
Shopify refund、GitHub deploy、procurement、MCP tool gate 等 examples 展示可替换 action。
本文导航 — 6 个章节依次:概述 → 架构图 → 核心模块 → 关键流程 → 测试与验证 → 风险。子页面在每个 H3 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

不可变意图先落盘

packages/pipeline/src/intent/ExecutionIntent.ts:12 到 packages/pipeline/src/intent/ExecutionIntent.ts:64 把不可信 agent proposal 和可信运行上下文分开建模。这一步的关键不是生成 UUID,而是让后续 policy authority 拿到一个字段顺序稳定、可哈希、可审计的 binding。

▸系统拓扑

Agent proposes action ↓ IntentCapture validates proposal + trusted context ↓ CanonicalIntentHasher creates sha256 binding ↓ DecionisGate returns ALLOW / ESCALATE / BLOCK ↓ SafeExecutor consumes one-use grant before action

▸模块清单

核心模块不多,但边界清楚: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 重放。

IntentCapture
从 packages/pipeline/src/intent/IntentCapture.ts:32 开始先做 bounded check,再 parse proposal/context,最后设置 capturedAt/expiresAt。
Canonical hash
packages/pipeline/src/intent/CanonicalIntentHasher.ts:35 先 canonical stringify,再由 packages/pipeline/src/intent/CanonicalIntentHasher.ts:38 计算 sha256。
SafeExecutor
packages/pipeline/src/execution/SafeExecutor.ts:39 先拒绝非 ALLOW,packages/pipeline/src/execution/SafeExecutor.ts:77 才真正调用 action registry。

▸关键数据结构

src/packages/pipeline/src/intent/ExecutionIntent.ts · L48-64TypeScript
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();
深度阅读 · 意图绑定和 canonical hash
§ 02 · CORE MODULES

授权与执行拆成两道门

▸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。

src/packages/pipeline/src/intent/IntentCapture.ts · L32-55TypeScript
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。

src/packages/pipeline/src/decision/DecionisGate.ts · L63-111TypeScript
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");
深度阅读 · policy verdict 与一次性 grant
§ 03 · KEY FLOW

从提议到一次性 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。

▸执行路径

proposal -> capture -> hash -> authority ALLOW -> verifyAndConsume -> registry.validate -> registry.execute packages/pipeline/src/intent/ExecutionIntent.ts:48 -> packages/pipeline/src/intent/IntentCapture.ts:32 -> packages/pipeline/src/intent/CanonicalIntentHasher.ts:26 -> packages/pipeline/src/decision/DecionisGate.ts:63 -> packages/pipeline/src/execution/AuthorizationVerifier.ts:64 -> packages/pipeline/src/execution/SafeExecutor.ts:51 -> packages/pipeline/src/execution/ActionRegistry.ts:53

▸阻断路径

BLOCK/ESCALATE -> DECISION_NOT_ALLOW hash mismatch -> INTENT_BINDING_MISMATCH missing grant -> AUTHORIZATION_MISSING stale or replayed token -> AUTHORIZATION_INVALID
深度阅读 · 执行门禁和测试证据
§ 04 · TESTS & VALIDATION

测试把 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 失败。

tests/pipeline/test/execution/SafeExecutor.test.ts · L189-213TypeScript
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,不把它伪装成授权失败。
§ 05 · HIGHLIGHTS

值得借鉴的工程亮点

绑定字段窄而稳定
bindingOf 只选择 authority 需要的字段;packages/pipeline/src/intent/CanonicalIntentHasher.ts:99 到 packages/pipeline/src/intent/CanonicalIntentHasher.ts:127 避免把运行时对象泄漏进授权材料。
执行前后双重 conformance
packages/pipeline/src/execution/SafeExecutor.ts:48 与 packages/pipeline/src/execution/SafeExecutor.ts:72 两次重算,能发现 grant 消费期间 captured envelope 被篡改。
packages/pipeline/src/execution/AuthorizationVerifier.ts · L64-118TypeScript
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      });
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

仓库是参考架构,不是完整生产系统。packages/pipeline/src/shadow/ShadowPipeline.ts:10 的 shadow mode 只有 compare 骨架,packages/pipeline/src/execution/ReplayStore.ts:5 的 InMemoryReplayStore 也明确只是进程内防重放。真实部署还需要持久化、审计、密钥轮换和 authority 高可用。

远端 authority 依赖
packages/pipeline/src/decision/DecionisGate.ts:127 catch 后 fail closed,这很安全,但也意味着 authority 不可用会阻断业务,需要 SLO 和降级策略。
内存 replay store 不够生产
packages/pipeline/src/execution/ReplayStore.ts:6 用 Map 保存 claims,单进程有效;多实例部署必须换成共享原子存储。
packages/pipeline/src/execution/ReplayStore.ts · L14-27TypeScript
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  }
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。