夜雨聆风学习资料网

ARTICLE · 1247872

DeepSeek-Harness 源码专题(8):沙箱不写一句禁令,让内核自己当狱警

DeepSeek-Harness 源码专题(8):沙箱不写一句禁令,让内核自己当狱警

摘要:系统提示里没有一句禁令:策略层只告知三档模式,拦截交给内核。Linux 链 bwrap 优先、Landlock 兜底,298 行 C 自我监禁随 exec 继承,全链 fail-closed。拒绝走 stderr 方言取证,升级严格变宽、执行前必过用户审批。

收官日。47 个 deprecated 调用清到零,模型提出跑全量测试做最终验证。开工之前我们先干了一件事:往会话日志写了一条 sandbox/mode 事件,把这场收官会话锁进 read-only。上一篇《正在干的 agent 递条子,它凭什么不停手》里,它差点顺手改掉 fixtures 里的快照;这次把它的每条命令都关进只读的笼子,是同一件事的下半场。

测试命令跑了几秒就炸了。bash 报 Permission denied,退出码非零,工具结果里多出一行谁也没配置过的标记:[sandbox: file access denied under read-only mode]。

奇怪的地方在这。我们翻遍这个会话的系统提示,没有一句「不许写文件」;dsh 也不在 bash 外面挂任何改写命令的钩子。那这个「不」是谁说的?

先给结论:说「不」的不是提示词,是 Linux 内核。dsh 把文件效果拆成三层:策略层负责告知模型当前模式,执行层把每条 bash 包进一个 runner,runner 在命令起跑前请内核上锁,文件工具另有一道进程内围栏。提示词从头到尾没有执法权。我们把这三层走完,最后会看到一件事:连「快照别动」这句话,在三档模式的词汇表里都没有位置。

一、提示词那层只告知,不执法

三档模式(read-only / workspace-write / danger-full-access)怎么落到一次工具调用上?策略服务每次调用现解析,全部逻辑在这(sandbox-policy/src/index.ts:135-142):

  resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {    const { session } = request    return {      mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,      workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),      ...session === undefined ? {} : { sessionId: session.id },    }  }

这段是模式的解析处(sandbox-policy/src/index.ts:135-142)。优先级从左到右排:调用点显式指定的模式、会话日志里最后一条 sandbox/mode 事件、部署默认。我们锁只读,写的就是中间那级事件;默认值也是 read-only(schema 里 .default('read-only'),:94,注释称之为 fail-safe:想要可写工作区的部署必须显式开口子)。

模型知道自己在笼子里吗?知道,但那段提示词的方向会让你意外。sandbox:policy 段(index.ts:112-123)在 read-only 下注入的原文(:41):

      return 'Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.'

这段是系统提示里的全部沙箱内容(sandbox-policy/src/index.ts:41)。注意最后半句:别只因为读到了策略就不敢干活,该试就试,然后跟着拒绝走。没有禁令句式,只有对现状的陈述加一句行动指引。告知层的全部立场就这些。

那真正拦命令的在哪儿?bash 一旦 spawn 出来就是一个操作系统进程,TypeScript 里的一个字段拦不住它。拦截必须发生在进程外面,这是 runner 层的事。

二、Linux 上第一顺位其实是容器

先修一个流传很广的说法:「dsh 不用容器,用的是 Landlock」。Linux 链里两个都有,而且容器在前。教程篇《不起容器不要特权,sandbox 凭什么管住每条命令》引过 PLATFORM_CHAINS 全表(sandbox-local/src/index.ts:159-166):linux 是 ['bwrap', 'landlock'],darwin 是 ['seatbelt'],win32 是 ['windows-acl']。bwrap 是无特权 user namespace 加 mount 的轻容器;Landlock 是纯 syscall 的内核 LSM,不需要 mount 也不需要 namespace。链怎么仲裁、怎么兜底,看仲裁的 body(:500-510):

    const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []    const [first, ...rest] = chain    if (first === undefined) return 'unavailable'    // A sole candidate needs no arbitration; its execution-time refusal still fails closed.    if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }    for (const runner of chain) {      const enforcement = this.probeRunner(runner)      if (enforcement !== 'unusable') return { runner, enforcement }    }    return 'unavailable'  }

