Harness 与治理
用运行环境控制工具、权限、副作用、预算、人工确认、恢复和平台差异。
谁控制权限、运行环境、副作用、平台适配和恢复?概念校准
工具
- 它是什么
- Agent 可通过明确参数、权限和结果合同调用的受约束能力。
- 它不是什么
- 工具不等于 MCP;本地函数、命令或 HTTP 客户端也可以是工具。
Harness
- 它是什么
- 承载模型、工具、状态、权限、日志、预算、人工确认和恢复的运行控制环境。
- 它不是什么
- 它能限制风险并保留证据,但不能直接阻止模型产生幻觉。
验证与评测
- 它是什么
- 用测试、Schema、引用、状态和验收规则检查单次结果,并用数据集衡量系统表现。
- 它不是什么
- 不等于让模型对自己的输出再给一次意见;验证必须落到外部可检查证据。
工具系统、沙箱与副作用控制
建立工具注册、Dry-run、变更计划、确认、回滚和审计日志。
Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n\n\n### 工具 tool\n\n- 它是什么: Agent 可通过明确参数、权限、执行边界和结果合同调用的受约束能力,例如函数、命令、数据库查询或 HTTP 客户端。\n- 它不是什么: 工具不等于 MCP,也不等于任意脚本。协议可以暴露能力,但 Harness 仍决定是否允许调用及如何处理副作用。\n- 与相邻概念的关系: Agent Loop 选择工具,Schema 约束参数,Harness 执行权限检查和隔离,Verification 检查结果是否推进目标。\n- 在 HeatStack Forge 中的位置: Forge 的 tool registry 保存合同与风险等级;执行前生成变更计划,高风险写操作等待确认,执行后记录结果与恢复信息。\n- 典型误用与修正: 误用是让模型根据工具名称自由拼参数。修正方法是严格 Schema、确定性校验、超时、幂等键和拒绝分支测试。\n\n### Harness harness\n\n- 它是什么: 承载模型、工具、状态、权限、日志、预算、人工确认、隔离和恢复的运行控制环境。\n- 它不是什么: 它可以缩小风险、拒绝动作并保留证据,但不能保证模型输出永远正确,也不能代替领域验收。\n- 与相邻概念的关系: Agent Loop 在 Harness 中运行;Tool、Memory 和 MCP 连接受其策略约束;Observability 记录过程,Evaluation 判断行为。\n- 在 HeatStack Forge 中的位置: Forge Harness 管理工作目录、工具、确认队列、预算、Checkpoint、审计日志和回滚,把模型建议与真实副作用分开。\n- 典型误用与修正: 误用是把系统提示词当成安全边界。修正方法是把权限、路径、网络、密钥、预算和回滚落实在模型之外的控制中。\n\n### 验证与评测 evaluation\n\n- 它是什么: Verification 用测试、Schema、引用、状态或验收规则检查一次结果;Evaluation 用数据集和指标衡量多次运行表现。\n- 它不是什么: 它不是让模型对自己的回答再给一次意见;通过与否必须落到外部可检查证据。\n- 与相邻概念的关系: Agent Loop 依赖 Verification 决定停止,RAG 需要分层评测,Planning 需要节点验收,Observability 提供运行记录。\n- 在 HeatStack Forge 中的位置: Forge 为单次运行保存合同测试与 evidence,再用固定场景集比较质量、延迟、成本、权限和恢复能力。\n- 典型误用与修正: 误用是把一个综合分数当成全部结论。修正方法是定义分层指标、失败样本和版本基线,并结合产品风险评审。\n\n
为工具声明读取、写入和网络能力,并按最小能力集合查找工具
先看一个具体问题
Harness 在工具调用前如何知道工具可能产生哪些副作用
在 Module 07 的单 Agent 循环中,工具调用是直接执行的,Harness 无法在调用前判断某个工具是否会写入文件或发起网络请求,这导致任何工具都可能产生未受控的副作用。
本阶段需要创建 forge/tools/registry.py,引入 ToolDescriptor 和 ToolRegistry 两个符号,让每个工具在注册时声明自己的能力集合,Harness 据此在调用前进行权限判断。
当前测试文件 tests/test_stage.py 尝试从 forge.tools.registry 导入 ToolDescriptor 和 ToolRegistry,但由于 forge/tools 包尚不存在,收集阶段直接抛出 ModuleNotFoundError: No module named 'forge.tools',退出码为 2。
先做判断
当 ToolDescriptor("shell", frozenset({"root"})) 被构造时,__post_init__ 中的 unknown = set(self.capabilities) - set(ALLOWED_CAPABILITIES) 计算结果是什么,接下来会发生什么?
- unknown 为空集合,构造成功
- unknown 为 {‘root’},抛出 ValueError
- unknown 为 {‘read’, ‘write’},抛出 ValueError
- unknown 为 {‘root’},构造成功
判断依据: ALLOWED_CAPABILITIES 包含 read、write、network、process 四个成员,不包含 root,因此差集结果为 {‘root’},非空集合触发 ValueError。选择第二项。
它是什么
工具能力声明是 Harness 权限决策的依据
ToolDescriptor 是一个冻结数据类,包含 name 和 capabilities 两个字段,capabilities 是 frozenset[str] 类型,表示该工具可能产生的副作用类别。
ALLOWED_CAPABILITIES 是一个模块级 frozenset,包含 read、write、network、process 四个成员,构成 Harness 的策略词汇表,所有工具声明的能力必须是其子集。
ToolRegistry 维护一个 dict[str, ToolDescriptor],提供 register 方法注册工具并在重名时抛出 ValueError,提供 supporting 方法按所需能力集合筛选工具。
它不是什么
工具能力声明不等于任意脚本或 MCP 协议
工具不是任意脚本,它必须通过 ToolDescriptor 显式声明能力,未在 ALLOWED_CAPABILITIES 中的能力会在构造阶段被拒绝,而不是等到运行时才发现。
工具也不等于 MCP,协议可以暴露能力,但 Harness 仍依据 ToolDescriptor.capabilities 决定是否允许调用,协议本身不构成权限依据。
能力声明不代替领域验收,它只告诉 Harness 某个工具可能产生哪类副作用,具体调用结果是否正确仍需 Verification 检查。
它与相邻概念的关系
能力词汇表、描述符与注册表的因果链
ALLOWED_CAPABILITIES 约束 ToolDescriptor 的合法能力集合,ToolDescriptor.__post_init__ 在构造时计算 set(self.capabilities) - set(ALLOWED_CAPABILITIES),若结果非空则抛出 ValueError,这确保注册到 ToolRegistry 中的工具不会携带未声明的能力。
ToolRegistry.supporting 接收一个 required 集合,通过 required <= set(tool.capabilities) 判断已注册工具的能力是否为所需集合的超集,仅返回满足全部能力要求的工具。
Agent Loop 选择工具时,先向 ToolRegistry 查询 supporting(required),Harness 再根据返回的描述符执行权限检查和隔离,Observability 记录整个过程。
本阶段的边界决定: 能力声明在构造阶段强制校验,注册阶段强制去重,查询阶段按子集关系筛选;Harness 不会执行任何携带未声明能力的工具,也不会在运行时动态扩展 ALLOWED_CAPABILITIES。
把工具路径限制在工作区内,并拒绝绝对路径、穿越和符号链接逃逸
先看一个具体问题
字符串拼接无法阻止路径穿越
在软件交付场景中,Agent 需要修改前端仓库内的文件,工具系统接收一个相对路径并将其拼接到工作区根目录上。如果仅做词法拼接而不进行规范化解析,攻击者可以传入 ../secret.txt 这样的路径,使最终路径指向工作区之外的文件。
当前测试套件在 tests/test_stage.py 中定义了三个测试:test_relative_path_resolves_inside_workspace、test_parent_traversal_is_rejected 和 test_symlink_escape_is_rejected。由于 forge/tools/paths.py 尚不存在,测试在收集阶段就因 ModuleNotFoundError 而中断,退出码为 2。
学习者需要创建 forge/tools/paths.py 文件,实现 resolve_workspace_path 函数,使其在路径合法时返回工作区内已规范化的绝对路径,在路径穿越或符号链接逃逸时抛出包含 escapes 的 ValueError。
先做判断
当 workspace 为 /tmp/repo 且 requested 为 ../secret.txt 时,执行 resolved = workspace / Path(requested) 后再调用 resolved.relative_to(workspace) 会发生什么?
- 抛出 ValueError,因为 ../secret.txt 不以 workspace 为前缀
- 不抛出异常,因为词法拼接后的路径字符串仍以 workspace 开头
- 抛出 FileNotFoundError,因为文件不存在
- 返回 /tmp/secret.txt 且不报错
判断依据: 词法拼接 workspace / Path(“../secret.txt”) 得到的是 /tmp/repo/../secret.txt,这个字符串仍然以 /tmp/repo 开头,所以 relative_to(workspace) 不会抛异常。只有先调用 .resolve() 将 .. 消解为规范路径后,路径才会变成 /tmp/secret.txt,此时 relative_to 才能检测到逃逸。
它是什么
路径规范化解析的含义
路径规范化解析是指通过 pathlib.Path.resolve() 方法将路径中的 . 和 .. 段消解为真实的绝对路径,同时解析沿途的符号链接指向。只有经过 resolve 后的路径才代表文件系统上的真实位置,才能可靠地判断它是否落在工作区范围内。
resolve_workspace_path 函数的核心策略是:先拒绝绝对路径输入,再将工作区根目录与候选路径拼接,然后对拼接结果调用 resolve(strict=False) 得到规范路径,最后用 relative_to 检查规范路径是否仍在工作区内。
它不是什么
词法前缀匹配不是安全检查
仅检查路径字符串是否以工作区前缀开头不是有效的安全措施,因为 ../secret.txt 拼接后仍以工作区前缀开头但实际指向外部文件。词法匹配无法理解 .. 的语义,也无法发现符号链接指向工作区之外的目标。
resolve(strict=True) 在此场景中也不合适,因为目标文件可能尚未创建,strict=True 会在文件不存在时抛出 FileNotFoundError,而路径安全检查的目的是在文件访问之前就拒绝非法路径。
它与相邻概念的关系
resolve 与 relative_to 的协作关系
resolve 将路径转换为规范形式,消除 .. 和符号链接的歧义;relative_to 则在规范路径上做严格的包含关系判断。两者必须配合使用:先 resolve 再 relative_to,顺序不能颠倒也不能省略 resolve。
workspace 根目录本身也需要先调用 resolve() 进行规范化,否则当 workspace 包含符号链接时,即使候选路径合法,relative_to 也可能因前缀不匹配而误判。
本阶段的边界决定: 路径安全检查的边界在于:resolve(strict=False) 负责消解路径语义但不要求文件存在,relative_to 负责在规范路径上做包含判断,两者缺一不可;绝对路径在进入拼接之前就应被拒绝。
在写入前生成不可变变更计划,明确目标、摘要和预期内容哈希
先看一个具体问题
写入前必须生成绑定字节内容的变更计划
当前代码库中不存在 forge/tools/plans.py 文件,因此测试在收集阶段抛出 ModuleNotFoundError,无法导入 build_change_plan 符号。学习者需要从零创建该文件,定义 PlannedWrite 与 ChangePlan 两个不可变数据类,并实现 build_change_plan 函数。
该函数的核心职责是在任何文件写入操作发生之前,将人类可读的摘要与待写入的字节内容分离,并对字节内容计算 SHA-256 哈希。如果错误地对摘要字符串计算哈希,审批环节将绑定到人类可读的描述文本而非实际写入的字节,从而导致内容替换攻击成为可能。
先做判断
在 faultySource 中,build_change_plan 使用 sha256(summary.encode(“utf8”)).hexdigest() 计算哈希。当测试断言 plan.writes[0].content_sha256 == sha256(b“A”).hexdigest() 时,结果会怎样?
- 断言失败,因为对摘要字符串 ‘write a’ 计算哈希的结果不等于对字节 b“A” 计算哈希的结果
- 断言通过,因为摘要和内容在概念上可以互换
- 抛出 TypeError,因为 summary 是字符串而 content 是 bytes
- 断言失败,因为排序导致路径顺序错误
判断依据: 正确答案是第一个。SHA-256 对不同输入产生不同输出,对字符串 ‘write a’ 编码后的哈希值必然不等于对单字节 b’A’ 计算的哈希值。这揭示了将人类可读元数据误认为副作用载荷的根本错误。
它是什么
变更计划是不可变的内容绑定契约
ChangePlan 是一个 frozen dataclass,它在写入操作执行之前捕获操作标识符和所有待写入文件的路径、摘要及内容哈希。一旦构造完成,其字段不可修改,这保证了审批阶段看到的计划与执行阶段使用的计划是同一份不可变记录。
content_sha256 字段必须由待写入的 bytes 直接计算得出,而非由 summary 字符串编码后计算。这意味着审批者通过验证哈希就能确认将要写入磁盘的确切字节内容,从而在审批与执行之间建立起密码学绑定。
它不是什么
摘要文本不能替代字节内容作为审批依据
summary 字段是人类可读的描述,例如 ‘write a’ 或 ‘write b’,它的作用是帮助操作者理解写入意图,但它的编码结果与实际文件字节毫无关联。两个完全不同的文件内容可以共享相同的摘要文本,因此对摘要计算哈希无法唯一标识写入载荷。
ChangePlan 也不是一个通用的键值存储或配置文件。它专门服务于副作用控制流程,其 writes 字段是一个有序的 PlannedWrite 元组,排序依据是 relative_path,这确保了同一组写入无论字典插入顺序如何,生成的计划始终一致。
它与相邻概念的关系
计划与 Harness 审批及验证的因果链
Harness 在执行文件写入前要求生成 ChangePlan,审批环节绑定到 plan 中的 content_sha256,执行环节再次计算实际写入字节的哈希并与计划中的哈希比对。如果两者不匹配,Harness 可以拒绝执行,从而防止审批后内容被替换。
Verification 依赖 ChangePlan 的不可变性来检查结果是否推进目标。测试 test_plan_hash_changes_when_content_changes 验证了当 content 从 b“A” 变为 b“B” 时,content_sha256 必须不同,这确保了哈希对内容变化的敏感性。
本阶段的边界决定: content_sha256 必须由待写入的 bytes 计算得出,绝不能由 summary 字符串编码计算;计划构造后不可变,writes 按 relative_path 排序以保证稳定性。
根据副作用风险和批准绑定信息决定是否允许执行
先看一个具体问题
旧审批通过新计划:缺少 plan_digest 绑定的副作用风险
在软件交付场景中,Agent 在修改前端仓库前需要生成 ChangePlan 并等待人工审批。当前 forge/tools/approval.py 文件不存在,测试在导入 Approval 与 ApprovalGate 时直接抛出 ModuleNotFoundError,导致 tests/test_stage.py 全部收集失败。
即使后续创建了文件,如果 authorize 方法仅检查 approval.operation_id == operation_id,那么针对旧计划摘要 old 的审批仍能授权新计划摘要 new 的写入操作。这意味着攻击者或过时流程可以在计划内容已被替换后,复用同一操作标识的审批令牌执行未经验证的新写入。
本阶段要求你创建 forge/tools/approval.py,定义 Approval 数据类与 ApprovalGate 类,使 authorize 方法根据风险等级、操作标识和计划摘要三者共同决定是否允许执行,从而堵住旧审批授权新内容的漏洞。
先做判断
当 risk="reversible_write" 且 approval=Approval("op", "old"),而当前 plan_digest="new" 时,仅检查 operation_id 的 authorize 方法会返回什么?
- 返回 False,因为 plan_digest 不匹配
- 返回 True,因为 operation_id 匹配而 plan_digest 被忽略
- 抛出 ValueError,因为风险等级未知
- 返回 None,因为 approval 不完整
判断依据: 正确答案是返回 True。因为 faultySource 中的 return approval.operation_id == operation_id 只比较操作标识,完全忽略了 plan_digest 字段,所以旧审批会错误地授权新计划。这正说明必须同时校验 approval.plan_digest == plan_digest。
它是什么
ApprovalGate 的审批绑定模型
ApprovalGate.authorize 是一个纯决策函数,接收风险等级 risk、操作标识 operation_id、当前计划摘要 plan_digest 以及可选的 Approval 对象,返回布尔值或抛出异常。它不执行任何写入,只判断当前调用是否被允许进入执行阶段。
Approval 是一个冻结数据类,包含 operation_id 和 plan_digest 两个字段,代表人工审批时绑定的操作与计划内容快照。审批对象一旦创建就不可变,确保审批令牌不会被篡改以匹配不同的计划。
风险等级分为三类:read 无副作用无需审批;reversible_write 和 irreversible 有副作用,必须提供 Approval 且其 operation_id 与 plan_digest 同时匹配当前调用参数;未知风险等级直接抛出 ValueError。
它不是什么
ApprovalGate 不是执行器也不是权限缓存
ApprovalGate 不负责执行写入操作或修改文件系统状态,它只做授权判断。实际执行由 Harness 中的其他组件完成,ApprovalGate 的返回值仅作为执行前的门控信号。
它不是对模型输出的二次意见验证,也不是通用的权限缓存系统。审批对象的生命周期由调用方管理,ApprovalGate 不存储历史审批记录,也不在多次调用间累积状态。
它不替代领域验收测试。即使 authorize 返回 True,执行结果仍需通过 Verification 检查是否真正推进目标,审批门控只是副作用控制链中的一个环节。
它与相邻概念的关系
ApprovalGate 与 ChangePlan、Harness 的因果链
ApprovalGate 依赖 Stage 03 中已通过的 forge/tools/plans.py 的 ChangePlan 产生的 plan_digest。计划摘要是计划内容的密码学指纹,当计划内容变化时摘要必然变化,因此将审批绑定到摘要可以防止内容被替换后旧审批仍然有效。
Harness 在 Agent Loop 中调用 authorize,如果返回 False 则拒绝执行并保留日志证据,如果返回 True 才将操作传递给隔离执行环境。这种设计使审批决策与执行隔离分离,降低单点失误的风险。
Approval 的 operation_id 绑定确保审批针对特定操作而非通用令牌,plan_digest 绑定确保审批针对特定内容版本。两者缺一不可:只有 operation_id 匹配时,攻击者可以更换计划内容复用审批;只有 plan_digest 匹配时,审批可能被挪用到其他操作。
本阶段的边界决定: read 风险直接返回 True 不检查 approval;reversible_write 和 irreversible 风险要求 Approval 的 operation_id 和 plan_digest 同时匹配当前调用参数;未知风险抛出 ValueError,绝不默认放行。
通过同目录临时文件和原子替换避免部分写入
先看一个具体问题
直接写入目标文件在崩溃时留下部分写入
在软件交付场景中,Agent 需要修改 config.txt 的内容。如果直接调用 target.write_bytes(content) 写入目标文件,当进程在写入过程中因崩溃或异常中断时,目标文件会残留部分字节,导致后续读取到截断的数据。
本阶段的起点是 forge/tools/approval.py 已通过验收,但 forge/tools/atomic.py 尚不存在,测试文件 tests/test_stage.py 在导入 forge.tools.atomic 时会抛出 ModuleNotFoundError。
学习者需要创建 forge/tools/atomic.py 并实现 atomic_write 函数,通过同目录临时文件和 os.replace 原子替换来保证目标文件要么完整更新,要么保持原样。
先做判断
当进程在 target.write_bytes(b"new-content") 执行到一半时崩溃,目标文件 config.txt 的内容会变成什么?
- 完整的新内容
b"new-content" - 原始内容
b"old" - 截断的部分内容,例如
b"new-c" - 文件为空
判断依据: 直接写入目标文件不是原子操作,操作系统按字节逐步写入,崩溃时目标文件会保留已写入的部分字节,导致内容被截断而非完整或原始。
它是什么
原子写入的机制
原子写入要求所有字节先持久化到与目标文件位于同一目录的临时文件中,调用 handle.flush() 将 Python 缓冲区刷入操作系统,再调用 os.fsync(handle.fileno()) 强制将操作系统页缓存写入磁盘。
在临时文件完整持久化后,通过 os.replace(temporary_name, target) 将临时文件原子替换为目标文件。os.replace 在 POSIX 和 Windows 上都是原子操作,替换期间目标文件始终可访问且内容完整。
它不是什么
原子写入的边界
原子写入不是先写临时文件再直接写目标文件。如果先写临时文件再调用 target.write_bytes(content),目标文件仍然面临部分写入的风险,临时文件的存在并不能保护直接写入的目标。
原子写入也不等于仅仅调用 flush()。flush() 只将 Python 内部缓冲区传递给操作系统,但数据可能仍停留在操作系统页缓存中,断电时会丢失,必须配合 os.fsync() 才能保证持久化。
它与相邻概念的关系
临时文件、替换与清理的协作
临时文件必须与目标在同一目录,因为 os.replace 的原子性仅在同一个文件系统内有效。跨文件系统替换会退化为先复制后删除的非原子操作。
finally 块中的 temporary_name 变量充当清理状态的标志。当 os.replace 成功后将其设为 None,表示临时文件已被替换、无需清理;若替换前发生异常,temporary_name 仍指向临时文件路径,finally 块负责将其删除以避免残留。
本阶段的边界决定: 原子写入的边界是:所有字节必须先通过同目录临时文件和 os.fsync 持久化,然后通过 os.replace 原子替换目标;失败时 finally 块必须清理残留的临时文件。
在变更前捕获文件存在性与原内容,并按逆序完整回滚
先看一个具体问题
文件修改后如何可靠地回到修改前的状态?
在软件交付场景中,你正在为一个前端仓库添加功能,需要修改现有文件并创建新文件。如果修改过程中出现错误,你必须能够将文件系统恢复到修改前的状态,否则后续步骤会基于错误的内容继续执行。
你手头已有 forge/tools/atomic.py 提供的 atomic_write 函数,它保证单次写入的原子性,但它不记录修改前的状态。你需要一个新的机制,在修改前捕获文件的存在性和原始字节,以便在需要时回滚。
本阶段的核心挑战是:当同一个文件被多次修改时,回滚日志必须保留第一次捕获的快照,而不是用后续修改后的内容覆盖它。如果覆盖了,回滚就会恢复到中间状态,而不是事务开始前的状态。
先做判断
在 RollbackJournal.capture 方法中,如果对同一个路径调用两次 capture,你认为第二次调用应该如何处理?
- 用当前内容覆盖第一次的快照,因为最新状态更准确。
- 忽略第二次调用,保留第一次捕获的快照。
- 追加一个新的快照,回滚时按逆序恢复所有快照。
- 抛出异常,因为重复捕获是编程错误。
判断依据: 正确答案是忽略第二次调用。回滚日志的目的是记录事务开始时的状态,而不是每次修改后的状态。如果覆盖了第一次快照,回滚就会恢复到中间状态,而不是原始状态。
它是什么
回滚日志是什么
回滚日志是一个记录文件系统变更前状态的列表,每个条目包含路径、该路径在捕获时是否存在以及原始字节内容。
capture 方法在修改文件之前调用,它记录当前状态;rollback 方法按逆序恢复这些状态,从而撤销所有变更。
它不是什么
回滚日志不是什么
回滚日志不是每次修改的完整历史记录,它只关心事务开始时的状态,因此不需要记录中间版本。
它也不是备份系统,它不保存修改后的内容,只保存修改前的状态,用于撤销操作。
它与相邻概念的关系
与原子写入的关系
atomic_write 保证单个文件写入的原子性,而回滚日志管理多个文件的整体恢复,两者互补。
回滚日志在恢复时调用 atomic_write 来写回原始内容,确保恢复过程本身也是原子的。
本阶段的边界决定: 回滚日志只处理文件系统状态,不涉及内存中的对象或数据库事务;它假设所有变更都通过文件系统操作完成,并且 capture 必须在修改前调用。
把路径校验、计划审批、原子写入、回滚和审计组合为安全变更事务
先看一个具体问题
控制器如何确保只有经过审批的字节被写入,且路径逃逸时能回滚?
你已经在前面阶段分别实现了路径解析、计划构建、审批门、原子写入和回滚日志,但还没有一个统一的入口来串联这些能力。现在需要创建 forge/tools/controller.py,提供 SideEffectController 类,其 apply 方法在一次调用中完成:构建计划、计算摘要、通过审批门校验、逐个原子写入,并在任何异常时回滚已写入的文件。
当前测试文件 tests/test_stage.py 导入 from forge.tools.controller import SideEffectController, plan_digest,但该模块不存在,因此运行 python -m pytest -q tests/test_stage.py 会报 ModuleNotFoundError: No module named 'forge.tools.controller',收集阶段直接失败。你的任务是创建这个模块,并让三个测试全部通过。
三个测试分别验证:正常审批后写入指定字节且审计状态为 committed;旧审批(摘要不匹配)被拒绝且不产生任何写入,审计状态为 denied;路径逃逸(如 ../escape.txt)触发回滚,已写入的安全文件被恢复,审计状态为 rolled_back。
先做判断
在实现 SideEffectController.apply 时,你认为审批校验的关键点是什么?
- 只要 approval 参数不是 None 就允许写入
- 必须用当前计划计算的摘要与 approval 中的摘要比对,通过 ApprovalGate 校验
- 只需要检查 approval.operation_id 是否匹配
- 不需要校验,直接写入即可
判断依据: 正确答案是第二个选项。测试 test_stale_approval_is_denied_without_writes 专门验证旧审批(摘要不匹配)必须被拒绝,因此仅检查 approval 非 None 是不够的,必须调用 ApprovalGate().authorize 并传入当前计划计算的摘要。
它是什么
安全变更事务的组成
SideEffectController.apply 是一个编排函数,它把五个独立能力按固定顺序串联:先用 build_change_plan 生成计划,再用 plan_digest 计算摘要,然后通过 ApprovalGate.authorize 校验审批,接着用 resolve_workspace_path 解析每个目标路径,最后用 atomic_write 写入并用 RollbackJournal 记录以便回滚。
摘要的计算方式是:对计划中每个写入项的 content_sha256 按顺序拼接成字符串,再对整个字符串做 SHA-256 哈希。这样任何字节变化都会导致摘要变化,从而让旧审批失效。
它不是什么
它不是简单的存在性检查
apply 不是只要 approval 对象存在就放行,而是必须验证审批的摘要与当前计划完全一致。如果只检查 approval is None,那么一个针对旧计划的审批也能通过,这正是故障夹具中注入的缺陷。
它也不是把写入和回滚分开处理,而是用 try/except 包裹整个写入循环,任何异常(包括路径逃逸)都会触发回滚,确保不会留下部分写入的状态。
它与相邻概念的关系
各组件如何协作
build_change_plan 生成计划,plan_digest 从计划中提取摘要,ApprovalGate.authorize 用摘要和 operation_id 校验审批,resolve_workspace_path 确保路径在 workspace 内,atomic_write 执行写入,RollbackJournal 记录已写入文件以便回滚。
审计列表 audit 记录每次操作的结果状态,测试通过检查 controller.audit[-1]["status"] 来验证行为是否符合预期。
本阶段的边界决定: 当审批摘要与当前计划摘要不一致时,必须拒绝写入并记录 denied,而不是尝试部分写入或忽略差异;当路径逃逸时,必须回滚所有已写入的文件并记录 rolled_back,而不是只跳过逃逸路径。
完成本章
修改文件前生成计划、等待确认并保留恢复记录。
本地实验自检
- 未开始
- 2阅读中
- 3实验已下载
- 4测试结果已读取
- 5本地自检通过
verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。
概念校准与一周复习
三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。