feat(sandbox): am-sandbox —— 给命令套 Landlock 内核边界(界内可写、界外 EACCES)

「工作区档」此前名不副实:档位表写「本目录内可动、越界要问人」,而 pi 这一路只能按
**工具名**判(bash/write/edit 一律问人),因为命令的影响范围无法从文本静态判定
(`cd /工作区 && rm -rf /opt/x` 以"进工作区"开头)。pi 自己**故意**不内置沙箱
(docs/security.md §No Built-in Sandbox:进程内的部分沙箱会被误解成安全边界,
"Real isolation needs to come from the operating system or a container boundary")——
dsh 是把沙箱放进自己的运行时;这个二进制补的是 pi 这一侧的**宿主**部分。

## 语义

写:只有 `--rw` 列出的目录(含子树)与 `--rw-file` 列出的文件可写,其余写操作一律
EACCES(内核判)。读与执行不限制 —— Landlock 只做白名单式加法,要连读都挡住得靠
容器/挂载命名空间。套不上边界时 **fail closed**(退出码 126,不执行命令):静默裸跑
会让"档位=workspace"变成谎话,而谎话比做不到更危险。

## 判据

- 行为 `deploy/check-sandbox.sh`:19 项全绿 —— 界内可写/子目录递归/rename+删除、
  读界外、执行、写 /dev/null;界外新建/建目录/覆盖/删除/删目录/跨边界 rename/
  软链接逃逸/子进程继承;**对照**(不套边界时那些"必须被拒"的命令必须成功,否则
  判据可能只是环境本来就只读);fail-closed 那条(rw 不存在 ⇒ 126 且命令未执行)。
- 算法 `cmd/am-sandbox` 单测:按 ABI 逐位裁剪、文件目标不得带目录类权限、
  allowed ⊂ handled、参数解析。

## 过程中撞到两个"看着能过"的坑

1. `--rw-file` 任意文件 → add_rule **EINVAL**:文件目标只能用文件类权限
   (MAKE_*/REMOVE_*/REFER 是目录类),而错误信息只有 "invalid argument",
   看不出是权限位不匹配。单测里"文件权限必须是 handled 的子集"就是拦它的。
2. ★ 判据自己翻车:`"$SB" … 2>&1 | grep -q "Permission denied"` 在
   `set -o pipefail` 下 —— **grep 命中即退出 → 左侧 EPIPE 失败 → 整条管道判失败**
   ⇒ 7 条"界外必须被拒"全被判成"没被拒"。改成先收输出再匹配,顺带打印真实输出。

## 已知边界(写进文件头 + 判据输出留痕,不假装没有)

- 元数据(chmod/chown/utimes)不受 Landlock 管辖:界外文件**内容**改不了,
  但**模式位**能改
- 网络出向不受限
- 界内**已存在**的、指向界外文件的硬链,经界内路径写入会改到界外(路径式沙箱固有)
- 本机 ABI=2(无 TRUNCATE);实测 `truncate` 越界仍被拒(coreutils 先 open 写 ⇒
  WRITE_FILE 挡住),所以那个缺口比纸面上窄
