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
+6
View File
@@ -0,0 +1,6 @@
"""Filesystem/process/network tool adapters split out of ``core/tools.py``
(EPIC R05) and the sandbox execution context they share."""
from .tool_context import CancelFn, ToolContext, ToolError
__all__ = ["CancelFn", "ToolContext", "ToolError"]
+110
View File
@@ -0,0 +1,110 @@
"""Command tools - run_command, install_package (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
two are the ones today's hand-written permission gate in
``core/chat_agent.py`` singles out by literal name
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
tuple with a capability lookup, but the tools themselves are unchanged here.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional
from .tool_context import CancelFn, ToolContext
COMMAND_TIMEOUT = 120 # seconds
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output=None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from cowork_local.core.deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def install_package(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
+55
View File
@@ -0,0 +1,55 @@
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
network access these three carry is exactly what the ``ToolCapability.NETWORK``
tag added in R05-T01/domain/tools/tool_registry.py describes.
"""
from __future__ import annotations
from typing import Any, Dict
from .tool_context import ToolContext
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from cowork_local.core import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from cowork_local.core.link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
+136
View File
@@ -0,0 +1,136 @@
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
dispatch to these via a hand-written if/elif chain over every tool name it
knew about. Splitting the built-in handlers into per-concern modules
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
longer means growing that one function; ``core/tools.py::execute_tool`` now
looks the name up in a dict built from these modules instead.
Behavior is unchanged from before the split - this is a pure move, not a
rewrite. Every existing characterization/contract test that exercises
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
still exercises the exact same code, just imported from here.
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import Any, Dict
from .tool_context import ToolContext
MAX_READ_BYTES = 200_000
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from cowork_local.core import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
+62
View File
@@ -0,0 +1,62 @@
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
Kept as its own leaf module (no dependency on any sibling in this package) so
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
it without creating an import cycle back through ``core/tools.py``, which
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
callers (``core/chat_agent.py``, ``core/code_agent.py``,
``core/task_executors.py``) that do ``from .tools import ToolContext``.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional
CancelFn = Callable[[], bool]
class ToolError(Exception):
pass
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
__all__ = ["CancelFn", "ToolError", "ToolContext"]
+5
View File
@@ -0,0 +1,5 @@
"""MCP server connection lifecycle management (EPIC R05)."""
from .mcp_source_manager import McpToolSourceManager
__all__ = ["McpToolSourceManager"]
+113
View File
@@ -0,0 +1,113 @@
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
of ``state.py::AppContext`` (R05-T05).
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
"start it, cache it, skip it on failure" loop repeated for both the
admin-configured servers AND the built-in MS365 server
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
ever inline because ``AppContext`` is where the config lived.
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
directly testable object — ``AppContext`` becomes a thin caller (one instance
per app, same as it holds one ``RoutingApplicationService``).
Pure Python: no Qt. It DOES touch the network/filesystem via
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
exactly what makes it infrastructure rather than domain.
"""
from __future__ import annotations
import threading
from typing import Dict, List, Optional
from cowork_local.core.mcp_client import McpServerConnection
class McpToolSourceManager:
"""Caches and supervises one :class:`McpServerConnection` per server name.
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
substitute a fake so no real subprocess is spawned (see
``tests/unit/test_mcp_source_manager.py``).
"""
def __init__(self, connection_factory=McpServerConnection) -> None:
self._connections: Dict[str, McpServerConnection] = {}
self._lock = threading.Lock()
self._connection_factory = connection_factory
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Return a live connection for ``name``, starting one if there is
none cached or the cached one's subprocess has died.
Serialized under one lock so two turns racing to build their tool
list at the same moment share one subprocess per server instead of
each spawning their own (the bug this replaces:
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
never raises - when the server fails to start, matching the existing
"one broken server must not block the turn" behavior.
"""
with self._lock:
existing = self._connections.get(name)
if existing is not None and existing.is_alive():
return existing
if existing is not None:
self._connections.pop(name, None)
connection = self._connection_factory(name, command, args or [], env)
try:
connection.start()
except Exception: # noqa: BLE001 - one broken server must not block the turn
return None
self._connections[name] = connection
return connection
def get(self, name: str) -> Optional[McpServerConnection]:
"""The cached connection for ``name``, without starting one."""
return self._connections.get(name)
def is_alive(self, name: str) -> bool:
connection = self._connections.get(name)
return connection is not None and connection.is_alive()
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Force a fresh connection for ``name`` even if the cached one still
looks alive - for a server the caller knows is misbehaving."""
with self._lock:
self._connections.pop(name, None)
return self.ensure(name, command, args, env)
def stop(self, name: str) -> None:
"""Stop and forget one connection - used when a server becomes
unavailable by configuration (e.g. MS365 signed out) rather than by
crashing."""
with self._lock:
connection = self._connections.pop(name, None)
if connection is not None:
try:
connection.stop()
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
pass
def active(self) -> List[McpServerConnection]:
"""Every currently cached connection - what
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
return list(self._connections.values())
def stop_all(self) -> None:
"""Terminate every connection's subprocess - called on app shutdown
so none of them linger as orphan processes."""
with self._lock:
connections = list(self._connections.values())
self._connections.clear()
for connection in connections:
try:
connection.stop()
except Exception: # noqa: BLE001
pass
__all__ = ["McpToolSourceManager"]