Files
MailUI4Agents/deploy/check-sandbox.sh
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

148 lines
7.3 KiB
Bash
Executable File
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.

#!/usr/bin/env bash
# 边界判据:am-sandbox 真的把「界外写」挡住了吗?
#
# # 为什么要点名"两侧都验"
#
# 只说"界外被拒"是不够的:**把 rw 列成空**、或者干脆不启动进程,都能让"界外被拒"成立,
# 而那种沙箱没法用(界内也写不了)。所以同时验界内可写、可读、可执行,并且加一条
# **对照**:不套边界时,那些"必须被拒"的命令**必须成功** —— 否则"被拒"可能只是这台
# 机器上那个路径本来就只读(同一条断言在只读环境里恒真 = 假绿)。
#
# # 写 /dev/null 必须放行(实测踩到的)
#
# 第一版判据把 `ls / >/dev/null` 当"只是输出重定向"—— 错了:**重定向到 /dev/null
# 本身就是一次界外写**,于是一条 `cmd 2>/dev/null` 全线失败。任何 `rw` 集合都必须
# 显式带上 `--rw-file /dev/null`,否则 agent 的日常命令(`git status 2>/dev/null`)
# 会集体报 Permission denied。这条在下面 ① 里有专门的判据。
#
# # 已知洞:如实记录,不参与退出码
#
# Landlock 管的是**文件内容**那一层:元数据(chmod/chown/utimes)、网络、以及
# "界内已存在的指向界外文件的硬链"都不在它的管辖里。这些洞用 `[观察 ]` 打出来,
# 免得下次有人以为"有沙箱 = 什么都挡住了";哪天机制变了,输出会跟着变。
#
# 用法:
# bash deploy/check-sandbox.sh # 用 /opt/agentmail/bin/am-sandbox
# AM_SANDBOX=/var/tmp/am-sandbox bash deploy/check-sandbox.sh
set -uo pipefail
SB="${AM_SANDBOX:-/opt/agentmail/bin/am-sandbox}"
if [ ! -x "$SB" ]; then
echo "找不到可执行的沙箱二进制:$SB" >&2
echo "(构建:cd server && go build -trimpath -o /var/tmp/am-sandbox ./cmd/am-sandbox)" >&2
exit 2
fi
BASE="${SANDBOX_CHECK_BASE:-/var/tmp}"
WS="$(mktemp -d "$BASE/am-sandbox-check-XXXXXX")" || exit 2
OUTDIR="$(mktemp -d "$BASE/am-sandbox-out-XXXXXX")" || exit 2
cleanup() { rm -rf "$WS" "$OUTDIR"; }
trap cleanup EXIT
# ★ 标准 rw 集合:界内目录 + /dev/null。见文件头那段说明。
RW=(--rw "$WS" --rw-file /dev/null)
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf ' [ OK ] %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' [FAIL] %s\n' "$1"; }
observe() { printf ' [观察 ] %s\n' "$1"; }
# 套边界跑一条命令(关心退出码时用)
inbox() { "$SB" "${RW[@]}" -- bash -c "$1" >/dev/null 2>&1; }
# 不套边界跑同一条(对照组)
raw() { bash -c "$1" >/dev/null 2>&1; }
# 「被拒」= 退出码非 0 **且**错误里带 Permission denied。
#
# ★ 不要写成 `"$SB" ... 2>&1 | grep -q "Permission denied"`:`grep -q` 命中后立刻退出、
# 把管道关掉,左侧的沙箱进程拿到 EPIPE 而失败;在 `set -o pipefail` 下整条管道因此
# 判为失败 —— **"被拒"和"被拒判据本身炸了"长得一模一样**。判据第一版就是这么
# 把 7 条全判成"没被拒"的。改成先收输出再匹配,顺带能打印真实输出。
denied() {
local out
out="$("$SB" "${RW[@]}" -- bash -c "$1" 2>&1)"
case "$out" in
*"Permission denied"*) return 0;;
*) printf ' 实际输出:%s\n' "${out:-(空)}"; return 1;;
esac
}
echo "边界判据(沙箱:$SB)"
echo " 界内 $WS"
echo " 界外 $OUTDIR"
echo
echo "① 功能侧:界内照常"
inbox "touch $WS/a" && ok "界内新建文件" || bad "界内新建文件(沙箱不该影响界内)"
inbox "mkdir -p $WS/sub && touch $WS/sub/b" && ok "界内子目录可写(递归)" || bad "界内子目录可写"
inbox "echo hi > $WS/a && grep -q hi $WS/a" && ok "界内改写 + 读回" || bad "界内改写 + 读回"
inbox "mv $WS/a $WS/b && rm -f $WS/b" && ok "界内 rename + 删除" || bad "界内 rename + 删除"
inbox "cat /etc/hostname" && ok "读界外仍允许(不限制读)" || bad "读界外"
inbox "ls / | head -1" && ok "执行仍允许" || bad "执行"
inbox "echo x >/dev/null" && ok "写 /dev/null 允许(否则 2>/dev/null 全线失效)" || bad "写 /dev/null 被拦"
echo
echo "② 边界侧:界外必须被拦"
denied "touch $OUTDIR/new" && ok "界外新建文件被拒" || bad "界外新建文件**没被拒**"
denied "mkdir $OUTDIR/d" && ok "界外建目录被拒" || bad "界外建目录**没被拒**"
denied "echo x > $OUTDIR/doc" && ok "界外覆盖已有文件被拒" || bad "界外覆盖已有文件**没被拒**"
# 先让界外真的有个文件(上一句被拒了,它并不存在 —— 否则 `rm -f` 对不存在的文件
# 照样返回 0,判据会以"没被拒"的假象失败)
raw "echo doc > $OUTDIR/doc"
denied "rm -f $OUTDIR/doc" && ok "界外删除被拒" || bad "界外删除**没被拒**"
raw "mkdir -p $OUTDIR/dir"
denied "rmdir $OUTDIR/dir" && ok "界外删目录被拒" || bad "界外删目录**没被拒**"
denied "mv $WS/sub/b $OUTDIR/moved" && ok "跨边界 rename 被拒" || bad "跨边界 rename **没被拒**"
inbox "ln -s /etc/hostname $WS/link" \
&& { denied "echo x > $WS/link" && ok "软链接逃逸被拒(写的是链的目标)" || bad "软链接逃逸**没被拒**"; } \
|| bad "软链接夹具建不出来(判据自身有问题)"
denied "bash -c 'touch $OUTDIR/nested'" && ok "子进程继承边界" || bad "子进程**逃出了**边界"
echo
echo "③ 对照:不套边界时,上面那些「必须被拒」的命令必须成功"
raw "touch $OUTDIR/c1" && ok "对照:界外新建本来可行" || bad "对照失败(这个环境本来就写不了界外 ⇒ ② 的判据恒真)"
raw "echo x > $OUTDIR/c2" && ok "对照:界外覆盖本来可行" || bad "对照失败(环境限制把判据变成假绿)"
raw "rm -f $OUTDIR/c1" && ok "对照:界外删除本来可行" || bad "对照失败"
echo
echo "④ 套不上边界时必须「不执行命令」(fail closed)"
"$SB" --rw "$WS/does-not-exist" -- bash -c "touch $WS/should-not-exist" >/dev/null 2>&1
code=$?
if [ -e "$WS/should-not-exist" ]; then
bad "rw 路径不存在时命令仍被执行 —— 静默裸跑"
elif [ "$code" = 126 ]; then
ok "套不上边界 → 退出码 126 且命令未执行"
else
bad "退出码是 $code(期望 126:明确的「边界未建立」)"
fi
echo
echo "⑤ 已知洞(如实记录,不参与退出码)"
echo probe > "$OUTDIR/lim"
if inbox "chmod 600 $OUTDIR/lim"; then
observe "元数据不受 Landlock 管辖:界外文件的模式位仍能改(内容改不了)"
else
observe "chmod 被拒(机制与预期不同,值得复核)"
fi
if inbox "truncate -s 0 $OUTDIR/lim" 2>/dev/null; then
observe "truncate 越界成功(ABI<3 的已知缺口)"
else
observe "truncate 越界被拒(本机 $( "$SB" --print-abi 2>/dev/null ))"
fi
if inbox "timeout 2 bash -c 'echo > /dev/tcp/127.0.0.1/8180'"; then
observe "网络出向不受限(要挡得上网络命名空间)"
fi
ln /etc/hostname "$WS/hardlink" 2>/dev/null || true
if [ -e "$WS/hardlink" ] && inbox "echo x > $WS/hardlink"; then
observe "界内已存在的「指向界外文件的硬链」可以经界内路径写入(路径式沙箱的固有缺口)"
fi
rm -f "$OUTDIR/lim"
echo
echo " abi: $("$SB" --print-abi 2>&1)"
if [ "$FAIL" != 0 ]; then
echo " 边界判据失败 $FAIL 项(通过 $PASS 项)—— 不要用它去替换人工闸门" >&2
exit 1
fi
echo " 边界判据全绿($PASS 项);已知洞见上。"