Skip to content

fix(permissions): prevent MCP permission identity collisions - #10202

Closed
SLP-DEV1 wants to merge 42 commits into
QwenLM:mainfrom
SLP-DEV1:fix/10199-mcp-permission-collisions
Closed

SLP-DEV1 wants to merge 42 commits into
QwenLM:mainfrom
SLP-DEV1:fix/10199-mcp-permission-collisions

Conversation

@SLP-DEV1

@SLP-DEV1 SLP-DEV1 commented Aug 26, 2026 •

Copy link
Copy Markdown

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/default precedence 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.bar and foo_bar can collide during permission matching, and distinct tool names such as foo/bar and foo:bar can share the same legacy alias mcp__srv__foo_bar. A saved allow rule intended for one MCP identity could therefore authorize a different MCP tool that should retain its normal ask behavior.

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

  • Count both MCP permission aliases and registered MCP tool names as alias claimants, closing the foo_bar registered-name collision variant.
  • Thread the authoritative raw MCP identity through permission matching and the subagent disallowedTools path.
  • Preserve historical provider-safe deny/ask spellings through a restriction-only registered-name compatibility context without re-enabling them as grants.
  • Ensure deny wins globally when different restrictive identity contexts produce ask and deny.
  • Fail closed for grant aliases when an authoritative raw identity exists but an incomplete registry does not expose the alias resolver.
  • Centralize the shared L4 hasRelevantRules -> evaluate -> forced ask logic in permission-helpers.ts; the MCP restrictive multi-context evaluator reuses the same core.
  • Add live permissionFlow coverage for the alias-resolver KEEP result so a unique legacy alias demonstrably reaches grant evaluation.
  • Add real ToolRegistry.getUnambiguousMcpPermissionAliases() coverage with actual DiscoveredMCPTool instances, including shared aliases, unique aliases, non-MCP registry entries, and the alias-vs-registered-name case.
  • Add a direct fail-closed regression test proving that provider-unsafe server rules cannot be reconstructed from a provider-safe registered name when no authoritative raw identity is available.

MCP permission-rule compatibility note

For server-level and wildcard grant rules, use the raw MCP server/tool spelling, for example mcp__foo.bar or mcp__foo.bar__*. Provider-safe sanitized wildcard spellings such as mcp__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 for deny/ask.

Reviewer Test Plan

How to verify

  1. Verify a server-level rule such as mcp__foo.bar matches the normalized tool identity belonging to server foo.bar, but does not grant a tool belonging to the distinct server foo_bar.
  2. Verify the wildcard form mcp__foo.bar__* likewise does not broaden across the same sanitized server-name collision.
  3. Register two MCP tools whose raw names foo/bar and foo:bar both produce the legacy alias mcp__srv__foo_bar; verify the ambiguous legacy alias is removed from the grant context for both identities.
  4. Register a provider-safe tool named foo_bar alongside foo/bar; verify the registered name itself counts as an alias claimant and the colliding legacy alias is dropped.
  5. Verify ambiguous legacy aliases remain available to restrictive deny/ask evaluation, while grant evaluation remains collision-filtered.
  6. Verify an ask match in one restrictive spelling context does not mask a deny match in another context.
  7. Verify a unique legacy exact alias and the existing unique 63-character truncated legacy alias still work for grants.
  8. Verify subagent disallowedTools handles both authoritative raw MCP spellings and historical provider-safe spellings.
  9. Verify provider-unsafe server rules fail closed when no authoritative raw MCP identity is available.
  10. Run the focused regression suite:
npx vitest run \
  packages/core/src/permissions/mcp-permission-collision.test.ts \
  packages/core/src/permissions/permission-manager.test.ts \
  packages/core/src/core/permissionFlow.test.ts \
  packages/core/src/core/permissionFlow.mcp-restrictive-collision.test.ts \
  packages/core/src/tools/tool-registry.mcp-permission-alias.test.ts \
  packages/core/src/agents/runtime/agent-core.mcp-disallowed.test.ts

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

  • PR is currently mergeable and not a draft.
  • Current HEAD: 3bbbdcd5412ef74f42899f7de7d225e06af23e9e.
  • Current upstream 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.
  • Fresh upstream Security Checks and Qwen Code CI runs were created for this HEAD and currently report action_required, i.e. the fork workflow approval gate; no new test failure has executed on this HEAD yet.
  • The maintainer's local verification at pre-merge HEAD 98bb096dee92f62a3c0fc01d280d07279a096347 reported 470/470 focused tests, 9269 passed / 6 skipped / 0 failed in the broad slice, clean tsc --noEmit, and clean ESLint. The merge from current main was GitHub-conflict-free and preserved the PR diff.

