1
00:00:00,100 --> 00:00:06,432
本内容改编自小山学堂《学 AI 产品，从入门到精通》，为二次演绎配音版。

2
00:00:06,382 --> 00:00:09,206
这是 DeepSeek Harness 系列的又一集。

3
00:00:09,206 --> 00:00:11,995
这一集只讲一件事，沙箱。

4
00:00:11,995 --> 00:00:17,139
但讲的不是沙箱怎么配置，而是沙箱的边界应该画在哪一层。

5
00:00:17,139 --> 00:00:20,000
为什么这个问题值得单独一集。

6
00:00:20,000 --> 00:00:33,317
因为大部分人对沙箱的理解是「给命令套一层限制」，这一层理解在只跑本机命令的时候够用，一旦你要跑进程内工具、要换容器、要把执行整体搬到远端，它就不够用了。

7
00:00:33,317 --> 00:00:41,262
你会发现有的操作根本不 spawn 子进程，套不上；有的隔离压根不是同一层的事，套错了白费力气。

8
00:00:41,262 --> 00:00:43,040
这一集分四块。

9
00:00:43,040 --> 00:00:54,987
先讲三层各管什么，再讲策略为什么跟着调用走，然后讲拒绝和故障这两件事怎么区分，最后讲把整个执行世界搬到远端到底要改多少代码。

10
00:00:54,987 --> 00:00:57,319
收五条能带走的原则。

11
00:00:57,319 --> 00:00:59,975
先把一个常见的误会掐掉。

12
00:00:59,975 --> 00:01:05,204
很多人一听到沙箱，脑子里浮现的是「能不能写文件」这一件事。

13
00:01:05,204 --> 00:01:12,896
但真正的边界问题比这宽得多，你要限制的是文件、进程、网络，还是整个执行环境？

14
00:01:12,896 --> 00:01:17,968
这四种东西的隔离机制完全不同，生效的位置也完全不同。

15
00:01:17,968 --> 00:01:22,608
把它们当成一回事，是绝大多数沙箱设计翻车的起点。

16
00:01:22,608 --> 00:01:27,596
再给一个判断标准，帮你在本集后面的内容里随时自检。

17
00:01:27,596 --> 00:01:34,627
判断一个隔离手段落在哪一层，只看一件事，它拦住的动作发生在哪个位置。

18
00:01:34,627 --> 00:01:37,668
拦在子进程启动时，是第一层。

19
00:01:37,668 --> 00:01:41,201
拦在进程内的函数调用里，是第二层。

20
00:01:41,201 --> 00:01:44,002
拦在环境本身，是第三层。

21
00:01:44,002 --> 00:01:49,362
位置不同，能拦住的攻击面就不同，别指望某一层能包打天下。

22
00:01:49,362 --> 00:01:55,709
顺带提醒，本集的行号都是二零二六年八月十三日核对源码的结果。

23
00:01:55,852 --> 00:02:00,393
先给结论，这里把沙箱拆成了三层，各管各的。

24
00:02:00,343 --> 00:02:02,867
第一层是共内核的子进程。

25
00:02:02,867 --> 00:02:08,672
受限这个函数只做一件事，把你要起的命令参数包一层限制器。

26
00:02:08,672 --> 00:02:14,574
Linux 用 bwrap 或者 Landlock，macOS 用 Seatbelt，Windows 用访问控制受限令牌。

27
00:02:14,574 --> 00:02:18,913
前提是子进程与宿主共享文件系统和内核。

28
00:02:18,913 --> 00:02:21,232
第二层是进程内的工具。

29
00:02:21,232 --> 00:02:28,576
读取、写入、编辑这些操作根本不起子进程，给它们包一层命令参数没有任何意义。

30
00:02:28,576 --> 00:02:38,504
所以这一层在受信代码里直接做策略检查，规范化路径、验证包含关系，拒绝的时候抛一个结构化的拒绝错误。

31
00:02:38,504 --> 00:02:43,336
关键是它自己知道拒绝了什么，不用去猜内核报错的文本。

32
00:02:43,336 --> 00:02:46,136
第三层是整个执行世界。

33
00:02:46,136 --> 00:02:54,405
容器、微型虚拟机、远程执行，属于整个能力接缝的同级替换，压根轮不到 ctx.sandbox 出面。