这段是链的仲裁逻辑(sandbox-local/src/index.ts:500-510,函数签名略)。独苗不探测直接选,执行期自己会 fail-closed;多候选才逐格探测,第一个能用的中选;全灭返回 unavailable。结论缓存在 provider 里,只探一次。

bwrap 那格的探测是真的跑一次容器(sandbox-local/src/index.ts:68-74):

function defaultProbeBwrap(timeoutMs: number): boolean {  const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {    timeout: timeoutMs,    stdio: 'ignore',  })  return probe.status === 0}

这段是链上第一格的功能探测(sandbox-local/src/index.ts:68-74)。用 read-only profile 包一个 true,退出零才算这格可用。不查版本号,不查二进制存在性,直接问内核:这套 profile 你真的执行吗。

我们的收官验证跑在一台加固过的宿主机上:unprivileged user namespace 被管理员禁了,bwrap 起不来,第一格探测失败,链走到第二格 Landlock。要是两格全灭呢?confine() 抛 SandboxUnavailableError,报错原文里有句硬话:refusing to run the command unconfined(拒绝不装箱就跑,sandbox/src/index.ts:134-138)。seam 基类把这条立场写进了契约(:153-156):静默的裸跑透传被明文禁止。命令跑不了可以报错,装没装成不能装作没看见。

链选中了 Landlock。一个连 mount 都不用的方案,凭什么当狱警?

三、298 行 C 的自我监禁

Landlock 有个天生的限制:一个进程只能限制自己,监禁不了别人。dsh 的 launcher 把这个限制翻成了通用方案:先给自己安装 ruleset,再 exec 目标命令。ruleset 随 execve 继承,于是被包的 bash 和它派生的全部子孙都在锁里,发起 spawn 的 Node 主进程毫发无损。launcher 是一个 298 行的自包含 C 文件(main.c),musl 静态编译,对外只认 --ro 、--rw 、-- 和 --probe 四种记号。

锁的内容是一个位掩码词汇表。launcher 没有包含内核头文件,自己定义了全部 UAPI(main.c:71-88,节选):

#define LL_FS_EXECUTE     (UINT64_C(1) << 0)  /* ABI 1 */#define LL_FS_WRITE_FILE  (UINT64_C(1) << 1)#define LL_FS_READ_FILE   (UINT64_C(1) << 2)#define LL_FS_READ_DIR    (UINT64_C(1) << 3)// …(ABI 1 共 13 位,中间略)#define LL_FS_TRUNCATE    (UINT64_C(1) << 14) /* ABI 3 (ABI 4 added TCP bits only) */#define LL_FS_IOCTL_DEV   (UINT64_C(1) << 15) /* ABI 5 */

这段是 16 个文件访问位的定义(main.c:71-88)。能不能执行、能不能写、能不能建目录、能不能删文件,各占一位,注释按引入它们的内核 ABI 分组。自绘 UAPI 的理由写在文件头(:50-57):内核 ABI 是稳定契约,自己定义既不依赖工具链头文件的年代,也顺手当审计记录。再看 :85 那行括号:ABI 4 只加了 TCP 位,而 launcher 没用它。网络从来不在锁的词汇表里,这条边界后面还要回来对账。

上锁的动作只有两步,但顺序钉死(main.c:254-259):

  if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {    return fail(「landlock ruleset error」, strerror(errno));  }  if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {    return fail(「landlock ruleset error」, strerror(errno));  }

这段是落锁处(main.c:254-259)。先 NO_NEW_PRIVS 再 restrict_self:前者是非特权进程调用 restrict 的前提,顺带封死沙箱内借 setuid 提权的路;后者把 ruleset 压进当前线程。教程篇引过 restrict 那一行的缩写,这里的重点是 prctl 必须先过,顺序反了都不成立。

