"""test_readonly.py — CRITICAL: ensure no write/ingest tools are exposed via MCP. This test MUST fail if any write or ingest tool is accidentally registered. It is the primary enforcement point for the read-only invariant of F-14. Read-only tools (allowed): search, ask, wiki_read, wiki_list, discover_leads, provenance_verify, synthesis_browse Write/ingest tools that must NEVER appear: Any tool whose name contains an ingest/write/delete/add/update/remove verb, or any of the known write-mode CLI operations. """ from __future__ import annotations import asyncio import re # --------------------------------------------------------------------------- # Write-verb pattern — any tool matching this is a forbidden write tool. # --------------------------------------------------------------------------- _WRITE_VERB_RE = re.compile( r"(?:^|_)(ingest|write|delete|remove|add|update|upsert|insert|create|sync|compile|export)", re.IGNORECASE, ) # Explicit allowlist — only these names may appear. ALLOWED_TOOLS = frozenset( { "search", "ask", "wiki_read", "wiki_list", "discover_leads", "provenance_verify", "synthesis_browse", } ) def test_no_write_tools_registered() -> None: """CRITICAL: fail if any write-verb tool is registered on the MCP server.""" from codex.mcp_server import mcp tools = asyncio.run(mcp.list_tools()) registered = {t.name for t in tools} violations: list[str] = [] for name in registered: if _WRITE_VERB_RE.search(name): violations.append(name) assert not violations, ( f"Write/ingest tools found in MCP server (FORBIDDEN): {violations}. " "MCP server must expose read-only tools only." ) def test_only_allowed_tools_registered() -> None: """CRITICAL: registered tools must be a subset of the explicit allowlist.""" from codex.mcp_server import mcp tools = asyncio.run(mcp.list_tools()) registered = {t.name for t in tools} disallowed = registered - ALLOWED_TOOLS assert not disallowed, ( f"Tools not on the read-only allowlist: {disallowed}. " "Add them to ALLOWED_TOOLS only after verifying they are read-only." ) def test_all_allowed_tools_present() -> None: """Every tool in the allowlist must be registered (completeness check).""" from codex.mcp_server import mcp tools = asyncio.run(mcp.list_tools()) registered = {t.name for t in tools} missing = ALLOWED_TOOLS - registered assert not missing, f"Expected read-only tools missing from server: {missing}"