Skip to content

[ac#1149] 围栏内 set-ID 二进制不可 exec:登记为已知代价 + 真 sandbox-exec 双向判据 - #1152

Merged
jinjunnn merged 1 commit into
alphafrom
feat/1149-setid-exec-registration
Aug 27, 2026
Merged

jinjunnn merged 1 commit into
alphafrom
feat/1149-setid-exec-registration

Conversation

@jinjunnn

Copy link
Copy Markdown
Owner

Fixes #1149

围栏开着时 ps / top 在围栏内拿到 Operation not permitted。这是 macOS seatbelt 对沙箱进程 exec set-ID 程序的固有拒绝(本仓 profile 只有 (allow default) + (deny file-write*)),#1144 量到过 —— 但它只存在于一份 dated 证据文件里,没有任何东西会因为这条代价被改掉或被扩大而变红。本 PR 补的就是那个东西。

按裁决取 (a) 只登记为已知代价,不改用户可见行为;tool/shell.ts 的错误路径一个字没动。


1. 单一权威在哪,新增 set-ID 成员时它怎么自己长出来

权威 = packages/ext/src/alpha-sandbox-setid-exec.test.ts(packages/ext/src/shell-sandbox.ts 的抬头新增一段 K1 指向它,不在那里枚举成员)。

它经生产的 wrapEngineShell 把 profile + wrapper 落到临时 root,再用真 /usr/bin/sandbox-exec 跑真二进制 —— 判的是行为,不是 profile 的文本(断言源码文本的闸门,在守卫被整段注释掉时照样绿)。

自己长出来的机制:语料只登记「候选路径 + 一组无害参数」,类别不写在语料里,由

const SET_ID_BITS = 0o6000                     // S_ISUID | S_ISGID
function expectationFor(mode: number): Verdict {
  return (mode & SET_ID_BITS) !== 0 ? "exec-denied" : "exec-allowed"
}

从盘上的真实 mode 派生。于是:

  • 某个二进制在新版 macOS 上不再是 set-ID ⇒ 自动换到「该通过」那一类,不产生假红;
  • 反过来变成 set-ID ⇒ 自动换到「该被拒」那一类,不靠人记得改一行散文;
  • 加一个成员 = 加一行路径,期望值自己算出来。

期望值来自文件系统、观察值来自跑围栏,两者不同源 —— 不是自指等价链。

2. 勘破更正三条(动笔前实测,票面/证据文件与之不符)

# 票面/证据文件写的 实测(2026-08-26,本机 macOS 26.3.1 / arm64)
1 这一类是 setuid 不止。/usr/bin/write = 0o2555 setgid-only(setuid 位为 0),围栏内同样 zsh:1: operation not permitted: /usr/bin/write,裸臂 write: … is not logged in rc=1。⇒ 判据取 0o6000 而不是 0o4000;只看 setuid 位会把 write 归进「该通过」那一类,制造一条假红
2 拒绝签名 rc=126 那一跑用的是 /bin/sh。生产 wrapper exec 的是真 shell(zsh)⇒ rc=127 + zsh:1: operation not permitted: <path>。判据两者都认,且要求「rc ∈ {126,127}」与「stderr 含 operation not permitted 且点名该路径」同时成立
3 语料应含 su 不进语料。有控制终端时 su 打印 Password: 并阻塞读 /dev/tty(stdin 给 pipe 拦不住);无围栏对照臂会真的 exec 它,把 bun test 挂死。实测 script -q /dev/null /usr/bin/su <不存在的用户> 5s 未返回只能 SIGKILL,而同一条命令无 tty 时 15ms 返回 su: Sorry ——「我这儿跑着没事」不是证据。set-ID 这一类由 ps/top/quota/write 覆盖

顺带实测掉的一条死路:合成 setuid 探针不可行 —— cp /bin/echo x && chmod 4755 x 之后 mode 实测仍是 0o755(set-ID 位没落上),且该副本两臂都 rc=137(SIGKILL)。语料只能用真系统二进制。

3. AC2 双向变异实测(真实输出)

基线(未变异):4 pass / 0 fail / Ran 4 tests across 1 file [1.53s]。

方向一 —— wrapper 改动意外恢复 set-ID exec ⇒ 必须红

变异:WRAPPER_SCRIPT 去掉 sandbox-exec,exec /usr/bin/sandbox-exec -f … "$ALPHA_REAL_SHELL" "$@" → exec "$ALPHA_REAL_SHELL" "$@"。

@@ -1,6 +1,6 @@
  [
-   "/bin/ps (0o4755): exec-denied",
-   "/usr/bin/top (0o4555): exec-denied",
-   "/usr/bin/quota (0o4555): exec-denied",
-   "/usr/bin/write (0o2555): exec-denied",
+   "/bin/ps (0o4755): exec-allowed",
+   "/usr/bin/top (0o4555): exec-allowed",
+   "/usr/bin/quota (0o4555): exec-allowed",
+   "/usr/bin/write (0o2555): exec-allowed",
    "/bin/ls (0o755): exec-allowed",
 3 pass / 1 fail · bun exit=1

还原 git checkout -- packages/ext/src/shell-sandbox.ts ⇒ 4 pass / 0 fail。

方向二 —— 把非 set-ID 的 ls / git 也拦了 ⇒ 必须红

变异:SEATBELT_PROFILE 追加 (deny process-exec (literal "/bin/ls") (literal "/usr/bin/git"))(先单独起 sandbox-exec 验过这条 profile 能解析、进程真的启动,不是靠空输出判断)。

@@ -5,5 +5,5 @@
    "/usr/bin/write (0o2555): exec-denied",
-   "/bin/ls (0o755): exec-allowed",
+   "/bin/ls (0o755): exec-denied",
    "/usr/bin/wc (0o755): exec-allowed",
-   "/usr/bin/git (0o755): exec-allowed",
+   "/usr/bin/git (0o755): exec-denied",
 3 pass / 1 fail · bun exit=1

只有被拦的那两行翻,wc 与四条 set-ID 行不动 ⇒ 判据的粒度落在单个二进制上,不是「围栏还在不在」。还原 ⇒ 4 pass / 0 fail。

方向三(观测手段自检)—— 空输出不许被读成「拦住了」

变异:profile 里塞一条解析不了的谓词((deny network* (remote host "github.com")),sandbox-exec 对它 unbound variable: host)⇒ 进程根本不启动,输出全空,长得像「全都被拦住了」。

(fail) … [观测手段] 每一次 exec,两臂的 shell 进程都真的启动过(空输出不算拦住)
+ "fenced /bin/ps: started=false rc=null stderr=sandbox-exec: unbound variable: host … line 4, column 24"
…
+   "/bin/ps (0o4755): unmeasured",     ← 不是 exec-denied
 2 pass / 2 fail · bun exit=1

关键是第二段:set-ID 那四行报 unmeasured 而不是 exec-denied —— 若判据只看「命令没跑成」,这一格会假绿。还原 ⇒ 4 pass / 0 fail。

4. 本地门(逐条真实输出,set -o pipefail,退出码单独取)

门 exit 自报处理量
north-star-guard.sh 0 ✓ zero upstream package edits (baseline origin/alpha…)
assert-frontend-patch-roundtrip.sh 0 —
assert-no-nul-bytes.py 0 ✓ 7738 个版本控制文件零字面 NUL 字节
typecheck ×4(contracts-consumer / ext / ui-mac / opencode) 0 / 0 / 0 / 0 tsgo 零条
check:vendor 0 —
bun-test-floor 15 alpha-contracts-consumer 0 60 pass / 0 fail / 6 files
bun-test-floor 100 ext 0 161 pass / 0 fail / 15 files
bun-test-floor 3000 ui-mac src 0 4506 pass / 0 fail / 313 files
bun-test-app.sh 0 624 条 + 5 个判据文件逐个精确匹配
assert-gate-files.sh 0 ✓ 162 个闸门文件全部在位且真的跑过
assert-seed-assets.sh 0 ✓ seed/vendored resources present
check-doc-links.py(改了 md) 0 ✓ 5 relative link(s) resolve across 1 file(s)

与 base fail-set 的差:空。 base = a4f750bcd(ext 157 pass / 14 files、ui-mac 4506 pass / 313 files / 0 fail)⇒ 本 PR:ext 161 / 15(+4 条 +1 文件 = 本 PR 的增量,逐条对得上)、ui-mac 4506 / 313 / 0 fail(逐字不变)。

git push --no-verify:pre-push 会 exec scripts/alpha-check.sh,而它第 [8/9] 步真建探针 worktree 并写全 worktree 共享的 core.hooksPath(并行 lane 期间互相制造假红,且每跑一次泄漏一棵探针树,ac#928)。派发书明确要求不跑它;上表 13 道门是逐条单跑的结果。

5. 为什么不进 scripts/gate-files.tsv

登记 = 精确条数,而 CI 是 runs-on: ubuntu-latest,本文件在非 darwin 上整块 describe.skip。这个前提我跑了一遍再写:临时造一个 describe.skip 的 4 条文件,bash scripts/bun-test-floor.sh =4 … 实测

0 pass / 4 skip / 0 fail
::error::… 实际 0 条 < 登记 4 条:有用例被删除/清空/skip 掉了

⇒ 登记它会让 CI 恒红,而「恒红的门训练人忽略它」。与 alpha-sandbox-escape.test.ts(真 sandbox-exec 正反语料,同样 darwin-only、同样未登记)是同一支,gate-files.tsv 里 alpha-sandbox-seam.test.ts 那行的 guarantee 已经写明这件事。文件名不命中 GATE_NAME_TOKENS,gate-file-registry.test.ts 不要求它二选一(实测 162 个闸门文件全绿)。

6. 我主动没做的事(留给主 session 裁,不顺手改)

  1. 没碰 packages/opencode/src/tool/shell.ts 的错误路径 —— 候选 (b)「给用户一句可读解释」按裁决 out of scope。今天用户看到的仍是裸 Operation not permitted。要做需另开票。
  2. 没把 setuid/set-ID 语料并进 REQ-138 的 §2.8 误伤集 —— 那个误伤集是「应该通过却被拦」的清单,而这一类是「确实被拦、且我们接受」,合并会让那张表的语义变成两件事。
  3. 没动 docs/verification/2026-08-26-req138-1144-packaged-shell-tool-chain/ —— dated 证据文件是那一天的快照(它自己写着「由 owner 裁」),裁决落在活基线 docs/architecture/2026-08-23-shell-sandbox-seam.md §5 上。
  4. 发现但没修的存量(建议单开票):alpha-sandbox-escape.test.ts 与本文件都没有任何东西钉住它们的存在 —— 两者都 darwin-only 故不在 gate-files.tsv,而 ext 整包地板是 100、实测 161 ⇒ 把这两个文件整个删掉,三层闸门全绿。这是 gate-files.tsv 抬头自己写的「单点消失」那一类,只是对 darwin-only 文件今天没有出口(登记会恒红)。本票 AC 不含它,不顺手扩。
  5. 单机单配置:一台 macOS 26.3.1 / arm64,SHELL=/bin/zsh。Intel 机器、真 shell 是 bash 的用户没跑过(与 #1076 / #1144 同一条残余)。

REQ-138 的围栏开着时,`ps` / `top` 在围栏内拿到 `Operation not permitted`。
这是 macOS seatbelt 对沙箱进程 exec set-ID 程序的固有拒绝(profile 只有
`(allow default)` + `(deny file-write*)`),`#1144` 量到过,但只存在于一份
dated 证据文件里 —— 没有任何东西会因为这条代价被改掉或被扩大而变红。

单一权威 = packages/ext/src/alpha-sandbox-setid-exec.test.ts:经生产的
wrapEngineShell 起真 sandbox-exec 跑真二进制,成员「该被拒还是该通过」由盘上的
真实 mode 派生(S_ISUID|S_ISGID),不写死清单。判据双向,两个方向各有正样本。

勘破更正三条(实测,2026-08-26):
- 这一类不止于 setuid —— /usr/bin/write(0o2555,setgid-only)同样被拒,
  判据取 0o6000;只看 setuid 位会把它归进「该通过」那一类而假红;
- zsh 的拒绝签名是 rc=127,不是证据文件里的 126(那一跑用的是 /bin/sh);
- /usr/bin/su 不进语料:有控制终端时它 `Password:` 阻塞读 /dev/tty,
  无围栏对照臂会真的 exec 它,把 bun test 挂死。

Refs #1149
@jinjunnn
jinjunnn merged commit 58d9acf into alpha Aug 27, 2026
5 of 6 checks passed
@jinjunnn
jinjunnn deleted the feat/1149-setid-exec-registration branch August 27, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][P2] 围栏内 setuid 二进制(ps/top/su)不可 exec —— 真实代价,但仓里零登记

1 participant