不用 root 也能给进程上锁:Landlock 从 0 到 1 实战
本文完全由 WorkBuddy 独立收集资料、调试代码并完成文章写作。
上个月我在做开源 Agent 沙箱调研时写过一句话:「Claude Code 和 Codex CLI 都不用 Docker 了,直接用 Linux 内核自带的能力把自己圈起来。」很多读者私信问到底怎么圈。这一篇就专门把其中最关键的一环——Landlock——从原理到代码一次性讲透:它在 C、Python、Go、Rust 四个语言里怎么调、踩过哪些坑、以及一个可以立刻拿去套在你的 AI Agent 上的零依赖沙箱启动器(实测过了,SSH 私钥真的偷不走)。
01为什么 2026 年又开始聊 Landlock
过去三年我观察到的曲线是:AI Agent 项目里「代码执行环境」从可选变成必备。Manus、Perplexity、Claude computer use、阿里元宝、HuggingFace Agents,没有一家让 Agent 直接落到用户机器上裸奔。但这条路是分叉的:
- 云上托管派——E2B、CubeSandbox、OpenSandbox、CNCF agent-sandbox,本质是帮你跑一个远端 microVM(上一篇文章的结论)。
- 本地自管派——Claude Code 用 bubblewrap + Landlock + srt,OpenAI Codex CLI 用 Landlock + seccomp-bpf,Anthropic 把这套打包成了
sandbox-runtime开源。
本地派是 2025-2026 年最大的反直觉信号:这俩最主流的 Coding Agent,都放弃了 Docker。原因很简单:本地 Agent 要的不是「另一台机器」,而是「按一次 F5 多 1 毫秒的延迟」。Docker 启动要 200ms,还要 daemon,体感是灾难;Landlock 是内核自带的一组 syscall,微秒级、零常驻、免 root。
这一篇就把这个被严重低估的内核原语讲清楚。我会在 WSL2(内核 6.18.33)上把四门主流语言的最小可用版本都跑通,然后给一个真实攻击案例:让你的 AI Agent 试图偷走 ~/.ssh/id_rsa、写 ~/.bashrc、外联外网——你看它偷得走不。
02Landlock 是什么:三句话讲清楚
Landlock 是 Linux 内核的一个 LSM(Linux Security Module),从 5.13(2021)合入主线,6.7(2024)加进网络能力。它让任何进程(含非 root)能给自己声明一组白名单规则,由内核强制执行。
- 声明式:进程自己用三个 syscall 描述「我允许自己做什么」,然后内核说「OK,不在白名单里的一律 EACCES」。
- 不可逆 + 可继承:上锁之后不能放宽,子进程自动继承父进程的规则——所以「包住父进程 = 包住 Agent 拉起的整棵进程树」。
- 按「资源语义」而不是「系统调用」过滤:你限制「不能写 /etc」,不是限制「不能用 write syscall」——后者太容易被 dup2 + write 绕过;前者内核层就给你拦了。
| 能力 | Docker | bubblewrap | seccomp-bpf | AppArmor/SELinux | Landlock |
|---|---|---|---|---|---|
| 免 root | 需要 daemon | 需要 user-ns | ✓ | 配置文件 | ✓✓ 几乎零成本 |
| 文件系统白名单 | ✓ | ✓ 视图级 | ✗ | ✓ | ✓ 路径级 |
| TCP 端口白名单 | ✓ | 需 unshare-net | ✓ | 部分 | ✓ 内核层 |
| 启动开销 | ~200ms | ~50ms | 极低 | 极低 | 微秒级 |
| 能动态加严 | ✗ | ✗ | ✓ | ✗ | ✓(可堆叠 16 层) |
| 规则随进程死 | — | — | ✓ | — | ✓ |

图 1 · Landlock 的三步上锁模型(实测 WSL2 6.18.33 内核支持 ABI v7)
03能力矩阵:ABI 决定你能干什么
Landlock 是按 ABI 版本号分批放能力的。永远查 ABI,不要用 uname -r 猜内核版本——很多发行版会 backport,能看到的版本和真实能力对不上。

