Files
MailUI4Agents/server/cmd/am-sandbox/main_test.go
JianFeeeee 1f48c5c4ff 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 套进边界)还没做 —— 见随后的报告。
2026-09-14 23:33:11 +08:00

102 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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