第10章 受控执行:权限、生命周期与实际边界
前面几章已经解决模型怎样选择能力、工具怎样接入、长任务怎样保存和使用材料。现在要面对一个更宽的动作入口:运行一条命令。用户让程序修改真实文件、构建真实产物或更新真实服务,目的就是让效果持续存在。把工作放到退出后丢弃的环境里,可能适合预演,却没有完成这个执行要求。
第3章的 read_source 与 create_card 通过固定实现限制效果。模型不能提供路径,不能启动程序,也不能调用任意库。这不是任意程序 sandbox。本章增加的是另一项责任:即使被运行的程序选择了没有事先列出的步骤,它的实际操作仍不能越过已确定的权限上界。先把边界说清,再选已有执行器,最后以允许与禁止的真实效果核验接入。我们不从零编写内核执行机制,也不靠产品名代替契约。
10.1从运行许可到实际约束
受控执行是把一个获准动作交给执行组件,并在动作实际发生的位置限制它能够读取、改变和传出的对象。它至少包含动作范围、执行主体、强制机制和结束条件。这里的“受控”不是提示词写着不要越界,也不是程序结束后发现违规再报警。
先分开四项责任。授权回答谁为哪个目标允许哪些操作。强制回答实际操作越界时由谁阻止。审计记录请求、判断与观察。恢复处理效果已经发生或可能发生之后怎样核对和接续。授权可以拒绝不适当的请求,却不能自行挡住获准命令内部另开的一条写入路径;审计可以揭示越界,却不能把已经传出的资料收回来。
最小形态不要求一个庞大的安全平台。它可以是一份绑定动作的策略、一条只能经已有执行器启动的路径,以及一个记录结果的宿主。策略限定专属输出目录可写,其他对象不可写;执行器在文件操作发生时执行这个限制;宿主保存动作身份、策略版本和结束证据。少掉强制路径,就只剩外层检查。
权限上界限定的是允许效果的集合,不是对完整执行轨迹的预测。例如,程序可以在授权目录内采用不同算法生成不同中间文件,但不能因此写入用户的其他文档。我们不必预先知道每次系统调用、所有文件名和每条分支,仍能限制它最多获得哪些能力。目标验收另行判断产物是否正确,上界本身不证明任务完成。
这也说明审批与凭据代理各自的位置。审批决定一次动作是否获准;凭据代理可以避免把秘密直接交给程序,并限定下游调用。它们有用,但若程序仍能自由写本机文件、接触既有连接或委托宽权限服务,就没有补齐那些运行时边界。不能用一个审批按钮解释所有实际效果。
可丢弃环境同样不是这项责任的替代品。它减少某些本地残留,不会自动阻止联网发送,也不会撤销远端提交。对于必须持久修改宿主文件的任务,应让获准位置真实可写,让其他位置实际不可写。效果持续存在,与效果范围受限,可以同时成立。
既然约束作用于实际效果,建设应从对象开始,而不是先选某种 sandbox。列出要保护的输入资料、其他工作区、凭证、系统配置和宿主账本;再列出允许读取的材料、允许写入的产物位置、允许调用的远端资源与允许发送的接收者。读取、写入和发送应分别写,不把“可访问”作为三个权限的共同名字。
接着确定可信组件。可信的含义是我们依赖它正确执行某项约束,不是认为它永不出错。执行器及其依赖承担强制责任,宿主承担授权和动作绑定,可信启动器承担环境与句柄整理。若被运行程序能替换策略、修改启动器或重写账本,这些组件就不能继续作为独立证据来源。
实际执行路径从工具请求走到对象效果。一个本地命令可能启动解释器,解释器启动子程序,子程序连接常驻服务,服务再执行文件或远端操作。只画到第一层进程,会漏掉后面的主体。每次跨边界都要问:后一个主体是否继承限制,或者是否在自己的执行处核验相同的操作范围?
可以把一项本地构建任务的声明写成下面的结构。它是接入时要落实的契约,不是某个平台可直接加载的配置:
保护资产:其他工作区、用户秘密、系统配置、宿主账本
允许读取:绑定版本的输入材料与可信运行时
允许写入:本次动作的专属 persistent 输出目录
允许传出:经宿主筛选后交付给获准接收者的结果
执行覆盖:命令的线程及全部后代
网络与外部 IPC:禁止
不能覆盖的转交路径:不开放
强制能力不可用:拒绝启动,不退回无约束执行
这份声明故意没有规定具体算法,却给出了判断执行器的条件。若执行器默认允许读取整个文件系统,它就没有兑现“只读绑定材料与运行时”。若写限制只保护一部分目录,也没有兑现其他对象不可写。名字里含有 sandbox 不会弥补这种契约差异。
选择已有执行器时,应核对它所在平台、版本、限制对象、继承范围、启动条件、不可覆盖路径和错误语义。文档说明机制支持某项能力,只是候选依据;接入验证确认当前机器上的实际路径确实使用它。未经验证的能力不进入可用工具清单,不能在运行失败后悄悄换成权限更宽的程序。
可信范围还应足够小。通用命令不需要修改 harness 源码、执行策略或事实账本,就不应给它这些权限。把业务输出与控制记录分开,不是要求业务输出变成临时文件,而是防止被运行程序通过改账本给自己签发成功或授权。
10.2文件限制与实际对象
文件路径是操作系统查找对象的入口,不是对象本身。路径中的目录可以变化,符号链接可以把查找指向别处,同一对象也可能有多个名字。只检查字符串以某个目录开头,再让任意程序自由打开文件,不能证明最终操作仍在该目录内。
符号链接是一个把名字解析引向另一位置的文件系统对象。输出目录内的链接若指向目录外文件,字符串仍在允许前缀下,实际写入却可能到达受保护对象。强制边界要依据实际访问语义处理这种路径,不能把字符串批准当作对象批准。硬链接等别名也需要按所选机制的对象覆盖和目录假设核验。
检查与使用之间还可能发生变化。宿主检查时指向输出目录,命令打开时可能已经指向另一对象。因此,将路径标准化一次不是普遍解法。若方案依赖输出目录不被其他主体替换,就把目录的控制权和无并发篡改假设写清;若威胁范围包括这种变化,就选择能够约束实际访问的执行器并验证对应路径。
文件描述符(FD)是进程持有的已打开对象句柄。程序不必重新按路径查找,就可以通过它读取或写入。启动前继承来的可写 FD,可能已经指向保护文件。某些访问限制在打开时检查,不能据此假定所有既有句柄的后续使用都会被阻止。所选机制是否覆盖既有句柄,需要具体契约和验证。
所以净化 FD 是独立的启动条件。宿主只向命令交付约定的输入和输出通道,关闭无关文件、套接字与其他连接。标准输入输出也不是无害的名字:若标准输出指向宿主重要文件,任意打印便成了写入能力。通道必须绑定宿主控制的接收端,而不是沿用偶然存在的重定向。
真实持久输出可以保持简单。宿主在可信父目录下建立专属输出位置,执行器允许命令在其中写入;动作结束后宿主重新读取对象并验收。允许写并不等于允许覆盖已有业务资料。任务只需创建新产物时,输出位置应独占且初始状态可确认;任务确需改已有对象时,授权与验收要绑定原对象版本和允许的改变。
最后,文件可读不等于资料可交给模型。命令输出可能包含输入原文、环境信息或诊断路径。宿主在转交工具结果前仍须检查发送范围。执行器限制命令能够取得什么,发送控制限制宿主随后把已取得内容送给谁,两者不能互相省略。
10.3命令之外的执行主体
一个进程可以同时运行多个线程。线程共享的资源和权限,使只观察主线程的做法不能覆盖整个命令。进程还可以创建后代,再由后代创建新的后代。执行边界必须覆盖这些实际主体,而不只是记录最初启动的进程编号(PID)。
创建后代常用 fork,替换当前进程运行的程序常用 exec;这里关注的是这两种操作之后限制是否继续成立,不是它们的 API 写法。权限继承使新线程、新进程和程序替换之后仍处于限制内。生命周期跟踪使宿主知道关联工作还有哪些、什么时候结束。后代仍受约束,不表示宿主已知道它结束;宿主能列出后代,也不表示它们受相同约束。
daemon是能够独立持续运行的后台服务。命令可以自己留下后台后代,也可以把请求交给早已存在的 daemon。前一种需要继承和跟踪,后一种通常不属于新命令的进程后代。服务以自己的身份执行时,客户端受到的文件限制不自动成为服务受到的限制。
进程间通信(IPC)让不同主体传送请求或共享对象。它不等于互联网访问。本地套接字、平台服务通道或句柄传递,都可能把命令连接到更宽权限主体。网络关闭以后,本地服务仍可能替它写文件或发起远端操作,因此外部 IPC 需要独立的允许与禁止范围。
最小方案可以关闭所有不必要的转交路径,而不必为每个 daemon 建适配层。任务确实需要某个服务时,则要确认服务只接受动作获准的对象和操作,并将服务任务身份纳入结果查询与取消。服务仅报告“请求收到”,不能证明效果完成,也不能证明调用者退出之后服务停止。
远端操作也是同一原则。程序向服务提交任务,远端接收者承担后续强制与执行事实。本地执行器只能限制本地程序如何发出请求,不能凭本地进程结束证明远端写入没有发生。若接收者没有所需的资源范围或查询契约,本地包装不能把它补成可靠受控动作。
环境变量还可能传入模型密钥、云账户凭证、代理地址和运行时加载配置。继承整个宿主环境,相当于交付一组未逐项授权的能力。启动器应从空环境构造必要项,固定运行时和搜索位置,不把 harness 的模型凭证交给业务命令。秘密不进环境,也仍要检查文件和已有连接等其他取得路径。
10.4执行契约与动作句柄
对象和执行主体明确之后,才有条件定义工具接口。接口不应只是 run(command) -> text。它应绑定动作身份、目标与授权版本、输入对象、工具实现、执行策略及输出位置,并返回可以追踪该动作的执行事实。命令文本只是其中一项,不承担全部安全与完成语义。
接入请求至少固定程序及参数、工作位置、净化后的环境、允许的 FD、策略版本、资源预算和发送对象。宿主从当前授权生成这些字段,不允许模型通过额外参数放宽策略。解释器与加载路径也属于绑定对象;同名程序换成另一实现之后,原能力验证不能自动沿用。
网络、IPC、资源和生命周期是独立维度。网络限制决定能够连接或传送到哪里;IPC 限制决定能够借用哪些其他主体;资源限制决定最多消耗多少计算、内存、进程、文件空间和输出;生命周期决定动作何时受理、何时不再运行,以及终止证据覆盖什么。一个维度成立不能推导另外三个。
资源预算还要区分声明和强制。接口里写了输出上限,但宿主无限缓存输出,就没有落实内存保护。主进程设置了时间限制,但后台后代继续运行,就没有落实整个动作的生命周期。执行器若只能控制部分资源,应说明覆盖范围;缺失的是任务必需边界时拒绝启动,而不是把预算名称当作能力。
业务输出仍保存在宿主的 persistent 目录。宿主可将标准输出和标准错误接到受控通道,限制读取与保留大小,同时单独记录产物对象。文本输出截断是观测表示的变化,不表示命令没有继续写产物,也不表示没显示的文本一定没有秘密。向模型发送时,应另给明确的大小、截断状态和产物引用。
执行器可信,不意味着命令返回的元数据可靠。命令自己打印的 finished、hash 或进程列表,只是程序提供的内容。执行组状态由可信管理方报告,文件结果由宿主独立核验。否则任意程序都能打印一份成功回执,而没有实际完成约定工作。
接入还要有失败闭合规则。执行策略无法加载、必要继承能力不可确认、环境或 FD 整理失败,均不能进入普通启动路径。能力状态可以是尚未验证或 unavailable,但不能被转换为“已执行失败”,更不能在结果里省略强制未启用这个事实。
接口定好,还要能跟踪它发起的动作。动作句柄(handle)是可信执行器返回的动作定位标识,用于查询、等待和请求取消。它不是一个可以授权任意操作的通行证,也不应仅由命令自己生成。最小形态是提交返回句柄,后续查询按这个句柄定位同一项工作,终结回执仍与原动作和策略绑定。
同步与异步的差别,首先是调用方何时得到响应。同步接口可以等到结果才返回;异步接口可以先返回受理事实。accepted 只说明执行器承诺管理这项工作,不表示已经开始,更不表示完成。异步接入必须说明句柄何时可靠、怎样查询、记录保留多久、宿主重启后是否仍能定位,以及重复提交怎样处理。
可读的生命周期流程如下。这里的接口名称表达建设契约,不表示随书 harness.py 已实现这些方法:
当前授权与绑定核验 → 持久写前意图 → submit
submit 返回 accepted + handle → 持久保存受理事实
query(handle) → accepted / running / terminal / unknown
wait(handle, 等待预算) → 当前观察或等待超时
cancel(handle, 原因) → 取消请求是否受理
query(handle) → 终结证据 → 产物验收与结果入账
终结元数据(terminal metadata)描述执行器认定不再推进的依据。最低信息包括动作与句柄、执行器和策略版本、执行组结束依据、根进程退出状态、取消或资源终止原因、输出保存与截断状态、已知产物、仍未知的外部效果。根进程状态与执行组状态应分列。
根进程退出不能证明后台后代已经结束,它们可能继续运行,转交给服务的任务也可能继续。若执行器不能证明整组结束,就如实报告根进程已退出、其余进程状态未知。终结也不等于成功:组已停止时可能留下部分文件;文件验收成功时,关联工作也可能没做完。
异步返回之后,宿主仍需接收事实,但不持续授予新动作权。用户撤权或目标变化时,旧任务的查询结果可以作为旧动作事实入账,不能自动推动下一步。若受理响应丢失,宿主需要接收者的查询或去重契约;仅有本地动作句柄设计,不足以推定接收者未受理。
查询和取消也需要授权语义。读取状态可能把受保护对象带进请求;取消可能影响共享服务上的任务。它们应定位原动作并核验当前允许的操作,而不是拿到字符串就能管理任意工作。第11章会继续讨论丢响应和重启后的接手续权,本节先把可管理的动作对象建立起来。
10.5超时、取消与终止的记录差异
等待超时说明观察者没有在等待预算内拿到所需结果,不说明动作失败。执行器可能仍在运行,也可能已经完成而回执未到。工具结果应保留 handle 和最新已知状态,不能把 timeout 直接映射成可以重新执行的 failed。
取消是改变继续意愿或提交一个停止请求。终止进程是执行处采取的停止手段。回滚是恢复效果前的状态。三者不同:取消未必已经停止,停止未必覆盖所有主体,停止已经发生也不会自行撤回文件写入、远端提交或已经发送的资料。
因此取消响应至少区分请求被受理、被拒绝、已在终态和响应未知。随后仍要查询停止证据。若取消只覆盖本地执行组,不覆盖转交的远端任务,就不能显示“所有工作已取消”。若外部任务需要单独取消,应通过原任务身份调用获准接口,并保留它自己的受理与结束事实。
授权撤回也存在生效边界。宿主可以立即停止启动新动作;正在运行的工作能否即时收紧权限,要看执行器是否支持。只有杀掉根进程的实现不能承诺所有后代和转交工作已经停下。不能把用户点击时间当作最后一个副作用的精确时刻,除非执行路径确实提供相应证明。
停止时仍可能有部分产物。宿主应记录已观察到的对象、观察时刻、版本和不能确认的范围,不为了展示干净状态删除现场。删除本身也是新效果,且可能破坏核对依据。成功退出码也不证明不存在其他效果,退出码只是根进程完成方式的一项观察。
确认结果时,应分别判断执行组是否结束、已知效果是什么、产物是否符合约定、当前是否允许交付。四项可以得到不同答案。完整产物但组结束未知,应继续核对执行状态;组已停止而文件短写,应报告部分效果;旧授权下的合格产物,也不能直接成为新目标的成功结果。
最小宿主不必实现自动清理或自动恢复。它可以停止调度、保留句柄与证据、向用户说明哪些仍属未知。相比假设取消总能撤销,这个策略承担的责任更小,也更容易用实际契约核验。需要补偿时,再按第11章建立新的动作,而不是把它藏在停止函数内部。
10.6强制效果的平台验证
随书入口 book/v2/execution_probe.py 已实现分阶段能力探测及限定路径验证。它与第3章固定工具程序分开,不把 harness.py 改成任意命令入口。本次实际运行停在策略加载阶段:环境拒绝施加 sandbox,能力记为不可用,后面的效果验证没有执行。代码存在不等于执行边界已经成立。
实践限定于本机 macOS 的 /usr/bin/sandbox-exec 与 Seatbelt。Seatbelt 在这里指平台的强制策略机制。profile_text 生成默认拒绝的策略,只声明当前探测脚本、固定 Python 运行时及系统依赖可读取,本次专属 allowed 目录子树可写,并声明所需 fork 和固定解释器 exec 操作。网络和未开放的外部 IPC 默认禁止;文件元数据显式允许读取,所以它不提供元数据隔离。环境和 FD 在 run_bounded 中单独净化。这里只验证固定启动路径;增加其他程序、业务输入、运行时或可用 IPC 后,都要重新确定配置与覆盖。
这是一项平台限定验证,不是跨平台长期方案推荐。sandbox-exec 所涉及的私有、已弃用接口不应被包装成稳定通用接入标准。当前机器上能否加载所需策略、覆盖必要路径,必须由实际验证回答。接口不存在、策略不能加载或必需边界失败,就报告 unavailable 并停下;没有无 sandbox 的回退运行。
允许项必须先产生真实效果。探测应在专属 persistent 输出目录写入可核对内容,在命令结束以后由宿主重新读取;可信运行时应能启动,获准输入应能读取。禁止项则使用专属测试对象检查目录外写入和越界读取,并核对对象实际内容,不只相信程序返回的拒绝文字。
继承检查应覆盖线程、后代及程序替换后的同类操作。另检查输出目录内符号链接指向目录外对象、启动前可写 FD 是否被移除、环境中无关凭证是否未交付,以及网络与外部服务转交路径是否关闭。测试对象不使用用户真实秘密;净化检查可以使用无敏感性的标记,不能为测保密而先泄露秘密。
每项结果应保留平台与运行时版本、策略内容及 hash、启动参数、动作身份、对象初始与最终状态、可靠观察和覆盖限制。允许写成功但禁止写也成功,不是“部分可用”即可忽略:若禁止写属于必需边界,这个接入不可提供该能力。拒绝路径通过也不代表所有路径都已经覆盖。
实践状态必须区分未验证、验证通过和能力不可用。本次环境是 Darwin 24.5.0 arm64、Python 3.13.12。能力阶段的实际错误为:
sandbox-exec: sandbox_apply: Operation not permitted
子进程退出码:71
能力状态:capability_unavailable
这说明本次启动路径未能施加所需策略,不说明 macOS 上所有执行环境都不支持它。宿主已保存策略、启动意图、有限标准错误和结果,然后停止,没有重试,也没有退回无约束执行。允许持久写入、禁止目录外写入、子孙继承、符号链接与网络检查均未执行,不计为通过。
完整配置在随书 execution_probe.py 的 profile_text 中;probe 先做能力阶段,只有成立后才进入效果检查。读者在另一个已具备能力的环境中主动运行 python3 -I -B book/v2/execution_probe.py --probe,须核对实际生成的策略及结果。--demo 仅显示合成契约,不探测机制。当前代码的超时终止只处理它持有的根进程,不承诺任意进程树已经全部终止;探针中的后代用 join/waitpid 等待其结束,没有验证父进程退出后后台工作是否终结。
本节没有允许或禁止路径通过的实证,也没有性能结论。发布任意命令能力仍缺这组效果验证。不能以其余 Python 测试通过填补这个缺口。
10.7能力开放的条件与顺序
执行器验证完成,开放的仍是一项有条件的能力,而不是“安全 shell”。工具发现描述应说明适用平台、允许效果和不覆盖的路径;完整调用契约再给出参数和生命周期语义。模型不必读取全部底层配置,但宿主必须依据已核验配置执行,不把描述文字当作实际策略。
能力版本应关联执行器、运行时、策略、启动器和验证范围。只改描述而不改执行路径,不会增加覆盖;更换运行时或输出目录控制方式,则可能改变原验证前提。发布与回退都应核对这些绑定。旧验证只能为其适用条件背书,不能为任意升级后的同名工具背书。
平台缺少所需强制能力时,可以选择另一已有执行器,但先重新核验契约。不能只复制配置字段,假定读限制、写允许列表或网络拒绝含义完全一致。尤其要核对默认读取范围:写 allowlist 不代表读也受同样限制。选择依据是声明与实证,而不是某产品被普遍称为 sandbox。
组织场景还要把用户、任务工作区与执行身份绑定。provider 使用平台凭证访问下游时,执行器不能把平台拥有的全部资源权力交给模型;connector 使用用户目标账户时,也不能自动换成另一个更宽权限账户。本章只保留这些接点,第13章集中解释平台授权与账户绑定责任。
执行器不替宿主判断目标是否仍有效,不替宿主验收内容,也不替宿主决定发送。反过来,验收成功不替执行器证明禁止效果被挡住。把这些责任保留为可独立核对的事实,才能在出错时辨认是授权、强制、观察还是结束这一步出了缺口。
最终交付应说明真实产物、动作结束范围、验收对象和剩余未知。一次有限探测不能提供无条件安全保证,但可以决定当前任务所需边界是否已经成立。没有成立的能力保持关闭,比用宽泛承诺让未知进入执行路径更符合这套 harness 的建设目标。
10.8本章判据
- ☐能否分别指出授权判断、实际强制、审计观察和恢复责任,而不是让审批或日志承担全部约束?
- ☐是否从保护资产、允许效果、可信组件和实际路径选择执行器,且业务产物仍持久保存在宿主获准位置?
- ☐文件范围是否落实到实际对象,并核验符号链接、别名与继承 FD,而不只检查路径字符串?
- ☐边界是否覆盖命令的线程、全部后代和程序替换?转交 daemon 或远端服务的路径是否关闭或另有执行处契约?
- ☐网络、外部 IPC、资源、环境凭据、输出发送和生命周期是否分别有声明与证据?
- ☐submit、handle、query、cancel 与 terminal metadata 是否绑定原动作,且 accepted 不冒充完成?
- ☐是否不以根进程退出证明后代结束,不以 timeout 证明失败,不以 cancel 或 kill 证明 rollback?
- ☐必需强制能力不可用时是否停下,并且没有无约束回退?未验证是否没有被写成通过?
- ☐允许与禁止效果是否由宿主检查真实对象,完整配置是否与执行器版本和实证关联?
本章没有回答什么
本章没有开发通用内核 sandbox,没有提供跨平台相同语义或抵挡所有攻击的保证。它也不把私有、已弃用的平台接口推荐为长期标准。所选执行路径的实际能力仍需要限定平台上的 probe 证据,未经验证的接口不能开放为可用能力。
本章没有证明产物内容质量,没有恢复丢失的回执,也没有撤销远端效果。它建立的是权限上界与可管理动作的接入契约。接下来必须回答:效果已经发生而响应丢失时,怎样核对;宿主重启之后,谁还有资格继续。那是第11章的责任。