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

2
00:00:06,382 --> 00:00:12,115
这是 Codex 系列的第十集，讲 Linux 上的沙箱是怎么分层搭起来的。

3
00:00:12,115 --> 00:00:14,975
先从一个很具体的现象出发。

4
00:00:14,975 --> 00:00:22,692
同事在 Mac 上跑 Codex，agent 去读用户目录下的私钥，Seatbelt 立刻回一句不允许操作。

5
00:00:22,692 --> 00:00:28,762
同一份仓库放到 Linux 笔记本上，常见文案却变成没有这个文件或目录。

6
00:00:28,762 --> 00:00:32,572
同一条读取，两种报错，差别在哪。

7
00:00:32,572 --> 00:00:34,399
差别在于顺序。

8
00:00:34,399 --> 00:00:40,276
Linux 这一侧先换掉进程能看见的文件树，再收紧它能调用的系统接口。

9
00:00:40,276 --> 00:00:47,331
路径常常根本不在挂载里，所以还没轮到系统调用表，内核就先回了文件不存在。

10
00:00:47,331 --> 00:00:49,278
这一集讲三件事。

11
00:00:49,278 --> 00:01:00,973
默认路径为什么分两拍，先用 bubblewrap 建视图再上 seccomp；为什么要单独起一个 helper 进程；以及为什么类型名还叫 Landlock，默认却已经走 bubblewrap。

12
00:01:00,973 --> 00:01:02,932
一句话先放在这儿。

13
00:01:02,932 --> 00:01:07,692
能看见什么，和能调用哪些系统接口，是两道墙。

14
00:01:07,692 --> 00:01:10,504
先换世界，再收紧调用。

15
00:01:10,504 --> 00:01:13,774
这一集的三块内容正好对应三拍。

16
00:01:13,774 --> 00:01:27,379
先讲为什么顺序不能反，这个决定了两拍的存在；再讲 helper 为什么要单独起一个，以及内层进来之后要做哪些自检；最后讲默认后端的真相，以及失败为什么不回退。

17
00:01:27,379 --> 00:01:34,975
串起来之后，你就能解释为什么同一条命令在不同机器上会给出完全不同的报错。

18
00:01:35,140 --> 00:01:39,164
先讲为什么必须分两拍，这个顺序反了会出事。

19
00:01:39,114 --> 00:01:50,027
如果先打开 PR_SET_NO_NEW_PRIVS，再去调系统自带的 bubblewrap，那么不少发行版上的 setuid 二进制会直接起不来。

20
00:01:50,027 --> 00:01:54,054
结果是沙箱在装得最全的机器上反而失败。

21
00:01:54,054 --> 00:01:57,047
这很反直觉，但原因很实在。

22
00:01:57,047 --> 00:02:05,148
no_new_privs 会挡住 setuid 提权，而系统自带的 bwrap 常常正是一个 setuid 包装器。

23
00:02:05,148 --> 00:02:10,160
你要先让它靠提权把命名空间建好，再在自己身上收紧。

24
00:02:10,160 --> 00:02:13,561
顺序反过来，工具本身就跑不起来。

25
00:02:13,561 --> 00:02:19,258
seccomp 是一套内核过滤器，用来规定进程还能调用哪些系统接口。

26
00:02:19,258 --> 00:02:23,441
no_new_privs 是它的前置条件。

27
00:02:23,441 --> 00:02:27,227
所以这两件事必须放在建好视图之后。

28
00:02:27,227 --> 00:02:30,713
默认路径是两次进入同一份 helper。

29
00:02:30,713 --> 00:02:36,145
外层只拼 bubblewrap，把文件系统换成默认只读，再叠上可写根。

30
00:02:36,145 --> 00:02:43,104
注意有个细节，.git、.agents、.codex 这几个目录即使落在可写根里，也保持只读。

31
00:02:43,104 --> 00:02:51,374
内层才打开 no_new_privs、装上网络 seccomp，然后 fork，把进程镜像让给用户命令。

