Skip to content

security(mcp): required_permission() default-allows at runtime for undeclared tools — #13228 stage 3 #14523

Closed

Description

@mrveiss

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_PERMISSIONS on a known bridge (one of the
    eleven BRIDGE_DEFAULT_PERMISSIONS keys) 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 treats None as "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

  1. Flip to deny-by-default. required_permission() returns None for
    any undeclared tool regardless of bridge (not only an unknown one), and
    PermissionEnforcementExtension denies on None for 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 updating BRIDGE_DEFAULT_PERMISSIONS
    fails — probably the same way test_every_bridge_has_a_default already
    expects.
  2. 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 = 90 in tools/lint/check_mcp_tool_permission_coverage.py
    is a single global floor over all bridges combined. A genuinely new tool
    declared in a syntax mcp_bridge_scan does not recognise contributes zero
    to the count and is invisible to both undeclared_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_PERMISSIONS is 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_TOOLS blocklist 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

Activity

  1. mrveiss commented on Aug 18, 2026

    @mrveiss
    OwnerAuthor

    Delivered by PR #14572, squash-merged as 156866cfb and verified in origin/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_PERMISSIONS 110 unique keys with the 6 ahead-of-time names a strict subset. Per-bridge counts match PER_BRIDGE_DISCOVERY_FLOOR exactly. 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/None bridge has no effect at all; PermissionEnforcementExtension raises, and fail_closed = True converts any hook exception to a deny rather than swallowing it into an allow; _would_deny refuses on undeclared/unknown-role/missing-permission, and the empty-cache branch is not exploitable because find_tool() also returns None.

    DISCOVERY_FLOOR becomes per-bridge, so one bridge's count dropping can no longer hide inside a global sum. The _ADMIN_ONLY_TOOLS substring blocklist was retired only against an explicit set comparison — of its 7 substrings only two match any live tool, and both resolve to MCP_MANAGE, which appears once in ROLE_PERMISSIONS under Role.ADMIN.

    Follow-up #14586: api/manual_mcp.py exposes three MCP tools gated only by get_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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions