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:
@@ -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"]
|
||||
Reference in New Issue
Block a user