32
00:02:51,374 --> 00:02:54,679
默认只读这件事也要展开说一句。

33
00:02:54,679 --> 00:03:00,112
外层把整个文件系统换成只读，然后按配置叠上可写根。

34
00:03:00,112 --> 00:03:07,191
可写根不是全部放开，里面那几个和仓库状态、配置相关的目录仍然保持只读。

35
00:03:07,191 --> 00:03:12,852
这样即使命令想在可写区域里动手脚，也动不了这几个位置。

36
00:03:12,852 --> 00:03:16,614
字段注释把这个原因直接写在旗标上。

37
00:03:16,614 --> 00:03:20,628
bubblewrap 可能依赖 setuid，必须先建好视图再收紧。

38
00:03:20,628 --> 00:03:29,847
出处是 linux_run_main.rs 的第一百一十七到一百二十一行，以及 landlock.rs 的第五十七到六十五行。

39
00:03:30,000 --> 00:03:36,476
函数头把顺序写成三步，这段代码就藏在 linux-sandbox 这个 crate 的入口处。

40
00:03:36,426 --> 00:03:41,594
第一步，需要时先用 bubblewrap 把命令包起来，构造文件系统视图。

41
00:03:41,594 --> 00:03:47,892
第二步，应用进程内的限制，也就是 no_new_privs 加 seccomp。

42
00:03:47,892 --> 00:03:50,777
第三步，execvp 进到最终命令。

43
00:03:50,777 --> 00:03:56,750
出处是 linux_run_main.rs 的第一百五十二到一百五十八行。

44
00:03:56,750 --> 00:04:06,306
源码快照依据的是本地仓库 openai 斜杠 codex，commit 是四f三九二五一a零一，核对日期二零二六年八月二十二日。

45
00:04:06,306 --> 00:04:09,214
这三步就是默认路径的合同。

46
00:04:09,214 --> 00:04:11,979
两层各挡什么，要分清楚。

47
00:04:11,979 --> 00:04:13,902
视图挡的是看见。

48
00:04:13,902 --> 00:04:19,755
路径不在挂载里，内核回文件不存在，这一层还没轮到系统调用表。

49
00:04:19,755 --> 00:04:21,702
seccomp 挡的是调用。

50
00:04:21,702 --> 00:04:27,592
过滤器默认放行，命中 connect、bind、ptrace 这类规则才回 EPERM。

51
00:04:27,592 --> 00:04:30,464
已经看不见的文件，它不管。

52
00:04:30,464 --> 00:04:34,587
所以同一个探针换一下，停的位置就变了。

53
00:04:34,587 --> 00:04:39,058
读 shadow 文件，停在视图层，文案是文件不存在。

54
00:04:39,058 --> 00:04:45,657
换成往外连接，同一条路会穿过视图，走到过滤器，文案变成 EPERM。

55
00:04:45,657 --> 00:04:49,262
这个对照是本集最值得记住的一处。

56
00:04:49,420 --> 00:04:53,276
接下来看为什么要单独起一个 helper 进程。

57
00:04:53,226 --> 00:04:54,656
原因很硬。

58
00:04:54,656 --> 00:04:59,920
bubblewrap 和 seccomp 必须发生在即将变成用户命令的那个进程里。

59
00:04:59,920 --> 00:05:10,293
如果让主命令行自己 unshare 再 exec，那么失败会把整次会话一起带走，而且没法在 PID 命名空间里留下一个收孤儿的 1 号进程。

60
00:05:10,293 --> 00:05:13,803
超时和取消会留下一堆睡眠进程。

61
00:05:13,803 --> 00:05:17,649
Codex 的做法是靠 argv 零的 basename 来分身。

62
00:05:17,649 --> 00:05:25,642
文件名如果已经是 codex-linux-sandbox 就保留，否则改成这个别名，把同一份二进制拐进 helper。

