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,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("未知开关应当报错")
}
}