34
00:02:54,405 --> 00:03:00,319
想换到远端，换掉文件系统和子进程这两个插口的实现就行。

35
00:03:00,319 --> 00:03:05,932
把这三层分开的意义在于，你不会再拿一把锤子去敲三种钉子。

36
00:03:05,932 --> 00:03:13,143
该包命令参数的包命令参数，该在进程里做检查的做检查，该整体替换的整体替换。

37
00:03:13,143 --> 00:03:16,509
再补一条为什么第三层要单独算。

38
00:03:16,509 --> 00:03:21,244
前面两层都是在现有环境里做减法，把能力收窄。

39
00:03:21,244 --> 00:03:25,956
第三层不是减法，它是整体替换，给你一个全新的环境。

40
00:03:25,956 --> 00:03:32,266
减法做多了会处处碰壁，整体替换则是在另一个世界里重新给权限。

41
00:03:32,266 --> 00:03:39,033
这两种思路的适用场合完全不同，想限制就做减法，想隔离就换环境。

42
00:03:39,172 --> 00:03:43,052
第一层有个特别容易想错的点，值得单独讲。

43
00:03:43,002 --> 00:03:49,480
沙箱策略是每次调用随身携带的参数，从来不焊死成提供方的全局状态。

44
00:03:49,480 --> 00:03:57,966
策略解析函数为每一次能力调用解析一份完整策略，里面包含模式、工作区根目录和会话标识。

45
00:03:57,966 --> 00:04:05,646
优先级也很清楚，显式批准的提权模式压过会话设置，会话设置压过部署默认值。

46
00:04:05,646 --> 00:04:07,557
这么设计换来什么。

47
00:04:07,557 --> 00:04:16,259
同一个进程里开两个会话，一个只读一个工作区可写，它们向同一个提供方要不同的边界，互不干扰。

48
00:04:16,259 --> 00:04:21,752
提供方根本不需要任何切换状态的动作，因为状态从来不在它身上。

49
00:04:21,752 --> 00:04:29,684
批准过的提权重试也是同理，它只是一次带着更宽策略的新调用，提供方的状态一点没变。

50
00:04:29,684 --> 00:04:38,350
这一点很重要，凡是「临时放宽一点」的需求，如果只是换个参数，就不会留下任何需要事后清理的状态。

51
00:04:38,350 --> 00:04:42,653
临时权限最怕的，就是放宽容易、收紧忘了。

52
00:04:42,653 --> 00:04:46,103
再看优先级的排序，它也很讲究。

53
00:04:46,103 --> 00:04:51,656
显式批准的提权压过会话设置，会话设置压过部署默认值。

54
00:04:51,656 --> 00:04:57,269
这条链的含义是，越靠近某一次具体动作的意图，优先级越高。

55
00:04:57,269 --> 00:05:01,824
因为越具体的意图，信息越充分，判断也就越可信。

56
00:05:01,824 --> 00:05:08,158
部署默认值是给所有会话的一刀切，它当然应该被更具体的判断覆盖。

57
00:05:08,158 --> 00:05:13,350
反过来说，如果让部署默认值压过会话设置，会发生什么。

58
00:05:13,350 --> 00:05:22,485
用户明明已经在界面上选了工作区可写，管理员的一条默认值却把它按回只读，用户会以为是自己操作没生效。

59
00:05:22,485 --> 00:05:29,312
这类「谁也解释不清当前为什么是这个状态」的问题，根源往往就是优先级链排反了。

60
00:05:29,452 --> 00:05:32,791
Linux 的 Landlock 后端值得单独说一句。

61
00:05:32,741 --> 00:05:39,724
这里没用现成的包装库，自己写了一个启动器，大约三百行 C11，用 musl 静态链接。

62
00:05:39,724 --> 00:05:45,505
它的做法是，先在自己身上装好 Landlock 规则集，再 exec 目标命令。

63
00:05:45,505 --> 00:05:52,092
规则集跨 exec 调用继承，所以命令拉起的每一个子孙进程都在限制之下。

64
00:05:52,092 --> 00:05:58,498
这一点是关键，只限制顶层进程的设计，一遇到会 fork 子进程的命令就漏了。

65
00:05:58,498 --> 00:06:02,585
内核不支持的时候它直接退出，不跑命令。

66
00:06:02,585 --> 00:06:05,349
宁可不跑，也不假装在跑。

