feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager

EPIC R05 (Team Hoa) - one security/approval path for every tool call.

R05-T01 domain/tools/{tool_descriptor,tool_registry}.py
  ToolCapability (READ/WRITE/EXECUTE/NETWORK, composable) + ToolDescriptor +
  ToolRegistry, replacing three independently-maintained gating lists
  (core/tools.py::WRITE_TOOLS, code_agent.py's WRITE_TOOLS|MS365_WRITE_TOOLS,
  chat_agent.py's literal ("run_command","install_package") tuple) with one
  capability lookup.

R05-T02 infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py
  core/tools.py's execute_tool if/elif chain split into per-concern modules.
  core/tools.py is now a strangler-fig shim: re-exports ToolContext/ToolError,
  dispatches through a {name: handler} dict built from the split modules.
  core/tools.py: 566 -> 291 lines.

R05-T03 application/conversations/tool_policy_gateway.py
  ToolPolicyGateway.allow(name, gate, payload) - capability-driven ALLOW vs
  ask-the-gate decision. Wired into both chat_agent.py::run_cowork and
  code_agent.py::run_code, replacing their separate hand-rolled checks.
  Verified equivalent to the old hardcoded sets by test.

R05-T04 (behavior change, not just refactor)
  MCP/connector tools (core/mcp_client.py, core/ext_connectors.py) reached
  chat_agent.py via extra_executor(name, args) with NO permission check at
  all. They are now tagged with a conservative default capability
  (WRITE|EXECUTE|NETWORK - no MCP tool self-declares risk) and routed through
  the SAME ToolPolicyGateway as built-ins. When "confirm before running
  commands" is on, MCP/connector calls now prompt like run_command already
  did - a real gap closed, and a user-visible change worth calling out.

R05-T05 infrastructure/mcp/mcp_source_manager.py
  McpToolSourceManager extracts the connection cache/lock/start-or-skip
  lifecycle out of state.py::AppContext (_mcp_connections/_conn_lock) into a
  standalone, directly-testable class. AppContext.build_mcp_tools and
  _ms365_builtin_connection now call ensure()/stop(); _ext_connections
  (unified Connectors) is out of scope for this task and keeps its own lock.

New tests: tests/unit/test_tool_registry_and_policy.py,
test_code_agent_tool_policy.py, test_cowork_extra_tool_policy.py,
test_mcp_source_manager.py (26 new tests).

Suite: 254 passed, 4 pre-existing failures unrelated to R05 (2 EPIC R02
config-security, 2 environment-dependent routing tests - see checklist).
check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:20:57 +09:00
co-authored by Claude Sonnet 5
parent 6d3217e0b5
commit ae4fe72b2e
22 changed files with 1355 additions and 412 deletions
+4 -2
View File
@@ -1,8 +1,10 @@
"""Conversation use case: the lifecycle of one agent turn (EPIC R04)."""
"""Conversation use case: the lifecycle of one agent turn (EPIC R04) and the
tool approval policy every turn's tool calls go through (EPIC R05)."""
from .conversation_application_service import (
ConversationApplicationService,
TurnResult,
)
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
__all__ = ["ConversationApplicationService", "TurnResult"]
__all__ = ["ConversationApplicationService", "TurnResult", "ToolPolicyGateway", "ConfirmGate"]
@@ -0,0 +1,83 @@
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
(R05-T03).
Today "does this tool call need the user's OK first" is answered by a
different hand-written check per engine:
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
"install_package")``, a literal tuple.
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
a set built from two other hand-maintained sets.
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
directly.
Three answers to the same question, and the third one is a real gap: an MCP
tool that deletes files or calls an external API today runs with zero
confirmation even when the user turned "confirm before running commands" on.
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
name list, so registering a tool with the right capability is what gates it -
nothing to remember at each new call site. R05-T04 is what actually registers
MCP/connector tools with a capability; this module only needs the mechanism
to exist.
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
method, supplied by the presentation layer (Settings' "confirm before running
commands" wires it up, or None for auto-run) - this module only decides
WHEN to ask it, never how to render the question.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Protocol
from cowork_local.domain.tools import ToolCapability, ToolRegistry
class ConfirmGate(Protocol):
"""Shape of the existing ``PermissionGate`` both engines already use."""
def request(self, payload: Dict[str, Any]) -> bool: ...
class ToolPolicyGateway:
"""Decides whether a tool call needs approval, for ONE calling surface.
``gated_capabilities`` is what makes this per-surface: Cowork only ever
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
while the Code tab additionally confirms plain file writes (capability
``WRITE``). Passing the wrong set here would silently change which tools
prompt for approval - see the callers in ``core/chat_agent.py`` and
``core/code_agent.py`` for the exact sets that preserve today's behavior.
"""
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
self._registry = registry
self._gated_capabilities = gated_capabilities
def requires_confirmation(self, name: str) -> bool:
"""True when ``name``'s declared capabilities overlap this surface's
gated set. An unregistered tool never requires confirmation through
this path - callers that must fail safe on unknown tools check
``name in registry`` themselves (see R05-T04's MCP wrapping, which
registers every tool it exposes before any call can reach here)."""
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
"""True when the call may proceed.
``gate is None`` preserves each engine's existing "no gate wired -
auto-run" behavior; a tool outside ``gated_capabilities`` is never
asked about, matching read-only tools "never confirm" today.
``payload`` is whatever ``gate.request(...)`` already expects at that
call site (the two engines use slightly different dict shapes) - this
gateway only decides WHETHER to call it, never reshapes the payload.
"""
if gate is None or not self.requires_confirmation(name):
return True
return bool(gate.request(payload))
__all__ = ["ToolPolicyGateway", "ConfirmGate"]