DAY 61 · GOVERNANCE

可移植的 Agent 运行时治理(ACS)

把护栏写成跟着 agent 走的策略文件——换框架时不用再重写一遍

2026-10-04 · BigCat

护栏是写在框架里的代码,还是交给框架去执行的数据?这决定了你换框架时要不要从头再写一遍。

前置 → 本站 Day 50 LLM 安全护栏与沙箱(自己动手写护栏)

// WHY THIS MATTERS

Day 50 讲的护栏有个隐性前提:它长在某个框架的回调里。Claude Code 用 hooks,LangGraph 用节点,OpenAI Agents SDK 用 guardrail 装饰器,三套写法互不相通。超级个体手里常常同时跑着两三个 agent 栈,于是同一条"禁止 rm -rf"在三个地方各写一遍,改规则时漏掉一处的那个就是漏洞。Microsoft 在 Build 2026 发布的 Agent Control Specification(ACS)想把这一格补上:策略写成数据(YAML 清单 + Rego/Cedar),框架只管在固定检查点上调用它、执行它给的判决。它现在还是 0.4.0-alpha.1 草案,不必急着上生产。值得读,是因为它把"可移植护栏"需要的几个工程决定都摆到了明面上:怎么切分职责、在哪些点拦截、判决分几种、出错时怎么办。这一期逐条拆开讲,也讲它的承诺在哪里会落空。

// POINT 01

PEP / PDP 分离:框架只负责执行,判断交给策略

可移植性来自一个很老的切分:拦截和执行留在框架里,判断搬出去成为一个无状态函数。

// 背景与原理

ACS 借用的是访问控制里的经典切分。宿主(host,即你的 agent 框架)是 PEP(策略执行点),ACS 运行时是 PDP(策略决策点)。到了检查点,宿主把当时的上下文拼成一份完整的 JSON 快照交给运行时;运行时按清单里绑定的策略算出一个归一化的判决,再交回宿主执行。规范给运行时立了三条硬约束:无状态(两次评估之间不能保留任何会影响判决的可变状态)、确定性(清单、快照、模式、分发器结果都相同时,判决必须相同)、fail-closed(评估中任何错误都必须给出 deny)。

可移植靠的就是这三条。判决只取决于"清单 + 快照",同一份策略文件在哪个框架下都应当得出同样的结论。策略本身可以用 Rego(OPA)或 Cedar 写,运行时核心是 Rust,另有 Python / Node / .NET / Go 绑定。

// 实战示例

# 规范附录里的完整例子(原文照录):拦截 input 检查点上的破坏性 SQL
agent_control_specification_version: 0.4.0-alpha.1
policies:
  input_guard:
    type: custom
    adapter: example_blocklist
intervention_points:
  input:
    policy_target_kind: user_input
    policy:
      id: input_guard
    policy_target: $.input

# 宿主交上来的快照       {"input": {"text": "please drop table users"}}
# 分发器返回 → 归一化判决  {"decision": "deny", "reason": "blocked_destructive_sql"}
# 换成 Rego 写策略:policy input 里有 intervention_point / policy_target / tool / annotations
package agent.tool_guard
default verdict := {"decision": "allow"}
verdict := {"decision": "deny", "reason": "destructive_shell"} if {
  input.tool.clearance == "shell"
  regex.match(`rm\s+-rf\s+/|mkfs|dd\s+if=`, input.policy_target.command)
}
失败模式:PDP 只能看到 PEP 交给它的东西。规范没有规定快照的顶层结构,结构由宿主决定,策略再通过 $.input 这类路径去取值。于是"可移植"其实有个前提:不同框架的适配器拼出的快照形状必须一致。LangChain 适配器把命令放在 args.command,你自己的适配器放在 args.cmd,那么同一条 Rego 在后者那里取不到值,按规范会以 runtime_error:path_missing fail-closed 拒绝——好在是拒绝而不是放行,但意味着搬一次框架就要过一遍快照契约。
// POINT 02

八个检查点:发布稿写的是五个,规范里定义了八个

拦截点放在哪里,决定了你能挡住哪一类攻击,也决定了每轮循环要多付多少延迟。

// 背景与原理

发布稿里的说法是"五个校验检查点:输入、LLM、状态、工具执行、输出"。规范落到实处时是 8 个具名拦截点:五类里的 LLM 和工具执行各拆成调用前、调用后两个点,"状态"对应会话的启动和结束。引用时以规范为准。

agent_startup  ─▶ 配置/环境自检
     │
input          ─▶ 用户输入进入模型前
     │
 ┌──▶ pre_model_call   ─▶ 完整 context 发给模型前
 │       │
 │   post_model_call  ─▶ 模型回复被执行前
 │       │
 │   pre_tool_call    ─▶ 工具名 + 参数执行前     ← 拦破坏性动作
 │       │
 └── post_tool_call   ─▶ 工具输出回灌 context 前  ← 拦间接注入
     │
output         ─▶ 最终回复离开 agent 前    ← 拦外泄
     │
agent_shutdown ─▶ 会话结束:审计与日志