67
00:06:05,349 --> 00:06:10,325
探测函数返回三种状态，完整、部分、不可用。

68
00:06:10,325 --> 00:06:13,642
老内核的 ABI 只能报部分支持。

69
00:06:13,642 --> 00:06:22,609
二进制约定，包括参数语法、退出码、报告行，都锁定在一个单独的文档里，避免实现和调用方各说各话。

70
00:06:22,609 --> 00:06:25,481
还有个细节特别能体现严谨。

71
00:06:25,481 --> 00:06:32,885
启动器失败的约定退出码是一百二十五，但成功启动的子进程自己也可能退一百二十五。

72
00:06:32,885 --> 00:06:38,714
所以光看退出码永远定不了性，必须同时看到致命的诊断行。

73
00:06:38,714 --> 00:06:41,719
这就引出了下面那套分类器。

74
00:06:41,860 --> 00:06:49,165
受限函数返回的包装参数附带两套标准错误分类器，这是这一集最实用的一个设计。

75
00:06:49,115 --> 00:06:52,024
第一套查的是运行器故障规则。

76
00:06:52,024 --> 00:06:59,957
匹配到致命签名，说明限制器自己挂了，命令根本没跑，这要按沙箱基础设施故障上报。

77
00:06:59,957 --> 00:07:02,493
第二套查的是拒绝签名。

78
00:07:02,493 --> 00:07:07,661
匹配到它，说明沙箱工作正常，成功拦下了一次越界操作。

79
00:07:07,661 --> 00:07:09,368
顺序不能反。

80
00:07:09,368 --> 00:07:15,161
先剔除无害的通知行，再找致命签名，最后才匹配拒绝词。

81
00:07:15,161 --> 00:07:17,336
为什么顺序这么重要。

82
00:07:17,336 --> 00:07:26,219
因为一旦把故障误判成拒绝，你会以为系统好好拦住了一次越界，实际上命令压根没执行，问题被藏起来了。

83
00:07:26,219 --> 00:07:29,572
拒绝词还得按后端的方言来匹配。

84
00:07:29,572 --> 00:07:37,493
只读挂载在 bwrap 上报的是只读文件系统的文本，Landlock 报的是拒绝访问，Seatbelt 报的是操作不允许。

85
00:07:37,493 --> 00:07:42,913
消费方只匹配当前后端声明的那几个词，不用跨后端的并集。

86
00:07:42,913 --> 00:07:50,113
因为并集会认领某个后端根本不会产生的拒绝，把一次正常的失败误报成沙箱拦截。

87
00:07:50,113 --> 00:07:56,339
这里能提炼出一条经验，分类器的词表要窄而准，不要图省事求全。

88
00:07:56,339 --> 00:08:00,329
宽词表换来的是误报，误报比漏报更难发现。

89
00:08:00,329 --> 00:08:07,973
漏报至少还有机会在别处暴露，误报会让你在错误的方向上越走越远，还自以为一切正常。

90
00:08:07,973 --> 00:08:11,267
再补一层关于「无害通知行」的思考。

91
00:08:11,267 --> 00:08:13,550
为什么必须先剔除它们。

92
00:08:13,550 --> 00:08:22,036
因为很多工具在正常运行时也会往标准错误里写点东西，进度提示、弃用警告、调试信息都在里面。

93
00:08:22,036 --> 00:08:30,365
如果不先做剔除，这些无害文本迟早会撞上某个致命签名，把一次完全正常的执行判成故障。

94
00:08:30,365 --> 00:08:35,702
先做减法再做匹配，顺序里藏着的是对误报的恐惧。

95
00:08:35,860 --> 00:08:38,081
再说两个很硬的规矩。

96
00:08:38,031 --> 00:08:41,481
第一个，部分支持不能当完整支持用。

97
00:08:41,481 --> 00:08:45,291
强制执行的完整性是后端上报的事实。

98
00:08:45,291 --> 00:08:50,988
老的内核接口、Windows 访问控制里的一些缺口，都只能报部分支持。

99
00:08:50,988 --> 00:08:56,469
需要绝对边界的消费方必须显式处理这个区别，不能装看不见。

100
00:08:56,469 --> 00:09:02,923
第二个，也是这一节我最想让你记住的一条，没有后端就拒绝跑，绝不裸奔。

101
00:09:02,923 --> 00:09:07,827
在受限策略下，静默的无隔离透传永远不合法。