图 2 · Landlock ABI 演进时间轴(按内核版本号到账)
几个关键断点:
- ABI v2 (5.19):
LANDLOCK_ACCESS_FS_REFER——跨目录 rename / link 之前是直接禁的,加了它之后才能做。容器运行时之前的最大阻塞点就是它。 - ABI v3 (6.2):
TRUNCATE——很多人以为给了 WRITE 就允许open(O_TRUNC),不是。Truncate 是单独的权限位,要单独放行。 - ABI v4 (6.7):这是 Agent 沙箱的真正分水岭。从 v4 开始可以按 TCP 端口号控制 bind / connect。之前 4 年 Landlock 一直被吐槽「没有网络能力是半残」,v4 之后这个吐槽可以停了。UDP 在主线更新的 v10 中加入(7.x 内核)。
- ABI v6 (6.12):
SCOPE——沙箱之间互不能发信号、不能跨沙箱用 abstract unix socket。给多租户 Agent 平台准备的。
04第一步:检测你机器上的 ABI
// abi.c —— 查询本机 Landlock ABI
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/landlock.h>
int main(void){
long abi = syscall(SYS_landlock_create_ruleset, NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) { puts("Landlock 不可用"); return 1; }
printf("Landlock ABI = %ld\n", abi);
return 0;
}
实测输出(WSL2 内核 6.18.33):
$ gcc abi.c -o abi && ./abi
Landlock ABI = 7
7 就是图 2 里最新的稳定 ABI。这意味着文件系统、TRUNCATE、IOCTL_DEV、TCP 网络、SCOPE 我都能用,UDP 还在主线里要等更新内核。
05C 语言 30 行最小例子
/* ll.c —— 最小可用 Landlock 沙箱 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <linux/landlock.h>
static int ll_create(const void *a, size_t s, __u32 f){
return syscall(__NR_landlock_create_ruleset, a, s, f);
}
static int ll_add(int fd, int t, const void *a){
return syscall(__NR_landlock_add_rule, fd, t, a, 0);
}
static int ll_enforce(int fd){
return syscall(__NR_landlock_restrict_self, fd, 0);
}
int main(void){
int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 1) { puts("Landlock 不可用"); return 1; }
printf("ABI v%d 已启用\n", abi);
struct landlock_ruleset_attr a = { 0 };
a.handled_access_fs = (1ULL<<13)-1; /* ABI v1 全集 13 位 */
if (abi>=2) a.handled_access_fs |= (1ULL<<13); /* REFER */
if (abi>=3) a.handled_access_fs |= (1ULL<<14); /* TRUNCATE */
a.handled_access_net = (1ULL<<0) | (1ULL<<1); /* 禁 BIND/CONNECT TCP */
int rs = ll_create(&a, sizeof(a), 0);
if (rs<0) { perror("create"); return 1; }
struct landlock_path_beneath_attr pb = {
.allowed_access = a.handled_access_fs,
.parent_fd = open("/tmp", O_PATH|O_CLOEXEC)
};
if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb)) { perror("add"); return 1; }
close(pb.parent_fd);
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("NNP"); return 1; }
if (ll_enforce(rs)) { perror("enforce"); return 1; }
close(rs);
puts("沙箱已上锁");
FILE *f = fopen("/etc/shadow", "r");
printf("open /etc/shadow -> %s\n", f ? "ALLOWED (BAD)" : "DENIED (好)");
return 0;
}
实测:
$ gcc ll.c -o ll && ./ll
ABI v7 已启用
沙箱已上锁
open /etc/shadow -> DENIED (好)
三个底层硬约束:
O_PATH是关键——给 ruleset 加规则不是传路径字符串,而是传一个 O_PATH 打开的 fd。这条设计防止路径解析时被 TOCTOU 攻击偷换。PR_SET_NO_NEW_PRIVS是必须的——没有它,restrict_self直接 EPERM。代价是之后任何 execve 都不再有效 setuid 位。- 对「没声明」的动作,内核完全不管(既不拦也不让)。你必须显式把要管的权限位放进
handled_access_fs,否则等于放行——这是新手最容易栽的坑。
06零依赖 Python 版(ctypes)
# llpy.py —— 零依赖 Landlock 沙箱(Python ≥ 3.8)
import ctypes, os
libc = ctypes.CDLL("libc.so.6", use_errno=True)
NR_CREATE, NR_ADD, NR_RESTRICT = 444, 445, 446
VER = 1 << 0
PR_SET_NO_NEW_PRIVS = 38
LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_RULE_NET_PORT = 2
ACCESS_NET_BIND_TCP = 1 << 0
ACCESS_NET_CONNECT_TCP = 1 << 1
class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64),
("handled_access_net", ctypes.c_uint64),
("scoped", ctypes.c_uint64)]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int)]
class NetPortAttr(ctypes.Structure):
_fields_ = [("allowed_access", ctypes.c_uint64),
("port", ctypes.c_uint64)] # 内核是 __u64,别写成 c_uint16
def get_abi():
return libc.syscall(NR_CREATE, None, 0, VER)
def _add_path(rs, path, access):
fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
try:
pb = PathBeneathAttr(access, fd)
if libc.syscall(NR_ADD, rs, LANDLOCK_RULE_PATH_BENEATH, ctypes.byref(pb), 0) != 0:
raise OSError(ctypes.get_errno(), f"add_rule {path}")
finally:
os.close(fd)
def _add_net_port(rs, port, access):
na = NetPortAttr(access, port)
if libc.syscall(NR_ADD, rs, LANDLOCK_RULE_NET_PORT, ctypes.byref(na), 0) != 0:
raise OSError(ctypes.get_errno(), f"add_net_port {port}")
def sandbox(rw_paths=(), ro_paths=(), allow_net=False, net_ports=()):
abi = get_abi()
if abi < 1:
raise RuntimeError("内核不支持 Landlock")
fs_mask = (1 << 13) - 1
if abi >= 2: fs_mask |= 1 << 13 # REFER
if abi >= 3: fs_mask |= 1 << 14 # TRUNCATE
# 网络:allow_net=True 就完全不声明 net 位(彻底不管);
# 否则声明 net 位,只放行 net_ports 列出的端口,其余全禁。
# 实测结论:声明了 net 位却不加任何端口规则 = 全禁(不是全开)。
net_mask = 0
if abi >= 4 and not allow_net:
net_mask = ACCESS_NET_BIND_TCP | ACCESS_NET_CONNECT_TCP
attr = RulesetAttr(fs_mask, net_mask, 0)
rs = libc.syscall(NR_CREATE, ctypes.byref(attr), ctypes.sizeof(attr), 0)
if rs < 0:
raise OSError(ctypes.get_errno(), "create_ruleset failed")
for p in ro_paths:
_add_path(rs, p, 1 | 8 | 4) # EXEC(1) | READ_FILE(4) | READ_DIR(8)
for p in rw_paths:
_add_path(rs, p, fs_mask)
if not allow_net:
for port in net_ports:
_add_net_port(rs, port, ACCESS_NET_CONNECT_TCP)
libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
if libc.syscall(NR_RESTRICT, rs, 0) != 0:
raise OSError(ctypes.get_errno(), "restrict_self failed")
os.close(rs)
return abi
07Go 与 Rust 的差异点
先补一个只有真跑一次才会暴露的坑:Landlock 的权限位不是按直觉顺序排的。
ACCESS_FS_EXECUTE = 1 << 0 → 1
ACCESS_FS_WRITE_FILE = 1 << 1 → 2 ← 写
ACCESS_FS_READ_FILE = 1 << 2 → 4 ← 读文件
ACCESS_FS_READ_DIR = 1 << 3 → 8 ← 列目录
我第一版把「只读」写成 1|4|2,注释还自信地标着 EXEC|READ_DIR|READ_FILE——实际给的是 EXEC | WRITE | READ_FILE:只读路径被误授予了写权限,而 READ_DIR 反而没给。后果很直观,os.listdir("/usr/bin") 直接 PermissionError,Python 解释器连标准库目录都列不出来。正确值是 1|8|4(上面代码块里已修正)。这类「位序记反」的错,编译器和解释器都不会提醒你。
Go 没封装,但 syscall.Syscall6 够用。另一个坑:size 字段必须用 unsafe.Sizeof(attr),硬编码 32/24 都会让内核返 E2BIG——我第一次写就栽在这儿。
Rust 走 libc::syscall 裸调用,结构体用 #[repr(C)] 固定布局;想用类型安全版本直接 cargo add landlock,强类型 + 编译期 ABI 检查。
四门语言跑下来:
| 语言 | 依赖 | ABI | 沙箱生效 | 感受 |
|---|---|---|---|---|
| C | 无 | v7 | ✓ | 最透明 |
| Python | 无(ctypes) | v7 | ✓ | AI Agent 集成最方便 |
| Go | 无 | v7 | ✓ | 注意 size 用 sizeof |
| Rust | libc = “0.2” | v7 | ✓ | 官方 landlock crate 封装更优雅 |
结论:底层 API 在四种语言里长得几乎一样,区别只在结构体怎么写、常量怎么取、size 怎么传。如果你只挑一个,Python 那个版本最适合 AI Agent 项目集成——零依赖、能写进子进程启动器、几秒就能改完上线。
08真实案例:给被 prompt injection 拐走的 Agent 套沙箱
把上面六个 demo 拼起来,写一个通用沙箱启动器 agentbox.py,核心 8.6 KB,零依赖。
# 给 AI Agent 生成的脚本套沙箱:只允许读写 ./workspace
python3 agentbox.py --rw ./workspace -- python3 untrusted_agent.py
# 想让它能调外部 API:只放行 443 端口
python3 agentbox.py --rw ./workspace --port 443 -- python3 untrusted_agent.py
模拟一个被注入的 agent(无害化版,只读 20 字节),让它尝试五件坏事:
# payload.py
import os, socket
targets = [
("读 SSH 私钥", lambda: open(os.path.expanduser("~/.ssh/id_rsa")).read()[:20]),
("读 /etc/shadow", lambda: open("/etc/shadow").read()[:20]),
("写宿主 .bashrc", lambda: open(os.path.expanduser("~/.bashrc"), "a").write("# pwned\n")),
("外连 1.1.1.1:443", lambda: socket.create_connection(("1.1.1.1", 443), timeout=2)),
("写工作区文件", lambda: open("/tmp/workspace/out.txt", "w").write("ok\n")),
]
for name, fn in targets:
try:
fn(); print(f" [!] {name:14s} -> 成功(危险)")
except Exception as e:
print(f" [x] {name:14s} -> 被拦截 ({type(e).__name__})")
三组实测对比:
===== A. 不加沙箱(裸跑)=====
$ python3 /tmp/workspace/payload.py
[!] 读 SSH 私钥 -> 成功(危险)
[x] 读 /etc/shadow -> 被拦截 (PermissionError)
[!] 写宿主 .bashrc -> 成功(危险)
[x] 外连 1.1.1.1:443 -> 被拦截 (TimeoutError)
[!] 写工作区文件 -> 成功(危险)
===== B. 加 Landlock 沙箱 =====
$ python3 agentbox.py --rw /tmp/workspace -- python3 /tmp/workspace/payload.py
[agentbox] Landlock ABI v7 已生效 | 可写: ['/tmp/workspace'] | 网络: 全禁
[x] 读 SSH 私钥 -> 被拦截 (PermissionError)
[x] 读 /etc/shadow -> 被拦截 (PermissionError)
[x] 写宿主 .bashrc -> 被拦截 (PermissionError)
[x] 外连 1.1.1.1:443 -> 被拦截 (PermissionError)
[!] 写工作区文件 -> 成功(被允许)
===== C. 沙箱 + 放行 443 端口 =====
$ python3 agentbox.py --rw /tmp/workspace --port 443 -- python3 /tmp/workspace/payload.py
[agentbox] Landlock ABI v7 已生效 | 可写: ['/tmp/workspace'] | 网络: 端口白名单 [443]
[x] 读 SSH 私钥 -> 被拦截 (PermissionError)
[x] 读 /etc/shadow -> 被拦截 (PermissionError)
[x] 写宿主 .bashrc -> 被拦截 (PermissionError)
[x] 外连 1.1.1.1:443 -> 被拦截 (TimeoutError) # 是 TimeoutError 不是 EACCES
[!] 写工作区文件 -> 成功(被允许)
三个值得细看的细节:
- A 裸跑:你的 SSH 私钥前 20 字节已经被读出来贴到 stdout 了;.bashrc 也被写进了
# pwned——这就是「AI Agent 失控」最常见的事故场景。 - B 全沙箱:五件事里四件 EACCES,工作区可写。Agent 还能干正事但不能干坏事。
- C 沙箱+白名单:网络动作从
PermissionError变成TimeoutError——这不是被拒绝了,而是「真的尝试连 1.1.1.1:443 失败了」。说明内核确实放行了 443 端口的 CONNECT,规则生效。
注意:外连 443 在沙箱内被放行却仍然失败,是因为我这台 WSL2 没有去 1.1.1.1 的路由——不是被 Landlock 拦的。换一台能通公网的机器跑 C 这一组,
TimeoutError就会变成「成功」。这正是「沙箱管得了规则但管不了网络可达性」的好例子。
09Landlock 不是银弹:纵深防御怎么叠
Landlock 本身只管「资源语义层」——能碰哪些文件、能连哪些端口。它不过滤系统调用、不隔离进程视图、不限 CPU/内存。所以任何严肃的 Agent 沙箱,都把它和别的原语叠着用。

