写入授权与隔离工作区:能编辑文件,不等于被授权编辑这个文件

StageWriteGrantV1 将任务权限压缩为绑定版本、路径、操作与执行实例的一次性能力,并由隔离工作树和逐层回读共同实施。

本页目录 · 9 节

Agent 拿到文件工具以后,通常具备很宽的技术能力:它能打开仓库、创建文件,也可能执行会间接写盘的命令。但一次任务真正授予它的权限往往很窄,例如「在这个 feature branch 上,只修改两个文件,不要碰用户已有改动」。成熟的写入控制必须把这两件事分开:

  • Capability(能力):进程和工具在操作系统层面能做什么;
  • Authority(授权):用户与当前任务允许这次执行做什么;
  • Enforcement(执行约束):即使执行器误解指令,哪些检查仍会阻止越权结果进入源工作区。

自然语言可以表达授权意图,却不适合单独充当执行边界。Marshall Harness 因此把写入权编码成 StageWriteGrantV1,再把不可信执行放进独立 worktree。前者回答「谁可以在什么前提下改哪些目标」,后者回答「执行失败会污染哪里」。

从任务意图到一次性能力

写入链路里,主 Agent 仍然是权限所有者。执行器只是接受一张窄授权并产生候选结果,不能自行扩大范围,也不能直接把结果写回源工作区。

flowchart LR
  A[目标与修改计划] --> B[分支及目标预条件回读]
  B --> C[StageWriteGrantV1]
  C --> D[独立 detached worktree]
  D --> E[受限 Executor]
  E --> F[候选结果独立回读]
  F --> G[PatchBundleV1]
  G --> H[Host 验证与提升]

这条链路有一个关键性质:执行器对隔离工作区拥有写能力,不代表它拥有写源工作区的能力;能够产生一个文件,也不代表这个文件会被接纳。真正跨越边界的是经过授权、验证和回读的候选状态。

StageWriteGrantV1 绑定了什么

Grant 不是一句「允许修改代码」,而是一份不可随意解释的结构化合同。Schema 使用 additionalProperties: false,要求调用方把授权上下文完整写出,未知字段也不能悄悄改变语义。

绑定维度 核心字段 要解决的问题
工作流身份 audit_idworkflow_versionstageattemptrun_idgenerationresume_epochissued_revision 防止上一轮或另一条工作流的授权被搬到本轮使用
并发所有权 lease_fencing_token 旧持有者即使晚到,也不能覆盖新 lease 已经接管的执行
节点与执行器 write_grant_node_idisolated_executor_node_idexecutor_authorityexecutor_instance_id 把授权绑定到指定节点、指定执行实例和指定执行权限
决策依据 plan_refbranch_check_refisolation_attestation_ref 用路径与 SHA-256 引用修改计划、分支检查及隔离证明,避免依据在签发后被替换
任务来源 jira_binding_refincident_binding_ref,二者必须且只能出现一个 证明分支与一个经过验证的任务来源相匹配,而不是由执行器自报范围
仓库快照 repository_identitybranchbase_commitbase_tree 限定具体仓库、非保护分支和确切 Git 基线
目标前提 target_preconditionstarget_preconditions_sha256 记录每个目标的 pathexistssha256modeindex_sha256
写入范围 write_allowlistwrite_allowlist_sha256 对每条路径只允许 createmodifydelete 中的一种操作
生命周期 grant_idstatusmax_usesusesissued_atexpires_at 把授权限制为有效期内的一次 claim,并支持精确对账

target_preconditionswrite_allowlist 必须覆盖同一组已排序、无重复路径。操作也必须符合目标状态:不存在的目标只能授权 create,存在的目标不能伪装成创建。两组内容各自再做整体哈希,避免有人只替换数组、却保留外围 Grant 身份。

这一设计接近最小权限原则:授权的不是抽象的「写仓库」,而是「执行实例 X 可以在基线 Y 上,对路径 Z 完成操作 O」。NIST SP 800-53 的 AC-6 将 least privilege 定义为只授予完成任务所需的最小权限;Grant 将这条原则落实到了单次代码修改的粒度。

一次使用、有效期与 fence 各自防什么

这三个限制看起来相似,实际防的是三类不同故障:

  1. max_uses: 1 防止同一授权被多个 Executor 抢用。claim 会绑定唯一的 ExecutorRunV1,重复 claim 必须与已有记录对账,而不是重新获得一次写入机会。
  2. expires_at 限制授权窗口。过期后到达的执行结果只是一个晚到的候选工件,不能自行开启新的写入操作。
  3. lease_fencing_token 处理并发接管。单靠超时无法阻止旧进程恢复运行;单调递增的 fence 让 Host 可以识别「这个结果属于已经失去所有权的执行者」。

若提升日志已经在授权窗口内按精确条款开始,崩溃恢复可以在新的同阶段 lease 下继续;它仍须证明新的 fence 不早于原 Grant,并沿用同一个 operation、bundle 与目标状态。恢复的是已开始的事务,不是给晚到执行器重新授权。

Worktree 是故障域,不是权限证明