102
00:09:07,827 --> 00:09:16,853
要求了沙箱但一个后端都不可用时，受限函数直接抛出不可用错误，整个调用失败，命令一行都不会跑。

103
00:09:16,853 --> 00:09:27,034
这个错误类型本身就把立场写死了，错误消息的第一句是拒绝在无隔离状态下运行这条命令，随后逐平台给出修复建议。

104
00:09:27,034 --> 00:09:36,348
Linux 上装 bubblewrap 或者换一个开启强制的内核，macOS 上确认 sandbox-exec 可用，Windows 上确认受限令牌运行器能启动。

105
00:09:36,348 --> 00:09:43,151
实在不想修，就把消费方显式切到完全访问，把不设防这件事写在明面上。

106
00:09:43,151 --> 00:09:48,175
它携带错误码穿过结构化错误通道，一路进工具结果。

107
00:09:48,175 --> 00:09:56,529
调用方靠错误码就能区分「缺隔离」和「命令失败」这两种完全不同的结算，不用去猜标准错误的文本。

108
00:09:56,529 --> 00:10:03,752
一句话总结这里的取向，环境没准备好，答案是不跑，谁也别想偷偷降级成裸奔。

109
00:10:03,916 --> 00:10:07,087
回到第二层，看它的实现有多短。

110
00:10:07,037 --> 00:10:15,150
沙箱版的文件系统后端继承本地后端，只在两个变更操作前加了一道按调用解析的围栏。

111
00:10:15,150 --> 00:10:22,217
逻辑只有几行，完全访问模式直接放行，只读模式直接抛沙箱拒绝错误。

112
00:10:22,217 --> 00:10:25,198
有意思的是工作区可写那一支。

113
00:10:25,198 --> 00:10:29,873
它在做包含性检查之前，先重新规范化一次路径。

114
00:10:29,873 --> 00:10:31,868
为什么要多此一举。

115
00:10:31,868 --> 00:10:39,320
因为这专门防一种调包戏法，检查的时候路径指向 A，真正写入的时候被符号链接换成了 B。

116
00:10:39,320 --> 00:10:45,053
检查完就用检查过的那个新目标去做变更，中间不留可乘之机。

117
00:10:45,053 --> 00:10:51,664
可写根目录集合来自同一个函数，和给 bash 的授权范围同源，两边不会漂移。

118
00:10:51,664 --> 00:10:59,428
这个同源很重要，否则会出现文件工具能写、命令行工具不能写的分裂，模型会很困惑。

119
00:10:59,428 --> 00:11:08,539
再往深想一层，这个「先规范化再检查，然后用检查过的目标去做变更」的顺序，是一个可以到处复用的模式。

120
00:11:08,539 --> 00:11:17,157
业界给它起了个名字叫检查与使用时间差问题，说的就是检查和使用之间那段空隙被人钻了空子。

121
00:11:17,157 --> 00:11:26,352
这里的解法很朴素，把检查的结果当成唯一合法的输入传给下一步，而不是检查完之后回头再取一次原值。

122
00:11:26,352 --> 00:11:32,926
凡是涉及路径、符号链接、临时文件的代码，都值得按这个模式自查一遍。

123
00:11:32,926 --> 00:11:34,981
还有一点值得强调。

124
00:11:34,981 --> 00:11:43,840
进程内这一层拒绝的时候抛的是结构化错误，带自己的错误码，不需要去解析内核或限制器吐出来的文本。

125
00:11:43,840 --> 00:11:49,368
这是它相对第一层的巨大优势，自己做的事自己知道，不必靠猜。

126
00:11:49,368 --> 00:11:54,669
把判断留在自己手里，比去解读别人的输出可靠得多。

127
00:11:54,820 --> 00:11:57,486
现在到最有意思的第三层。

128
00:11:57,436 --> 00:12:10,765
所有涉及可变状态的组件，命令执行器、持久终端、语言服务器宿主、文件工具，都不直接碰操作系统，它们把活委托给文件系统和子进程这两个插口。

129
00:12:10,765 --> 00:12:14,527
这两个插口约定共享一个路径命名空间。

130
00:12:14,527 --> 00:12:21,030
文件系统插口返回的绝对路径，子进程插口起的子进程直接就能打开。