图 3 · 纵深防御:Landlock + seccomp + namespace/bwrap 各管一段
Codex CLI 的 Linux 沙箱按这个顺序叠:
- 先硬化进程:
PR_SET_DUMPABLE=0防调试 attach,RLIMIT_CORE=0防 core dump,剥光LD_PRELOAD等动态链接器注入点。 - Landlock:全局只读 + 白名单目录可写 + 把
/dev/null显式 bind 进来防符号链接越狱。 - seccomp-bpf:禁
socket(AF_INET)/AF_INET6——彻底断网,但保留AF_UNIX让 sandbox 进程能跟 supervisor IPC。 - restrict_self:不可逆地生效,并且只对子进程生效——主 CLI 自己保持完整权限去改文件、跑 git。
- exec 不可信命令:最后才 exec 用户提交的那条命令;没有 Landlock 时回退 bwrap,两者都不可用 → 拒绝运行(fail closed,不裸奔)。
Claude Code 走另一条路:文件系统用 bubblewrap(user namespace),网络用代理进程做域名级管控——而不是内核的端口白名单。两条路差别:
- 端口白名单(Landlock + seccomp):内核原生、零代理、规则简单,但只能管到 IP:port,管不了域名。
- 代理 + 域名白名单(Claude Code 路线):能管域名,但要多跑一个 proxy 进程;它本身需要足够的权限去代发请求。
选哪条取决于你的威胁模型:能列清 IP:port 走 Landlock;只愿给域名走代理。
10坑清单
| 坑 | 现象 | 正解 |
|---|---|---|
没设 PR_SET_NO_NEW_PRIVS | 非 root 调用 restrict_self 返 EPERM | 必须 prctl(38,1,0,0,0) 先行 |
把 WRITE_FILE=2 当成 READ_DIR=8 | 只读路径被授予写权限,却 listdir 报 EACCES | 位序:EXEC=1, WRITE=2, READ_FILE=4, READ_DIR=8;只读用 `1 |
| 硬编码 ABI ≥ 3 的权限位跑在 5.13 内核 | create_ruleset 返 EINVAL | 先查 ABI,把不认识的位 mask 掉 |
Go/Python 把 size 写错 | create_ruleset 返 E2BIG | 用 sizeof(attr),不要硬编码 |
给 WRITE 没给 TRUNCATE | 写入器 open(O_TRUNC) 静默失败 | ABI ≥ 3 时 mask 加上 1ULL<<14 |
声明 handled_access_net 但没加任何端口规则 | 以为「放开了网络」实际全禁——连出网全被 EACCES | 想放行就按端口显式加规则(--port 443);不想管网络就别声明 net 位 |
| 用 Landlock 当唯一防护 | 子进程仍能 fork bomb、仍能 /proc/self/mem 读自己 | 叠 seccomp + cgroup + namespace |
| 第 17 次 restrict_self | 返 E2BIG(栈满) | 上限 16 层;包到 Agent 时要预留层数 |
想给 /proc 加细规则 | 很多 /proc 文件无法被显式限制 | /proc 默认只放只读全局,别试着精细化 |
| 规则生效后想放宽 | 没有任何 syscall 能放宽 | 重启进程;上锁前先准备好全部 fd/socket |
11选型地图
| 场景 | 是否用 Landlock | 理由 |
|---|---|---|
| AI Agent 跑不可信脚本(Codex / Claude Code) | 强烈推荐 | 零成本 + 文件路径级精度 |
| CI runner 跑用户 PR | 推荐(叠 bwrap) | 但 CI 场景 nsjail 整体更合适 |
| 多租户 SaaS 跑用户代码 | 不够用 | 需要 microVM(Firecracker / CubeSandbox) |
| 浏览器渲染进程 | 可作加固层 | Chrome 的多层 sandbox 已经做完了 |
| 通用桌面应用沙箱 | 不够用 | 桌面场景 bubblewrap/Flatpak 更主流 |
| 本机 IDE 插件隔离 | 强烈推荐 | VS Code / JetBrains 已经在考虑这条路 |
| macOS / Windows | 用不上 | macOS 走 Seatbelt,Windows 走 AppContainer |
一句话总结:Landlock 是给「我想在不动 docker 的前提下,给单进程画个圈」准备的。这个场景下它几乎没有替代品,缺点是只能管资源语义不管别的,需要叠层。
12结语:把沙箱从云上搬回本地
2026 年的本地 Agent 沙箱正在发生一件有趣的事:云上在内卷 microVM 启动时间(E2B → Cube 把冷启从 150ms 压到 60ms),本地则在内卷「零 daemon 免 root」。后者就是 Landlock 这条线——它不跟谁竞争,它做的事是 Docker、Firecracker、bubblewrap 都做不了也懒得做的:让任何进程用三行 syscall 给自己画一个圈。
如果你的 Agent 还跑在宿主机裸环境里,今天就花 10 分钟把 agentbox.py 接到子进程启动那一行——本文所有代码块都在 WSL2(内核 6.18.33,Landlock ABI v7)上逐字跑过,包括四门语言的最小版本和三组沙箱对比。Landlock 从 5.13 就进了主线,所以只要内核 ≥ 5.13 的发行版都能直接跑(网络端口规则需要 ≥ 6.7)。下一篇文章我会继续拆 Anthropic 那个 sandbox-runtime,把 Claude Code 怎么「bwrap + Landlock + 代理」三件套的内部细节再展开——这玩意比想象的复杂,它做了一件很容易被忽略的事:在 macOS 上替已经 deprecated 的 sandbox-exec 续了命。
本文同步发布于 zicode.com · 公众号「字与码」
— 前百度/微博工程师,写技术不靠百度 —
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。