跳到主要内容

两道锁:seccomp 系统调用白名单与 chroot 换根降权

30 秒导读: 上一章讲了「一段不可信代码怎么被关进笼子」的全景——包一层 prescript.py、fork 子进程、装隔离。这一章只干一件事:把那两把最硬的锁拆开看。第一把是 chroot + 降权(把进程的"根目录"换成沙箱目录,再把身份从 root 一路降到一个无名普通用户);第二把是 seccomp(给进程装一张 BPF 过滤器,内核层面只放行事先列好的少数几个系统调用,其余一律杀进程)。关键不在"用了这两样",而在上锁的顺序——顺序错一步,锁就白上。

本章聚焦 DifySeccomp 这个入口背后的全部实现。如果你还不清楚这段代码整体在沙箱执行流里的位置,先看 02-sandbox-core.md;想看一次请求端到端的路径,看 01-request-lifecycle.md


1. 先建立直觉:两把锁各防什么

不可信代码跑在一个真实的进程里。这个进程要是能随便调系统调用、又顶着高权限身份、还能看到整个宿主机文件系统,那"沙箱"就是个笑话。Dify Sandbox 用两把互补的锁把这三件事都堵死:

防的是一句话原理
chroot + 降权进程"看得到什么、以谁的身份看"把根目录换成沙箱目录,再把身份从 root 降成一个只有编号、没有权限的普通用户
seccomp进程"能对内核说什么"装一张系统调用白名单,名单外的调用被内核直接拦下——默认是杀掉整个进程

两把锁是互补的:chroot 限制了"资源可见性",seccomp 限制了"能力"。就算代码想办法绕过其一,另一把还在。而它们必须在同一个函数、按同一个精确顺序上,这就是 InitSeccomp

这段代码在哪里跑? 它不是主服务进程直接调的。主进程把 Go 代码编译成一个 .so 动态库,里面 //export 出一个 C 符号 DifySeccomp(cmd/lib/python/main.go:8-13);沙箱子进程跑的 prescript.py 通过 ctypes 加载这个 .so,在真正 exec 用户代码之前调用它(internal/core/runner/python/prescript.py:29,lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}}))。也就是说——先把自己锁死,再放不可信代码进来


2. 上锁的顺序:InitSeccomp 逐步拆解

整把锁的全部逻辑在 internal/core/lib/python/add_seccomp.goInitSeccomp(add_seccomp.go:17)。它的执行顺序不能乱,每一步都为下一步铺路。先看全貌:

InitSeccomp(uid, gid, enable_network):
① Chroot(".") 换根:把当前目录变成新的 "/"
② Chdir("/") 把工作目录挪进新根内,避免 chroot 逃逸
③ SetNoNewPrivs() 立誓:此后永不通过 exec 提权
④ 组装白名单 (env 覆盖 / 默认表 / 按需加网络)
⑤ Seccomp(...) 装上 BPF 过滤器
⑥ Setgroups([]) 清空附加组
⑦ Setgid(gid) 降组身份
⑧ Setuid(uid) 降用户身份 —— 最后一步,不可逆

下面一步一步讲每步防什么、以及为什么必须排在这个位置

① Chroot(".") — 换根

err := syscall.Chroot(".") // add_seccomp.go:18

chroot(".") 把进程当前的工作目录设为它的新"文件系统根"。此后进程眼里的 / 就是沙箱目录,它再也看不到宿主机的 /etc/shadow/root、别的容器的文件——凡是新根之外的路径,对它都不存在。

这一步能成功的前提是此刻进程还是 root(chroot 是特权调用)。所以降权必须排在它后面——先用 root 权限把根换掉,才谈得上把 root 权限交出去。至于新根里到底有什么(Python 运行时、标准库软链、/etc/passwd……),那是「文件系统搭建」的事,见 04-fs-and-multilang.md

② Chdir("/") — 把工作目录挪进新根

err = syscall.Chdir("/") // add_seccomp.go:22

chroot 只改了"根"的指向,没有改进程的当前工作目录。如果 cwd 还停在旧根之外,存在经典的 chroot 逃逸:进程可以用相对路径 ../../.. 一路爬回真正的根。Chdir("/") 把 cwd 钉进新根内部,把这条逃逸路径掐断。②必须紧跟①——先换根再进根,顺序反了就没意义。

③ SetNoNewPrivs() — 上锁前先立誓

lib.SetNoNewPrivs() // add_seccomp.go:27

