本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13,随版本演进可能变化。
两件事:既然 bash + read/write 已经能干活,为什么还要再配三套一等公民工具;以及"权限"这个大词在底层是怎么被拆开的。
贯穿两件事的,是同一种设计习惯——把复杂概念拆成正交的小变量,再让产品概念降级成"给变量组合起的名字"。
| 部分 | 核心问题 | 答案 |
|---|---|---|
| 工具面 | 三套工具各治什么病 | 状态失忆 / 文本搜索无语义 / 后台任务没人收尸 |
| 权限 | 权限如何拆分 | sandbox/mode × approval/policy 两个正交旋钮 |
| 工具面 | 治什么 | 形态 | 源码位置 |
|---|---|---|---|
terminal | 状态失忆 | 6 个工具(terminal_open / send / read / signal / close / list)+ 持久 PTY | packages/terminal/ |
lsp | 文本搜索无语义 | 恰好 4 类语义查询 | packages/lsp/ |
jobs | 后台任务没人收尸 | 统一 job_list / job_output / job_kill | packages/jobs/ |
三者共同的纪律:闭合词汇、结构化降级、归属即边界。且全是可选插件——极简预设一个都不挂,照样是完整的 coding agent。
产品可以不做的事,运行时值得做。CC 是产品,交互式调试有 IDE 兜底、语义导航有编辑器兜底;DSH 是运行时,要在无头环境里独自把这些能力供给模型。
一次性 bash 的问题:REPL 的变量活不过一次调用,模型只能把旧代码全部重发一遍,轮数与 token 双倍烧。
解法是把 PTY 会话做成一等资源:terminal_open 建会话拿 id,之后 send / read / signal / close 全按 id 操作。会话在后端和工具插件热重载期间照样活着。
它改变的是模型的工作方式:
两种模式的产出质量差得很远。
startSend())出处:packages/terminal/terminal/src/index.ts 第 243–254 行。
| 顺序 | 检查 | 失败行为 |
|---|---|---|
| 1 | expectOwned(owner, id) 校验归属 | 授权比较的是拥有会话的确切 Agent 实例,id 不是秘密 |
| 2 | 会话是否正在关闭 | 关到一半的会话拒收任何新输入 |
| 3 | record.active 上一条 send 是否结算 | 抛带 SEND_ACTIVE 的 TerminalError(第 246 行) |
检查全过 → 登记为该会话唯一的活动 send,等其 done 承诺结算(成功失败都算)后登记才清空。
归属即边界:不靠保密,靠校验。顺序发放的 id 也很安全——这条纪律在 jobs 里会再出现一次。
恰好四类语义查询:跳定义、找引用、跳实现、hover。
为什么不把整套 LSP 协议透出去:那样模型面对的是一个无底洞 schema,provider 换一家行为就漂一次。
seam、提供方、工具三层共享同一个四操作联合——加第五个操作会让编译失败,直到三层都改完。用编译期错误挡住词汇表膨胀(与上一集用 never 类型挡住非法组合是同一类思路)。
选路按文件扩展名,一个扩展名只属于一家(packages/lsp/lsp/src/index.ts 第 143–149 行)。
lsp 工具照常挂在目录里,schema 一字不变,调用返回带 LSP_UNAVAILABLE 的结构化失败。
| 做法 | 模型学到什么 |
|---|---|
| 让工具消失 | 不知道工具去哪了 → 反复试探一个不存在的东西 |
| 结构化降级 | "这个项目没配语言服务器" → 一次讲清 |
接口的消失比接口的失败昂贵得多。 凡是模型能看见的接口,稳定性都比丰富度重要。
三种生产方形态完全不同,却全部注册进同一个 ctx.jobs 注册表:
| 生产方 | 形态 | 统一 id |
|---|---|---|
| 后台 bash 命令 | 进程 | bash-N |
terminal_send 后台模式 | PTY | terminal-N |
| 后台 subagent | 子智能体 | subagent-N |
分工:生产方拥有执行资源;注册表拥有身份、访问权限与生命周期状态。
id 按 <kind>-N 顺序发放,完全可预测。防线是所有者授权:读、杀、等全部校验调用方会话与任务 owner 是否一致,别人的任务连 label 都看不到。每个 owner 默认最多 10 个并发。
reported 标记抑制重复完成通知,避免模型收两遍消息、白白多开一个 turn。| 旋钮 | 回答什么 | 取值 | 管辖范围 |
|---|---|---|---|
sandbox/mode | 这条命令的文件效果允许到什么程度 | read-only / workspace-write / danger-full-access | 只管文件系统;网络与进程可见性不在其词汇表 |
approval/policy | 碰到需要人拍板的事问不问 | ask / never | 只管问不问人 |
正交的实际含义:两个旋钮在日志里是两种独立事件;执行、提示词、回放全都只读这两种事件的折叠结果。拧任何一个,另一个纹丝不动。
| 档位 | 行为 |
|---|---|
read-only | 后端必须拒绝写入,只保留 /dev/null 这类 shell 必需接收器 |
workspace-write | 工作区根目录 + 后端承诺的临时区可写,界外一律拦 |
danger-full-access | 绕过隔离,消费方直接 spawn 原始命令,根本不调用 ctx.sandbox |
ask(默认):交给组合的应答者链。一个应答者都没有 → 链走到头返回 unavailable,照样按拒绝处理。never:确定性返回 rejected,不分发任何应答者。CI 与无人值守的标准姿势。"ask" 不是"问了就一定答",而是"启动一条链去要答案"——要不到就当拒绝。把"要不到答案"与"明确拒绝"在执行方眼里统一成同一个结果,是这套设计里最省心的决定之一。
ctx.permissionPresets 维护一张"名字 → 旋钮组合"的表。默认表就两行(permission-presets/src/index.ts 第 167–176 行):
| 预设名 | sandbox/mode | approval/policy |
|---|---|---|
workspace-write | workspace-write | ask |
danger-full-access | danger-full-access | never |
它是可选能力,不在 agent loop 主干上,也不拥有任何强制执行。
三条事件,顺序固定(apply(),第 379–392 行):
permission/preset,记下你的意图;sandbox/mode 与 approval/policy;切预设没有第三个执行路径,也没有隐藏状态:日志里追加了什么,执行层就折叠出什么。
回放时执行层只认旋钮事件;preset 事件的唯一作用是当两个预设共享同一组合时,记住你当初选的是哪个名字。
custom 是保留字,表里不许有叫这个名字的条目(插件加载时抛异常)。只有手动把旋钮拧到表里没有的组合,current() 才派生出 custom 给客户端显示。
对称性:查表(名字 → 组合)与派生(组合 → 名字)两个方向都写死,各走各的路。
唯一的放行值是 allowed-once,且只授权所询问的那一个操作。 rejected / cancelled / unavailable 三种结果调用方全部执行拒绝。
归一化为拒绝的情形:没有应答者、应答者抛异常、返回词汇表外的怪值、弹窗期间用户关掉界面。
approval/asked 与 approval/decided 成对写入会话日志,只做审计。模型看到的是工具结果与运行时上下文快照。ApprovalRequest 故意不带工具参数,用 callId 指向已流式输出的那次调用,避免渲染一份会漂移的副本。
写操作被沙箱拦下后,模型可带 sandbox_permissions 请求提权重试;通过给 allowed-once,这次重试以显式更宽的模式解析一次策略,用完即弃,会话的旋钮设置不因此改变。
边界问题:若插件在审批服务挂载后用prepend往应答者链最前面塞一个全部放行的应答者,能否绕过never?不能——因为 never 根本不在链里执行。
出处:packages/interaction/user-approval/src/index.ts 第 304–312 行 decide()。注释把设计意图讲完了:
The 'never' policy is decided HERE, before any dispatch: a listener registered with prepend: true after this service mounts would sit ahead of any gate LISTENER, so a listener-shaped gate cannot keep the documented promise that 'never' rejects deterministically regardless of registration order — only the service's own request path can.
这与上一集 Guard 的单调性是同一类思路:能用结构保证的属性,不要留给运行时顺序去碰运气。
| 产品 | 模型 | 特点 |
|---|---|---|
| DeepSeek Harness | 双旋钮正交 | 能表达 read-only + ask(沙箱只读兜底,确需写入时弹窗问一次) |
| Claude Code | 单一 permission mode 轴(default / plan / acceptEdits / bypassPermissions / dontAsk)+ 规则表 | 五档在一根轴上滑动,沙箱与问人被绑在一起卖;但规则表可做命令前缀级细粒度控制 |
| Grok Build | 授权链逐层放行 | 从工具请求到受限执行逐层放行 |
| Codex | 两层叠加 | execpolicy 命令级精确前缀 + 反例;Guardian 用一次模型审查把弹窗从主路径拿掉 |
各自的取舍:CC 的规则表是工具/命令前缀粒度,DSH 的两个旋钮都是会话级全局值,工具粒度门禁要靠 hook 层做。两边把复杂度放在了不同地方。
设计面向模型的工具面时:
设计权限系统时:
deepseek-harness-master 本地仓库核对。凡模型需要与有状态程序反复对话,先问:这个状态能不能跨调用保留?重发一遍旧代码的代价,远大于多写几个工具。
给模型的接口宁可窄而稳定,也不要宽而漂移。"加一类操作要付出编译期代价"正是词汇表不膨胀的保证金。
能力不可用时返回带错误码的失败,而不是让工具消失。前者教会模型一件事,后者只会让它反复试探。
不要靠 id 保密做授权——id 迟早被猜到或泄露。把归属校验放在每个入口上,顺序发放的 id 同样安全。
沙箱管文件效果,审批管问不问人,产品概念降级成给组合起的名字。拆对了,你能表达出单轴上根本不存在的组合。派生状态要只出不进。
场景一:agent 在同一 terminal 会话上先发一条 run_in_background: true 的 send,紧接着又发一条前台 send。第二条的下场是什么,错误码是哪个?job 面板里此时能看到什么?
场景二:项目没配任何语言服务器,模型调了一次 lsp 工具查 a.py 的定义。写出模型收到的结果形态。
场景一:第二条前台 send 会因第三条检查(上一条 send 尚未结算)抛 SEND_ACTIVE 错误码的结构化失败。注意——后台 send 也是一次 send,只要有活跃 send 未结算就拦。job 面板里此时能看到后台任务 terminal-N,可用 job_output 取输出。
场景二:模型收到带 LSP_UNAVAILABLE 错误码的结构化失败。这比把 lsp 工具从目录里摘掉更友好——模型学到的是"这个项目没配语言服务器",而不是"工具怎么突然消失了",不会反复试探。
场景三:审批弹窗弹出后用户直接关掉浏览器,UI 应答者随 dispose 被移除。这次请求最终以什么结果结算?
答:unavailable(或cancelled),调用方一律当拒绝——不放行。
sandbox/mode 管文件效果,approval/policy 管问不问人;allowed-once 且只管那一个操作;never 在服务内部、瀑布分发之前就拦死。来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂课程素材,为二次演绎版本。