Skip to content

fix(core): stop MCP server rules from authorizing a colliding server - #12531

Merged
wenshao merged 51 commits into
mainfrom
fix/issue-10199-mcp-permission-alias-collision
Oct 6, 2026
Merged

wenshao merged 51 commits into
mainfrom
fix/issue-10199-mcp-permission-alias-collision

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • Register foo.bar and foo_bar, and then foo_bar/get_x with foo: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.
  • Register foo/deploy and foo__bar/deploy. mcp__foo__* must allow the former while the latter requires normal confirmation. Under default approval and trust:false, verify zero foreign calls before confirmation and one after Allow once.
  • Register foo/bar__deploy_x and foo__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.
  • Register two long URL keys with the same first 56 registered-name characters. A saved allow on that cut head must grant neither while both exist, including an App-only competitor. Deliberate raw mcp__foo* and mcp__* rules remain broad.
  • Register foo/bar and foo__bar/deploy. Bare mcp__foo__bar must 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.
  • Register foo.bar/deploy and foo_bar__deploy_service_prod/x. Lossy mcp__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.
  • Preserve the underscore controls: mcp__foo____i* selects foo/__internal_debug over foo_/_internal_secret; mcp__foo___* selects whole server foo_ over foo/_deploy.
  • For a long URL key whose historical alias is withheld, save an exact or faithful historical-prefix deny/ask. It must still restrict its own tool and beat a competing registered allow; deny must provide its rule citation and disable the tool. An agent's same blocklist must filter declarations and block invocation. The restrictive fallback cannot grant.
  • Two same-server tools with a shared published exact legacy alias must not inherit a shared allow; explicit restrictions retain their coverage.

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

  • Ambiguous grants return to normal approval. Trusted-server and YOLO defaults are unchanged; this is not a global deny. Use a unique registered exact name or explicit raw exact rule for a precise grant.
  • Lossy restrictive spellings may intentionally cover multiple tools. Producer boundaries still protect adjacent-key cases; use registered exact names to narrow a historical restriction.
  • Direct matcher callers without a live registry remain compatibility predicates, not standalone ambiguity-safe grant authorities. Other naming families and permission-helper centralization remain separately scoped.
  • Maintainer approval and current CI remain merge requirements. This update does not merge or approve the PR.

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。

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
@yiliang114

Copy link
Copy Markdown
Collaborator Author

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 matchesMcpPattern() without rawToolName (agent-core.ts ×3 over toolConfig.disallowedTools, workflow-agent-tools.ts over input.denies, and matchesAgentToolBlocklist() in subagent-plan-tool-policy.ts). User permissions.deny is unaffected — aliases are threaded through permissionFlow.ts → permission-helpers.ts. Recorded as a disclosed residual; the narrowing only triggers when both the server and tool segments are provider-unsafe. Threading aliases into these call sites needs registry context they do not currently hold, so it belongs to the variant-2 work tracked in #10199 rather than this PR.

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 main (from "every tool of the colliding server" to "tools whose name embeds a computed hash"). Real closure needs the registry-backed ambiguity filtering attempted in #10202, so the matcher can ask "does this rule denote more than one registered server?" instead of reasoning about one name in isolation.

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 rawToolName === undefined branch at 67d07f2; no behavior in this PR is changed by these disclosures. The merge decision on this permission-path change is handed to @zjunothing.

yiliang114 and others added 3 commits September 23, 2026 20:26
…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.
@yiliang114

yiliang114 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Scope ledger — delivered 7e489a5b331287dab9cf6015d94d2bb076f5a633, substantive round 27.

Goal and scope. Preserve #10199 and original baseline 67d07f238b (one production file/102 lines, one test file/114 lines). Four independently reproduced findings are addressed by the bounded R24 repair. Existing Suggestions and other naming/centralization work stay deferred. No new production file, API, alias channel, dependency, cache or option; suspiciousFiles stays empty. Earlier scope-fuse deferrals are superseded by this verified fix-forward result under the user's explicit continuation request.

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 e46e5ae0ed088e68ab5180ff311b80ea0b55d622dae96e53cbd91c641b87404b maps exactly to all five committed blobs; executed binary/module and TS emit bindings are recorded. Initial harness failures and controlled-provider/unrun boundaries remain explicit in the report with screenshots.

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 6abd1eb228d7ed6efd4fdfd08aa184a5e8fc5f02, substantive round 26.

Goal and scope. Preserve #10199 and original baseline 67d07f238b (one production file/102 lines, one test file/114 lines). This round fixes only the two independently reproduced R23 Criticals. Existing Suggestions and unrelated naming/centralization work remain deferred. No new production file, API, alias publication, option, dependency or cache; suspiciousFiles remains empty.

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 94db481861bf790f7f2dac3d3c5f6e04873f9e30743d57e839895d6c764bde2e: 798 focused tests across6 core files, core build/typecheck, bundle, changed-source lint, final hook and independent correctness/reuse/efficiency reviews pass. 21 actual producer/registry/shared-flow observations pass, including minimal real agent declaration/invocation guards. Four real CLI/tmux/stdio MCP flows prove foreign confirmation, own direct allow, cited legacy deny with zero transport calls, and explicit ask with Always Allow hidden. Three original ANSI-frame replay PNGs have full-text parity, visual/public-byte checks and immutable img-host links. Source emit matches rebuiltcore; executed entry/chunk/direct-module hashes and post-hook committed blobs match the candidate. Initial type/prerequisite and observer failures are preserved. Controlled localhost model; external models, App RPC/UI and full nested-agent E2E are unrun. Current report and screenshots.

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 7d5192bad16e29e0bcaff92175a6f9bb0d6b301a, substantive round 25.