隔离执行使用 git worktree add --detach <path> <base_commit> 创建独立 checkout。运行时随后回读 HEADHEAD^{tree},两者必须分别等于 Grant 中的 base_commitbase_tree。worktree 位于仓库之外的 owner-only 私有目录,.git 指针也必须指回预期的 Git common directory。

它主要缩小三种故障的影响半径:

  • Executor 生成大量错误文件时,用户正在使用的 checkout 不会被直接覆盖;
  • 执行中断时,半成品留在可隔离检查的目录,而不是混进用户未提交修改;
  • 多个 Executor 可以拥有不同 checkout,避免把同一个 working tree 当作共享可变内存。

但 worktree 本身不是授权系统。一个进程仍可能在隔离 checkout 内修改白名单之外的文件。因此打包阶段会独立读取 git status --porcelain -z,将所有变化与 Grant 的精确 allowlist 比较;只要出现额外路径,整个候选结果就被拒绝。隔离证明还约束网络、MCP、插件、子 Agent、外部变更以及 source Git write 等能力,防止执行器绕过文件合同从另一条通道产生副作用。

这也说明了安全边界的真实范围:worktree 能很好地隔离误操作和工具失败,却不能对抗已经控制同一主机、同一用户身份的恶意进程。Host、操作系统权限与平台 sandbox 仍然是更外层的信任根。

TOCTOU:签发时正确,不代表写入时仍正确

从检查目标到真正写入之间存在时间窗口。用户可能拉取了新提交、切换了分支、修改了目标文件,另一个进程也可能替换目录或符号链接。只在签发 Grant 时检查一次,会留下典型的 TOCTOU(time-of-check to time-of-use)漏洞。

这套合同采用多层重验:

  1. 签发前:记录 repository identity、branch、HEAD、tree、目标内容哈希、mode 和 index 状态;
  2. 创建隔离区后:回读 detached worktree 的 commit/tree;
  3. 打包前:从 Git 基线和执行结果分别读取 old/new 状态,确认 old 与 Grant 预条件一致;
  4. 提升前:再次检查源仓库 identity、branch、HEAD/tree、目标 index 以及工作区目标状态;
  5. 每个文件落盘时:保留从仓库根到父目录的描述符链,写前确认目标仍是 old 状态,原子替换后确认已经成为 new 状态。

因此「仓库是 dirty 的」不是一个足够精确的拒绝理由。非目标路径上的用户改动可以保持不动;真正的硬边界是:授权目标必须匹配被记录的 working-tree 与 index 预条件。目标一旦变成既不是预期 old、也不是预期 new 的第三种状态,就停止,而不是猜测怎样合并。

路径不是普通字符串

路径校验要在进入文件系统之前完成。Grant 与 Patch 合同只接受规范的仓库相对 POSIX 路径,并拒绝:

  • 绝对路径、空路径、NUL 与反斜杠;
  • ...、空组件和 .git 组件;
  • 重复或大小写折叠后冲突的目标;
  • 与授权操作不一致的 create/modify/delete。

读取执行结果时还使用 no-follow 语义:符号链接、非普通文件、超出大小上限的文件和多 hard-link 文件都会失败。提升时不能只做一次字符串前缀判断;父目录链与叶子文件的 device/inode 会在打开、读取和替换前后反复核对,从而降低 symlink swap 和目录替换绕过 ../ 检查的风险。

失败应当怎样表现

成熟的写入隔离不是尽量完成,而是在权限证据不完整时 fail closed。

失败场景 正确处理
Executor 修改了 allowlist 外路径 不生成可提升的 Bundle;隔离区保留用于诊断
目标路径在签发后被用户修改 拒绝提升,重新读取目标并重新计划;不覆盖用户版本
输出是 symlink、目录或 hard link 拒绝读取和打包,不跟随链接
分支、HEAD 或 tree 已改变 Grant 失效;不能只更新一个 base 字段继续使用
Executor 在授权过期后才返回 标记为晚到结果;若没有窗口内已开始的日志,不产生新的写权限
旧 lease 的进程继续运行 fence 校验失败;结果不能进入 Host 写入路径
worktree 创建到一半失败 隔离并对账 orphan,不自动清理可能仍含证据的目录

Trade-off 与适用边界

这套机制用相当高的实现成本换取了三项性质:精确授权、用户工作区隔离和可恢复的证据链。代价也很具体:每次写入都需要计划与预条件、Grant 生命周期、额外 worktree、文件哈希、隔离证明以及跨边界验证;目标频繁变化时,基线失效会造成重跑。

它更适合这些场景:执行器信任较低、任务运行时间长、多个执行器并发、用户工作区有高价值未提交改动,或修改属于安全/生产相关代码。对于可即时审查、影响面很小的本地修改,同等强度的运行时可能不划算;此时仍应保留权限边界,但可以通过 feature branch、平台 sandbox、精确 diff 和风险相称的验证来实现。

参考资料

相关文章

在做类似的事情?

很乐意就分布式系统、交付流程和应用 AI 交换意见。

[email protected]