ARTICLE · 1247872
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 ruleset | main.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。有收获就点个关注。















































