Goal and scope. Preserve the original #10199 contract and baseline 67d07f238b (one implementation file / 102 lines, one test file / 114 lines). This round implements the verified R19 reverse whole-server collision and the user's requested further Ponytail simplification. No new product file, dependency, cache, option or matching framework. The previous Suggestions stay deferred; suspiciousFiles remains empty.

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 mcp__foo___* with foo/_deploy and foo_/release: the former now returns to normal approval, the latter retains whole-server authority. The mirror is limited to the other exact server boundary; old lettered-prefix behavior survives. Owner registration/removal and App-only claimants still participate. Latest author comment requests keeping the thread open for broader structural tracking; the concrete defect is fixed, the wider parser/ownership rewrite remains deferred, and the thread is left open accordingly. This is not an approval or a claim that all MCP naming ambiguity is eliminated.

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 trust:false pass: foreign _deploy has zero calls before confirmation and one after Allow once; own release executes once directly. Two original ANSI-frame PNG replays have full-frame plain-text parity, visual inspection and public URL/hash checks. All five committed blobs match the candidate after hooks; test processes are cleaned. The model is a controlled loopback fixture; external providers and App RPC/UI are unverified. Initial local compilation failure, post-gate verifier interruption and split-entry lookup failure are retained. Report and screenshots.

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 272d3efc3180dc2f69d2f35666d5b308349008c2, substantive round 24.

Goal and scope. Continue the original #10199 contract and the baseline 67d07f238b (one implementation file / 102 lines, one test file / 114 lines). The user explicitly requested a further whole-PR Ponytail simplification. This round preserves the R19/R20 fixes and all current permission behavior, removing proven duplication. Unrelated Suggestions and the existing nonblocking documentation/migration follow-ups stay deferred. suspiciousFiles remains empty.

Measured reduction. Relative to round 23 (d5e9fc7): five existing files, +345/-914, -569 net lines. Whole PR 29 files, +3656/-99 → 28 files, +3092/-104. Production now +669/-82 in 18 files (-133 net this round); tests +2355/-21 in eight files (-436); the two existing design documents stay +68/-1. No new production file, dependency, grant policy or cache.

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 d5e9fc7ac4255b707828bee58a70036d811e9692 (substantive round 23).

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 b5f5dcc (round 22); this round closes its remaining raw lettered wildcard entrance. Single-claimant compatibility, explicit raw exact/unique registered exact/coarse rules and restrictive coverage remain. Direct matcher callers without a live registry are compatibility predicates; no claim of eliminating every naming ambiguity is made. The registered ambiguity work, previously a disclosed follow-up, was subsequently implemented; the current user explicitly requested the remaining bug fix and Ponytail simplification.

Rounds and baseline. Continue round 21 at 1ace635, round 22 at b5f5dcc, and round 23 at this commit. Preserve the original baseline (67d07f238b, one implementation file / 102 lines, one test file / 114 lines); do not reset it. The roughly-five-round rule keeps unrelated Suggestions deferred.

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. suspiciousFiles remains empty. The full PR is still large; this ledger records real cuts, not a blanket minimality claim.

Ponytail decisions. Remove rawOnly and its parameter propagation. Reuse the restrictive matcher, alias producer and agent blocklist predicate. Reuse the existing App identity map, read computed aliases once, and fold unique exact-name validation into the existing peer scan. Retain the live-registry ownership check and restrictive fallbacks because they protect the demonstrated permission contract. Preserve the two distinct tool-pool regressions and the own/removal controls. Earlier helper reuse and dominated-prefix consolidation remain fixed in 1ace635 (−221 net).

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,延续 1ace635 的 round 21 与 b5f5dcc 的 round 22,保留最初 baseline。R20 限制规则回退保持修复;R19 的共享 alias 授权已由 b5 修复,本轮再修复原始字母通配绕过实时 server 边界的入口。生产净减 31 行,加上模型工具池/App-only 两条最小回归后总体净减 17 行;没有新增生产文件、依赖、选项、抽象或缓存。整份 PR 仍为 +3656/−99、29 文件,不以一次简化宣称整体最小。

1011 项定向测试、完整构建/类型检查/打包、lint/format、两遍自审和两份独立 review 均通过。真实 tmux 前后对照证明旧版零确认执行,新版确认前零调用、Allow once 后一次,自身工具继续允许。四张原帧回放截图已上传并核对公共 URL 与 SHA;七个发布文件与测试候选一致。其余与契约无关的 Suggestions 继续延后,CI 和维护者评审仍待完成。

Historical round-21 ledger and earlier evidence (preserved)

Scope ledger — delivered 1ace63544433f91b2cc20ff5a42af56492d00ab0.

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 / 5bfea95dd7. The producer-identity exact restrictive fallback at d62f5b79d2 accounts for round 20; this repair and simplification batch is round 21. The original baseline (67d07f238b, one implementation file / 102 lines, one test file / 114 lines) is preserved, not reset.

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. suspiciousFiles remains empty.

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.