63
00:05:25,642 --> 00:05:32,589
也就是说，外层 exec 出去的不是 ls，是 helper 自己，带着 apply-seccomp-then-exec 这个参数。

64
00:05:32,589 --> 00:05:38,406
launcher 还会插进 as-pid-1，让 bubblewrap 自己当命名空间里的 1 号进程。

65
00:05:38,406 --> 00:05:42,384
出处是 launcher.rs 的第三十八到三十九行。

66
00:05:42,384 --> 00:05:49,151
这个设计的巧妙之处在于，同一份二进制进了两次，中间隔着 bubblewrap 建好的世界。

67
00:05:49,151 --> 00:05:55,750
第一次进去是为了建世界，第二次进去是为了在已经建好的世界里把自己收死。

68
00:05:55,750 --> 00:06:01,351
再补一层，为什么必须是独立进程而不是线程或者子进程。

69
00:06:01,351 --> 00:06:09,235
命名空间和 seccomp 都是进程级属性，你没法在一个已经跑着别的东西的进程里干净地重建它们。

70
00:06:09,235 --> 00:06:20,005
所以自我调用是最直接的做法，把同一份二进制重新执行一次，在新进程里完成所有准备，然后让进程镜像被目标命令接管。

71
00:06:20,005 --> 00:06:23,045
顺带说一句跨平台的一致性。

72
00:06:23,045 --> 00:06:26,435
Windows 看隐藏参数，Linux 看 argv 零。

73
00:06:26,435 --> 00:06:33,130
包装器失败的时候，固定退出码加固定的 stderr 前缀，更好做自动归因。

74
00:06:33,270 --> 00:06:36,393
内层进来之后，第一件事是自检。

75
00:06:36,343 --> 00:06:37,677
先 capget。

76
00:06:37,677 --> 00:06:43,506
如果 effective 或者 permitted 任一非零，立刻 panic，命令根本不会跑。

77
00:06:43,506 --> 00:06:49,720
这道检查很关键，它保证后面的 seccomp 是装在一个能力已经清零的进程上。

78
00:06:49,720 --> 00:06:52,040
过了这道检查才 fork。

79
00:06:52,040 --> 00:06:58,290
子进程 execvp，父进程用 waitpid 负一把子孙都收掉，退出码原样传出。

80
00:06:58,290 --> 00:07:07,508
出处是 linux_run_main.rs 的第二百一十六到二百二十一行，以及第二百四十二到二百四十三行。

81
00:07:07,508 --> 00:07:09,720
为什么父进程要留下。

82
00:07:09,720 --> 00:07:15,405
因为用户在命名空间里是 1 号进程，孤儿进程会被交给它收养。

83
00:07:15,405 --> 00:07:20,970
没有这个人在，超时和取消之后就会留下没人收的睡眠进程。

84
00:07:20,970 --> 00:07:26,186
教学上的一句话概括，进程让出去之后，收尸的人还在。

85
00:07:26,330 --> 00:07:29,537
第三部分，也是最容易搞错的一处。

86
00:07:29,487 --> 00:07:37,576
类型名还叫 LandlockCommand，crate 目录也叫 linux-sandbox，所以网上不少资料写成 Codex 在 Linux 上用 Landlock。

87
00:07:37,576 --> 00:07:41,326
但当前默认的文件系统沙箱已经是 bubblewrap。

88
00:07:41,326 --> 00:07:47,359
Landlock 是显式打开的 legacy 回退，特性开关标成 Deprecated，默认关。

89
00:07:47,359 --> 00:07:52,467
而且 legacy 吃不下受限读，也吃不下拆分出来的嵌套只读。

90
00:07:52,467 --> 00:07:54,679
更关键的是失败策略。

91
00:07:54,679 --> 00:07:56,891
外层写明 never falls back。

92
00:07:56,891 --> 00:08:00,977
bubblewrap 失败就是失败，不会悄悄换成 Landlock。