底层是一个 prctl(PR_SET_NO_NEW_PRIVS, 1, ...)(internal/core/lib/set_no_new_privs.go:14-15,prctl0x26 即 38)。它设置一个一旦置上就不可撤销的进程标志:此后任何 execve不会再让进程获得新权限——setuid/setgid 位的可执行文件、文件 capabilities,统统失效。

为什么它必须先于 seccomp? 这是内核的硬性规定,也是安全上的必需。没有 no_new_privs 的进程,一个非特权用户是不被允许安装 seccomp 过滤器的(内核会拒绝)。更本质的原因是:seccomp 过滤器会被子进程继承,若允许过滤后的进程通过 exec 一个 setuid 程序重新提权,整张白名单就可能被绕过。先立下"永不提权"这道誓,seccomp 才是牢靠的。所以 ③ 必须排在 ⑤ 之前

④ 组装白名单 — 决定放行谁

在装过滤器前,先把"允许哪些系统调用"这份名单拼出来(add_seccomp.go:29-49)。这里有两条支线——默认表、还是 env 覆盖——留到 §4§5 细讲。这一步不涉及内核操作,只是准备两个 int 切片:allowed_syscalls(放行)和 allowed_not_kill_syscalls(拦下但返错而非杀,见 §4)。

⑤ Seccomp(...) — 装上过滤器

err = lib.Seccomp(allowed_syscalls, allowed_not_kill_syscalls) // add_seccomp.go:51

这是本章的技术核心,单独在 §3 拆。装完之后,进程能调的系统调用就被内核钉死了。

注意:此刻进程还是 root。 为什么在还没降权时就装过滤器?因为白名单里必须包含 setgroups / setgid / setuid 这三个降权调用本身(见 ALLOW_SYSCALLS 的 user/group 段,syscalls_amd64.go:24)。顺序是精心设计的:先装好一张"允许我调 setuid"的过滤器,再去调 setuid——装过滤器和降权都得成功,少一样都不行。

⑥⑦⑧ Setgroups → Setgid → Setuid — 三步降权,最后不可逆

err = syscall.Setgroups([]int{}) // add_seccomp.go:56 清空附加组
err = syscall.Setgid(gid) // add_seccomp.go:62 降组
err = syscall.Setuid(uid) // add_seccomp.go:68 降用户,最后做

降权的顺序本身也有讲究,而且是安全铁律:

  • Setgroups([]) 必须先做。 附加组(supplementary groups)是 root 可能属于的一堆额外组(如 dockersudo)。如果先 setuid 丢掉了 root 身份,就再也没权限清这些组了——残留的附加组会成为提权口子。所以趁还是 root,先把附加组清空。
  • SetgidSetuid 之前。 同理:一旦 setuid 交出了 root,就没权限再改组身份了。必须先降组、最后降用户。
  • Setuid 是最后一步,且不可逆。 把有效/实际/保存 UID 全部换成一个普通用户编号(uid,来自 UID 池,见 §6)后,进程再也回不到 root。这一步一落,身份锁死。

到这里,两把锁全部上完:进程看不到新根外的文件、身份是个无权限的普通用户、能调的系统调用被白名单钉死。InitSeccomp 返回,prescript.pyexec 用户代码。

一句话记住这个顺序: 特权操作(chroot / 清组)趁早做,seccomp 夹在中间(先于降权、允许降权调用),降权(尤其 setuid)留到最后且不可逆。这不是随手写的顺序,是"root 权限用完就交、交了就拿不回"的严格链条。


3. seccomp BPF 怎么生成、怎么装

第五步 lib.Seccomp 是全章最"内核"的一段,实现在 internal/core/lib/seccomp.go:15-70。它做四件事:建过滤器 → 加规则 → 导出成 BPF 字节码 → 直接 syscall 加载到所有线程

3.1 默认动作:名单外一律杀进程

ctx, err := sg.NewFilter(sg.ActKillProcess) // seccomp.go:16

用 libseccomp(github.com/seccomp/libseccomp-golang)新建一个过滤器上下文,默认动作设为 ActKillProcess。这是"默认拒绝"哲学:凡是没被规则显式放行的系统调用,内核直接 SIGSYS 杀掉整个进程。白名单模型,不是黑名单——你不需要枚举所有危险调用,只需列出少数几个安全的。

3.2 两类规则:放行 vs 返错