中文说明

范围账本 — 已交付 1ace63544433f91b2cc20ff5a42af56492d00ab0。

目标与契约。 保留 #10199 variant 1 的原始目标:原始 server 规则不得授权另一个以不同名称注册的碰撞 server。保留现有身份传递、兼容行为,以及维护者对 restrictive-only fallback 的裁定。provider 拼写授权与有歧义的保长精确 alias 仍是已披露残留,本轮不声称解决;R19 保持未解决供维护者评审。

轮数记账。 原账本停在 substantive round 19 / 5bfea95dd7。d62f5b79d2 的生产方身份精确限制兜底计入 round 20;本轮修复与简化为 round 21。保留原 baseline(67d07f238b,一个生产文件 / 102 行,一个测试文件 / 114 行),不重置。

范围纠偏。 相对本轮起始 head,PR 从 +3552/−90 缩到 +3331/−90,28 文件。当前分类:生产 +692/−68(18 文件),测试 +2573/−21(8 文件),设计 +66/−1(2 文件)。本轮只改四个已有文件,+116/−337(净减 221 行):matcher −33,测试 −190,双语设计 +2。没有新增生产文件、依赖、选项或匹配框架;suspiciousFiles 仍为空。

归因与决策。 必须修复 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 substantiveRounds goes 18 -> 19.

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 dbab8f48f0 -> 5bfea95dd7 (+3457/-90, 28 files), still 2.3x the 1500-addition scope fuse. The fuse was overridden for this one commit only, by that ruling.

Delta and attribution. +30 implementation lines in one existing file (packages/core/src/permissions/rule-parser.ts: new matchesRestrictiveCutRegistration wired into the two restrictive sites) and +146 test lines in one new file (packages/core/src/permissions/mcp-cut-registration.test.ts, 10 rows). Both attributed to R18-1 under the maintainer ruling. No new implementation file, suspiciousFiles stays empty, no unattributed growth. Deliberately not added: the design-doc paragraph for the sibling over-block trade-off that @wenshao suggested next to the R15-1 note (docs/design/mcp-tool-name-provider-compatibility.md:37 + zh-CN twin) - out of the ruled scope, flagged in the thread reply instead.

Verification. Red-first reproduced locally: the 10 rows give 4 failed / 6 passed on dbab8f48f0 and 10/10 with the fallback. mcp-server-rule-collision.test.ts 69/69, rule-parser + permission-manager + tool-registry 667/667, tsc --noEmit clean, prettier and eslint --max-warnings 0 clean on both changed files.

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
@yiliang114

Copy link
Copy Markdown
Collaborator Author

R2 round delivered — 59ba562c68

All four Criticals and all eleven Suggestions from the 2026-09-23T21:16Z review are addressed in 59ba562c68, by taking the route R2-3 named: the producer now publishes the tool's exact raw mcp__<server>__<tool> identity, the matcher compares it literally, and the entire reconstruction machinery is deleted. Nothing is hashed or reconstructed anywhere in the permission path, so no gate built on "does this name look like a normalization" remains to have a next corner.

What changed

  • DiscoveredMCPTool.permissionAliases now publishes [raw, legacy?] — the exact raw spelling first, the legacy reduction second, deduped. Verbatim provider-safe registrations still publish [] (the legacyName === this.name rule is unchanged), so the pinned no-alias posture and the isToolDisabled / matchesLegacyExactName consumers of the legacy spelling are unaffected.
  • rule-parser.ts: deleted isNormalizedFromRawPrefix, PROVIDER_NAME_HASH_SUFFIX, PROVIDER_NAME_HASH_LENGTH, the aliasAdvertised gate, and the relaxed server-segment alias arm. The exact arm is now pattern === rawToolName (literal, provenance-anchored). resolveRawMcpIdentity keeps only the strict arm. Net effect in this file: 66 insertions / 149 deletions — the round is a simplification, not another layer.
  • matchesToolPattern gains the advertised-exact-name arm, so an exact 3-part entry now blocks identically via permissions.deny and disallowedTools (R2-6).
  • ACP L1 isToolEnabled gate threads the same alias channel as the scheduler (R2-5); workflow narrowing skips session-registry aliases for agent types with their own MCP servers (R2-12); the stale docblock sentence and dangling {@link} are corrected (R2-13); docs/design/mcp-tool-name-provider-compatibility.md now describes the shipped algorithm and gained its zh-CN counterpart (R2-11).

Red-first evidence

