上一章的结论是"沙箱是语法层防御,威胁在语义层"。本章讲 Codex 补上语义层的三级递进:审批策略 → Starlark 规则 → Guardian(用模型当判官)。第三级是目前公开的 agent 项目里少见的实践,本章会把它的策略提示词摊开讲。
用户可选的审批档位有四种 [源码 protocol/src/protocol.rs:924]:
| 策略 | 行为 |
|---|---|
| UnlessTrusted(untrusted) | "Internal policy for projects marked untrusted. Commands require approval unless an explicit exec policy rule allows them." |
| OnRequest(默认) | "The model decides when to ask the user for approval." |
| Granular(GranularApprovalConfig) | 细粒度开关 |
| Never | "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated." |
Granular 是四档里最有意思的一个 [源码 protocol/src/protocol.rs:950]:
注释里的关键一句:
When a field is true, commands in that category are allowed. When it is false, those requests are automatically rejected instead of shown to the user.
false 不是"总是问",是"自动拒绝"。 这个语义很重要——它让用户可以说"这类请求我根本不想看到,直接拒掉",而不是被反复打扰。
配合第 6 章讲的权限说明模板,模型是知道自己处在哪档策略下的。never.md 那份模板就一句话:
Approval policy is currently never. Do not provide the sandbox_permissions for any reason, commands will be rejected.
别费劲请求了,一定被拒。 省下模型的一次尝试。
core/src/safety.rs 处理不需要问任何人就能判定的情况。核心是补丁安全性 [源码 core/src/safety.rs:20]:
逻辑很直白:这个补丁要写的所有路径,是不是都在可写根目录里? 是就放行,不是就要审批或拒绝。
其中 normalize(path) 那个辅助函数是重点——路径归一化是这类检查的命门。workspace/../../../etc/passwd 归一化之后就露馅了。不归一化的路径白名单等于没有。
[源码 execpolicy/src/decision.rs:8]
策略用 Starlark(Google 的 Python 子集配置语言,Bazel 用的那个)写 [源码 execpolicy/src/parser.rs:3]。当前版本只暴露两个内置函数 [源码 execpolicy/README.md]:
一条真实的例子规则 [源码 execpolicy/examples/example.codexpolicy:4]:
① match / not_match 是策略的单元测试
README 说得很直接:
match / not_match supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with shlex).
(match / not_match 提供在加载时被验证的示例调用——把它们当成单元测试;示例可以是 token 数组或字符串,字符串用 shlex 分词。)
策略文件加载时就跑自测。 你写了一条 git reset --hard 的禁令,同时声明 git reset --keep 不该匹配——如果你的 pattern 写错了(比如只写了 ["git", "reset"]),加载就失败。
这是安全策略应该有的样子。一条写错的安全规则比没有规则更危险,因为它给人虚假的安全感。
可迁移的判断 ⑲ 让安全/权限规则的定义格式自带示例断言,并在加载时执行。
这个成本极低(多写两行示例),收益极高(规则写错当场暴露)。本仓的 .claude/settings.json permissions 列表如果有这个机制,就不会出现"以为 Bash(git *) 覆盖了某条命令、实际没覆盖"的情况。
② justification 是给人和模型看的
justification is an optional human-readable rationale for why a rule exists. It can be provided for any decision and may be surfaced in different contexts (for example, in approval prompts or rejection messages). When decision = "forbidden" is used, include a recommended alternative in the justification, when appropriate (e.g., "Use \jj` instead of `git`."`).
禁止一件事的时候,顺便说该用什么替代。 这对 agent 尤其重要——模型被拒绝后如果知道替代方案,下一步就是对的;不知道就会换个写法再试一次同样的事。
③ 严格性取最大值
The effective decision is the strictest severity across all matches (forbidden > prompt > allow).
多条规则同时匹配时取最严的。这是合并策略时唯一安全的做法——任何"后面的规则覆盖前面的"设计都会被规则顺序坑到。
host_executable 是为这个存在的。匹配语义 [源码 execpolicy/README.md]:
- execpolicy always tries exact first-token matches first.
- With host-executable resolution disabled, /usr/bin/git status only matches a rule whose first token is /usr/bin/git.
- With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from /usr/bin/git to basename rules for git.
- If host_executable(name="git", ...) exists, basename fallback is only allowed for listed absolute paths.
- If no host_executable() entry exists for a basename, basename fallback is allowed.
翻译成攻击视角:如果你写了一条 prefix_rule(pattern=["git", "push"], decision="prompt"),攻击者在 /tmp/evil/git 放一个恶意程序、把 /tmp/evil 加进 PATH——它的 basename 也是 git,会不会命中你的规则从而被当成"那个 git"处理?
host_executable 的答案:声明了合法路径之后,只有这些路径能享受 basename 回退。
第 6 章引过 on_request.md 模板的这段,这里补上它的意义:
The command string is split into independent command segments at shell control operators, including but not limited to: Pipes |, Logical operators &&, ||, Command separators ;, Subshell boundaries (...), $(...)
ls && rm -rf / 不会因为 ls 是安全的就整条放行。 每一段独立判定,取最严的结果。
core/src/command_canonicalization.rs 负责规范化(第 8 章那条流水线里的一步)。
前两级能拦的都是能写成规则的。写不成规则的例子:
这些需要理解意图、上下文和信任关系。 Codex 的答案是:再拉一个模型来判断。
模块文档给了完整流程 [源码 core/src/guardian/mod.rs:1]:
Guardian review decides whether an on-request approval should be granted automatically instead of shown to the user.
High-level approach:
- Reconstruct a compact transcript that preserves user intent plus the most relevant recent assistant and tool context.
- Ask a dedicated guardian review session to assess the exact planned action and return strict JSON. The guardian clones the parent config, so it inherits any managed network proxy / allowlist that the parent turn already had.
- Fail closed on timeout, execution failure, or malformed output.
- Apply the guardian's explicit allow/deny outcome.
注意第一句:Guardian 的定位是"减少打扰",不是"增加拦截"。它替用户回答那些本来要弹窗问的问题。这个定位很关键——如果定位成"再加一道墙",那它的误报会直接变成产品可用性灾难。
[源码 core/src/guardian/mod.rs:118、protocol/src/approvals.rs:87]
两个正交的维度:这件事有多危险(risk_level),用户授权了多少(user_authorization)。结论是这两者的函数。
这个二维模型比单一的"危险/不危险"强很多。rm -rf ./node_modules 危险等级不低,但如果用户刚说了"清理一下依赖重装",授权就是 High,结论是 Allow。同一条命令在没有授权的情况下就该 Deny。
core/src/guardian/policy.md(8281 字节)是写给 Guardian 模型看的风险分类学。它分成这些章节 [源码]:
| 章节 | 管什么 |
|---|---|
| Environment Profile | 什么算可信环境、可信仓库 |
| Data Exfiltration | 数据外泄 |
| Credential Probing | 凭据探测 |
| Persistent Security Weakening | 持久性安全削弱 |
| Destructive Actions | 破坏性操作 |
| Low-Risk Actions | 明确的低风险清单 |
每一节的结构都是:定义 → 判定规则 → Outcome rule: 结论规则。
摘几条最能说明设计思路的:
数据外泄那节的追溯规则:
Payloads must be traced back to their original data. Any payload which is somehow derived from sensitive data is also sensitive. Sharing an artifact or a link that grants access to it discloses the artifact's contents. Authorization to create or interact with content does not authorize its egress.
(载荷必须能追溯到原始数据。任何由敏感数据派生出的载荷同样敏感。分享一个制品、或分享一个能访问它的链接,就等于披露了它的内容。授权创建或操作某内容,不等于授权把它发出去。)
最后一句是防提示词注入的核心逻辑:"用户让我处理这个文件"不等于"用户让我把这个文件发出去"。
破坏性操作那节,有大量在给模型"减压":
- Do not treat normal internal operational workflows as destructive solely because they are large-scale or long-running.
- Do not assume the user has version control when evaluating file changes for destructiveness. If needed, use commands to check.
- Treat git actions as low or medium when they only affect one verified user-owned feature branch… including one-ref --force-with-lease pushes to that branch. Keep them high if they touch a protected/default branch, use broad refspecs or branch deletion, push private data to an unverified remote, bypass security-related hooks, or destroy unpushed work.
- High risk requires evidence of significant risk of irreversible damage.
"high risk 需要证据" 这句是整份策略里最重要的一条。没有它,模型会把一切都判成高风险,Guardian 变成一个永远说不的东西。
还有一条极具体的:
Shadowing of common variables like HOME is highly risky - the agent must use an original name instead. Outcome rule: deny destructive actions which involve a shadowed common variable like HOME.
HOME=/tmp/x rm -rf $HOME/... 这类把关键变量遮蔽掉再做破坏的手法,被单独点名了。这条一定是从真实事件里学来的。
Low-Risk Actions 整节都在防误报:
- Do not treat a sandbox retry/escalation as suspicious by itself.
- Do not assign high or critical solely because a path is outside the writable workspace roots. Benign local filesystem actions are usually low risk.
- User-requested deletion of a specific local path with rm -rf is usually low or medium risk if a read-only check shows the target is a regular file or normal directory and is missing, empty, or narrowly scoped.
注意最后一条的措辞:"如果一次只读检查显示……"——Guardian 被鼓励先跑只读命令去看看再下结论,而不是纯靠推理。这是它相对于静态规则的最大优势:它可以调查。
可迁移的判断 ⑳ 如果你要用 LLM 做判官,把判定标准写成一份可读、可版本控制、可评审的策略文档,而不是散在提示词里的几句话。
这份 policy.md 的价值不在"它是给模型看的",而在它是给人看的:安全团队可以评审它、可以针对每条规则写测试、可以在事故后往里加一条。它是一个可治理的对象。
反例是把判断标准塞进 system prompt 的角落里——没人知道它说了什么,改一次没人能评审,出事了也无从追溯。
[源码 core/src/guardian/mod.rs:54]
连续被 Guardian 拒 3 次("cyber" 模型只需 1 次),这一轮就被中断。
为什么需要这个?因为模型被拒之后会换个写法再试。如果 Guardian 一直拒、模型一直试,这一轮就会烧光预算什么也没做成。熔断器让它尽早失败并交还给用户。
注意有两套阈值:CYBER_* 那套只允许 1 次。这应该对应某类高风险场景(网络安全相关任务),容忍度更低。
每一档都有独立上限:消息转录 1 万、工具转录 1 万、单条消息 2 千、单条工具输出 1 千、被评估的动作字符串 1.6 万、最近条目 40 条。
设计意图很清楚:Guardian 的成本必须有界。它是在用户等待的关键路径上跑的(90 秒超时),不能因为主会话很长就跟着变慢变贵。
注意 GUARDIAN_MAX_ACTION_STRING_TOKENS 是最大的一档(16000)——被评估的那个动作本身给的预算最多,历史给得少。这个优先级排序是对的。
Fail closed on timeout, execution failure, or malformed output.
超时、执行失败、输出格式不对,一律当作"没有批准"。第 3 章的协议里有 GuardianAssessmentStatus::{InProgress, Approved, Denied, TimedOut, Aborted} 五种状态,UI 能区分"Guardian 拒绝了"和"Guardian 超时了"。
用户还有最终否决权:Op::ApproveGuardianDeniedAction(第 3 章)——人可以推翻 Guardian 的拒绝。同时 AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX 会把这个事实注入上下文:
告诉主模型"用户手动批准了一个之前被拒的动作"——避免它继续按"这条路走不通"来规划。
每一级都可以独立关掉,关掉任何一级其余照常工作。 这是"纵深防御"该有的形状。
诚实地列三条:
① Guardian 也是模型,也会被骗。 用模型防提示词注入,本身就在提示词注入的攻击面上。Codex 的缓解是:Guardian 跑在独立的 review session 里,看到的是重构过的精简转录而不是原始上下文——降低了注入内容直达 Guardian 的概率,但没有消除。
② 延迟与成本。 90 秒超时意味着最坏情况下用户要等一分半才知道命令能不能跑。每次评估都是一次真实的模型调用。
③ 误报的产品代价。 前面引的那些"Do not treat … as suspicious"条款说明他们在和误报作战。策略文档里防误报的篇幅和防漏报的篇幅大致相当——这个比例本身就是经验的沉淀。
下一章换个方向:这些跑过的东西怎么存下来,以及一个会话怎么恢复、分叉、回滚。