Article
权限系统真正要证明的,不是“允许”,而是“在哪个边界内执行”
如果一个 Agent 命令失败了,系统应该怎样决定是否给它更多权限?
这是 Orca 这次重构真正要回答的问题。
旧逻辑会从子进程的 stderr 中提取绝对路径,再把它当成“沙箱拒绝了这个路径”:
Unable to create '/etc/example': Operation not permitted
但这段文字可能是命令自己打印的。命令甚至没有访问过 /etc/example,只是输出了一条看起来像错误的信息。只要它能影响下一次授权,错误信息就获得了不该有的权力。
我后来把问题改写成了一句更严格的话:权限系统必须证明一次进程启动发生在什么边界内,而不能只记录某个人点过“允许”。
这句话决定了后面的架构。
先分清三个容易混在一起的事实
Agent 的权限链路里至少有三种不同的事实:
- 模型或配置提出了什么意图;
- runtime 根据规则计算出了什么能力;
- 操作系统最终让进程在哪个边界内运行。
过去这三件事经常由不同模块分别保存。模型说“写入这个目录”,approval handler 说“允许”,sandbox 再根据自己的状态拼一条启动命令。正常路径上它们大多一致,异常路径就开始互相猜测:stderr 是不是内核拒绝?UI 的允许是不是已经落盘?backend 不可用时能不能先用普通 shell?
如果没有一个对象把最终结果固定下来,系统就没有“这次执行究竟获得了什么权限”的答案。
因此新的模型把三个阶段写成三个类型:
CapabilityRequest 用户或模型提出的请求
EffectiveCapability 经过所有上限收紧后的结果
CapabilityReceipt 进程实际启动时的证明
CapabilityRequest 是输入,不是授权。它包含进程类别、shell 能力、读写根目录、metadata 根目录、网络开关和目标集合。
EffectiveCapability 只能通过交集得到:
request
∩ parent ceiling
∩ approval-mode ceiling
∩ platform ceiling
每一层都可以收紧,不能增加能力。Plan 模式的只读上限就是硬约束。即使项目配置声明允许写入,交互层收到“允许”,最终结果也不能突破这个上限。
进程启动后生成 CapabilityReceipt,记录 request id、进程类别、cwd、读写根目录、网络目标、backend 和 enforcement 状态。后面的协议、日志和重试逻辑引用的是 receipt,不是最初的请求,也不是一个孤立的按钮事件。
为什么 Broker 必须卡在 spawn 之前
只在模型层做权限检查是不够的。任何一个绕过检查、直接启动子进程的调用点,都可以把前面的策略变成说明文档。
所以 Orca 增加了 ExecutionBroker,把“解析能力”和“启动进程”放进同一条控制路径:
请求能力
-> 计算交集
-> 检查父级上限
-> 检查 backend 是否真的可用
-> 启动进程
-> 返回 receipt
runtime shell、MCP stdio、workflow、hook、subagent、内置 process tool、通知、更新和 worktree 操作,都经过这个 broker。生产 crate 中的旧 ProcessJob::spawn 被标记为 deprecated,并通过 deny(deprecated) 和静态边界检查阻止新的业务调用点。
这不是为了让代码看起来更整齐。它解决的是一个所有权问题:只有启动进程的地方,才知道“算出来的能力”有没有真正交给操作系统。
这里保留了一个显式例外:UserTrustedIntegration。MCP stdio 或 danger-full-access shell 可能确实需要运行在宿主环境,它们由用户拥有的 launcher 显式启动,并返回 Advisory receipt。模型不能把普通工具自行升级成这个类别,advisory 也不能被描述成 sandbox 已生效。
权限边界只能向内收紧
交集规则解决的是“能力不能被扩大”,但还要解决“谁可以提出请求”。
项目目录里的 .orca/config.toml 现在可以描述模型和展示偏好,却不能决定执行权限。approval mode、permission rules、MCP servers、hooks、external tools、subagents、workflows、tools 和 budget settings 等安全相关字段会被剥离或降级。
原因并不复杂:项目文件可能来自仓库,也可能刚刚被 Agent 修改过。让被执行对象修改自己的权限策略,相当于把 ceiling 交给了请求本身。
子 Agent 使用同样的规则。它拿到的是父任务能力的子集,任何扩大读写根目录或网络目标的请求都会在启动前被拒绝。重试授权还会根据完整的运行时设置和工具目录生成 fingerprint;approval、profile、root 或网络策略发生变化,旧授权就失效。
这套设计把“授权一次”改成了“授权一组明确的能力,并且只对那次能力快照有效”。
cwd 不是字符串,sandbox 也不是一个布尔值
路径边界是另一处容易产生错觉的地方。
/workspace/project 和 /workspace/project-other 不能靠字符串前缀安全地区分。新的 WorkspaceIdentity 会规范化 workspace root 和 cwd,拒绝相对路径与 .. 穿越,检查 cwd containment,并在 Unix 下用设备号和 inode 检测 workspace root 是否被替换。
Linux Landlock 的根目录进一步使用 descriptor-backed PathBeneath 规则打开。读、写、metadata 和 denied roots 分开计算,.git、.agents、.codex 等受保护目录不会因为一个普通写请求被一起放开。
但路径检查解决不了 backend 不存在的问题。新的 EnforcementState 明确区分:
| 状态 | 代表什么 | 普通非信任工具 |
|---|---|---|
Enforced | 操作系统边界已建立 | 可以执行 |
Advisory | 只有显式用户信任集成可用 | 拒绝执行 |
Unavailable | 无法证明隔离成立 | 拒绝执行 |
Linux 的 bwrap、Landlock 和 seccomp 不能完整建立时,不再退回普通 shell。macOS Seatbelt 先经过 restrictive probe,Windows 的 AppContainer 和 ConPTY 走原生授权路径。失败的结果是“当前环境不能提供这项能力”,不是“先放行,之后再补日志”。
这就是 fail-closed 在 Agent 产品里的实际含义:少完成一次命令,也不要伪造一次安全边界。
stderr 的正确位置:诊断,不是证据
回到开头的路径问题,修复并不是把路径解析器写得更复杂,而是降低 stderr 的权限。
进程输出仍然可以帮助用户理解失败,但会明确标记为:
Sandbox diagnostic (process output, non-authoritative)
它不能直接触发文件系统扩权,也不能触发 unsandboxed shell retry。真正可以支持后续升级的输入,必须是 backend 发出的结构化 SandboxDenialReceipt,并带有明确的 Kernel 或 Backend 来源。
一个进程可以报告“我失败了”,但不能凭自己的输出证明“我应该获得更多权限”。这条限制也适用于未来的自动修复:修复动作要消费可验证的拒绝 receipt,而不是消费一段长得像系统错误的文本。
UI 的任务不是替用户猜测
当权限请求只显示“允许访问文件”时,用户无法判断自己究竟批准了什么。
新的权限协议把执行上下文一起带到 UI:command、cwd、read roots、write roots、network targets、backend、enforcement state 和 denial provenance。用户看到的不是一个抽象的权限按钮,而是一份待确认的能力快照。
这也改变了 UI 的职责。UI 可以让人批准或拒绝请求,却不能凭一个交互事件制造 receipt;客户端断线、重复响应或响应延迟,也不能改变已经提交的执行事实。
如果这部分没有和 runtime 共用同一个能力对象,界面迟早会重新长出一套自己的“当前权限”。那时系统只是把猜测从 stderr 搬到了前端。
读者可以带走的检查表
设计一个带 shell、文件或网络能力的 Agent 工具时,我现在会先检查四件事:
- 请求是什么? 能否用不可变数据完整描述,而不是隐含在 prompt 或配置里?
- 上限在哪里? 父任务、运行模式和平台能否只收紧权限,不能被请求反向扩大?
- 谁负责启动? 是否存在一个不可绕过的 broker,把最终能力交给真实 backend?
- 执行凭什么算成立? 是否有 receipt 记录 cwd、根目录、网络目标和 enforcement,而不是只记录“用户点了允许”?
如果第四个问题答不上来,前三个问题大多只是文档。权限系统的目标不是让每次操作都通过,而是让每次通过的操作都能说明自己在哪个边界内发生。
Keep Reading