Repository navigation
Conversation
|
@qwen-code /triage |
|
/review |
|
@qwen-code /review |
|
@qwen-code /review |
|
Follow-up after the previous review rounds:
Focused fork validation passed 498/498 tests plus A fresh |
1a8a71d to
1a9dae8
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
|
@qwen-code /review |
🔬 Maintainer review + tmux E2E report — no Critical found at current headCode review (head
|
| Step | Expected (PR) | Observed |
|---|---|---|
/mcp |
both servers registered, distinct identities | ✅ srv.dot ✓ connected, srv_dot ✓ connected |
call mcp__srv_dot__ping (server srv_dot) |
allowed by the wildcard (raw identity mcp__srv_dot__ping matches) |
✅ executed with no confirmation — pong from srv_dot |
call mcp__srv_dot__ping_01lsbax (server srv.dot) |
wildcard must NOT leak across servers | ✅ confirmation dialog: Allow execution of MCP tool "ping" from server "srv.dot"? — grant did not bleed; declining via Esc cancelled the call cleanly |
Under the pre-PR matching (sanitized-both-sides wildcard), mcp__srv_dot__* prefix-matches mcp__srv_dot__ping_01lsbax and would have auto-approved the second call — the dialog is the observable proof the collision hole is closed.
Methodology notes:
- Headless
-pcontrol runs (with and without the allow rule) auto-execute MCP tools either way — non-interactive mode does not enforce MCP confirmations — so the discrimination above was done in the interactive TUI, whereasksurfaces a real dialog. - Local full build initially failed at
packages/sdk-typescript: the browser-bundle size guard (220220 > 215*1024). This is not caused by this PR: the merge touched no SDK files, the built browser daemon bundle contains none of this PR's code (verified by symbol scan), and the growth traces to main's recent daemon SDK change (fix(sdk): Surface daemon JSON-RPC error details #10571) landing without a limit bump. I relaxed the constant in my scratch build only, to produce the test bundle. Maintainers may want to raiseMAX_DAEMON_BROWSER_BUNDLE_BYTESon main if/when this lane runs in CI.
Status
- No Critical at current head → no Request Changes from me.
- CI on this head has not run the test lanes yet (fork-PR authorization pending), and the standing
CHANGES_REQUESTEDstate predates the fixes — so I'm not approving; a green full-CI pass plus maintainer sign-off is still needed.
— qqqys · code review + tmux E2E
|
@qwen-code /review Review follow-ups are pushed on HEAD Key follow-ups since the previous review:
Please review the current HEAD rather than superseded earlier commits. |
🔬 Maintainer verification — real local environment, head
|
| piece | what it actually is |
|---|---|
| CLI | dist/cli.js bundled from the PR merge ref (9f7116c827 = 98bb096dee into f86098363d) |
| pre-PR control | same worktree, the 5 production files reverted to f86098363d, re-bundled → dist-base |
| MCP servers | 6 real stdio MCP servers (low-level Server + raw tools/list, so provider-unsafe tool names survive onto the wire) |
| provider | local OpenAI-compatible server; logs every request so the exact tool declarations are readable |
| witness | each MCP tools/call appends to a file — "executed" is a side effect on disk, not a screen string |
Bundle separation was verified by symbol scan: dist-pr contains evaluateRestrictivePermissionRules /
getUnambiguousMcpPermissionAliases, dist-base contains neither.
Registered identities the CLI actually produced (read off the wire, matching the issue's model exactly):
| MCP server | raw tool | registered name | legacy alias |
|---|---|---|---|
foo.bar |
evil |
mcp__foo_bar__evil_1oxrpi0 |
mcp__foo.bar__evil |
foo_bar |
evil |
mcp__foo_bar__evil |
– |
srv |
foo/bar |
mcp__srv__foo_bar_0cnn7di |
mcp__srv__foo_bar |
srv |
foo:bar |
mcp__srv__foo_bar_0lg9idv |
mcp__srv__foo_bar ← same alias |
srv3 |
foo/bar |
mcp__srv3__foo_bar_14vwvt9 |
mcp__srv3__foo_bar ← = another tool's registered name |
srv3 |
foo_bar |
mcp__srv3__foo_bar |
– |
srv2 |
uniq/name |
mcp__srv2__uniq_name_1quugtb |
mcp__srv2__uniq_name |
srvL |
80-char name | mcp__srvL__…_15o9gg1 |
63-char truncated form |
1. The vulnerability reproduces, and the PR closes it
--approval-mode default, headless. EXECUTED = auto-approved without confirmation (the bug);
ask = confirmation required.
| # | rule in permissions.allow |
tool actually called | pre-PR | PR |
|---|---|---|---|---|
| A1 | mcp__foo.bar |
server foo.bar (intended) |
EXECUTED | EXECUTED |
| A2 | mcp__foo.bar |
server foo_bar (different server) |
✅ ask | |
| B1 | mcp__foo.bar__* |
server foo.bar (intended) |
EXECUTED | EXECUTED |
| B2 | mcp__foo.bar__* |
server foo_bar |
✅ ask | |
| C1 | mcp__srv__foo_bar (ambiguous alias) |
tool foo/bar |
✅ ask | |
| C2 | mcp__srv__foo_bar (ambiguous alias) |
tool foo:bar |
✅ ask | |
| D1 | mcp__srv3__foo_bar |
the real tool foo_bar |
EXECUTED | EXECUTED |
| D2 | mcp__srv3__foo_bar |
the other tool foo/bar |
✅ ask | |
| J1 | (none) | any MCP tool | ask | ask |
Backwards compatibility that the PR promises — all still granted:
| # | rule | tool | pre-PR | PR |
|---|---|---|---|---|
| E1 | mcp__srv2__uniq_name (unique legacy alias) |
mcp__srv2__uniq_name_1quugtb |
EXECUTED | ✅ EXECUTED |
| T1 | 63-char truncated legacy alias | mcp__srvL__…_15o9gg1 |
EXECUTED | ✅ EXECUTED |
| G1 | exact registered name (what "Always allow" persists) | same | EXECUTED | ✅ EXECUTED |
| K1 | mcp__srv2 (server-level) |
mcp__srv2__uniq_name_1quugtb |
EXECUTED | ✅ EXECUTED |
| W1 | mcp__srv__* |
mcp__srv__foo_bar_0cnn7di |
EXECUTED | ✅ EXECUTED |
| W3 | mcp__* |
mcp__srv__foo_bar_0cnn7di |
EXECUTED | ✅ EXECUTED |
| W4 | mcp__srv |
mcp__srv__foo_bar_0cnn7di |
EXECUTED | ✅ EXECUTED |
Screenshots (interactive TUI, Ask permissions mode, real bundles)
Same settings.json (allow: ["mcp__foo.bar__*"]), same prompt, only the bundle differs:
Before (main) — the grant for server foo.bar silently authorizes server foo_bar:
After (this PR) — the same call stops at the confirmation dialog:
After (this PR) — the intended server is still granted by the same rule, so the fix is not a blanket revocation:
Issue variant 2 — one ambiguous legacy alias mcp__srv__foo_bar owned by both foo/bar and foo:bar.
Before: auto-executes. After: confirmation required.
2. Restrictive rules stay fail-closed (no fail-open regression)
Run under --yolo, which bypasses ask but not deny, so only a genuine deny blocks
(control R0/R8 confirm the isolation). "blocked" = the call never reached the MCP server.
| # | rules | tool | pre-PR | PR |
|---|---|---|---|---|
| R0 | (none) | server foo.bar tool |
EXECUTED | EXECUTED |
| R1 | deny: mcp__foo_bar (historical sanitized server spelling) |
server foo.bar tool |
blocked | ✅ blocked |
| R2 | deny: mcp__foo.bar (raw spelling) |
same | blocked | ✅ blocked |
| R3 | deny: mcp__foo_bar__* |
same | blocked | ✅ blocked |
| R4 | deny: mcp__foo.bar__* |
same | blocked | ✅ blocked |
| R5 | deny: mcp__srv__foo_bar (ambiguous alias) |
foo/bar |
blocked | ✅ blocked |
| R6 | deny: mcp__srv__foo_bar (ambiguous alias) |
foo:bar |
blocked | ✅ blocked |
| R7 | deny matches only the registered-name context + ask matches only the raw context |
same | blocked | ✅ blocked (deny wins) |
| R8 | ask only |
same | EXECUTED | EXECUTED |
| S1 | allow: <exact registered> + ask: mcp__foo_bar (sanitized spelling) |
same | ask | ✅ ask |
| S2 | allow: <exact registered> + ask: mcp__foo_bar__* |
same | ask | ✅ ask |
(S1/S2 are run in default mode instead, since ask is the observable there; they show that a
historical sanitized-spelling ask rule still overrides an explicit exact allow.)
R5/R6 are the important pair: the alias that lost its grant power is still fully effective for
deny — exactly the asymmetry the PR describes.
3. Subagent disallowedTools
A real subagent (disallowedTools in agent frontmatter) was launched and its own model request was
read off the wire to see which MCP tools it was given:
disallowedTools |
pre-PR: tools removed | PR: tools removed |
|---|---|---|
mcp__foo.bar (raw) |
both foo.bar's and foo_bar's tool (over-block) |
✅ only foo.bar's tool |
mcp__foo_bar (sanitized) |
both | ✅ both (compat retained, fail-closed) |
So the blocklist got more precise without losing the historical spelling.
4. Mutation testing — is each production hunk load-bearing?
Eight mutants applied to the PR's production code, each run against the PR's focused suite
(470 tests) and, where reachable, re-bundled and re-run through the live rig.
| mutant | unit suite | live rig |
|---|---|---|
| M1 — wildcard sanitizes both sides again | 3 failed | |
| M2 — server-level compares sanitized names again | 3 failed | |
| M3 — registered names no longer count as alias claimants | 2 failed | |
| M4 — alias ambiguity filter disabled | 4 failed | |
M5 — restrictive loop breaks on the first ask (the pre-fix bug) |
2 failed | not observable — see F4 |
| M6 — registered-name restrictive context removed | 2 failed | |
| M7 — grant aliases fail open when the registry resolver is missing | 2 failed | n/a (registry always complete in-product) |
M8 — alias !== rawMcpToolName exclusion removed |
0 failed | equivalent mutant |
M8 survives but is equivalent, not a coverage gap: resolveToolName is an identity map for
mcp__* names, so the only alias that exclusion can drop is the raw identity itself — and
matchesMcpPattern already returns true for pattern === matchTarget before the legacy-exact
scan runs. The exclusion is defensive; removing it changes no reachable outcome.
Everything else is killed twice over — by tests and by the live rig — so no production hunk is dead weight.
5. Repo checks (local, on the merge ref)
- Focused suite from the PR description: 470 passed (6 files).
- Broad slice
src/permissions src/core src/tools src/agents: 9269 passed, 6 skipped, 0 failed (221 files). tsc --noEmitonpackages/core: clean.eslint --max-warnings 0on all 11 changed files: clean.
Findings
F1 — the three red CI jobs are a stale main-side guard, not this PR (actionable: merge main).
Test (ubuntu), Integration Tests (no-AK) and web-shell E2E Smoke all die in the same
npm ci → prepare → build step with the identical error:
Error: Browser daemon SDK bundle is 220220 bytes; expected <= 220160.
That limit is MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024 in
packages/sdk-typescript/scripts/build.js — a file this PR does not touch. main already made
that budget advisory in #10630 (2268c9bc06, 2026-08-31 11:48Z), but this branch's last main
merge is 1ad331195f (2026-08-31 03:53Z), so the head tree CI checks out still carries the hard
limit. Locally the same bundle measures 221 474 bytes: it fails the old guard and only warns under
the current one. Merging current origin/main into the branch should clear all three jobs —
no code change needed here. (Please merge rather than rebase, per the force-push note above.)
F2 — Suggestion: an undocumented compatibility break for rules written in the sanitized spelling.
Two cases lose their grant, both fail-closed (extra prompt, never an unintended approval):
| rule | tool | pre-PR | PR |
|---|---|---|---|
mcp__foo_bar__* (sanitized wildcard for server foo.bar) |
mcp__foo_bar__evil_1oxrpi0 |
EXECUTED | ask |
mcp__srv__foo_* (sub-tool prefix wildcard) |
mcp__srv__foo_bar_0cnn7di (raw foo/bar) |
EXECUTED | ask |
This follows directly from making the raw identity authoritative and I think it is the right call,
but the PR description only mentions ambiguous aliases and cross-identity matches losing power —
a unique wildcard written in the provider-safe spelling loses power too, silently. Worth noting
because the confirmation dialog persists and displays the registered name
(permissionRules: [this.registeredToolName], e.g. mcp__foo_bar__evil_1oxrpi0), so a user who
generalizes by hand from what the UI shows would naturally write mcp__foo_bar__* — a rule that
will now never match. A line in docs/users/configuration/settings.md (which currently only
documents "mcp__puppeteer") saying server/wildcard rules must use the raw server and tool
names would close this.
F3 — Nit: two files are not prettier-clean.
packages/core/src/core/permission-helpers.ts and permissionFlow.ts differ from
prettier output (an interface … extends wrap, a ternary wrap, and the fullAliases array).
This will not turn CI red — scripts/lint.js --prettier runs prettier --write . — but the
committed source drifts from repo formatting. npm run format fixes it.
F4 — Observation, no action: the cross-context deny > ask ordering is unreachable in product code.
Both callers of evaluatePermissionFlow (coreToolScheduler.ts:2205 and
Session.ts:7238) first run pm.isToolEnabled(canonicalName) with a bare context — no aliases,
no raw identity — and reject on deny before the flow is entered. Instrumented traces confirm it: a
deny in a sanitized/registered spelling never reaches evaluateRestrictivePermissionRules, which is
why M5 flips no live behavior while still failing two unit tests. The multi-context ordering is
therefore defense-in-depth for the flow's own contract rather than a live gate. The raw-spelling
deny path is live and does depend on restrictive context #1 (R2/R4/R5/R6 traces show
restrictiveMatched=true), so the mechanism as a whole is load-bearing.
Verdict
- Reported issue reproduces on
main(both variants) and is closed by this PR, verified in a real
bundle with real MCP servers, not just in unit tests. - Documented backwards compatibility (unique legacy aliases, the 63-char truncated form, exact
registered names,mcp__*,mcp__server,mcp__server__*) is preserved. - Restrictive rules do not fail open in any spelling I could construct.
- No Critical from me. F1 is what actually blocks the merge and it is a branch-freshness issue, not a
code issue: merge currentmain, let CI go green. F2/F3 are non-blocking. - The standing
CHANGES_REQUESTEDpredates these fixes and can only be cleared by the review bot
itself or by a human approval/dismissal.
中文说明
维护者本地真实环境验证 —— head 98bb096dee
我在本地重建了这个 PR,用真实 MCP 服务器 + 真实 provider 跑通链路,并用同一棵工作树产出的
「PR 前」bundle 做 A/B 对照。结论:Issue 报告的漏洞在 main 上可复现,本 PR 确实堵住了;
PR 承诺的向后兼容也都成立。未发现 Critical。 当前 CI 三个红灯与本 PR 无关,见 F1。
说明:之前那份维护者报告针对的是
1ad331195f(当时只是一次 merge main)。本次是针对当前
head98bb096dee的独立验证,覆盖了新增的permission-helpers.ts抽取与ToolRegistry
别名解析器,并补上了上一份报告没有覆盖的 Issue 变体 2(旧版别名碰撞) 与 子代理
disallowedTools两条链路。
验证台
- CLI:从 PR merge ref(
9f7116c827)打包的dist/cli.js - 对照组:同一棵树把 5 个生产文件回退到
f86098363d后重新打包(dist-base) - MCP:6 个真实 stdio MCP 服务器,用低层
Server+ 原始tools/list,让foo/bar、foo:bar
这类 provider 不安全的工具名原样上线 - provider:本地 OpenAI 兼容服务,落盘记录每次请求,可直接读出 CLI 实际暴露的工具声明
- 判据:MCP 端每次
tools/call往磁盘追加一条记录 —— 「执行了」是磁盘副作用,不是屏幕字符串
符号扫描确认两个 bundle 确实不同:dist-pr 含 evaluateRestrictivePermissionRules /
getUnambiguousMcpPermissionAliases,dist-base 两者都没有。
1. 漏洞可复现,PR 已堵住
--approval-mode default,headless。EXECUTED = 未经确认直接执行(漏洞);ask = 要求确认。
- A2:规则
mcp__foo.bar→ 打到另一个服务器foo_bar的工具:修复前 直接执行,修复后 要求确认 - B2:通配
mcp__foo.bar__*→ 同上,修复前直接执行,修复后要求确认 - C1/C2:有歧义的旧版别名
mcp__srv__foo_bar同时被foo/bar与foo:bar认领 → 修复前两者都被放行,修复后都要求确认 - D2:别名恰好等于另一个工具的注册名(
mcp__srv3__foo_bar)→ 修复前放行,修复后要求确认 - A1/B1/D1:目标身份本身仍然被正常授权,不是一刀切吊销
兼容性(全部仍然放行):唯一旧版别名 mcp__srv2__uniq_name、63 字符截断别名、精确注册名
(也就是「Always allow」实际写入的那个串)、mcp__srv2、mcp__srv__*、mcp__*、mcp__srv。
截图为交互式 TUI(Ask permissions 模式)、同一份 settings、同一句 prompt,只换 bundle:
修复前跨服务器静默执行;修复后弹出确认框;同一条规则对目标服务器依然直接放行。
旧版别名那一组同理(d- / e- 两图)。
2. 限制性规则保持 fail-closed
用 --yolo 跑(ask 被绕过、deny 不被绕过,因此只有真 deny 才会拦,R0/R8 作为对照证明了这一点)。
结果:历史 sanitized 拼写、raw 拼写、通配、以及有歧义的旧版别名,在 deny/ask 侧全部照旧生效;
R7 证明「一个上下文出 ask、另一个上下文出 deny」时 deny 全局胜出。
R5/R6 是关键一对:那个失去授权能力的别名,在 deny 侧依然完全有效 —— 正是 PR 描述的不对称性。
3. 子代理 disallowedTools
真起了一个子代理,从链路上读它自己那轮拿到的 MCP 工具声明:
mcp__foo.bar(raw 拼写)修复前会连带误杀 foo_bar 服务器的工具,修复后只移除 foo.bar 的;
mcp__foo_bar(sanitized 拼写)修复前后都两个都移除(兼容保留,方向是 fail-closed)。
即:黑名单变精确了,但没有丢掉历史拼写。
4. 变异验证
对生产代码做了 8 个变异体,每个都跑 PR 自带的 470 条聚焦用例,能到达真实链路的还重新打包上机:
- M1(通配恢复两侧 sanitize):3 条用例挂 + 真机漏洞重开
- M2(server 级恢复 sanitize 比较):3 条挂 + 真机漏洞重开
- M3(注册名不再计入别名归属):2 条挂 + 真机漏洞重开
- M4(关掉别名歧义过滤):4 条挂 + 真机漏洞重开
- M5(限制性循环遇到第一个 ask 就 break,即修复前的 bug):2 条挂;真机不可观测,见 F4
- M6(去掉「注册名兼容上下文」):2 条挂 + 真机 S1 fail open
- M7(resolver 缺失时授权侧 fail open):2 条挂
- M8(去掉
alias !== rawMcpToolName):0 条挂 —— 等价变异体,不是覆盖缺口:
resolveToolName对mcp__*是恒等映射,该排除项唯一能丢掉的就是 raw 身份本身,
而matchesMcpPattern里pattern === matchTarget早就先返回 true 了。
也就是说:没有一处生产改动是死代码,每一处都被用例和真机双重钉住(M8 除外,且已论证等价)。
5. 本地仓库检查
聚焦套件 470 全过;src/permissions src/core src/tools src/agents 大切片 9269 过 / 6 skip / 0 失败;
packages/core 的 tsc --noEmit 干净;11 个改动文件 eslint --max-warnings 0 干净。
结论与建议
F1(真正卡合并的点,但不是本 PR 的锅):三个红灯 job 全部死在同一处 —
Browser daemon SDK bundle is 220220 bytes; expected <= 220160,来自
packages/sdk-typescript/scripts/build.js 的 MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024,
本 PR 没碰这个文件。main 已在 #10630(2268c9bc06,08-31 11:48Z)把该预算改成 advisory,
而本分支最后一次合 main 是 1ad331195f(08-31 03:53Z),CI 检出的是 PR head,所以还带着硬上限。
本地实测同一个 bundle 是 221 474 字节:旧门槛会红、新门槛只告警。
把当前 origin/main merge 进分支即可清掉三个红灯,不需要改代码。(请用 merge 不要 rebase。)
F2(Suggestion):有一类未在描述中说明的兼容性变化 —— 用 sanitized 拼写写的通配规则会失去授权能力:
mcp__foo_bar__*(对应服务器 foo.bar)和 mcp__srv__foo_*(对应工具 foo/bar)修复前放行、修复后要求确认。
方向是 fail-closed,我认为取舍正确;但描述里只说了「有歧义的别名」和「跨身份有损匹配」会失效,
唯一且无歧义的 sanitized 通配也一并失效了。尤其值得提醒的是:确认框持久化并展示的是注册名
(permissionRules: [this.registeredToolName],例如 mcp__foo_bar__evil_1oxrpi0),
用户照着 UI 手工泛化出来的 mcp__foo_bar__* 从此永远不会命中。建议在
docs/users/configuration/settings.md(目前只写了 "mcp__puppeteer")补一句:
server/通配规则必须使用原始服务器名与工具名。
F3(Nit):permission-helpers.ts 与 permissionFlow.ts 不是 prettier 干净的。
因为 scripts/lint.js --prettier 跑的是 prettier --write .,CI 不会因此变红,但提交进来的源码
与仓库格式有偏差,npm run format 即可修掉。
F4(观察,不需要改动):deny 跨上下文压过 ask 的那段排序逻辑在产品路径上到不了。
evaluatePermissionFlow 的两个调用方(coreToolScheduler.ts:2205、Session.ts:7238)都会先用
裸上下文(无别名、无 raw 身份)跑 pm.isToolEnabled(canonicalName),deny 在进入 flow 之前就被拦了。
插桩 trace 证实了这一点,也解释了为什么 M5 能挂两条用例却改不动任何真机行为。
但 raw 拼写的 deny 路径是活的,确实依赖限制性上下文 #1(R2/R4/R5/R6 的 trace 都是
restrictiveMatched=true),所以整套机制整体上仍然承重。
总评:Issue 的两个变体都在 main 上真实复现并被本 PR 关闭;承诺的兼容性都保住;
限制性规则在我能构造的所有拼写下都没有 fail open。我这边没有 Critical。
建议:先把当前 main merge 进分支让 CI 转绿,F2/F3 不阻塞合并。
现存的 CHANGES_REQUESTED 早于这些修复,只能由评审 bot 自己或人工 approve / dismiss 清除。
— local end-to-end verification: real MCP servers + real bundle A/B + 8 mutants + interactive TUI screenshots
🔬 Maintainer verification, round 2 — head
|
| piece | what it is |
|---|---|
| merge ref | 95cfb5b1c3 = head 75e91d6287 into main 8290c81eea |
dist-pr |
npm ci + npm run bundle on that ref |
dist-base |
same worktree, the 5 production files reverted to 8290c81eea, re-bundled |
dist-mut |
same worktree + a one-line candidate fix (see N1), re-bundled |
| MCP | 6 real stdio servers (low-level Server + raw tools/list, so foo/bar / foo:bar reach the wire) |
| provider | local OpenAI-compatible server, logs every request body |
| witness | each MCP tools/call appends to a file — "executed" is a side effect on disk |
Bundle separation re-verified by symbol scan: evaluateRestrictivePermissionRules /
getUnambiguousMcpPermissionAliases appear in dist-pr, in neither file of dist-base.
All 8 registered identities came up byte-identical to round 1 (read off the wire).
1. The fix is intact after the main merge
Grant matrix, --approval-mode default, headless. Identical to round 1, case for case:
| # | rule | tool called | pre-PR | PR |
|---|---|---|---|---|
| A2 | mcp__foo.bar |
server foo_bar |
✅ ask | |
| B2 | mcp__foo.bar__* |
server foo_bar |
✅ ask | |
| C1/C2 | mcp__srv__foo_bar (ambiguous alias) |
foo/bar / foo:bar |
✅ ask | |
| D2 | mcp__srv3__foo_bar (alias == another tool's registered name) |
foo/bar |
✅ ask | |
| A1/B1/D1 | same rules, intended identity | — | EXECUTED | ✅ EXECUTED |
| E1/F1/G1 | unique legacy alias / mcp__srv2 / exact registered name |
— | EXECUTED | ✅ EXECUTED |
| I1–I4 | deny (sanitized, raw, ambiguous alias) beats allow | — | never executed | ✅ never executed |
Restrictive matrix under --yolo (bypasses ask, not deny, so only a genuine deny blocks;
R0/R8 are the isolation controls): R0 EXECUTED, R1–R7 blocked, R8 EXECUTED — identical to round 1
in both arms. Blocked means the witness file stays empty: the call never reached the MCP server.
Repo checks on the merge ref: focused suite 473 passed (6 files, up from 470 — main added 3),
broad slice src/permissions src/core src/tools src/agents 9309 passed / 6 skipped / 0 failed
(221 files), tsc --noEmit on packages/core clean, eslint --max-warnings 0 on all 11 changed
files clean. Mutants M1–M8 re-run against the 473-test suite: same kill counts as round 1
(M1 3, M2 3, M3 2, M4 4, M5 2, M6 2, M7 2, M8 0 — M8 still the equivalent survivor I argued in round 1).
2. Round-1 F1 (three red CI jobs) is resolved
The merge picked up #10630, which made the daemon bundle budget advisory. Locally the same step now
prints Browser daemon SDK bundle is 221839 bytes / exceeds the 221184-byte warning threshold
as a warning and npm ci exits 0. On CI, Integration Tests (no-AK) — one of the three jobs that
died in that step — is now green (9m29s). Test (ubuntu-latest) is still running as I write this.
No code change was needed; F1 is closed.
3. Adjudicating the three deferred [probe] items
D7-1 — bare * rule: confirmed, and it is the one finding I would act on (N1 below)
D7-2 — disallowedTools ignores legacy aliases: real, but pre-existing, not a regression (N2 below)
D7-3 — "no test covers restrictive-ask-beats-grant-allow": not reproducible at this head
I tried to kill the !restrictiveMatchCtx guard two ways and the suite caught both:
| mutant | effect | focused suite |
|---|---|---|
M9 — guard removed entirely (if (true)) |
grant pass always overwrites the restrictive verdict | 3 failed |
M11 — || finalPermission !== 'deny' |
restrictive ask loses to a grant allow (the exact scenario named) |
1 failed |
permissionFlow.mcp-restrictive-collision.test.ts > preserves forced ask when no restrictive deny exists
is the test that pins it. And the behavior holds live on the real bundle: with
allow: [<exact registered name>] plus ask: ["mcp__foo_bar"] (historical sanitized spelling),
the call still stops at the prompt — pre-PR and PR, wildcard variant too. I would drop D7-3.
N1 — Suggestion → I'd fix before merge: a bare * rule silently becomes an all-MCP wildcard
matchesMcpPattern pre-PR sanitized the wildcard prefix, and sanitizeToolNameForProvider('')
returns "tool_", so a bare * matched no MCP tool. The PR drops the sanitization
(correctly — that is the whole point) and the prefix becomes the empty string, so
matchTarget.startsWith('') is true for every MCP tool.
Live A/B, same settings.json, only the bundle differs:
permissions |
tool called | pre-PR | PR |
|---|---|---|---|
allow: ["*"] |
mcp__foo_bar__evil |
ask | |
allow: ["*"] |
mcp__srv__foo_bar_0cnn7di |
ask | |
allow: ["*"] |
mcp__srv2__uniq_name_1quugtb |
ask | |
deny: ["*"] (under --yolo) |
mcp__foo_bar__evil_1oxrpi0 |
EXECUTED | blocked |
Before (main) — allow: ["*"] is inert, the MCP prompt still appears:
After (this PR) — the same rule auto-executes the MCP tool with no prompt, in Ask permissions mode:
Three things make me flag it rather than shrug:
-
It is asymmetric and undocumented.
*still matches nothing for non-MCP tools —
toolMatchesRuleToolNamehas no wildcard branch (matchesRule(parseRule('*'), 'read_file')→
false,'run_shell_command'→false,'web_fetch'→false, MCP tool →true).
settings.mddocuments no bare-*rule, and the PR description does not mention this change. -
It is reachable from repository-controlled config, not just hand-edited settings.
A skill'sallowedToolsentries are handed verbatim toPermissionManager.addSessionAllowRule
(skill-utils.ts:318, no validation inparseAllowedToolsField), andallowedTools: ["*"]is a
natural way to write "this skill may use any tool". Driving the real classes:applySkillAllowedTools(pm, ['*']) pm.evaluate({toolName:'mcp__foo_bar__evil'}) -> allow pm.evaluate({toolName:'read_file'}) -> defaultBefore this PR that entry was harmless for MCP tools; after it, it auto-approves all of them
for the rest of the session. -
It is untested, and a one-line guard closes it with zero fallout. Adding
&& pattern.length > 1to the wildcard branch:- kills 0 of 473 focused tests (nothing pins the new behavior),
- restores pre-PR semantics in both directions (
allow: ["*"]→ ask,deny: ["*"]→ inert), - leaves every case this PR fixes untouched, re-verified on a rebuilt bundle:
A1 EXECUTED, A2 ask, B1 EXECUTED, B2 ask, C1 ask, D2 ask, E1 EXECUTED,mcp__srv__*EXECUTED,
mcp__*EXECUTED.
If a bare * should mean "all MCP tools", that is a defensible design — but then it needs a doc
line and a test, and it should probably mean all tools, not only MCP ones. Either way it should not
land as an unremarked side effect of a collision fix.
N2 — Suggestion (pre-existing, not caused by this PR): disallowedTools does not honour legacy aliases
agent-core.ts now threads the raw MCP identity into the subagent blocklist, but it still never
consults permissionAliases. Real subagent launched, its own model request read off the wire
(the tool is mcp__srv2__uniq_name_1quugtb, legacy alias mcp__srv2__uniq_name, unique — so
the alias is a valid grant/deny spelling elsewhere):
disallowedTools entry |
pre-PR | PR |
|---|---|---|
mcp__srv2__uniq_name (unique legacy alias) |
||
mcp__srv2__uniq_name_1quugtb (registered name) |
removed | ✅ removed |
mcp__srv2 (server level) |
removed | ✅ removed |
The same alias in permissions.deny does block the call at runtime (verified under --yolo:
witness empty, both arms). So an operator can write one spelling that works in settings.json and
silently does nothing in agent frontmatter. Not a regression and not a merge blocker — but since this
PR edits exactly that line, wiring permissionAliases in (or documenting the restriction in
sub-agents.md, which currently only mentions "MCP server-level patterns") is the natural follow-up.
Still open from round 1
- F2 (Suggestion): the sanitized-spelling wildcard compatibility break is still undocumented —
H1(allow: ["mcp__foo_bar__*"]for serverfoo.bar) goes EXECUTED → ask, and the confirmation
dialog persists the registered name, so a user generalizing from the UI writes exactly the rule
that will never match. One line insettings.mdcloses it. - F3 (Nit):
permission-helpers.tsandpermissionFlow.tsare still not prettier-clean
(aninterface … extendswrap, apmForcedAsk:ternary wrap, thefullAliasesarray).
CI will not turn red (scripts/lint.js --prettierruns--write), butnpm run formatfixes it.
Verdict
- The reported issue still reproduces on
mainand is still closed by this PR, on the merged tree,
in a real bundle with real MCP servers. - Backwards compatibility, restrictive fail-closed behavior, and every mutant verdict are unchanged
by themainmerge. - Round-1 F1 is resolved; CI is green except the one job still running.
- Of the three deferred probes: D7-3 is stale, D7-2 is real but pre-existing, D7-1 is real and I'd
fix it here — it is one line, it is untested either way, and it is the only place in this PR where
the change moves in the fail-open direction. - No Critical from me. With N1 applied I would merge this.
中文说明
维护者本地真实环境验证(第 2 轮)—— head 75e91d6287(已合入 main)
接续第 1 轮报告(head 98bb096dee)。
此后分支两次合入 main,基线前进了 54 个提交,并且改动了本 PR 路径上的两个文件
(permission-manager.ts、coreToolScheduler.ts)。因此我在当前 merge ref 上重建了整套验证台并全部重跑,
同时用这一轮真机结果去裁决评审 bot 第 7 轮延后的三条 [probe] 结论。
结论:合并 main 后修复完好;第 1 轮的 F1(三个红灯)已消除;但发现一处我认为应当在合并前修掉的
fail-open 扩权——它是本 PR 的副作用。
验证台(重建,非复用)
- merge ref
95cfb5b1c3= head75e91d6287合入main8290c81eea dist-pr:该 ref 上npm ci+npm run bundledist-base:同一棵树把 5 个生产文件回退到8290c81eea后重新打包dist-mut:同一棵树 + N1 的一行候选修复后重新打包- MCP:6 个真实 stdio 服务器(低层
Server+ 原始tools/list,让foo/bar、foo:bar原样上线) - provider:本地 OpenAI 兼容服务,落盘记录每次请求体
- 判据:MCP 端每次
tools/call往磁盘追加一条 —— 「执行了」是磁盘副作用,不是屏幕字符串
符号扫描再次确认两个 bundle 不同;8 个注册身份与第 1 轮逐字一致(从链路读出)。
1. 合并 main 后修复完好
授权矩阵(default 模式、headless)与第 1 轮逐条一致:A2/B2/C1/C2/D2 修复前直接执行、修复后要求确认;
A1/B1/D1 目标身份仍正常授权;E1/F1/G1(唯一旧版别名 / mcp__srv2 / 精确注册名)兼容保留;
I1–I4 deny 压过 allow。限制性矩阵在 --yolo 下(绕过 ask、不绕过 deny,R0/R8 为隔离对照)
R0 执行、R1–R7 拦截、R8 执行,同样与第 1 轮一致;「拦截」= witness 文件为空,调用根本没到 MCP 服务器。
仓库检查:聚焦套件 473 全过(第 1 轮是 470,main 新增 3 条);
src/permissions src/core src/tools src/agents 大切片 9309 过 / 6 skip / 0 失败(221 个文件);
packages/core 的 tsc --noEmit 干净;11 个改动文件 eslint --max-warnings 0 干净。
M1–M8 变异体重跑,杀伤数与第 1 轮完全相同(M8 仍是我在第 1 轮论证过的等价存活体)。
2. 第 1 轮 F1(三个红灯)已消除
合入 main 带进了 #10630,把 daemon bundle 预算改成了 advisory。本地同一步现在打印
Browser daemon SDK bundle is 221839 bytes / exceeds the 221184-byte warning threshold,
是告警且 npm ci 退出码 0。CI 上原先死在这一步的三个 job 中,Integration Tests (no-AK)
已经转绿(9m29s);Test (ubuntu-latest) 在我写这份报告时仍在跑。无需改代码,F1 关闭。
3. 三条延后 [probe] 的裁决
- D7-1(裸
*规则):成立,并且是我唯一建议合并前修掉的一条 —— 见下方 N1。 - D7-2(
disallowedTools忽略旧版别名):真实存在,但是既有问题、不是本 PR 引入的回归 —— 见 N2。 - D7-3(「restrictive-ask 压过 grant-allow 没有测试」):在当前 head 上复现不出来。
我用两种方式去杀!restrictiveMatchCtx这道门,套件都抓住了:
M9(整门删除,if (true))挂 3 条;M11(改成|| finalPermission !== 'deny',即让限制性 ask
输给授权 allow,正是该条描述的场景)挂 1 条。钉住它的用例是
permissionFlow.mcp-restrictive-collision.test.ts > preserves forced ask when no restrictive deny exists。
真机上也成立:allow: [精确注册名]+ask: ["mcp__foo_bar"](历史 sanitized 拼写)在修复前后都停在确认框,
通配变体同理。建议把 D7-3 划掉。
N1(Suggestion,但我建议合并前修):裸 * 规则悄悄变成了「所有 MCP 工具」的通配
修复前 matchesMcpPattern 会 sanitize 通配前缀,而 sanitizeToolNameForProvider('') 返回 "tool_",
所以裸 * 一个 MCP 工具都不匹配。本 PR 去掉了这层 sanitize(方向本身是对的,这正是修复要点),
前缀于是变成空串,matchTarget.startsWith('') 对每一个 MCP 工具都为真。
真机 A/B(同一份 settings,只换 bundle):allow: ["*"] 打三个不同 MCP 工具,修复前全部要求确认,
修复后全部直接执行;deny: ["*"] 在 --yolo 下修复前不拦、修复后拦住。截图见英文部分
(f = 修复前仍弹确认框,g = 修复后无提示直接执行,h = 加上一行护栏后确认框回来)。
我认为值得提出来而不是耸肩带过,有三个理由:
- 不对称且无文档。
*对非 MCP 工具依然什么都不匹配(toolMatchesRuleToolName没有通配分支:
matchesRule(parseRule('*'), 'read_file' / 'run_shell_command' / 'web_fetch')全是false,
只有 MCP 工具是true)。settings.md没有记录裸*规则,PR 描述里也没提这处变化。 - 可达路径不止手写 settings,还包括仓库可控的配置。 skill 的
allowedTools条目是原样交给
PermissionManager.addSessionAllowRule的(skill-utils.ts:318,parseAllowedToolsField不做任何校验),
而allowedTools: ["*"]是「这个 skill 可以用任何工具」最自然的写法。用真实类跑一遍:
applySkillAllowedTools(pm, ['*'])之后,MCP 工具得到allow,read_file得到default。
本 PR 之前这一条对 MCP 工具是无害的,之后它会在整个会话里自动放行所有 MCP 工具。 - 没有任何测试覆盖,而一行护栏就能关掉且零副作用。 在通配分支加上
&& pattern.length > 1:
473 条聚焦用例一条都不挂(说明新行为没有被任何测试钉住);allow: ["*"]与deny: ["*"]
两个方向都回到修复前语义;本 PR 修好的每一个用例都不受影响(重新打包后实测:
A1 执行、A2 确认、B1 执行、B2 确认、C1 确认、D2 确认、E1 执行、mcp__srv__*执行、mcp__*执行)。
如果裸 * 本来就该表示「所有 MCP 工具」,那是一个可以成立的设计,但它需要一行文档和一条测试,
而且大概应该表示「所有工具」而不是只有 MCP。无论选哪条路,它都不该作为一个碰撞修复的无声副作用合进来。
N2(Suggestion,既有问题、非本 PR 引入):disallowedTools 不认旧版别名
agent-core.ts 现在把 raw MCP 身份接进了子代理黑名单,但仍然完全不查 permissionAliases。
真起子代理、从链路读它自己那轮拿到的工具声明(目标工具 mcp__srv2__uniq_name_1quugtb,
旧版别名 mcp__srv2__uniq_name 是唯一的,所以这个拼写在别处是合法的授权/拒绝拼写):
disallowedTools: [mcp__srv2__uniq_name](唯一旧版别名):修复前仍可见,修复后仍可见disallowedTools: [mcp__srv2__uniq_name_1quugtb](注册名):修复前后都被移除disallowedTools: [mcp__srv2](server 级):修复前后都被移除
而同一个别名写进 permissions.deny 是能在运行时拦住的(--yolo 下实测:witness 为空,两臂皆然)。
也就是说,操作者写出的同一个拼写在 settings.json 里生效、在 agent frontmatter 里静默失效。
这不是回归,也不阻塞合并;但既然本 PR 正好改的就是那一行,把 permissionAliases 一并接上
(或者在 sub-agents.md 里写明限制,那里目前只提到「MCP server 级模式」)是自然的后续。
第 1 轮遗留
- F2(Suggestion):sanitized 拼写通配失去授权能力这件事仍然没有文档 ——
H1(服务器foo.bar写allow: ["mcp__foo_bar__*"])从执行变成要求确认,
而确认框持久化并展示的是注册名,用户照着 UI 泛化出来的恰好就是那条永远不会命中的规则。
在settings.md补一行即可。 - F3(Nit):
permission-helpers.ts与permissionFlow.ts仍不是 prettier 干净的
(interface … extends换行、pmForcedAsk:三元换行、fullAliases数组)。
CI 不会因此变红(scripts/lint.js --prettier跑的是--write),npm run format即可修掉。
总评
- Issue 报告的问题在合并后的树上依然可在
main侧复现、并被本 PR 关闭,证据来自真实 bundle + 真实 MCP 服务器。 - 向后兼容、限制性规则 fail-closed、以及全部变异体判定都没有被 main 合并改变。
- 第 1 轮 F1 已消除,CI 除一个仍在跑的 job 外全绿。
- 三条延后项里:D7-3 已过时,D7-2 真实但是既有问题,D7-1 真实且我建议就在本 PR 修掉 ——
一行改动、两边都没有测试,而且它是本 PR 里唯一朝 fail-open 方向移动的地方。 - 我这边没有 Critical。N1 修掉之后我认为可以合并。
— round 2: rebuilt on the current merge ref · real MCP servers · bundle A/B + candidate-fix bundle · 11 mutants · TUI screenshots
|
CI follow-up: the previous Qwen Code CI failure was isolated to the unrelated timing-budget test |
doudouOUC
left a comment
There was a problem hiding this comment.
Verified the two-spelling mechanism at the PR head. No blocking issues found.
matchesMcpPatternnow takes the raw MCP identity as an optional third argument:matchTarget = rawToolName ?? toolName. Exact rules match the registered name OR the raw identity; wildcard rules prefix-match the raw identity when present; the server-levelmcp__serverform requires the raw identity's server segment and deliberately does not sanitize either side (foo.barnever broadens tofoo_bar).- The agent-core call site evaluates both spellings (
matchesMcpPattern(pattern, name, raw) || matchesMcpPattern(pattern, name)), so a raw wildcard and a provider-safe wildcard each block — the new test pins all four shapes (mcp__foo.bar,mcp__foo.bar__*,mcp__foo_bar,mcp__foo_bar__*) blocking, plus a same-prefix unrelated server not blocked. - The restrictive-evaluation helper (
evaluateRestrictivePermissionRules) has the right semantics for collision-safe spelling evaluation: deny wins globally, the first explicitaskis kept when no deny matches,allowis deliberately ignored — grants are evaluated separately against the ambiguity-filtered context, which is the correct fail-closed/fail-open split.
One note, not a finding: the double-call matchesMcpPattern(pattern, t.name!, rawMcpToolName) || matchesMcpPattern(pattern, t.name!) looks redundant at first glance (the first call already compares pattern === toolName), but the second call is what catches the provider-safe wildcard (mcp__foo_bar__*) — with rawToolName set, matchTarget is the raw name, so the first call's wildcard test can't see the provider-safe prefix. Worth one comment line saying so, so a future reader doesn't "simplify" it into a regression.









What this PR does
This PR prevents MCP permission rules from broadening across identities that collide after legacy or provider-name sanitization. Server-level and wildcard permission matching uses the authoritative raw MCP identity for grants instead of treating a lossy provider-safe spelling as proof of identity. Legacy exact MCP permission aliases are resolved against the currently registered MCP toolset and are accepted for grants only when exactly one tool owns the alias. Unique legacy aliases, including the existing 63-character truncated form, remain supported for backwards compatibility.
Restrictive rules stay fail-closed: deny/ask evaluation keeps the unfiltered historical spellings, evaluates both raw-identity and registered-name compatibility contexts, and preserves global
deny > ask > allow/defaultprecedence across those contexts. The grant pass still runs only against the collision-filtered context.Why it's needed
MCP tool registration already uses collision-resistant normalized names, but the permission compatibility layer could collapse distinct server or tool identities back onto the same lossy representation. As described in #10199, server names such as
foo.barandfoo_barcan collide during permission matching, and distinct tool names such asfoo/barandfoo:barcan share the same legacy aliasmcp__srv__foo_bar. A savedallowrule intended for one MCP identity could therefore authorize a different MCP tool that should retain its normalaskbehavior.This PR keeps backwards compatibility only where it is safe: ambiguous aliases cannot grant permission, while restrictive legacy spellings continue to deny/ask rather than failing open.
Review follow-ups addressed
foo_barregistered-name collision variant.disallowedToolspath.denywins globally when different restrictive identity contexts produceaskanddeny.hasRelevantRules -> evaluate -> forced asklogic inpermission-helpers.ts; the MCP restrictive multi-context evaluator reuses the same core.permissionFlowcoverage for the alias-resolver KEEP result so a unique legacy alias demonstrably reaches grant evaluation.ToolRegistry.getUnambiguousMcpPermissionAliases()coverage with actualDiscoveredMCPToolinstances, including shared aliases, unique aliases, non-MCP registry entries, and the alias-vs-registered-name case.MCP permission-rule compatibility note
For server-level and wildcard grant rules, use the raw MCP server/tool spelling, for example
mcp__foo.barormcp__foo.bar__*. Provider-safe sanitized wildcard spellings such asmcp__foo_bar__*are intentionally not treated as grant aliases because that spelling can represent multiple raw identities; such calls fall back to the safer prompt path instead of being auto-approved. Unique exact legacy aliases remain supported, including the existing 63-character truncated exact form. Restrictive legacy spellings continue to fail closed fordeny/ask.Reviewer Test Plan
How to verify
mcp__foo.barmatches the normalized tool identity belonging to serverfoo.bar, but does not grant a tool belonging to the distinct serverfoo_bar.mcp__foo.bar__*likewise does not broaden across the same sanitized server-name collision.foo/barandfoo:barboth produce the legacy aliasmcp__srv__foo_bar; verify the ambiguous legacy alias is removed from the grant context for both identities.foo_baralongsidefoo/bar; verify the registered name itself counts as an alias claimant and the colliding legacy alias is dropped.askmatch in one restrictive spelling context does not mask adenymatch in another context.disallowedToolshandles both authoritative raw MCP spellings and historical provider-safe spellings.Expected result: permission grants remain bound to the intended collision-resistant MCP identity, ambiguous legacy aliases do not grant permission, restrictive deny/ask rules do not fail open, and unique legacy aliases continue to work.
Current validation status
3bbbdcd5412ef74f42899f7de7d225e06af23e9e.main(2b8f73c1e9cf8b355ec46c4623398c27b458b076) was merged into the PR branch with a normal two-parent merge commit (no rebase/force-push). The resulting tree is byte-identical to GitHub's conflict-free test merge.Security ChecksandQwen Code CIruns were created for this HEAD and currently reportaction_required, i.e. the fork workflow approval gate; no new test failure has executed on this HEAD yet.98bb096dee92f62a3c0fc01d280d07279a096347reported 470/470 focused tests, 9269 passed / 6 skipped / 0 failed in the broad slice, cleantsc --noEmit, and clean ESLint. The merge from currentmainwas GitHub-conflict-free and preserved the PR diff.Risk & Scope
Linked Issues
Closes #10199
中文说明
此 PR 做了什么
此 PR 防止 MCP 权限规则因为旧版名称或 provider 名称的有损清理而扩展到发生碰撞的其他身份。授权匹配使用权威的原始 MCP 身份;旧版精确别名只有在当前注册的 MCP 工具中拥有唯一归属时才可用于授权。唯一旧版别名(包括现有 63 字符截断形式)继续保持兼容。
限制性规则保持 fail-closed:deny/ask 会保留未过滤的历史拼写,并同时检查原始身份与注册名称兼容上下文;跨上下文保持
deny > ask优先级。真正的 allow 授权仍只在经过碰撞过滤的上下文中执行。Review 修复内容
foo_bar注册名碰撞。disallowedTools中使用权威 raw MCP identity。ask会遮蔽后续deny的问题。permission-helpers.ts。disallowedTools回归覆盖。兼容性说明
server 级和通配形式的授权规则应使用 raw MCP server/tool 名称(例如
mcp__foo.bar/mcp__foo.bar__*)。provider-safe 的 sanitized 通配形式(例如mcp__foo_bar__*)不再作为授权别名,以避免不同 raw identity 因名称清理而互相获得权限;未命中时会回退到更安全的确认流程。唯一且无歧义的精确 legacy alias(包括现有 63 字符截断形式)仍保持兼容。验证
当前分支已通过普通 merge commit 合入最新
main,没有 rebase/force-push。新的上游 Actions 已为当前 HEAD 创建,但由于 fork PR 的工作流审批门槛目前显示action_required。合并前 HEAD 的本地验证为 focused 470/470 通过、宽范围 9269 通过 / 6 skipped / 0 failed、TypeScript 与 ESLint 均干净。关联 Issue
Closes #10199