「工作区档」此前名不副实:档位表写「本目录内可动、越界要问人」,而 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 套进边界)还没做 —— 见随后的报告。
102 lines
3.4 KiB
Go
102 lines
3.4 KiB
Go
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("未知开关应当报错")
|
||
}
|
||
}
|