我实现了 /goal,但人还是不能离开
Goal 能让任务持续推进,却不足以让人离场:Permission、Sandbox 如何共同补齐长程任务的控制边界。
为什么我要实现 /goal
我实现 /goal,不是为了让模型单次运行更久,而是为了让人不再逐轮接棒。过去每轮回复后,
人都要决定是否继续;模型说完成时,人还要判断是否真的完成。工作由模型执行,人却仍是
agent loop 的时钟。
当目标明确、验收证据可以在执行中产生、实现路径可以交给模型时,逐轮确认不再带来相应的 控制价值。监督单位应该从 turn 提升到 task。
/goal 因此只做一件事:让用户一次定义目标和完成条件,再由系统持续推进,直到目标达成或
需要人作出新的决定。它不替模型选择实现步骤,也不改变工具权限。
我是怎么实现 /goal 的
要把这种 task 委托落到系统里,必须回答三个问题:
- 目标是什么,什么证据算完成;
- 谁来判断任务已经完成;
- 判断之后,系统怎样继续、暂停或停止。
它们分别对应 Goal 契约、独立 Judge 和 Goal Controller。
Goal 契约定义什么
Goal 首先是一份由用户定义的自然语言完成契约。用户不再逐轮描述下一步,而是在任务开始时声明:
/goal 完成 permission runtime 重构;运行相关测试、mypy 和 ruff;
不弱化测试,不修改无关行为;最多运行 20 turns
它不要求用户填写结构化字段,但内容需要说明:要完成什么、什么证据算完成,以及这次任务有 哪些约束。
用户定义 Goal 契约
├── 目标是什么
├── 什么证据能够证明完成
└── 有哪些任务约束或不能做的事
程序提供运行边界
└── 最多自动运行多少轮
防止错误目标或连续误判一直消耗 token
“不能做的事”在这里指任务约束,例如“不修改无关测试”,由 judge 根据执行记录验收。自动 turn 上限则由程序提供默认值,用户不需要每次声明,用来限制错误目标、连续误判和 provider 故障的 最坏成本。
如何判断任务已经完成
判断任务是否完成,本质上是比较两件事:Goal 要求的完成条件,以及当前任务实际产生的执行证据。
这项判断不能只依赖工作模型的自我总结。它可能没有运行测试就宣布“应该已经修好”,也可能只
完成了要求的一部分。因此,/goal 把判断交给一次独立、禁用工具的 Judge 调用。Worker 负责
执行任务,Judge 只检查完成条件是否已经被执行记录证明。
如果 Goal 要求测试通过,记录中就必须出现实际的测试命令、退出状态和结果;“应该能过”不构成
证据。/goal 设立时还会标记本次任务的证据起点:旧对话可以帮助 Worker 理解背景,但只有
/goal 之后产生的执行记录才能证明当前任务完成,避免用任务 A 的测试结果证明任务 B。
Goal Controller 怎样工作
Goal Controller 包在 Engine 外面。它把 Goal 契约作为初始输入调用 Engine,收集本轮输出和执行 记录,再把 Goal 契约与当前记录交给独立 judge。Judge 给出判决后,Controller 决定停止、暂停, 或者把未完成的原因组织成下一次 Engine 输入。
用户设置 Goal 契约
│
▼
Goal Controller
│
│ 初始输入
▼
Engine
模型工作、调用工具、产生输出
│
▼
Controller 收集本轮输出与执行记录
│
│ Goal 契约 + 当前执行记录
▼
独立 Judge
│
├── MET
│ └──> Controller 写入完成状态并停止
│
├── ERROR
│ └──> Controller 保留 Goal 并暂停
│
└── NOT_MET
│
▼
自动 turn 预算还有剩余?
├── 否 ──> 保留 Goal 并暂停
│
└── 是
│
▼
Controller 把 Judge 指出的缺口
组织成下一次 Engine 输入
│
└────────> 再次调用 Engine
Goal Controller 的最小伪代码
把状态展示拿掉以后,Controller 本质上就是包在 Engine 外的一层循环:
goal = 用户定义(
目标是什么,
什么证据算完成,
有哪些任务约束,
)
max_turns = 程序默认值
next_input = goal
evidence = []
for turn in range(max_turns):
output, new_evidence = engine(next_input)
evidence += new_evidence
verdict, reason = judge(goal, evidence)
if verdict == MET:
return 完成
if verdict == ERROR:
return 暂停
# NOT_MET
next_input = reason
return 暂停 # 达到自动运行上限
Controller 只有两个工作函数:调用 Engine 和调用 Judge。Judge 输出的是判决,Controller 才负责
状态转换:MET 停止,ERROR 暂停,NOT_MET 在预算允许时继续,否则同样暂停。
证据表明尚未完成,才能继续;判官无法判断,系统必须暂停。
Goal 因此不是贴在 prompt 上的一句“请持续努力”,而是一个根据证据决定继续、暂停或停止的任务 控制器。
到这里,我第一次感到人真的可以离开终端了:设定 goal,让 worker 工作,让 judge 接 turn, 完成后再回来验收。
然后它停在了一次 permission 上。
为什么有了 /goal ,人还是不能离开
Worker 想执行一条 Bash 命令,permission checker 返回了 ASK。
这在交互式 REPL 里很合理。系统无法确定一个动作是否应该执行,于是把决定交给当前在线的 人。人读完命令和上下文,选择允许或拒绝。
但我设置 Goal 的目的,就是不再一直守在电脑前。此时 ASK 暴露了它隐藏的前提:
ASK 不是一个授权结论,而是一项实时交接协议。它假设另一个有权决策的人此刻在线。
当人不在线,ASK 只能走向三个结果:等待、退化成拒绝,或者被粗粒度地当成允许。前两种会 让长程任务停止;最后一种虽然让任务继续,却把“无人运行”偷换成了“无人约束”。
我这才意识到,Goal 只拿走了一根人工接力棒。人留在 agent loop 里,至少还承担着另外两种 职责:
- 判断一个副作用是否获得授权;
- 监督动作执行后不会影响不该影响的地方。
它们分别对应 Permission 和 Sandbox。
还缺 Permission 与 Sandbox
很多系统把 permission 和 sandbox 都画成“安全设置”,但它们不是同一个开关。
Permission 回答:
这个具体副作用有没有授权?
Sandbox 回答:
无论授权判断是否正确,动作实际上最多能碰到什么?
前者是 authorization,后者是 containment。Permission 依赖人或机器对用户意图的判断; Sandbox 依赖操作系统对文件、网络、进程和环境的强制约束。
两者的差异可以粗略写成:
| Permission | Sandbox | |
|---|---|---|
| 核心问题 | 该不该让它做 | 做起来最多能影响什么 |
| 主要依据 | 用户意图、规则、动作语义 | 已安装的 runtime boundary |
| 失败方式 | 错误批准或错误拒绝 | 边界不完整、实现可绕过或静默降级 |
| 不能替代什么 | 不能限制批准后的真实后果 | 不能判断动作是否符合用户意图 |
这也给出了一个理解 agent 风险的简单模型:
预期风险
≈ 危险动作被错误放行的概率
× 错误动作能够造成的影响范围
Permission 主要降低第一项;Sandbox 主要降低第二项。只优化其中一项,都不足以支撑人离场后的
长程任务。到这里,/goal 留下的问题已经明确:系统不仅要让任务持续推进,还要在没有人实时
看守时,处理动作授权并限制错误动作的最坏后果。
调研 Claude Code 与 Codex:先判断意图,还是先建立边界
为了决定自己的 permission 应该怎样演进,我调研了 Claude Code 与 Codex。
这不是为了评判谁更安全。两者现在都同时使用规则、模型判断和 OS sandbox,最终形态正在趋同。 真正值得比较的是:它们把主要控制点放在哪里,优先降低哪个风险变量。
Claude Code:先判断动作是否符合用户意图
Claude Code 的 Auto mode 会让一个独立 classifier 在动作执行前判断:动作是否超出用户请求、 是否指向未识别的基础设施、是否不可逆或具有破坏性,以及是否可能受到 hostile content 驱动。
显式 allow/deny rules 和确定性的已知安全动作先处理,剩余动作再进入 classifier。Classifier 阻止时,Claude 会收到理由并寻找替代方案。它擅长回答纯命令规则难以表达的问题,例如一次 push 是否指向受信任分支、一个部署目标是否属于生产环境、删除的是本轮临时产物还是既有数据。
这条路线优先提高 authorization judgment 的质量,也直接减少逐动作 permission prompt。 但 classifier 仍然是概率模型。一次错误批准之后,真实后果取决于执行环境拥有的能力。
Claude Code 现在也提供 native sandbox:macOS 使用 Seatbelt,Linux 使用 bubblewrap,限制 Bash 及其子进程的 filesystem 和 network。Sandbox 内的 Bash 可以自动执行,越界或无法 sandbox 的命令再回到普通 permission flow。它的文档同时明确说明,Read、Edit、Write 等 内置文件工具仍直接使用 permission system,而不是运行在 Bash sandbox 中。
参考: Claude Code Permission Modes · Claude Code Sandboxing · Claude Code Permissions
Codex:先建立边界,再审核边界例外
Codex 的默认本地路径先建立一个 OS-enforced sandbox。workspace-write 允许 agent 在工作区内
读代码、修改文件和运行常规命令,网络默认关闭;写工作区外路径或请求网络时,再进入 approval
flow。
因此 Codex 把两个控制明确拆开:sandbox mode 决定技术上能够做什么,approval policy 决定 什么时候必须停下来取得授权。
Auto-review 也不是取消 sandbox。它只替换原本处理 approval request 的人:边界内动作无需 额外 reviewer;sandbox escalation、blocked network request 或有副作用的外部工具调用,才进入 automatic reviewer。Reviewer 失败、输出不可解析或命中高风险策略时,动作不执行。
这条路线优先限制 blast radius。Reviewer 仍可能误判,但它审核的是一个已经需要越界的例外, 而不是给 agent 永久获得宿主权限。
参考: Codex Agent Approvals & Security · Codex Sandbox · Codex Auto-review
我的选择:先限制最坏后果
我的问题发生在长程任务中。
人在场时,错误批准一次 permission,还可能在下一秒按下 Ctrl+C;人离场后,一次错误可能沿着 后续 turns 持续放大。此时我首先需要的,不是一个更自信的 yes/no,而是任何 yes/no 判断错误时, 最坏后果都有上限。
因此我选择 boundary-first:先把模型可控副作用放进可信的 runtime boundary,再让自动审批器 根据用户授权和具体动作,处理低频、精确的边界例外。
这不是照搬 Codex,也不是拒绝 Claude Code 的 classifier。OpenHarness 的 permission reviewer 同样读取用户授权上下文和动作语义。我的选择只是改变 reviewer 的站位:
Reviewer 不审核所有日常动作;它只审核超出基础授权的精确例外。
这也确定了后续重构的顺序:先统一基础授权,把本地部分安装成可信的 Sandbox boundary,再让 Permission 根据本地边界或外部策略提供的事实处理精确例外。
Permission 从基础授权开始
长程任务开始前,人需要先定义 agent 已经拥有哪些基础能力。它不是一串等待逐条确认的工具调用, 而是当前 session 可以使用的能力范围。代码里,这份基础授权被组织成一个 Permission Profile。
Permission Profile 不只包含“项目里的文件权限”。它同时描述本地能力和外部能力:
基础授权(Permission Profile)
├── 本地能力
│ ├── 哪些文件可以读写
│ ├── 是否可以联网、访问哪些域名
│ ├── 可以继承哪些环境变量
│ └── 子进程、超时和资源限制
│ │
│ ▼
│ 由 Sandbox 安装并强制
│
└── 外部能力
├── Web
├── MCP
├── Browser
└── Computer Use
│
▼
由外部策略与 Permission 控制
例如,当前默认 Profile 大致表达:
文件
├── 当前 workspace:可以读写
├── .git:禁止写
├── .codex:禁止写
└── .agents:禁止写
网络
└── 默认关闭
环境变量
└── 只继承最小集合,不暴露凭据
进程
└── 有超时和资源限制
外部工具
├── Web:需要审批
├── MCP:需要审批
├── Browser:需要审批
└── Computer Use:需要审批
其中,本地部分交给 Sandbox,外部部分使用各自的策略与 Permission。两者都来自同一份基础授权, 但执行机制不同:
Permission Profile
→ 希望允许什么
Sandbox
→ 用什么机制强制本地部分
同一份本地授权可以交给不同 backend 编译成运行时边界:
同一份本地授权
│
├──> Seatbelt 编译成 macOS 边界
└──> Docker 编译成容器边界
不同 backend 能覆盖的能力可能不同。这张图只表示同一份授权意图可以交给不同机制实现,不能 预先假设它们拥有相同的覆盖范围。
这些基础授权在会话开始时确定,并在整个任务期间保持不变。Goal 只能在其中推进,不能因为任务 尚未完成就自行扩大权限。Permission Profile 是授权意图,不是执行证明:它说明人希望允许什么; 这些本地限制是否真的成立,需要 Sandbox 把它们安装成可以验证的运行时边界。
Sandbox:把授权意图变成执行事实
配置写着“只能写 workspace”,不代表系统真的做到了。Sandbox 必须把授权意图安装成操作系统 强制执行的边界,并报告自己实际覆盖了什么、安装了哪些规则、哪些能力不支持,以及边界是否 经过验证。如果 backend 无法建立要求的边界,系统就停止,而不是静默退回宿主权限执行。
这也是为什么 Sandbox 不能只包住 Bash。Read、Write、Edit、Grep 和 Bash 都由模型控制,都能 读取或改变真实世界。启用统一 Sandbox 时,它们必须经过同一个 session boundary;subagent 也 继承同一份 runtime,不能从另一条工具路径重新获得宿主权限。
从概要设计看,Sandbox 不是一串执行步骤,而是一条稳定的会话边界:上层输入人的基础授权, 边界内部承接所有本地副作用,底层由操作系统强制执行,并向 Permission 报告真实的越界事实。
人定义的基础授权
文件 / 网络 / 环境 / 进程
│
编译、安装、验证
│
▼
┌──────────────── 当前会话的 Sandbox 边界 ────────────────┐
│ │
│ 唯一入口:来自 Agent / Subagent 的本地动作 │
│ │ │
│ ▼ │
│ 统一的本地执行面 │
│ Bash / Read / Write / Edit / Grep │
│ │ │
│ ▼ │
│ 操作系统强制约束 │
│ 文件访问 / 网络访问 / 环境变量 / 子进程 │
│ │
│ Sandbox 同时报告: │
│ · 实际覆盖了哪些副作用 │
│ · 实际安装了哪些规则 │
│ · 哪些能力当前无法支持 │
│ · 当前边界是否有效 │
└──────────────────────────┬─────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
正常执行结果 明确的越界事实
│
▼
Permission
这张边界图表达了四条不变量:一个会话只有一条本地执行路径;边界由操作系统强制执行,而不是 依赖模型遵守 prompt;Sandbox 必须报告实际覆盖范围;无法建立或验证边界时,不能静默退回宿主 权限执行。
这条边界只对本地执行面作出保证。它可以限制本地进程是否联网,却无法限制一次 Web、Browser、 MCP 或 Computer Use 调用在远端产生什么副作用。因此,这些外部能力不能被包含在本地 Sandbox 的安全结论里,而要单独建模和授权:
本地 Sandbox 覆盖
└── 文件 / 命令 / 网络 / 环境 / 进程
本地 Sandbox 不覆盖
└── Web / Browser / MCP / Computer Use
│
└──> 使用独立的外部能力策略和 Permission
Permission 表达人的授权;Sandbox 把这份授权变成可以被信任的执行事实。
Permission:只授权精确、一次性的例外
Permission 授权的对象,不是某个 Agent,也不是某个工具的永久使用权,而是基础授权之外的一项 具体动作。Goal 定义任务要到达哪里,但不会因此扩大基础授权。留在基础授权内的动作可以直接 执行;超出基础授权的动作,只有能够表达成精确例外,并且没有命中 hard deny,才进入审批。
假设基础边界允许修改 workspace,但禁止网络。Agent 运行测试时需要访问 pypi.org。系统请求的
不应该是“允许 Bash”或“允许联网”,而应该是:是否允许这个最终动作访问 pypi.org?
为了防止一次批准漂移成更大的权力,这份请求必须绑定动作的最终参数、当前基础授权与运行时 边界、所需的最小能力变化,以及数据从哪里流向哪里。本地请求的事实来自 Sandbox 返回的越界 结果;外部请求的事实来自外部策略、工具与服务身份。参数被 hook 改写,或者边界、策略发生 变化,原来的批准就不再有效。
基础授权 + 最终动作 + 运行时事实 + 数据流向
│
▼
精确权限请求
│
▼
Permission
┌─────────┼─────────┐
▼ ▼ ▼
拒绝 无法判断 批准
│ │ │
不执行 Park ▼
一次性批准
│
┌────────────┴────────────┐
▼ ▼
本地越界 外部调用
Sandbox 安装并验证临时边界 放行这一次绑定工具
原动作重试一次后销毁 与最终参数的调用
这里还要区分两个问题:谁来审批,以及批准后能得到什么。Permission 并不固定指向一个 LLM 判官。当前实现提供两种审批方式:
精确权限请求
│
├── Manual
│ └── Park,等人稍后批准或拒绝
│
└── Auto
└── 独立 LLM Reviewer
├── 批准
├── 拒绝
└── 无法判断 / 审批失败 → Park
无论由谁批准
│
▼
同一份精确、一次性的授权凭证
“精确”意味着批准不能被另一组参数、另一条边界或另一种外部策略复用;“一次性”意味着批准被 消费一次后立即失效,不会修改基础授权。对于本地越界,批准不是关闭 Sandbox,而是在原边界上 安装并验证一个临时能力;对于外部调用,批准只控制这一次调用是否发生,不声称能够限制远端 后果。
OpenHarness 当前不允许一次审批自动升级成项目级规则,也不提供 Full Access。重复出现的合理 能力,应由人显式写入基础授权,再由 Sandbox 重新安装和验证;一次审批只解决眼前这一个例外。
Permission approval 不是绕过 Sandbox,而是授权一个精确、一次性的例外。
手动模式会直接 Park,把问题留给稍后回来的人;自动模式则先由 LLM Reviewer 判断,无法判断或 审批失败时同样 Park。两条路径都不要求一个人必须实时在线。
Park:把未决授权变成可恢复状态
Reviewer 可以批准或拒绝,也可能无法判断、调用失败,或者发现 backend 无法安装获批的临时 边界。最后这些情况都不能被当作普通工具错误。否则 Goal 会认为任务尚未完成,worker 下一轮 再次尝试同一个动作,自动控制环只会空转。
当前设计会保存这次精确请求和它所依据的授权与边界事实,然后 park session。终端不必一直阻塞, 人也不必当场出现。人回来后可以查看请求,选择批准或拒绝,再显式 resume;如果参数或边界已经 变化,请求就必须重新审批。
Park 不是失败退出,而是一份可恢复的人机交接:
机器已经推进到当前能力边界。这里需要人的新决定,所以任务先释放执行资源,等决定出现后 再继续。
回到长程任务:Goal、Permission 与 Sandbox 如何闭环
现在再看长程任务,三个机制分别填补了人离场后留下的三个控制空位:
| 问题 | 机制 | 它不负责什么 |
|---|---|---|
| 任务是否继续、何时完成 | Goal | 不授予新的能力 |
| 这一次例外是否获得授权 | Permission | 不负责强制本地边界 |
| 本地动作实际上最多能影响什么 | Sandbox | 不判断动作是否符合人的意图 |
三者不是依次执行的三个步骤。Goal Controller 包在 Engine 外面,控制任务是否继续;Permission 与 Sandbox 则守在 Engine 的动作边界,控制一次动作能不能发生,以及实际最多能产生什么后果。
人定义 Goal 契约与基础授权
│
▼
┌──────────────────── Goal Controller ────────────────────┐
│ │
│ 调用 Engine / worker │
│ │ │
│ │ 提出动作 │
│ ▼ │
│ ┌─────────────── 动作边界 ─────────────────────────┐ │
│ │ │ │
│ │ 本地动作 │ │
│ │ Sandbox 强制基础边界 │ │
│ │ 越界时由 Permission 审核精确例外 │ │
│ │ │ │
│ │ 外部调用 │ │
│ │ Permission 根据外部策略决定是否允许调用 │ │
│ └──────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ 执行结果或拒绝事实 未决请求被 park │
│ │ │ │
│ ▼ └──> 保存状态并暂停 │
│ 进入本次执行记录 │
│ │ │
│ ▼ │
│ worker 本轮结束 │
│ │ │
│ ▼ │
│ 独立 Goal judge │
│ ┌──────┼─────────┐ │
│ ▼ ▼ ▼ │
│ NOT_MET MET ERROR │
│ │ │ │ │
│ 再次调用 停止 暂停 │
│ Engine │
└────────────────────────────────────────────────────────┘
Goal 不直接执行工具,也不改变权限。它只在 worker 一轮工作结束后,根据 judge 的判断决定是否 再次调用 Engine。Permission 与 Sandbox 也不判断任务是否完成,它们只守住动作发生的边界。
如果 Permission 已经产生未决请求,Goal Controller 会在调用 judge 之前暂停。因为更多推理不会 产生新的授权;继续启动 worker,只会让长程任务围绕同一个能力缺口空转。
结论:人不再是 agent loop 的时钟
回头看,我真正想解决的并不是怎样让模型一次运行更久。
我想改变的是人的位置。
过去,每一个关键节点都要求人实时在线:
模型完成一轮
→ 人决定是否继续
→ 人处理权限请求
→ 人判断任务是否完成
现在,人先定义目标、完成条件和基础授权,然后可以离开:
人定义目标、完成条件和基础授权
│
▼
系统持续推进任务
│
┌─────┼──────────┬──────────┐
▼ ▼ ▼ ▼
目标完成 达到上限 无法判断 需要新的授权
│ │ │ │
└─────┴──────────┴──────────┘
│
▼
人回来验收或作出决定
人并没有退出责任链。目标仍由人定义,基础能力仍来自人的授权,最终结果仍需要人的验收。变化的 是,人不再需要守在每一个 turn 和每一次工具调用旁边。
Goal 接管任务的持续推进与完成判断;Permission 让边界例外只能获得精确授权;Sandbox 限制本地 动作的实际后果;park 则把无法自动解决的决定保存下来,等人方便时再处理。
人的注意力因此不再是 agent loop 的时钟。人可以离开,然后在任务完成,或者系统真正抵达人的 决策边界时再回来。