#!/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 项);已知洞见上。"