Repository navigation
fix(core): stop MCP server rules from authorizing a colliding server - #12531
Conversation
matchesMcpPattern reduced both sides of a server-level or wildcard comparison through sanitizeToolNameForProvider, which maps every provider-unsafe character to "_". The servers "foo.bar" and "foo_bar" therefore reduce to the same string, so an allow rule written for "mcp__foo.bar" (or "mcp__foo.bar__*") also matched the tools of the different server "foo_bar", whose registered name never needed a hash. Registration keeps the two servers apart; only the matching layer re-introduced the collision, so one saved allow rule skipped confirmation for a server the user never granted. Prefix patterns are now compared literally against the registered name and, when the tool advertises one, its raw pre-normalization identity. A legacy spelling that is not provider-safe is no longer reduced: it is verified against the hash registration appended, by rebuilding the candidate raw name and requiring its normalization to equal the registered name exactly. A registered name that never needed normalization carries no hash and can never be verified, which is what keeps a "foo.bar" rule off the "foo_bar" server. A pattern that neither matches literally nor verifies does not match at all, so the tool falls back to ask. Variant 2 of the report (one lossy permissionAliases entry shared by several distinct modern tools) is deliberately not addressed here. Refs #10199 Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-issue-patrol/jmudrd2nsgf
|
Both stage-2 findings are now dispositioned — details in the updated Risk & Scope section of the PR body (bilingual), cross-posted to #10199 for tracking. Finding 1 — alias-less blocklist paths narrow fail-open. Call sites confirmed first-hand at this head: five deny-semantics callers reach Finding 2 — hash verification is a consistency check, not an authenticity check. Agreed; the forgeability is documented in Risk & Scope. Exposure is still strictly smaller than on Both findings were statically derived (the review environment could not execute code) and were re-verified by a first-hand read of the call sites and the |
…her docs Dropping the `sanitizeToolNameForProvider` reduction from the wildcard branch also dropped that function's letter-guard, which turns an empty prefix into `tool_` -- a prefix no `mcp__` name starts with. A bare `*` therefore matched no MCP tool on main, but the new literal prefix comparison makes every string match `''`, so `*` started matching every MCP tool while built-ins kept prompting (`toolMatchesRuleToolName` has no wildcard case). That silently authorized the one tool class whose own default is `ask`: `permissions.allow: ['*']` went from `default` to `allow`, and `disallowedTools: ['*']` started stripping every MCP tool from a subagent pool while leaving every built-in declared. Reject the empty prefix, restoring the base behaviour. `mcp__*` and `mcp__server__*` keep their documented non-empty prefixes and are unaffected. Pinned by three rows in mcp-server-rule-collision.test.ts; removing the guard reds exactly the two behavioural ones and leaves the match-all row green. Also correct four comments that asserted more than the code does: - `isNormalizedFromRawPrefix` claimed a registered name that never needed normalization carries no hash suffix and so can never be verified. The suffix is matched against the registered name's own tail and `stableToolNameHash` is an unkeyed FNV-1a over a string the registering party chooses, so a server registered as `foo_bar` can name a tool `evil_<hash of "mcp__foo.bar__evil">`, register verbatim with no alias to contradict it, and satisfy the check against a rule for the different server `foo.bar`. The check proves an existential, not provenance; the docblock now says so and names registry-backed ambiguity filtering as the out-of-scope fix. - Both no-match fallbacks were documented as "the tool asks for confirmation instead of being authorized". That holds only for an allow rule; on a deny/ask rule or a `disallowedTools` blocklist a lost match removes a restriction, i.e. fails open. - `matchesMcpPattern` read as if prefixes are never reduced through `sanitizeToolNameForProvider`. They are, in one direction: the registered spelling that a literal comparison runs against is that reduction, so a rule written provider-safe still reaches any server whose name sanitizes under that prefix. The asymmetry is now recorded. - The alias lookup claimed `permissionAliases` publishes the pre-normalization `mcp__<server>__<tool>` spelling. It publishes `generateLegacyMcpToolName(...)` -- a second lossy reduction that keeps `.` and `-`, maps every other character to `_`, and middle-truncates past 63 chars -- so the alias is found only when that reduction was itself lossless. Addresses review finding R1-4 at head 67d07f2. Corrects the comments cited by R1-2, R1-3, R1-5 and R1-6. The alias threading R1-1/R1-2/R1-3 ask for reaches five deny-direction blocklist call sites plus `getToolRegistrationStatus`, which has no alias parameter at all, so it is a security-surface change wider than this matcher fix and stays with the pending human ruling. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmue0nfox07
Review round on #12531 found the alias channel stopped at matchesRule's callers, so legacy-spelled deny rules silently lost coverage: - matchesToolPattern / matchesAgentToolBlocklist take the tool's advertised permissionAliases and resolve the raw identity with the same rule matchesRule uses; the five blocklist sites (agent-core declaration filter and invocation re-check, workflow narrowing, fork inheritance) resolve them from the registry via the new ToolRegistry.getPermissionAliases. - getToolRegistrationStatus / isToolEnabled accept and forward toolAliases, closing the registration-path gap where a whole-tool deny on a legacy dotted server no longer disabled the tool; the scheduler's prevalidation and deny-rule citation pass them down. - matchesRule's alias gate also accepts an alias whose server segment equals the rule's verbatim: permissionAliases publishes generateLegacyMcpToolName(raw), a second lossy reduction, so requiring its normalization to equal the registered name failed exactly when the tool segment was lossy (my+tool, >63 chars) -- deny fell open there. - matchesMcpPattern's hash-reconstruction fallback now requires the tool to have advertised at least one alias. A verbatim provider-safe registration advertises none, which closes the offline-computable forgery where a foo_bar server names a tool evil_<hash of 'mcp__foo.bar__evil'> to satisfy a victim's allow rule for foo.bar. Hash-suffixed registrations always advertise an alias, so no legitimate coverage is lost. Witness rows in mcp-server-rule-collision.test.ts build aliases the way production does (generateLegacyMcpToolName), cover the lossy-tool-segment and >63-char deny cases, the forged-suffix allow case, and the negative control that a foo_bar tool still cannot match a foo.bar rule.
|
Scope ledger — delivered Goal and scope. Preserve #10199 and original baseline Result. Producer identity handles nested bare servers and real underscore tool prefixes; actual registry competition handles grant refusal. Unique registered/raw exact names retain priority. Lossy heads cannot ignore a longer competing server key. Partial-separator restrictions regain ordinary-tool coverage, with underscore-leading registered boundary refusals retained. Historical deny/status assertions use the actual producer context; alias-only checks remain labelled compatibility predicates. EN/CN contracts match. Metrics. Five existing files +114/-109, net+5: production -9, tests+14, docs0. Whole PR 28 files,+2877/-106: production18/+595/-84; tests8/+2214/-21; docs2/+68/-1. The preceding reductions and full history remain below; the PR still exceeds the original minimal baseline. No global-minimality claim. Evidence. 805 focused tests, dependency/CLI build, core typecheck, bundle and changed-source lint pass. Independent reuse/correctness, quality/correctness and efficiency checks are clean. Same21-row source before/after +27-row real module flow pass; four actual packaged CLI/tmux/stdio paths verify cited nested deny0, single nested allow1, longer-key confirmation0→Allow once1, ordinary partial-separator deny0. Four original-frame PNGs are visually checked, full-text equal and publicly byte verified at immutable img-host URLs. Tested candidate Delivery snapshot. Normal additive push confirmed exact remote head, OPEN/MERGEABLE. One snapshot: ten checks running, six queued, two skipped; review CHANGES_REQUESTED. Fresh CI and maintainer review remain merge gates. No merge/approval or informational-job wait. Scope verdict: corrected. 中文说明Round27 保留原始 baseline 与全部历史,只处理本轮四项已复现问题。生产方身份处理嵌套键与下划线工具前缀,实时注册表处理竞争授权;精确注册/原始精确优先权保持。有损前缀不再遗漏更长 key,普通工具的分隔符内限制恢复;测试区分实际 identity 限制与 alias-only 特征。五个已有文件净增5行(生产减9、测试增14、文档0),整份28文件、+2877/-106,仍偏大,不宣称全局绝对最小。 805定向测试、构建/类型检查/打包/lint和三路审查通过;21行源码前后、27行真实模块流、四组实际CLI/tmux/MCP与四张公开原始帧回放截图通过。失败、受控模型及未运行边界如实保留,源码与提交绑定。本轮报告。推送后一次快照确认新head,可合并,CI仍运行/排队,review CHANGES_REQUESTED;CI及维护者复审是独立合入条件。 Round26 and earlier ledger history (preserved)Scope ledger — delivered Goal and scope. Preserve #10199 and original baseline Choice and effect. Replace cross-server entrance enumeration with live producer-boundary ownership. Complete raw whole-server rules select their owner; different server/tool separator positions require confirmation for both claimants; the established same-underscore-run/raw/coarse/exact controls remain. Shared cut heads compare competing live renderings. Restrictive historical exact/prefix coverage reuses the existing identity matcher with local producer-derived spellings, with the existing registered-cut fallback kept. The misleading registry-less universal grant assertion is replaced with the real registry-backed cut witness; App-only and live removal are covered. English/Chinese design contracts are synchronized. Both Criticals are addressed at this head; prior ledger states below are historical. Metrics. Five existing files +181/-119, net+62: production+9, tests+53, docs0. Whole PR 28 files,+2872/-106; implementation18 files,+604/-84; tests8,+2200/-21; docs2,+68/-1. Previous round was +2811/-107. The preceding two rounds removed853 net lines; that reduction remains. The PR still exceeds its original minimal baseline, and this is not a global-minimality claim. This round's growth is attributed solely to reproduced Critical classes and distinct regression evidence. Evidence. Candidate SHA Delivery snapshot. Exact remote head confirmed, MERGEABLE; aggregate CI PENDING, review CHANGES_REQUESTED. Checks and maintainer review remain external merge gates. No merge/approve or informational-bot wait. Scope verdict: corrected. 中文说明Round26 保留原始 baseline 和历史归因,只修两个已复现 Critical。统一实时生产方边界归属替换入口枚举:整 server 保留自身授权,两个不同分隔位置均能解释的工具前缀对双方回到确认;原有下划线、原始/唯一注册精确名、有意粗前缀控制保留。限制通道复用已有匹配器恢复被扣留 legacy 的精确与历史前缀覆盖,无新增产品接口、alias 通道或缓存。 五个已有文件净增62行(生产9、测试53、文档0),整份 PR28文件、+2872/-106;前两轮净减853行仍保留,不声称整体绝对最小。798项定向测试、21条真实模块流、四组原生 CLI/tmux/MCP 和三张公开原始帧回放截图通过;保留失败与未验证边界,源码与提交绑定。当前 CI PENDING/维护者 CHANGES_REQUESTED 是独立合入条件。本轮报告。 Round25 and earlier ledger history (preserved)Scope ledger — delivered Goal and scope. Preserve the original #10199 contract and baseline Measured reduction. Five existing files, +133/-417: 284 net lines removed (production 76, tests 208). Whole PR 28 files, +3092/-104 → 28 files, +2811/-107. Current GitHub file-diff metrics: production +596/-85 in 18 files; tests +2147/-21 in eight; the two existing design documents remain +68/-1. Together with round 24 this removes 853 net lines. Original baseline and earlier round attribution are preserved. Choices and disposition. Reuse one private MCP name-matching path and existing producer/registry test helpers. Merge equivalent rule cases while preserving every input, expected value and identity-free path. Keep the ACP no-build assertion and remove its dominated fake execution setup. The accepted Critical is Evidence. The unchanged candidate passed core build/typecheck, CLI bundle, 776 core cases across five files, one selected ACP L1 case, final five-file Prettier/ESLint hook and independent candidate reviews. Changed-source emit matches the built permission module; executed entry, matcher chunk, its direct dependencies and imported probe modules are bound to the frozen runtime. Ten real producer/registry/shared-permission observations pass, including model/App owner and registration/removal. Two real packaged CLI/tmux/stdio MCP runs under default approval and Scope verdict: corrected. One post-push snapshot confirms the delivered head and no conflicts; fresh CI is pending and maintainer review remains required. 中文说明Round 25 保留原始 baseline 和全部历史归因。五个已有文件再净减 284 行,其中生产 76、测试 208;整份 PR 当前 28 文件、+2811/-107,与上一轮合计净减 853 行。复用原匹配路径和 fixture、合并等价测试、移除 ACP 被 no-build 断言覆盖的 fake;未增加产品文件或框架。R19 反向整服务器通配缺陷已修复,并保留自身授权、旧字母前缀和注册移除行为。最新作者评论要求保留结构性讨论,故更广的重设计记录为后续,线程维持 open,不能据此误称代码缺陷仍未修复或已获批准。 776 项 core+1 项 ACP、受影响 core 构建/类型检查、打包、提交 hook 与独立候选审查通过。十条真实权限流和两组真实 CLI/tmux/MCP 交互通过;两张原始帧回放 PNG 已做全文、目视和公开文件字节核验。测试模型为本地受控 fixture,未验收外部 provider 或 App RPC/UI。保留失败与中断记录,发布源码与冻结候选一致,进程已清理。新 CI/维护者评审仍待完成。报告与截图。 Round-24 ledger and earlier evidence (preserved)Scope ledger — delivered Goal and scope. Continue the original #10199 contract and the baseline Measured reduction. Relative to round 23 ( Ponytail choices. Share the existing agent blocklist gate while retaining separate declaration and invocation enforcement. Declarations pass the registry captured before asynchronous warm-up; invocation reads the current registry after the empty-blocklist early return. Merge exact and registered-prefix restriction-only fallbacks, and remove a fully dominated head-window condition. Shorten PR-added duplicate explanations while retaining the non-obvious reasons. Share fresh initialized test managers and alias/producer contexts; merge all 10 cut-registration cases into the 75-case collision suite. Alias-only inputs remain identity-free. Evidence. Independent whole-program AST reverse expansion preserves all table inputs, bindings, control flow and expected values; 239 expanded assertions remain identical. Full build/typecheck/bundle, 1011/1011 cases in seven core files, ESLint/Prettier, two clean self-audits and three independent review dimensions pass on the exact frozen patch. Actual matcher and producer probes compare 14,976 + 512 results with zero differences; real producer/registry/shared permission-flow probes compare 37 observations per arm, including App-only and immediate registration/removal. Four real packaged CLI/tmux runs compare frozen d5 and this simplification: foreign tool has zero calls before approval and one after Allow once, own tool executes once directly. Four selected original-frame replay screenshots have text parity and public URL/hash checks. Delivered blobs match the tested candidate after hooks and test processes/configuration are cleaned. Report and screenshots. Scope verdict: corrected. This pass removes demonstrated repetition; it does not claim the remaining PR is globally minimal. Fresh CI and maintainer review remain separate merge requirements. 中文说明本轮为 round 24,保留最初 baseline,响应用户对整份 PR 再按 Ponytail 简化的明确要求。五个已有文件净减 569 行;整份 PR 从 29 文件、+3656/-99 缩至 28 文件、+3092/-104。生产代码及说明净减 133,测试净减 436;两个设计文档不变。未新增生产文件、依赖、授权策略或缓存,R19/R20 和必要身份通道均保留。 原有 75 项 collision 与全部 10 项截断注册 case 合并,49 次新 PM 初始化以及 alias-only/producer 两种输入保持原语义。独立整文件逆展开与 239 个断言比对通过;1011/1011 定向测试、构建/类型检查/打包、lint/format、两遍自审及三维独立 review 通过。真实共享权限流每臂 37 条观察及四组真实 CLI/tmux 前后对照一致,四张选定原帧回放截图有文本及公开 URL/hash 核验。发布源码与测试候选一致,测试进程和配置已清理。进一步 Suggestions 继续延后,新 CI 和维护者评审仍是独立合入条件。 Historical round-23 ledger and earlier evidence (preserved)Scope ledger — delivered Contract and disposition. Preserve the original #10199 goal: a saved rule must not grant a distinct registered claimant merely because server/tool renderings collide. R19's registered provider/legacy and shared middle-truncated grants were repaired in Rounds and baseline. Continue round 21 at Scope. Full PR: +3656/−99 across 29 files; production +797/−77 in 18 files, tests +2791/−21 in 9, designs +68/−1 in 2. Round 22 added +351/−18 (+333 net). This round is +90/−107 (−17 net): four existing production files −31, the minimal model-visible/App-only regression table +14, existing bilingual design net 0. No new production file, dependency, option, abstraction or cache. Ponytail decisions. Remove Verification. Both new cases fail on b5; final 1011/1011 tests in eight affected core files pass, as do full build/typecheck/bundle and lint/format. Two consecutive clean self-audits and independent correctness/security and Ponytail/test-value reviews use the identical frozen patch. Seventeen observations per production-flow arm change only three foreign lettered prefixes. Real native CLI/tmux with default approval, trust=false and stdio MCP reproduces b5's unconfirmed foreign call; the candidate waits with zero calls, then executes once after Allow once. The own tool stays allowed. Four original tmux-frame replay screenshots have exact text parity and public URL/hash verification. All seven committed blobs match the tested candidate after hooks; fixture processes/configuration are cleaned. Current report and screenshots. Scope verdict: corrected. Implementation work and evidence for the reported R19/R20 witnesses are complete; new CI and maintainer review are externally pending. 中文说明本轮为 round 23,延续 1011 项定向测试、完整构建/类型检查/打包、lint/format、两遍自审和两份独立 review 均通过。真实 tmux 前后对照证明旧版零确认执行,新版确认前零调用、Allow once 后一次,自身工具继续允许。四张原帧回放截图已上传并核对公共 URL 与 SHA;七个发布文件与测试候选一致。其余与契约无关的 Suggestions 继续延后,CI 和维护者评审仍待完成。 Historical round-21 ledger and earlier evidence (preserved)Scope ledger — delivered Goal and contract. Keep the original #10199 variant-1 goal: raw server rules must not authorize the differently registered colliding server. Preserve the existing identity propagation, compatibility behavior and the maintainer’s restrictive-only fallback ruling. Provider-spelling grants and ambiguous length-preserving exact aliases remain disclosed residuals, not claims of this batch. R19 stays unresolved for maintainer review. Round accounting. The ledger previously stopped at substantive round 19 / Scope correction. From this batch’s starting head, the PR shrinks from +3552/−90 to +3331/−90, 28 files. Current categories: production +692/−68 in 18 files; tests +2573/−21 in 8 files; designs +66/−1 in 2 files. This batch changes four existing files, +116/−337 (−221 net lines): matcher −33, tests −190, bilingual design +2. No new implementation files, dependencies, options or matching framework. Attribution and decisions. Must fix R20-1: read the registered suffix from the producer’s provider-safe server boundary, preserving the existing raw boundary fallback. Reuse the existing test helper at 15 equivalent calls; merge a dominated legacy-prefix witness into its stronger table. Remove redundant truncated-head matching because the already-retained length-preserving spelling includes it. No distinct assertions or exact/server-level spelling paths are removed. Existing consumer propagation remains necessary for evaluation, enablement, ACP and agent blocklists. R19 is Follow-up: confirmed at both base and head; no registry or new grant policy added. Verification. Full workspace build/typecheck/bundle; 665/665 focused permission tests; changed-file ESLint and Prettier; independent correctness/security and Ponytail review clean on the identical frozen patch. 11,045 producer-backed prefix comparisons passed. Removing only the boundary fix makes the two new suffix cases fail while the original control passes; restoring the exact candidate makes 3/3 pass. A 15-row built-module probe verifies actual default permission, shared permission merge, invocation flow, registration, relevance and blocklists. Global and built CLI E2E attempts both stop at Keychain startup before any model/MCP call; successful live transport remains unverified. Delivered PR head was confirmed before closing fixed threads. Scope verdict: corrected. Further grant-ambiguity redesign stays separate; this round fixes the owned regression and deletes demonstrated redundancy. 中文说明范围账本 — 已交付 目标与契约。 保留 #10199 variant 1 的原始目标:原始 server 规则不得授权另一个以不同名称注册的碰撞 server。保留现有身份传递、兼容行为,以及维护者对 restrictive-only fallback 的裁定。provider 拼写授权与有歧义的保长精确 alias 仍是已披露残留,本轮不声称解决;R19 保持未解决供维护者评审。 轮数记账。 原账本停在 substantive round 19 / 范围纠偏。 相对本轮起始 head,PR 从 +3552/−90 缩到 +3331/−90,28 文件。当前分类:生产 +692/−68(18 文件),测试 +2573/−21(8 文件),设计 +66/−1(2 文件)。本轮只改四个已有文件,+116/−337(净减 221 行):matcher −33,测试 −190,双语设计 +2。没有新增生产文件、依赖、选项或匹配框架; 归因与决策。 必须修复 R20-1:按生产方 provider 安全 server 边界读取注册后缀,并保留原始边界兜底。复用已有测试 helper 的 15 处等价调用;把被覆盖的 legacy-prefix witness 并入更强参数表。删除重复截断头部匹配,因为已经保留的保长拼写包含它。没有删除独立断言或精确/server-level 拼写路径。现有 consumer 身份传递仍服务于权限求值、启用检查、ACP 与 agent 阻止列表。R19 归为 Follow-up:base 与 head 都已验证;不在本轮新增 registry 或授权策略。 验证。 全工作区构建、类型检查、打包;665/665 项权限测试;改动文件 ESLint、Prettier;对同一冻结 patch 的独立正确性/安全与 Ponytail review 均 clean。11,045 次生产方前缀比较通过。只撤掉边界修复时,两个新增后缀案例失败,原有控制通过;恢复相同候选后 3/3 通过。15 条构建模块探针验证了真实默认权限、共享权限合并、调用流程、注册、relevance 与阻止列表。全局和构建 CLI E2E 均在 Keychain 启动阶段停止,零模型/MCP 调用;真实传输成功路径仍未验证。关闭已修复线程前已核对实际 PR head。 范围结论:corrected。进一步授权歧义设计单独处理;本轮修复自身回退,并删除能证明冗余的部分。 Earlier ledger evidence (preserved) / 历史账本证据(保留)Scope ledger - round 20 was substantive (code + tests, one commit, one push), so Round 19 left this PR human-gated on R18-1 (restore main's matching vs. accept the fail-open loss). The gate was answered by the maintainer, not by this skill: @wenshao's round-2 real-environment verification reproduced R18-1 end to end (U1/U2/U5/U8/A4 ran with no prompt on head, blocked on base), rejected the author's option (a) discriminator on measurement (misses U5, restores main's grant direction on U7), and ruled option (c), a restrictive-only fallback, supplying the patch himself: "Once (c) or an equivalent lands, I have no further blockers." Head moved Delta and attribution. +30 implementation lines in one existing file ( Verification. Red-first reproduced locally: the 10 rows give 4 failed / 6 passed on Scope verdict: corrected (the round-19 human-gate is closed by the maintainer's ruling; the only growth this round is the ruled fix). |
Replace the hash-reconstruction fallback and the relaxed alias arm with the tool's exact raw `mcp__<server>__<tool>` identity, published first in `DiscoveredMCPTool.permissionAliases` and compared literally in `matchesMcpPattern`. Nothing is reconstructed or hashed, so a registered name whose tail imitates a normalization hash no longer authorizes a different server (#10199 review): - exact arm now requires the raw identity (verbatim hash-imitating forgery, both witnesses); - isNormalizedFromRawPrefix / PROVIDER_NAME_HASH_* / the aliasAdvertised gate deleted (unkeyed-hash reconstruction entrances); - relaxed server-segment alias arm deleted (middle-truncation over-match across servers); - legacy-spelled deny/ask/disallowedTools rules keep coverage at any raw length and character set (the cliff was raw length 56, not 63); - matchesToolPattern gains the advertised-exact-name arm so an exact entry blocks identically via permissions.deny and disallowedTools; - ACP L1 isToolEnabled gate threads the same alias channel as the scheduler; - workflow narrowing skips session-registry aliases for agent types with their own MCP servers; - design doc updated to the new algorithm, with zh-CN counterpart. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-issue-patrol/jmuenih9i06
R2 round delivered —
|
|
@qwen-code /triage |
Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmufqt39629
R3-1: `matchesMcpPattern` decided whether a tool name *has* a tool segment
from the registered name alone. A server key of ~53+ characters pushes the
registered name past the 63-character budget, so truncation cuts the `__`
separator out of the registration and leaves it with two parts. The bare
`mcp__<server>` spelling of a whole-server rule then silently matched
nothing while the `mcp__<server>__*` spelling of the same rule kept
matching through the raw identity — two forms the docs present as
equivalent, disagreeing, and the bare one a fail-open on `permissions.deny`,
`permissions.ask` and `disallowedTools`. Ask the structure test of the same
spellings the body matches against.
Verified: for `'k'.repeat(53)` and `'a.b-' + 'k'.repeat(50)` the registered
name has 2 `__`-parts, bare was false while `__*` was true; after the fix
both spellings match, `evaluate` returns 'deny' and
`getToolRegistrationStatus` returns 'disabled'. The cross-server rows stay
false — a `foo_bar` tool advertises no alias, so the literal `${pattern}__`
prefix comparison, not the structure test, is what keeps it out.
R3-2: the vouching guard in `resolveRawMcpIdentity` is the anti-forgery
invariant, but every existing row passed the production alias array whose
first element IS the exact raw identity, so `.find` answered with it and the
lossy legacy alias was never considered — replacing the guard with
`toolAliases?.[0]` left the whole suite green. Add a legacy-alias-only case
to the middle-truncated witness; under that mutant it now reds.
Mutation evidence (this suite, 31 tests):
guard reverted to `toolParts.length >= 3` -> 2 failed | 29 passed
guard replaced with `toolAliases?.[0]` -> 1 failed | 30 passed
both restored -> 31 passed
Also corrects the "no length boundary" comment, which described a sweep that
varies raw length through the tool segment under short keys only.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufqt39629
…wing
R2-12, the half that needs no policy ruling.
`toolAliasesFor` occurs three times in packages/ and zero times in any test
file; the only orchestrator test populating `permissionAliases` asserts the
branch where the resolver is deliberately `undefined`. Replacing the resolver
with `undefined` unconditionally kept the whole suite green, so the branch
that actually threads aliases into `narrowAgentTools` was pinned by nothing.
Add a dispatch through an agent type with no `mcpServers` of its own, whose
legacy-spelled `disallowedTools` entry only matches via the alias channel,
and assert the up-front refusal plus an empty `calls`.
Mutation evidence: with the resolver replaced by `undefined`, this test reds
("promise resolved 'ok' instead of rejecting"); restored, it passes.
Also records in the narrowing comment what the own-MCP `undefined` costs, so
the next reader does not take it for an oversight: the up-front "every
requested tool is denied" refusal no longer fires for a legacy-spelled deny
on an own-MCP agent, and the dispatch instead runs tool-less after the
agent's own declaration filter strips the tool. Nothing forbidden executes.
Restoring that refusal would mean matching the deny's server segment against
`Object.keys(baseConfig.mcpServers)` through `sanitizeToolNameForProvider`,
which reintroduces the reduction the design doc forbids in matching, so it is
left to the alias-threading policy ruling rather than settled here.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufqt39629
Whitespace only, from `npx prettier --write`; the repo's format gate rejected the two wrapped calls added in the previous commit. No behaviour change: `src/permissions/mcp-server-rule-collision.test.ts` still 31 passed, eslint clean, `prettier --check` clean on all four files this branch touches. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmufqt39629
…laim
R3-5: witnesses 3 and 4 asserted only the attacker's own registered name, so the
byte-identical collision their comments describe was never executed. Construct the
dotted victim in both and assert attacker.name === victim.name. Measured with a
mutation that hashes the sanitized body instead of the raw name
(tool-name-utils.ts): witness 4 stayed green before this change (5 failed | 26
passed) and now reddens at the new assertion (6 failed | 25 passed) -- its own
attacker name is untouched by that mutation because its raw is already
provider-safe, so only the collision assertion can catch it.
R3-6: the disabledTools consumption row fed the exact raw spelling, which
isToolDisabled's surviving normalizeMcpToolName(entry) === name arm already
matches, so dropping aliases.some(...) left it green. Feed it the legacy reduction
instead: normalizeMcpToolName('mcp__foo_bar__a.b') is 'mcp__foo_bar__a_b_043em62',
not the registered 'mcp__foo_bar__a_b_1aofxjh', so only the alias channel can
disable the tool. Dropping aliases.some(...) now fails 2 rows instead of 1.
R3-4: the Rule-matching section listed disabledTools among the knobs rule-parser.ts
matches and declared that nothing is hashed. disabledTools never reaches
rule-parser.ts, so scope the sentence to the two knobs it does match, qualify the
hashing claim to that matcher, and add a bullet describing
ToolRegistry.isToolDisabled (exact alias set membership plus a surviving
normalization arm that predates #10199 and fails closed). Mirrored in the zh-CN
copy in the same change.
No production behaviour changes: three test/doc files plus one design-doc pair.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufv3et42h
…s' discriminating power R4-3: the Rule-matching bullet named an "L2" permission gate that has no referent anywhere in the repo, in the one bullet that tells maintainers which gates must consume the alias array. Replace it with the gates that exist and are verified at their cited lines -- L1 (aliases resolved in coreToolScheduler.ts, consumed by Session.ts's "L1: Tool enablement check"), L3/L4 (permissionFlow.ts), L5 (coreToolScheduler.ts), L5.1-L5.3 (autoMode.ts) -- plus the registry-side readers that actually call getPermissionAliases (agent-core.ts, tools/agent/agent.ts, workflow-orchestrator.ts). State explicitly that the registration-time gate is NOT one of them: every getToolRegistrationStatus caller passes the tool name alone, so a maintainer auditing "is the registration gate threaded?" no longer gets a term that resolves to nothing or to a false yes. Both language copies. R4-4: the negative arm of "keeps exact provider-safe MCP permission matches collision-safe" was left unthreaded when the positive arm was upgraded to pass [dottedName], so the assertion the test is named for no longer compared two aliased tools and returned false for a structural reason. Thread [slashedName]. Both raws sanitize to the identical body mcp__zybio__literature_search and differ only in the FNV hash, so admitting a reduced spelling to the exact arm is now caught here instead of leaving the suite green. R4-5: fakeConfigWithMgr returned the SAME object for both registry roles, so the two alias tests could not tell which registry the narrowing resolver consults -- production reads the session registry only (workflow-orchestrator.ts:1253-1256) and the per-agent registry does not exist yet at narrowing time. Give the per-agent role a distinguishable object that vouches for no alias. Verified: pointing the session role at it reddens "refuses up front when a legacy-spelled deny covers every requested tool" (1 failed | 262 passed), where the shared object kept all 263 green. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuga3j8j3b
pomelo-nwu
left a comment
There was a problem hiding this comment.
At this head, matchesMcpPattern only checks the registered and raw names for prefix patterns (rule-parser.ts around lines 1674–1701). A persisted legacy deny/ask/disallowedTools prefix whose suffix was changed by legacy name reduction can therefore stop matching its own MCP tool. This is fail-open for restrictions and matches unresolved Critical R4-1. Please preserve the legacy spelling for restrictive prefix matches with server provenance, and add a regression for deny/ask as well as allow. I cannot approve the current behavior.
…-alias-collision Main moved the CI gate files (eslint.config.js, .prettierignore, .github/workflows/ci.yml at 73aa65a) after this branch last synced, so Lint & Static's gate-freshness check demands the merge before this push. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuhaj9f755
…wn server A `deny` / `ask` / `disallowedTools` prefix persisted before provider-safe MCP names stopped matching its own tool once the prefix tail crossed a character `generateLegacyMcpToolName` reduces: the wildcard arm compared only the registered name and the vouched raw identity, and `matchesAdvertisedExactName` refuses `*`-terminated patterns, so no arm picked the rule up. `evaluate` answered `default` instead of `deny`, `getToolRegistrationStatus` answered `registered` instead of `disabled`, and for a `trust: true` server in a trusted folder or under auto-accept the call the operator explicitly denied ran with no prompt — fail-open on a restriction. The wildcard arm now also reads the legacy reduction of the tool's own raw identity. Provenance is established twice over: the input is the raw identity `resolveRawMcpIdentity` already vouched for, and the reduction must have left the server segment byte-identical — which rejects the middle-truncated reduction of a 24+ character server key, since `slice(0, 28) + '___'` shortens the key and injects the very `__` a prefix match needs (`mcp__weather-forecast-server__*` would otherwise reach the different server `weather-forecast-server-premium`). Scoped to the wildcard arm: exact rules already reach every advertised spelling through `matchesAdvertisedExactName`, and server-level rules reach the raw identity through the existing spelling list. Implements the maintainer ruling on R4-1. Regression rows cover deny, ask and allow end to end plus `getToolRegistrationStatus`, and pin both directions: a differently-registered `foo_bar` server still does not match a `foo.bar` prefix (#10199), and a middle-truncated reduction still does not supply the separator to a shorter server's wildcard (R2-2). Not covered here, recorded on the PR: the mixed-spelling population (rule tool segment sanitized, server segment legacy) still needs the segment-wise `{server, toolGlob}` comparison R4-2 names, and the design doc's "compared literally against those two spellings" bullet is now owed a correction in both language copies. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuhaj9f755
|
@pomelo-nwu R4-1 is fixed at
Regression coverage in Measured in Deliberately not fixed in this commit:
Also in this push: |
Keep whole-server grants bound to their registered producer in the reverse underscore collision. Reuse MCP matching and existing test fixtures to remove duplicate setup while retaining restrictive compatibility. Co-authored-by: Qwen-Coder <[email protected]>
|
Further Ponytail pass in Also fixed R19 reverse underscore ownership. With both Verification on the unchanged candidate and delivered source:
These are PNG replays of the original tmux ANSI captures; paired plain-text captures match the rendered text. Images are hosted in Foreign tool awaiting confirmation (zero MCP calls): Own-server tool executes directly under its whole-server rule: Earlier 272d3efc report retains the previous 1011-case and four-run before/after evidence at that head. Current fresh CI/review status: post-push snapshot: CI pending/queued, no conflicting files ( Retained verification attemptsThe first local candidate failed compilation due to a helper-name shadow and one unused test binding; both were corrected before publication. The verifier was interrupted after all five command gates returned exit 0. Its recorded outputs were recovered, and the exact unchanged source/patch/artifact identity was checked before continuing the final flows. The first bundle text lookup checked only the split CLI entry and found no matcher; the actual permission chunk was then identified and bound. These attempts are retained locally rather than counted as successful product tests. 中文说明本轮再净减 284 行:生产部分 76 行、测试部分 208 行,仅改五个已有文件;整份 PR 当前 28 个文件、+2811/-107。复用现有匹配路径和测试 fixture,合并等价用例,ACP 保留有意义的 no-build 断言。未增加生产文件、依赖或匹配框架,不宣称整份 PR 已达到全局最小。 同时修复 R19 反向边界: 当前候选 core 构建/类型检查、CLI 打包、776 项 core+1 项 ACP 定向测试通过,提交 hook 校验改动文件格式与 lint。真实注册表与共享权限流 10 条观察覆盖 App-only、注册移除及旧控制。两组真实打包 CLI、tmux、stdio MCP 交互通过:外来 模型为本地受控 fixture,不代表外部真实模型/provider 验收;本轮未测试 App RPC/UI、Linux、Windows 或 Web Shell,当前只跑受影响的窄检查,旧完整工作区检查仍归属于其原 head。初版局部编译失败、验证器在命令全部通过后的中断、分块入口文字查找失败均保留;修正或绑定后再继续,没有带失败候选提交。新 CI/评审状态:推送后快照 CI 待运行/排队,无代码冲突;维护者评审仍待完成。反向缺陷已修复,更广的结构性讨论按评论要求保留 open,未合并。 |
Preserve exact and coarse raw grants while rejecting ambiguous server/tool prefixes and shared registered cut heads. Restore producer-owned legacy wildcard restrictions through the existing restrictive matcher. Co-authored-by: Qwen-Coder <[email protected]>
|
R23 follow-up verified at 6abd1eb (candidate diff SHA-256
The second row intentionally uses both → default. The rule has two different real server/tool boundaries; assigning only one claimant would retain the ambiguity. Complete raw whole-server boundaries, explicit raw exact/unique registered exact rules and intentional raw coarse prefixes keep their existing authority. The old no-registry “never grants” fixture was replaced with the real registry-backed shared-cut witness; it could not prove its advertised invariant. Validation: 798 cases across six focused core files, core build/typecheck, CLI bundle and changed-source ESLint passed. Independent correctness, reuse and efficiency/test-value reviews found no remaining actionable issue in the unchanged final delta. The single 21-row real producer/registry/permission/shared-flow probe passes, including App-only and actual agent declaration/invocation guards (minimal agent fixture, not nested-agent E2E). Source/runtime hashes and executed matcher chunk/imports are bound to this candidate; committed blobs match after hooks. Native acceptance uses packaged CLI 0.24.7, Node 22, real tmux and stdio MCP transports with default approval and Foreign foo__bar: confirmation before action, transport calls0 Own foo: direct successful MCP execution, transport calls1 Legacy deny: cited scheduler decline, transport calls0 (fixture final text is not execution proof) Images replay original captured ANSI frames using the repository terminal renderer, with complete plain-text frame parity and visual inspection; they are not native Terminal.app screenshots. Uploaded only to Scope: this round touches five existing files, +181/-119 (net +62): production +9, tests +53, bilingual docs net zero. Whole PR: 28 files, +2872/-106, versus +2811/-107 at the previous head. The previous two rounds' 853-line reduction remains; this round adds only the reproduced Critical repair and its smallest distinct regressions. The overall PR is still larger than the initial fix, and this is not a global-minimality claim. Initial validation attempts are retained: the new two-/three-column test callback needed an optional parameter type; the first build rejected it, and a concurrent test start stopped at the build-prerequisite guard before executing cases. The type was fixed and the build completed before the successful focused test run. The overstrict bundle lexical observer and the initial wrong expectation that native deny removes model declarations are also retained; the corrected denial verifier passed with unchanged source. No external provider credentials were used. One post-push snapshot confirms this exact PR head; current aggregate CI is PENDING, mergeability is MERGEABLE, and review remains CHANGES_REQUESTED. Fresh checks and maintainer approval remain separate merge gates; this repair does not wait for informational review jobs. Post-delivery local state: the verification worktree and its ignored evidence directory became unavailable during publication closeout. The remote commit and immutable screenshots remain available, but the original local runtime/logs can no longer be read. Publication receipts are preserved outside that worktree; the validated branch content was not changed or rerun. 中文说明当前提交 6abd1eb 收敛两条已复现 Critical:通配授权按实时生产方边界归属判定,旧 deny/ask 在 legacy alias 被扣留时仍复用已有匹配器保留覆盖。 798 项定向用例、core 构建/类型检查、打包、lint 和独立审查通过。21 条真实生产方/注册表/权限流检查通过,含 App-only 和真实 agent 声明/调用共用拦截(最小 agent fixture,非完整嵌套 agent E2E)。 四条原生路径通过:外来确认前0次、Allow once 后1次;自身直接1次;历史 deny 虽仍向模型声明,但调度器引用规则拒绝且0次;历史 ask 隐藏 Always Allow,确认前0次、Allow once 后1次。 上方截图为本轮原始 ANSI 帧回放,已核对整帧文本、目视和公开 PNG 字节;不是系统终端截屏。模型是本地受控 fixture,未验收外部 provider、App RPC/UI、Web Shell、Windows 或本轮 Linux UI。保留首次类型检查失败与测试前置 guard 中止记录;修正后顺序验证通过。 五个已有文件净增 62 行,其中生产 9、测试 53、文档净零;整份 PR 当前 28 文件、+2872/-106。前两轮净减 853 行仍保留,本轮只修真实 Critical 并补对应回归,不声称整份 PR 已经绝对最小。一次推送后快照确认该 PR head,当前 CI 汇总为 PENDING,可合并状态 MERGEABLE,评审为 CHANGES_REQUESTED;新检查和维护者批准仍是独立合入条件,不等待信息性评审任务。 发布收尾期间,本地验证工作树及被忽略的证据目录变为不可用。远端提交和不可变截图仍可访问,但原本的本地运行时/日志现在无法读取;发布回执已保存到工作树之外,未改动或重跑已验证的分支内容。 |
Co-authored-by: Qwen-Coder <[email protected]>
|
Delivered Nested server keys now use producer identity for bare whole-server rules and underscore tool prefixes. Real registry competition owns grant refusal rather than a re-split local predicate. Unique registered/raw exact priority remains: Verification: 805 focused core tests (798 across five files + seven direct matcher cases), dependency/CLI build, core typecheck, bundle and changed-source ESLint pass. Independent reuse/correctness, quality/correctness and efficiency passes are clean. The same 21 bounded exact-source before/after rows reproduce six pre-fix mismatches and none after; this baseline is source-function fallback, not an old packaged CLI or old real PermissionManager. A separate 27-row real built Config/producer/ToolRegistry/PermissionManager/shared-flow probe passes, including App-only competition, registration/removal, citation/status, and actual agent declaration/invocation predicates in a minimal fixture. Four actual packaged CLI/tmux/stdio MCP paths pass in one consolidated run, under default approval and server trust false:
Denied tools can remain advertised to the model. The evidence is the cited scheduler decline and actual zero MCP transport calls; the controlled model's final text is not execution proof. Screenshots: original tmux ANSI frames replayed through TerminalCapture/xterm, not OS screenshots. All four have complete text parity, visual inspection and public PNG SHA-256 equality. Images are hosted only in public Bare nested-server restriction: Single nested-server allow: Longer-key competition, before confirmation: Partial-separator restriction: Identity: tested base Failures and limits: the first test launcher referenced a nonexistent package-local bin and ran no tests; correcting its path passed. A concurrent seven-case matcher run hit a shared coverage-directory ENOENT after its cases passed; only that check was rerun with coverage disabled and exited0. The first unbundled Node module observer stopped before any case on existing One post-push snapshot at 中文验收说明四条 R24 反馈已按现有契约修复并正常推送到 包含 805项定向测试、构建、core类型检查、打包、lint 和三路集中审查通过;21行同输入前后源码对照及27行真实模块权限流通过。四组实际 CLI/tmux/MCP 一次通过:nested deny 引用规则且0次;nested单一allow直接1次;有损前缀竞争确认前0次、Allow once 后1次; 首次 launcher 路径错误、并发覆盖目录冲突和 unbundled MIME 解析错误均保留并纠正,无产品代码为此改动;原生四场景首次运行全部通过。模型为本地受控 fixture;旧 before 只作源码函数复现,未冒充旧原生运行。未验证外部真实模型、App RPC/UI 或完整嵌套 agent。验证自身进程和端口已退出,原始记录与 fixture 留存。推送后一次快照确认该 head,CI仍在运行/排队,可合并但 review 为 CHANGES_REQUESTED;CI与维护者复审仍是独立合入条件。 |
|
@qwen-code /triage |
chiga0
left a comment
There was a problem hiding this comment.
Tier: Deep — auth/permission system, security-critical. Full contract + cross-file analysis.
What I checked
-
Security invariant (grant vs. restrict asymmetry): grants use
matchesMcpName(narrower) +hasAmbiguousMcpGrantlive-registry ambiguity guard; deny/ask usematchesRestrictiveMcpNamewhich builds its alias set independently from the producer identity (broader, fail-safe). Correct by design — an allow that can't determine ownership is withheld; a deny that can't determine ownership still fires. -
Producer identity chain:
DiscoveredMCPToolInvocation.mcpIdentity→permissionFlow.ts: buildPermissionCheckContext(invocation.mcpIdentity)→PermissionCheckContext.mcpIdentity→permission-manager.evaluate()→matchesRule()→matchesRestrictiveMcpName/hasAmbiguousMcpGrant. Verified complete across all changed call sites. -
permissionAliases/disabledToolAliasessplit:disabledToolAliases(broad — raw + full legacy) feedsisToolDisabled()at registration;permissionAliases(narrow — filters to server-boundary-preserved aliases only vialegacyReductionVouchesForServer) feeds grant evaluation. The registration call sites that switched frompermissionAliasestodisabledToolAliases(tool-registry.ts:332, 374, 462, 1482, 1488) are the correct direction — disable checks should be broader. -
getMcpToolIdentities()and App-only tools: UsesMap(this.mcpAppTools)as base then mergesDiscoveredMCPToolinstances, so app-only registrations count in ambiguity detection even when not in the main tool map. Correct — this is whatmcp-grant-ambiguity.test.tstests directly. -
matchesRestrictiveMcpNamedoesn't rely ontoolAliases: It builds[rawName, generateLegacyMcpToolName(rawName)]fromidentitydirectly. So even when callers pass narrowpermissionAliases(orundefined), deny/ask matching is complete as long asidentityis present. -
Cross-check vs. existing reviews: Prior
qwen-code-ci-botsuggestions are all S-level (no blockers among them). The two deferred findings aboutmcp-server-rule-collision.test.ts— one about comment precision (line 23), one aboutpermissionsAskcoverage (line 57) — are both resolved at head:permissionsAskappears throughout the 1534-line file and the comment at line 23 is accurate to the threat model being tested. -
Test efficacy:
mcp-server-rule-collision.test.tscovers the primary attack surface (cross-server forgery, truncation attacks, separator continuation, producer identity channel isolation).mcp-grant-ambiguity.test.tscovers the live registry side (ambiguity guard, dynamic registration/removal, App-only tool counting). Both include negative controls that confirm the pre-fix behavior was wrong and the post-fix behavior is correct.
Non-blocking
narrowAgentToolsinworkflow-agent-tools.tspassestoolAliases: undefinedwhen callingmatchesToolPatternfor deny filtering. This is safe —matchesRestrictiveMcpNamebuilds from the producer identity and handles legacy spellings independently. But callers that providegetMcpIdentitytoAgentToolNarrowingInputwill get full coverage; callers that don't (e.g.workflow-orchestrator.ts) fall back to the pre-fix behavior for that code path. Already noted by prior review as S-level; not a blocker here.
Verdict
No blockers. The security invariant (fail-safe deny, conservative grant + registry guard) is correctly implemented and tested. The producer-identity channel is threaded end-to-end. Approving.
Maintainer verification, round 4 (real local environment) — PR #12531 @
|
| # | Item | Status at 7e489a5b33 |
Against main |
Blocks merge? |
|---|---|---|---|---|
| 1 | Partial-separator restrictions mcp__<key>_*: A (bot R26-2) and B (new this round) |
fail-open, 152 of 16,502 rule×tool rows | regression: main blocks every row | yes, fixed by candidate E |
| 2 | R23-1: a legacy exact allow with a same-spelling server registered |
grants (E1) | same as main | no; doc mismatch |
| 3 | R26-1: the ambiguity pool is the session registry, not the subagent's | blind to agent-local competitors (P3); refuses an agent-local owner (P4) | P3 same as main; P4 fail-closed | no |
| 4 | Everything rounds 1–3 measured, plus the PR's nested-key test plan | holds | — | — |
Correction to round 3
In round 3 I wrote that after candidate D "I have no blockers". That was wrong.
My round-3 sweep only took literal prefixes of the registered name at the key's own boundary. It never generated prefixes that stop inside the separator, or coarse legacy prefixes. Measured with this round's differential, candidate D (which landed as d62f5b79d2) still lost 569 restrictions against main. The author's R23/R24 rounds fixed 417 of them, and the remaining 152 are item 1.
The three test rows that candidate E flips also come from me: my round-2 candidate C added them ("uncut registrations must not enter the fallback") to keep the R17-2 decision. Once R24 made the same fallback restrict ordinary tools, those rows only pin the asymmetry.
Environment
-
Arms. Each was built from git with
pnpm install --frozen-lockfile,npm run buildandnpm run bundle, all exit 0:- base
b3dda468f2, the merge-base; - prev
d62f5b79d2, my round-3 candidate D as landed; - head
7e489a5b33; - merge: head merged into current
main43a6e1e5e4, no conflicts; - cand E: head + the patch in §1.
- base
-
Harness. Same as rounds 2–3:
- real stdio MCP servers, whose own call log is the execution oracle;
- a scripted OpenAI-compatible model that calls exactly the target tool;
- a fresh
HOMEper run.
-
New scenario groups:
- N: partial separator;
- E: R23-1;
- P: R26-1, with agent-frontmatter
mcpServers; - Q: the PR's own nested-key test plan.
-
Runs:
- headless
qwen -p: 83 scenarios × 5 arms = 415 runs; qwen --acp: 9 scenarios × 3 arms;- real interactive TUI: N8 and N2 × 3 arms.
- headless
-
Module differential, new this round. For 18 server keys × 14 tool names, I took every rule that is literally an exact name or a
<prefix>*of the tool's own raw, registered or legacy spelling, plus bare-server forms. That is 16,502 rule×tool rows. Each arm's built modules were called the way production calls them:- L4
PermissionManager.evaluatefor deny and for ask, with the invocation's aliases andmcpIdentity; - L1 scheduler
isToolEnabled; - the subagent
disallowedToolspredicate that arm actually uses.
A loss is a row that
mainrestricts and the arm does not. No registry competitor is involved, because restrictive matching never reads the registry. - L4
-
Linux x86_64, Node 22.22.2. Round 3 ran on macOS arm64.
1. Open: partial-separator restrictions fail open (bot R26-2 + a new raw-key family)
All 152 remaining losses have the same shape: a restrictive wildcard mcp__<key spelling>_* that stops one underscore into the key's own separator. This is the coarse "everything from this server" spelling that R24 restored for mcp__github_* (row N1). matchesRestrictiveMcpName (rule-parser.ts:1912) accepts it only in two cases:
- the rule is a literal prefix of the registered name;
- and the tool segment does not start with
_(:1936-1937).
That leaves two holes.
| family | rule shape | rows | real-CLI witness (base → head) |
|---|---|---|---|
| A (bot R26-2) | registered spelling, tool name starts with _: mcp__foo_* → foo/_internal, mcp__github_* → github/_admin_reset, mcp__foo__* → foo_/_internal |
64 | N2 deny, N3 ask, N4 subagent, N6, N7 (YOLO), N14: blocked/ask/filtered → ran |
| B (new) | raw or legacy spelling of a key with characters outside [A-Za-z0-9_-], every tool of the key: mcp__zybio.db_*, mcp__foo:bar_*, mcp__com.example.enterprise-search_*, URL keys |
88 | N8 deny, N9 (YOLO), N10 ask, N11 subagent: blocked/ask/filtered → ran |
- Same on ACP. N2, N3, N7, N8, N9, N10 and N14 run on head. Base and cand E report
Tool "…" is disabled.or sendsession/request_permission. - The contract is inconsistent at head. The registered spelling
mcp__zybio_db_*blocks (N12), and so domcp__zybio.db__*(N13) andmcp__zybio.db(D4); only the one-underscore raw form misses. Likewisedeny mcp__foo__*blocksfoo_/deploy(N15) but notfoo_/_internal(N14). - Family A is documented (design doc
:38: "underscore-leading registered tool segments retain the boundary refusal"). In the restrictive direction, though, that refusal has only one effect: the deny does not fire. The doc's own Asymmetry bullet names exactly that failure. The grant-side R17-2 refusal is separate, and candidate E keeps it. - Exposure. It needs a restrictive entry written as
mcp__<key>_*. For A, the tool name must also start with_. For B, the key must contain.,:or/, which is common forzybio.db-style and URL keys. On a trusted server, or in YOLO, the tool then runs with no prompt, whilemainblocks it.
Candidate E. This is restrictive-only. matchesRestrictiveMcpName is reached only from deny, ask and disallowedTools, and from matchesToolPattern, whose production callers are all restrictive. The rule it applies: a prefix that stops at or inside the key's own separator, in any of the key's three spellings, names that key.
patch: rule-parser.ts +8/−5, test rows, two doc sentences — 4 files, +55/−13 (full patch)
@@ -1930,11 +1930,14 @@ function matchesRestrictiveMcpName(
const prefix = pattern.slice(0, -1);
const registeredServerPrefix = `mcp__${identity.serverName.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
return (
- toolName.startsWith(prefix) &&
- (!toolName.startsWith(registeredServerPrefix) ||
- prefix.startsWith(registeredServerPrefix) ||
- (registeredServerPrefix.startsWith(prefix) &&
- !toolName.slice(registeredServerPrefix.length).startsWith('_')))
+ (toolName.startsWith(prefix) &&
+ (!toolName.startsWith(registeredServerPrefix) ||
+ prefix.startsWith(registeredServerPrefix))) ||
+ // A prefix that stops at or inside this key's own separator names the
+ // key in that spelling, whatever the tool segment starts with.
+ mcpSegmentSpellings(identity.serverName).some((server) =>
+ `mcp__${server}__`.startsWith(prefix),
+ )
);
}Test changes in mcp-server-rule-collision.test.ts:
- The three-row
an uncut registration never enters the fallbackblock at:1494-1516becomes eight rows:- the three original shapes, now expecting a restriction;
github/_admin_reset;- the raw-key shapes
zybio.db,foo:barandfoo.bar; foo_undermcp__foo__*.
- Each row checks
matchesToolPattern, denyevaluate,isToolEnabled, askevaluateandmatchesAgentToolBlocklist. It also asserts thatallowstaysdefault, which keeps the R17-2 grant pin. - A new control checks that key
foobaris not restricted bymcp__foo_*. - Docs: the sentence at
:38of both design docs is updated to match.
| check | result |
|---|---|
| Real CLI, 83 scenarios | Exactly the 10 fail-open rows (N2–N4, N6–N11, N14) return to base. The other 73 are identical to head, including every allow, ask and grant-guard row (S, U6/U7, F7/F8, K5, E, P, Q). |
| ACP (7 fail-open rows) | All are blocked or ask again. |
| Differential, 16,502 rows | Losses against main: head 152 → cand E 0, on all four gates. Cand E adds 17 own-key restrictions that main lacks: the raw/legacy partial-separator rule for the 52-char URL key, which main misses because its truncated legacy alias drops the separator. These are fail-closed and touch no other key. Head and cand E differ on 169 rows = 152 + 17. |
| Red-first | The 8 new rows on head: 8 fail. Cand E: 101/101 in the collision suite. |
| Mutants, 5/5 killed | Each made at least one of the four PR permission suites (708 tests) fail: drop the new disjunct (10 failures); restore the _ carve-out (6); registered spelling only, dropping raw and legacy (3); accept any key-head prefix without the separator (1); let the fallback grant (14). |
| Gates | Targeted core (35 files, 7 skipped) cand E 2854/2854, head 2848/2848. CLI Session.test.ts 1114/1114. Full core suite 34076 passed, 6 failed; the same 6 fail identically on base and head (root-uid and local git-environment tests, none in permissions). eslint --max-warnings 0, tsc --noEmit and prettier are clean. The patch applies cleanly to head and to head+main. |
2. R23-1: a legacy exact allow still grants with a same-spelling server registered (not a regression)
| id | servers | rule | base | head |
|---|---|---|---|---|
| E0 | foo:bar/a.b only |
allow mcp__foo_bar__a.b |
ran | ran (sole claimant, compatible) |
| E1 | + foo_bar/unrelated |
allow mcp__foo_bar__a.b |
ran | ran, also on ACP |
| E2 / E3 | same | mcp__foo_bar__* / mcp__foo_bar |
ran | ask |
E1 is what main does, so this is not a regression. But design doc :36 says "a legacy exact entry cannot grant to foo:bar while foo_bar is also registered", and the code does not do that. Either narrow that sentence now or keep the case in #13412. I would not block on it.
3. R26-1: the ambiguity pool is the session registry (reproduced in both directions)
isMcpAllowAmbiguous reads this.config.getToolRegistry() (permission-manager.ts:452). A subagent with frontmatter mcpServers runs on its own rebuilt registry but keeps the session's PermissionManager.
| id | setup (allow: ["agent", "mcp__foo_bar__*"]) |
base | head |
|---|---|---|---|
| P1 | main thread, foo.bar + foo_bar both session-level → foo.bar/evil |
ran | ask (fixed) |
| P2 | same, called from a subagent | ran | ask (fixed) |
| P3 | foo_bar only in the agent's mcpServers → foo.bar/evil |
ran | ran: the foreign tool is still auto-approved, same as main |
| P4 | same → the agent-local owner foo_bar/evil |
ran | ask: the rightful owner loses its grant (fail-closed, new) |
P3 matches main. P4 is a fail-closed UX regression, limited to agent-local MCP servers whose spelling collides with a session-level server. Neither should block; both belong to the #13412 decision about which registry owns the ambiguity check.
4. What still holds at head
- Earlier rows. All 48 rows of round 3's candidate-D run give the same verdict at head, and prev on Linux reproduces that macOS run cell for cell (groups R, T, U, F, K, A in Fig 3). One grant row changed on purpose: K5 went from ask at prev to ran at head, because R24 now grants the sole
__-key claimant. Since prev, R24 also fixed N1, N5, N12 and N15. - The PR's nested-key test plan (Q1–Q5) behaves as described: the foreign
foo__barasks undermcp__foo__*; two boundary claimants both ask; baremcp__foo__barrestricts both. - Pomelo-nwu's R4-1 (legacy spelling for restrictive prefixes):
- satisfied at head for every legacy prefix shape, including the 252 coarse legacy prefixes on long URL keys that were still lost at prev;
- the only legacy rows still lost are 4 family-B partial-separator rows, which candidate E closes.
- Head + current main. Identical to head on all 83 real-CLI rows and all 16,502 differential rows.
5. Gates and coverage
| gate | result |
|---|---|
CI at 7e489a5b33 |
25 success, 22 skipped. The only cancelled runs are route jobs superseded by later dispatches. review-pr completed (the source of R26-1 and R26-2). |
| Unit, head | Targeted core (35 files, 7 skipped) 2848/2848. CLI Session.test.ts 1114/1114. |
| Review state | chiga0 APPROVED at this head. pomelo-nwu still holds CHANGES_REQUESTED from 952e3ef668, so reviewDecision is CHANGES_REQUESTED. |
Not covered:
- Windows and macOS (Linux x86_64 only this round).
- App-only RPC/UI.
- External providers; the model is a loopback fixture.
- The
tool_searchbridge. - The interactive TUI for rows other than N2 and N8.
Recommendation
- Fold in candidate E, or an equivalent restrictive-only fix for both families. After that I have no blockers.
- R23-1 (doc vs code) and R26-1 (P3/P4) do not block. Narrow the doc sentence or track both in Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412.
- Pomelo-nwu's re-review is still required to clear the standing CR.
Evidence is in wenshao/qwen-code asserts/pr-12531-r4: harness, per-run rows (headless, ACP, cand E), the differential JSON, unit logs and the candidate E patch.
中文版(点击展开)
维护者验证第 4 轮(本地真实环境)— PR #12531 @ 7e489a5b33
结论:不能原样合入。相对 main 仍有一族 fail-open;一个只作用于限制型规则的小补丁(候选 E)能把它全部关掉,且不改变任何授权行为。 本轮对这一族做了穷举测量,而不是逐个举例。
bot 另外两个未关闭的 Critical(R23-1、R26-1)都成立,但都不是相对 main 的回退:新的授权 guard 漏掉了这些情形,于是行为停留在 main 的样子。放到 #13412 跟踪是合理的。
| # | 项目 | 在 7e489a5b33 上的状态 |
相对 main |
阻塞合入? |
|---|---|---|---|---|
| 1 | 部分分隔符限制规则 mcp__<key>_*:A(bot R26-2)与 B(本轮新发现) |
fail-open,16,502 个规则×工具组合中有 152 个 | 回退:main 每一行都拦截 | 是,候选 E 修复 |
| 2 | R23-1:注册了同拼写 server 时,legacy 精确 allow 仍授权 |
授权(E1) | 与 main 相同 | 否;文档与代码不一致 |
| 3 | R26-1:歧义池读的是会话注册表,不是子代理自己的注册表 | 看不到 agent 本地的竞争者(P3);拒绝 agent 本地的真正属主(P4) | P3 与 main 相同;P4 是 fail-closed | 否 |
| 4 | 第 1–3 轮测过的全部内容,以及 PR 自己的嵌套 key 测试计划 | 成立 | — | — |
对第 3 轮结论的更正
第 3 轮我写过“合入候选 D 后我没有阻塞项”,这个结论是错的。
第 3 轮的扫描只取了“在 key 自身边界处、注册名的字面前缀”,从未生成停在分隔符内部的前缀,也没有生成粗粒度的 legacy 前缀。用本轮的差分重新测量,候选 D(落地为 d62f5b79d2)相对 main 仍丢失 569 条限制。作者 R23/R24 两轮修掉了其中 417 条,剩下的 152 条就是第 1 项。
另外,候选 E 翻转的那 3 行测试也出自我:它们是我第 2 轮候选 C 加的(“未截断的注册名不得进入兜底”),当时是为了保留 R17-2 的决定。R24 让同一个兜底开始限制普通工具之后,这 3 行只是在钉住这种不对称。
环境
-
实测臂。 每个臂都从 git 构建:
pnpm install --frozen-lockfile、npm run build、npm run bundle,全部 exit 0:- base
b3dda468f2(merge-base); - prev
d62f5b79d2(我第 3 轮的候选 D,已落地); - head
7e489a5b33; - merge:head 合入当前
main43a6e1e5e4,无冲突; - cand E:head 加上 §1 的补丁。
- base
-
装置。 与第 2–3 轮相同:
- 真实 stdio MCP server,其自身调用日志就是“是否执行”的判据;
- 脚本化的 OpenAI 兼容模型,只调用目标工具;
- 每次运行使用全新
HOME。
-
新增场景组:
- N:部分分隔符;
- E:R23-1;
- P:R26-1,使用 agent frontmatter 的
mcpServers; - Q:PR 自己的嵌套 key 测试计划。
-
运行:
- headless
qwen -p:83 个场景 × 5 个臂 = 415 次; qwen --acp:9 个场景 × 3 个臂;- 真实交互 TUI:N8、N2 各跑 3 个臂。
- headless
-
模块级差分(本轮新增)。 取 18 个 server key × 14 个工具名。对每个工具,生成所有在字面上等于它自身原始、注册或 legacy 拼写的精确名,以及这些拼写的每个
<前缀>*,再加上裸 server 写法,共 16,502 个规则×工具组合。每个臂都按生产代码的调用方式调用其构建产物:- L4
PermissionManager.evaluate(deny 与 ask 各一次),传入 invocation 的 alias 和mcpIdentity; - L1 调度器的
isToolEnabled; - 该臂实际使用的子代理
disallowedTools判定。
“丢失”指
main会限制而该臂不限制的组合。这里不涉及注册表竞争者,因为限制型匹配从不读注册表。 - L4
-
Linux x86_64,Node 22.22.2。第 3 轮是在 macOS arm64 上跑的。
1. 未解决:部分分隔符限制规则 fail-open(bot R26-2 + 新发现的原始 key 拼写族)
剩下的 152 条丢失全部是同一种形态:限制型通配 mcp__<key 的某种拼写>_*,前缀恰好停在 key 自身分隔符的第一个下划线处。这正是 R24 为 mcp__github_* 恢复的“该 server 全部工具”的粗写法(N1 行)。matchesRestrictiveMcpName(rule-parser.ts:1912)只在两个条件同时满足时接受它:
- 规则是注册名的字面前缀;
- 工具段不以
_开头(:1936-1937)。
于是留下两个缺口:
| 族 | 规则形态 | 组合数 | 真实 CLI 见证(base → head) |
|---|---|---|---|
| A(bot R26-2) | 注册拼写,工具名以 _ 开头:mcp__foo_* → foo/_internal,mcp__github_* → github/_admin_reset,mcp__foo__* → foo_/_internal |
64 | N2 deny、N3 ask、N4 子代理、N6、N7(YOLO)、N14:拦截 / ask / 过滤 → 执行 |
| B(新) | key 含 [A-Za-z0-9_-] 以外字符时,用原始或 legacy 拼写,影响该 key 的全部工具:mcp__zybio.db_*、mcp__foo:bar_*、mcp__com.example.enterprise-search_*、URL key |
88 | N8 deny、N9(YOLO)、N10 ask、N11 子代理:拦截 / ask / 过滤 → 执行 |
- ACP 上相同。 N2、N3、N7、N8、N9、N10、N14 在 head 上都执行了;base 和 cand E 报
Tool "…" is disabled.,或发出session/request_permission。 - head 上契约自相矛盾。 注册拼写
mcp__zybio_db_*能拦截(N12),mcp__zybio.db__*(N13)和mcp__zybio.db(D4)也能,唯独单下划线的原始拼写不行。同样,deny mcp__foo__*拦得住foo_/deploy(N15),却拦不住foo_/_internal(N14)。 - A 族是有文档记载的(设计文档
:38:“以下划线开头的注册工具段保留边界拒绝”)。但在限制方向上,这种“拒绝”唯一的效果就是 deny 不生效,而文档自己的 Asymmetry 一条写的正是这种失效。授权方向的 R17-2 拒绝是另一回事,候选 E 保留它。 - 暴露条件。 需要有一条写成
mcp__<key>_*的限制条目。A 族还要求工具名以_开头;B 族要求 key 含.、:或/,这在zybio.db这类 key 和 URL key 中很常见。满足条件时,可信 server 上或 YOLO 下工具会无提示执行,而main会拦截。
候选 E。 补丁只影响限制型规则:matchesRestrictiveMcpName 只从 deny、ask、disallowedTools 进入,或经 matchesToolPattern 进入,而后者的生产调用方全部是限制型。它采用的规则是:前缀以 key 的三种拼写之一停在 key 自身分隔符处或分隔符内时,就指向这个 key。
补丁 diff 见英文部分的折叠块。内容如下:
rule-parser.ts+8/−5。- 测试:
mcp-server-rule-collision.test.ts:1494-1516原来 3 行的块扩成 8 行:- 原有 3 种形态,改为断言受限;
github/_admin_reset;- 原始 key 拼写的
zybio.db、foo:bar、foo.bar; mcp__foo__*下的foo_。
- 每行都检查
matchesToolPattern、denyevaluate、isToolEnabled、askevaluate和matchesAgentToolBlocklist,并断言allow仍为default,即保留 R17-2 的授权钉子。 - 新增一个对照:
mcp__foo_*不会限制 keyfoobar。 - 两份设计文档
:38的那句话同步修改。 - 共 4 个文件,+55/−13。
| 检查 | 结果 |
|---|---|
| 真实 CLI,83 个场景 | 恰好 10 个 fail-open 行(N2–N4、N6–N11、N14)回到 base 行为。其余 73 行与 head 完全一致,包括所有 allow、ask 和授权 guard 行(S、U6/U7、F7/F8、K5、E、P、Q)。 |
| ACP(7 个 fail-open 行) | 全部重新被拦截或请求确认。 |
| 差分,16,502 个组合 | 相对 main 的丢失:head 152 → cand E 0,四个闸门都是如此。cand E 还新增 17 条 main 没有的、只针对自身 key 的限制:52 字符 URL key 的原始 / legacy 部分分隔符规则。main 漏掉它们,是因为截断后的 legacy alias 丢了分隔符。这些都是 fail-closed,不涉及其它 key。head 与 cand E 相差 169 个组合 = 152 + 17。 |
| 先红后绿 | 8 个新测试行在 head 上全部失败;cand E 上 collision 套件 101/101 通过。 |
| 变异体,5/5 被杀 | 每个变异都会让 PR 的 4 个权限套件(708 个测试)中至少一个失败:删除新析取项(10 个失败);恢复 _ 豁免(6);只用注册拼写、去掉原始与 legacy(3);接受任意 key 头前缀、不要求分隔符(1);让兜底参与授权(14)。 |
| 门禁 | core 定向套件(35 个文件,7 个跳过):cand E 2854/2854,head 2848/2848。CLI Session.test.ts 1114/1114。core 全量 34076 通过、6 失败;同样 6 个在 base 和 head 上以相同方式失败(root 用户与本地 git 环境相关,均不涉及权限)。eslint --max-warnings 0、tsc --noEmit、prettier 均干净。补丁可干净应用到 head,也可应用到 head 与 main 的合并结果。 |
2. R23-1:注册了同拼写 server 时,legacy 精确 allow 仍授权(不是回退)
| id | server | 规则 | base | head |
|---|---|---|---|---|
| E0 | 只有 foo:bar/a.b |
allow mcp__foo_bar__a.b |
执行 | 执行(唯一归属,兼容行为) |
| E1 | 再加 foo_bar/unrelated |
allow mcp__foo_bar__a.b |
执行 | 执行,ACP 上相同 |
| E2 / E3 | 同上 | mcp__foo_bar__* / mcp__foo_bar |
执行 | ask |
E1 与 main 的行为相同,所以不是回退。但设计文档 :36 写的是“同时注册了 foo_bar 时,……历史精确条目不能授权 foo:bar”,代码并没有做到。可以现在收窄那句话,也可以留在 #13412 里处理。我不认为它应该阻塞合入。
3. R26-1:歧义池读的是会话注册表(两个方向都已复现)
isMcpAllowAmbiguous 读的是 this.config.getToolRegistry()(permission-manager.ts:452)。带 frontmatter mcpServers 的子代理运行在自己重建的注册表上,但沿用会话的 PermissionManager。
| id | 设置(allow: ["agent", "mcp__foo_bar__*"]) |
base | head |
|---|---|---|---|
| P1 | 主线程,foo.bar 与 foo_bar 都在会话级 → foo.bar/evil |
执行 | ask(已修复) |
| P2 | 同上,由子代理调用 | 执行 | ask(已修复) |
| P3 | foo_bar 只在 agent 的 mcpServers 里 → foo.bar/evil |
执行 | 执行:外来工具仍被自动批准,与 main 相同 |
| P4 | 同上 → agent 本地的属主 foo_bar/evil |
执行 | ask:真正的属主失去授权(fail-closed,新行为) |
P3 与 main 一致。P4 是 fail-closed 的体验回退,只在 agent 本地 MCP server 的拼写与会话级 server 碰撞时出现。两者都不应阻塞合入,都属于 #13412 要定的“歧义判定归哪个注册表”的问题。
4. head 上仍然成立的内容
- 既有各行。 第 3 轮候选 D 那次运行的 48 行在 head 上判定全部相同;prev 在 Linux 上也逐格复现了第 3 轮 macOS 上的结果(图 3 中的 R、T、U、F、K、A 组)。只有一个授权行是有意改变的:K5 从 prev 的 ask 变成 head 的执行,因为 R24 现在会授权唯一的
__key 归属方。此外,R24 自 prev 以来还修复了 N1、N5、N12、N15。 - PR 自己的嵌套 key 测试计划(Q1–Q5)与描述一致:在
mcp__foo__*下,外来的foo__bar需要确认;两个边界归属方都需要确认;裸mcp__foo__bar两边都限制。 - pomelo-nwu 的 R4-1(限制型前缀保留 legacy 拼写):
- head 上所有 legacy 前缀形态都已满足,包括 prev 时仍丢失的 252 条长 URL key 粗粒度 legacy 前缀;
- 仍丢失的 legacy 行只剩 4 条 B 族部分分隔符,候选 E 会把它们关掉。
- head 合入当前 main 的结果 在 83 个真实 CLI 场景和 16,502 个差分组合上都与 head 完全一致。
5. 门禁与覆盖
| 门禁 | 结果 |
|---|---|
7e489a5b33 的 CI |
25 个成功、22 个跳过。被取消的只有被后续调度取代的 route 任务。review-pr 已完成(R26-1、R26-2 就出自这一轮)。 |
| head 单测 | core 定向套件(35 个文件,7 个跳过)2848/2848;CLI Session.test.ts 1114/1114。 |
| 评审状态 | chiga0 已在当前 head 上 APPROVED。pomelo-nwu 在 952e3ef668 上的 CHANGES_REQUESTED 仍在,所以 reviewDecision 是 CHANGES_REQUESTED。 |
未覆盖:
- Windows 与 macOS(本轮只在 Linux x86_64 上跑)。
- App-only 的 RPC/UI。
- 外部模型提供方(模型是本地回环 fixture)。
tool_search桥接。- 除 N2、N8 以外场景的交互 TUI。
建议
- 合入候选 E,或任何对两族都生效、且只作用于限制型规则的等价修复。之后我这边没有阻塞项。
- R23-1(文档与代码不一致)和 R26-1(P3/P4)不阻塞合入:可以收窄文档那句话,也可以都放到 Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412 跟踪。
- 仍需 pomelo-nwu 重新评审,才能清除那条 standing CR。
证据在 wenshao/qwen-code asserts/pr-12531-r4:装置、每次运行的原始数据(headless、ACP、cand E)、差分 JSON、单测日志和候选 E 补丁。
🤖 Generated with Claude Code — Claude Opus 5.5
A restrictive wildcard `mcp__<key>_*` whose prefix stops one underscore into the key's own separator matched only when the rule was a literal prefix of the registered spelling AND the tool segment did not start with `_`. That left two holes against `main`, which blocks every such row: - family A: registered spelling with an underscore-leading tool segment (`mcp__foo_*` vs `foo/_internal`, `mcp__github_*` vs `github/_admin_reset`); - family B: raw or legacy spelling of a key containing characters outside `[A-Za-z0-9_-]` (`mcp__zybio.db_*`, `mcp__foo:bar_*`, URL keys). Measured by the maintainer's module differential over 16,502 rule x tool rows, these were 152 losses against `main` on all four restrictive gates (deny, ask, `isToolEnabled`, `disallowedTools`), and 10 real-CLI rows plus 7 ACP rows ran where base blocked or prompted. Restrictive-only fix: a prefix that stops at or inside the key's own separator, in any of the key's three spellings (raw, provider-safe, legacy), names that key whatever the tool segment starts with. `matchesRestrictiveMcpName` is reached only from deny, ask, `disallowedTools`, and `matchesToolPattern` whose production callers are all restrictive, so no grant changes: the R17-2 pin (`allow` stays `default` on every row) is asserted per row. Test rows: the three-row "an uncut registration never enters the fallback" block becomes eight rows expecting a restriction, each checking all five gates plus the `allow` pin, and a new control asserts key `foobar` is NOT restricted by `mcp__foo_*`. Both design doc sentences at :38 are updated to match. Differential after the fix: 152 losses -> 0. Four PR permission suites 708/708; collision suite 101/101; red-first on head was 8 failed. Ref: #12531 (comment) Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuwcsv789o
Gate-freshness only: main advanced past the branch merge-base and touched scripts/lint.js (6b878e1, fix(ci): fail yamllint and shellcheck lanes loudly on an empty git file list). No code interaction with this branch. Main tip 43a6e1e is the same main the maintainer built his round-4 "merge" arm against; he measured head+main as identical to head on all 83 real-CLI rows and all 16,502 differential rows. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuwcsv789o
Maintainer verification, round 5 (delta) — PR #12531 @
|
| Check | Result |
|---|---|
Real CLI qwen -p, 83 scenarios |
Identical to round-4 head + cand E on all 83 rows. Against the round-4 head, exactly the 10 fail-open rows changed (N2–N4, N6–N11, N14), each back to main's verdict. |
ACP qwen --acp, 9 scenarios |
N2, N7, N8, N9, N14 return Tool "…" is disabled.; N3 and N10 raise a permission request. Both match main. P1 and E1 are unchanged from round 4. |
| Real interactive TUI, N8 and N2 | Blocked with Matching deny rule; the MCP server logged 0 calls (Fig 1, Fig 2). |
| Module differential, 16,502 rule × tool rows | 0 losses against main on deny, ask, isToolEnabled and subagent disallowedTools (round-4 head: 152). 0 rows differ from round-4 cand E. |
Against main, all 83 rows |
One row runs where main blocks: A5, the PR's declared narrowing (a foo.bar entry no longer blocks server foo_bar). 17 rows that run on main now ask or are blocked: the #10199 fixes, plus P4 below. Same set as round 4. |
| Unit tests | Core targeted suites: 2924 passed, 7 skipped (35 files; up from 2854 because the merged main adds tests). Collision suite 101/101. CLI ACP Session.test.ts 1152/1152. Red-first and mutation results carry over from round 4 because the code is byte-identical (8/8 new rows red on 7e489a5b33, 5/5 mutants killed); the author's red-first count (8 failed, 93 passed) matches. |
CI at 0a5e943bf1 |
Test (ubuntu-latest), Lint & Static, Integration (no-AK), Desktop Shell and TUI parity all pass. Test on macOS and Windows was skipped by routing. Both review-pr runs (and their fallback-comment jobs) failed before reviewing anything: the bot token got HTTP 403: Sorry. Your account was suspended. That is infrastructure, not this PR. web-shell E2E Smoke was still running when I posted. |
Current main (481b4837aa, 2 commits ahead) |
git merge-tree is clean. The two commits touch the XML tool-call fallback and the memory dream, nothing under permissions or MCP naming, so I did not build a separate arm. |
Still open, not blocking (unchanged from round 4)
- R23-1. E1 (a legacy exact
allowwhile a same-spelling server is registered) still grants, which is whatmaindoes. The design doc's:36sentence still promises a refusal. - R26-1. P3 (agent-local competitor) behaves like
main. P4 (an agent-local owner now gets a prompt) is a fail-closed UX regression. - As the bot points out on both threads, Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412 does not list these rows yet. E1 and P3/P4 should be added there when this merges, so the follow-up is actually tracked.
Merge gates
pomelo-nwu'sCHANGES_REQUESTEDfrom952e3ef668(2026-09-25) still stands, soreviewDecisionisCHANGES_REQUESTED.chiga0's approval on7e489a5b33was dismissed by the new push, so it needs a fresh approval at0a5e943bf1.
Not covered: Windows and macOS (Linux x86_64 only), App-only RPC/UI, external model providers, the tool_search bridge. Same limits as round 4.
Evidence: wenshao/qwen-code asserts/pr-12531-r5 — harness, raw per-run data (headless, ACP, TUI), the differential JSON, unit logs, and tree-equality.txt (the byte-identity and tree checks above).
中文版(点击展开)
维护者验证 第 5 轮(增量)— PR #12531 @ 0a5e943bf1
结论:我这边可以合入。第 4 轮的阻塞项已关闭,我这边没有剩余阻塞项。 所有检查都在真实 PR head 上重跑(全新 pnpm install --frozen-lockfile、npm run build、npm run bundle,没有本地补丁)。剩下的只是下面列出的人工评审门禁。
本轮是相对第 4 轮的增量;第 4 轮测过、这里没列出的内容均未变化。
与第 4 轮相比的变化
cdb3cb189e与候选 E 逐字节相同。4 个文件我都逐一和第 4 轮的 cand E 工作树做了 diff。0a5e943bf1合入了main43a6e1e5e4,正是第 4 轮merge臂所用的同一个main。它的树等于该臂(7e489a5b33+43a6e1e5e4)加候选 E,别无其他:两者之间的git diff --stat只有这 4 个文件,+55/−13。
0a5e943bf1 上的结果
| 检查 | 结果 |
|---|---|
真实 CLI qwen -p,83 个场景 |
83 行与第 4 轮 head + cand E 完全一致。相对第 4 轮 head,恰好是那 10 个漏拦行发生了变化(N2–N4、N6–N11、N14),全部回到 main 的判定。 |
ACP qwen --acp,9 个场景 |
N2、N7、N8、N9、N14 返回 Tool "…" is disabled.;N3、N10 发起权限请求,均与 main 一致。P1、E1 与第 4 轮相同。 |
| 真实交互 TUI,N8 与 N2 | 均被拦截,显示 Matching deny rule,MCP server 记录 0 次调用(图 1、图 2)。 |
| 模块差分,16,502 个"规则 × 工具"组合 | 在 deny、ask、isToolEnabled 和子代理 disallowedTools 上相对 main 漏拦为 0(第 4 轮 head 为 152);与第 4 轮 cand E 相差 0 行。 |
与 main 对比,全部 83 行 |
只有一行是 main 拦截而这里放行:A5,即 PR 声明的有意收窄(foo.bar 条目不再拦截 server foo_bar)。17 行在 main 上会执行,现在改为询问或拦截:#10199 的修复,加上下面的 P4。与第 4 轮是同一组。 |
| 单测 | core 定向套件:2924 通过、7 跳过(35 个文件;比 2854 多,是因为合入的 main 新增了测试)。冲突套件 101/101。CLI ACP Session.test.ts 1152/1152。代码逐字节相同,所以第 4 轮的红灯先行和变异结果直接沿用(7e489a5b33 上 8/8 新行失败,5/5 变异被杀);作者报告的红灯先行计数(8 失败、93 通过)与之一致。 |
0a5e943bf1 的 CI |
Test(ubuntu-latest)、Lint & Static、Integration(no-AK)、Desktop Shell、TUI parity 全部通过。macOS 和 Windows 的 Test 按路由规则跳过。两次 review-pr(以及对应的 fallback-comment)都在开始评审前失败:bot token 返回 HTTP 403: Sorry. Your account was suspended。这是基础设施问题,与本 PR 无关。发评论时 web-shell E2E Smoke 仍在运行。 |
当前 main(481b4837aa,领先 2 个提交) |
git merge-tree 无冲突。这两个提交改的是 XML 工具调用回退和 memory dream,不涉及权限或 MCP 命名代码,所以没有单独构建一个臂。 |
仍未解决、但不阻塞(与第 4 轮相同)
- R23-1。 E1(注册了同拼写 server 时,旧写法的精确
allow)仍然放行,这与main一致;设计文档:36那句话仍然承诺会拒绝。 - R26-1。 P3(子代理自带的竞争者)与
main一致。P4(子代理自带 server 的合法工具现在会弹确认)是偏保守的体验回退。 - 正如 bot 在两个 thread 上指出的,Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412 目前还没有列出这些行。合入时应把 E1 和 P3/P4 补进 Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412,后续跟踪才算真正落地。
合入门禁
pomelo-nwu在952e3ef668上的CHANGES_REQUESTED(2026-09-25)仍在,所以reviewDecision是CHANGES_REQUESTED。chiga0在7e489a5b33上的批准已被新的推送 dismiss,需要在0a5e943bf1上重新批准。
未覆盖: Windows 与 macOS(只跑了 Linux x86_64)、App-only 的 RPC/UI、外部模型提供方、tool_search 桥接。与第 4 轮相同。
证据在 wenshao/qwen-code asserts/pr-12531-r5:装置、每次运行的原始数据(headless、ACP、TUI)、差分 JSON、单测日志,以及 tree-equality.txt(上面的逐字节与树一致性核对)。
🤖 Generated with Claude Code — Claude Opus 5.5
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Reviewed head: 0a5e943bf1108125d4aa1da9a5b8e1b45f05c105 (base main).
Approve. The one human CHANGES_REQUESTED on this PR and the Critical it cites are both verified fixed in source at this exact head, and the current Critical-only scan of the permission-matching path found nothing blocking.
Historical blocking finding — status at this head
pomelo-nwu, CHANGES_REQUESTED at 2026-09-25T12:30:43Z on commit 952e3ef6, citing Critical R4-1 — the claim was that matchesMcpPattern checked only the registered and raw names for prefix patterns, so a persisted legacy deny/ask/disallowedTools prefix whose suffix had been altered by legacy name reduction could stop matching its own MCP tool. That is fail-open for restrictions, the worst direction for this PR. Two things were demanded: preserve the legacy spelling for restrictive prefix matches with server provenance, and add a regression for deny/ask as well as allow.
Both are fixed at head, and the underlying mechanism was closed rather than patched around.
-
Legacy spelling preserved for restrictive matches.
matchesMcpPatternnow resolvesresolveLegacyMcpSpelling(rawToolName, toolAliases)and pushes it into the spelling set, and builds a separateprefixSpellingslist that adds the untruncated provider-safe rendering of the raw name — with the reasoning recorded in place: the untruncated legacy rendering already contains the faithful head of a truncated alias without treating its injected___as a separator.matchesPrefixLiterallytests every one of those spellings, and it is the fallback on both the no-boundary branch and the no-identity path, so a legacy-spelled restrictive prefix still reaches its own tool. -
Server provenance comes from the producer, not from string splitting. The matcher takes an
McpToolIdentity { serverName, serverToolName }, supplied bymcp-tool.ts'sget mcpIdentity(), and derives boundaries throughmcpSegmentSpellings(raw, provider-safe and legacy character spellings). This is the structured-identity remedy that the author's own earlier thread identified as the only fix that closes the R4-1 loss population — the spelling-list extension alone closed none of the single-alias rows. Wildcard matching now reads the registered boundary to retain hash/truncation suffixes, and a coarse prefix that overruns the key is treated as naming a sibling and refuses rather than granting. -
Deny/ask regressions exist and pin both directions.
mcp-server-rule-collision.test.tscarries dedicated restrictive suites:legacy-spelled deny coverage,legacy-spelled wildcard prefixes keep covering their own server,legacy-spelled bare server rules keep covering their own server, and cases for a lossy tool segment, a colon-keyed server, a >28-char legacy prefix under a truncated advertised reduction, a bare legacy deny where truncation cut only the tool segment, andgetToolRegistrationStatusdisabling a legacy-denied tool when the alias is supplied. Crucially the suite also pins the opposite risk —a legacy-spelled deny does not reach a forged verbatim registrationanddoes not reach a colon-keyed forgery— so preserving restrictions did not buy an over-reach that a forged registration could exploit.
Critical-only scan
Focused on the authorization decision path, which is where this PR can only matter.
The grant/restrict asymmetry is correct and enforced where it counts. hasAmbiguousMcpGrant's contract — "a lossy spelling may restrict every claimant, but must not grant to them" — is actually wired into the decision, not just declared: the allow arm in permission-manager.ts requires matchesRule(...) && !this.isMcpAllowAmbiguous(rule.toolName, mcpIdentity), while the deny/ask arms match without that veto. So an ambiguous or colliding spelling can still restrict every claimant (fail-closed) but can never authorize one (no bypass). That is the right direction for a PR whose stated purpose is closing an approval bypass.
No bare-wildcard widening. A pattern of * with an empty prefix returns false explicitly, so an empty prefix cannot turn a bare * into an MCP grant. Exact matching keeps the raw-identity escape only for a non-wildcard pattern with at least three __ segments that equals the raw name, so pre-normalization saved rules stay honoured without widening the wildcard path.
No Critical found on these paths.
Not scanned — disclosed, not asserted clean. I did not audit the agent-runtime policy files (agent-core.ts, subagent-plan-tool-policy.ts, workflow-agent-tools.ts, workflow-orchestrator.ts), the scheduler and memory-scoped config call sites beyond confirming they thread the identity, the ACP Session.ts change, the generated contract surface, or the two design docs. I report no Critical there because I found none where I looked, not because I proved absence. The two findings the author's threads record as ruled non-blocking by the maintainer in round 4 (rule-parser.ts:1810, permission-manager.ts:454) were not re-litigated here.
CI
No product check fails at this head: 25 pass, 26 skipped, 2 failed. Both failures are the automatic-review infrastructure jobs (review-pr and its fallback-comment companion), which per this channel's rules are summarised and never treated as a gate; no build, lint, typecheck or test lane failed. Nothing indicates a defect introduced by this PR.
Scope note
Approval is bound to commit 0a5e943b. This is a code-level review: I did not execute the permission suites, and I did not reproduce the collision registrations against a live MCP server. The maintainer's approval of this same head is noted but was not substituted for the source verification above.















What this PR does
MCP permission rules retain the tool producer's raw server/tool identity. An allow written for one server cannot acquire a different registered server through a lossy provider or historical spelling. Model-visible and App-only registrations participate in the same live ambiguity check; adding or removing a competing tool takes effect immediately.
A complete raw whole-server wildcard selects its server. Bare whole-server rules support nested
__keys, but a competing full tool name keeps its exact registered priority rather than granting the broad server. Tool prefixes with competing server boundaries require confirmation for both claimants; shared registered cut heads cannot grant across long keys. Explicit raw exact identities, unique registered exact names, intentional coarse raw prefixes and unambiguous aliases remain usable. Persisted deny/ask and agent blocklists keep producer-owned historical exact and prefix coverage even when the granting alias is withheld.Why it's needed
Lossy character replacement and truncation can collapse distinct server/tool identities into one permission spelling. Registration already distinguishes the tools, but permission compatibility could reintroduce an approval bypass. Narrowing grants must also preserve explicit restrictions, which take precedence over allow and trusted-server defaults.
Reviewer Test Plan
How to verify
foo.barandfoo_bar, and thenfoo_bar/get_xwithfoo:bar/get+x. Raw server grants and unique registered exact names must keep their intended authority; a colliding alias must not grant the other identity. Repeat with an App-only competitor and add/remove it without a restart.foo/deployandfoo__bar/deploy.mcp__foo__*must allow the former while the latter requires normal confirmation. Under default approval andtrust:false, verify zero foreign calls before confirmation and one after Allow once.foo/bar__deploy_xandfoo__bar/deploy_y.mcp__foo__bar__deploy*has two real boundary interpretations and must automatically grant neither. Removing the competitor restores the sole claimant's coverage.mcp__foo*andmcp__*rules remain broad.foo/barandfoo__bar/deploy. Baremcp__foo__barmust restrict both; in the grant direction it retains the exact registered foo/bar tool's priority and requires confirmation for the broad nested server. Without the competitor, the nested server's bare rule and genuine underscore tool-prefix rule must grant normally.foo.bar/deployandfoo_bar__deploy_service_prod/x. Lossymcp__foo_bar__deploy*must automatically grant neither; removing the competitor restores the single claimant.mcp__github_*deny/ask must still restrict ordinary github/deploy while an allow of that form remains non-granting.mcp__foo____i*selectsfoo/__internal_debugoverfoo_/_internal_secret;mcp__foo___*selects whole serverfoo_overfoo/_deploy.Evidence (Before & After)
Current 7e489a5: 805 focused core tests, dependency/CLI build, core typecheck, bundle, changed-source lint and three independent review passes are green. Same21-row source fallback before/after and27-row real Config/producer/registry/permission/shared-flow checks pass, including App-only and actual agent guards in a minimal fixture. Four packaged CLI/tmux/stdio flows pass: cited nested deny0 calls; single nested underscore allow1; lossy longer-key confirmation0 before→1 after Allow once; partial-separator ordinary deny0. Current report and four screenshots. Tested source, emitted core, executed binary/modules and committed blobs are bound.
This round changes five existing files,+114/-109, net+5: production -9, tests+14, bilingual docs0. Whole PR 28 files,+2877/-106 (production18/+595/-84; tests8/+2214/-21; docs2/+68/-1). Earlier reductions and R23 evidence remain evidence at their named commits. This is a bounded Critical repair and does not claim global minimality.
The report retains corrected launcher, concurrent coverage and unbundled MIME observer failures. Native inference uses a controlled loopback model. Screenshots replay original tmux ANSI frames with complete text parity and public-byte verification; they are not OS captures. External real providers, App RPC/UI and full nested-agent E2E are unrun. One post-push snapshot confirms this head, OPEN/MERGEABLE, review CHANGES_REQUESTED, ten checks running/six queued/two skipped. Fresh CI and maintainer review remain merge gates.
Risk & Scope
Design: English · 中文.
Linked Issues
Refs #10199.
中文说明
MCP 权限保留生产方原始身份,避免有损 provider/历史拼写把 allow 扩散给另一个注册身份。实时注册表同时包含模型可见与 App-only 工具,增删立即生效。嵌套
__键支持 bare 整 server 和真实下划线工具前缀;同字符串竞争时,唯一注册/原始精确工具名保留优先权,不能扩大授权给整个 nested server。不同边界和更长 key 的有损前缀竞争回到确认,单一归属恢复兼容。deny/ask 与 agent blocklist 保留生产方历史限制,普通工具的 partial-separator 前缀恢复限制,前导下划线边界拒绝保留。评审按上方行为核验安全/不安全键碰撞、App-only 增删、整 server 与工具前缀、共享截断头、正反下划线、历史限制引用/禁用与 agent 声明/调用拦截,以及本轮嵌套 bare、单一 underscore 前缀、更长 key 竞争和
mcp__github_*限制。当前 7e489a5 的805定向测试、构建、core类型检查、打包、lint和三路审查通过。21行同输入源码前后与27行真实模块流通过,含App-only和实际共享agent guard的最小fixture;四组实际CLI/tmux/MCP一次通过:nested deny0次、nested单一allow1次、有损竞争确认前0→Allow once后1次、partial-separator deny0次。本轮报告与四张截图。截图重放原始ANSI帧并全文、目视和公开字节核对;模型为本地受控fixture,旧before只作源码函数复现,不冒充旧原生运行。未验证外部真实模型、App RPC/UI或完整嵌套agent;首次launcher、coverage和MIME observer错误均如实记录并纠正。
本轮五个已有文件+114/-109,净增5(生产减9、测试增14、双语文档0);整份仍28文件、+2877/-106,不声称已全局最小。既有Suggestions及其它命名/权限集中化仍属后续范围。推送后一次快照确认该head,OPEN/MERGEABLE,CI运行/排队,review CHANGES_REQUESTED;新CI与维护者复审仍为合入条件。
歧义allow走正常批准,可信server/YOLO默认不变。有损限制可有意覆盖多个工具;精确缩小范围使用注册名。没有实时注册表的直接matcher是兼容谓词,不能单独证明授权无歧义。
设计:English · 中文。Refs #10199。