From 1f48c5c4ffbf14197a768c2ef1ab79ab4356191f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 23:33:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20am-sandbox=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=BB=99=E5=91=BD=E4=BB=A4=E5=A5=97=20Landlock=20?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E8=BE=B9=E7=95=8C=EF=BC=88=E7=95=8C=E5=86=85?= =?UTF-8?q?=E5=8F=AF=E5=86=99=E3=80=81=E7=95=8C=E5=A4=96=20EACCES=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「工作区档」此前名不副实:档位表写「本目录内可动、越界要问人」,而 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 套进边界)还没做 —— 见随后的报告。 --- deploy/check-sandbox.sh | 147 +++++++++++++++ deploy/install.sh | 13 ++ deploy/redeploy-gateway.sh | 24 +++ server/cmd/am-sandbox/main.go | 279 +++++++++++++++++++++++++++++ server/cmd/am-sandbox/main_test.go | 101 +++++++++++ 5 files changed, 564 insertions(+) create mode 100755 deploy/check-sandbox.sh create mode 100644 server/cmd/am-sandbox/main.go create mode 100644 server/cmd/am-sandbox/main_test.go diff --git a/deploy/check-sandbox.sh b/deploy/check-sandbox.sh new file mode 100755 index 0000000..3b94357 --- /dev/null +++ b/deploy/check-sandbox.sh @@ -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 项);已知洞见上。" diff --git a/deploy/install.sh b/deploy/install.sh index 503297e..c99bed0 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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" diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index ec97e8d..93f8a9d 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -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 diff --git a/server/cmd/am-sandbox/main.go b/server/cmd/am-sandbox/main.go new file mode 100644 index 0000000..43a8946 --- /dev/null +++ b/server/cmd/am-sandbox/main.go @@ -0,0 +1,279 @@ +// am-sandbox —— 给一个命令套上 **Landlock** 内核边界,然后 exec 它。 +// +// # 为什么需要它 +// +// 「工作区档」在 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` 列出的目录/文件可写(及其子树),其余一律 EACCES —— 内核判, +// 不是提示词里"请你遵守"。 +// - **读与执行**:不限制(Landlock 的规则是白名单式的加法,无法表达"除了 X 都能读"; +// 真要把凭证也挡在外面需要容器/挂载命名空间那一层,见文件末尾的"已知边界")。 +// +// # 为什么不用 systemd-run / bwrap +// +// systemd-run 需要 dbus 与 root,且每个 worker 会变成一条 transient scope; +// bwrap 依赖 user namespace(本机可用,但多一层依赖)。Landlock 是**进程内**的: +// 不需要额外权限、不需要守护进程,`restrict_self` 之后本进程及其所有子孙都出不去。 +// +// # 用法 +// +// am-sandbox --rw /home/program/x [--rw /tmp] [--rw-file /path/f] -- cmd args... +// am-sandbox --print-abi # 打印内核支持的 Landlock ABI 版本 +// +// 退出码:0 = 命令正常退出;126 = 边界套不上(**fail closed**,不降级为"裸跑"); +// 其余为命令自身的退出码。 +// +// # 已知边界(如实写下来,不假装没有) +// +// - ABI < 3 的内核上 `truncate(2)`/`ftruncate(2)` 不受 Landlock 管辖(本机 ABI=2), +// 即"把界外某个已有文件截断为 0 字节"仍可能成功。`open(O_TRUNC)` 不受影响。 +// --print-abi 就是给排查这个用的。 +// - 无法限制**读**与网络出向(要那两样得上容器/网络命名空间)。 +// - 硬链接:在同一受控目录里对界外文件建硬链(需要 REFER,通常会被挡), +// 但若界内**已经存在**一个指向界外文件的硬链接,经它写入走的是界内路径。 +// - `PR_SET_NO_NEW_PRIVS` 是 Landlock 的前提,设了之后 setuid 提权失效 +// (`sudo` 这类在沙箱内不再能提权)—— 对 agent 来说这是想要的方向。 +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + "unsafe" + + "golang.org/x/sys/unix" +) + +// 我们**要管**的权限:只挑"会改变文件系统状态"的那些。 +// +// 刻意不管 READ_FILE / READ_DIR / EXECUTE:管了就等于"只允许白名单目录可读", +// 而 Landlock 没有否定式规则,会把整个系统读权限一起收掉(agent 连 /usr/include +// 都读不了)。写权限才是"越界"的实害面。 +const ( + fsWrite = unix.LANDLOCK_ACCESS_FS_WRITE_FILE + fsRemove = unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR + fsMake = unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | unix.LANDLOCK_ACCESS_FS_MAKE_DIR | + unix.LANDLOCK_ACCESS_FS_MAKE_REG | unix.LANDLOCK_ACCESS_FS_MAKE_SOCK | + unix.LANDLOCK_ACCESS_FS_MAKE_FIFO | unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK | + unix.LANDLOCK_ACCESS_FS_MAKE_SYM + fsRefer = unix.LANDLOCK_ACCESS_FS_REFER // ABI 2+ + fsTruncate = unix.LANDLOCK_ACCESS_FS_TRUNCATE // ABI 3+ +) + +// handleFor 按内核 ABI 取"这一版内核认得"的写权限集合。 +// +// 关键:**不能把内核不认识的位塞进 handled_access_fs** —— 那会让 create_ruleset +// 直接 EINVAL(整条边界都套不上)。所以按 ABI 逐位裁剪。 +func handleFor(abi int) uint64 { + // 这些位 ABI 1 就有。 + h := uint64(fsWrite | fsRemove | fsMake) + if abi >= 2 { + h |= fsRefer + } + if abi >= 3 { + h |= fsTruncate + } + return h +} + +// fileHandleFor 是"规则目标是**单个文件**"时能用的写权限子集。 +// +// ★ 这里有个坑值得记下来:把目录类权限(MAKE_*/REMOVE_*/REFER)用在文件目标上, +// `landlock_add_rule` 直接返回 **EINVAL**,而错误信息只说"invalid argument", +// 看不出是权限位不匹配。实测为了放行 `cmd 2>/dev/null` 给 /dev/null 加规则时才撞上。 +func fileHandleFor(abi int) uint64 { + h := uint64(fsWrite) // WRITE_FILE:文件目标真正有意义的那一位 + if abi >= 3 { + h |= fsTruncate + } + // 刻意**不**带 IOCTL_DEV(ABI 4 才有):我们没把它列进 handled_access_fs + // —— 规则里的 allowed_access 必须是 handled 的子集,否则内核直接 EINVAL。 + // 单元测试里那条"文件权限必须是 handled 的子集"就是拦这个的。 + return h +} + +func abiVersion() (int, error) { + r, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0, + unix.LANDLOCK_CREATE_RULESET_VERSION) + if errno != 0 { + return 0, fmt.Errorf("landlock_create_ruleset(VERSION) 失败: %w", errno) + } + return int(r), nil +} + +type opt struct { + rwDirs []string + rwFiles []string + cmd []string +} + +func usage() { + fmt.Fprint(os.Stderr, `am-sandbox —— 给命令套 Landlock 内核边界后 exec 它 + +用法: + am-sandbox --rw <目录> [--rw <目录>]... [--rw-file <文件>]... -- <命令> [参数...] + am-sandbox --print-abi + +语义:--rw 列出的目录(含子树)可写,其余路径写操作一律 EACCES;读与执行不限制。 +套不上边界时**不降级**(退出码 126),绝不"静默裸跑"。 +`) +} + +func parseArgs(args []string) (opt, error) { + var o opt + i := 0 + for i < len(args) { + a := args[i] + switch { + case a == "--": + o.cmd = args[i+1:] + return o, nil + case a == "--rw": + if i+1 >= len(args) { + return o, errors.New("--rw 后面要给目录") + } + o.rwDirs = append(o.rwDirs, args[i+1]) + i += 2 + case a == "--rw-file": + if i+1 >= len(args) { + return o, errors.New("--rw-file 后面要给文件路径") + } + o.rwFiles = append(o.rwFiles, args[i+1]) + i += 2 + case strings.HasPrefix(a, "--rw="): + o.rwDirs = append(o.rwDirs, strings.TrimPrefix(a, "--rw=")) + i++ + case strings.HasPrefix(a, "--rw-file="): + o.rwFiles = append(o.rwFiles, strings.TrimPrefix(a, "--rw-file=")) + i++ + case a == "-h" || a == "--help": + usage() + os.Exit(0) + default: + return o, fmt.Errorf("看不懂的参数 %q(命令前要有 --)", a) + } + } + return o, errors.New("缺少 `-- <命令>`") +} + +// restrictSelf 把当前进程关进 Landlock 域。返回 error 时**调用方必须放弃执行命令**。 +func restrictSelf(rwDirs, rwFiles []string) (int, error) { + abi, err := abiVersion() + if err != nil { + return 0, err + } + handled := handleFor(abi) + + attr := unix.LandlockRulesetAttr{Access_fs: handled} + rfd, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, + uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0) + if errno != 0 { + return abi, fmt.Errorf("create_ruleset 失败: %w", errno) + } + defer unix.Close(int(rfd)) + + // 目录:允许整个子树。文件:只允许这个文件本身(Landlock 的 path_beneath 对 + // 文件同样成立,且不含"父目录可写")。 + addRule := func(path string, isDir bool) error { + st, err := os.Stat(path) + if err != nil { + // 路径不存在就说清楚是哪一个:静默跳过 = 少一条规则而没人知道。 + return fmt.Errorf("--rw%s 指向的路径不存在: %s(%w)", map[bool]string{true: "", false: "-file"}[isDir], path, err) + } + if isDir && !st.IsDir() { + return fmt.Errorf("--rw 要求目录,但 %s 不是目录", path) + } + if !isDir && st.IsDir() { + return fmt.Errorf("--rw-file 要求文件,但 %s 是目录", path) + } + fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("打开 %s 取句柄失败: %w", path, err) + } + defer unix.Close(fd) + allowed := handled + if !isDir { + // 文件目标只能用文件类权限,否则 EINVAL(见 fileHandleFor 的注释)。 + // 由此也限定了语义:`--rw-file /dev/null` 只放行"写这个设备", + // **不放行**在同目录里新建/删除任何东西。 + allowed = fileHandleFor(abi) + } + pa := unix.LandlockPathBeneathAttr{Allowed_access: allowed, Parent_fd: int32(fd)} + if _, _, e := unix.Syscall(unix.SYS_LANDLOCK_ADD_RULE, rfd, + unix.LANDLOCK_RULE_PATH_BENEATH, uintptr(unsafe.Pointer(&pa))); e != 0 { + return fmt.Errorf("add_rule(%s) 失败: %w", path, e) + } + return nil + } + for _, d := range rwDirs { + if err := addRule(d, true); err != nil { + return abi, err + } + } + for _, f := range rwFiles { + if err := addRule(f, false); err != nil { + return abi, err + } + } + + // Landlock 要求先 no_new_privs(否则 restrict_self 直接 EPERM)。 + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return abi, fmt.Errorf("PR_SET_NO_NEW_PRIVS 失败: %w", err) + } + if _, _, e := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, rfd, 0, 0); e != 0 { + return abi, fmt.Errorf("restrict_self 失败: %w", e) + } + return abi, nil +} + +func main() { + args := os.Args[1:] + if len(args) == 1 && args[0] == "--print-abi" { + abi, err := abiVersion() + if err != nil { + fmt.Fprintf(os.Stderr, "Landlock 不可用:%v\n", err) + os.Exit(126) + } + fmt.Printf("landlock abi=%d handled_write_rights=0x%x\n", abi, handleFor(abi)) + return + } + + o, err := parseArgs(args) + if err != nil { + fmt.Fprintf(os.Stderr, "am-sandbox: %v\n", err) + usage() + os.Exit(2) + } + + // unix.Exec 是裸 execve,不查 PATH —— 先解析成绝对路径。 + // 解析放在关进边界**之前**:这一步不该受边界影响,出问题也要能给出清楚的报错。 + bin, err := exec.LookPath(o.cmd[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "am-sandbox: 找不到可执行文件 %q: %v\n", o.cmd[0], err) + os.Exit(127) + } + o.cmd[0] = bin + + abi, err := restrictSelf(o.rwDirs, o.rwFiles) + if err != nil { + // ★ fail closed:套不上边界就**不执行命令**。 + // 悄悄裸跑会让"档位=workspace"变成一句谎话,而谎话比做不到更危险 + // (模型以为越界会被拦,于是自己不设防)。 + fmt.Fprintf(os.Stderr, "am-sandbox: 边界未建立,拒绝执行(abi=%d):%v\n", abi, err) + os.Exit(126) + } + + if err := unix.Exec(o.cmd[0], o.cmd, os.Environ()); err != nil { + fmt.Fprintf(os.Stderr, "am-sandbox: exec %q 失败: %v\n", o.cmd[0], err) + os.Exit(127) + } +} diff --git a/server/cmd/am-sandbox/main_test.go b/server/cmd/am-sandbox/main_test.go new file mode 100644 index 0000000..b8b90f8 --- /dev/null +++ b/server/cmd/am-sandbox/main_test.go @@ -0,0 +1,101 @@ +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("未知开关应当报错") + } +}