- **读不限制** ⇒ 以 root 跑的 agent 仍能读整机(含 /etc/agentmail/*.env)。沙箱在这里
  的价值是"界外留不下脚印",不是"拿不到东西" —— 要后者得上容器

## 接线

`redeploy-gateway.sh` / `install.sh` 在**边界判据真跑绿了**之后才装到
`/opt/agentmail/bin/am-sandbox`(装一个套不上边界的工具等于发空头支票)。
pi 桥的接线(按档位把 worker 套进边界)还没做 —— 见随后的报告。
This commit is contained in:
2026-09-14 23:33:11 +08:00
parent 2a5e3d7d15
commit 1f48c5c4ff
5 changed files with 564 additions and 0 deletions

View File

@ -0,0 +1,279 @@
// am-sandbox —— 给一个命令套上 **Landlock** 内核边界,然后 exec 它。
//
// # 为什么需要它
//
// 「工作区档」在 pi 这一路上一直是名不副实的:档位表写的是「本目录内可动、越界要问人」,
// 而实现只能按**工具名**判(bash/write/edit 一律问人)—— 因为命令的影响范围无法从文本
// 静态判定(`cd /工作区 && rm -rf /opt/x` 以"进工作区"开头)。pi 自己**故意**不内置沙箱
// (docs/security.md §No Built-in Sandbox:进程内的部分沙箱会被误解成安全边界,
// "Real isolation needs to come from the operating system or a container boundary")。
// dsh 的做法是把沙箱放进自己的运行时;这个二进制就是补上 pi 这一侧的宿主部分。
//
// # 语义(与档位表对齐)
//
// - **写**:只有 `--rw` 列出的目录/文件可写(及其子树),其余一律 EACCES —— 内核判,
// 不是提示词里"请你遵守"。
// - **读与执行**:不限制(Landlock 的规则是白名单式的加法,无法表达"除了 X 都能读";
// 真要把凭证也挡在外面需要容器/挂载命名空间那一层,见文件末尾的"已知边界")。
//
// # 为什么不用 systemd-run / bwrap
//
// systemd-run 需要 dbus 与 root,且每个 worker 会变成一条 transient scope;
// bwrap 依赖 user namespace(本机可用,但多一层依赖)。Landlock 是**进程内**的:
// 不需要额外权限、不需要守护进程,`restrict_self` 之后本进程及其所有子孙都出不去。
//
// # 用法
//
// am-sandbox --rw /home/program/x [--rw /tmp] [--rw-file /path/f] -- cmd args...
// am-sandbox --print-abi # 打印内核支持的 Landlock ABI 版本
//
// 退出码:0 = 命令正常退出;126 = 边界套不上(**fail closed**,不降级为"裸跑");
// 其余为命令自身的退出码。
//
// # 已知边界(如实写下来,不假装没有)
//
// - ABI < 3 的内核上 `truncate(2)`/`ftruncate(2)` 不受 Landlock 管辖(本机 ABI=2),
// 即"把界外某个已有文件截断为 0 字节"仍可能成功。`open(O_TRUNC)` 不受影响。
// --print-abi 就是给排查这个用的。
// - 无法限制**读**与网络出向(要那两样得上容器/网络命名空间)。
// - 硬链接:在同一受控目录里对界外文件建硬链(需要 REFER,通常会被挡),
// 但若界内**已经存在**一个指向界外文件的硬链接,经它写入走的是界内路径。
// - `PR_SET_NO_NEW_PRIVS` 是 Landlock 的前提,设了之后 setuid 提权失效
// (`sudo` 这类在沙箱内不再能提权)—— 对 agent 来说这是想要的方向。
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"unsafe"
"golang.org/x/sys/unix"
)
// 我们**要管**的权限:只挑"会改变文件系统状态"的那些。
//
// 刻意不管 READ_FILE / READ_DIR / EXECUTE:管了就等于"只允许白名单目录可读",
// 而 Landlock 没有否定式规则,会把整个系统读权限一起收掉(agent 连 /usr/include
// 都读不了)。写权限才是"越界"的实害面。
const (
fsWrite = unix.LANDLOCK_ACCESS_FS_WRITE_FILE
fsRemove = unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR
fsMake = unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | unix.LANDLOCK_ACCESS_FS_MAKE_DIR |
unix.LANDLOCK_ACCESS_FS_MAKE_REG | unix.LANDLOCK_ACCESS_FS_MAKE_SOCK |
unix.LANDLOCK_ACCESS_FS_MAKE_FIFO | unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK |
unix.LANDLOCK_ACCESS_FS_MAKE_SYM
fsRefer = unix.LANDLOCK_ACCESS_FS_REFER // ABI 2+
fsTruncate = unix.LANDLOCK_ACCESS_FS_TRUNCATE // ABI 3+
)
// handleFor 按内核 ABI 取"这一版内核认得"的写权限集合。
//
// 关键:**不能把内核不认识的位塞进 handled_access_fs** —— 那会让 create_ruleset
// 直接 EINVAL(整条边界都套不上)。所以按 ABI 逐位裁剪。
func handleFor(abi int) uint64 {
// 这些位 ABI 1 就有。
h := uint64(fsWrite | fsRemove | fsMake)
if abi >= 2 {
h |= fsRefer
}
if abi >= 3 {
h |= fsTruncate
}
return h
}
// fileHandleFor 是"规则目标是**单个文件**"时能用的写权限子集。
//
// ★ 这里有个坑值得记下来:把目录类权限(MAKE_*/REMOVE_*/REFER)用在文件目标上,
// `landlock_add_rule` 直接返回 **EINVAL**,而错误信息只说"invalid argument",
// 看不出是权限位不匹配。实测为了放行 `cmd 2>/dev/null` 给 /dev/null 加规则时才撞上。
func fileHandleFor(abi int) uint64 {
h := uint64(fsWrite) // WRITE_FILE:文件目标真正有意义的那一位
if abi >= 3 {
h |= fsTruncate
}
// 刻意**不**带 IOCTL_DEV(ABI 4 才有):我们没把它列进 handled_access_fs
// —— 规则里的 allowed_access 必须是 handled 的子集,否则内核直接 EINVAL。
// 单元测试里那条"文件权限必须是 handled 的子集"就是拦这个的。
return h
}
func abiVersion() (int, error) {
r, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0,
unix.LANDLOCK_CREATE_RULESET_VERSION)
if errno != 0 {
return 0, fmt.Errorf("landlock_create_ruleset(VERSION) 失败: %w", errno)
}
return int(r), nil
}
type opt struct {
rwDirs []string
rwFiles []string
cmd []string
}
func usage() {
fmt.Fprint(os.Stderr, `am-sandbox —— 给命令套 Landlock 内核边界后 exec 它
用法:
am-sandbox --rw <目录> [--rw <目录>]... [--rw-file <文件>]... -- <命令> [参数...]
am-sandbox --print-abi
语义:--rw 列出的目录(含子树)可写,其余路径写操作一律 EACCES;读与执行不限制。
套不上边界时**不降级**(退出码 126),绝不"静默裸跑"。
`)
}
func parseArgs(args []string) (opt, error) {
var o opt
i := 0
for i < len(args) {
a := args[i]
switch {
case a == "--":
o.cmd = args[i+1:]
return o, nil
case a == "--rw":
if i+1 >= len(args) {
return o, errors.New("--rw 后面要给目录")
}
o.rwDirs = append(o.rwDirs, args[i+1])
i += 2
case a == "--rw-file":
if i+1 >= len(args) {
return o, errors.New("--rw-file 后面要给文件路径")
}
o.rwFiles = append(o.rwFiles, args[i+1])
i += 2
case strings.HasPrefix(a, "--rw="):
o.rwDirs = append(o.rwDirs, strings.TrimPrefix(a, "--rw="))
i++
case strings.HasPrefix(a, "--rw-file="):
o.rwFiles = append(o.rwFiles, strings.TrimPrefix(a, "--rw-file="))
i++
case a == "-h" || a == "--help":
usage()
os.Exit(0)
default:
return o, fmt.Errorf("看不懂的参数 %q(命令前要有 --)", a)
}
}
return o, errors.New("缺少 `-- <命令>`")
}
// restrictSelf 把当前进程关进 Landlock 域。返回 error 时**调用方必须放弃执行命令**。
func restrictSelf(rwDirs, rwFiles []string) (int, error) {
abi, err := abiVersion()
if err != nil {
return 0, err
}
handled := handleFor(abi)
attr := unix.LandlockRulesetAttr{Access_fs: handled}
rfd, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET,
uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0)
if errno != 0 {
return abi, fmt.Errorf("create_ruleset 失败: %w", errno)
}
defer unix.Close(int(rfd))
// 目录:允许整个子树。文件:只允许这个文件本身(Landlock 的 path_beneath 对
// 文件同样成立,且不含"父目录可写")。
addRule := func(path string, isDir bool) error {
st, err := os.Stat(path)
if err != nil {
// 路径不存在就说清楚是哪一个:静默跳过 = 少一条规则而没人知道。
return fmt.Errorf("--rw%s 指向的路径不存在: %s(%w)", map[bool]string{true: "", false: "-file"}[isDir], path, err)
}
if isDir && !st.IsDir() {
return fmt.Errorf("--rw 要求目录,但 %s 不是目录", path)
}
if !isDir && st.IsDir() {
return fmt.Errorf("--rw-file 要求文件,但 %s 是目录", path)
}
fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
if err != nil {
return fmt.Errorf("打开 %s 取句柄失败: %w", path, err)
}
defer unix.Close(fd)
allowed := handled
if !isDir {
// 文件目标只能用文件类权限,否则 EINVAL(见 fileHandleFor 的注释)。
// 由此也限定了语义:`--rw-file /dev/null` 只放行"写这个设备",
// **不放行**在同目录里新建/删除任何东西。
allowed = fileHandleFor(abi)
}
pa := unix.LandlockPathBeneathAttr{Allowed_access: allowed, Parent_fd: int32(fd)}
if _, _, e := unix.Syscall(unix.SYS_LANDLOCK_ADD_RULE, rfd,
unix.LANDLOCK_RULE_PATH_BENEATH, uintptr(unsafe.Pointer(&pa))); e != 0 {
return fmt.Errorf("add_rule(%s) 失败: %w", path, e)
}
return nil
}
for _, d := range rwDirs {
if err := addRule(d, true); err != nil {
return abi, err
}
}
for _, f := range rwFiles {
if err := addRule(f, false); err != nil {
return abi, err
}
}
// Landlock 要求先 no_new_privs(否则 restrict_self 直接 EPERM)。
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return abi, fmt.Errorf("PR_SET_NO_NEW_PRIVS 失败: %w", err)
}
if _, _, e := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, rfd, 0, 0); e != 0 {
return abi, fmt.Errorf("restrict_self 失败: %w", e)
}
return abi, nil
}
func main() {
args := os.Args[1:]
if len(args) == 1 && args[0] == "--print-abi" {
abi, err := abiVersion()
if err != nil {
fmt.Fprintf(os.Stderr, "Landlock 不可用:%v\n", err)
os.Exit(126)
}
fmt.Printf("landlock abi=%d handled_write_rights=0x%x\n", abi, handleFor(abi))
return
}
o, err := parseArgs(args)
if err != nil {
fmt.Fprintf(os.Stderr, "am-sandbox: %v\n", err)
usage()
os.Exit(2)
}
// unix.Exec 是裸 execve,不查 PATH —— 先解析成绝对路径。
// 解析放在关进边界**之前**:这一步不该受边界影响,出问题也要能给出清楚的报错。
bin, err := exec.LookPath(o.cmd[0])
if err != nil {
fmt.Fprintf(os.Stderr, "am-sandbox: 找不到可执行文件 %q: %v\n", o.cmd[0], err)
os.Exit(127)
}
o.cmd[0] = bin
abi, err := restrictSelf(o.rwDirs, o.rwFiles)
if err != nil {
// ★ fail closed:套不上边界就**不执行命令**。
// 悄悄裸跑会让"档位=workspace"变成一句谎话,而谎话比做不到更危险
// (模型以为越界会被拦,于是自己不设防)。
fmt.Fprintf(os.Stderr, "am-sandbox: 边界未建立,拒绝执行(abi=%d):%v\n", abi, err)
os.Exit(126)
}
if err := unix.Exec(o.cmd[0], o.cmd, os.Environ()); err != nil {
fmt.Fprintf(os.Stderr, "am-sandbox: exec %q 失败: %v\n", o.cmd[0], err)
os.Exit(127)
}
}