Every witness from the findings was pinned as a failing test against b6ae20e9a3 before the fix (13 red / 15 green in mcp-server-rule-collision.test.ts, every failure a designated witness, every positive control green), and all pass after:

  • R2-1: evil_1oxrpi0 byte-identical forgery and the zybio_db/literature_search_0qeuuzz witness — exact arm matched true pre-fix, now false / default.
  • R1-3: both entrances red — the foo:bar server naming evil[a b#c#d"e~f|x (hash-rebuild verifies) and the 64-char provider-safe raw registering byte-identical (_0lt68rp). Both now false / default; the fallback they entered no longer exists.
  • R2-2: weather-forecast-server-premium's middle-truncated alias matched rule mcp__weather-forecast-server pre-fix (true/allow); now false/default. The own-server controls (foo:bar, com.example.enterprise-search, zybio.db) still match.
  • R1-2: row H (foo:bar + a.b deny), the 24+-char truncation entrances (both mcp__com.example.enterprise-search forms), and the dotted-server tool-prefixed wildcard (mcp__zybio.db__literature.search_*) were all default-fail-open pre-fix; now deny/disabled. The length sweep is pinned: foo:bar denies at rawLen 55, 56, 59, 63, 64, 69, 74, 80 (pre-fix red at 56/59/63/64/69/74/80 — the cliff was indeed 56); foo.bar matches at every length.

Verification

  • packages/core focused suites on the final commit: rule-parser 7/7, mcp-server-rule-collision 28/28, permission-manager 514/514, tool-registry 75/75, workflow-orchestrator 262/262, plus the memory/skill shim suites, mcp-tool, permissionFlow — 1120/1120 pass (independently re-run by the orchestrator on the pushed SHA: 886/886 across the five core files).
  • pnpm typecheck: core ✓, cli ✓. eslint clean on all touched files.
  • The keeps a deny rule effective past the 63-char truncation and keeps a legacy dotted server rule on its own tool when both segments are unsafe rows stay green — the relaxed capability is redirected through the raw identity, not deleted.

Disclosed expectation changes

Two permission-manager.test.ts rows pinned the unprovenanced exact-normalize arm by supplying no alias where production always advertises one for an unsafe raw; they now supply [legacyName] / [dottedName] respectively. The slashed-mismatch assertion in the second row is unchanged (still no-alias false). No expectation was weakened to pass.

Claim corrections

The PR body's "forgery … is closed in b6ae20e9a3" certification was true only for the server-level/wildcard shapes — the exact 3-part shape still evaluated 'allow' until this commit — and the two "crafted name never reaches the check" docblock sentences were false (provider-safe raws over 63 chars also carry hash suffixes). Both are corrected in the body and the code comments now describe the literal-raw-identity mechanism.

Residuals (unchanged, accepted)

  • Provider-safe-spelled rules (mcp__foo_bar) still literally match any server sanitizing under that prefix — the pre-existing accepted residual.
  • Legacy-spelled rules now rely on the alias channel at every evaluation site; all known sites are threaded and the no-alias posture is pinned, but a future call site that forgets it fails open on deny. The design doc's new "Rule matching" section records this.
  • Full Session.test.ts could not run on the worker box (pre-existing OOM, reproduced unmodified); the L1 subset passed 2/2 and CI should run the full file.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

yiliang114 and others added 4 commits September 25, 2026 01:29
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
@yiliang114
yiliang114 dismissed a stale review via 884a20d September 24, 2026 17:55
yiliang114 and others added 2 commits September 25, 2026 02:54
…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 pomelo-nwu 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.

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.

yiliang114 and others added 2 commits September 26, 2026 03:11
…-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
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@pomelo-nwu R4-1 is fixed at a9009261b8, per your ruling:

Please preserve the legacy spelling for restrictive prefix matches with server provenance, and add a regression for deny/ask as well as allow.

matchesMcpPattern's wildcard arm now also reads generateLegacyMcpToolName's reduction of the tool's own vouched raw identity — the spelling a persisted prefix was copied from. Server provenance is established twice: the input is the raw identity resolveRawMcpIdentity already vouched for (an alias whose own normalization IS the registered name), and the reduction must have left the server segment byte-identical, which rejects the middle-truncated reduction of a 24+ character server key (slice(0, 28) + '___' shortens the key and injects the __ a prefix match needs). One production file, packages/core/src/permissions/rule-parser.ts; no exported signature change, no new cross-package channel, nothing admitted from the alias array.

Regression coverage in mcp-server-rule-collision.test.ts (new describe, 9 rows) covers deny, ask and allow end to end plus getToolRegistrationStatus, and pins both directions: a differently-registered foo_bar server still does not match a foo.bar prefix at any of the three matchers and still evaluates default under both a deny and an allow list, so #10199 is intact and no allow rule matches more against a different server; and a middle-truncated reduction still does not reach a shorter server's wildcard, so R2-2 is intact.

Measured in packages/core: npx vitest run src/permissions/ → 14 files, 1109 passed; tsc --noEmit clean; prettier and eslint clean on both changed files. Three mutation checks, each reverted afterwards — reverting the production change reddens exactly the 5 new positive rows (5 failed / 1104 passed); deleting the server-provenance check reddens the truncated-reduction row (1 failed / 1108); replacing the fix with the "reduce both sides through the sanitization" remedy reddens 5 rows including the four pre-existing #10199 pins and the new cross-server row (5 failed / 1104).

Deliberately not fixed in this commit:

  1. The mixed-spelling sub-population — a rule whose tool segment is the sanitized one while the reduction preserves the dot, e.g. prodTool('zendesk.default', 'literature.search') against mcp__zendesk.default__literature_search* and its mirror. No spelling of that tool starts with that pattern, so it needs the segment-wise {server, toolGlob} comparison R4-2 names plus a ruling on the two decomposed matchers at agent-core.ts:1765-1802 and tools/agent/agent.ts:1814-1839. R4-2 stays unresolved with what I established on it — notably that the server-identity half no longer needs a new channel, because rawToolName.split('__')[1] is already authoritative whenever the raw identity vouches.
  2. docs/design/mcp-tool-name-provider-compatibility.md:32 and :36, plus the zh-CN copy, which still describe matching as literal against two spellings. The matching matchesMcpPattern docblock is corrected in this commit; the doc is not, because that is two more files in two languages on top of this round's three-file ceiling, and per the note already on R4-3 the section should be edited once against the final matcher design rather than twice.
  3. The other four open Suggestions (R2-6, R2-12, R3-3, R4-3) — unchanged from the rounds already recorded on each of them.

Also in this push: 3c8afa34c3 merges main (c3a4058a0c), which the gate-freshness check required — main moved eslint.config.js, .prettierignore and .github/workflows/ci.yml at 73aa65a4b4 after this branch last synced. The merge was clean, no conflicts.

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]>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Further Ponytail pass in 7d5192bad16e29e0bcaff92175a6f9bb0d6b301a: 284 net lines removed (76 production, 208 tests), with five existing files changed. The whole PR is 28 files, +2811/-107. Duplicate MCP name matching now shares its existing path, tests reuse existing producer/registry fixtures and equivalent cases, and the ACP gate retains its meaningful no-build assertion.