Risk & Scope

  • Main risk/tradeoff: an existing ambiguous legacy MCP permission alias will no longer authorize a tool automatically; affected invocations fall back to the safer permission path instead of preserving a potentially unsafe broad match.
  • Server-level/wildcard grant rules written in a provider-safe sanitized spelling may also stop auto-granting even when that spelling happened to be unique; use the raw MCP server/tool spelling for these rules. This is fail-closed (extra prompt), not unintended authorization.
  • Restrictive compatibility intentionally prefers over-blocking to fail-open behavior when an old provider-safe spelling is ambiguous.
  • Out of scope: this PR does not redesign the MCP naming scheme or remove legacy permission compatibility.
  • Breaking changes / migration notes: no API migration is required. Unique exact legacy aliases continue to work; ambiguous aliases, cross-identity lossy matches, and provider-safe sanitized server/wildcard grant spellings lose authorization power.

Linked Issues

Closes #10199

中文说明

此 PR 做了什么

此 PR 防止 MCP 权限规则因为旧版名称或 provider 名称的有损清理而扩展到发生碰撞的其他身份。授权匹配使用权威的原始 MCP 身份;旧版精确别名只有在当前注册的 MCP 工具中拥有唯一归属时才可用于授权。唯一旧版别名(包括现有 63 字符截断形式)继续保持兼容。

限制性规则保持 fail-closed:deny/ask 会保留未过滤的历史拼写,并同时检查原始身份与注册名称兼容上下文;跨上下文保持 deny > ask 优先级。真正的 allow 授权仍只在经过碰撞过滤的上下文中执行。

Review 修复内容

  • 将已注册 MCP 工具名与旧版别名一起计入别名归属,修复 foo_bar 注册名碰撞。
  • 在权限流和子代理 disallowedTools 中使用权威 raw MCP identity。
  • 仅在限制性 deny/ask 路径保留历史 provider-safe 拼写,避免重新扩大 allow 授权范围。
  • 修复多上下文中先出现 ask 会遮蔽后续 deny 的问题。
  • 当存在 raw identity 但 registry resolver 不可用时,授权侧旧版别名 fail-closed。
  • 将 L4 的公共求值核心集中到 permission-helpers.ts。
  • 新增 resolver KEEP、真实 ToolRegistry 接线、别名与注册名碰撞、无 raw identity 时 fail-closed、以及 MCP 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

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 26, 2026
@SLP-DEV1
SLP-DEV1 marked this pull request as ready for review August 26, 2026 20:53

Copy link
Copy Markdown
Author

@qwen-code /triage

Copy link
Copy Markdown
Author

/review

Copy link
Copy Markdown
Author

@qwen-code /review

Copy link
Copy Markdown
Author

@qwen-code /review

Copy link
Copy Markdown
Author

Follow-up after the previous review rounds:

  • Current head: 1e5ff83cf3f59950fef6048f7565bd87c4cf0949
  • Allow evaluation now uses the collision-filtered grant context only.
  • Restrictive deny / ask evaluation preserves raw MCP identity plus the registered provider-safe spelling, so legacy/provider-sanitized restrictive rules remain fail-closed without re-broadening grants.
  • Subagent disallowedTools matching now receives the authoritative raw MCP identity as well.
  • The no-resolver fallback is pinned to raw identity only for grants.
  • Temporary validation workflows have been removed from the PR diff.

Focused fork validation passed 498/498 tests plus tsc --noEmit -p packages/core/tsconfig.json.

A fresh @qwen-code /review has been requested against this head; the currently visible CHANGES_REQUESTED submissions are from older SHAs.

@SLP-DEV1
SLP-DEV1 force-pushed the fix/10199-mcp-permission-collisions branch from 1a8a71d to 1a9dae8 Compare August 29, 2026 19:22
@github-actions

Copy link
Copy Markdown
Contributor

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)为单个提交。

Copy link
Copy Markdown
Author

@qwen-code /review

@qqqys

qqqys commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

🔬 Maintainer review + tmux E2E report — no Critical found at current head

Code review (head 1ad331195f9f)

The only change since the last completed review round (bot round 4 on abd822f8071a, Suggestions only) is a merge of main — verified via compare: the merge touches none of this PR's files, so the reviewed production code is byte-identical at the current head. Spot-checks of the former Criticals at this head:

  • R2-3 (two-context restrictive loop broke on first ask, demoting a later-spelling deny): fixed — the loop now only breaks on deny, records the first ask, and applies it after the loop only if no spelling produced deny ("deny that matches a later spelling must still win globally").
  • R2-2 (agent-core disallowedTools went raw-only, silently un-blocking sanitized-spelling entries): fixed — matching is now dual: matchesMcpPattern(pattern, name, rawMcpToolName) || matchesMcpPattern(pattern, name); the second (sanitized) pass restores the old spellings, which is the fail-closed direction for disallow lists.
  • Grant path semantics verified by reading: grants use collision-filtered legacy aliases (filterUnambiguousMcpPermissionAliases) + the authoritative raw identity appended last; restrictive rules evaluate both spellings; wildcards/server-level rules match on the raw identity without lossy sanitization; matchesRule excludes the raw identity from the legacy-exact alias scan.