这里的工程要点是注解(annotations)。每个检查点可以挂 classifier / llm / endpoint 三类注解器,它们在调用策略之前就跑完,把"这段文本像不像注入"之类的分数写进 policy input,策略本身只对这些分数做确定性判断。所以确定性指的是运行时这一层;LLM 注解器的网络调用归宿主负责,本身并不确定。分工的意思是:语义判断交给模型,"分数多少算越线"写进可审计的规则。

// 实战示例

# Rego:post_tool_call 上用注解器的分数挡间接注入,pre_tool_call 上只做参数规则
package agent.ingest_guard
default verdict := {"decision": "allow"}
verdict := {"decision": "deny", "reason": "indirect_injection"} if {
  input.intervention_point == "post_tool_call"
  input.annotations.injection_score >= 0.8        # 分数由 classifier 注解器写入
}
verdict := {"decision": "warn", "reason": "injection_suspect"} if {
  input.intervention_point == "post_tool_call"
  input.annotations.injection_score >= 0.5
  input.annotations.injection_score < 0.8
}
失败模式:最常见的是只守 pre_tool_call。破坏性命令挡住了,但网页、邮件、检索结果里的注入是从 post_tool_call 进入 context 的(Day 24 讲的就是这条路径),只守执行前等于把它放了进来。反过来,八个点都挂 LLM 注解器,每个工具调用要多做两次模型往返,长任务的延迟和成本都会翻倍。实用的配法是:工具前后挂便宜的分类器,只在 output 上挂 LLM judge。
// POINT 03

五种意图,三种判决:escalate 本质上是一次可以被解除的 deny

人工审批批准的必须是被审的那份参数——否则就有 TOCTOU 漏洞。

// 背景与原理

策略可以表达五种意图,最后都归一化成三种判决:allow / deny / transform。warn 归为 allow,同时在 warnings[] 里记一笔;escalate 归为一个带 approval 块的 deny,规范称之为可解除的拒绝(liftable deny),交给宿主的审批解析器,结果可以是放行、拒绝或挂起。transform(脱敏、改写)只能改策略目标本身,碰不到快照、注解或宿主状态。

审批这一段的设计值得照抄。审批对象带一个 enforced_identity,是 transform 之后的规范化 policy input 的 SHA-256。真正执行前,SDK 会用当前 input 重新算一遍,对不上就以 host_error:approval_identity_mismatch fail-closed。这样就堵住了"审的是 A,执行时被换成 B"的检查时与使用时(TOCTOU)窗口。另外,evaluate_only 模式下判决照常计算,但 transform 不执行,相当于影子模式,可以先看策略会拦什么再决定开不开。

// 实战示例

# 宿主侧执行循环的骨架(伪代码,示意 PEP 的职责;非 SDK 原样 API)
v = acs.evaluate(point="pre_tool_call", snapshot=snap, mode="enforce")
if v.decision == "allow":      run(tool, args); log(v.warnings)
elif v.decision == "transform": run(tool, v.transformed_target)      # 只用改写后的参数
elif v.approval:                   # liftable deny
    ok = ask_human(v.approval, show=canonical(args))
    if ok and sha256(canonical(current_input)) == ok.enforced_identity:
        run(tool, args)
    else: refuse()                   # 参数变了 → 必须重新审
else: refuse(v.reason)               # runtime_error:* 也走这里
失败模式:fail-closed 有可用性代价。注解器超时会被判成 runtime_error:annotation_timeout 并拒绝,分类器服务一抖动,所有 agent 一起停摆。这是你需要明确接受的取舍,所以要给注解器配超时预算和降级路径,不能默认它永远在线。第二个坑是审批疲劳:escalate 阈值定得太松,人很快就会养成一路点"同意"的习惯,审批也就形同虚设。上线前先在 evaluate_only 下跑一周,统计 escalate 的频率。
// POINT 04

超级个体怎么落地:先把策略和框架解耦,再决定要不要上 ACS

今天就能拿到的收益不在 ACS 运行时本身,而在"策略即数据"这个习惯。

// 背景与原理

先对照你已经在用的东西。Claude Code 的 PreToolUse hook 能返回 permissionDecision(allow / deny / ask),还能用 updatedInput 改写参数,语义上正对应 ACS 的 allow / deny / escalate / transform,只是锁在 Claude Code 里。所以判据很简单:只用一个框架时,hooks 就够了;同时跑两个以上的框架,或者需要给策略做版本管理和审计,ACS 这种外置 PDP 才划算。

ACS 清单的 extends 适合分层治理。它的合并是只增不改的:同一个名字在多份清单里出现时,必须完全一致或者互不冲突;同一个检查点上,只有注解可以取并集。也就是说,子清单可以在你的基础规则之上加注解器,却放宽不了基础规则,有冲突就直接 fail-closed。远程 extends 只走 HTTPS,可以用 sha256 钉住内容,并禁止指向内网和云元数据地址。

// 实战示例