Also fixed R19 reverse underscore ownership. With both foo/_deploy and foo_/release registered, mcp__foo___* grants the foo_ tool. The foreign foo tool returns to normal approval. The guard stays tied to an actual competing registration: removing foo_ restores the single-claimant grant, while the existing mcp__foo____i* own-tool control remains allowed. A broader ownership/parser redesign stays deferred; the later author comment asks to keep the discussion open for tracking, so this report distinguishes the verified fix from that design proposal.

Verification on the unchanged candidate and delivered source:

  • Core build/typecheck and CLI bundle passed; 776/776 core cases in five files plus 1/1 selected ACP L1 case passed (1113 other ACP cases skipped). The final commit hook passed formatting and lint for the five changed files; all five committed blobs match the tested candidate.
  • 10 actual producer/registry/shared-permission observations passed, including model-visible/App-only ownership, owner removal and the existing lettered-prefix control. Changed-source emit matches the built permission module; the executed CLI entry and permission chunk are bound to the frozen candidate.
  • Two real packaged CLI/tmux/stdio MCP runs passed in default approval with trust:false. Foreign _deploy: Ask, 0 calls before approval, 1 after Allow once. Own release: direct Allow, 1 call. The model is a controlled loopback fixture; this does not verify an external model/provider or App RPC/UI.

These are PNG replays of the original tmux ANSI captures; paired plain-text captures match the rendered text. Images are hosted in yiliang114/img-host and their public bytes were verified.

Foreign tool awaiting confirmation (zero MCP calls):

Foreign _deploy asks before execution

Own-server tool executes directly under its whole-server rule:

Own foo_ release executes directly

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 (MERGEABLE); maintainer review remains required. The reverse entrance is fixed, and the broader structural discussion remains open as requested. No merge performed.

Retained verification attempts

The 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 反向边界:foo/_deploy、foo_/release 并存时,mcp__foo___* 只授权 foo_;外来 foo 走普通确认。移除竞争 foo_ 后恢复单一认领者授权,原有字母前缀控制继续通过。更广的归属/解析器重设计仍延后。

当前候选 core 构建/类型检查、CLI 打包、776 项 core+1 项 ACP 定向测试通过,提交 hook 校验改动文件格式与 lint。真实注册表与共享权限流 10 条观察覆盖 App-only、注册移除及旧控制。两组真实打包 CLI、tmux、stdio MCP 交互通过:外来 _deploy 确认前零调用,Allow once 后一次;自身 release 直接执行一次。上述两张图来自原始 tmux ANSI 帧,渲染文本与对应 plain capture 一致;图片在指定公开图床,已核对公开文件字节。

模型为本地受控 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]>
@yiliang114

yiliang114 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

R23 follow-up verified at 6abd1eb (candidate diff SHA-256 94db481861bf790f7f2dac3d3c5f6e04873f9e30743d57e839895d6c764bde2e). Both Criticals reproduced at 7d5192bad16e; the current repair replaces the grant entrance enumeration with live producer-boundary ownership and reuses the existing matcher for restrictive-only legacy coverage. No new product API, alias publication, dependency or cache.

Scenario Before (7d5192) Current
mcp__foo__*, foo/deploy + foo__bar/deploy Foreign tool auto-approved Own remains allowed; foreign returns to confirmation
mcp__foo__bar__deploy*, foo/bar__deploy_x + foo__bar/deploy_y Both auto-approved Both require confirmation; removing the competitor restores the sole claimant
Shared 56-character registered head from two long URL keys Both auto-approved Both return to confirmation, including App-only competitor
Withheld legacy wildcard deny/ask, plus exact registered allow Allow wins; restriction has no match/citation Deny/ask wins; deny citation restored and deny disables target

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 trust:false. Four flows pass: foreign confirmation with0 calls before and1 after Allow once; own direct1 call; legacy deny with exact rule citation and0 calls (still advertised to the model); legacy ask with Always Allow hidden,0 before and1 after Allow once. These are controlled loopback model runs, not external-provider/model acceptance. App-only coverage is real registry/shared-flow coverage; App RPC/UI, Web Shell, Windows and this repair's Linux UI are unrun.

