沙箱是 Agent 从「会聊天」走到「能干活」时绕不开的那道墙。模型一旦拿到 shell,它能做的事和它可能做错的事就是同一个集合:删掉不该删的目录、改掉不该改的配置、被一段注入的文本牵着去读凭据再把结果写回来。提示词里的「请小心」不是权限,真正的边界只能由宿主在进程创建那一刻施加。
这篇长文拆开源码讲清一套 Agent 沙箱的实现原理,并以 DeepSeek Harness(下称 DSH)的沙箱体系作为参照方案:它把沙箱拆成三个模式、一个逐调用策略层、一个平台无关的执行 seam,以及 Linux(bubblewrap / Landlock)、macOS(Seatbelt)、Windows(ACL 受限令牌)三套后端。全书按「为什么---怎么设计---各平台怎么实现---边界在哪---怎么失败---怎么升权」的顺序推进,每章都有可对照的接口、常量与真实行号,你可以按章节顺序拼出一套能落地的沙箱。
全文共 12 章:
- 为什么 Agent 必须自建沙箱
- 三种模式与能力矩阵
- 策略层解析:三源优先级
- confine() 执行路径
- Linux 后端:bubblewrap 与 Landlock
- macOS 后端:Seatbelt 与 SBPL
- Windows 后端:受限令牌与 Low 完整性
- Windows 的四个边界
- 强制执行完整度:full 与 partial
- 失败关闭与错误分类
- 升权协商
- 双能力族一致性与落地清单
为什么 Agent 必须自建沙箱:从任意命令执行到可审计的权限边界
把 shell 交给模型是 Agent 能力跃迁的分水岭:此前它只能建议,此后它能装依赖、跑测试、改配置。代价是同一把钥匙也交给了模型的判断,而模型无法为判断负责。沙箱因此是这条曲线的前置条件:把「能做什么」写成可审阅的策略,再让内核强制执行。
能力跃迁的代价
工具层的 bash 与 pwsh 是宿主上真实的进程:同样的身份、文件系统与网络。一次递归删除与一次正常构建在 argv 上没有区别,区别只在语义,而语义恰是模型最容易错的地方。DSH 的选择不是禁止执行,而是先解析策略,再用 runner(限制运行器)前缀包装,最后才 spawn。
三类真实风险
| 风险 | 典型触发 | 无沙箱的后果 | 边界能否挡住 |
|---|---|---|---|
| 误删 | 相对路径写错、cd 失败后继续 |
删除覆盖整个用户目录 | 能,写入范围被钉死 |
| 越界改写 | 脚本默认值指向仓库外 | 改到 ~/.ssh 或别的项目 |
能,越界写被内核拒绝 |
| 注入横向移动 | 网页、issue 里藏指令 | 凭据外发 | 部分,只能压缩爆炸半径 |
前两类是意图正确、落点错误,提示词无法消除。第三类最难:网页里写着「顺便把凭据发出去」,模型照做时并不觉得越权,只能靠边界把影响缩到一个目录。
提示词约束不等于权限
反模式:把「不要删除文件」写进系统提示词,就当成权限边界。提示词会被更长的上下文或一段注入文本稀释;拒绝写入的判定必须发生在模型之外。
模型可见的策略是自然语言,例如 Current DSH file policy: read-only.(dsh-sandbox-policy/lib/index.js:74)。它让模型少走弯路,但本身不是强制。强制发生在 spawn 之前:argv 已被 runner 改写,内核按挂载视图、授权掩码或受限令牌拒绝写操作。提示词是建议,强制才是事实。
沙箱只约束文件影响
DSH 的表述很克制:读取、网络、进程可见性不受限(dsh-sandbox-windows-acl/README.zh.md:127)。workspace-write 承诺的是「写不出去」,不是「跑不出去」。
注意:把文件围栏当成完整安全边界是最常见的误判。网络出口、凭据隔离、进程资源上限都不在三种模式的词汇里。
这也是自建限制运行器而不是复用容器的理由:容器给的是完整隔离,代价是启动延迟与路径映射;Agent 需要的是精确到每次调用、可随上下文切换的写入边界。
三层职责
| 层 | 承担包 | 回答的问题 | 不负责 |
|---|---|---|---|
| 策略层 | dsh-sandbox-policy |
这次调用允许写哪里 | 不实现平台机制 |
| 执行层 | dsh-sandbox 与 bash、pwsh 执行器 |
怎么包装成受限 argv | 不了解内核细节 |
| 平台后端 | dsh-sandbox-local、dsh-sandbox-windows-acl |
用哪个机制真正落地 | 不决定策略,只如实上报 |
三层通过 seam(接缝)解耦:消费方只认识 confine(),不关心下面是 bubblewrap、Seatbelt 还是受限令牌。
ts
// 逐调用策略:每次工具调用携带自己的 {mode, workspaceRoot},不固定在 provider 上
type Policy = {
mode: "read-only" | "workspace-write" | "danger-full-access";
workspaceRoot: string;
};
// 执行层只回答一个问题:这条命令该以什么 argv 启动
interface SandboxProvider {
confine(argv: string[], policy: Policy): Promise<ConfinedArgv>;
}
// 四字段结果(第 4 章展开):限制是 argv 前缀,不是进程内的钩子
interface ConfinedArgv {
argv: string[];
enforcement: "full" | "partial";
denialSignatures: string[];
runnerFailureRules: string[];
}
bash
# 无沙箱:argv 原样交给宿主 shell,影响范围由命令自己决定
bash -lc 'rm -rf ./build'
# 有沙箱:同一条命令被 runner 链改写成受限进程(参数仅为示意)
bwrap --ro-bind / / --dev /dev --proc /proc -- \
landlock-run -- bash -lc 'rm -rf ./build'
#mermaid-svg-ctjq84wxNKWoX5ex{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ctjq84wxNKWoX5ex .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ctjq84wxNKWoX5ex .error-icon{fill:#552222;}#mermaid-svg-ctjq84wxNKWoX5ex .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ctjq84wxNKWoX5ex .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ctjq84wxNKWoX5ex .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ctjq84wxNKWoX5ex .marker.cross{stroke:#333333;}#mermaid-svg-ctjq84wxNKWoX5ex svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ctjq84wxNKWoX5ex p{margin:0;}#mermaid-svg-ctjq84wxNKWoX5ex .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ctjq84wxNKWoX5ex .cluster-label text{fill:#333;}#mermaid-svg-ctjq84wxNKWoX5ex .cluster-label span{color:#333;}#mermaid-svg-ctjq84wxNKWoX5ex .cluster-label span p{background-color:transparent;}#mermaid-svg-ctjq84wxNKWoX5ex .label text,#mermaid-svg-ctjq84wxNKWoX5ex span{fill:#333;color:#333;}#mermaid-svg-ctjq84wxNKWoX5ex .node rect,#mermaid-svg-ctjq84wxNKWoX5ex .node circle,#mermaid-svg-ctjq84wxNKWoX5ex .node ellipse,#mermaid-svg-ctjq84wxNKWoX5ex .node polygon,#mermaid-svg-ctjq84wxNKWoX5ex .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ctjq84wxNKWoX5ex .rough-node .label text,#mermaid-svg-ctjq84wxNKWoX5ex .node .label text,#mermaid-svg-ctjq84wxNKWoX5ex .image-shape .label,#mermaid-svg-ctjq84wxNKWoX5ex .icon-shape .label{text-anchor:middle;}#mermaid-svg-ctjq84wxNKWoX5ex .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ctjq84wxNKWoX5ex .rough-node .label,#mermaid-svg-ctjq84wxNKWoX5ex .node .label,#mermaid-svg-ctjq84wxNKWoX5ex .image-shape .label,#mermaid-svg-ctjq84wxNKWoX5ex .icon-shape .label{text-align:center;}#mermaid-svg-ctjq84wxNKWoX5ex .node.clickable{cursor:pointer;}#mermaid-svg-ctjq84wxNKWoX5ex .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ctjq84wxNKWoX5ex .arrowheadPath{fill:#333333;}#mermaid-svg-ctjq84wxNKWoX5ex .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ctjq84wxNKWoX5ex .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ctjq84wxNKWoX5ex .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctjq84wxNKWoX5ex .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ctjq84wxNKWoX5ex .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctjq84wxNKWoX5ex .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ctjq84wxNKWoX5ex .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ctjq84wxNKWoX5ex .cluster text{fill:#333;}#mermaid-svg-ctjq84wxNKWoX5ex .cluster span{color:#333;}#mermaid-svg-ctjq84wxNKWoX5ex div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ctjq84wxNKWoX5ex .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ctjq84wxNKWoX5ex rect.text{fill:none;stroke-width:0;}#mermaid-svg-ctjq84wxNKWoX5ex .icon-shape,#mermaid-svg-ctjq84wxNKWoX5ex .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctjq84wxNKWoX5ex .icon-shape p,#mermaid-svg-ctjq84wxNKWoX5ex .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ctjq84wxNKWoX5ex .icon-shape .label rect,#mermaid-svg-ctjq84wxNKWoX5ex .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctjq84wxNKWoX5ex .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ctjq84wxNKWoX5ex .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ctjq84wxNKWoX5ex :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 工具层 dsh-tool-bash / dsh-tool-pwsh
执行器 dsh-bash-sandbox / dsh-pwsh-sandbox
策略层 dsh-sandbox-policy resolve()
seam dsh-sandbox confine()
平台后端 dsh-sandbox-local
runner: bwrap / landlock-run / sandbox-exec / windows-acl-run
内核强制:挂载视图 / Landlock / Seatbelt / 受限令牌
一个最小可用沙箱的五个部件
- 策略源:部署默认、会话模式事件、逐调用批准,合成一个
{mode, workspaceRoot}。 - seam:只暴露
resolve()与confine()两个动词。 - runner:argv 前缀程序,Linux 侧链为
bwrap与landlock,Windows 侧为windows-acl(dsh-sandbox-local/lib/index.js:174-176)。 - 强制执行完整度:
full或partial必须如实上报,windows-acl即为partial(dsh-sandbox-local/lib/index.js:188-191)。 - 故障关闭(fail-closed):后端不可用时抛
SANDBOX_UNAVAILABLE,不放行(dsh-sandbox/lib/index.js:264)。
无沙箱与有沙箱的差别
| 维度 | 无沙箱 | 有沙箱(workspace-write) | 审计依据 |
|---|---|---|---|
| 可写范围 | 整台机器 | 工作区根加平台临时区 | 根集合可枚举 |
| 拒绝的产生方式 | 靠命令自觉 | 内核返回拒绝 | stderr 拒绝方言 |
| 后端缺失时 | 不受影响 | 拒绝运行并报错 | 故障关闭错误 |
| 模型看到的信息 | 一句口头约定 | 每次调用的模式声明 | 策略上下文 |
它把「模型判断错误」从不可恢复的事故降级为一次可解释的拒绝:有固定文案、有可枚举的根目录集合、有可替换的后端。本章只立边界,后续再拆策略解析、argv 包装与升权协商。
三种模式与能力矩阵:read-only、workspace-write、danger-full-access
沙箱的第一层决策不是拦截什么代码,而是先回答「这次调用被允许影响哪些字节」。dsbx 与 DSH 都把答案收敛成三个互斥模式名:read-only、workspace-write、danger-full-access------它同时要当人类可复核的承诺与模型可见的上下文,粒度过细则前两者失效。
三个模式各自承诺什么
read-only :读取按调用者身份照常进行,写入在策略层直接被拒。它不是「让内核碰巧拒绝」,而是可写根集合为空------dsh-sandbox/lib/index.js:167 处,只要模式不是 workspace-write 就返回空数组。fs 族在这一模式下抛 FS_SANDBOX_DENIED,shell 族由 runner 让内核拒绝。
workspace-write :可写根集合 = 规范化后的工作区根 + 平台临时区。工作区根取会话不可变的 cwd;临时区给编译器、包管理器、TMPDIR 一族工具留副作用缓冲区。临时区绝不能落在工作区内部:Windows 后端在授权任何 ACE 之前就调用 assertTempRootOutsideWorkspace 拒绝这种包含关系(dsh-sandbox-windows-acl/README.zh.md:118)。
danger-full-access :显式无约束。它不是一个更宽的 profile,而是没有 profile------confine() 在这一模式下根本不咨询 provider。正当用途是宿主级安装、镜像构建、跨盘迁移:影响面本就超出工作区,硬塞进 profile 只会制造「表面受限、实际失败」的假象。
| 模式 | 承诺 | 可写根集合 | 读取 | 典型用途 |
|---|---|---|---|---|
read-only |
无持久化写入 | 空 | 宿主身份 | 代码审查、只读问答 |
workspace-write |
只改工作区与临时区 | 工作区根 + 平台临时区 | 宿主身份 | 日常编码、构建测试 |
danger-full-access |
不做文件约束 | 不适用 | 宿主身份 | 宿主级安装、迁移 |
read-only 的 sink 例外
模式承诺可写根为空,不等于「所有写系统调用都失败」。有两类目标天然能过,它们不是持久化目标,而是丢弃端(sink)。
Linux 上 bwrap 给命令全新的 /dev,/dev/null 是设备节点,写入不落在任何可写根上。Windows 上 NUL 设备的 DACL 授予 Everyone 0x1201BF,所以 > NUL 与 \\.\NUL 在两种受限模式下都能写;但 Set-Content NUL 在两种模式下都失败------那是 PowerShell 与 .NET 层的路径解析效应,与沙箱无关(dsh-sandbox-windows-acl/README.zh.md:106)。
bash
# 反例:把丢弃端当成可写证明
echo probe > /dev/null; echo "exit=$?" # 成功,但什么都没落盘
# 正例:写一个真实路径
echo probe > ./probe.txt # read-only: Read-only file system
powershell
"probe" > NUL # 成功:设备 DACL 允许
"probe" | Set-Content NUL # 失败:PowerShell/.NET 层效应
失败模式:把 sink 当边界。用
2>/dev/null或> NUL探测沙箱,看到「能写」就断言 read-only 无效。正确的探测必须落在真实路径上,并检查 stderr 里的拒绝方言。
可写根集合是单一派生源
三个模式里唯一需要计算的是 workspace-write 的可写根。它要在策略层一次算好,再被 fs 围栏与 shell runner 共用,否则同一次会话会出现「bash 改不了、fs 能改」的裂缝。
ts
export interface SandboxPolicy {
mode: "read-only" | "workspace-write" | "danger-full-access";
workspaceRoot: string;
}
// 非 workspace-write 时直接返回空集合,不做任何路径判断
if (policy.mode !== "workspace-write") return [];
return canonicalizeRoots([policy.workspaceRoot, platformTempRoot()]);
能力矩阵:模式只表达文件影响
| 维度 | read-only |
workspace-write |
danger-full-access |
由模式表达 |
|---|---|---|---|---|
| 工作区写入 | 拒绝 | 允许 | 允许 | 是 |
| 工作区外写入 | 拒绝 | 拒绝 | 允许 | 是 |
| 网络访问 | 不约束 | 不约束 | 不约束 | 否 |
| 进程可见性 | 不约束 | 不约束 | 不约束 | 否 |
| 凭据读取 | 不约束 | 不约束 | 不约束 | 否 |
这张表制止一种误读:把 workspace-write 当网络安全隔离。DSH 的 README 明说读取、网络、进程可见性都不受限制(dsh-sandbox-windows-acl/README.zh.md:127)。模式词汇只覆盖文件影响。
反模式:拿模式当安全等级。在文档里写「已启用 workspace-write 沙箱,可防数据外泄」,而实际网络完全开放、凭据可读。模式没承诺过这些,承诺错的是文档。
模式是声明,完整度是事实
策略里的模式是调用方写下的意图;runner 在真机上实际能管住多大范围,是另一个事实,用 full / partial 表示。DSH 的静态声明表在 dsh-sandbox-local/lib/index.js:188-191:bwrap 与 landlock 是 full,windows-acl 是 partial。这两件事不可互相替代------模式回答「想要什么」,完整度回答「做到了多少」。消费者要两者在手,才能判断拒绝要不要升权、成功能不能算数。
模型可见文案因此写成 Current DSH file policy: <mode>(dsh-sandbox-policy/lib/index.js:74)------policy,不是 guarantee。
默认落点是 read-only
三个模式不是平权选项。DSH 用 zod 的 .default("read-only") 把部署默认落成最窄的一档(dsh-sandbox-policy/lib/index.js:99-102),于是「没有显式配置」与「配置成只读」在行为上等价。默认值比配置文档更重要:它决定多数会话的真实姿态。
升权也不是任意跳转。dsh-sandbox/lib/index.js:31-32 把可行方向写成封闭阶梯:read-only 只能升到 workspace-write 或 danger-full-access,workspace-write 只能升到 danger-full-access。往回收窄不需要申请,因为没有新增权限。
模式是入口词汇:向上由策略层解析成逐调用策略,向下被 confine() 翻译成受限 argv。
策略层解析:部署默认、逐会话切换与逐调用策略的三源优先级
一个会话同时挂着 bash、pwsh 与 fs 三个能力族,它们各有强制执行方言,却必须对「这次调用能写哪里」给出同一答案。把当前模式记在某个 provider 实例上最省事,也最容易漂移:重启丢状态、会话互相污染、多出带外通道。dsh-sandbox-policy 反过来做------每次调用现算一次,随调用传递。
策略为什么必须随调用走
seam 约定 provider 只回答「怎么把一条 argv 关进笼子」,会话与当前模式都不在它的接口里;执行器同样 session-free,拿到的是 resolve() 的返回值。于是每次调用的 {mode, workspaceRoot} 是一份快照:重叠调用即使中间插入切换,也不会互相改写边界。
ts
resolve(request = {}) {
const { session } = request;
return {
mode: request.mode ?? (session === void 0 ? void 0 : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
...session === void 0 ? {} : { sessionId: session.id }
};
}
用的是空值合并而非逻辑或:request.mode 为 undefined 才代表「本次没有显式批准」,三源优先级就编码在这条表达式里(dsh-sandbox-policy/lib/index.js:133-145)。
三源优先级
| 优先级 | 来源 | 存放位置 | 生效范围 | 跨重启 |
|---|---|---|---|---|
| 最高 | 显式批准模式 | 逐调用参数 | 仅该次调用 | 不保留 |
| 中 | 会话最后一条 sandbox/mode 事件 |
会话日志 + 投影 | 该会话后续受限调用 | 回放保留 |
| 最低 | 部署默认模式 | 插件配置 | 全进程 | 配置即事实 |
会话层更短:effective = projection state ?? the deployment default(dsh-sandbox-policy/lib/index.js:9-10)。投影为 null 时默认值顶上,所以「没表过态」与「表态为 read-only」不同:前者随部署变更,后者锁定会话。
工作区根也逐调用解析:有 cwd 用 session.header.cwd,否则退回配置的 workspaceRoot(服务无 agent 调用)。两者都要求绝对路径,相对值当场抛错,不猜 process.cwd()。
切换即事件,没有带外状态
切换只有一条写路径:往会话日志追加一条 sandbox/mode 事件(dsh-sandbox-policy/lib/index.js:41)。切换本身就是事件,任何机制不得带外修改;投影由 fold 得到(dsh-sandbox-policy/lib/index.js:119),语义为「最后一条生效」。
json
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
后果三条:会话间永不互见;覆盖经回放跨重启保留,无需额外持久化;切换在该会话下一次受限调用生效。
反模式:在 provider 或工具层缓存「当前模式」,切换时同步刷新。缓存与日志不同源时,重启、回放、并发各给一个答案,而错的方向通常更宽。
工作区边界取不可变 cwd
模式可切,工作区不能。SessionHeader.cwd 在创建时写入,不因切换改变,resolve() 读的是 session?.header.cwd ?? this.workspaceRoot。所以 workspace-write 的边界全程是同一个根:切到 danger-full-access 再切回也不漂。
| 事实 | 存放位置 | 是否带外 |
|---|---|---|
| 当前模式 | 会话日志的 sandbox/mode 事件 |
否 |
| 工作区根 | SessionHeader.cwd(创建时固定) |
否 |
| 部署默认 | 插件配置 | 否 |
| 本次调用的批准 | 调用参数 | 否 |
模型可见的 sandbox:policy 上下文
每次模型请求前,归属方把解析结果贡献为 sandbox:policy 上下文(dsh-sandbox-policy/lib/index.js:74 起的三段文案)。它只声明文件影响约定,并明说「不要仅凭策略拒绝一次必要修改,先正常调用工具,再按返回的拒绝与升权提示处理」;不枚举已挂载能力,因为那是部署事实,写进文案即会腐烂。
KV cache 决定这段文案只能追加不能改写:切换时系统提示逐字节不变,新快照追加在保留历史之后,已缓存前缀继续可用;未变化的请求不新增内容。
注意:把模式或工作区拼进系统提示,或让每个工具自己渲染策略说明,等于每次切换都重写前缀。省下的文案长度会以整段前缀未命中付回去。
配置错误在加载期报错
mode 是三个字面量的联合并带 .default("read-only")(dsh-sandbox-policy/lib/index.js:99-102),默认朝故障安全方向:需写入的部署必须显式选 workspace-write;非法字符串在加载期即报错。
yaml
- name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: workspace-write
workspaceRoot: /absolute/path/to/workspace
失败模式:捕获配置校验失败后「降级为 danger-full-access 先跑起来」。这把拼写错误变成无约束运行,除日志外没有东西提示边界已消失;正确做法是让加载失败。
三源优先级把三件不同生命期的事实放进三个位置:部署默认进程级、会话切换日志级、显式批准调用级;任一处越界------provider 自持模式、工作区随目录漂移、配置错误静默降级------策略就退化成建议。
confine() 执行路径:把一条命令包装成受限 argv
一条命令从模型吐出的字符串变成受限进程,中间只发生一次形态变化:调用方准备好的完整 argv,被追加到某个 runner 的前缀之后,前缀与 argv 之间用 -- 分隔。这次变化全部发生在 ctx.sandbox.confine() 内部,返回值只有四个字段。这四个字段怎么被消费,就是执行层的分工边界。
链路上每一环只做一件事
工具层把模型参数翻译成精确的 ['bash','-c',command],并在结果里渲染拒绝标记与升权提示;策略层给出这一次调用的 {mode, workspaceRoot};执行器 dsh-bash-sandbox 注册为 ctx.shell,在 spawn 之前等待 confine() 完成限制准备,然后原样 spawn 返回的 argv。README 把这条分界写得很直白:本执行器「通过 ctx.sandbox.confine() 等待每条命令的精确 ['bash','-c',command] argv 完成限制准备,再直接 spawn 返回的 argv」(dsh-bash-sandbox/README.zh.md:78)。执行器不解析平台参数,那属于 provider 的职责。
runner 进程 提供方 (ctx.sandbox) 策略层 (ctx.sandboxPolicy) 执行器 (ctx.shell) 工具层 (tool-bash) runner 进程 提供方 (ctx.sandbox) 策略层 (ctx.sandboxPolicy) 执行器 (ctx.shell) 工具层 (tool-bash) #mermaid-svg-pCwsKF4QdPMhzk63{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pCwsKF4QdPMhzk63 .error-icon{fill:#552222;}#mermaid-svg-pCwsKF4QdPMhzk63 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pCwsKF4QdPMhzk63 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pCwsKF4QdPMhzk63 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pCwsKF4QdPMhzk63 .marker.cross{stroke:#333333;}#mermaid-svg-pCwsKF4QdPMhzk63 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pCwsKF4QdPMhzk63 p{margin:0;}#mermaid-svg-pCwsKF4QdPMhzk63 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pCwsKF4QdPMhzk63 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pCwsKF4QdPMhzk63 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-pCwsKF4QdPMhzk63 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-pCwsKF4QdPMhzk63 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-pCwsKF4QdPMhzk63 .sequenceNumber{fill:white;}#mermaid-svg-pCwsKF4QdPMhzk63 #sequencenumber{fill:#333;}#mermaid-svg-pCwsKF4QdPMhzk63 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-pCwsKF4QdPMhzk63 .messageText{fill:#333;stroke:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pCwsKF4QdPMhzk63 .labelText,#mermaid-svg-pCwsKF4QdPMhzk63 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .loopText,#mermaid-svg-pCwsKF4QdPMhzk63 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pCwsKF4QdPMhzk63 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-pCwsKF4QdPMhzk63 .noteText,#mermaid-svg-pCwsKF4QdPMhzk63 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-pCwsKF4QdPMhzk63 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pCwsKF4QdPMhzk63 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pCwsKF4QdPMhzk63 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pCwsKF4QdPMhzk63 .actorPopupMenu{position:absolute;}#mermaid-svg-pCwsKF4QdPMhzk63 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-pCwsKF4QdPMhzk63 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pCwsKF4QdPMhzk63 .actor-man circle,#mermaid-svg-pCwsKF4QdPMhzk63 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-pCwsKF4QdPMhzk63 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} execute({ command, sandbox_permissions, justification })resolve(session, 本次调用){ mode, workspaceRoot }confine('bash','-c',command, policy, signal)canonicalPath(workspaceRoot) → 选择 runner / 物化授权{ argv, enforcement, denialSignatures, runnerFailureRules }spawn(受限 argv)exit code + stderr 尾部按方言分类 denied,按致命签名分类 runner 失败{ sandbox: { mode, denied } } + enforcement
confine() 自身很薄:先检查取消,再规范化 workspaceRoot,然后或走操作方断言的 runnerCommand 分支,或走平台 runner 链------两条分支都只是拼出前缀(dsh-sandbox-local/lib/index.js:305、:311-333)。复杂性被推给 runnerArgv() 与各平台构建器,它才能对四个平台保持同一段代码。
ConfinedArgv 的四个字段
| 字段 | 承载的事实 | 被谁消费 |
|---|---|---|
argv |
runner 前缀 + -- + 原始 argv |
执行器直接 spawn,不做二次拼装 |
enforcement |
该次运行实际达到的完整度 full / partial |
结算时写进结果,供上层判断能否拒绝 |
denialSignatures |
该后端的拒绝方言列表 | 结算时匹配 stderr 尾部,决定 denied |
runnerFailureRules |
结构化致命签名,标明 runner 自身崩溃 | 优先于拒绝分类:命令根本没跑 |
四个字段是同一件事的四个侧面:argv 是「怎么跑」,后三个是「跑完怎么判读」,执行器因此只剩分类工作。
ts
type Mode = "read-only" | "workspace-write" | "danger-full-access";
interface ConfinedArgv {
argv: string[]; // runner 前缀 + "--" + 调用方原始 argv
enforcement: "full" | "partial"; // 本次运行实际达到的完整度
denialSignatures: string[]; // 该后端的拒绝方言
runnerFailureRules: { fatalSignatures: string[] }[];
}
interface SandboxProvider {
confine(
argv: readonly string[],
policy: { mode: Mode; workspaceRoot: string; sessionId?: string },
signal?: AbortSignal,
): Promise<ConfinedArgv>;
}
seam 刻意窄:输入是「即将 spawn 的确切 argv」,输出是「改好的 argv + 判读依据」,没有中间句柄,也没有反问 provider 的回调。
为什么限制放在 argv 前缀而不是进程内打钩
把限制做成可执行文件前缀,换来三件事:限制由内核实施,覆盖 runner 派生的整棵进程树;harness 不必在每个平台各写一套路径判定;执行器与平台解耦。代价是 runner 必须能启动,否则只剩故障关闭。
反模式:用进程内路径检查代替 argv 前缀。进程内检查只看得见 harness 自己发起的写入,runner 之后的任何子进程都绕过了它;argv 前缀把限制交给内核,一次覆盖整棵树。
bash 与 pwsh 是一对孪生执行器
dsh-pwsh-sandbox 是 bash 版的孪生:同一个 seam、同一条路径、同一个 provider,差别只在原始 argv 的首元素与 PowerShell 语言模式。共享 seam 让策略只解析一次,两个执行器拿到同一份 {mode, workspaceRoot},不会出现默认模式不一致的裂缝。
| 维度 | dsh-bash-sandbox |
dsh-pwsh-sandbox |
|---|---|---|
| 注册的 seam | ctx.shell |
ctx.shell |
| 原始 argv | ['bash','-c',command] |
['pwsh','-Command',command] |
| 前置调用 | ctx.sandbox.confine() |
ctx.sandbox.confine() |
| 平台差异位置 | provider 内部 | provider 内部 |
danger-full-access 完全不咨询 provider
danger-full-access 不是「更宽的 profile」,而是显式无约束模式:执行器直接走未包装的 argv,结果携带 sandbox: { mode, denied: false },全程不调用 confine()。README 的模式表把它标注为「绝不咨询提供方」(dsh-bash-sandbox/README.zh.md:40)。若它也要经过 provider,provider 不可用会连累无约束模式,故障关闭语义被反向污染。
按进程保留隔离事实
执行器的第三条不变式是「隔离事实在结算前按句柄保留,因为提供方可能在重叠调用之间改变强制执行方式」(dsh-bash-sandbox/README.zh.md:96)。原因很直接:同一会话可以有两条命令同时在跑,一条前台同步、一条后台作业;enforcement 来自 confine() 的返回值,而不是 provider 的某个全局字段。若执行器把完整度存成自己的成员变量,先结算的那次会读到后一次的值,于是可能把 partial 上报成 full。结算时按句柄取回那份被保留的事实,才保证「结果描述的是这一次进程」。
注意:
enforcement是「这一次进程」的属性,不是 provider 的属性。任何把它缓存到实例级状态的实现,都会在并发下悄悄说谎。
于是 confine() 的契约可以压缩成一句话:它只回答「这条命令该怎么被限制、跑完该怎么判读」,不回答「这台机器现在是否安全」。安全性是结果事实(enforcement 与 denied);没有可用 runner 时它抛出 SANDBOX_UNAVAILABLE(dsh-sandbox/lib/index.js:264),绝不无隔离放行(dsh-sandbox/lib/index.js:272)。
Linux 后端:bubblewrap 的挂载命名空间与 Landlock 的授权掩码
Linux 上的 dsh-sandbox-local 不发明权限模型。它把逐调用策略里的 {mode, workspaceRoot} 翻译成两个既有内核机制的 argv 前缀:bubblewrap 的挂载命名空间,与 Landlock 的系统调用访问掩码。前者改写命令看到的那棵文件树,后者让内核对每次写入做位掩码裁决。两条路都落在进程之外,因此不需要工具层做任何配合。
bwrap:用挂载表达文件影响
bwrapProfileArgs(policy) 的输出几乎就是一份文件树重写脚本(dsh-sandbox-local/lib/index.js:22-38):先以只读方式把宿主根挂到自身,再叠上全新的 /dev,在私有 PID 命名空间里提供 /proc,最后按模式追加可写位置。
| 参数 | 语义 | 出现条件 |
|---|---|---|
--ro-bind / / |
整个宿主根只读可见 | 两种模式 |
--dev /dev |
全新设备目录,不继承宿主 /dev |
两种模式 |
--unshare-pid + --proc /proc |
私有 PID 命名空间,只看得见自己的后代 | 两种模式 |
--die-with-parent |
父进程消失即终止 | 两种模式 |
--tmpfs /tmp |
一次性可写临时区 | workspace-write |
--bind <root> <root> |
可写工作区绑定挂载 | workspace-write |
挂载顺序本身就是策略:workspace-write 仍先做只读根,再用 --bind 把工作区盖回可写;后挂的挂载遮蔽先前的只读视图。因此可写面不是一个「例外白名单」,而是一次精确的遮蔽,工作区之外没有任何一条路径被放开。
bash
bwrap \
--ro-bind / / \
--dev /dev \
--unshare-pid --proc /proc \
--die-with-parent \
--tmpfs /tmp \
--bind "$PWD" "$PWD" \
-- bash -lc 'echo hello > out.txt'
私有 PID 命名空间是这条路线最容易被低估的一步。命令可以管理自己派生的后代,却看不到宿主上任何其他进程;更关键的是 /proc 由新的 procfs 提供,诸如 /proc/self/root 这类魔法链接只在当前挂载命名空间内解析,指向的正是被改写后的根,而不是宿主真实根。也就是说,procfs 魔法链接无法绕过挂载。
Landlock:把模式映射成授权词汇
bwrap 缺席时链上还有第二档。Landlock 的 launcher、功能探测与授权词汇都在带版本的 binary @deepseek-ai/node-addon-system/landlock-run 里(dsh-sandbox-local/lib/index.js:6),provider 只做一件事:模式到授权的映射(dsh-sandbox-local/lib/index.js:45-52)。
ts
function landlockProfileArgs(policy: { mode: SandboxMode; workspaceRoot: string }) {
const readWrite = ["/dev/null"];
if (policy.mode === "workspace-write") readWrite.push("/tmp", policy.workspaceRoot);
return grantArgs({ readOnly: ["/"], readWrite });
}
| 模式 | readOnly 授权 | readWrite 授权 |
|---|---|---|
read-only |
/ |
/dev/null |
workspace-write |
/ |
/dev/null、/tmp、workspaceRoot |
对比 bwrap 很有意味:两者的授权面等价,但表达位置完全不同。bwrap 表达「哪里能写」靠挂载遮蔽,Landlock 表达同一件事靠内核里的访问掩码;/dev/null 在 bwrap 里靠全新 /dev 天然可写,在 Landlock 里必须显式列出。把路径解析和类别裁剪留在 binary 里,是刻意的分工------这些细节随内核 ABI 变化,放在 TypeScript 侧就会与内核版本脱钩。
反模式:把
which bwrap当作能力探测。命令在 PATH 上只证明文件存在,不证明内核允许非特权用户命名空间与挂载,而不少发行版正是用 sysctl 关掉了它。defaultProbeBwrap因此真跑一次完整 profile,再在内层执行true,以退出码作为唯一结论(dsh-sandbox-local/lib/index.js:100-113)。
runner 链与探测:先 bwrap,后 Landlock
平台链在代码里是一张常量表:linux: ["bwrap", "landlock"](dsh-sandbox-local/lib/index.js:174-176)。选择遵循「平台优先、探测其次」:链上只有一个候选时直接选用不探测;有竞争候选时按链序各真跑一次探测,首个可用结论在提供方生命周期内缓存。这解释了 Linux 为何偏爱 bwrap------它的挂载模型与模式词汇最贴近;Landlock 只在 bwrap 探测失败后才登场。
探测必须真跑一次 profile,而不是查版本号或查二进制。原因有三层:第一,二进制存在不代表内核允许创建对应命名空间;第二,bwrap 的 profile 是否被内核接受,只有构造一次挂载才暴露;第三,旧内核上的 Landlock 可能只覆盖自身公开的访问类别,这时 launcher 会在每次受限运行中自行在 stderr 上报 partial,探测缓存下来的 enforcement 才是区分 full 与按 ABI 降级 partial 的依据(dsh-sandbox-local/lib/index.js:178-192)。
注意:Landlock 的旧 ABI 只限制自己公开的类别,此时后端如实上报
partial而不是full(dsh-sandbox-local/README.zh.md:132)。需要绝对边界的消费方可以据此拒绝执行;把partial当full宣称,等于让上层失去判断依据。
两档的静态声明是一致的:bwrap: "full" 与 landlock: "full"(dsh-sandbox-local/lib/index.js:188-191)------bwrap 按构造即管辖模式承诺的每个文件操作,Landlock 的 full 则只在该 ABI 足够新时才成立,所以它永远经由探测被选中。链上任何一档都不通过时,confine() 故障关闭,命令不会无限制地跑起来。
macOS 后端:Seatbelt 与 SBPL 白名单策略
macOS 不向普通进程暴露挂载命名空间,也没有 Landlock 那样可编程的授权系统调用。DSH 在 darwin 上只有一个后端:Seatbelt,由 sandbox-exec 把一个 SBPL 策略字符串交给内核的强制访问控制引擎。它的形态完全符合 runner(限制运行器)的定义------sandbox-exec -p <profile> -- <command> <args...>,策略本身就是 argv 的一段。因此 confine() 在 macOS 上要做的只是接一段 argv 前缀:不要 root、不要 entitlement、不要守护进程。
策略的构造集中在一个函数里,dsh-sandbox-local/lib/index.js:65-75:
ts
function seatbeltProfileArgs(policy: FileEffectPolicy): string[] {
const forms = [
"(version 1)",
"(allow default)",
"(deny file-write*)",
`(allow file-write* (literal ${sbplString("/dev/null")}))`,
];
const roots = writableRoots(policy); // 与 fs 围栏同一个派生函数
if (roots.length > 0)
forms.push(`(allow file-write* ${roots.map((r) => `(subpath ${sbplString(r)})`).join(" ")}`);
return ["-p", forms.join(" ")];
}
四条基础形式的职责
四条基础形式各司其职,其余全是白名单条目:
| SBPL 形式 | 匹配粒度 | 在本策略中的职责 |
|---|---|---|
(allow default) |
全集 | 让系统调用与读取通过,进程才起得来 |
(deny file-write*) |
全部写入类操作 | 把「写」从默认允许里整体摘出 |
(literal "/dev/null") |
单一路径 | 保留丢弃输出的能力 |
(subpath "<root>") |
整棵子树 | 把模式承诺的可写根放回白名单 |
(allow default) 最容易被误读:它不是兜底,而是把管辖范围收窄到文件写入这一件事------读取、网络、进程可见性全在管辖之外,(deny file-write*) 只买了「文件影响」这一份承诺。
反模式:把 Seatbelt profile 读成「默认拒绝的沙箱」。它默认允许一切,只拒绝写入,再把少数根放回来。指望它阻止外连、读取密钥或横向移动,是把模式承诺(文件影响)当成了安全承诺(全部隔离)。
现在看白名单怎么补。/dev/null 用 (literal ...),因为要的只是一个设备节点;可写根用 (subpath ...),因为授权对象是整棵子树。两种匹配都发生在内核解析路径之后,而「解析后」这三个字就是这一章真正的技术难点。
失败模式:把配置里的
/tmp原样写进 profile。darwin 上/tmp是/private/tmp的符号链接,(subpath "/tmp")在解析后路径上什么都匹配不到------命令照常成功启动,写入却全部被拒,表现为「沙箱莫名拒绝」,而不是一条可读的配置错误。
可写根集合与路径规范化
DSH 用 canonicalPath 解决它:realpathSync.native 成功则用解析后的路径,失败则原样返回(dsh-sandbox/lib/index.js:150-156)。失败分支是有意保守的------根不存在时生成的授权匹配不到任何路径,既不报错也不凭空造一个 fallback 目录,因为凭空授权等于授予调用方从未命名的路径。可写根集合(writable roots)因此是这样组成的:
| 根 | 来源 | 出现条件 |
|---|---|---|
| 工作区根 | policy.workspaceRoot 的规范化结果 |
仅 workspace-write |
宿主 /tmp |
平台固定路径,同样规范化 | 仅 workspace-write |
| 每用户临时区 | os.tmpdir(),即 mkstemp 族工具的落脚点 |
仅 workspace-write |
/dev/null |
profile 内硬编码 | 两种受限模式都保留 |
read-only 下这张表整体为空:dsh-sandbox/lib/index.js:167 的 if (policy.mode !== "workspace-write") return [] 是唯一出口,profile 于是只剩拒绝与 /dev/null 两条。
共用同一个函数才是关键设计。同一份 allow-list 同时喂给 Seatbelt profile 与进程内的 fs 围栏(dsh-sandbox/lib/index.js:128-167),因此不可能出现「bash 改不了、写文件工具却能改」或反向的不对称。bwrap 与 Landlock 保留各自的授权拼写,那是有记录的真实差异;Seatbelt 与 fs 围栏则是同构的一对。
探测:必须真跑一次 profile
策略好不好用,靠真跑一次来确定。defaultProbeSeatbelt 用真实的 read-only profile 起一个 true,退出 0 才说明内核接受并强制执行了这条 profile(sandbox_init 拒绝时 sandbox-exec 以非零退出),见 dsh-sandbox-local/lib/index.js:123-135:
bash
PROFILE='(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))'
# 探测:退出码 0 = 内核接受并强制执行该 profile
sandbox-exec -p "$PROFILE" -- true; echo "probe exit=$?"
# 反证:被 deny 覆盖的写入必须失败
sandbox-exec -p "$PROFILE" -- /bin/sh -c 'echo x > /tmp/probe.txt'
echo "write exit=$?"
静态检查 profile 字符串毫无意义:SBPL 的合法性、内核策略引擎是否可用,都只在 sandbox_init 真正被调用时暴露。macOS 上也没有第二层机制能兜住一个语法正确但语义失效的 profile。
技术债:
sandbox-exec已被 Apple 标记弃用。它目前每台 macOS 都还在,但它是进入该私有策略引擎的唯一入口,DSH 没有可替换的 runner。若 Apple 移除它,正确行为不是降级放行,而是探测报不可用、confine()故障关闭(dsh-sandbox-local/README.zh.md:133)。反过来看,探测函数本身就是那道 fail-closed 闸门(dsh-sandbox-local/lib/index.js:116-121)。
结构上还有一处与 Linux 的根本差异:darwin 的 runner 链只有 seatbelt 一个成员(dsh-sandbox-local/lib/index.js:174-176),没有 bwrap→Landlock 那样的第二级。macOS 的可用性因此完全等于 sandbox-exec 的可用性,一旦它消失,没有任何退路------这也解释了为什么这一档必须靠「每次启动都真跑一次探测」来确认能力,而不是缓存一个平台版本判断。默认允许加写入白名单把实现压到极简,代价是全部筹码押在一个已弃用的二进制上。
Windows 后端:WRITE_RESTRICTED 受限令牌与 Low 完整性标签
Linux 可以用挂载命名空间换掉整棵文件树,Windows 没有等价物:能改的只有令牌与对象自身的安全描述符。dsh-sandbox-windows-acl 走的就是这条替代路线,它在 runner 链里写作 win32: ["windows-acl"](dsh-sandbox-local/lib/index.js:174-176),静态声明 partial(dsh-sandbox-local/lib/index.js:188-191)。
两次访问检查:令牌怎么降权
调用者令牌被复制为 WRITE_RESTRICTED 受限令牌,其 restricting SIDs 携带彼此独立的工作区 SID 与私有临时 SID,令牌再降级为 Low 完整性(dsh-sandbox-windows-acl/README.zh.md:100)。此后 Windows 做两次访问检查:先对正常 SID,再对 restricting SID,两次都通过才授予写类访问;同时内核的强制完整性检查拒绝对任何未标记 Low 的对象写入。
pass-2 只授予 restricting 列表携带的内容,这个细节决定了模式语义:
| 模式 | restricting SID 列表 | 写入结果 |
|---|---|---|
workspace-write |
登录 SID、Everyone、工作区 SID、临时 SID | 工作区与私有临时子目录可写 |
read-only |
登录 SID、Everyone,不含写入 SID | 除 Low 标记目录外一律拒写 |
因此 workspace-write 时期留下的常驻授权 ACE 对 read-only 依然失效,常驻描述符不必回滚,降级即生效(dsh-sandbox-windows-acl/README.zh.md:104)。
一次调用写三件:授权物化
一次授权要在同一次 SetNamedSecurityInfoW 里落下三件互不可分的东西:能力 SID 的允许 ACE、对 world SID 拒绝 FILE_DELETE_CHILD、把目录标记为 Low(dsh-sandbox-windows-acl/README.zh.md:100)。分开写会留下「已授权但还删不掉」的中间态。
| 对象 | ACE / 标签类型 | 掩码 | 继承 |
|---|---|---|---|
| 工作区 SID 或临时 SID | ACCESS_ALLOWED_ACE | 写/修改类 | CONTAINER_INHERIT_ACE + OBJECT_INHERIT_ACE |
| Everyone(world SID) | ACCESS_DENIED_ACE | FILE_DELETE_CHILD(0x40) |
仅 CONTAINER_INHERIT_ACE |
| 目录 SACL | SYSTEM_MANDATORY_LABEL_ACE | Low 完整性 | 继承到子目录 |
第二行是最容易被省掉的一件。只做 restricting SID 交叉检查不够 :Windows 也可以依据父目录的 FILE_DELETE_CHILD 权限批准删除,而这项权限不需要任何 restricting SID 副署。于是只带交叉检查的令牌不仅能删掉其环境用户 SID 控制的任何文件,还能删掉另一个授权根目录 内的文件------那里的 Low 标签恰好能通过完整性检查(dsh-sandbox-windows-acl/README.zh.md:100)。加上拒绝项后,能力 ACE 的 DELETE 位才是授权根内唯一的删除授权来源。
反模式:把「restricting SID + Low 标签」当成完整边界。漏掉
FILE_DELETE_CHILD拒绝项时,删除路径根本不经过能力 SID 检查,两个相邻授权根可以互相删除,而只验证写入的测试仍然全绿。
SID 派生:确定性的是工作区,随机的是临时目录
工作区 SID 由规范化工作区路径确定性派生(workspaceWriteSid),每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中「精确 ACE/精确拒绝/精确标签」而跳过传播(dsh-sandbox-windows-acl/README.zh.md:100)。临时侧相反:每个活跃的会话/工作区对获得一个随机私有临时目录,以及由该路径派生的 tempWriteSid(dsh-sandbox-windows-acl/README.zh.md:81、:118)。会话共享预期的工作区权限,却不继承彼此的临时目录权限;fork 拿到不同的临时能力,崩溃残留只是失效垃圾。两者的引入点同在 dsh-sandbox-local/lib/index.js:9。
保活组也不能省。登录 SID + Everyone 在两种模式下都必须存在:缺了它,早期 DLL 初始化会以 0xC0000142 死亡,CNG 则会让 pwsh 以 0xE0434352 崩溃(dsh-sandbox-windows-acl/README.zh.md:104)。Everyone 留在列表里并不带来写权限------Low 标签已关闭这个旧缺口,它只是进程能起来的前提。
注意:
0xC0000142与0xE0434352看起来像沙箱拒绝,实际是令牌过度收缩导致的启动失败。见到它们先查保活组,不要查 DACL。
runner 形态:argv 前缀与 127
包的入口是 argv 前缀包装(./runner),与 bwrap、landlock-run、sandbox-exec 同一架构(dsh-sandbox-windows-acl/README.zh.md:112):创建受限令牌,在其下 spawn 包装后的 argv,stdio 直通,包进 KILL_ON_JOB_CLOSE job,镜像退出码,退出时撤销自管的临时授权。
sh
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> \
[--write-sid <S-1-4-...> --temp-write-sid <S-1-4-...>] -- <argv...>
seam 只做两件事:先物化确定性工作区 ACE,再把成对的 --write-sid / --temp-write-sid 传进去;runner 对照各自所属路径验证二者,既不授权也不撤销(manageDacls: false)。不带这对标志时 --temp 被当作根目录,runner 自建随机私有子目录、重写 TMP/TEMP、退出时移除(dsh-sandbox-windows-acl/README.zh.md:118),且工作区等于或包含临时根目录时会在任何授权前被拒(assertTempRootOutsideWorkspace)。
ts
// dsh-sandbox-local/lib/index.js:338-341 的 windows-acl 分支形态
const argv: string[] = [
runnerPath, // ./runner,argv 前缀
"--workspace", policy.workspaceRoot,
"--temp", tempDir,
"--mode", policy.mode, // read-only | workspace-write
"--write-sid", workspaceWriteSid(workspaceRoot), // 确定性,可复用
"--temp-write-sid", tempWriteSid(tempDir), // 每会话随机,可回收
"--", ...command,
];
runner 侧任何失败都向 stderr 打印 windows-acl-run: <detail> 并以 127 退出。这条签名进的是致命签名表(dsh-sandbox-local/lib/index.js:232-241),消费方据此分清「命令被内核拒绝」与「runner 自己坏了」:前者是策略生效,后者必须故障关闭。
失败模式:把
windows-acl报成full。本章机制只覆盖写类访问,读取、网络与进程可见性都不受限(dsh-sandbox-windows-acl/README.zh.md:127),read-only也还需要读侧策略才能真正表达。谎报full会让消费方失去拒绝依据。
Windows 的四个边界:硬链接、删除权限、AppContainer ACL 与命名管道
上一章拆开了受限令牌怎么造。本章讲它做不到什么:dsh-sandbox-windows-acl 把这些记作「已验证边界」------「只约束写入」与 NTFS 语义之间的摩擦,不是待修缺陷。
边界一:硬链接是文件对象别名
可继承的工作区授权传播到已有硬链接时,会标记并授权底层同一文件的描述符,于是同一对象也能从工作区外的别名写入。别名指向文件对象而非路径,「目录外即边界外」对它不成立(dsh-sandbox-windows-acl/README.zh.md:128)。但「一律拒绝多链接文件」走不通:pnpm 安装大量用硬链接,按链接数拒绝会让最常见的前端工作流当场不可用(同处)。防线只能放在「谁能创建硬链接」上。
失败模式:把链接数当安全信号。它覆盖不了其他进程事后建立的硬链接,又会误伤 pnpm 的正常布局,收益接近零而损失确定。
| 观察点 | 事实 | 结论 |
|---|---|---|
| 别名指向 | 文件对象,描述符一份 | 路径前缀判断无效 |
| 授权传播 | 可继承 ACE 覆盖已有硬链接 | 外部别名可写 |
边界二:删除权限只落在目录上
Windows 可凭父目录 FILE_DELETE_CHILD 批准删除,这正是「只做交叉检查不够」的原因。对 world SID 的拒绝项因此必须带 CONTAINER_INHERIT_ACE,只继承到子目录、绝不落到文件:位 0x40 属于 FILE_ALL_ACCESS,落到文件上会让用户、Administrators、SYSTEM 与 DSH host 的每次 FullControl 打开都被拒绝(dsh-sandbox-windows-acl/README.zh.md:126)。根内目录保留该项而拒绝这类打开,DELETE 删除与常规读写不受影响。
ts
// 拒绝项只用于目录:0x40 = FILE_DELETE_CHILD
const deleteChildDeny = {
mask: 0x40,
inherit: "CONTAINER_INHERIT_ACE", // 绝不使用 OBJECT_INHERIT_ACE
} as const;
反模式:为求彻底把拒绝项继承到文件。第一受害者是宿主自己,故障点离沙箱很远,排查成本极高。
边界三:被 AppContainer 工具 ACL 过的对象读不到
写入交叉检查只拦写,读取与网络不受限(dsh-sandbox-windows-acl/README.zh.md:127),所以「能读」通常不是问题。例外来自别的产品:DACL 一旦带针对包 SID(S-1-15-2-...)的 ACE,Low 完整性令牌就访问不到该对象,即使它同时给用户完全控制、给 Everyone 读取(dsh-sandbox-windows-acl/README.zh.md:191)。只加这一条 ACE 即可复现,补授 Everyone 读取不解除,复制到别处仍可读;内核规则未确证,修复方向是移除外来 ACE。
边界四:命名管道 stdio 捕获 EPERM
libuv 的管道 stdio 走命名管道,client 端打开时所请求的写访问没有 restricting SID 被授予------那是 Win32 层的默认 SD 模板,而非令牌默认 DACL------于是受限进程内 spawn(..., { stdio: 'pipe' }) 以 EPERM 失败;继承与忽略可用,PowerShell 匿名管道可用(受限令牌默认 DACL 带 restricting SID 全权 ACE)(dsh-sandbox-windows-acl/README.zh.md:188)。dsh-tool-pwsh/lib/index.js:258 把这点写进工具说明:两种受限模式下管道捕获 EPERM,inherit/ignore 可用。
json
{ "mode": "workspace-write", "stdio": "pipe", "result": "EPERM",
"cause": "named pipe client 侧没有 restricting SID 被授予(默认 SD 模板)" }
两个溢出沙箱生命期的副作用
语言模式随模式变。 read-only 下无法在临时目录创建 AppLocker 探针文件,故保守以 ConstrainedLanguage 启动,Add-Type、非核心 .NET 静态调用、COM 与反射失败;workspace-write 走 FullLanguage,tempDir: null 时无保证(dsh-sandbox-windows-acl/README.zh.md:194)。NUL 例外:设备 DACL 给 Everyone 0x1201BF,故 > NUL 两模式都能写,而 Set-Content NUL 两模式都失败,属 PowerShell/.NET 层效应(dsh-sandbox-windows-acl/README.zh.md:106)。
Low 标签是驻留改动。 可继承标签在会话结束、甚至同卷移动后依然存在,任何同用户身份的 Low-IL 进程都能写删工作区内容,Medium 下则被拒------这是写边界的代价:没有标签受限子进程写不进去,按会话回收则每次供给都要重传整棵树。安全描述符改动同理,手工 icacls 回收不了(ERROR_NONE_MAPPED 1332),必须走 dispose()(dsh-sandbox-windows-acl/README.zh.md:130-131)。
诊断:补齐工具层报不出的那一半
工具层只能上报拒绝方言,无法证明 ACL 层缺了什么。随包发布的 diagnose-windows-sandbox-acl 一次运行检查目标路径与每级祖先,并对本次证明的问题顺手修复,保留拒绝项、所有者、继承与 SACL,并备份每个被改的 DACL;脚本内容不进模型上下文。它自身也需要一次升权,否则升权 fail-closed,诊断路径保持未诊断(dsh-sandbox-windows-acl/README.zh.md:193)。
| 边界 | 成因层次 | 可修复性 |
|---|---|---|
| 硬链接别名 | NTFS 对象语义 | 不可修,接受 |
| 删除权限继承 | 权限位复用 | 已用继承标志规避 |
| AppContainer 读拒绝 | 外来 ACE 与完整性 | 移除外来 ACE |
| 命名管道 stdio | Win32 默认 SD 模板 | 改用 inherit/ignore |
四条边界说明:Windows 档的 partial 不只是探测结果,也是这些语义的诚实边界------下一章讲它如何如实上报。
强制执行完整度:full 与 partial 为什么必须如实上报
同一个 workspace-write 请求,在不同主机上得到的约束强度可能不同。enforcement 回答的就是「这次到底管住了多少」。
| 取值 | 含义 | 消费方应如何对待 |
|---|---|---|
full |
管辖模式承诺的每个文件操作 | 可据此认定边界完整 |
partial |
只管辖其中一个子集 | 需要绝对边界时可拒绝 |
两者之间没有中间态。判断标准不是「做了多少限制」,而是以模式承诺为分母逐项对齐:read-only 承诺任何写入都不生效;workspace-write 承诺只有工作区根与平台临时区可写。bwrap 的挂载 profile 按构造满足这一点------整个宿主根只读绑定,可写面只剩精确遮蔽的位置,不存在「某些路径忘了处理」。因此后端的静态声明表可以直接写死(dsh-sandbox-local/lib/index.js:188-191):
ts
const STATIC_ENFORCEMENT = {
bwrap: "full",
landlock: "full",
seatbelt: "full",
"windows-acl": "partial"
};
这张表只在平台链上仅一个候选、无需探测即可选定时被直接采用(dsh-sandbox-local/lib/index.js:497-503)。链上有两个候选时,结论必须来自探测。
partial 的三类成因
| 成因 | 机制 | 依据 |
|---|---|---|
| Windows ACL 档 | NTFS 硬链接别名同一文件对象;读取不受限;被其他 AppContainer 工具用包 SID 标记的目录树对 Low 完整性不可读 | 静态声明(dsh-sandbox-local/lib/index.js:191) |
| 旧 Landlock ABI | 内核只限制该 ABI 自身公开的访问类别 | 探测结果按 ABI 判定(dsh-sandbox-local/lib/index.js:517) |
runnerCommand 操作方断言 |
自定义 runner 跳过探测,假定其诚实实现 bwrap 兼容 profile | dsh-sandbox-local/README.zh.md:135 |
前两类是内核与平台能力的事实,第三类则是信任关系的变化:一旦 runner 由操作方提供,后端就无法替它作证,上报的完整度因此成了操作方责任而非平台保证。
Landlock 最微妙。静态表给它写 full,但它永远不会不经探测被选中------Linux 链是两档,它只在 bwrap 探测失败后登场,而探测返回的正是 full 或按 ABI 降级的 partial(dsh-sandbox-local/lib/index.js:505-509)。代码注释说明:静态表里的 landlock: "full" 只为表的总全性存在,真正区分两者的是探测报告。
反模式:把「二进制在 PATH 上」当成
full。能否创建挂载命名空间、内核 ABI 覆盖哪些类别,都不是查文件能回答的。探测必须真跑一次 profile 或 launcher,结论才配写进enforcement(dsh-sandbox-local/lib/index.js:514-521)。
谎报 partial 为 full 为什么是最坏设计
enforcement 的消费方唯一的动作是「决定这次执行要不要接受」。谎报 partial 为 full,等于抽掉这个决定的前提:本可以拒绝并改用容器或远程执行器的调用方会放行,把边界不足当完整边界用;而若所有后端都自称 full,这个字就失去信息量,真正完整的一档也不再可信。可被区分的完整度才有用,不可区分的完整度等于没有。
正因如此,enforcement 与 denied 被定义为结果上的事实 ,而不是能力清单。一次 full 运行可能 denied: false,因为命令根本没尝试越界;一次 partial 运行也可能拒绝得很干净,因为越界写恰好落在它管辖的类别内。
json
{
"enforcement": "partial",
"denied": true,
"denialSignatures": ["permission denied"],
"runnerFailureRules": []
}
这条结果的含义是:本次运行由一个只覆盖子集的后端约束,其中一次操作被拒绝,依据是 stderr 出现 permission denied,且未触发 runner 自身故障规则。它没说明后端「永久 partial」,也没说明「以后都会拒绝」------换模式、换主机,两个字段都可能变。
探测缓存与重载
探测结论在提供方生命周期内缓存:链的判定每档最多执行一次,首个可用结论被记住。每次 confine() 都真跑 profile 代价过高,这个优化是必要的;代价是缓存会过期于运维动作------装上、移除或修复 runner 之后,必须重载插件才能改变选择(dsh-sandbox-local/README.zh.md:134)。
注意:探测缓存按「提供方生命周期」而非「每次调用」生效。装好 bwrap 后若仍看到
landlock或partial,先确认是否重载了插件,再去怀疑内核配置;否则会朝错误方向排查。
这也解释了为什么完整度必须在运行期上报:同一份配置在不同内核与不同 runner 可用性下会得到不同结论,安装期写死的标签会在第一次换主机时变成谎言。
失败关闭与错误分类:SANDBOX_UNAVAILABLE、runner 失败与拒绝方言
一次受限调用的非零退出,背后可能是三件互不替代的事实:策略拒绝了操作、runner 坏了没把命令跑起来、本机没有能执行该模式的后端。归错类的代价很具体:把 runner 损坏当策略拒绝,模型会去申请升权,而升权修不好路径错误。
三类事实的判定
| 事实 | 触发时机 | 观察信号 | 命令是否执行 | 正确处置 |
|---|---|---|---|---|
| 策略拒绝 | 命令已运行 | 非零退出 + stderr 命中拒绝方言 | 是 | 报告拒绝,走升权 |
| runner 损坏 | 包装或启动期 | 致命签名 / 特定退出码 | 否 | 修环境,不升权 |
| 模式不可用 | confine() 内 |
SANDBOX_UNAVAILABLE |
否 | 装后端或改模式 |
三者判据落在不同层:模式不可用是 confine() 抛出的结构错误(dsh-sandbox/lib/index.js:264 定义错误码);runner 损坏在启动后按后端规则匹配;策略拒绝只能从子进程 stderr 推断。
模式不可用:拒绝运行而不是放行
错误码是 SANDBOX_UNAVAILABLE,错误文本把因果和出口一起写清(dsh-sandbox/lib/index.js:272):模式被请求但没有可用后端,因此拒绝在无约束状态下运行命令;随后点名三平台修复路径(安装 bubblewrap 或可用的 Landlock 内核、可用的 sandbox-exec、能启动的 ACL 受限令牌 runner),最后给出唯一产品级出口------把消费方切到 danger-full-access。
json
{
"code": "SANDBOX_UNAVAILABLE",
"message": "sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Runner failure: bwrap: No such file or directory"
}
启动后才失败的后端会追加上 Runner failure: <detail>。这个后缀让同一条文本既表达「一开始就没有后端」,也表达「后端在,但这次包装或探测失败」。
注意:把
SANDBOX_UNAVAILABLE读成「平台不支持」然后降级放行,是最贵的误读------故障关闭正是宁可拒绝,也不静默无限制运行。
runner 失败:按退出码门控的致命签名
各后端 runner 故障词汇集中在 RUNNER_FAILURE_RULES(dsh-sandbox-local/lib/index.js:232-241):
ts
const RUNNER_FAILURE_RULES = {
bwrap: [{ fatalSignatures: ["bwrap: "] }],
seatbelt: [{ fatalSignatures: ["sandbox-exec: "] }],
"windows-acl": [{ allowedExitCodes: [127], fatalSignatures: ["windows-acl-run: "] }]
};
三条细节值得抄。其一,逐行匹配而非整段包含,且匹配前排除 informational 行------Landlock 的 partial enforcement (older Landlock ABI) 属于提示而非故障。其二,windows-acl 的规则被 127 退出码门控,被限制的命令自己打印 windows-acl-run: 不会被误判成「命令没跑」。其三,bwrap 致命路径当前退出 1,但公开契约没保留该状态;sandbox-exec 干脆不发布 launcher 失败码,两者只能靠签名。
拒绝方言:只能从 stderr 推断
策略拒绝的签名按后端登记在 DENIAL_SIGNATURES(dsh-sandbox-local/lib/index.js:207-216):bwrap 的 read-only file system、Landlock 的 permission denied、Seatbelt 的 operation not permitted,以及 windows-acl 的四条组合。匹配同时要求非零退出与具体退出码------信号终止(exitCode === null)不参与方言匹配。
这是已知限制而非遗漏:seam 返回后端签名,不是类型化拒绝通道。受限命令若自行打印 permission denied,就可能被误判为策略拒绝;带内通道也无法证明那行 stderr 由哪个进程写。取舍明确:带外状态通道要改 runner 协议,而方言至少在各平台内核输出上稳定。
denied 与 enforcement 不是同一维度
结果里的 denied 与 enforcement 常被一起读,但它们回答不同问题:
| 字段 | 回答的问题 | 取值 | 边界 |
|---|---|---|---|
denied |
这次操作被策略拒绝了吗 | 布尔 | 只对已被管辖的操作有意义 |
enforcement |
后端管辖了多大范围 | full / partial |
能力声明,不是本次结果 |
denied: true 配 enforcement: partial 成立:Windows ACL 档确实拦下了一次写入,而它并不管辖模式承诺的全部文件操作。反过来,denied: false 在 partial 下不能读成「操作被允许」------它只说明没观察到拒绝。工具层把两者分开渲染(File sandbox: <mode>; enforcement: <e>; operation denied),以免模型把「没拒绝」当成「合规」。
反模式:用单个
ok/denied布尔表示沙箱结果。丢掉enforcement,消费方就失去拒绝 partial 结果的依据;丢掉runnerFailed,损坏的 runner 会被当成可用后端。
fs 族的结构化对照
fs 族把同一判定做成结构化错误:read-only 直接抛 FsError(..., \"FS_SANDBOX_DENIED\")(dsh-fs-sandbox/lib/index.js:157),workspace-write 在祖先包含检查失败时抛同一错误码(dsh-fs-sandbox/lib/index.js:164),工具层再把它映射成模型可见的拒绝标记与升权提示(dsh-tool-fs/lib/index.js:1172-1174)。根源在执行位置:fs 围栏在进程内同步判断,能给类型化错误;shell 只能等子进程退出后读 stderr。代价对称------fs 侧结构化但没有内核边界,shell 侧有内核边界却依赖方言。
两类错误的判定标准应当统一到「操作是否真的执行过」:执行过才谈策略拒绝,没执行只谈环境失败。
升权协商:sandbox_permissions、justification 与一次性批准
写入被沙箱正确拒绝之后,模型只有三条路:绕过(换工具、换路径、改命令)、放弃、显式申请更宽的模式。前两条一条是漏洞一条是死路,第三条才是设计出来的出口。所以升权不是更大的权限常量,而是协商协议:一次性、带理由、必须由人批准------把批准权交给模型,等于把沙箱连同审批一起删掉。
成对参数与三条前置校验
入口是两个成对参数,sandbox_permissions 与 justification,必须同时出现:有权限没理由是无法审阅的弹窗,有理由没权限是驱动不了任何东西的废话;空理由同样被拒。三条校验在 dsh-sandbox/lib/index.js:52-53 附近,都发生在执行之前:
ts
function validateEscalationArgs(sandboxPermissions?: string, justification?: string) {
if (sandboxPermissions !== void 0 && justification === void 0)
throw new Error("invalid escalation: sandbox_permissions requires a justification");
if (justification !== void 0 && sandboxPermissions === void 0)
throw new Error("invalid escalation: justification is only valid together with sandbox_permissions");
if (justification !== void 0 && justification.trim().length === 0)
throw new Error("invalid justification: expected a non-empty sentence");
}
可执行范围:两张表分工
允许升到哪些模式,由两张表分管,它们看起来重复,职责却完全不同:
| 常量 | 内容 | 何时生效 | 为什么不能合并 |
|---|---|---|---|
WIDER_MODES |
read-only → workspace-write、danger-full-access;workspace-write → danger-full-access |
执行期逐调用校验 | 有效模式是逐调用策略(per-call policy)的事实 |
ESCALATION_TARGETS |
["workspace-write", "danger-full-access"] |
工具 schema 的参数枚举 | schema 是注册表全局的,一次注册服务所有会话 |
把 ESCALATION_TARGETS 收窄成「比部署默认更宽的模式」会制造死角:danger-full-access 作默认时空集可宣布,被切窄的会话却连申请杠杆都没有。可宣布的词汇因此是全集,能不能用交给执行期判断(dsh-sandbox/lib/index.js:42)。
封闭阶梯很短:read-only 是地板,没有模式能升到它,它自己却可一步跳到另外两个;workspace-write 只剩一个上行目标(dsh-sandbox/lib/index.js:31-32)。表外目标一律在执行期抛错,原文是 sandbox escalation to "..." is not strictly wider than this call's current "..." mode(dsh-sandbox/lib/index.js:102)------升权不能用来降权。
审批三态与一次性语义
审批结果是三态的,第四态是故障关闭:
| 审批结果 | 语义 | 后果 |
|---|---|---|
allowed-once |
人批准了这一次 | 返回更宽模式,只被发起申请的这次调用消费 |
rejected |
人明确拒绝 | 抛错,文案要求停下解释而不是想办法绕开 |
cancelled |
弹窗被取消 | 抛错,与拒绝分开报告 |
unavailable |
没有可用的审批通道 | 抛错,绝不无审批放行 |
请求重复当前模式时直接返回,不惊动人;裁决也从不写进会话状态,下一次调用仍按原有效模式执行(dsh-sandbox/lib/index.js:118-120 给出拒绝与取消的原文)。与会话模式切换的区别是:切换持久,升权只作用于该次调用。
反模式:把「批准」当成解锁后的常驻状态。一次性批准既不改变会话模式,也不被后续调用继承;模型在下一个调用里继续按更宽模式行动会重新被拒。当常驻状态实现,等于用一次点击换掉整个沙箱。
拒绝标记与升权提示
拒绝必须让模型认得出、也知道下一步。两族强制能力共用同一条拒绝标记:[sandbox: file access denied under <mode> mode](dsh-sandbox/lib/index.js:65),内核拒了 bash 的文件效果、进程内围栏拒了文件修改,模型看到的是同一种事实。同一条结果里还会追加升权提示(dsh-sandbox/lib/index.js:77),它带一个 family 名词------bash 用 command,文件工具用 operation------把「重试这一次、带最窄够用的更宽模式加理由」放在决策点上,而不指望模型记得工具说明。
text
tool → 模型 [sandbox: file access denied under workspace-write mode]
[sandbox: escalation available --- retry this exact operation once with
sandbox_permissions (the narrowest wider mode that suffices) + justification;
the approval prompt asks the user]
模型 → tool { "sandbox_permissions": "danger-full-access",
"justification": "需要在 /etc/hosts 追加一条本地域名映射,工作区之外的单点修改" }
审批 → 人 Allow this operation with danger-full-access permissions: ...
人 允许一次(allowed-once)
tool 重试 同一调用以更宽模式执行,写入成功;下一次调用回到 workspace-write
最后一段路必须问人
最后一段路必须真的问人。approveEscalation 只接受一个结构化的求助函数,而不依赖审批服务类型:工具层持有 agent、call id 与工具名,把 ctx.approval.request(...) 闭包传进去,沙箱包因此不依赖审批包与 agent 包,bash 与 fs 两族的审批顺序和错误文案也不会各自漂移。审批请求带工具名、调用 id、理由与中英双语文案,人看到的是「允许什么权限、为了什么」。
失败模式:审批通道缺失时降级放行。无审批服务、调用没有可路由的 agent、或结果不在四种之内,都应当抛错------最后一种由
assertNever兜住,不让未知结果被当成批准。任何「审批失败就按更宽处理」的写法,等于让升权机制在关键时刻自动失效。
于是越界从自由发挥变成闭环:拒绝留下可识别的事实,提示给出唯一被许可的下一步,申请带理由,批准由人一次性给出。沙箱的可审计性不在拒绝了多少次,而在每次放开都能追到一条理由和一次点击。
双能力族一致性:fs 围栏与 shell runner 如何共用同一可写根集合
一次会话里有两族写入能力:shell 族由内核执行边界,fs 族在进程内查策略后调用底层写入。两族若各自解释同一个模式名,沙箱就成了两个同名的策略。
一条漏洞的形状
设 shell 的可写清单是「工作区根 + /tmp」,fs 工具只有工作区根:bash 能写 /tmp/build.o,write 工具却被拒;反过来 fs 更宽,模型就能用工具写进 shell 进不去的地方。前者是「沙箱乱拒」,后者是「沙箱有缺口」,同一个错误:允许清单有两份实现。模式语义只能有一处定义。
writableRoots 是唯一派生源
DSH 把这唯一定义放在 dsh-sandbox 的 roots 模块:Seatbelt profile 与 fs 围栏都从这里派生允许清单,所以「写工具写不了 /tmp 而 bash 能」的不对称无法产生(dsh-sandbox/lib/index.js:128-167)。
每个根都规范化成解析后路径,因为两族都按解析形态比较;解析失败时原样返回,缺失的根匹配不到任何东西------保守,也不凭空授予未命名的路径。
ts
export function writableRoots(policy: SandboxPolicy): string[] {
if (policy.mode !== "workspace-write") return [];
return [...new Set([
canonicalPath(policy.workspaceRoot),
canonicalPath("/tmp"),
canonicalPath(os.tmpdir()),
])];
}
read-only 返回空数组,danger-full-access 不参与派生,两族都跳过检查。
两族不是同一种边界
一致性指「同一个可写根集合」,不是「同一种执行强度」。fs 围栏是进程内策略检查:danger-full-access 放行;read-only 抛 FS_SANDBOX_DENIED(dsh-fs-sandbox/lib/index.js:157);workspace-write 重新解析目标、判它是否落在可写根下,不满足抛同一错误码(dsh-fs-sandbox/lib/index.js:164),满足则返回解析后的目标,写入正好落在被检查的对象上。
| 维度 | fs 围栏 | shell runner |
|---|---|---|
| 边界位置 | 进程内策略检查 | 内核机制 |
| 可写清单来源 | writableRoots |
writableRoots |
| 拦截时机 | 每次变更之前 | 进程整个生命期 |
TOCTOU 只能缩小,不能关闭
进程内检查天然有窗口:检查与写入之间的时间差。DSH 在写入前重新规范化(resolve 对最深的已存在祖先做 realpath),用解析后的新目标执行写入,于是没有「检查 A、写 B」的分叉;并发替换符号链接只会让规范化得到新值。
但它只是缩小窗口,不是关闭:内核边界没有这个窗口,进程内检查永远有。fs 围栏是「同一策略的进程内实现」,不是「安全边界」。
失败模式:把 fs 围栏当安全边界。靠「write 工具会被拒」保护工作区外的数据,但依赖脚本、编辑器插件、模型自己写的 bash 都绕过进程内检查,兜底只能在内核一侧。
拒绝必须同形
两族拒绝的呈现必须一致,否则模型学不会「这个模式不让做这件事」。fs 侧把 FS_SANDBOX_DENIED 映射成 sandboxDenialMarker(mode) 加 escalationHintMarker("operation"),保留结构化错误码(dsh-tool-fs/lib/index.js:1172-1174);标记与 shell 侧模板同源(dsh-sandbox/lib/index.js:65、dsh-sandbox/lib/index.js:77)。
ts
mapError(error, policy) {
if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
return new FsError(
`${sandboxDenialMarker(policy.mode)}\n${escalationHintMarker("operation")}`,
"FS_SANDBOX_DENIED",
);
}
反模式:两族各写一套拒绝文案。bash 说「file access denied」,write 说「permission denied by policy」,模型得出两个结论,于是一件事换条路重试十次。
两条能力族的威胁模型差异
| 威胁 | fs 围栏能防 | shell runner 能防 |
|---|---|---|
| 模型误写工作区外路径 | 是 | 是 |
| 拉取并直接执行远端安装器 | 否 | 是(内核) |
| 符号链接穿越 | 部分(规范化缩小) | 是(内核解析) |
| 依赖包安装脚本 | 否 | 是 |
两族必须同时存在:fs 围栏便宜、能给出结构化错误,却守不住子进程;shell runner 守得住子进程,但只能从 stderr 方言推断拒绝是策略拒绝还是 runner 故障(见第 10 章)。
落地清单
- 模式语义只写一次,放在共同依赖的最底层。
- 允许清单规范化;解析失败返回原样,不兜底。
- 进程内检查返回被检查的目标,写入用它。
- 拒绝用结构化码,文案由一处模板生成。
- 进程内实现自报「非内核边界」。
- 差异只在执行强度,不在可写集合。
- 一致性别靠评审,靠共用函数与测试。
- 升权入口同形,别让模型学会换工具就过。
结语
一个 Agent 沙箱的可信度,不取决于内核机制有多硬,而取决于同一个模式名在各条执行路径上是否意味着同一件事。DSH 用 writableRoots 把语义钉在一处,用同形拒绝把反馈钉在一处;其余部分------bwrap、Landlock、Seatbelt、受限令牌------都只是把这个语义翻译成各平台可执行的形态。模式是声明,完整度是事实,两族一致是前提。