Repository navigation
security(mcp): govern manual_mcp as the twelfth bridge, fix double mount (#14586) - #14627
Conversation
… them (#14586) manual_mcp declared its three tools as raw dicts, a fourth source shape mcp_bridge_scan never parsed -- un-excluding it later would have registered a bridge with zero discovered tools, the exact "guard narrower than its own subject" trap. Switched to MCPTool(name=..., ...), the shape every other bridge uses. Also replaces the bare stem check in bridge_files() with an auditable EXCLUDED_BRIDGE_STEMS table (empty now) so a future exclusion needs a reasoned entry, not a second inline check.
) Adds a read-level BRIDGE_DEFAULT_PERMISSIONS entry and three exact TOOL_PERMISSIONS entries (lookup_man_page, search_man_pages, get_doc_index) -- all MCP_READ, since every one is a read-only local man page / cached doc-index lookup via an argument-list subprocess. Adds manual_mcp's floor (3) to PER_BRIDGE_DISCOVERY_FLOOR and raises DISCOVERY_FLOOR to 93 to match the new 107-tool total. Without a TOOL_PERMISSIONS entry these tools would now be denied by #14523's deny-by-default rather than merely unreachable.
…unt (#14586) _BRIDGE_MODULE_REGISTRY had no manual_mcp entry, so discover_bridges() never included it -- its tools could never reach MCPDispatcher._tool_cache even once declared. Added the entry. Also removed the duplicate optional-router registration in mcp_routers.py: manual_mcp was mounted both there (prefix "") and in core_routers.py (prefix "/manual"), the only bridge mounted twice.
…silently (#14586) New static cross-check: every *_mcp router actually mounted in core_routers.py / mcp_routers.py must be either governed (mcp_bridge_scan.bridge_files() finds its source) or named in EXCLUDED_BRIDGE_STEMS with a reason. Also pins EXCLUDED_BRIDGE_STEMS to empty today and asserts any future entry carries a non-empty reason and does not name an already-governed bridge -- the durable fix so the next manual_mcp-shaped carve-out fails a test instead of landing silently.
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Review — APPROVEEvery load-bearing claim re-derived independently rather than taken from the body. The zero-tools discovery is real, and it is the most valuable part of this PR. Traced the three scan regexes by hand against the pre-PR source: It also confirms, by instance, the premise #14523 was filed on: "a tool declared in a source shape Counts, independently derived: 104 across the eleven pre-existing bridges, each matching its
The mount guard is load-bearing, not vacuous. Both regexes resolve to a non-empty 12-stem set. And Double mount: the Nit, for a follow-up rather than here
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…14586) test_discover_bridges_known_names pinned an exact 11-bridge set; manual_mcp is now a real twelfth. A fourth hand-maintained bridge list, alongside PER_BRIDGE_DISCOVERY_FLOOR, _BRIDGE_MODULE_REGISTRY and mcp_bridge_scan's glob+exclusion table -- tracked separately, not consolidated here.
Closes #14586
Thinking Path
api/manual_mcp.pyexposed three MCP tools (lookup_man_page,search_man_pages,get_doc_index) gated only byDepends(get_current_user)— any authenticated user, no permission check. It was excluded from
mcp_bridge_scan.bridge_files()by a bareif p.stem != "manual_mcp"with nocomment, absent from
mcp_registry.py's_BRIDGE_MODULE_REGISTRY, andtherefore unreachable by
required_permission()— meaning the LLM/agentdispatch path (
MCPDispatcher.dispatch()) already denied every call to thesetools as "undeclared", for every role including a plain
user. Also mountedtwice: once as a core router (
core_routers.py, prefix/manual) and againas an "optional" router (
mcp_routers.py, prefix"").Checked what the three tools actually reach:
_query_doc_indexrunssubprocess.run(["man", "-k", query], capture_output=True, text=True, timeout=10)— argument list, noshell=True, explicit timeout — and_lookup_man_pageparses local man pages viaservices/man_page_parser.py,caching results in the
knowledgeRedis database. No filesystem write, nonetwork egress, no code execution beyond that fixed
maninvocation. This isread-only system documentation, confirming the issue's own severity read: an
authorization/governance gap, not an injection one.
Decision: govern it as the twelfth bridge, not keep the exclusion. Two
peer bridges (
knowledge_mcp,redis_mcp) already use the identical"authenticated-user-only HTTP + agent-path-governed" model for read-mostly
tools, so this brings
manual_mcpto parity with that precedent rather thaninventing a fourth authorization shape. A read-level permission
(
Permission.MCP_READ— held by every role exceptreadonly) is the correctgrant, per the issue's own hint.
One discovery mid-fix:
manual_mcp's tool schemas were plain dicts(
{"name": ..., ...}), a fourth source shapemcp_bridge_scan's threeregexes never matched. Un-excluding it without fixing that would have
registered a bridge with zero discovered tools — the "guard narrower than
its own subject" trap this repo has hit before (
sequential_thinking_mcp,redis_mcp). Reshaped the three schema functions to returnMCPTool(name=...)instances, the shape every other bridge already uses, before adding the
TOOL_PERMISSIONSentries.What Changed
autobot-backend/api/manual_mcp.py— tool schemas are nowMCPToolinstances instead of raw dicts; added a
MANIFEST(matching every siblingbridge) so
mcp_registry.discover_bridges()'s module-scan fallback works.autobot_shared/auth/mcp_bridge_scan.py— replaced the barep.stem != "manual_mcp"check with an auditableEXCLUDED_BRIDGE_STEMS: dict(empty today). A future exclusion needs a reasoned entry there, not a second
inline stem check.
autobot_shared/auth/mcp_tool_permissions.py—manual_mcp: MCP_READinBRIDGE_DEFAULT_PERMISSIONS;lookup_man_page,search_man_pages,get_doc_indexeach declaredMCP_READinTOOL_PERMISSIONS. Updated the"eleven governed bridges" prose to twelve where it describes current
behaviour (left dated
#14523-time measurement snapshots alone).autobot-backend/api/mcp_registry.py— addedmanual_mcpto_BRIDGE_MODULE_REGISTRYsodiscover_bridges()includes it and its toolsreach
MCPDispatcher._tool_cache.autobot-backend/initialization/router_registry/mcp_routers.py— removedthe duplicate
manual_mcpentry fromoptional_mcp_configs; it staysmounted once, as a core router.
tools/lint/check_mcp_tool_permission_coverage.py— added"manual_mcp": 3toPER_BRIDGE_DISCOVERY_FLOOR, raisedDISCOVERY_FLOOR90 → 93 for the new 107-tool total.
autobot_shared/auth/mcp_bridge_scan_test.py(new) — the durable guard:statically cross-checks every
*_mcprouter actually mounted incore_routers.py/mcp_routers.pyagainstbridge_files()andEXCLUDED_BRIDGE_STEMS, failing if a future router lands in neither. Alsopins
EXCLUDED_BRIDGE_STEMS == {}and requires any future entry to carry areason and not name an already-governed bridge.
autobot_shared/auth/mcp_tool_permissions_test.py— added a test pinningmanual_mcp's three tools toMCP_READ.autobot-backend/middleware/builtin/permission_enforcement.py,autobot-backend/services/mcp_dispatch.py— updated "eleven governedbridges" → "twelve" in the two present-tense docstring claims about the
current precondition (left historical
#14523-time snapshots as written).Not changed, deliberately: the direct HTTP endpoints in
manual_mcp.pystillgate on
Depends(get_current_user)alone, matchingknowledge_mcpandredis_mcpexactly. #13228 already documents this as one of three parallelauthorization models (HTTP-direct vs. agent-dispatch vs. external MCP/JWT);
unifying HTTP-direct access across all bridges is out of scope for a
twelfth-bridge governance fix and would need its own issue.
Verification
Static, no app boot (regex cross-checks against the real source files, not
imports):
mcp_bridge_scan's three tool-declaration regexes now findmanual_mcp's3 tools (
lookup_man_page,search_man_pages,get_doc_index) — 0 beforethis PR's schema reshape.
manual_mcp: 3, everyPER_BRIDGE_DISCOVERY_FLOORentry matches exactly,0 name collisions, 0 tools missing a
TOOL_PERMISSIONSentry, 0 unexplainedTOOL_PERMISSIONSentries outside_DECLARED_AHEAD_OF_TIME.core_routers.pyresolve as governed;
autobot_mcp_router(a distinct, out-of-scope module —api/autobot_mcp_router.py, not a*_mcp.py/*_mcp/bridge) correctlydoes not false-positive as an ungoverned bridge.
Exploit-driven verification of the actual decision path (
_would_deny/required_permission), per the task's "run the exploit" requirement — astandalone scratch reproduction (not imported from the repo), driving two
non-admin identities (
user,readonly) at all three tools:both
userandreadonlydenied on all 3 tools via the agent/LLM dispatchpath — 6/6 denied. Confirms these tools were unreachable by an LLM agent
call before this PR, not merely under-permissioned.
TOOL_PERMISSIONSentries,MCP_READ):user(holdsMCP_READ) allowed on all 3 tools,readonly(does not hold it) stilldenied on all 3,
adminallowed on all 3 — 9/9 assertions as intended.declaration): the
after/userassertion fails immediately — the guard doesnot fail open.
Confirmed no new
TOOL_PERMISSIONSentries were needed to avoid taking thefeature offline under #14523's deny-by-default for the agent path — the
tools were already denied there pre-PR (undeclared), so #14523 changed
nothing about their agent-path reachability. What this PR adds is the actual
grant: without it they would remain permanently unreachable by an agent, even
though the direct HTTP endpoint (unaffected by this table either way) still
answers any authenticated caller.
Model Used
Claude Opus 5 (1M context)