Skip to content

fix(egress): 模型读网页与 bash 出网有了出口 —— 授权判据的第三个半场 = 用户当场批准 (#1412) - #1426

Merged
jinjunnn merged 5 commits into
alphafrom
feat/1412-model-chosen-egress
Sep 24, 2026
Merged

jinjunnn merged 5 commits into
alphafrom
feat/1412-model-chosen-egress

Conversation

@jinjunnn

@jinjunnn jinjunnn commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1412

它修的是什么

在 app 里让模型「打开这个网页读一下」,一直失败;bash 里让它 curl / pip install / git clone 一个新地址,也一直失败。两件事是同一个原因:目的地是模型在调用那一刻生成的,而我们自己的出网围栏只放行「有配置来源」的地址 —— 这两条轴没有配置来源,也不该有,于是整类被拒,而用户只看到「那个网站打不开」。

修完之后:第一次去一个新地址时,应用弹一个框问你允不允许;答应了就通,勾了「以后不再询问这个地址」就记住。没答应、没人答、没框可弹 —— 一律还是拒,和今天一个字节都不差。

改法

授权判据加第三个半场:静态表 ∪ 配置派生 ∪ 用户当场批准。形状照搬文件轴那个已经在跑的解法(有真源 + 有出口):

  • 真源 <casBaseRoot>/egress-grants/<env>.json,与 mcp-servers/ 同父目录,只有 main 写;
  • 出口 = 代理判出 unregistered 的那一刻先问用户(main 的原生对话框,围栏之外);
  • 判据仍然只有 isEgressAuthorizedForSidecar 一处,精确 host:port,不通配。

webfetch 与 bash 用同一个机制,因为它们在代理眼里就是同一条 CONNECT host:port;不为其中任何一个单开通道(理由见方案文档 §3 的 B / E)。

方案、八个被否决的替代、十条类边界、八条实现不变量与四项已知残留:docs/design/2026-09-23-model-chosen-egress-baseline.md。

证据

全部用生产模块本体:真 sandbox-exec(生产 renderProcessFenceProfile 渲染的 profile)+ 真 /usr/bin/curl + 生产 startEgressPolicyProxy + 生产 sidecarEgressProxyEnv。

自证(先证明手段测得出已知的好/坏)

SELF-1 无围栏无代理 example.com        exit=0 HTTPCODE=200 BYTES=559
SELF-2 围栏+代理 github.com(静态表内)  exit=0 HTTPCODE=200 BYTES=576869
SELF-3 围栏+忽略代理 example.com       exit=6 "Could not resolve host"(DNS 死在围栏里)

改前 —— 两条轴各自 403

A1 webfetch 轴  en.wikipedia.org           exit=56 "CONNECT tunnel failed, response 403"
B1 bash 轴      curl https://example.com   exit=56 同上
B2 bash 轴      raw.githubusercontent.com  exit=56 同上
代理侧:verdict:"deny" reason:"unregistered" status:403

改后 —— 两条轴各自取回真实字节

A1 en.wikipedia.org           exit=0 HTTPCODE=200 BYTES=129603
B1 example.com                exit=0 HTTPCODE=200 BYTES=559
B2 raw.githubusercontent.com  exit=0 HTTPCODE=301
代理侧:egress.grant decision:"granted" → egress.connect verdict:"allow" → tunnel-closed bytesDown=141267

(模拟的只有「人点的那一下」;出网链路 —— 判据 / 代理 / seatbelt / curl —— 全是生产本体。)

反向证明 —— 放宽之后原本该拦的仍然被拦

N1 127.0.0.1:9      exit=7  连代理都没到(NO_PROXY + seatbelt N4;代理侧零记录)
N2 192.168.1.1:443  exit=56 403,且**连问都没问**(准入先拒私网字面量,代理侧无 egress.grant 记录)
N3 evil.example.com exit=56 403(问了,用户答拒;记录 decision:"refused")

围栏内写不到批准真源(真 sandbox-exec,四臂;⚠️ 状态根不能放 $TMPDIR —— 那是可写集 W12,第一版这么放 G1 写成功了,是测量故障不是漏洞)

G1 围栏内改写真源      rc=1 "Operation not permitted"  真源内容原样
G2 围栏内建真源目录    rc=1 "Operation not permitted"  目录没建出来
G3 对照 W2 alpha.jsonc rc=0 落盘(证明不是「这台机器写不了」)
G4 自证 不套围栏改真源 rc=0 改成功

本机门

bash scripts/alpha-check.sh,在 .worktrees/1412-webfetch。

bun test src(packages/ui-mac 全量):

pass junit 失败 console fail errors
base f56748c4f 5275 0 3 3
本分支 5308 0 3 3

与 base fail-set 的差 = 0(+33 pass,全部是本票新增判据)。两侧 bun-test-floor.sh 都以同一句退出:测量作废:junit 失败 0 条 ≠ console 外层总结 3 fail。那 3 条是 auth-recovery.test.ts 与 ext-session-grants.test.ts 故意抛的异常(用例本身是绿的),bun 把 errors 一并计进 console 的 fail 数,而 junit 只记真失败 —— 判官据此判两轴打架。base 上同一步同样红(rc=1),所以这道红不归本 PR;known-fails.tsv 一个字没动。

其余步骤全绿,包括 assert gate files(新增两个闸门文件已登记,network-egress-proxy.test.ts 13→17)、north-star、typecheck ×6、docs 链接(54 条 / 2 文件)、seed assets。模块体积棘轮:packages/ui-mac/src/main 基线人手抬到 59,669(+614,全部是本票的,理由写进 TSV);另有 ext-install-planner.ts +14 的告警在 base 上就有,不是本 PR 的。

⚠️ 本 worktree 起初缺两件环境产物,已在本地补上(都只落在这棵 worktree 里):native/alpha-fence/build/alpha_fence.node(用生产脚本 build-fence-addon.ts 现编)与 node_modules/.../electron/dist(从主 checkout 拷)。补之前 process-fence-wiring / process-fence-apply / network-egress-fence 三个闸门文件恒红;补之后全绿 —— 其中 network-egress-fence.test.ts(REQ-137 强制半场)带着本 PR 的改动 4/4 绿。


R1 裁决落地(2026-09-24,三个追加提交)

已 rebase 到 8c8867c90(两处登记簿冲突:network-egress-registry.test.ts 条数取上游的 8、棘轮理由段并入)。

1. Blocker-1(inet_aton 短写绕过准入)—— owner 2026-09-24 裁决接受,代码一行未改

我先独立复跑确认了它(生产判据 + dns.lookup,Darwin 25.3):

2130706433 → 127.0.0.1      127.1 → 127.0.0.1        0x7f.1 → 127.0.0.1
10.1 → 10.0.0.1             192.168.1 → 192.168.0.1  0300.0250.1.1 → 192.168.1.1
169.254.43518 → 169.254.169.254   ← 云元数据地址
七条全部 parseConnectAuthority=PASS 且 admitUserEgressGrant=PASS
对照:en.wikipedia.org / 198.18.0.7 / 93.184.216.34 也 PASS(它们本该 PASS)

owner 选择接受绕过,所以不加那行归一化判据。改的是记录:

  • 基线 §5.1 新增 K3′,把 K3/K4 的覆盖面改成实话 ——「点分四段的字面量」而不是「loopback / 私网」;写清成因(准入判文本形状、拨号走 getaddrinfo,两套解析器不一致)、七条实测读数、审计的端到端实录(框里只显示 0x7f.1:11434、down=24 真的穿回来)、受影响面(bash 轴里不归一的客户端 python3 urllib / nc / openssl / wget / 手写 socket;webfetch / curl / git 归一后不受影响)、以及将来关它的最小修法与「该修法不影响 198.18/15」的实测。
  • §4「用户批不出 loopback / 私网」那句加了例外指针;§6 判据地图写明「覆盖面到点分四段为止,K3′ 刻意没有判据」。
  • network-egress-grants.ts 的 isNonPublicV4Literal 与 admitUserEgressGrant 抬头同步,并明写不要顺手补。

2. §7.2 那条前提为假的理由 —— 已更正

原文写「照解析结果立闸会拒载真实配置」。不成立:fake-IP 拓扑下 dns.lookup 恒返回 198.18.x.x(本轮实测 en.wikipedia.org → 198.18.7.205),所以一个不含 198.18/15 的解析后判据在这台机器上永不触发 —— 它是空转,不是拒载。改成真实理由:那样一道闸只保护非 fake-IP 拓扑的机器,而今天整个 portfolio 没有那样的租户;并写明将来的落点(defaultDial 给 net.connect 传 lookup 钩子,且必须放行 198.18/15)。代码注释同改。

3. minor-1 撤销要重启 —— 两处文案都说出来了

egress-grant-records.ts 的日志改成 … until you remove that record AND restart the app (the grant set is loaded once per run; deleting the record alone does not revoke it for this run);基线 §7.4 改写;server.ts 装载处补一段说明为什么。

4. minor-4 —— 两处「requestGrant 恒答 false」改成 not-asked 三态

5. 本轮自己跑出来的一条真红(不在审计里)

全量跑出 network-egress-proxy.test.ts 两条断言精确日志的臂红,而单跑 17/17 绿:

- ["github.com:443:deny:dial-failed",                     "github.com:22:deny:unregistered"]
+ ["github.com:443:deny:dial-failed",  "egress.grant",    "github.com:22:deny:unregistered"]
expect(logs).toHaveLength(1)  →  Received length: 2

403 / 零拨号 / 拒绝正文一个字节都没变,多出来的只有一条 egress.grant —— 变的是「这一次有没有人被问过」。成因:configureEgressGrantApproval 是进程级单例,而全量把所有文件跑在同一进程里,好几个文件会驱动生产的 ensureEgressPolicyProxy(没注入 egressProxy 替身)⇒ 真 approver 被装上并漏进后面每个文件,于是那两条臂测的是「上一个文件剩下什么」而不是「生产默认接线」。

修法是让那四个字为真:proxy 测试 beforeEach 先 __resetEgressGrantsForTests();configureEgressGrantApproval 抬头写明这条陷阱。不动任何生产判据。 读数 bun test src/main:3563 pass / 2 fail → 3565 pass / 0 fail。

本轮门(hook 真跑,没有 --no-verify)

git push → PUSH_RC=0     远端 ref = c40ba5f1f(PR head 同值,OPEN)
[5/14]  bun test (ui-mac) 全量   5354 pass / 0 fail / Ran 5354 tests across 384 files
        base = 5321 pass / 0 fail  ⇒  差集 0,+33 pass 全是本票判据
[6/14]  ✓ gate files
[8/14]  ✓ 60 relative link(s) resolve across 2 file(s)
        先证伪:把一条链接改成 THIS-FILE-DOES-NOT-EXIST.md ⇒ rc=1「✗ 1 broken relative link(s) (of 60 checked)」,还原后 rc=0
[14/14] ✓ packages/ui-mac/src/main 59720 行(= 基线,人手抬 59,075 → 59,720)
        ⚠ ext-install-planner.ts +14 —— base 上就有,不是本 PR 的
结论:⚠️ local gates passed(唯一「未验成」是 [11/14] 三个 BYOK provider 本机无 API key,与本 PR 无关)

6. 追加一句:K3′ 那一格不是假想的(b565ac4f0,纯文档)

复跑短写探针时它在 target.listen(11434) 上 EADDRINUSE 挂了,追下去发现失败原因本身就是证据:
lsof -nP -iTCP:11434 -sTCP:LISTEN ⇒ ollama pid 842 LISTEN 127.0.0.1:11434。审计随手挑的那个端口
正是 ollama 的默认端口,而 owner 本机上确实常驻着一个 —— 这一类绕过在这台机器上到得了一个真在跑的
本机服务
,不只是合成靶站。已知接受的残留要按真实代价记,所以补进 K3′,并钉住它与
network-egress-registry.ts「产品面当前没有任何本地模型入口」不矛盾(那句说我们的产品不去连它;
这里说围栏内的代码借这个绕过可以连它)。裁决不变,代码一行未改。

我按裁决没有做的

不加归一化判据(owner 放宽)· 不改 403 正文(基线 I2)· 不加速率限制(minor-3)· 不动 defaultDial 的 lookup 钩子(Major-1 已知接受)。

🤖 Generated with Claude Code

@jinjunnn
jinjunnn force-pushed the feat/1412-model-chosen-egress branch from bf3932e to c40ba5f Compare September 24, 2026 02:29
jinjunnn and others added 5 commits September 23, 2026 23:18
`webfetch` 的 URL 与 `bash` 里命令自带的目的地由模型在调用那一刻产生,没有、也不该有
配置来源。而出网放行集合的两个半场共用一条规则「有围栏外的真源才进得来」,于是这两条轴
整类被我们自己的围栏 403 —— 连「用户想让它过」都没有任何地方可以表态。

实测(真 sandbox-exec + 生产渲染 profile + 真 /usr/bin/curl + 生产策略代理):
  改前  en.wikipedia.org / example.com / raw.githubusercontent.com → CONNECT tunnel failed, 403
        自证臂:无围栏 example.com 200;围栏+代理 github.com(静态表内)200 / 576 869 B
  改后  同三个目的地 200,wikipedia 取回 129 603 B、example 559 B

修法照搬文件轴那个已经在跑的形状(有真源 + 有出口):
  · 真源 <casBaseRoot>/egress-grants/<env>.json,与 mcp-servers/ 同父目录,只有 main 写;
    真 seatbelt 实测:围栏内改写它 / 建它的目录都是 Operation not permitted,
    而同一份 profile 下 W2 的 alpha.jsonc 写得进、不套围栏也写得进(三条对照臂)。
  · 出口:代理判出 unregistered 的那一刻先问用户(main 的原生对话框,围栏之外),
    答「允许」才建隧道;勾「记住」才落盘。
  · 判据仍然只有 isEgressAuthorizedForSidecar 一处 = 静态 ∪ 配置派生 ∪ 用户批准。

降级方向全部 fail-closed(没接通道 / 无窗口 / 超时 / 超并发 / 拒绝 ⇒ 仍是那条 403,
字节逐字不变;没问过的不写 grant 记录)。反向实测:私网字面量 192.168.1.1 连问都不问、
loopback 结构上到不了代理、没批准过的 evil.example.com 仍 403。

方案与安全面(十条类边界 + 八条不变量 + 八个被否决的替代 + 四项已知残留)见
docs/design/2026-09-23-model-chosen-egress-baseline.md。

Fixes #1412

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
零判据变更:本轮只让记录与文案说实话。

一、K3/K4 的覆盖面此前写过头了。审计实测:准入用**文本形状**判(只认点分四段),
   拨号用 `getaddrinfo`(接受 inet_aton 全部短写)—— 两套解析器不一致,于是
   `2130706433` / `127.1` / `0x7f.1` / `10.1` / `192.168.1` / `169.254.43518` /
   `0300.0250.1.1` 七条**全部**通过 `admitUserEgressGrant` 与 `parseConnectAuthority`,
   而本机 `dns.lookup` 把它们归一到 loopback / 私网(`169.254.43518` → 169.254.169.254)。
   基线新增 **K3′** 如实登记:成因、七条实测读数、受影响面(`bash` 轴里不归一的客户端:
   python3 urllib / nc / openssl / wget / 手写 socket;webfetch / curl / git 因归一不受影响)、
   以及将来关它的最小修法与它不影响 198.18/15 的实测。
   **owner 2026-09-24 裁决接受这个绕过** —— 代码一行未改,判据刻意不加,§6 判据地图同步写明。

二、§7.2 与 network-egress-grants.ts 里「照解析结果立闸会拒载真实配置」是**前提为假**的论证。
   fake-IP 拓扑下 `dns.lookup` 恒返回 198.18.x.x(本轮实测 en.wikipedia.org → 198.18.7.205),
   一个不含 198.18/15 的解析后判据在这台机器上**永不触发** —— 它是空转,不是拒载。
   真实理由只有一条:那样一道闸只保护非 fake-IP 的机器,而今天没有那样的租户。按实情改写,
   并写明将来的落点(defaultDial 给 net.connect 传 lookup 钩子,且必须放行 198.18/15)。

三、撤销要重启才生效,而两处文案说「删掉记录就行」。批准集合只在代理单例创建时装载一次、
   没有任何重读 ⇒ 用户删了记录本次运行照样放行。`egress-grant-records.ts` 的日志与基线 §7.4
   各加「并重启应用」,server.ts 接线处补一段说明。这是产品在说假话,与安全面无关。

四、顺手:两处注释写「requestGrant 恒答 false」,实际是 `not-asked` 三态。

另:四份勘破已进 alpha(f245627),把此前刻意留成纯文本的两处引用还原成相对链接。
模块体积棘轮基线随 rebase 重算 59,075 → 59,713(+638 = 新增非测试文件 541 + 已有文件净增 97,
逐项对得上,全部是本票的)。

Refs #1412

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bun test src/main` 实测(全量,非单跑):network-egress-proxy.test.ts 里两条断言**精确日志**的臂红,
各多出一条 `egress.grant` 记录 —— 而 403 / 零拨号 / 拒绝正文**一个字节都没变**,变的只有
「这一次有没有人被问过」。

成因:`configureEgressGrantApproval` 是**进程级单例**,生产里一个进程一个应用装一次即可;
但全量把所有测试文件跑在同一个进程里,而好几个文件会驱动生产的 `ensureEgressPolicyProxy`
(server.ts,它们没注入 `egressProxy` 替身)⇒ 真 approver 被装上并漏进后面每一个文件。
于是那两条臂测的不是「生产默认接线」,而是「上一个文件剩下什么」。

修法是让那四个字为真:proxy 测试 `beforeEach` 先 `__resetEgressGrantsForTests()`。
不动任何生产判据 —— 本轮 owner 已裁决不改安全面。
`configureEgressGrantApproval` 抬头写明这条陷阱与它的判据落点,免得下一个人重新诊断一遍。

读数:`bun test src/main` 3563 pass / 2 fail → **3565 pass / 0 fail**;proxy 文件单跑仍 17/17
(单跑一直是绿的 —— 这正是它难认的地方)。棘轮 59,713 → 59,720(+7,全是那段注释)。

Refs #1412

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
上一轮把 K3′ 写成「短写绕过可以到 loopback / 内网」,举的是审计的 `0x7f.1:11434` 实录。
本轮复跑那份探针时它在 `target.listen(11434)` 上失败(EADDRINUSE),追下去发现原因本身就是证据:
`lsof -nP -iTCP:11434 -sTCP:LISTEN` ⇒ `ollama` pid 842 LISTEN 127.0.0.1:11434。

即:审计随手挑的那个端口正是 ollama 的默认端口,而 owner 本机上确实常驻着一个 ollama ——
这一类绕过在这台机器上到得了一个**真在跑的本机服务**,不只是合成靶站。已知接受的残留要按
真实代价记,所以这一句补进 K3′。

顺带钉住它与 network-egress-registry.ts「产品面当前没有任何本地模型入口」**不矛盾**:
那句说的是我们的产品不去连它;这里说的是围栏内的代码借这个绕过可以连它。两句都成立。

裁决不变(owner 2026-09-24 接受绕过),代码一行未改。

Refs #1412

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#1436 与 #1426 都抬了同一行。rebase 时取 HEAD 侧(59,117,#1436 的值)让 rebase 走完,
再按最终树实测重算 —— 59,762 = 59,117 + 645,本票增量 645 不变(新增文件 541 +
已有文件净增 97 + 注释 7,逐项对得上),变的只是基数。两边的理由都保留在该行。

不这么做的两种错法:选小的一边 ⇒ 门红;选大的一边 ⇒ 悄悄放松棘轮。

Refs #1412, #1413

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jinjunnn
jinjunnn force-pushed the feat/1412-model-chosen-egress branch from b565ac4 to 727eb77 Compare September 24, 2026 03:32
@jinjunnn
jinjunnn merged commit f2fe21b into alpha Sep 24, 2026
1 check passed
jinjunnn pushed a commit that referenced this pull request Sep 24, 2026
由编排者代解冲突(owner 2026-09-24 授权先合本 session、再帮另一 session 合)。
两处冲突:
- CHANGELOG.md:两条都是新增条目,都保留。
- scripts/module-size-ratchet.tsv 的 tree 行:基线与此前全部理由取 alpha 那侧
  (59,762,含 #1436 / #1426 两次抬高),本票增量按合并后实测重算 = 59,813(+51)。
  分支上原写的 59,126 是按旧基数算的,直接取会让门红。

合并后 assert-module-size 三行全 = 基线,且 #1438 新加的「半份登记」交叉判据未告警。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

模型读网页整个是废的 —— 每一次抓取都被我们自己的出网围栏拒掉

1 participant