fix(egress): 模型读网页与 bash 出网有了出口 —— 授权判据的第三个半场 = 用户当场批准 (#1412) - #1426
Merged
Merged
Conversation
jinjunnn
force-pushed
the
feat/1412-model-chosen-egress
branch
from
September 24, 2026 02:29
bf3932e to
c40ba5f
Compare
`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>
jinjunnn
force-pushed
the
feat/1412-model-chosen-egress
branch
from
September 24, 2026 03:32
b565ac4 to
727eb77
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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。自证(先证明手段测得出已知的好/坏)
改前 —— 两条轴各自 403
改后 —— 两条轴各自取回真实字节
(模拟的只有「人点的那一下」;出网链路 —— 判据 / 代理 / seatbelt / curl —— 全是生产本体。)
反向证明 —— 放宽之后原本该拦的仍然被拦
围栏内写不到批准真源(真⚠️ 状态根不能放
sandbox-exec,四臂;$TMPDIR—— 那是可写集 W12,第一版这么放 G1 写成功了,是测量故障不是漏洞)本机门
bash scripts/alpha-check.sh,在.worktrees/1412-webfetch。bun test src(packages/ui-mac 全量):f56748c4f与 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.ts13→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 的。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):owner 选择接受绕过,所以不加那行归一化判据。改的是记录:
getaddrinfo,两套解析器不一致)、七条实测读数、审计的端到端实录(框里只显示0x7f.1:11434、down=24真的穿回来)、受影响面(bash轴里不归一的客户端python3urllib /nc/openssl/wget/ 手写 socket;webfetch/curl/git归一后不受影响)、以及将来关它的最小修法与「该修法不影响 198.18/15」的实测。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 绿: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)6. 追加一句:K3′ 那一格不是假想的(
b565ac4f0,纯文档)复跑短写探针时它在
target.listen(11434)上 EADDRINUSE 挂了,追下去发现失败原因本身就是证据:lsof -nP -iTCP:11434 -sTCP:LISTEN⇒ollamapid 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