Skip to content

feat(composer): 运行权限三档 —— 「请求审批」真的逐次询问,「全部批准」给默认行为一个诚实的名字 (#1413) - #1436

Merged
jinjunnn merged 5 commits into
alphafrom
feat/1413-permission-tiers
Sep 24, 2026
Merged

jinjunnn merged 5 commits into
alphafrom
feat/1413-permission-tiers

Conversation

@jinjunnn

Copy link
Copy Markdown
Owner

Fixes #1413

用户看到什么

输入框「运行权限」菜单从两项变成三项,每一项都做它名字说的事:

主标签 · 副标题 提交时发出的 agent 引擎里发生什么
1(新会话默认) 全部批准 · 改文件/执行命令不再询问 不带(引擎默认 build) edit / bash 直接放行 —— 这就是此前「请求审批」的真实行为,现在有了诚实的名字
2 请求审批 · 逐次询问 alpha-ask edit / bash 各进一次待批队列,不批就不执行(fail-closed)
3 只读 · 不能改文件/执行命令 alpha-readonly edit / bash deny;逐字不变

改了什么(M)

  • 主进程 alpha-config-injection.ts:多注入一个 hidden agent alpha-ask = build + edit: ask + bash: ask
    (显式带上 build 对 defaults 的 question / plan_enter: allow 覆盖,否则「请求审批」会比默认档少掉 question 工具;
    刻意无 prompt —— 给了 prompt 就会在 llm/request.ts:64 顶掉整份底座提示词)。ALPHA_INJECTED_AGENTS 加入它(治理不许 disable/hide)。
  • renderer composer-state.ts:PermMode = "allow" | "ask" | "readonly";提交层不再是 if (readonly) … else … 的二元分支,
    改为穷举表 PERM_AGENT: Record<PermMode, string | null>(少一档 typecheck 红);新会话默认档 = allow
    alpha-composer.tsx 三个 menuitemradioPERM_MODES 生成,标签/副标题/图标同样按 Record<PermMode, …> 穷举;
    askreadonly 一样压过手选档位(计划 chip 置灰,title 点名是哪一档压的;Shift+Tab、装配弹窗计划行同步)。
  • ext 登记簿 context-injection.ts:允许没有 prompt 的 agent(只登记 description),库存快照重生(44 → 45 段);
    ui-mac 咽喉 config-injection-throat.test.ts 改为消费登记簿自己算的 id 集,并断言 alpha-ask 不写 prompt 键
  • 文案 zh/en:permAllow / permAllowHint 新增;planReadonlyplanLocked(带档名参数)、readonlyPlanpermLockedPlan
  • CSS:[data-mode="allow"]--a-warning(活稿 design.css .chip.perm[data-mode="full"] 同款);ask 中性色;readonly 不变。

AC 实测(全部是真跑的输出,不是推导)

AC1 —— 引擎层真 Permission.Service.ask(packages/opencode/test/permission/alpha-composer-tiers.test.ts,4 pass / 0 fail)

injectAlphaConfig(临时盘,不读 owner 配置)→ 真 Agent.Service → 真 Permission.ask,ALPHA_PERMISSION_ASK_TIMEOUT_MS=300:

ASK build external_directory "/Users/nobody/Downloads/x" -> unanswered       ← 正样本:探针读得出「真的在问」
ASK alpha-readonly bash "ls" -> denied                                      ← 反样本 / AC3:只读是 deny,不是 ask
ASK alpha-readonly edit "src/index.ts" -> denied
ASK build bash "rm -rf node_modules && bun install" -> passed-through       ← 对照臂「全部批准」:不弹框
ASK build edit "src/index.ts" -> passed-through
ASK alpha-ask bash "rm -rf node_modules && bun install" -> unanswered       ← 被测臂「请求审批」:进待批队列,无人批 ⇒ UnansweredError(fail-closed)
ASK alpha-ask edit "src/index.ts" -> unanswered

unanswered = Permission.UnansweredError(instanceof RejectedError,processor 据此把回合置 blocked),pending 全程清空。
另一条用例 AC1':alpha-ask + bash 进 list(),reply: once 之后 Exit 成功 —— 是不是拒。
矩阵用例:alpha-askbuild 对每一把内置权限键的 evaluate 只在 bash / edit 上不同,Permission.disabled 工具表逐字相同,
external_directory / doom_loop / read *.env 两者都仍是 ask(没有顺手放宽),alpha-ask.prompt === undefined

变异实测(做完已还原,git status 干净):alpha-config-injection.tsalpha-askbash: "ask" 改成 "allow" ⇒ 该文件 1 pass / 3 fail(AC1、AC1'、矩阵三条当场红)。

AC4 —— 三档提交的请求体逐字节不同(生产 buildPromptRequest 直接打印)

DEFAULT_PERM = allow
PERM_AGENT = {"allow":null,"ask":"alpha-ask","readonly":"alpha-readonly"}
allow     agent=null  -> {"parts":[{"type":"text","text":"hi"}]}
allow     agent=plan  -> {"parts":[{"type":"text","text":"hi"}],"agent":"plan"}
ask       agent=null  -> {"parts":[{"type":"text","text":"hi"}],"agent":"alpha-ask"}
ask       agent=plan  -> {"parts":[{"type":"text","text":"hi"}],"agent":"alpha-ask"}
readonly  agent=null  -> {"parts":[{"type":"text","text":"hi"}],"agent":"alpha-readonly"}
readonly  agent=plan  -> {"parts":[{"type":"text","text":"hi"}],"agent":"alpha-readonly"}

composer-state.test.ts 把这三个字符串钉成精确断言(17 pass)。变异实测:PERM_AGENT.ask 改成 null(= REQ-126 退休掉的那个假档形状)⇒
composer-state.test.ts 2 条 + shell-commands.test.ts 1 条当场红(36 pass / 3 fail),还原后回绿。

AC2 —— 真壳里三档各自可选、标签各自正确(shell-commands.test.ts,22 pass)

PermChip 挂载 → 3 个 menuitemradio、标签两两不同、每项都有非空副标题槽 → 逐档真点 → 提交层 agent
{allow: undefined, ask: "alpha-ask", readonly: "alpha-readonly"} 三个互不相同;composer-a11y.test.ts 的可达性用例改成 3 项。

AC3 —— 只读逐字不变

注入面 alpha-readonly 的 permission 一格未动;config-injection-throat.test.ts 13/13、alpha-config-injection.test.ts 14/14;
引擎层上表两条 denied;真组件 cases(alpha-composer-model.cases.ts #884 ⑤–⑯、new-session-workspace.cases.ts #891/#894)里所有
alpha-readonly 载荷断言原样保留,只把「默认档」从 "ask" 改成 "allow"、「退出只读的那一行」从「请求审批」改成「全部批准」。

本地门(真实输出)

  • typecheck:ui-mac 0 / ext 0 / opencode 0(error TS 计数;opencode 那份在把 injectAlphaConfig 改成非字面量路径动态 import 之前是 3 条 —— 静态 import 会把 ui-mac 主进程整张图拖进 opencode 的 tsconfig)
  • packages/ext context-injection.test.ts:31 pass / 0 fail(条数不变,登记簿快照 diff 只多 agent.alpha-ask.description 一行)
  • packages/opencode alpha-composer-tiers.test.ts:4 pass / 0 fail(已登记 scripts/gate-files.tsv,精确 4 条)
  • packages/ui-mac 定点:composer-state 17、throat 13、injection 14、shell-commands 22、a11y 2、autocomplete-core、locale-regression、builtin-policy、cloud-web-search 全绿;
    两个组件宿主(alpha-composer-model.component.test.ts / new-session-workspace.component.test.ts)2 pass;gate-file-registry.test.ts 22/22
  • packages/ui-mac 全量 bun test src:见下方「与 base 的差」
  • north-star guard:✓ zero upstream package edits
  • docs gate:✓ 96 relative link(s) resolve across 6 file(s);git diff --check 干净
  • pre-push 钩子(alpha-check.sh 14 步):见下方

与 base fail-set 的差

base(origin/alpha @ 8c8867c90,主 session 给的 fail-set):5321 pass / 0 fail
本分支(pre-push 钩子里跑的那一遍,树 = 14ac7b986,随后 99130e4b9 只改 TSV 一行、钩子再跑一遍同样 5323 / 0,ALPHA_KNOWN_FAILS_FILE 口径):5323 pass / 0 fail(Ran 5323 tests across 382 files)。
差集:+2 条新用例(composer-state.test.ts 的「请求审批 → alpha-ask」与「AC4 三份请求体」),0 条红,0 条消失。
另:ext 208/208,contracts-consumer 60/60,app 724/724;[6/14] 逐文件精确条数全绿(含新登记的 4 条)。

pre-push 钩子(alpha-check.sh 14 步)结果:[1]–[10]、[12] 全 ✓;[11/14] 3 个 BYOK provider 本机无 key ⇒ 那几条「未验证」(与本票无关);
[13/14] 只量不拦;[14/14] 模块体积棘轮响了(不拦 push):
packages/ui-mac/src/main 实测 59117 > 基线 59075(+42)—— 这 42 行全是本 PR 的非测试新增(alpha-config-injection.ts +30、alpha-agents.ts +12),
按棘轮的规矩已在本 PR 里人手把那一行基线抬到 59117 并在 TSV 同一行写明理由(99130e4b9;第二次过钩子 ✓ packages/ui-mac/src/main 59117 行(= 基线),全量仍 5323 / 0);ext-install-planner.ts 3539 > 3525(+14)不是本 PR 的(origin/alpha 上就是 3539),没有动它。
阈值告警(>800 行的被改文件 alpha-composer.tsx 1861 / en.ts 1887 / zh.ts 1847)都是既有大文件各加了几行,本 PR 不拆它们。

环境(按《本机验证陷阱》补齐后再量):electron/{dist,path.txt} 软链主 checkout;alpha_fence.nodebun run --cwd packages/ui-mac build:fence-addon 在本树现编。
第一遍全量是在我边改边跑时量的(污染,15 条红全部可归因于改到一半的默认档),作废不采;上面的数字取自钩子里对最终树的那一遍。

文档影响

design(活稿 §07 改成落地形状 + components.md 登记 #perm)、architecture(勘破文档 §11 后记、context-injection 登记簿文档第四个 agent、quality-gate-environments §3.6)、
release(CHANGELOG.md [Unreleased] Fixed)、gate registry(scripts/gate-files.tsv +1 行)。没有出新的设计增量稿:活稿 §07 自最初提交起就是三档,本票只把第一档的措辞与「映射」注改成落地的机制。

我主动没做的(请主 session 裁)

  1. 打包版真机端到端(点选 → IPC → sidecar → 弹框)没有跑。 本 PR 的 AC1 证据是引擎层(真注入 + 真权限服务)。真机那一格按仓内惯例归 RC 检查单 / VERIFY 票。
  2. 默认档定成「全部批准」是我定的可回滚默认值:理由 = 它就是此前默认档的真实行为(从没碰过 chip 的用户改前改后请求逐字节相同)+ 活稿 §07 的默认选中项就是它。若 owner 要「新会话默认请求审批」,只改 composer-state.tsDEFAULT_PERM 一处(测试里的默认断言跟着改)。
  3. 「请求审批」与「只读」一样压过计划模式 / 第三方主档(计划 chip 置灰并点名)。另一种做法是让计划赢 —— 但那会让「请求审批 + 计划」下 bash 又不问了,正是本票要消灭的形状。没有为 plan 造 alpha-plan-ask(票面边界:不动 plan 档)。
  4. Settings → 「自动批准权限」总闸(settings.tsx:561-577,勘破 §8.1 说它今天开了也没有可命中的记录)与本菜单语义重叠,未动。要不要退休它,是另一张票。
  5. alpha-readonly 注入面上那条与实测矛盾的注释(alpha-config-injection.ts:201-203「question/task 允许」,实测 question = deny)未改,只在本 PR 的设计上没有照它做。
  6. alpha-ask 没有逃生 env(其余三个 alpha agent 各有 ALPHA_*_DISABLE):agent 缺席 = 引擎具名报错(fail-closed),不是静默回到全放行;若要对齐可另加。
  7. 顺带观察(不在本票):alpha-readonly 带自己的 prompt,按 llm/request.ts:64 它会整段顶替底座提示词 —— 只读档今天跑在一句话的系统提示上。本 PR 没有动它。

🤖 Generated with Claude Code

jinjunnn and others added 5 commits September 23, 2026 22:17
此前 composer 只有两档,「请求审批 · 逐次询问」提交时不带 agent、落到引擎默认 build(`"*": "allow"`),
真 Permission.ask 对 edit/bash 直接放行 —— 副标题是假的。

- 主进程注入第四个 hidden agent `alpha-ask` = build + edit/bash 改 ask(无 prompt,不顶掉底座);
- `PermMode = "allow" | "ask" | "readonly"`,提交层按 `PERM_AGENT` 穷举表决定 agent(不再是二元分支);
- 新会话默认档 = allow(它就是此前默认档的真实行为);ask / readonly 都压过手选档位;
- 引擎层常驻闸 packages/opencode/test/permission/alpha-composer-tiers.test.ts:真 injectAlphaConfig + 真 Agent
  + 真 Permission.ask,alpha-ask 的 bash/edit 进待批队列并 fail-closed,build 对照臂放行,readonly deny;
- ext 登记簿允许没有 prompt 的 agent,只登记 description;库存快照重生。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…、CHANGELOG

- docs/design/current/composer/design.html §07:第一档措辞「全部批准 · 改文件/执行命令不再询问」,映射改成
  「每条消息自带 agent」的真实机制,写明「全部批准」不取消的那几类审批;components.md 登记 #perm 一行。
- docs/architecture/2026-09-23-runtime-permission-tiers.md 加 §11 后记(以上各节保留为落地前的地面真相);
  docs/README.md 索引行同步。
- docs/architecture/2026-09-08-context-injection-registry.md:第四个 agent alpha-ask(只登记 description,无 prompt)。
- scripts/gate-files.tsv:登记 packages/opencode/test/permission/alpha-composer-tiers.test.ts(精确 4 条);
  docs/architecture/quality-gate-environments.md §3.6 同步。
- CHANGELOG [Unreleased] Fixed:用户可见变化与诚实边界。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…:提到的测试文件必须已分类)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erdict>,让 PR / CI 日志直接读得到实测

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…入 alpha-ask 的非测试新增,理由写在 TSV 同一行

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jinjunnn
jinjunnn merged commit 0b2ce2d into alpha Sep 24, 2026
5 of 6 checks passed
jinjunnn pushed a commit that referenced this pull request Sep 24, 2026
#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 added a commit that referenced this pull request Sep 24, 2026
* feat(egress): 模型即时生成的目的地有了出口 —— 授权判据的第三个半场 = 用户当场批准

`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>

* docs(egress): R1 裁决落地 —— 把 K3/K4 改成实话,更正一条前提为假的理由,撤销文案说出「并重启」

零判据变更:本轮只让记录与文案说实话。

一、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>

* fix(egress test): 「默认接线」这四个字在断言里必须是真的 —— 询问通道是进程级单例,全量跑时会漏

`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>

* docs(egress): K3′ 那一格不是假想的 —— 审计用的 11434 上确有 ollama 在跑

上一轮把 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>

* chore(ratchet): rebase 后按实测重算 tree 基线 59,117 -> 59,762

#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>

---------

Co-authored-by: jinjunnn <slmbaovanetti99@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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