Foreign foo__bar: confirmation before action, transport calls0

Foreign foo__bar: confirmation before action, transport calls0

Own foo: direct successful MCP execution, transport calls1

Own foo: direct successful MCP execution, transport calls1

Legacy deny: cited scheduler decline, transport calls0 (fixture final text is not execution proof)

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 yiliang114/img-host, at immutable image commits; public PNG bytes and GitHub rendered image URLs were checked.

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 被扣留时仍复用已有匹配器保留覆盖。foo 整服务器规则不再放行 foo__bar;两个不同边界都能解释同一工具前缀时双方回到确认;共享长 URL 截断头不再误授权。旧限制恢复匹配、deny 引用和禁用结果,且优先于竞争的 allow。明确整 server、原始/唯一注册精确名、有意粗前缀和即时增删/App-only 控制保留。

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;新检查和维护者批准仍是独立合入条件,不等待信息性评审任务。

发布收尾期间,本地验证工作树及被忽略的证据目录变为不可用。远端提交和不可变截图仍可访问,但原本的本地运行时/日志现在无法读取;发布回执已保存到工作树之外,未改动或重跑已验证的分支内容。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Delivered 7e489a5b331287dab9cf6015d94d2bb076f5a633: the four R24 findings are addressed with five existing files, +114/-109 (net +5). Production code is net -9, tests +14, bilingual docs net zero. Whole PR: 28 files, +2877/-106 (production18/+595/-84, tests8/+2214/-21, docs2/+68/-1). Existing Suggestions remain deferred; this repair adds no production file, dependency, cache, option or alias channel. The PR remains larger than its original fix; this is not a claim of global minimality.

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: mcp__foo__bar grants exact foo/bar, but does not grant the competing broad foo__bar server. A lossy prefix also checks a competing key extending beyond its head. Restrictive partial-separator prefixes again cover ordinary registered tool segments, while underscore-leading boundary controls and the narrower allow posture remain. Shared historical-spelling tests now evaluate production identity for deny/status and keep alias-only checks explicitly as compatibility characterizations.

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:

Scenario Transport and scheduler result
Bare nested-server deny mcp__foo__bar Exact deny citation; 0 calls.
Single my__svc/_internal, allow mcp__my__svc___* Direct execution; exactly 1 call.
foo.bar/deploy plus longer foo_bar__deploy_service_prod/x, allow mcp__foo_bar__deploy* Confirmation; 0 calls before action, exactly 1 after Allow once.
Ordinary github/deploy, deny mcp__github_* Exact deny citation; 0 calls.

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 yiliang114/img-host, with immutable commit URLs.

Bare nested-server restriction:

Nested whole-server deny with rule citation

Single nested-server allow:

Single nested server executes its own underscore tool

Longer-key competition, before confirmation:

Lossy prefix requires confirmation before any tool call

Partial-separator restriction:

Partial separator deny retains rule citation

Identity: tested base 6abd1eb228d7ed6efd4fdfd08aa184a5e8fc5f02 + candidate diff SHA-256 e46e5ae0ed088e68ab5180ff311b80ea0b55d622dae96e53cbd91c641b87404b; all five committed blobs at 7e489a5b331287dab9cf6015d94d2bb076f5a633 equal the tested source. Packaged CLI SHA-256 ad57ff1c3a9a21ad60c32466a4e8d827330b6075b87781a3d26ab0b0eb93b2b8, actual matcher chunk d88793e4b370e839e89a9dbb1a04dced5fc4b0f98c79240e5d44d5693a3675cb, matcher source 796521169fbaf8ac34a71d41ee0cf859c4b7b7ea503f84ad92cc57d586f15f22. The binary was built before the additive commit; source-to-commit equality and in-memory TypeScript emit-to-rebuilt-core equality were verified. Esbuild lexical equality is not claimed.

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 mime/lite resolution; the prepared tsx loader passed the unchanged built runtime. The four native cases passed on their first run. Native inference uses a controlled localhost OpenAI-compatible fixture; external real models/providers, App RPC/UI and full nested-agent E2E are unrun. Fixture homes and raw captures remain; owned processes/ports are stopped. Prior R23 evidence remains evidence at its named head.

One post-push snapshot at 2026-10-05T10:24:39+00:00 confirms this head, OPEN/MERGEABLE, review CHANGES_REQUESTED, with ten checks running, six queued and two skipped. Fresh CI and maintainer review are separate merge gates; informational review jobs are not awaited.

中文验收说明

四条 R24 反馈已按现有契约修复并正常推送到 7e489a5b331287dab9cf6015d94d2bb076f5a633。本轮五个已有文件 +114/-109,净增5行:生产净减9行、测试净增14行、双语文档净零。整份 PR 仍为28文件、+2877/-106;未新增生产文件、依赖、缓存、选项或 alias 通道,既有 Suggestions 继续后续处理,不声称整份 PR 已绝对最小。