for _, syscall := range allowed_syscalls {
ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActAllow) // seccomp.go:28-30 放行
}
for _, syscall := range allowed_not_kill_syscalls {
ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActErrno) // seccomp.go:32-34 返错不杀
}

两份名单对应两种动作:

名单动作效果
allowed_syscallsActAllow正常放行
allowed_not_kill_syscallsActErrno拦下,但返回一个错误码给调用方,而不是杀进程

ActErrno 这个"温和拦截"是巧妙点,专门给某些"调了也不该崩"的调用用——细节见 §4.3

3.3 从 libseccomp 导出成内核认得的 BPF

libseccomp 内部把规则编译成 BPF(Berkeley Packet Filter)字节码。Dify Sandbox 不用 libseccomp 自己的加载函数,而是把 BPF 掏出来手动加载——因为它要用一个特殊标志(见 3.4)。掏出来的手法有点取巧:

reader, writer, err := os.Pipe() // seccomp.go:21 用管道当中转
...
file := os.NewFile(uintptr(writer.Fd()), "pipe")
ctx.ExportBPF(file) // seccomp.go:36-37 把 BPF 写进管道写端

data := make([]byte, 4096)
n, err := reader.Read(data) // seccomp.go:40-41 从读端读回原始字节

sock_filters := make([]syscall.SockFilter, n/8) // seccomp.go:46 每条 BPF 指令 8 字节
bytesBuffer := bytes.NewBuffer(data)
err = binary.Read(bytesBuffer, binary.LittleEndian, &sock_filters) // seccomp.go:47-48

流程是:ExportBPF 把二进制 BPF 写进一个 os.Pipe 的写端 → 再从读端 Read 回来 → 按 binary.Read 小端解码成 []syscall.SockFilter。每条 BPF 指令固定 8 字节,所以指令条数 = n/8(seccomp.go:46)。这样就把 libseccomp 的规则,变成了内核 SECCOMP_SET_MODE_FILTER 直接吃的 SockFilter 数组。

3.4 直接 syscall 加载,并用 TSYNC 同步到所有线程

bpf := syscall.SockFprog{
Len: uint16(len(sock_filters)),
Filter: &sock_filters[0],
} // seccomp.go:53-56

_, _, err2 := syscall.Syscall(
SYS_SECCOMP, // seccomp.go:59 seccomp 系统调用号
uintptr(SeccompSetModeFilter), // seccomp.go:60 = 0x1,加载一张 BPF 过滤器
uintptr(SeccompFilterFlagTSYNC), // seccomp.go:61 = 0x1,同步到所有线程
uintptr(unsafe.Pointer(&bpf)),
) // seccomp.go:58-63

最后把 SockFilter 数组包成 SockFprog,绕过 libc、直接 syscall.Syscall(SYS_SECCOMP, ...) 装载。两个关键常量:

  • SeccompSetModeFilter = 0x1(seccomp_syscall_amd64.go 邻近的 set_no_new_privs.go:10):告诉内核"这是一张 BPF 过滤器"。
  • SeccompFilterFlagTSYNC = 0x1(set_no_new_privs.go:11):Thread Sync——把过滤器同步到调用进程的所有线程,而不只是当前线程。

TSYNC 是这里的要害。Python 解释器是多线程的(GC 线程、信号线程等),如果过滤器只装在当前线程,不可信代码完全可以切到另一个没被过滤的线程去调危险 syscall,整把锁就漏了。TSYNC 保证没有一个线程能逃过这张网

SYS_SECCOMP 的号按架构区分:amd64 硬编码为 317(seccomp_syscall_amd64.go:6),arm64 用 syscall.SYS_SECCOMP(seccomp_syscall_arm64.go:8)。


4. 白名单本体:名单里放了什么

放行哪些系统调用,定义在 internal/static/python_syscall/syscalls_amd64.go(arm64 有对应的 syscalls_arm64.go)。这里有三份名单。

4.1 ALLOW_SYSCALLS:按用途分类的基础放行表

ALLOW_SYSCALLS(syscalls_amd64.go:13-40)是默认放行的核心名单,源码里按用途分了段,读起来就是一份"Python 跑起来最低限度需要什么"的清单:

类别放行的调用(示例)为什么需要
file ioopenat read write close lseek fstat getdents64读代码、写输出、遍历目录:15-16
threadfutex sched_getaffinityPython 线程同步:18
memorymmap brk mprotect munmap mremap madvise解释器分配/管理内存:20-21
user/groupsetgroups setgid setuid getuid给 InitSeccomp 自己降权用:24
processgetpid gettid exit exit_group tgkill rt_sigaction进程/信号基本操作:26-30
timeclock_gettime nanosleep gettimeofday epoll_create1 pselect6计时、sleep、事件循环:33-39