No new merge-blocking issues found. Remaining open threads are Suggestion-level (already reported).

E2E (interactive TUI under tmux, real built bundle)

Built the head from source and ran the interactive CLI (Ask permissions mode) against two minimal stdio MCP servers whose names collide under provider sanitization: srv.dot and srv_dot (both expose ping; the dotted server registers as mcp__srv_dot__ping_01lsbax, raw identity mcp__srv.dot__ping). User-scope settings: permissions.allow: ["mcp__srv_dot__*"] — a wildcard written in the sanitized spelling.

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 -p control 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, where ask surfaces 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 raise MAX_DAEMON_BROWSER_BUNDLE_BYTES on 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_REQUESTED state 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

Copy link
Copy Markdown
Author

@qwen-code /review

Review follow-ups are pushed on HEAD 98bb096dee92f62a3c0fc01d280d07279a096347.

Key follow-ups since the previous review:

  • centralized restrictive L4 evaluation in permission-helpers.ts
  • added live resolver KEEP coverage in permissionFlow
  • added real ToolRegistry.getUnambiguousMcpPermissionAliases() coverage with actual MCP tools
  • added fail-closed no-raw-identity regression coverage
  • resolved outdated Critical threads against the current implementation
  • updated the PR reviewer test plan with the expanded focused suite

Please review the current HEAD rather than superseded earlier commits.

@wenshao

wenshao commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

🔬 Maintainer verification — real local environment, head 98bb096dee

I rebuilt this PR locally and ran it against real MCP servers and a real provider, comparing
the PR bundle with a pre-PR bundle produced from the same worktree. The reported vulnerability
reproduces on main and is closed by this PR; documented backwards compatibility holds.
No Critical found. The three red CI jobs are not caused by this PR — details in F1.

Scope note: an earlier maintainer report covered head 1ad331195f (merge-only delta at the time).
This is an independent run at the current head 98bb096dee, which adds the permission-helpers.ts
centralization and the ToolRegistry alias resolver, and it covers issue-variant 2 (legacy alias
collisions) and the subagent disallowedTools path, which the earlier report did not.

Rig

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) ⚠️ EXECUTED ✅ ask
B1 mcp__foo.bar__* server foo.bar (intended) EXECUTED EXECUTED
B2 mcp__foo.bar__* server foo_bar ⚠️ EXECUTED ✅ ask
C1 mcp__srv__foo_bar (ambiguous alias) tool foo/bar ⚠️ EXECUTED ✅ ask
C2 mcp__srv__foo_bar (ambiguous alias) tool foo:bar ⚠️ EXECUTED ✅ ask
D1 mcp__srv3__foo_bar the real tool foo_bar EXECUTED EXECUTED
D2 mcp__srv3__foo_bar the other tool foo/bar ⚠️ EXECUTED ✅ 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:

before

After (this PR) — the same call stops at the confirmation dialog:

after

After (this PR) — the intended server is still granted by the same rule, so the fix is not a blanket revocation:

after-intended

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.

before-alias
after-alias

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 ⚠️ hole reopens (B2 → EXECUTED)
M2 — server-level compares sanitized names again 3 failed ⚠️ hole reopens (A2 → EXECUTED)
M3 — registered names no longer count as alias claimants 2 failed ⚠️ hole reopens (D2 → EXECUTED)
M4 — alias ambiguity filter disabled 4 failed ⚠️ hole reopens (C1 → EXECUTED)
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 ⚠️ S1 fails open (→ EXECUTED)
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 --noEmit on packages/core: clean.
  • eslint --max-warnings 0 on 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 current main, let CI go green. F2/F3 are non-blocking.
  • The standing CHANGES_REQUESTED predates 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)。本次是针对当前
head 98bb096dee 的独立验证,覆盖了新增的 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

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 1, 2026
@wenshao

wenshao commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

🔬 Maintainer verification, round 2 — head 75e91d6287 (after main was merged)

Follow-up to my round-1 report
(head 98bb096dee). Since then the branch merged main twice, which moved the base by 54 commits
and touched two files directly on this PR's path (permission-manager.ts, coreToolScheduler.ts).
So I rebuilt the whole rig on the current merge ref and re-ran everything, then used the run to
adjudicate the three [probe] items the review bot deferred in round 7.

Result: the fix survives the merge unchanged, round-1's F1 (red CI) is resolved, and I found one
thing I would fix before merging — a fail-open widening that this PR introduces as a side effect.

