护栏是写在框架里的代码,还是交给框架去执行的数据?这决定了你换框架时要不要从头再写一遍。
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 草案,不必急着上生产。值得读,是因为它把"可移植护栏"需要的几个工程决定都摆到了明面上:怎么切分职责、在哪些点拦截、判决分几种、出错时怎么办。这一期逐条拆开讲,也讲它的承诺在哪里会落空。
可移植性来自一个很老的切分:拦截和执行留在框架里,判断搬出去成为一个无状态函数。
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)
}
$.input 这类路径去取值。于是"可移植"其实有个前提:不同框架的适配器拼出的快照形状必须一致。LangChain 适配器把命令放在 args.command,你自己的适配器放在 args.cmd,那么同一条 Rego 在后者那里取不到值,按规范会以 runtime_error:path_missing fail-closed 拒绝——好在是拒绝而不是放行,但意味着搬一次框架就要过一遍快照契约。拦截点放在哪里,决定了你能挡住哪一类攻击,也决定了每轮循环要多付多少延迟。
发布稿里的说法是"五个校验检查点:输入、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。人工审批批准的必须是被审的那份参数——否则就有 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:* 也走这里
runtime_error:annotation_timeout 并拒绝,分类器服务一抖动,所有 agent 一起停摆。这是你需要明确接受的取舍,所以要给注解器配超时预算和降级路径,不能默认它永远在线。第二个坑是审批疲劳:escalate 阈值定得太松,人很快就会养成一路点"同意"的习惯,审批也就形同虚设。上线前先在 evaluate_only 下跑一周,统计 escalate 的频率。今天就能拿到的收益不在 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 的沙箱兜底,策略层只是纵深防御里的一层。agent_startup 到 agent_shutdown,每个点绑定一条策略。classifier / llm / endpoint 三类,在策略执行前给 input 加上分数;出错或超时都按 fail-closed 处理。escalate 归一化后的形态:带 approval 块的 deny,由审批解析器决定放行、拒绝或挂起。deny 结束,原因只能是保留的 runtime_error:* 标识。