包含 __ 的 bare 整 server 和下划线工具前缀读取真实生产方身份,竞争授权由实时注册表判断。精确注册的 foo/bar 保留已有优先权,同字符串不能扩大授权给 foo__bar。更长 key 同样能参与有损前缀竞争;普通工具的 partial-separator deny/ask 恢复,原始及归约后的前导下划线边界对照仍保留。历史 alias 的 deny/status 测试改为真实生产上下文,不再把 alias-only 结果当线上限制结论。

805项定向测试、构建、core类型检查、打包、lint 和三路集中审查通过;21行同输入前后源码对照及27行真实模块权限流通过。四组实际 CLI/tmux/MCP 一次通过:nested deny 引用规则且0次;nested单一allow直接1次;有损前缀竞争确认前0次、Allow once 后1次;mcp__github_* deny 引用规则且0次。四张公开图片均重放原始 ANSI 帧,全文一致且已目视、公开字节校验,属于 tmux 帧回放截图。构建二进制、实际执行模块与提交源码绑定。

首次 launcher 路径错误、并发覆盖目录冲突和 unbundled MIME 解析错误均保留并纠正,无产品代码为此改动;原生四场景首次运行全部通过。模型为本地受控 fixture;旧 before 只作源码函数复现,未冒充旧原生运行。未验证外部真实模型、App RPC/UI 或完整嵌套 agent。验证自身进程和端口已退出,原始记录与 fixture 留存。推送后一次快照确认该 head,CI仍在运行/排队,可合并但 review 为 CHANGES_REQUESTED;CI与维护者复审仍是独立合入条件。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

chiga0
chiga0 previously approved these changes Oct 5, 2026

@chiga0 chiga0 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.

Tier: Deep — auth/permission system, security-critical. Full contract + cross-file analysis.

What I checked

  1. Security invariant (grant vs. restrict asymmetry): grants use matchesMcpName (narrower) + hasAmbiguousMcpGrant live-registry ambiguity guard; deny/ask use matchesRestrictiveMcpName which 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.

  2. 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.

  3. permissionAliases / disabledToolAliases split: disabledToolAliases (broad — raw + full legacy) feeds isToolDisabled() at registration; permissionAliases (narrow — filters to server-boundary-preserved aliases only via legacyReductionVouchesForServer) feeds grant evaluation. The registration call sites that switched from permissionAliases to disabledToolAliases (tool-registry.ts:332, 374, 462, 1482, 1488) are the correct direction — disable checks should be broader.

  4. getMcpToolIdentities() and App-only tools: Uses Map(this.mcpAppTools) as base then merges DiscoveredMCPTool instances, so app-only registrations count in ambiguity detection even when not in the main tool map. Correct — this is what mcp-grant-ambiguity.test.ts tests directly.

  5. matchesRestrictiveMcpName doesn't rely on toolAliases: It builds [rawName, generateLegacyMcpToolName(rawName)] from identity directly. So even when callers pass narrow permissionAliases (or undefined), deny/ask matching is complete as long as identity is present.

  6. Cross-check vs. existing reviews: Prior qwen-code-ci-bot suggestions are all S-level (no blockers among them). The two deferred findings about mcp-server-rule-collision.test.ts — one about comment precision (line 23), one about permissionsAsk coverage (line 57) — are both resolved at head: permissionsAsk appears throughout the 1534-line file and the comment at line 23 is accurate to the threat model being tested.

  7. Test efficacy: mcp-server-rule-collision.test.ts covers the primary attack surface (cross-server forgery, truncation attacks, separator continuation, producer identity channel isolation). mcp-grant-ambiguity.test.ts covers 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

  • narrowAgentTools in workflow-agent-tools.ts passes toolAliases: undefined when calling matchesToolPattern for deny filtering. This is safe — matchesRestrictiveMcpName builds from the producer identity and handles legacy spellings independently. But callers that provide getMcpIdentity to AgentToolNarrowingInput will 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.

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification, round 4 (real local environment) — PR #12531 @ 7e489a5b33

Verdict: not mergeable as-is. One fail-open family against main is still open, and a small restrictive-only patch (candidate E) closes all of it without changing any grant. I measured it exhaustively this round rather than case by case.

The other two open bot Criticals, R23-1 and R26-1, are real. Neither is a regression against main: in both, the new grant guard misses a case, so main's behaviour stays in place. Tracking them in #13412 is reasonable.

# 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 build and npm 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 main 43a6e1e5e4, no conflicts;
    • cand E: head + the patch in §1.
  • 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 HOME per 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.
  • 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.evaluate for deny and for ask, with the invocation's aliases and mcpIdentity;
    • L1 scheduler isToolEnabled;
    • the subagent disallowedTools predicate that arm actually uses.

    A loss is a row that main restricts and the arm does not. No registry competitor is involved, because restrictive matching never reads the registry.

  • 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)

Fig 1: N8 in the real TUI

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 send session/request_permission.
  • The contract is inconsistent at head. The registered spelling mcp__zybio_db_* blocks (N12), and so do mcp__zybio.db__* (N13) and mcp__zybio.db (D4); only the one-underscore raw form misses. Likewise deny mcp__foo__* blocks foo_/deploy (N15) but not foo_/_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 for zybio.db-style and URL keys. On a trusted server, or in YOLO, the tool then runs with no prompt, while main blocks it.