Rig (rebuilt, not reused)

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 ⚠️ EXECUTED ✅ ask
B2 mcp__foo.bar__* server foo_bar ⚠️ EXECUTED ✅ ask
C1/C2 mcp__srv__foo_bar (ambiguous alias) foo/bar / foo:bar ⚠️ EXECUTED ✅ ask
D2 mcp__srv3__foo_bar (alias == another tool's registered name) foo/bar ⚠️ EXECUTED ✅ 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.

after-head

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 ⚠️ EXECUTED
allow: ["*"] mcp__srv__foo_bar_0cnn7di ask ⚠️ EXECUTED
allow: ["*"] mcp__srv2__uniq_name_1quugtb ask ⚠️ EXECUTED
deny: ["*"] (under --yolo) mcp__foo_bar__evil_1oxrpi0 EXECUTED blocked

Before (main) — allow: ["*"] is inert, the MCP prompt still appears:

before-star

After (this PR) — the same rule auto-executes the MCP tool with no prompt, in Ask permissions mode:

after-star

Three things make me flag it rather than shrug:

  1. It is asymmetric and undocumented. * still matches nothing for non-MCP tools —
    toolMatchesRuleToolName has no wildcard branch (matchesRule(parseRule('*'), 'read_file') →
    false, 'run_shell_command' → false, 'web_fetch' → false, MCP tool → true).
    settings.md documents no bare-* rule, and the PR description does not mention this change.

  2. It is reachable from repository-controlled config, not just hand-edited settings.
    A skill's allowedTools entries are handed verbatim to PermissionManager.addSessionAllowRule
    (skill-utils.ts:318, no validation in parseAllowedToolsField), and allowedTools: ["*"] 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'})           ->  default
    

    Before this PR that entry was harmless for MCP tools; after it, it auto-approves all of them
    for the rest of the session.

  3. It is untested, and a one-line guard closes it with zero fallout. Adding
    && pattern.length > 1 to 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.

    fix-star

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) ⚠️ still visible ⚠️ still visible
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 server foo.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 in settings.md closes it.
  • F3 (Nit): permission-helpers.ts and permissionFlow.ts are still not prettier-clean
    (an interface … extends wrap, a pmForcedAsk: ternary wrap, the fullAliases array).
    CI will not turn red (scripts/lint.js --prettier runs --write), but npm run format fixes it.

Verdict

  • The reported issue still reproduces on main and 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 the main merge.
  • 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 = head 75e91d6287 合入 main 8290c81eea
  • dist-pr:该 ref 上 npm ci + npm run bundle
  • dist-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 = 加上一行护栏后确认框回来)。

我认为值得提出来而不是耸肩带过,有三个理由:

  1. 不对称且无文档。 * 对非 MCP 工具依然什么都不匹配(toolMatchesRuleToolName 没有通配分支:
    matchesRule(parseRule('*'), 'read_file' / 'run_shell_command' / 'web_fetch') 全是 false,
    只有 MCP 工具是 true)。settings.md 没有记录裸 * 规则,PR 描述里也没提这处变化。
  2. 可达路径不止手写 settings,还包括仓库可控的配置。 skill 的 allowedTools 条目是原样交给
    PermissionManager.addSessionAllowRule 的(skill-utils.ts:318,parseAllowedToolsField 不做任何校验),
    而 allowedTools: ["*"] 是「这个 skill 可以用任何工具」最自然的写法。用真实类跑一遍:
    applySkillAllowedTools(pm, ['*']) 之后,MCP 工具得到 allow,read_file 得到 default。
    本 PR 之前这一条对 MCP 工具是无害的,之后它会在整个会话里自动放行所有 MCP 工具。
  3. 没有任何测试覆盖,而一行护栏就能关掉且零副作用。 在通配分支加上 && 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

SLP-DEV1 commented Sep 2, 2026

Copy link
Copy Markdown
Author

CI follow-up: the previous Qwen Code CI failure was isolated to the unrelated timing-budget test src/utils/shellAstParser.test.ts > classifyShellCommandSafety > classifies adversarial rule inputs within the CPU budget (1361.812 ms vs < 1000 ms). Security Checks passed, and the latest Qwen review reports “no blockers”. I pushed an empty-tree commit (5ee305d) solely to retrigger CI without changing the PR diff. The new upstream Qwen Code CI and Security Checks runs are currently at the fork action_required approval gate, so a maintainer needs to approve the workflows; after approval this should give us a clean fresh signal.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the two-spelling mechanism at the PR head. No blocking issues found.

  • matchesMcpPattern now 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-level mcp__server form requires the raw identity's server segment and deliberately does not sanitize either side (foo.bar never broadens to foo_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 explicit ask is kept when no deny matches, allow is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: lossy MCP permission aliases can authorize tools from a different server

4 participants