锁建不成怎么办?全部致命路径共用一个出口(main.c:107-112):

/* * Every fatal launcher error prints `landlock-run: <message>` to stderr * and exits 125 — a code the wrapped command itself is unlikely to use, so * the executor can tell launcher failures from command failures. */#define EXIT_LAUNCHER_FAILURE 125

这段是失败语义的契约(main.c:107-112)。launcher 级错误一律带 landlock-run: 前缀打到 stderr、退出 125。被包的命令不太会用这个码,执行器就永远分得清「锁没建成,命令没跑」和「命令自己失败」这两种非零退出。

四、策略翻译成方言,只要七行

三档模式怎么变成 --ro/--rw 旗标?翻译函数 landlockProfileArgs(profiles.ts:30-36)教程篇逐行拆过:read-only 等于 --ro / 加 --rw /dev/null,全盘可读可执行,全机器唯一可写的是 /dev/null;workspace-write 在这之上追加 /tmp 和工作区根。这里补一个教程篇没拆的问题:/dev/null 不是目录,内核会拒绝目录专属的访问位,那这条授权怎么活下来的?答案是一次位裁剪(main.c:203-209):

  /* The kernel rejects directory-only accesses on a non-directory rule   * (EINVAL), so a file grant keeps only the file-compatible bits — how the   * `--rw /dev/null` grant works. */  struct stat st;  if (fstat(path_fd, &st) == 0 && !S_ISDIR(st.st_mode)) {    access &= LL_FS_EXECUTE | LL_FS_WRITE_FILE | LL_FS_READ_FILE | LL_FS_TRUNCATE | LL_FS_IOCTL_DEV;  }

这段是非目录授权的裁位(main.c:203-209)。fstat 发现授权对象不是目录,就把 make_dir、remove_dir 这类目录专属位从请求里剔掉,只留 execute、write、read、truncate、ioctl 五个文件兼容位。少了这步,整条授权被内核判 EINVAL,launcher 退出 125,命令根本起不来。

我们的测试就死在这套翻译的字面意义上。测试器起手要建缓存目录,缓存在工作区也好、在 /tmp 也好,read-only 模式下两个都不在白名单里,第一条写请求就被内核用 EACCES 顶了回来。到这里行为完全符合设计:词汇表说全机器只准写 /dev/null,内核照办了。

顺带两句对照。bwrap 版翻译用 --ro-bind / / 加 --tmpfs /tmp 表达同一件事,同义不同方言;文件工具那道进程内围栏的可写根与 Seatbelt 同源于一个 writableRoots()(roots.ts:52-55),「bash 能写而文件工具不能写」的两头漂移被结构性排除。方言层都是小账,要紧的问题只剩一个:这台机器的链,凭什么信内核真的在执行锁?

五、探测:真的上一次锁才算数

JS 侧的探测(entry/index.ts:116-127,节选):

  const result = spawnSync(launcher, ['--probe'], {    timeout: options.timeoutMs ?? 2000,    encoding: 'utf8',    stdio: ['ignore', 'pipe', 'ignore'],  })  if (result.status !== 0) return 'unusable'  return /partially enforced/.test(result.stdout) ? 'partial' : 'full'

这段是 Landlock 格的探测实现(entry/index.ts:116-127)。spawn launcher 自己的 --probe,退出非零算 unusable,输出里带 partially enforced 算 partial,否则 full。

--probe 在 C 侧干的事才是重点(main.c:269-283):在一个短命进程里真的构建并 enforce 一个 maximal ruleset,打一行 landlock: fully enforced 就退出。文件注释把理由写死了,原话是 actually restricting is the only honest signal,翻译过来:真的 restrict 一次才是唯一可信的信号。--version 式的检查会漏掉「有 syscall 但内核拒绝执行」的主机,所以版本号在这里没有发言权。