93
00:08:00,977 --> 00:08:03,141
为什么这条这么重要。

94
00:08:03,141 --> 00:08:12,323
如果 bwrap 失败后静默换成 Landlock，模型会按我被关在视图里来规划下一步，实际上却还看得见主机文件树。

95
00:08:12,323 --> 00:08:17,167
下一步的行为就会分叉，而且这种分叉最难排查。

96
00:08:17,167 --> 00:08:19,018
几种具体情况。

97
00:08:19,018 --> 00:08:27,443
WSL1 在需要 bubblewrap 的时候，transform 会返回一个专门的 WSL1 不支持错误，命令压根不会进 helper。

98
00:08:27,443 --> 00:08:34,174
全盘可写且不走代理时，根本不会进 bwrap，所以 WSL1 这种情况可以过。

99
00:08:34,174 --> 00:08:39,967
user namespace 建不出来的话，给的是启动警告，不会改走更弱的后端。

100
00:08:39,967 --> 00:08:56,125
出处是 linux_run_main.rs 的第二百九十到二百九十四行、manager.rs 的第四百一十三到四百二十行与第六百九十六到七百一十行、features 下 lib.rs 的第一千零六十四到一千零六十九行。

101
00:08:56,125 --> 00:08:59,599
再把这几个场景串成一条判断链。

102
00:08:59,599 --> 00:09:05,356
第一步问，这次请求需要 bubblewrap 吗，全盘可写且不走代理时不需要。

103
00:09:05,356 --> 00:09:13,253
第二步问，环境支持吗，WSL1 遇到需要 bubblewrap 的请求就不支持，user namespace 建不出来也给警告。

104
00:09:13,253 --> 00:09:16,918
第三步才是执行，执行失败就是失败。

105
00:09:16,918 --> 00:09:23,914
三步里任何一步不满足，结果都是明确的拒绝，而不是换一条更弱的路继续走。

106
00:09:23,914 --> 00:09:25,801
一句话收在这里。

107
00:09:25,801 --> 00:09:29,971
进不去就明说，不要合成一句已经沙箱了。

108
00:09:30,120 --> 00:09:32,161
横向看另外两家。

109
00:09:32,111 --> 00:09:36,113
DeepSeek 的 Harness 把 Linux 链写死为 bubblewrap 再 landlock。

110
00:09:36,113 --> 00:09:39,983
它的 bwrap 档是视图加 exec，中间没有 seccomp。

111
00:09:39,983 --> 00:09:45,200
探测不过才走 landlock-run，在自己身上装规则再 exec 目标命令。

112
00:09:45,200 --> 00:09:48,998
文件系统限制和 exec 合在同一份 main 里。

113
00:09:48,998 --> 00:09:54,358
这条路径没有 setuid 的 bwrap，所以不必把过滤器再拆到下一拍。

114
00:09:54,358 --> 00:09:58,517
两边都走独立辅助进程，但失败表达不一样。

115
00:09:58,517 --> 00:10:06,209
DSH 启动器失败是退出码一百二十五，还要同时看到 landlock-run 的致命行，更好自动归因。

116
00:10:06,209 --> 00:10:11,113
Codex 这边 helper 失败是 panic 或者原样的 wait status。

117
00:10:11,113 --> 00:10:23,265
还有一个差别，两条链都探测失败时 DSH 拒绝裸跑，它可以换到探测过的第二档，换完仍然 fail closed；Codex 的默认路径失败则不回退到 Landlock。

118
00:10:23,265 --> 00:10:25,500
Grok 又是另一种放法。

119
00:10:25,500 --> 00:10:33,854
顺序和 Codex 是同一条工程理由，先让可能依赖 setuid 的 bwrap 跑完，再在已经进命名空间的进程里收紧。

120
00:10:33,854 --> 00:10:35,536
差别在主体。