值得单独点出的:user/group 段里的 setgroups/setgid/setuid(syscalls_amd64.go:24)——正是 §2 里 ⑥⑦⑧ 降权三步要用的调用。白名单必须先放行它们,后面的降权才不会被自己的过滤器杀掉。这就是"先装过滤器、再降权"能成立的原因。

名单里没有的:execve(无法起新进程)、fork/vforkptracesocket 系列(除非开网络)、chmod/chown 等——想跳出笼子的调用,一个都不在。

4.2 ALLOW_NETWORK_SYSCALLS:仅在开网络时追加

var ALLOW_NETWORK_SYSCALLS = []int{
syscall.SYS_SOCKET, syscall.SYS_CONNECT, syscall.SYS_BIND, ... // syscalls_amd64.go:48-53
}

网络相关的 socket/connect/bind/sendto/recvfrom默认不在基础白名单里。只有当 enable_network 为真时,InitSeccomp 才把这份名单追加进去(add_seccomp.go:46-48)。这是默认无网的体现:不显式开网络,沙箱里的代码连 socket 都调不了,内核直接杀。开不开网络由上层配置传入 enable_network(见 01-request-lifecycle.md)。

4.3 ALLOW_ERROR_SYSCALLS:返错而非杀

var ALLOW_ERROR_SYSCALLS = []int{
syscall.SYS_CLONE, // syscalls_amd64.go:43
syscall.SYS_MKDIRAT, // :44
syscall.SYS_MKDIR, // :45
}

这份名单走的是 ActErrno(返错,§3.2),而不是 ActKillProcess(杀)。为什么 clonemkdir 这种调用要温和地返错、而不是像别的危险调用那样直接杀进程?

  • CLONE(创建线程/进程):Python 解释器或某些标准库在清理/退出阶段,底层可能顺手调用 clone(比如线程池收尾)。如果直接杀,一段本来就要正常结束的代码会在退出时崩掉、污染退出状态。返一个 EPERM 让调用方"以为失败了"继续走清理逻辑,比杀掉更平滑——既拦住了真正的建线程/建进程企图,又不误伤正常收尾。
  • MKDIR/MKDIRAT(建目录):同理,某些库在初始化时会尝试建缓存目录之类,建不成不该让整个进程暴毙——返错让它"优雅地失败"。

InitSeccomp 把这份名单赋给 allowed_not_kill_syscalls(add_seccomp.go:30-31),它不受 env 覆盖影响、也不受 enable_network 影响,始终以 ActErrno 生效。这是"安全"与"别把正常代码误杀"之间的一处精细权衡。


5. 旁路:env 覆盖白名单

默认名单是写死在 Go 里的,但运维有时需要给沙箱临时放行几个额外调用(比如某个库需要一个名单里没有的 syscall)。Dify Sandbox 留了一个 env 旁路:ALLOWED_SYSCALLS 环境变量。

5.1 config 侧读取

配置加载时(internal/static/config.go:140-151)读 ALLOWED_SYSCALLS,按逗号切开、Atoi[]int,存进全局配置的 AllowedSyscalls(internal/types/config.go:22)。这份配置里的名单是数字形式的 syscall 号——运维得自己查号填进去。

5.2 InitSeccomp 侧:env 优先,且强制补 SETGROUPS

InitSeccomp 里 env 的优先级高于默认表(add_seccomp.go:33-49):

allowed_syscall := os.Getenv("ALLOWED_SYSCALLS")
if allowed_syscall != "" { // env 非空 → 走覆盖分支
nums := strings.Split(allowed_syscall, ",")
for num := range nums {
syscall, err := strconv.Atoi(nums[num])
if err != nil { continue } // 解析不了就跳过,不报错
allowed_syscalls = append(allowed_syscalls, syscall)
}
allowed_syscalls = append(allowed_syscalls, syscall.SYS_SETGROUPS) // :43 强制补
} else {
allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_SYSCALLS...) // :45 默认表
if enable_network { ... } // 默认表才追加网络
}