unusable 的构成拆开看:缺二进制、架构不对、超时、内核没有 Landlock,全部归进同一个词。类型注释明说这是刻意的(entry/index.ts:33-41):这几种情况消费者手里的答案一样,都是不信任这个 launcher,区分它们没有价值。这个包还有一条安全红线(:12-14):全模块没有任何环境变量覆盖,用哪个二进制去监禁进程,绝不能由环境变量说了算,测试注入只走函数参数。

partial 是唯一放行的降级:老内核 ABI 管不住全部 16 位,launcher 照样上锁,只在 stderr 多报一行 partial enforcement(main.c:288-293)。这行报告后面要在分类层被单独摘出去,不能误判成锁坏了。

六、拒绝的两张脸

命令跑在锁里,写缓存的请求被内核顶回。这个「不」要回到模型眼前,先过一道分类:是「锁生效了,拦得对」,还是「锁压根没建成,命令没跑」?两种情况的善后完全不同。判定顺序在执行器里(bash-sandbox/src/index.ts:107-113):

    // Runner failure outranks denial because the command did not run. Carry    // the matched fatal line, not an informational line that preceded it.    const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)    if (runnerFailure !== undefined) {      throw new SandboxUnavailableError(mode, runnerFailure.detail)    }    return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }

这段是拒绝与 runner 失败的分岔处(bash-sandbox/src/index.ts:107-113)。先按三段式规则判 runner 失败(exit-code 门、信息行排除、致命签名),退出 125 加 stderr 里有 landlock-run: 致命行,说明锁没建成,抛 SANDBOX_UNAVAILABLE,这个码经结构化错误通道穿透 tool/result(sandbox/src/index.ts:118-124);判完 runner 失败才轮到拒绝:退出非零加方言匹配,说明锁建成了、拦得对,结果标 denied。

方言匹配靠一张按 runner 分的签名表。landlock 底下跑出的 bash 报 Permission denied,bwrap 底下是 read-only file system,Seatbelt 底下是 operation not permitted,全表(DENIAL_SIGNATURES,sandbox-local/src/index.ts:205-213)教程篇有,这里不重复。接口注释解释了为什么按 runner 分别匹配而不取并集(sandbox/src/index.ts:100-107):并集会声称某个 backend 从不产生的拒绝,匹配就失真了。顺带一提,partial enforcement 那行报告就在三段式的「信息行排除」里被摘掉(RUNNER_FAILURE_RULES.landlock,sandbox-local/src/index.ts:233-237),上一节埋的问题在这收口。