# 今天就能用的过渡方案:同一份 Rego,先由 Claude Code hook 调用,日后挪进 ACS 照样复用
# .claude/hooks/policy.sh(PreToolUse;需要本机装 opa 和 jq)
#!/usr/bin/env bash
in=$(cat)
snap=$(jq '{intervention_point:"pre_tool_call",
            tool:{clearance:(if .tool_name=="Bash" then "shell" else "other" end)},
            policy_target:.tool_input}' <<<"$in")
v=$(opa eval -d ~/policies/tool_guard.rego -I --format raw \
      'data.agent.tool_guard.verdict' <<<"$snap")
if [ "$(jq -r .decision <<<"$v")" = "deny" ]; then
  jq -n --arg r "$(jq -r .reason <<<"$v")" '{hookSpecificOutput:{
    hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
fi

快照的形状(tool.clearance / policy_target)一开始就按 ACS 的 policy input 来拼。这样适配器只写薄薄一层,策略只写一份,换框架时只需要重写那个十几行的适配器。

失败模式:一是规范还在变:早期引擎里的 bundle_url 远程策略包在这一版已经被删掉,整个工具包也还是 Public Preview。深度绑定 SDK 的 API 等于签了一份迁移债,Rego 策略本身才是最稳的资产。二是 Rego 等于多学一门语言,策略写错同样会无声放行,所以要给策略写单测(OPA 自带 opa test)。三是确定性规则的天花板:参数正则拦得住 rm -rf /,拦不住"把密钥 base64 之后塞进一个看起来无害的 URL"。语义层面的攻击还得靠注解器和 Day 50 的沙箱兜底,策略层只是纵深防御里的一层。

// 术语表

ACS(Agent Control Specification)
Microsoft 在 Build 2026 发布的开放规范:把 agent 运行时护栏写成可移植的策略清单,属于 Agent Governance Toolkit 的策略层。
PEP / PDP
策略执行点 / 策略决策点。宿主框架负责拦截和执行,ACS 运行时负责无状态地算出判决。
intervention point(拦截点)
规范定义的 8 个检查点,从 agent_startup 到 agent_shutdown,每个点绑定一条策略。
snapshot(快照)
宿主在检查点拼出的完整 JSON 上下文。顶层结构由宿主定义,策略通过路径取值。
annotator(注解器)
classifier / llm / endpoint 三类,在策略执行前给 input 加上分数;出错或超时都按 fail-closed 处理。
liftable deny(可解除的拒绝)
escalate 归一化后的形态:带 approval 块的 deny,由审批解析器决定放行、拒绝或挂起。
enforced_identity
transform 之后规范化 policy input 的 SHA-256,把审批绑定到确切的参数上,防 TOCTOU。
evaluate_only
影子模式:照常计算判决,但不执行 transform,用于上线前观察策略的效果。
fail-closed
任何评估错误都以 deny 结束,原因只能是保留的 runtime_error:* 标识。

// 深入思考

ACS 号称"厂商中立",那它的可移植性真正卡在哪一层?
卡在快照契约这一层,不在策略语言。Rego、Cedar 本来就是中立的,难的是每个框架的适配器能不能把同一件事拼成同样的 JSON:工具参数叫什么、注解分数放哪儿、多 agent 场景下"谁在调用"怎么表达。规范把快照结构留给宿主,这是务实的选择(框架差异太大),代价是可移植性取决于适配器之间有没有约定好。类比 OpenTelemetry:协议很早就有了,真正让它可用的是后来定下的 semantic conventions。ACS 缺的正是这一份。
"确定性运行时 + LLM 注解器"——这里的确定性到底保证了什么?
保证的是"同样的证据得出同样的判决",不保证证据本身稳定。LLM 注解器今天打 0.79、明天打 0.81,判决就会在 warn 和 deny 之间来回跳。它的价值在于把不确定性隔离在一处、并且可以审计:出了事能复盘出"是分数变了还是规则错了"。这和 Day 58 持久化执行把不确定性赶进 activity 是同一个思路:确定的部分写成可重放的规则,不确定的部分隔离开并记录下来。
只增不改的 extends 合并,是在防谁?
防的是"子清单悄悄放宽父清单"。组织的基础策略不能被某个 agent 的清单覆盖掉,冲突就直接失败,而不是后写的覆盖先写的。这和 CSS 层叠或 Kubernetes 的 overlay 正好相反,它更接近"安全基线只能收紧"的 policy-as-code 惯例。代价是例外很难开:某个 agent 确实需要更宽的权限时,只能去改基础策略或者另起一份清单,而这正好逼着你把例外显式地写出来。
对单人开发者,什么时候外置 PDP 是过度工程?
只有一个框架、规则不到十条、也没有审计需求的时候,hooks 加几行 shell 已经够用,多一个 OPA 和一套 Rego 反而多出两个会坏的组件。真正的拐点有三个:同一条规则需要在两个以上的地方生效;规则需要版本管理和回滚;要向别人证明"这个 agent 做不了某件事"。就算还没到拐点,也可以先采纳"策略即数据"这个习惯,不必急着上运行时。

// 延伸阅读