两个关键细节:

  • 完全覆盖,不是合并。 一旦 ALLOWED_SYSCALLS 非空,默认的 ALLOW_SYSCALLS(和网络表)整个不加——白名单变成只有 env 里列的那些号。这是"完全替换"语义,运维必须自己把 Python 跑起来所需的全部 syscall 都列进去,否则进程会被自己的过滤器杀死。
  • 强制补 SETGROUPS(add_seccomp.go:43)。 无论 env 名单里有没有,代码都强行追加一个 syscall.SYS_SETGROUPS。为什么?因为 §2 的降权第一步 Setgroups([])跳不过的——要是运维忘了在 env 里放 setgroups,降权会在装完过滤器后立刻被自己杀掉。代码替运维兜住了这个最容易踩的坑。(注意它只补了 setgroups;setgid/setuid 若漏填仍会崩——这是这条旁路的一处边界,见 05-cleverness-and-boundaries.md。)

一句话:env 旁路是"完全替换 + 只兜底 setgroups"。方便,但危险——填错一个号,沙箱要么起不来、要么锁松了。


6. UID 池:一次执行一个专属身份

§2 的第 ⑧ 步 Setuid(uid) 里那个 uid 从哪来?来自一个 UID 池,实现在 internal/core/runner/uidpool/uid_pool.go。设计目标:每次执行拿一个专属的、互不冲突的普通用户编号,执行完再还回去。

6.1 用 channel 当池子

type UIDPool struct {
pool chan int // uid_pool.go:15
min int
max int
}
func NewUIDPool(min, max int) *UIDPool {
p := &UIDPool{ pool: make(chan int, max-min), ... }
for i := min; i < max; i++ {
p.pool <- i // uid_pool.go:26-28 预填所有可用 UID
}
return p
}

池子就是一个带缓冲的 channel,容量 max-min,初始化时把 [min, max) 里的每个整数都塞进去(uid_pool.go:26-28)。全局池在首次 AcquireUID 时懒初始化,范围是 10000–11000(uid_pool.go:59,NewUIDPool(10000, 11000))——即最多 1000 个并发执行,每个一个专属 UID。这些是高位编号,不与系统用户冲突。

6.2 Acquire:借不到就等,ctx 取消即判池耗尽

func (p *UIDPool) Acquire(ctx context.Context) (int, error) {
select {
case uid := <-p.pool: // uid_pool.go:35 有空闲 → 立即拿到
return uid, nil
case <-ctx.Done(): // uid_pool.go:37 ctx 取消/超时 → 判耗尽
return 0, ErrUIDPoolExhausted
}
}

Acquire 是 channel 的经典用法:池里有空闲 UID 就立刻取一个;没有就阻塞等,直到别的执行 Release 还回来。等待期间若 ctx 被取消(上层超时或客户端断开),返回 ErrUIDPoolExhausted(uid_pool.go:12)——即"1000 个 UID 全被占着,你没等到"。Release 则把 UID 塞回 channel(uid_pool.go:42-46),并做范围校验避免塞进非法值。

为什么给每次执行一个独立 UID? 两段同时跑的不可信代码,如果共用一个 UID,理论上能通过共享 UID 的资源(信号、同 UID 可见的进程)互相干扰。一执行一 UID,把这层横向隔离也补上。UID 从池里 Acquire 的调用点在 internal/core/runner/python/python.go:39

6.3 ensurePasswdEntries:一个和 seccomp 呼应的暗坑

池子首次初始化时,除了填 channel,还做了一件容易忽略但很关键的事(uid_pool.go:60,ensurePasswdEntries(10000, 11000)):

// ensurePasswdEntries appends sandbox UIDs to /etc/passwd so that
// Python's cleanup (e.g. getpwuid) doesn't trigger blocked syscalls.
func ensurePasswdEntries(min, max int) { // uid_pool.go:67
f, _ := os.OpenFile("/etc/passwd", os.O_APPEND|os.O_WRONLY, 0644)
...
for i := min; i < max; i++ {
fmt.Fprintf(f, "sandbox%d:x:%d:0::/nonexistent:/usr/sbin/nologin\n", i, i) // :75
}
}

它把 10000–10999 每个 UID 都写一条形如 sandbox10000:x:10000:0::/nonexistent:/usr/sbin/nologin 的记录进 /etc/passwd为什么要这么做? 这正是和 seccomp 白名单呼应的暗坑:

进程降权到某个 UID 后,Python 在清理阶段可能调 getpwuid(uid) 去查"我这个 UID 对应哪个用户名"。getpwuid 底层若在 /etc/passwd 里查不到这个 UID,glibc 的 NSS 机制可能回退去尝试别的解析路径,触发一些不在 seccomp 白名单里的系统调用——结果就是进程在正常收尾时被过滤器杀掉。

预先往 /etc/passwd 写好这些条目,getpwuid 一次本地查表就命中、不再乱调 syscall。它是"让降权后的进程能干净退出、而不被 seccomp 误杀"的补丁——把 §4.3ActErrno 兜底和这里的 passwd 预填放在一起看,能感到这套沙箱在"锁得够死"和"别把正常代码误杀"之间反复打磨的痕迹。


7. 把两把锁串起来看

回到全局:一次执行请求进来,上层从 UID 池 Acquire 一个 uid(python.go:39),把它连同 gid、enable_network 一起塞进 prescript.py 模板;子进程起来后,在 exec 用户代码前调 DifySeccomp(prescript.py:29)→ InitSeccomp,于是:

还是 root

①Chroot(".") ─── 把根换成沙箱目录(需要 root)
②Chdir("/") ─── 进新根,堵 chroot 逃逸
③NoNewPrivs ─── 立誓永不提权(seccomp 的前提)
④组装白名单 ─── env 覆盖 or 默认表(+可选网络)
⑤Seccomp ─── 装 BPF,TSYNC 到所有线程,默认杀
│ (白名单含 setuid/setgid/setgroups)
⑥Setgroups([])─── 清附加组(趁还是 root)
⑦Setgid ─── 降组
⑧Setuid ─── 降到池里的普通 UID —— 不可逆

普通用户 + 换了根 + 系统调用被钉死

exec 用户代码 ← 此刻才放不可信代码进来

这张顺序图就是本章的全部精华。 每一步都踩在前一步的肩膀上:chroot 要 root,所以降权在后;seccomp 要 no_new_privs,所以立誓在前;降权要调 setuid,所以白名单先放行它;setuid 不可逆,所以留到最后。任何一步挪位置,锁就松。


8. 代码地图(导航索引)

主题文件路径符号名
上锁总入口:换根+降权+装 seccomp 的完整顺序internal/core/lib/python/add_seccomp.goInitSeccomp
C 导出符号:Python 侧通过 .so 调用的入口cmd/lib/python/main.goDifySeccomp (//export)
子进程在 exec 用户代码前调用锁internal/core/runner/python/prescript.pylib.DifySeccomp(...) (:29)
no_new_privs:seccomp 的前置誓言internal/core/lib/set_no_new_privs.goSetNoNewPrivs
BPF 生成:建过滤器→加规则→导出→syscall 加载internal/core/lib/seccomp.goSeccomp
seccomp 默认动作 = 杀进程internal/core/lib/seccomp.gosg.NewFilter(sg.ActKillProcess) (:16)
TSYNC 标志:过滤器同步到所有线程internal/core/lib/set_no_new_privs.goSeccompFilterFlagTSYNC
seccomp 系统调用号(amd64=317)internal/core/lib/seccomp_syscall_amd64.goSYS_SECCOMP
基础白名单(按 file-io/memory/... 分类)internal/static/python_syscall/syscalls_amd64.goALLOW_SYSCALLS
仅开网络时追加的名单internal/static/python_syscall/syscalls_amd64.goALLOW_NETWORK_SYSCALLS
返错而非杀的名单(clone/mkdir)internal/static/python_syscall/syscalls_amd64.goALLOW_ERROR_SYSCALLS
env 覆盖白名单:config 侧读取internal/static/config.goAllowedSyscalls (:140-151)
env 覆盖白名单:强制补 SETGROUPSinternal/core/lib/python/add_seccomp.gosyscall.SYS_SETGROUPS (:43)
UID 池:channel 实现 + Acquire/Releaseinternal/core/runner/uidpool/uid_pool.goUIDPool / Acquire
UID 范围 10000–11000 与懒初始化internal/core/runner/uidpool/uid_pool.goAcquireUID (:57-63)
/etc/passwd 预填,防 getpwuid 触发被封 syscallinternal/core/runner/uidpool/uid_pool.goensurePasswdEntries

同组其它章: index.md(这是什么·全景·阅读地图) · 01-request-lifecycle.md(一次执行请求的完整生命周期) · 02-sandbox-core.md(沙箱核心) · 04-fs-and-multilang.md(文件系统与多语言) · 05-cleverness-and-boundaries.md(巧妙之处与边界)。