Fig 2: N2 (R26-2) in the real TUI

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 fallback block at :1494-1516 becomes eight rows:
    • the three original shapes, now expecting a restriction;
    • github/_admin_reset;
    • the raw-key shapes zybio.db, foo:bar and foo.bar;
    • foo_ under mcp__foo__*.
  • Each row checks matchesToolPattern, deny evaluate, isToolEnabled, ask evaluate and matchesAgentToolBlocklist. It also asserts that allow stays default, which keeps the R17-2 grant pin.
  • A new control checks that key foobar is not restricted by mcp__foo_*.
  • Docs: the sentence at :38 of 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__bar asks under mcp__foo__*; two boundary claimants both ask; bare mcp__foo__bar restricts 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.

Fig 3: real-CLI matrix

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_search bridge.
  • The interactive TUI for rows other than N2 and N8.

Recommendation

  1. Fold in candidate E, or an equivalent restrictive-only fix for both families. After that I have no blockers.
  2. 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.
  3. 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 合入当前 main 43a6e1e5e4,无冲突;
    • cand E:head 加上 §1 的补丁。
  • 装置。 与第 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 个臂。
  • 模块级差分(本轮新增)。 取 18 个 server key × 14 个工具名。对每个工具,生成所有在字面上等于它自身原始、注册或 legacy 拼写的精确名,以及这些拼写的每个 <前缀>*,再加上裸 server 写法,共 16,502 个规则×工具组合。每个臂都按生产代码的调用方式调用其构建产物:

    • L4 PermissionManager.evaluate(deny 与 ask 各一次),传入 invocation 的 alias 和 mcpIdentity;
    • L1 调度器的 isToolEnabled;
    • 该臂实际使用的子代理 disallowedTools 判定。

    “丢失”指 main 会限制而该臂不限制的组合。这里不涉及注册表竞争者,因为限制型匹配从不读注册表。

  • 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、deny evaluate、isToolEnabled、ask evaluate 和 matchesAgentToolBlocklist,并断言 allow 仍为 default,即保留 R17-2 的授权钉子。
  • 新增一个对照:mcp__foo_* 不会限制 key foobar。
  • 两份设计文档 :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。

建议

  1. 合入候选 E,或任何对两族都生效、且只作用于限制型规则的等价修复。之后我这边没有阻塞项。
  2. R23-1(文档与代码不一致)和 R26-1(P3/P4)不阻塞合入:可以收窄文档那句话,也可以都放到 Deferred review finding from PR #12531: attribute an MCP permission rule to its server from producer identity #13412 跟踪。
  3. 仍需 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

yiliang114 and others added 2 commits October 6, 2026 15:51
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
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification, round 5 (delta) — PR #12531 @ 0a5e943bf1

Verdict: mergeable from my side. The round-4 blocker is closed and I have no blockers left. I re-ran everything on the real PR head (fresh pnpm install --frozen-lockfile, npm run build, npm run bundle, no local patches). What remains is the human review gate below.

This is a delta over round 4. Anything round 4 measured that is not listed here is unchanged.

What changed since round 4

  • cdb3cb189e is byte-identical to candidate E. I diffed each of the four files against my round-4 cand E worktree.
  • 0a5e943bf1 merges main 43a6e1e5e4, which is the same main my round-4 merge arm was built on. Its tree equals that arm (7e489a5b33 + 43a6e1e5e4) plus candidate E and nothing else: git diff --stat between the two lists only the four files, +55/−13.

Results at 0a5e943bf1

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.

Fig 1: N8 before and after

Fig 2: N2 before and after

Fig 3: round 4 to round 5

Still open, not blocking (unchanged from round 4)

Merge gates

  • pomelo-nwu's CHANGES_REQUESTED from 952e3ef668 (2026-09-25) still stands, so reviewDecision is CHANGES_REQUESTED.
  • chiga0's approval on 7e489a5b33 was dismissed by the new push, so it needs a fresh approval at 0a5e943bf1.

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 合入了 main 43a6e1e5e4,正是第 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 命名代码,所以没有单独构建一个臂。

图 1:N8 修复前后

图 2:N2 修复前后

图 3:第 4 轮到第 5 轮

仍未解决、但不阻塞(与第 4 轮相同)

合入门禁

  • 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

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge October 6, 2026 10:08

@qqqys qqqys 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.

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.

  1. Legacy spelling preserved for restrictive matches. matchesMcpPattern now resolves resolveLegacyMcpSpelling(rawToolName, toolAliases) and pushes it into the spelling set, and builds a separate prefixSpellings list 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. matchesPrefixLiterally tests 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.

  2. Server provenance comes from the producer, not from string splitting. The matcher takes an McpToolIdentity { serverName, serverToolName }, supplied by mcp-tool.ts's get mcpIdentity(), and derives boundaries through mcpSegmentSpellings (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.

  3. Deny/ask regressions exist and pin both directions. mcp-server-rule-collision.test.ts carries 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, and getToolRegistrationStatus disabling 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 registration and does 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.

@yiliang114
yiliang114 dismissed stale reviews from pomelo-nwu and ghost October 6, 2026 10:52

fixed

@wenshao
wenshao added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 9cdb0f3 Oct 6, 2026
188 of 216 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants