Repository navigation
security(mcp): required_permission() default-allows at runtime for undeclared tools — #13228 stage 3 #14523
Description
Problem
autobot_shared.auth.mcp_tool_permissions.required_permission() default-allows
at runtime in both branches it can return, and #14494 (CI-time declaration
coverage) does not touch either one:
- A tool absent from
TOOL_PERMISSIONSon a known bridge (one of the
elevenBRIDGE_DEFAULT_PERMISSIONSkeys) resolves through the bridge's
default — a real, grantable read permission, not a refusal. Pinned
intentionally by
mcp_tool_permissions_test.test_an_undeclared_tool_on_a_known_bridge_inherits_the_least_privilege. - A tool on an unknown bridge resolves to
None.PermissionEnforcementExtension.on_before_tool_execute
currently treatsNoneas "legacy — allow through", with no check at all.
So a tool that reaches production without a CI run having caught it — on a
twelfth bridge, or declared in a source shape
autobot_shared.auth.mcp_bridge_scan does not recognise — is exactly as
under-granted as select was before #14469, just not caught by the guard
#14494 added. #14494's guarantee is complete CI-time coverage across the
eleven governed bridges, backstopped at runtime only by the read-level
default; it is not a runtime denial. (Raised in the #14521 security review,
which otherwise approved that PR.)
This is the runtime half of #13228 stage 3, deliberately deferred rather than
folded into #14494: it changes live MCP authorization behaviour for every
existing "legacy" tool, and deserves its own review.
Two options, either closes this
- Flip to deny-by-default.
required_permission()returnsNonefor
any undeclared tool regardless of bridge (not only an unknown one), and
PermissionEnforcementExtensiondenies onNonefor MCP dispatch instead
of allowing it through as legacy. Requires confirming every currently-live
tool actually has a declaration first (security(mcp): the under-grant guard infers 'mutating' from a hand-written verb list, so a differently-named mutating tool inherits read-level permission silently #14494 makes that true today) and
deciding how a bridge added without updatingBRIDGE_DEFAULT_PERMISSIONS
fails — probably the same waytest_every_bridge_has_a_defaultalready
expects. - Document the default-allow as deliberate, if there's a reason runtime
enforcement should stay permissive independent of the CI guard (e.g. a
migration/rollout concern) — state it in the module docstring and close
this without a behaviour change.
Also worth fixing while in this code
DISCOVERY_FLOOR = 90intools/lint/check_mcp_tool_permission_coverage.py
is a single global floor over all bridges combined. A genuinely new tool
declared in a syntaxmcp_bridge_scandoes not recognise contributes zero
to the count and is invisible to bothundeclared_tools()and
stale_declarations()— it would not trip a 104 → 103 drop against a floor
of 90. A per-bridge floor that fails when any known bridging bridge's own
tool count drops (rather than only the sum) would close this gap.TOOL_PERMISSIONSis keyed by tool name alone, not(bridge, tool). Zero
collisions today across all 104 tools on the eleven bridges, but nothing
stops two different bridges' tools sharing a name and one of them silently
taking the other's permission. Worth stating as a documented constraint (or
guarding against) before a twelfth bridge arrives.MCPDispatcher.dispatch()still carries the original seven-substring
_ADMIN_ONLY_TOOLSblocklist MCP tool calls bypass canonical RBAC — three parallel authorization models, default-allow blocklist #13228 was meant to replace. Not a bypass
today (enforcement runs before dispatch), but it is now duplicate logic and
a cleanup candidate once option 1 above lands.
Related
- MCP tool calls bypass canonical RBAC — three parallel authorization models, default-allow blocklist #13228 — MCP tool governance (this is stage 3, the runtime enforcement)
- security(mcp): the under-grant guard infers 'mutating' from a hand-written verb list, so a differently-named mutating tool inherits read-level permission silently #14494 / security(mcp): declare every tool's permission, retire the verb-list under-grant guard (#14494) #14521 — CI-time declaration coverage; this issue is its
explicitly-deferred runtime half, per the security review on security(mcp): declare every tool's permission, retire the verb-list under-grant guard (#14494) #14521 - security: PermissionEnforcementExtension has no coverage for execute_command, browser tools, or web_search #14469 / fix(security): thread RBAC role into execute_command/browser/web_search dispatch (#14469) #14492 — the
select/select_indexunder-grant this whole chain
traces back to
Activity
docs(mcp): stop overclaiming runtime denial in mcp_tool_permissions d…
security(mcp): required_permission() deny-by-default at runtime (#14523…
mrveiss commentedon Aug 18, 2026
Delivered by PR #14572, squash-merged as
156866cfband verified inorigin/Dev_new_gui.Option 1 (deny-by-default) implemented, and the precondition was re-derived independently before landing rather than taken from the PR: 104 live tools across the 11 governed bridges, 0 undeclared, 0 name collisions;
TOOL_PERMISSIONS110 unique keys with the 6 ahead-of-time names a strict subset. Per-bridge counts matchPER_BRIDGE_DISCOVERY_FLOORexactly. That measurement is what made this safe to land unflagged — any tool absent from the table goes offline, so the number had to be right.Enforced at three layers, each traced rather than read:
required_permission()is now an exact dict lookup, so an unknown/renamed/Nonebridge has no effect at all;PermissionEnforcementExtensionraises, andfail_closed = Trueconverts any hook exception to a deny rather than swallowing it into an allow;_would_denyrefuses on undeclared/unknown-role/missing-permission, and the empty-cache branch is not exploitable becausefind_tool()also returnsNone.DISCOVERY_FLOORbecomes per-bridge, so one bridge's count dropping can no longer hide inside a global sum. The_ADMIN_ONLY_TOOLSsubstring blocklist was retired only against an explicit set comparison — of its 7 substrings only two match any live tool, and both resolve toMCP_MANAGE, which appears once inROLE_PERMISSIONSunderRole.ADMIN.Follow-up #14586:
api/manual_mcp.pyexposes three MCP tools gated only byget_current_user, excluded from the bridge scan by name with no comment and absent from the registry — so it reaches none of these layers, before or after. Not a regression here and unaffected in either direction, but a nameless carve-out from the governance #13228 exists to build. Its subprocess surface is clean (["man", "-k", query], no shell, timeout), so it is an authorization gap over system documentation, not code execution.feat(mcp): standalone chat-tool bridge for external MCP servers, wire…
feat(mcp): standalone chat-tool bridge for external MCP servers, wire…
feat(mcp): standalone chat-tool bridge for external MCP servers, wire…