131
00:12:21,030 --> 00:12:30,885
这句话听起来平淡，实际上是个强约定，它保证了读取看到的文件和命令摸到的文件，是同一个世界里的同一个文件。

132
00:12:30,885 --> 00:12:33,962
于是换世界就变成了换插口。

133
00:12:33,962 --> 00:12:41,739
官方提供的两个适配器，分别通过远端沙箱的文件接口和命令接口实现了同样的接缝。

134
00:12:41,739 --> 00:13:02,452
官方 README 把消费方零改动这件事说得很直接，现有的本地命令工具、终端工具、语言服务器工具都不需要做专用分支，它们把执行环境中的所有操作都委托给这两个插口，因此挂上远端适配器之后，它们所有涉及可变状态的工作都发生在同一个远端沙箱里。

135
00:13:02,452 --> 00:13:04,615
边界也画得很清醒。

136
00:13:04,615 --> 00:13:13,425
搬走的只是文件和进程，主进程本身、模型调用、智能体与会话状态、会话持久化都留在本机。

137
00:13:13,425 --> 00:13:22,957
智能体之所以全程无感，原因就在这，它调的还是那几个工具，工具连的还是那两个插口，只是插口后面的世界换了。

138
00:13:22,957 --> 00:13:25,457
这里值得停下来算一笔账。

139
00:13:25,457 --> 00:13:34,687
换执行世界这件事，在很多产品里意味着给每个工具做一套远端分支，或者干脆 fork 一份远端专用实现。

140
00:13:34,687 --> 00:13:39,002
工具越多，分支越多，两边的行为迟早会漂。

141
00:13:39,002 --> 00:13:46,682
这里付出的代价是，所有组件都必须克制，只准连那两个插口，不许直接碰操作系统。

142
00:13:46,682 --> 00:13:50,937
这笔约束是长期纪律，换来的是搬家时零改动。

143
00:13:50,937 --> 00:13:54,663
所以这不只是技术选型，是架构纪律。

144
00:13:54,663 --> 00:13:58,726
插口越少、越窄，未来的可替换性就越强。

145
00:13:58,726 --> 00:14:09,014
反之，只要有一个组件图省事直接调了系统接口，换世界的时候它就得单独处理，而且往往是最后一个被发现的问题。

146
00:14:09,148 --> 00:14:10,900
收五条原则。

147
00:14:10,850 --> 00:14:13,410
第一条，先分层再动手。

148
00:14:13,410 --> 00:14:19,660
包命令参数、进程内检查、整体替换环境，是三种不同层级的手段。

149
00:14:19,660 --> 00:14:22,906
用错了层级，再努力也是白费。

150
00:14:22,906 --> 00:14:27,136
第二条，策略跟着调用走，不要焊在提供方上。

151
00:14:27,136 --> 00:14:34,817
凡是需要「临时宽一点再收回来」的场景，把它做成参数而不是状态，你就永远不会忘记收。

152
00:14:34,817 --> 00:14:38,471
第三条，故障和拒绝必须分开上报。

153
00:14:38,471 --> 00:14:46,367
把基础设施故障误判成安全拦截，是最危险的一类误报，因为它让问题看起来像是被处理好了。

154
00:14:46,367 --> 00:14:50,466
第四条，缺隔离就报错，绝不静默降级。

155
00:14:50,466 --> 00:14:58,903
安全机制的默认值应该是失败，而且错误消息要写明怎么修，实在不想修也得让人把不设防写在明面上。

156
00:14:58,903 --> 00:15:03,819
第五条，把可变状态收敛到少数几个插口后面。

157
00:15:03,819 --> 00:15:07,257
收敛得越干净，换实现就越便宜。

158
00:15:07,257 --> 00:15:15,538
这里把整个执行世界搬到远端，消费方零改动，靠的就是多年坚持只让组件连那两个插口。

159
00:15:15,538 --> 00:15:16,956
留一道练习。

160
00:15:16,956 --> 00:15:20,526
一条被包装的命令以一百二十五退出。

161
00:15:20,526 --> 00:15:35,818
列出你在下结论之前必须检查的证据，想想约定退出码、致命诊断行、无害通知行的剔除顺序，然后分别写出运行器故障和命令自己退出这两种判定下，工具层应该给模型报什么。

162
00:15:35,818 --> 00:15:41,107
想清楚这一题，这套分类器的价值就装进你脑子里了。