文件工具那条路不走 stderr。进程内围栏确切知道自己拒绝了什么:read-only 下写请求直接抛结构化的 FS_SANDBOX_DENIED(fs-sandbox/src/index.ts:130-131),不需要从文本反推,checkedTarget 的核心分岔教程篇拆过。两条路最后在模型眼前汇成同一句话,拼装处(tool-bash/src/render.ts:45-51):

  if (result.sandbox?.denied) {    markers.push(sandboxDenialMarker(result.sandbox.mode))    // Hint only when the composition exposes escalation, before the final exit marker.    if (escalationModes.length > 0) {      markers.push(escalationHintMarker('command'))    }  }

这段是拒绝标记的拼装(tool-bash/src/render.ts:45-51)。denied 为真时附上标记行和升级提示。模型看到的完整反馈是:命令输出、[sandbox: file access denied under read-only mode]、一行升级提示、退出码。

七、升级梯子:拒绝之后不是死路

那行升级提示值得整句读(escalation.ts:85):retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user。原命令重试一次,带最窄的够用宽档,带一句理由,审批框会去问用户。

能升到哪一档是张查表(WIDER_MODES,escalation.ts:28-31,教程篇引过全表):read-only 能升 workspace-write 或 danger-full-access,workspace-write 只能升 danger-full-access。每一跳必须严格变宽,原地重申或越级变窄在第一道检查就被拒掉,连人都不问。审批的编排是执行前的(approveEscalation,escalation.ts:157-189):先验严格变宽,再验审批服务在不在、调用有没有 agent,三道都过才真正弹审批。三个坏结局各有一句报错原文,rejected 那条(:180-184,节选):

  switch (outcome) {    // The schema enum already pinned `mode` to the closed target vocabulary;    // the check above proved it is strictly wider.    case 'allowed-once': return mode as SandboxMode    case 'rejected': throw new Error(`the user rejected escalating this ${subject} to 「${mode}」`)  // …(cancelled/unavailable 分支与 switch 收尾略)

这段是审批结局的分流(escalation.ts:180-184)。批准返回授权模式,盖在且仅盖在这一次调用上;拒绝抛错,工具注册表把错误转成 isError 的结果,什么都没跑过。

回到收官现场。模型按提示重试了,参数里带着 sandbox_permissions: workspace-write 和一句 justification:跑全量验证需要写测试缓存和产物。审批框弹到我们面前。workspace-write 开的是整个工作区的写权限,fixtures 里那些故意保持旧行为的快照就躺在里面。我们点了拒绝。

拒绝落成 isError 的工具结果进了会话日志,重放时看得到模型要过什么、我们拒了什么。模型接下来换了个跑法:typecheck 和 lint(不带 --fix)是纯读操作,在只读锁下全绿;全量行为测试它没再要权限,列了两个方案(升一次 workspace-write,或者我们在自己终端跑)等人定。danger-full-access 那道逃生门真实存在(bash-sandbox/src/index.ts:91-94),但模型想拿到它得跨两级宽、过同一个人。

词汇表到此就到头了:三档里没有「工作区可写、快照别动」这一档。模式能表达的最细粒度就是三选一,「快照别动」在机制里没有挂点,它只能活在审批框弹出那一刻,由人来持有。

八、对账

问题答案落点
提示词拦不拦不拦,sandbox:policy 段只陈述现状并鼓励照常尝试sandbox-policy/src/index.ts:41、112-123
只读怎么生效调用显式 > 会话 sandbox/mode 事件 > 部署默认resolve() :135-142
Linux 用什么链:bwrap 优先,Landlock 兜底,全灭不跑chainVerdict :499-510;SandboxUnavailableError :131-144
Landlock 凭什么自我监禁 + execve 继承,allow-list 默认全禁main.c:230-262
模式怎么变成旗标7 行翻译;/dev/null 靠非目录裁位活下来profiles.ts:30-36;main.c:203-209
凭什么信内核在锁--probe 真的 enforce 一次 maximal rulesetmain.c:269-283;entry/index.ts:116-127
拒绝怎么回传runner 失败先判、方言匹配后判;文件工具走结构化错误bash-sandbox :107-113;fs-sandbox :130-131
拒绝之后严格变宽梯,执行前用户审批,授权只盖一次调用escalation.ts:28-31、157-189

边界照例如实说。网络零覆盖:read-only 下进程照样能出网,词汇表声明网络不在管辖内(sandbox/src/index.ts:27),ABI 4 的 TCP 位 launcher 刻意未用(main.c:85)。--ro / 授予全盘读,私钥文件一样可读。bash 面的 denied 终究是对 stderr 文本的推断,一条恰好打印了 permission denied 的失败命令会被误报,exit 码门控收窄了这面,没有消除。文件效果由内核强制,剩下的部分靠词汇表和审批兜着,这是整套设计的账。

结语

回到收官那天。全量行为测试没有在锁内跑:模型的纯读检查全绿,行为测试落回我们自己的终端,快照一个没动,47 个 deprecated 调用清零。那台禁了 user namespace 的宿主机上,链的第一格探测失败,第二格的 298 行 C 先给自己上了锁,再把整条命令 exec 进去,--ro / 在前,--rw /dev/null 在后。

专题线 8 篇到此走完,DeepSeek-Harness 系列三条线(教程 13、深读 32、专题 8)共 53 篇收官。你的 agent 第一次伸手要 workspace-write 时,你批了吗?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注。

相关学习资料