Agent 沙箱实现原理:拆解 DeepSeek Harness 的三层沙箱架构与三平台后端

沙箱是 Agent 从「会聊天」走到「能干活」时绕不开的那道墙。模型一旦拿到 shell,它能做的事和它可能做错的事就是同一个集合:删掉不该删的目录、改掉不该改的配置、被一段注入的文本牵着去读凭据再把结果写回来。提示词里的「请小心」不是权限,真正的边界只能由宿主在进程创建那一刻施加。

这篇长文拆开源码讲清一套 Agent 沙箱的实现原理,并以 DeepSeek Harness(下称 DSH)的沙箱体系作为参照方案:它把沙箱拆成三个模式、一个逐调用策略层、一个平台无关的执行 seam,以及 Linux(bubblewrap / Landlock)、macOS(Seatbelt)、Windows(ACL 受限令牌)三套后端。全书按「为什么---怎么设计---各平台怎么实现---边界在哪---怎么失败---怎么升权」的顺序推进,每章都有可对照的接口、常量与真实行号,你可以按章节顺序拼出一套能落地的沙箱。

全文共 12 章:

  1. 为什么 Agent 必须自建沙箱
  2. 三种模式与能力矩阵
  3. 策略层解析:三源优先级
  4. confine() 执行路径
  5. Linux 后端:bubblewrap 与 Landlock
  6. macOS 后端:Seatbelt 与 SBPL
  7. Windows 后端:受限令牌与 Low 完整性
  8. Windows 的四个边界
  9. 强制执行完整度:full 与 partial
  10. 失败关闭与错误分类
  11. 升权协商
  12. 双能力族一致性与落地清单

为什么 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 / 受限令牌

一个最小可用沙箱的五个部件

  1. 策略源:部署默认、会话模式事件、逐调用批准,合成一个 {mode, workspaceRoot}。
  2. seam:只暴露 resolve() 与 confine() 两个动词。
  3. runner:argv 前缀程序,Linux 侧链为 bwrap 与 landlock,Windows 侧为 windows-acl(dsh-sandbox-local/lib/index.js:174-176)。
  4. 强制执行完整度:full 或 partial 必须如实上报,windows-acl 即为 partial(dsh-sandbox-local/lib/index.js:188-191)。
  5. 故障关闭(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 章)。

落地清单

  1. 模式语义只写一次,放在共同依赖的最底层。
  2. 允许清单规范化;解析失败返回原样,不兜底。
  3. 进程内检查返回被检查的目标,写入用它。
  4. 拒绝用结构化码,文案由一处模板生成。
  5. 进程内实现自报「非内核边界」。
  6. 差异只在执行强度,不在可写集合。
  7. 一致性别靠评审,靠共用函数与测试。
  8. 升权入口同形,别让模型学会换工具就过。

结语

一个 Agent 沙箱的可信度,不取决于内核机制有多硬,而取决于同一个模式名在各条执行路径上是否意味着同一件事。DSH 用 writableRoots 把语义钉在一处,用同形拒绝把反馈钉在一处;其余部分------bwrap、Landlock、Seatbelt、受限令牌------都只是把这个语义翻译成各平台可执行的形态。模式是声明,完整度是事实,两族一致是前提。

相关推荐
tianyuanwo1 小时前
Prometheus 服务部署指南:从架构原理到生产级落地
架构·prometheus
推送者1 小时前
大将怪兽桌宠教程:从一张正脸照到本地透明桌宠的工程化路线
人工智能·大将怪兽桌宠教程
shennv8881 小时前
2026年国内如何开通ChatGPT Plus和Pro?订阅方式、Codex额度及常见问题整理
人工智能·chatgpt
u1301301 小时前
GitHub 热榜项目:日榜(2026-10-25)
人工智能·github
尤水就下1 小时前
🔥说一说在线文档的 Block 架构是如何实现的
前端·javascript·架构
机器学习之心1 小时前
电池SOH估计和RUL预测,五个时间特征 + 基于GPR高斯过程回归的锂电池健康状态估计和剩余寿命预测
人工智能·数据挖掘·回归·锂电池健康状态估计·剩余寿命预测
代码方舟1 小时前
零信任架构实战:基于天远企业二要素验证构建自动化小微快贷法人资质核验网关
运维·人工智能·架构·自动化
tellmewhoisi1 小时前
深度学习13:反向传播4:输出层Softmax-with-Loss
人工智能·深度学习
hahaha60161 小时前
Unsharp Masking算法综述
图像处理·人工智能·计算机视觉