View File

@ -0,0 +1,101 @@
package main
import (
"strings"
"testing"
"golang.org/x/sys/unix"
)
/*
边界工具的判据分两层:
- **行为**(真跑内核边界):`deploy/check-sandbox.sh` —— 界内可写/界外被拒/
软链接逃逸/子进程继承/对照,19 项。
- **算法**(不碰内核):这里。它们决定"套什么样的边界",错了会以很难看的方式
失败(比如把内核不认识的位塞进 handled_access_fs ⇒ create_ruleset EINVAL ⇒
**整条边界都套不上**,而错误信息只有 invalid argument)。
*/
// 按 ABI 逐位裁剪是硬要求:内核不认识的位会让 create_ruleset 直接 EINVAL。
func TestHandleForTrimsByABI(t *testing.T) {
base := uint64(fsWrite | fsRemove | fsMake)
abi1 := handleFor(1)
if abi1 != base {
t.Fatalf("ABI 1 只该有写/删/建这些位,实际 0x%x", abi1)
}
if abi1&fsRefer != 0 || abi1&fsTruncate != 0 {
t.Fatal("ABI 1 不该带上 REFER/TRUNCATE —— 内核不认识那两位")
}
abi2 := handleFor(2)
if abi2 != base|fsRefer {
t.Fatalf("ABI 2 = 基础位 + REFER,实际 0x%x", abi2)
}
if abi2&fsTruncate != 0 {
t.Fatal("ABI 2 不该带上 TRUNCATE(那是 ABI 3 的)")
}
abi3 := handleFor(3)
if abi3 != base|fsRefer|fsTruncate {
t.Fatalf("ABI 3 该带上 TRUNCATE,实际 0x%x", abi3)
}
// 单调:高 ABI 的集合必须是低 ABI 的超集(否则"升级内核反而更松")。
if abi1&^abi2 != 0 || abi2&^abi3 != 0 {
t.Fatal("ABI 集合必须单调递增")
}
}
// 文件目标只能用文件类权限:带上目录类权限会让 add_rule 返回 EINVAL
// (实测放行 `cmd 2>/dev/null` 给 /dev/null 加规则时撞上的)。
func TestFileHandleExcludesDirectoryRights(t *testing.T) {
for abi := 1; abi <= 4; abi++ {
h := fileHandleFor(abi)
if h&fsWrite == 0 {
t.Fatalf("ABI %d:文件目标必须允许 WRITE_FILE(否则这个开关毫无用处)", abi)
}
for name, bit := range map[string]uint64{
"MAKE_DIR": unix.LANDLOCK_ACCESS_FS_MAKE_DIR,
"MAKE_REG": unix.LANDLOCK_ACCESS_FS_MAKE_REG,
"REMOVE_FILE": unix.LANDLOCK_ACCESS_FS_REMOVE_FILE,
"REMOVE_DIR": unix.LANDLOCK_ACCESS_FS_REMOVE_DIR,
"REFER": fsRefer,
} {
if h&bit != 0 {
t.Fatalf("ABI %d:文件目标的权限里不该有 %s(add_rule 会 EINVAL)", abi, name)
}
}
// 规则里的 allowed 必须是 handled 的子集,否则内核对不上。
if h&^handleFor(abi) != 0 {
t.Fatalf("ABI %d:文件权限不是 handled 的子集", abi)
}
}
}
func TestParseArgs(t *testing.T) {
o, err := parseArgs([]string{"--rw", "/a", "--rw=/b", "--rw-file", "/dev/null", "--", "bash", "-c", "x"})
if err != nil {
t.Fatal(err)
}
if strings.Join(o.rwDirs, ",") != "/a,/b" {
t.Fatalf("rw 目录解析错了:%v", o.rwDirs)
}
if strings.Join(o.rwFiles, ",") != "/dev/null" {
t.Fatalf("rw 文件解析错了:%v", o.rwFiles)
}
if strings.Join(o.cmd, " ") != "bash -c x" {
t.Fatalf("命令解析错了:%v", o.cmd)
}
// 缺 `--` 必须报错:否则命令会被当成未知开关,而"什么都没有"看起来像"没配 rw"。
if _, err := parseArgs([]string{"--rw", "/a", "bash"}); err == nil {
t.Fatal("缺 `--` 应当报错")
}
if _, err := parseArgs([]string{"--rw"}); err == nil {
t.Fatal("--rw 后面没值应当报错")
}
if _, err := parseArgs([]string{"--bogus", "--", "true"}); err == nil {
t.Fatal("未知开关应当报错")
}
}