121
00:10:35,536 --> 00:10:39,935
Grok 把三拍打在 agent 进程上，命令只继承结果。

122
00:10:39,935 --> 00:10:46,197
视图是补丁式的，把根目录 bind 上去再盖一层 deny 路径，主机树还在。

123
00:10:46,197 --> 00:10:51,630
子进程 spawn 时如果需要限网，才在 pre_exec 里装 seccomp。

124
00:10:51,630 --> 00:10:54,250
失败策略上两者也不同。

125
00:10:54,250 --> 00:11:00,572
bwrap exec 失败且不要求拒绝读时，Grok 会警告一句再回退到 Landlock。

126
00:11:00,572 --> 00:11:06,967
内置 Profile 的 apply 失败记警告并继续，真正要看状态得问 is_active。

127
00:11:06,967 --> 00:11:09,635
Codex 外层写的是 never falls back。

128
00:11:09,635 --> 00:11:12,544
这里有个判断值得单独拎出来。

129
00:11:12,544 --> 00:11:17,880
三种失败策略没有绝对优劣，但它们要求的诚实程度不同。

130
00:11:17,880 --> 00:11:25,344
回退到更弱的后端，前提是所有依赖这份说明的地方都能同步知道降级已经发生。

131
00:11:25,344 --> 00:11:28,830
如果这个同步做不到，那就宁可失败。

132
00:11:28,830 --> 00:11:31,354
三台机器不是同一道墙。

133
00:11:31,354 --> 00:11:36,702
本机有新的 bwrap，CI 容器禁了 user namespace，同事还在 WSL1。

134
00:11:36,702 --> 00:11:42,243
产品文案和给模型的说明，必须按实际启用的那一层来写。

135
00:11:42,380 --> 00:11:45,370
收尾，四条可以带走的原则。

136
00:11:45,320 --> 00:11:48,505
一，先换世界，再收紧调用。

137
00:11:48,505 --> 00:11:53,830
凡是包装器本身可能依赖 setuid 提权的场景，顺序都不能反。

138
00:11:53,830 --> 00:11:59,479
反了就是沙箱在装得最全的机器上失败，这种故障最难归因。

139
00:11:59,479 --> 00:12:03,181
二，把分层写进代码而不是文档。

140
00:12:03,181 --> 00:12:06,546
入口函数头那三步注释就是合同。

141
00:12:06,546 --> 00:12:10,320
后来的人改顺序之前，会先看到这段注释。

142
00:12:10,320 --> 00:12:14,142
契约放在最容易被看见的地方，才有约束力。

143
00:12:14,142 --> 00:12:17,724
三，给命名空间留一个收孤儿的人。

144
00:12:17,724 --> 00:12:21,847
超时和取消不是边缘情况，它们每天都发生。

145
00:12:21,847 --> 00:12:30,188
父进程留在那里用 waitpid 收掉子孙，退出码原样传出，是这套设计里最低调但最必要的一环。

146
00:12:30,188 --> 00:12:33,373
四，失败不要换更弱的后端。

147
00:12:33,373 --> 00:12:38,241
探测不到就在 spawn 之前拒绝，把原因明明白白返回去。

148
00:12:38,241 --> 00:12:44,960
静默降级会让调用方按照错误的世界模型做规划，那比直接失败危险得多。

149
00:12:44,960 --> 00:12:46,763
最后留一道练习。

150
00:12:46,763 --> 00:12:54,419
把探针留在读 /etc/shadow，先打开 WSL1，写下你的预测，停在哪一层、文案是什么。

151
00:12:54,419 --> 00:13:00,813
然后关掉 WSL1，打开 legacy Landlock，看受限读会不会变成不支持的操作。

152
00:13:00,813 --> 00:13:10,008
进阶一问是，探针换成往外连接，为什么默认路径会穿过视图停在 seccomp，文案从文件不存在变成 EPERM。

153
00:13:10,008 --> 00:13:12,111
这一集就到这里。

