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:
147
deploy/check-sandbox.sh
Executable file
147
deploy/check-sandbox.sh
Executable file
@ -0,0 +1,147 @@
|
||||
#!/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 项);已知洞见上。"
|
||||
@ -379,6 +379,19 @@ echo "==> 安装 systemd 单元"
|
||||
# 源码目录(旧版 ExecStart 指向 /home/program/agentmail/plugins/...)。
|
||||
install -d "$PREFIX/bin"
|
||||
install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"
|
||||
|
||||
# 边界工具(Landlock 沙箱):只有本机能真建立边界时才装。理由与判据见
|
||||
# redeploy-gateway.sh 同一段(am-sandbox 自己 fail closed,装个用不了的工具
|
||||
# 等于给"档位=workspace"发空头支票)。
|
||||
SANDBOX_STAGE="$(mktemp -d)/am-sandbox"
|
||||
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ) \
|
||||
&& AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
|
||||
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
|
||||
echo " 边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
|
||||
else
|
||||
echo " [警告] 本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)" >&2
|
||||
fi
|
||||
rm -f "$SANDBOX_STAGE"
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$REPO/deploy/systemd/"}"
|
||||
install -D -m 0644 "$f" "/etc/systemd/system/$rel"
|
||||
|
||||
@ -170,6 +170,30 @@ if ! install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/serv
|
||||
fi
|
||||
ok "故障通知脚本已装到 /opt/agentmail/bin/"
|
||||
|
||||
# 边界工具 am-sandbox(Landlock)—— 只有**本机真的能建立边界**时才装。
|
||||
#
|
||||
# 为什么不无条件装:`am-sandbox` 自己 fail closed(套不上边界就拒绝执行命令,退出码 126),
|
||||
# 所以在没有 Landlock 的机器上装它等于给"档位=workspace"发一张空头支票 —— 装一个
|
||||
# 用不了的工具比不装更坑。这里先真跑一遍边界判据(19 项:界内可写/界外被拒/软链接
|
||||
# 逃逸/子进程继承/对照),绿了才 install。
|
||||
#
|
||||
# 不把判据失败当致命错误:它可能只是内核老 —— 那种机器仍然可以部署网关,
|
||||
# 只是沙箱接线会在运行时 fail closed(那一步自己会叫)。
|
||||
say "1b. 边界工具(Landlock 沙箱)"
|
||||
SANDBOX_STAGE="/tmp/am-sandbox-build-$TS"
|
||||
rm -f "$SANDBOX_STAGE"
|
||||
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ); then
|
||||
if AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
|
||||
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
|
||||
ok "边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
|
||||
else
|
||||
warn "本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)"
|
||||
fi
|
||||
else
|
||||
warn "am-sandbox 构建失败(不影响网关本身)"
|
||||
fi
|
||||
rm -f "$SANDBOX_STAGE"
|
||||
|
||||
# ---------------------------------------------------------------- 2 静态检查与测试
|
||||
say "2. 构建前检查"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
|
||||
Reference in New Issue
Block a user