diff --git a/Dockerfile b/Dockerfile index 2b43b961..c7c6939e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ AAIS_RUNTIME_DIR=/app/.runtime/aais-data \ + AAIS_WORKSPACE_ROOT=/app \ PATH=/usr/local/bin:$PATH \ PORT=8000 diff --git a/deploy/pilot/Dockerfile.aais b/deploy/pilot/Dockerfile.aais index 310f89e4..03bce894 100644 --- a/deploy/pilot/Dockerfile.aais +++ b/deploy/pilot/Dockerfile.aais @@ -4,6 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ AAIS_RUNTIME_DIR=/app/.runtime/aais-data \ + AAIS_WORKSPACE_ROOT=/app \ PIP_NO_CACHE_DIR=1 WORKDIR /app diff --git a/deploy/platform/Dockerfile b/deploy/platform/Dockerfile index 7365c60e..ed77cbc7 100644 --- a/deploy/platform/Dockerfile +++ b/deploy/platform/Dockerfile @@ -3,6 +3,7 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ + AAIS_WORKSPACE_ROOT=/app \ PIP_NO_CACHE_DIR=1 WORKDIR /app diff --git a/governance/mcp_server_manifest.v1.json b/governance/mcp_server_manifest.v1.json index e5934253..ddd4fc90 100644 --- a/governance/mcp_server_manifest.v1.json +++ b/governance/mcp_server_manifest.v1.json @@ -1,5 +1,33 @@ { "mcp_server_manifest_version": "mcp_server_manifest.v1", "generated_by": "manual", - "servers": {} + "servers": { + "aais-tools-mcp": { + "server_id": "aais-tools-mcp", + "display_name": "AAIS Operator Tools MCP", + "transport": "stdio", + "command": "python", + "args": ["-m", "aais_tools_mcp"], + "cwd": "services/aais-tools-mcp", + "env_keys": [ + "AAIS_WORKSPACE_ROOT", + "AAIS_TOOLS_MCP_ALLOW_WRITES", + "AAIS_JARVIS_TOOLS_MCP", + "AAIS_TOOLS_MCP_CMD", + "AAIS_TOOLS_MCP_TIMEOUT_SEC" + ], + "tools": [ + "read_file", + "write_file", + "apply_patch", + "list_dir", + "search_code", + "run_tests", + "git_status", + "git_diff" + ], + "writes_default": false, + "notes": "Governed workspace R/W + allowlisted tests; Jarvis prefers stdio when AAIS_JARVIS_TOOLS_MCP=1 (fail-open to local adapter). See services/aais-tools-mcp/README.md" + } + } } diff --git a/render.yaml b/render.yaml index e905b0df..6854c284 100644 --- a/render.yaml +++ b/render.yaml @@ -193,6 +193,8 @@ services: value: "8000" - key: AAIS_RUNTIME_DIR value: /app/.runtime/aais-data + - key: AAIS_WORKSPACE_ROOT + value: /app - key: JARVIS_DATA_DIR value: /app/.runtime/aais-data - key: ENVIRONMENT diff --git a/services/aais-tools-mcp/README.md b/services/aais-tools-mcp/README.md new file mode 100644 index 00000000..6e18d524 --- /dev/null +++ b/services/aais-tools-mcp/README.md @@ -0,0 +1,110 @@ +# AAIS Tools MCP + +**Engineering:** `AaisOperatorToolServer` / `AaisOperatorToolCatalog` +**Mythic (docs only):** Operator Workshop Tools + +Governed Model Context Protocol (stdio JSON-RPC) server so Cursor and Jarvis/AAIS can **read, write, search, and run allowlisted tests** inside the Project Infinity workspace — with path sandboxing, write policy gates, and mutation evidence logs. + +Matches the dependency-light pattern used by `services/jarvis-memoryboard/mcp` (stdlib only; no `@modelcontextprotocol` SDK required). + +## Tools + +| Tool | Mode | Notes | +|------|------|--------| +| `read_file` | read | Sandboxed text read | +| `write_file` | write | Needs `AAIS_TOOLS_MCP_ALLOW_WRITES=1` **and** `allow_write=true`; audited | +| `apply_patch` | write | Full replace or unique `old_string`/`new_string`; same write policy | +| `list_dir` | read | Directory listing | +| `search_code` | read | Bounded regex search | +| `run_tests` | allowlisted | `pytest` or `npm_test` only — **not** arbitrary shell | +| `git_status` | read | `git status --short --branch` | +| `git_diff` | read | `git diff` / `--cached` | + +### Hard constraints + +- Paths must be **relative** to the workspace root (`AAIS_WORKSPACE_ROOT`, else repo root). +- Refuses `..`, absolute escapes, `.env*`, credentials/secrets, `.runtime/oauth`, `.ssh`, etc. +- Writes are off by default; every successful mutation appends JSONL under `.runtime/aais-tools-mcp/mutations.jsonl` (gitignored via `.runtime/`). +- No offensive/unrestricted shell. + +## Cursor MCP config + +Add to project `.cursor/mcp.json` or user `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "aais-tools": { + "command": "python", + "args": ["-m", "aais_tools_mcp"], + "cwd": "/ABS/PATH/TO/Project-Infinity/services/aais-tools-mcp", + "env": { + "AAIS_WORKSPACE_ROOT": "/ABS/PATH/TO/Project-Infinity", + "AAIS_TOOLS_MCP_ALLOW_WRITES": "0" + } + } + } +} +``` + +Set `AAIS_TOOLS_MCP_ALLOW_WRITES` to `"1"` only when you intentionally allow agent writes. Each write/patch call must still pass `"allow_write": true`. + +Restart Cursor after saving. Verify with a `list_dir` or `git_status` tool call. + +## Run locally (stdio) + +```bash +cd services/aais-tools-mcp +AAIS_WORKSPACE_ROOT=/ABS/PATH/TO/Project-Infinity python -m aais_tools_mcp +``` + +JSON-RPC line protocol (same as Continuity Ledger MCP): `initialize` → `tools/list` → `tools/call`. + +## How Jarvis / AAIS uses it + +### Env flags (Jarvis) + +| Env | Default | Meaning | +|-----|---------|---------| +| `AAIS_JARVIS_TOOLS_MCP` | off | Set to `1` / `true` / `yes` / `on` so Jarvis prefers **stdio MCP** for operator tools | +| `AAIS_TOOLS_MCP_CMD` | `python3 -m aais_tools_mcp` | Optional spawn command override (shell-split) | +| `AAIS_TOOLS_MCP_TIMEOUT_SEC` | `30` | Stdio handshake / call timeout | +| `AAIS_WORKSPACE_ROOT` | repo root via `src/workspace_root.py` | Sandbox root passed into the MCP child (never `/`) | +| `AAIS_TOOLS_MCP_ALLOW_WRITES` | `0` | Server write gate (still requires `allow_write=true` per call) | + +When `AAIS_JARVIS_TOOLS_MCP` is enabled, Jarvis calls `src/aais_tools_mcp_client.py` (`AaisOperatorToolsStdioClient`). Spawn or protocol failures **fail-open** to the in-process adapter so chat never dies (same posture as the `/proc` walk guard). + +Selection lives in `src/aais_tools_mcp_adapter.py` → `invoke_aais_operator_tool(...)`. Tool turns that name `read_file`, `write_file`, `apply_patch`, `list_dir`, `search_code`, `run_tests`, `git_status`, or `git_diff` go through `JarvisOperator.handle_tool_request` → that helper (one API for both transports). + +1. **Local capability adapter** (default when MCP flag is off): + +```python +from src.aais_tools_mcp_adapter import invoke_aais_operator_tool + +print(invoke_aais_operator_tool("read_file", {"path": "README.md"})) +``` + +2. **Stdio client** (when `AAIS_JARVIS_TOOLS_MCP=1`): + +```python +from src.aais_tools_mcp_client import invoke_aais_operator_tool_stdio + +print(invoke_aais_operator_tool_stdio("git_status", {})) +``` + +3. Existing Jarvis workspace browsing (`WorkspaceTools` / capability workspace lane) remains the primary **browse/search** path for natural-language chat. Structured coding/tool envelopes use the AAIS operator tool names above (MCP or adapter). + +## Tests + +```bash +cd services/aais-tools-mcp +python -m pytest -q +``` + +Coverage includes sandbox denials (traversal, `.env`, secrets) and read/write happy paths under a temp workspace. + +## Related + +- Continuity Ledger MCP: `services/jarvis-memoryboard/mcp` +- MCP plug bridge (observe/assist): `src/mcp_bridge.py` +- Operator Cursor MCP merge: `src/operator_plugin_bootstrap.py` diff --git a/services/aais-tools-mcp/aais_tools_mcp/__init__.py b/services/aais-tools-mcp/aais_tools_mcp/__init__.py new file mode 100644 index 00000000..39790693 --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/__init__.py @@ -0,0 +1,18 @@ +"""AAIS / Jarvis governed workspace tools MCP. + +Mythic: Operator Workshop Tools +Engineering: AaisOperatorToolServer package +""" + +from __future__ import annotations + +__version__ = "0.1.0" + +from aais_tools_mcp.capability_adapter import AaisOperatorToolsCapability +from aais_tools_mcp.tools import AaisOperatorToolCatalog + +__all__ = [ + "AaisOperatorToolCatalog", + "AaisOperatorToolsCapability", + "__version__", +] diff --git a/services/aais-tools-mcp/aais_tools_mcp/__main__.py b/services/aais-tools-mcp/aais_tools_mcp/__main__.py new file mode 100644 index 00000000..b3b95071 --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/__main__.py @@ -0,0 +1,4 @@ +from aais_tools_mcp.server import main + +if __name__ == "__main__": + main() diff --git a/services/aais-tools-mcp/aais_tools_mcp/capability_adapter.py b/services/aais-tools-mcp/aais_tools_mcp/capability_adapter.py new file mode 100644 index 00000000..69d93ce8 --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/capability_adapter.py @@ -0,0 +1,49 @@ +"""Local capability adapter — same tools without an MCP client. + +Mythic: Workshop Direct Plug +Engineering: AaisOperatorToolsCapability + +Inputs: tool name + args (same as MCP tools/call) +Outputs: structured result dict +Constraints: identical sandbox/write policy as the MCP server +Failure modes: unknown tool / sandbox deny → ok=False with reason_code +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from aais_tools_mcp.tools import AaisOperatorToolCatalog + + +class AaisOperatorToolsCapability: + """Thin local adapter so Jarvis/AAIS can invoke tools without stdio MCP.""" + + capability_id = "aais_operator_tools" + display_name = "AAIS Operator Tools" + + def __init__(self, workspace_root: str | Path | None = None) -> None: + self.catalog = AaisOperatorToolCatalog(workspace_root=workspace_root) + + def list_tools(self) -> list[str]: + return self.catalog.list_tool_names() + + def invoke(self, tool_name: str, args: dict[str, Any] | None = None) -> dict[str, Any]: + result = self.catalog.call(tool_name, args) + return { + "capability_id": self.capability_id, + "tool": tool_name, + "result": result, + "transport": "local_adapter", + } + + def snapshot(self) -> dict[str, Any]: + return { + "capability_id": self.capability_id, + "display_name": self.display_name, + "tools": self.list_tools(), + "writes_env": "AAIS_TOOLS_MCP_ALLOW_WRITES", + "workspace_root": str(self.catalog.sandbox.resolve_root()), + "mcp_server": "services/aais-tools-mcp", + } diff --git a/services/aais-tools-mcp/aais_tools_mcp/evidence.py b/services/aais-tools-mcp/aais_tools_mcp/evidence.py new file mode 100644 index 00000000..1cd752aa --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/evidence.py @@ -0,0 +1,55 @@ +"""Mutation evidence / audit log for AAIS operator tools. + +Mythic: Workshop Trace Ledger +Engineering: MutationEvidenceLog + +Inputs: mutation event dict +Outputs: appended JSONL line under .runtime/aais-tools-mcp/ +Constraints: never stores secret file contents; gitignored via .runtime/ +Failure modes: disk error → event still returned to caller with log_error set +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +class MutationEvidenceLog: + """Append-only JSONL audit log for write/patch/command mutations.""" + + def __init__(self, workspace_root: Path) -> None: + self.workspace_root = workspace_root + self.log_dir = workspace_root / ".runtime" / "aais-tools-mcp" + self.log_path = self.log_dir / "mutations.jsonl" + + def record(self, event: dict[str, Any]) -> dict[str, Any]: + payload = { + "ts": _utc_now(), + "server": "aais-tools-mcp", + **event, + } + try: + self.log_dir.mkdir(parents=True, exist_ok=True) + with self.log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + payload["evidence_path"] = str(self.log_path.relative_to(self.workspace_root)) + payload["logged"] = True + except OSError as exc: + payload["logged"] = False + payload["log_error"] = str(exc) + return payload + + +def writes_allowed(*, explicit_allow: bool = False) -> bool: + """Writes require env policy and/or explicit per-call allow flag.""" + env_flag = os.getenv("AAIS_TOOLS_MCP_ALLOW_WRITES", "0").strip().lower() + env_ok = env_flag in {"1", "true", "yes", "on"} + return bool(env_ok and explicit_allow) diff --git a/services/aais-tools-mcp/aais_tools_mcp/sandbox.py b/services/aais-tools-mcp/aais_tools_mcp/sandbox.py new file mode 100644 index 00000000..67c142f4 --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/sandbox.py @@ -0,0 +1,138 @@ +"""Workspace path sandbox for AAIS operator tools. + +Mythic: Operator Workshop Boundary +Engineering: WorkspacePathSandbox + +Inputs: relative path string, workspace root Path +Outputs: resolved Path inside root, or PolicyDenial +Constraints: no `..` escape, no absolute root escape, deny secrets/.env/oauth +Failure modes: escape or deny-list match → raise WorkspaceSandboxError with reason_code +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path + +WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" + +# Basename / relative path fragments that must never be read or written. +DENIED_BASENAME_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^\.env($|\.)", re.IGNORECASE), + re.compile(r"^credentials(\.|$)", re.IGNORECASE), + re.compile(r".*secret.*", re.IGNORECASE), + re.compile(r".*\.pem$", re.IGNORECASE), + re.compile(r".*\.key$", re.IGNORECASE), + re.compile(r"^id_rsa", re.IGNORECASE), + re.compile(r"^id_ed25519", re.IGNORECASE), +) + +DENIED_PATH_FRAGMENTS: tuple[str, ...] = ( + ".runtime/oauth", + ".runtime/secrets", + "secrets/", + "/secrets/", + ".aws/", + ".ssh/", + "node_modules/", + ".git/objects/", +) + + +@dataclass(frozen=True) +class WorkspaceSandboxError(Exception): + """Path refused by sandbox policy.""" + + reason_code: str + message: str + + def __str__(self) -> str: + return f"{self.reason_code}: {self.message}" + + +class WorkspacePathSandbox: + """Confine all file ops to an allowed workspace root.""" + + def __init__(self, workspace_root: str | Path | None = None) -> None: + self._explicit = Path(workspace_root) if workspace_root is not None else None + + def resolve_root(self) -> Path: + configured = os.getenv(WORKSPACE_ROOT_ENV) + if configured: + root = Path(configured).expanduser().resolve() + elif self._explicit is not None: + root = self._explicit.expanduser().resolve() + else: + # services/aais-tools-mcp/aais_tools_mcp/sandbox.py → repo root + root = Path(__file__).resolve().parents[3] + if not root.is_dir(): + raise WorkspaceSandboxError( + "WORKSPACE_ROOT_MISSING", + f"Workspace root is not a directory: {root}", + ) + if root.resolve() == Path(root.resolve().anchor): + raise WorkspaceSandboxError( + "WORKSPACE_ROOT_INVALID", + "Filesystem root is not an allowed workspace", + ) + return root + + def resolve_path(self, relative_path: str, *, for_write: bool = False) -> Path: + raw = str(relative_path or "").strip() + if not raw: + raise WorkspaceSandboxError("PATH_REQUIRED", "Path is required") + if Path(raw).is_absolute(): + raise WorkspaceSandboxError( + "PATH_ABSOLUTE_DENIED", + "Absolute paths are not allowed; use a path relative to the workspace root", + ) + parts = Path(raw).parts + if any(part == ".." for part in parts): + raise WorkspaceSandboxError( + "PATH_TRAVERSAL_DENIED", + "Path traversal ('..') is not allowed", + ) + root = self.resolve_root() + candidate = (root / raw).resolve() + try: + relative = candidate.relative_to(root) + except ValueError as exc: + raise WorkspaceSandboxError( + "PATH_ESCAPE_DENIED", + "Path must stay inside the workspace root", + ) from exc + + rel_posix = relative.as_posix() + self._assert_not_denied(rel_posix, candidate.name) + if for_write: + # Evidence log dir is writable by the server itself, not by tool clients. + if rel_posix.startswith(".runtime/aais-tools-mcp/"): + raise WorkspaceSandboxError( + "PATH_EVIDENCE_RESERVED", + "Evidence log paths are reserved for the tool server", + ) + return candidate + + def _assert_not_denied(self, rel_posix: str, basename: str) -> None: + lowered = rel_posix.lower() + for fragment in DENIED_PATH_FRAGMENTS: + if fragment.lower() in lowered: + raise WorkspaceSandboxError( + "PATH_SECRET_DENIED", + f"Access denied for protected path fragment: {fragment}", + ) + for pattern in DENIED_BASENAME_PATTERNS: + if pattern.search(basename): + raise WorkspaceSandboxError( + "PATH_SECRET_DENIED", + f"Access denied for protected file name: {basename}", + ) + # Also match any path component (e.g. nested .env) + for part in Path(rel_posix).parts: + if pattern.search(part): + raise WorkspaceSandboxError( + "PATH_SECRET_DENIED", + f"Access denied for protected path component: {part}", + ) diff --git a/services/aais-tools-mcp/aais_tools_mcp/server.py b/services/aais-tools-mcp/aais_tools_mcp/server.py new file mode 100644 index 00000000..681a6a5e --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/server.py @@ -0,0 +1,223 @@ +"""JSON-RPC stdio MCP server for AAIS / Jarvis workspace tools. + +Mythic: Operator Workshop MCP Gate +Engineering: AaisOperatorToolServer + +Inputs: MCP initialize / tools/list / tools/call over stdin (JSON-RPC lines) +Outputs: JSON-RPC responses on stdout +Constraints: sandbox + write policy; no arbitrary shell +Failure modes: unknown method/tool → JSON-RPC or tool-level error +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +# Allow `python -m aais_tools_mcp.server` from the service directory or repo root. +_SERVICE_ROOT = Path(__file__).resolve().parents[1] +_REPO_ROOT = Path(__file__).resolve().parents[3] +for candidate in (_SERVICE_ROOT, _REPO_ROOT): + text = str(candidate) + if text not in sys.path: + sys.path.insert(0, text) + +from aais_tools_mcp.tools import AaisOperatorToolCatalog # noqa: E402 + +SERVER_INFO = {"name": "aais-tools-mcp", "version": "0.1.0"} + +TOOLS: list[dict[str, Any]] = [ + { + "name": "read_file", + "description": "Read a text file inside the AAIS workspace sandbox.", + "inputSchema": { + "type": "object", + "required": ["path"], + "properties": { + "path": {"type": "string", "description": "Path relative to workspace root"}, + "max_chars": {"type": "integer", "minimum": 1, "maximum": 200000}, + }, + }, + }, + { + "name": "write_file", + "description": ( + "Write a text file inside the sandbox. Requires AAIS_TOOLS_MCP_ALLOW_WRITES=1 " + "and allow_write=true. Mutations are audited under .runtime/aais-tools-mcp/." + ), + "inputSchema": { + "type": "object", + "required": ["path", "content", "allow_write"], + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + "allow_write": {"type": "boolean"}, + "create_parents": {"type": "boolean", "default": True}, + }, + }, + }, + { + "name": "apply_patch", + "description": ( + "Patch one sandboxed file via full content replace or unique old_string/new_string. " + "Requires write policy + allow_write=true; audited." + ), + "inputSchema": { + "type": "object", + "required": ["path", "allow_write"], + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + "old_string": {"type": "string"}, + "new_string": {"type": "string"}, + "allow_write": {"type": "boolean"}, + }, + }, + }, + { + "name": "list_dir", + "description": "List entries in a sandboxed directory.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "default": "."}, + "include_hidden": {"type": "boolean", "default": False}, + }, + }, + }, + { + "name": "search_code", + "description": "Regex search across sandboxed text sources (bounded).", + "inputSchema": { + "type": "object", + "required": ["pattern"], + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string", "default": "."}, + "max_matches": {"type": "integer", "minimum": 1, "maximum": 50}, + "case_insensitive": {"type": "boolean", "default": False}, + }, + }, + }, + { + "name": "run_tests", + "description": ( + "Run an allowlisted test command only (pytest or npm_test). " + "Not an arbitrary shell." + ), + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["pytest", "npm_test"], + "default": "pytest", + }, + "extra_args": { + "type": "array", + "items": {"type": "string"}, + "description": "Safe path/flag selectors only", + }, + }, + }, + }, + { + "name": "git_status", + "description": "Read-only git status --short --branch for the workspace.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "git_diff", + "description": "Read-only git diff (optionally staged / path-scoped).", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "staged": {"type": "boolean", "default": False}, + }, + }, + }, +] + + +class AaisOperatorToolServer: + """MCP JSON-RPC request handler backed by AaisOperatorToolCatalog.""" + + def __init__(self, catalog: AaisOperatorToolCatalog | None = None) -> None: + self.catalog = catalog or AaisOperatorToolCatalog() + + def handle_request(self, message: dict[str, Any]) -> dict[str, Any] | None: + method = message.get("method") + request_id = message.get("id") + if method == "notifications/initialized": + return None + if method == "initialize": + result: dict[str, Any] = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + } + elif method == "tools/list": + result = {"tools": TOOLS} + elif method == "tools/call": + params = message.get("params") or {} + name = str(params.get("name") or "") + arguments = params.get("arguments") or {} + try: + payload = self.catalog.call(name, arguments if isinstance(arguments, dict) else {}) + result = { + "content": [ + { + "type": "text", + "text": json.dumps(payload, indent=2, sort_keys=True), + } + ], + "isError": not bool(payload.get("ok", True)), + } + except Exception as exc: # noqa: BLE001 — surface as MCP tool error + result = { + "content": [{"type": "text", "text": str(exc)}], + "isError": True, + } + else: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def handle_request(message: dict[str, Any], server: AaisOperatorToolServer | None = None) -> dict[str, Any] | None: + """Module-level entry used by tests (matches memoryboard MCP shape).""" + return (server or AaisOperatorToolServer()).handle_request(message) + + +def call_tool(name: str, arguments: dict[str, Any] | None = None, catalog: AaisOperatorToolCatalog | None = None) -> Any: + return (catalog or AaisOperatorToolCatalog()).call(name, arguments) + + +def main() -> None: + server = AaisOperatorToolServer() + for line in sys.stdin: + try: + response = server.handle_request(json.loads(line)) + if response is not None: + print(json.dumps(response), flush=True) + except json.JSONDecodeError: + print( + json.dumps( + { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": "Parse error"}, + } + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/services/aais-tools-mcp/aais_tools_mcp/tools.py b/services/aais-tools-mcp/aais_tools_mcp/tools.py new file mode 100644 index 00000000..07c2aade --- /dev/null +++ b/services/aais-tools-mcp/aais_tools_mcp/tools.py @@ -0,0 +1,585 @@ +"""Governed workspace tool implementations for AAIS / Jarvis. + +Mythic: Operator Workshop Toolkit +Engineering: AaisOperatorToolCatalog (+ per-tool classes) + +Inputs: tool name + JSON-compatible args +Outputs: result dict with ok/error and reason_code on failure +Constraints: sandboxed paths; writes gated; commands allowlisted only +Failure modes: sandbox deny, policy deny, OS errors → structured error dict +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any, Callable + +from aais_tools_mcp.evidence import MutationEvidenceLog, writes_allowed +from aais_tools_mcp.sandbox import WorkspacePathSandbox, WorkspaceSandboxError + +MAX_READ_CHARS = 200_000 +MAX_LIST_ENTRIES = 500 +MAX_SEARCH_MATCHES = 50 +MAX_COMMAND_OUTPUT = 40_000 +MAX_DIFF_CHARS = 80_000 + +IGNORED_DIR_NAMES = frozenset( + { + ".git", + ".hg", + ".svn", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".tox", + "dist", + "build", + ".runtime", + "proc", + "sys", + "dev", + "map_files", + } +) + +ALLOWLISTED_COMMANDS: dict[str, list[str]] = { + "pytest": [sys.executable, "-m", "pytest", "-q"], + "npm_test": ["npm", "test", "--", "--watchAll=false"], +} + + +class WorkspaceFileReadTool: + """Read a sandboxed workspace text file.""" + + name = "read_file" + + def __init__(self, sandbox: WorkspacePathSandbox) -> None: + self.sandbox = sandbox + + def run(self, path: str, max_chars: int = MAX_READ_CHARS) -> dict[str, Any]: + target = self.sandbox.resolve_path(path) + if not target.is_file(): + return {"ok": False, "reason_code": "FILE_NOT_FOUND", "error": f"Not a file: {path}"} + limit = max(1, min(int(max_chars or MAX_READ_CHARS), MAX_READ_CHARS)) + try: + text = target.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return {"ok": False, "reason_code": "READ_FAILED", "error": str(exc)} + truncated = len(text) > limit + root = self.sandbox.resolve_root() + return { + "ok": True, + "path": str(target.relative_to(root)), + "content": text[:limit], + "truncated": truncated, + "bytes": target.stat().st_size, + } + + +class WorkspaceFileWriteTool: + """Write a sandboxed workspace text file when policy allows.""" + + name = "write_file" + + def __init__(self, sandbox: WorkspacePathSandbox, evidence: MutationEvidenceLog) -> None: + self.sandbox = sandbox + self.evidence = evidence + + def run( + self, + path: str, + content: str, + *, + allow_write: bool = False, + create_parents: bool = True, + ) -> dict[str, Any]: + if not writes_allowed(explicit_allow=bool(allow_write)): + return { + "ok": False, + "reason_code": "WRITE_POLICY_DENIED", + "error": ( + "Writes require AAIS_TOOLS_MCP_ALLOW_WRITES=1 and allow_write=true" + ), + } + target = self.sandbox.resolve_path(path, for_write=True) + root = self.sandbox.resolve_root() + try: + if create_parents: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(str(content), encoding="utf-8") + except OSError as exc: + return {"ok": False, "reason_code": "WRITE_FAILED", "error": str(exc)} + event = self.evidence.record( + { + "tool": self.name, + "path": str(target.relative_to(root)), + "bytes_written": len(str(content).encode("utf-8")), + "action": "write", + } + ) + return { + "ok": True, + "path": str(target.relative_to(root)), + "bytes_written": event.get("bytes_written"), + "evidence": event, + } + + +class WorkspaceApplyPatchTool: + """Apply a unified-diff style patch to one file (simple hunk-less replace mode). + + For MVP, accepts either: + - ``content`` full-file replacement, or + - ``old_string`` / ``new_string`` unique substring replace. + """ + + name = "apply_patch" + + def __init__(self, sandbox: WorkspacePathSandbox, evidence: MutationEvidenceLog) -> None: + self.sandbox = sandbox + self.evidence = evidence + + def run( + self, + path: str, + *, + content: str | None = None, + old_string: str | None = None, + new_string: str | None = None, + allow_write: bool = False, + ) -> dict[str, Any]: + if not writes_allowed(explicit_allow=bool(allow_write)): + return { + "ok": False, + "reason_code": "WRITE_POLICY_DENIED", + "error": ( + "Patches require AAIS_TOOLS_MCP_ALLOW_WRITES=1 and allow_write=true" + ), + } + target = self.sandbox.resolve_path(path, for_write=True) + root = self.sandbox.resolve_root() + if not target.is_file(): + return {"ok": False, "reason_code": "FILE_NOT_FOUND", "error": f"Not a file: {path}"} + try: + original = target.read_text(encoding="utf-8", errors="replace") + if content is not None: + updated = str(content) + mode = "replace_file" + else: + if old_string is None or new_string is None: + return { + "ok": False, + "reason_code": "PATCH_ARGS_REQUIRED", + "error": "Provide content, or both old_string and new_string", + } + if old_string not in original: + return { + "ok": False, + "reason_code": "PATCH_OLD_NOT_FOUND", + "error": "old_string not found in file", + } + if original.count(old_string) != 1: + return { + "ok": False, + "reason_code": "PATCH_OLD_NOT_UNIQUE", + "error": "old_string matches multiple locations; refine the patch", + } + updated = original.replace(old_string, new_string, 1) + mode = "substring_replace" + target.write_text(updated, encoding="utf-8") + except OSError as exc: + return {"ok": False, "reason_code": "PATCH_FAILED", "error": str(exc)} + event = self.evidence.record( + { + "tool": self.name, + "path": str(target.relative_to(root)), + "action": mode, + "bytes_written": len(updated.encode("utf-8")), + } + ) + return { + "ok": True, + "path": str(target.relative_to(root)), + "mode": mode, + "evidence": event, + } + + +class WorkspaceListDirTool: + """List a sandboxed directory.""" + + name = "list_dir" + + def __init__(self, sandbox: WorkspacePathSandbox) -> None: + self.sandbox = sandbox + + def run(self, path: str = ".", *, include_hidden: bool = False) -> dict[str, Any]: + target = self.sandbox.resolve_path(path or ".") + if not target.is_dir(): + return {"ok": False, "reason_code": "DIR_NOT_FOUND", "error": f"Not a directory: {path}"} + root = self.sandbox.resolve_root() + entries: list[dict[str, Any]] = [] + try: + children = sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except OSError as exc: + return {"ok": False, "reason_code": "LIST_FAILED", "error": str(exc)} + for child in children: + if not include_hidden and child.name.startswith("."): + continue + if child.name in IGNORED_DIR_NAMES: + continue + try: + kind = "dir" if child.is_dir() else "file" + size = child.stat().st_size if child.is_file() else None + except OSError: + continue + entries.append( + { + "name": child.name, + "path": str(child.relative_to(root)), + "kind": kind, + "size": size, + } + ) + if len(entries) >= MAX_LIST_ENTRIES: + break + return { + "ok": True, + "path": str(target.relative_to(root)), + "entries": entries, + "truncated": len(entries) >= MAX_LIST_ENTRIES, + } + + +class WorkspaceSearchCodeTool: + """Grep-like bounded search under the workspace root.""" + + name = "search_code" + + def __init__(self, sandbox: WorkspacePathSandbox) -> None: + self.sandbox = sandbox + + def run( + self, + pattern: str, + path: str = ".", + *, + max_matches: int = MAX_SEARCH_MATCHES, + case_insensitive: bool = False, + ) -> dict[str, Any]: + cleaned = str(pattern or "").strip() + if not cleaned: + return {"ok": False, "reason_code": "PATTERN_REQUIRED", "error": "pattern is required"} + flags = re.IGNORECASE if case_insensitive else 0 + try: + regex = re.compile(cleaned, flags) + except re.error as exc: + return {"ok": False, "reason_code": "PATTERN_INVALID", "error": str(exc)} + + start = self.sandbox.resolve_path(path or ".") + root = self.sandbox.resolve_root() + if start.is_file(): + files = [start] + elif start.is_dir(): + files = list(self._iter_text_files(start)) + else: + return {"ok": False, "reason_code": "PATH_NOT_FOUND", "error": f"Not found: {path}"} + + matches: list[dict[str, Any]] = [] + limit = max(1, min(int(max_matches or MAX_SEARCH_MATCHES), MAX_SEARCH_MATCHES)) + for file_path in files: + try: + text = file_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for line_no, line in enumerate(text.splitlines(), start=1): + if regex.search(line): + matches.append( + { + "path": str(file_path.relative_to(root)), + "line": line_no, + "text": line[:240], + } + ) + if len(matches) >= limit: + return { + "ok": True, + "pattern": cleaned, + "matches": matches, + "truncated": True, + } + return {"ok": True, "pattern": cleaned, "matches": matches, "truncated": False} + + def _iter_text_files(self, start: Path): + def _onerror(_exc: OSError) -> None: + return None + + for current, dirs, files in os.walk(start, onerror=_onerror): + dirs[:] = [ + d + for d in dirs + if d not in IGNORED_DIR_NAMES and not d.startswith(".") + ] + for name in files: + path = Path(current) / name + if path.suffix.lower() in { + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".json", + ".md", + ".toml", + ".yml", + ".yaml", + ".txt", + ".css", + ".html", + ".sh", + ".rs", + ".go", + } or name.lower().startswith("readme"): + try: + if path.is_file() and path.stat().st_size <= 1_000_000: + yield path + except OSError: + continue + + +class WorkspaceRunTestsTool: + """Run allowlisted test commands only (no arbitrary shell).""" + + name = "run_tests" + + def __init__(self, sandbox: WorkspacePathSandbox, evidence: MutationEvidenceLog) -> None: + self.sandbox = sandbox + self.evidence = evidence + + def run(self, command: str = "pytest", *, extra_args: list[str] | None = None) -> dict[str, Any]: + key = str(command or "pytest").strip().lower().replace("-", "_") + if key not in ALLOWLISTED_COMMANDS: + return { + "ok": False, + "reason_code": "COMMAND_NOT_ALLOWLISTED", + "error": f"Command '{command}' is not allowlisted. Allowed: {sorted(ALLOWLISTED_COMMANDS)}", + } + argv = list(ALLOWLISTED_COMMANDS[key]) + safe_flags = {"-q", "-v", "-x", "--tb=short", "--maxfail=1"} + for arg in list(extra_args or []): + text = str(arg) + if text.startswith("-"): + if text not in safe_flags: + return { + "ok": False, + "reason_code": "COMMAND_ARG_DENIED", + "error": f"Unsafe extra arg refused: {text}", + } + elif not re.fullmatch(r"[A-Za-z0-9_./\\-]+", text): + return { + "ok": False, + "reason_code": "COMMAND_ARG_DENIED", + "error": f"Unsafe extra arg refused: {text}", + } + argv.append(text) + + root = self.sandbox.resolve_root() + try: + completed = subprocess.run( + argv, + cwd=str(root), + capture_output=True, + text=True, + timeout=300, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"ok": False, "reason_code": "COMMAND_FAILED", "error": str(exc)} + + stdout = (completed.stdout or "")[:MAX_COMMAND_OUTPUT] + stderr = (completed.stderr or "")[:MAX_COMMAND_OUTPUT] + event = self.evidence.record( + { + "tool": self.name, + "action": "run_tests", + "command": key, + "argv": argv, + "exit_code": completed.returncode, + } + ) + return { + "ok": completed.returncode == 0, + "reason_code": "OK" if completed.returncode == 0 else "TESTS_FAILED", + "command": key, + "argv": argv, + "exit_code": completed.returncode, + "stdout": stdout, + "stderr": stderr, + "evidence": event, + } + + +class WorkspaceGitStatusTool: + """Read-only git status.""" + + name = "git_status" + + def __init__(self, sandbox: WorkspacePathSandbox) -> None: + self.sandbox = sandbox + + def run(self) -> dict[str, Any]: + root = self.sandbox.resolve_root() + try: + completed = subprocess.run( + ["git", "status", "--short", "--branch"], + cwd=str(root), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"ok": False, "reason_code": "GIT_FAILED", "error": str(exc)} + return { + "ok": completed.returncode == 0, + "exit_code": completed.returncode, + "output": (completed.stdout or "")[:MAX_COMMAND_OUTPUT], + "stderr": (completed.stderr or "")[:MAX_COMMAND_OUTPUT], + } + + +class WorkspaceGitDiffTool: + """Read-only git diff (no apply).""" + + name = "git_diff" + + def __init__(self, sandbox: WorkspacePathSandbox) -> None: + self.sandbox = sandbox + + def run(self, path: str | None = None, *, staged: bool = False) -> dict[str, Any]: + root = self.sandbox.resolve_root() + argv = ["git", "diff"] + if staged: + argv.append("--cached") + if path: + resolved = self.sandbox.resolve_path(path) + argv.extend(["--", str(resolved.relative_to(root))]) + try: + completed = subprocess.run( + argv, + cwd=str(root), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"ok": False, "reason_code": "GIT_FAILED", "error": str(exc)} + output = (completed.stdout or "")[:MAX_DIFF_CHARS] + return { + "ok": completed.returncode == 0, + "exit_code": completed.returncode, + "staged": bool(staged), + "path": path, + "output": output, + "truncated": len(completed.stdout or "") > MAX_DIFF_CHARS, + "stderr": (completed.stderr or "")[:MAX_COMMAND_OUTPUT], + } + + +class AaisOperatorToolCatalog: + """Facade that dispatches named tools with shared sandbox + evidence.""" + + def __init__(self, workspace_root: str | Path | None = None) -> None: + self.sandbox = WorkspacePathSandbox(workspace_root) + root = self.sandbox.resolve_root() + self.evidence = MutationEvidenceLog(root) + self.read_file = WorkspaceFileReadTool(self.sandbox) + self.write_file = WorkspaceFileWriteTool(self.sandbox, self.evidence) + self.apply_patch = WorkspaceApplyPatchTool(self.sandbox, self.evidence) + self.list_dir = WorkspaceListDirTool(self.sandbox) + self.search_code = WorkspaceSearchCodeTool(self.sandbox) + self.run_tests = WorkspaceRunTestsTool(self.sandbox, self.evidence) + self.git_status = WorkspaceGitStatusTool(self.sandbox) + self.git_diff = WorkspaceGitDiffTool(self.sandbox) + self._dispatch: dict[str, Callable[..., dict[str, Any]]] = { + "read_file": self._call_read_file, + "write_file": self._call_write_file, + "apply_patch": self._call_apply_patch, + "list_dir": self._call_list_dir, + "search_code": self._call_search_code, + "run_tests": self._call_run_tests, + "git_status": self._call_git_status, + "git_diff": self._call_git_diff, + } + + def list_tool_names(self) -> list[str]: + return sorted(self._dispatch) + + def call(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + handler = self._dispatch.get(str(name or "")) + if handler is None: + return {"ok": False, "reason_code": "UNKNOWN_TOOL", "error": f"Unknown tool: {name}"} + try: + return handler(dict(arguments or {})) + except WorkspaceSandboxError as exc: + return {"ok": False, "reason_code": exc.reason_code, "error": exc.message} + + def _call_read_file(self, args: dict[str, Any]) -> dict[str, Any]: + return self.read_file.run(str(args.get("path") or ""), max_chars=int(args.get("max_chars") or MAX_READ_CHARS)) + + def _call_write_file(self, args: dict[str, Any]) -> dict[str, Any]: + return self.write_file.run( + str(args.get("path") or ""), + str(args.get("content") if args.get("content") is not None else ""), + allow_write=bool(args.get("allow_write")), + create_parents=bool(args.get("create_parents", True)), + ) + + def _call_apply_patch(self, args: dict[str, Any]) -> dict[str, Any]: + return self.apply_patch.run( + str(args.get("path") or ""), + content=args.get("content"), + old_string=args.get("old_string"), + new_string=args.get("new_string"), + allow_write=bool(args.get("allow_write")), + ) + + def _call_list_dir(self, args: dict[str, Any]) -> dict[str, Any]: + return self.list_dir.run( + str(args.get("path") or "."), + include_hidden=bool(args.get("include_hidden")), + ) + + def _call_search_code(self, args: dict[str, Any]) -> dict[str, Any]: + return self.search_code.run( + str(args.get("pattern") or ""), + str(args.get("path") or "."), + max_matches=int(args.get("max_matches") or MAX_SEARCH_MATCHES), + case_insensitive=bool(args.get("case_insensitive")), + ) + + def _call_run_tests(self, args: dict[str, Any]) -> dict[str, Any]: + extra = args.get("extra_args") + return self.run_tests.run( + str(args.get("command") or "pytest"), + extra_args=list(extra) if isinstance(extra, list) else None, + ) + + def _call_git_status(self, _args: dict[str, Any]) -> dict[str, Any]: + return self.git_status.run() + + def _call_git_diff(self, args: dict[str, Any]) -> dict[str, Any]: + path = args.get("path") + return self.git_diff.run( + str(path) if path else None, + staged=bool(args.get("staged")), + ) diff --git a/services/aais-tools-mcp/mcp.json.example b/services/aais-tools-mcp/mcp.json.example new file mode 100644 index 00000000..2796ac61 --- /dev/null +++ b/services/aais-tools-mcp/mcp.json.example @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "aais-tools": { + "command": "python", + "args": ["-m", "aais_tools_mcp"], + "cwd": "${workspaceFolder}/services/aais-tools-mcp", + "env": { + "AAIS_WORKSPACE_ROOT": "${workspaceFolder}", + "AAIS_TOOLS_MCP_ALLOW_WRITES": "0" + } + }, + "jarvis-continuity-ledger": { + "command": "python", + "args": ["-m", "mcp.server"], + "cwd": "${workspaceFolder}/services/jarvis-memoryboard", + "env": { + "JARVIS_MEMORYBOARD_URL": "http://127.0.0.1:8001" + } + } + } +} diff --git a/services/aais-tools-mcp/pyproject.toml b/services/aais-tools-mcp/pyproject.toml new file mode 100644 index 00000000..7e9212e2 --- /dev/null +++ b/services/aais-tools-mcp/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "aais-tools-mcp" +version = "0.1.0" +description = "Governed MCP server for AAIS / Jarvis workspace read, write, search, and allowlisted tests" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0"] + +[project.scripts] +aais-tools-mcp = "aais_tools_mcp.server:main" + +[tool.setuptools.packages.find] +include = ["aais_tools_mcp*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/services/aais-tools-mcp/tests/test_aais_tools_mcp.py b/services/aais-tools-mcp/tests/test_aais_tools_mcp.py new file mode 100644 index 00000000..b509e25b --- /dev/null +++ b/services/aais-tools-mcp/tests/test_aais_tools_mcp.py @@ -0,0 +1,148 @@ +"""Sandbox and tool happy-path / deny tests for AAIS Tools MCP.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from aais_tools_mcp.capability_adapter import AaisOperatorToolsCapability +from aais_tools_mcp.sandbox import WorkspacePathSandbox, WorkspaceSandboxError +from aais_tools_mcp.server import TOOLS, handle_request +from aais_tools_mcp.tools import AaisOperatorToolCatalog + + +@pytest.fixture() +def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "ws" + root.mkdir() + (root / "src").mkdir() + (root / "src" / "hello.py").write_text("print('hi')\n", encoding="utf-8") + (root / ".env").write_text("SECRET=1\n", encoding="utf-8") + (root / "secrets").mkdir() + (root / "secrets" / "token.txt").write_text("nope\n", encoding="utf-8") + monkeypatch.setenv("AAIS_WORKSPACE_ROOT", str(root)) + monkeypatch.delenv("AAIS_TOOLS_MCP_ALLOW_WRITES", raising=False) + return root + + +def test_sandbox_denies_traversal(workspace: Path) -> None: + sandbox = WorkspacePathSandbox(workspace) + with pytest.raises(WorkspaceSandboxError) as exc: + sandbox.resolve_path("../outside") + assert exc.value.reason_code == "PATH_TRAVERSAL_DENIED" + + +def test_sandbox_denies_absolute(workspace: Path) -> None: + sandbox = WorkspacePathSandbox(workspace) + with pytest.raises(WorkspaceSandboxError) as exc: + sandbox.resolve_path("/etc/passwd") + assert exc.value.reason_code == "PATH_ABSOLUTE_DENIED" + + +def test_sandbox_denies_env_and_secrets(workspace: Path) -> None: + sandbox = WorkspacePathSandbox(workspace) + with pytest.raises(WorkspaceSandboxError) as exc_env: + sandbox.resolve_path(".env") + assert exc_env.value.reason_code == "PATH_SECRET_DENIED" + with pytest.raises(WorkspaceSandboxError) as exc_secret: + sandbox.resolve_path("secrets/token.txt") + assert exc_secret.value.reason_code == "PATH_SECRET_DENIED" + + +def test_read_file_happy_path(workspace: Path) -> None: + catalog = AaisOperatorToolCatalog(workspace) + result = catalog.call("read_file", {"path": "src/hello.py"}) + assert result["ok"] is True + assert "print('hi')" in result["content"] + + +def test_write_denied_without_policy(workspace: Path) -> None: + catalog = AaisOperatorToolCatalog(workspace) + result = catalog.call( + "write_file", + {"path": "src/new.py", "content": "x = 1\n", "allow_write": True}, + ) + assert result["ok"] is False + assert result["reason_code"] == "WRITE_POLICY_DENIED" + + +def test_write_happy_path_with_evidence( + workspace: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AAIS_TOOLS_MCP_ALLOW_WRITES", "1") + catalog = AaisOperatorToolCatalog(workspace) + result = catalog.call( + "write_file", + {"path": "src/written.py", "content": "ok = True\n", "allow_write": True}, + ) + assert result["ok"] is True + assert (workspace / "src" / "written.py").read_text(encoding="utf-8") == "ok = True\n" + evidence = workspace / ".runtime" / "aais-tools-mcp" / "mutations.jsonl" + assert evidence.is_file() + line = evidence.read_text(encoding="utf-8").strip().splitlines()[-1] + event = json.loads(line) + assert event["tool"] == "write_file" + assert event["path"] == "src/written.py" + + +def test_apply_patch_substring(workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AAIS_TOOLS_MCP_ALLOW_WRITES", "1") + catalog = AaisOperatorToolCatalog(workspace) + result = catalog.call( + "apply_patch", + { + "path": "src/hello.py", + "old_string": "print('hi')", + "new_string": "print('hello')", + "allow_write": True, + }, + ) + assert result["ok"] is True + assert "hello" in (workspace / "src" / "hello.py").read_text(encoding="utf-8") + + +def test_list_and_search(workspace: Path) -> None: + catalog = AaisOperatorToolCatalog(workspace) + listed = catalog.call("list_dir", {"path": "src"}) + assert listed["ok"] is True + assert any(e["name"] == "hello.py" for e in listed["entries"]) + found = catalog.call("search_code", {"pattern": "print\\(", "path": "src"}) + assert found["ok"] is True + assert found["matches"] + + +def test_run_tests_rejects_arbitrary_shell(workspace: Path) -> None: + catalog = AaisOperatorToolCatalog(workspace) + result = catalog.call("run_tests", {"command": "bash"}) + assert result["ok"] is False + assert result["reason_code"] == "COMMAND_NOT_ALLOWLISTED" + + +def test_mcp_initialize_and_tools_list() -> None: + initialized = handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + listed = handle_request({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + assert initialized["result"]["serverInfo"]["name"] == "aais-tools-mcp" + names = {tool["name"] for tool in listed["result"]["tools"]} + assert { + "read_file", + "write_file", + "apply_patch", + "list_dir", + "search_code", + "run_tests", + "git_status", + "git_diff", + } <= names + assert listed["result"]["tools"] == TOOLS + + +def test_capability_adapter_local_invoke(workspace: Path) -> None: + cap = AaisOperatorToolsCapability(workspace) + snap = cap.snapshot() + assert snap["capability_id"] == "aais_operator_tools" + invoked = cap.invoke("read_file", {"path": "src/hello.py"}) + assert invoked["transport"] == "local_adapter" + assert invoked["result"]["ok"] is True diff --git a/src/aais_tools_mcp_adapter.py b/src/aais_tools_mcp_adapter.py new file mode 100644 index 00000000..4941b318 --- /dev/null +++ b/src/aais_tools_mcp_adapter.py @@ -0,0 +1,101 @@ +"""Jarvis/AAIS local + stdio transport selector for AAIS Tools MCP. + +Mythic: Workshop Direct Plug (repo entry) +Engineering: import shim + transport preferrer for AaisOperatorToolsCapability + +Inputs: tool name + args; optional workspace_root; env AAIS_JARVIS_TOOLS_MCP +Outputs: invoke dict with transport local_adapter | mcp_stdio (+ optional mcp_fallback) +Constraints: fail-open to in-process adapter when stdio spawn/protocol fails +Failure modes: missing service package → ImportError; MCP errors → adapter fallback when enabled +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +_SERVICE_ROOT = Path(__file__).resolve().parents[1] / "services" / "aais-tools-mcp" +if _SERVICE_ROOT.is_dir() and str(_SERVICE_ROOT) not in sys.path: + sys.path.insert(0, str(_SERVICE_ROOT)) + +from aais_tools_mcp.capability_adapter import AaisOperatorToolsCapability # noqa: E402 + +from src.aais_tools_mcp_client import ( # noqa: E402 + AaisToolsMcpClientError, + invoke_aais_operator_tool_stdio, + jarvis_tools_mcp_enabled, +) + +AAIS_OPERATOR_TOOL_NAMES = frozenset( + { + "read_file", + "write_file", + "apply_patch", + "list_dir", + "search_code", + "run_tests", + "git_status", + "git_diff", + } +) + +__all__ = [ + "AAIS_OPERATOR_TOOL_NAMES", + "AaisOperatorToolsCapability", + "get_aais_operator_tools", + "invoke_aais_operator_tool", + "jarvis_tools_mcp_enabled", +] + + +def get_aais_operator_tools(workspace_root: str | Path | None = None) -> AaisOperatorToolsCapability: + return AaisOperatorToolsCapability(workspace_root=workspace_root) + + +def _invoke_local( + tool_name: str, + args: dict[str, Any] | None = None, + *, + workspace_root: str | Path | None = None, +) -> dict[str, Any]: + return get_aais_operator_tools(workspace_root).invoke(tool_name, args) + + +def invoke_aais_operator_tool( + tool_name: str, + args: dict[str, Any] | None = None, + *, + workspace_root: str | Path | None = None, + prefer_mcp: bool | None = None, +) -> dict[str, Any]: + """Invoke one AAIS operator tool via MCP stdio (when enabled) or local adapter. + + When ``AAIS_JARVIS_TOOLS_MCP=1`` (or ``prefer_mcp=True``), try stdio first. + Spawn/protocol failures fail-open to the in-process adapter so chat never dies. + """ + use_mcp = jarvis_tools_mcp_enabled() if prefer_mcp is None else bool(prefer_mcp) + if use_mcp: + try: + return invoke_aais_operator_tool_stdio( + tool_name, + args, + workspace_root=workspace_root, + ) + except AaisToolsMcpClientError as exc: + fallback = _invoke_local(tool_name, args, workspace_root=workspace_root) + fallback = dict(fallback) + fallback["transport"] = "local_adapter" + fallback["mcp_fallback"] = True + fallback["mcp_error"] = str(exc) + fallback["mcp_reason_code"] = exc.reason_code + return fallback + except Exception as exc: # noqa: BLE001 — fail-open like /proc walk guards + fallback = _invoke_local(tool_name, args, workspace_root=workspace_root) + fallback = dict(fallback) + fallback["transport"] = "local_adapter" + fallback["mcp_fallback"] = True + fallback["mcp_error"] = str(exc) + fallback["mcp_reason_code"] = "MCP_UNEXPECTED_ERROR" + return fallback + return _invoke_local(tool_name, args, workspace_root=workspace_root) diff --git a/src/aais_tools_mcp_client.py b/src/aais_tools_mcp_client.py new file mode 100644 index 00000000..3a034ccc --- /dev/null +++ b/src/aais_tools_mcp_client.py @@ -0,0 +1,390 @@ +"""Stdio JSON-RPC MCP client for AAIS operator tools. + +Mythic: Workshop Remote Plug +Engineering: AaisOperatorToolsStdioClient + +Inputs: tool name + args; optional command/cwd/env/workspace_root +Outputs: structured invoke dict (same shape as local adapter) +Constraints: line-delimited JSON-RPC only; no arbitrary shell beyond configured MCP spawn +Failure modes: spawn/protocol/timeout → raise AaisToolsMcpClientError (caller fail-opens) +""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any + +WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" +WRITES_ENV = "AAIS_TOOLS_MCP_ALLOW_WRITES" +MCP_ENABLE_ENV = "AAIS_JARVIS_TOOLS_MCP" +MCP_CMD_ENV = "AAIS_TOOLS_MCP_CMD" +MCP_TIMEOUT_ENV = "AAIS_TOOLS_MCP_TIMEOUT_SEC" + +DEFAULT_TIMEOUT_SEC = 30.0 +PROTOCOL_VERSION = "2024-11-05" + +__all__ = [ + "AaisOperatorToolsStdioClient", + "AaisToolsMcpClientError", + "DEFAULT_TIMEOUT_SEC", + "MCP_CMD_ENV", + "MCP_ENABLE_ENV", + "MCP_TIMEOUT_ENV", + "invoke_aais_operator_tool_stdio", + "jarvis_tools_mcp_enabled", + "resolve_mcp_command", + "resolve_mcp_cwd", +] + + +class AaisToolsMcpClientError(RuntimeError): + """Stdio MCP spawn or protocol failure.""" + + def __init__(self, message: str, *, reason_code: str = "MCP_CLIENT_ERROR") -> None: + super().__init__(message) + self.reason_code = reason_code + + +def jarvis_tools_mcp_enabled(env: dict[str, str] | None = None) -> bool: + source = env if env is not None else os.environ + raw = str(source.get(MCP_ENABLE_ENV, "") or "").strip().lower() + return raw in {"1", "true", "yes", "on"} + + +def _repo_root() -> Path: + try: + from src.workspace_root import default_repo_root + + return default_repo_root(module_file=Path(__file__)) + except Exception: # noqa: BLE001 — client must stay usable without workspace_root + return Path(__file__).resolve().parents[1] + + +def resolve_mcp_cwd(*, repo_root: Path | None = None) -> Path: + root = repo_root or _repo_root() + service = root / "services" / "aais-tools-mcp" + return service if service.is_dir() else root + + +def resolve_mcp_command(env: dict[str, str] | None = None) -> list[str]: + source = env if env is not None else os.environ + configured = str(source.get(MCP_CMD_ENV, "") or "").strip() + if configured: + return shlex.split(configured) + return [sys.executable or "python3", "-m", "aais_tools_mcp"] + + +def _resolve_workspace_root(explicit: str | Path | None = None) -> Path: + try: + from src.workspace_root import resolve_workspace_root + + return resolve_workspace_root(explicit, module_file=Path(__file__)) + except Exception: # noqa: BLE001 + configured = os.getenv(WORKSPACE_ROOT_ENV) + if configured: + return Path(configured).expanduser().resolve() + if explicit is not None: + return Path(explicit).expanduser().resolve() + return _repo_root() + + +def _timeout_sec(env: dict[str, str] | None = None) -> float: + source = env if env is not None else os.environ + raw = str(source.get(MCP_TIMEOUT_ENV, "") or "").strip() + if not raw: + return DEFAULT_TIMEOUT_SEC + try: + value = float(raw) + except ValueError as exc: + raise AaisToolsMcpClientError( + f"Invalid {MCP_TIMEOUT_ENV}={raw!r}", + reason_code="MCP_TIMEOUT_INVALID", + ) from exc + return max(1.0, value) + + +class AaisOperatorToolsStdioClient: + """Thin line-delimited JSON-RPC client over a spawned aais-tools-mcp process.""" + + def __init__( + self, + *, + workspace_root: str | Path | None = None, + command: list[str] | None = None, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, + timeout_sec: float | None = None, + allow_writes: bool | None = None, + ) -> None: + self.workspace_root = _resolve_workspace_root(workspace_root) + self.command = list(command or resolve_mcp_command(env)) + self.cwd = Path(cwd) if cwd is not None else resolve_mcp_cwd() + self.timeout_sec = float(timeout_sec if timeout_sec is not None else _timeout_sec(env)) + self._extra_env = dict(env or {}) + self._allow_writes = allow_writes + self._proc: subprocess.Popen[str] | None = None + self._next_id = 1 + self._stderr_tail: list[str] = [] + self._lock = threading.Lock() + + def __enter__(self) -> "AaisOperatorToolsStdioClient": + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def start(self) -> None: + if self._proc is not None: + return + child_env = os.environ.copy() + child_env.update(self._extra_env) + child_env[WORKSPACE_ROOT_ENV] = str(self.workspace_root) + if self._allow_writes is not None: + child_env[WRITES_ENV] = "1" if self._allow_writes else "0" + try: + self._proc = subprocess.Popen( + self.command, + cwd=str(self.cwd), + env=child_env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + except OSError as exc: + raise AaisToolsMcpClientError( + f"Failed to spawn MCP server: {exc}", + reason_code="MCP_SPAWN_FAILED", + ) from exc + self._start_stderr_drain() + self._handshake() + + def close(self) -> None: + proc = self._proc + self._proc = None + if proc is None: + return + try: + if proc.stdin and not proc.stdin.closed: + proc.stdin.close() + except OSError: + pass + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: # noqa: BLE001 + try: + proc.kill() + except Exception: # noqa: BLE001 + pass + + def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + self.start() + response = self._request( + "tools/call", + {"name": str(tool_name), "arguments": dict(arguments or {})}, + ) + return self._parse_tool_result(tool_name, response) + + def list_tools(self) -> list[str]: + self.start() + response = self._request("tools/list", {}) + tools = response.get("tools") if isinstance(response, dict) else None + if not isinstance(tools, list): + raise AaisToolsMcpClientError( + "tools/list returned unexpected payload", + reason_code="MCP_PROTOCOL_ERROR", + ) + names: list[str] = [] + for item in tools: + if isinstance(item, dict) and item.get("name"): + names.append(str(item["name"])) + return names + + def _handshake(self) -> None: + self._request( + "initialize", + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "aais-jarvis-tools-mcp-client", "version": "0.1.0"}, + }, + ) + self._notify("notifications/initialized", {}) + + def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + with self._lock: + request_id = self._next_id + self._next_id += 1 + message = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + self._write(message) + response = self._read_matching(request_id) + if "error" in response: + error = response.get("error") or {} + raise AaisToolsMcpClientError( + str(error.get("message") or error), + reason_code="MCP_RPC_ERROR", + ) + result = response.get("result") + if not isinstance(result, dict): + raise AaisToolsMcpClientError( + f"Missing result for {method}", + reason_code="MCP_PROTOCOL_ERROR", + ) + return result + + def _notify(self, method: str, params: dict[str, Any]) -> None: + with self._lock: + self._write({"jsonrpc": "2.0", "method": method, "params": params}) + + def _write(self, message: dict[str, Any]) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise AaisToolsMcpClientError("MCP process is not running", reason_code="MCP_NOT_RUNNING") + try: + proc.stdin.write(json.dumps(message) + "\n") + proc.stdin.flush() + except OSError as exc: + raise AaisToolsMcpClientError( + f"Failed writing to MCP stdin: {exc}", + reason_code="MCP_STDIN_FAILED", + ) from exc + + def _read_matching(self, request_id: int) -> dict[str, Any]: + deadline = time.monotonic() + self.timeout_sec + while time.monotonic() < deadline: + line = self._readline(deadline) + try: + payload = json.loads(line) + except json.JSONDecodeError as exc: + raise AaisToolsMcpClientError( + f"Invalid JSON from MCP: {line[:200]!r}", + reason_code="MCP_PARSE_ERROR", + ) from exc + if not isinstance(payload, dict): + continue + if payload.get("id") == request_id: + return payload + raise AaisToolsMcpClientError( + f"Timed out waiting for MCP response id={request_id}", + reason_code="MCP_TIMEOUT", + ) + + def _readline(self, deadline: float) -> str: + proc = self._proc + if proc is None or proc.stdout is None: + raise AaisToolsMcpClientError("MCP process is not running", reason_code="MCP_NOT_RUNNING") + remaining = max(0.05, deadline - time.monotonic()) + holder: dict[str, Any] = {} + + def _reader() -> None: + try: + holder["line"] = proc.stdout.readline() + except Exception as exc: # noqa: BLE001 + holder["error"] = exc + + thread = threading.Thread(target=_reader, daemon=True) + thread.start() + thread.join(timeout=remaining) + if thread.is_alive(): + raise AaisToolsMcpClientError( + "Timed out reading MCP stdout", + reason_code="MCP_TIMEOUT", + ) + if "error" in holder: + raise AaisToolsMcpClientError( + f"Failed reading MCP stdout: {holder['error']}", + reason_code="MCP_STDOUT_FAILED", + ) + line = holder.get("line") + if not line: + code = proc.poll() + stderr = "\n".join(self._stderr_tail[-20:]).strip() + detail = f"exit={code}" if code is not None else "eof" + if stderr: + detail = f"{detail}; stderr={stderr[:500]}" + raise AaisToolsMcpClientError( + f"MCP stdout closed unexpectedly ({detail})", + reason_code="MCP_PROCESS_EXITED", + ) + return str(line).strip() + + def _start_stderr_drain(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + + def _drain() -> None: + try: + for line in proc.stderr: + text = str(line).rstrip() + if text: + self._stderr_tail.append(text) + if len(self._stderr_tail) > 100: + del self._stderr_tail[:-50] + except Exception: # noqa: BLE001 + return + + threading.Thread(target=_drain, daemon=True).start() + + @staticmethod + def _parse_tool_result(tool_name: str, mcp_result: dict[str, Any]) -> dict[str, Any]: + content = mcp_result.get("content") + text = "" + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = str(block.get("text") or "") + break + payload: dict[str, Any] + if text: + try: + decoded = json.loads(text) + payload = decoded if isinstance(decoded, dict) else {"ok": True, "value": decoded} + except json.JSONDecodeError: + payload = { + "ok": not bool(mcp_result.get("isError")), + "raw_text": text, + } + else: + payload = {"ok": not bool(mcp_result.get("isError")), "raw": mcp_result} + if mcp_result.get("isError") and payload.get("ok", True): + payload = dict(payload) + payload["ok"] = False + payload.setdefault("reason_code", "MCP_TOOL_ERROR") + return { + "capability_id": "aais_operator_tools", + "tool": tool_name, + "result": payload, + "transport": "mcp_stdio", + } + + +def invoke_aais_operator_tool_stdio( + tool_name: str, + args: dict[str, Any] | None = None, + *, + workspace_root: str | Path | None = None, + command: list[str] | None = None, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, + timeout_sec: float | None = None, +) -> dict[str, Any]: + """One-shot stdio invoke: spawn → handshake → tools/call → close.""" + with AaisOperatorToolsStdioClient( + workspace_root=workspace_root, + command=command, + cwd=cwd, + env=env, + timeout_sec=timeout_sec, + ) as client: + return client.call_tool(tool_name, args) diff --git a/src/evolving_workbench.py b/src/evolving_workbench.py index 637bb503..d94e95dd 100644 --- a/src/evolving_workbench.py +++ b/src/evolving_workbench.py @@ -22,6 +22,8 @@ def _wrap_ul_payload(payload: dict) -> dict: from typing import Any from uuid import uuid4 +from src.workspace_root import resolve_workspace_root + PRIMARY_PROJECT_ENV = "AAIS_PRIMARY_PROJECT" APPROVAL_AUDIT_FILENAME = "evolving-approval-audit.json" MAX_SYMBOL_RESULTS = 40 @@ -39,6 +41,13 @@ def _wrap_ul_payload(payload: dict) -> dict: "build", "dist", "_archives", + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", } CODE_EXTENSIONS = { @@ -203,12 +212,10 @@ def __init__(self, workspace_root: str | Path | None = None): self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - configured = os.getenv("AAIS_WORKSPACE_ROOT") - if configured: - return Path(configured).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _preferred_project_name(self) -> str: configured = str(os.getenv(PRIMARY_PROJECT_ENV, "")).strip() @@ -225,7 +232,11 @@ def _preferred_project_name(self) -> str: def _iter_visible_files(self) -> list[str]: root = self._resolve_workspace_root() visible: list[str] = [] - for current_root, dirs, files in os.walk(root): + + def _skip_unreadable(_error: OSError) -> None: + return None + + for current_root, dirs, files in os.walk(root, onerror=_skip_unreadable): dirs[:] = [ directory for directory in dirs @@ -233,7 +244,10 @@ def _iter_visible_files(self) -> list[str]: ] for filename in files: path = Path(current_root) / filename - relative = _normalize_posix_path(path.relative_to(root)) + try: + relative = _normalize_posix_path(path.relative_to(root)) + except (OSError, ValueError): + continue if relative: visible.append(relative) return sorted(visible) diff --git a/src/jarvis_operator.py b/src/jarvis_operator.py index 43fa57ff..905d0d5d 100644 --- a/src/jarvis_operator.py +++ b/src/jarvis_operator.py @@ -43,6 +43,7 @@ from src.patch_execution_preview import PatchExecutionPreview from src.patch_review_store import PatchReviewStore from src.project_infi_law import ProjectInfiLaw +from src.workspace_root import resolve_workspace_root from src.forge_repo_governance import ( build_forge_contractor_payload, build_forge_eval_payload, @@ -267,6 +268,14 @@ def _filter_workspace_context_for_forge( "build", "dist", "_archives", + # Container/host pseudo-fs — walking these hits /proc/*/map_files (EPERM). + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", } TEXT_EXTENSIONS = { @@ -2730,13 +2739,11 @@ def __init__(self, workspace_root: str | Path | None = None): def _resolve_workspace_root(self): """Resolve the workspace root that Jarvis may inspect.""" - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + env_var=WORKSPACE_ROOT_ENV, + module_file=Path(__file__), + ) def _preferred_project_name(self): """Return the project folder that should rank highest in ambiguous searches.""" @@ -2765,7 +2772,11 @@ def _iter_files(self): """Yield candidate files under the workspace root, skipping bulky/system dirs.""" root = self._resolve_workspace_root() - for current_root, dirs, files in os.walk(root): + def _skip_unreadable(_error: OSError) -> None: + # Fail-open: container /proc/*/map_files raises EPERM; keep chat alive. + return None + + for current_root, dirs, files in os.walk(root, onerror=_skip_unreadable): dirs[:] = [ directory for directory in dirs @@ -2779,21 +2790,28 @@ def _iter_files(self): def _is_text_file(self, path: Path): """Check whether a file is safe and useful to preview/search as text.""" - if not path.is_file(): - return False + try: + if not path.is_file(): + return False - normalized = str(path).replace("/", "\\").lower() - if "\\training\\out\\" in normalized or "\\checkpoint-" in normalized: - return False + normalized = str(path).replace("/", "\\").lower() + if "\\training\\out\\" in normalized or "\\checkpoint-" in normalized: + return False + # Defense-in-depth if a walk somehow enters proc/sys/dev. + parts = {part.lower() for part in path.parts} + if parts & {"proc", "sys", "dev", "map_files"}: + return False - if path.stat().st_size > MAX_FILE_BYTES: - return False + if path.stat().st_size > MAX_FILE_BYTES: + return False - suffix = path.suffix.lower() - if suffix in TEXT_EXTENSIONS: - return True + suffix = path.suffix.lower() + if suffix in TEXT_EXTENSIONS: + return True - return path.name.lower().startswith("readme") + return path.name.lower().startswith("readme") + except OSError: + return False def _read_text_file(self, path: Path, max_chars: int | None = MAX_FILE_CHARS): """Read a bounded text preview from disk.""" @@ -2951,7 +2969,10 @@ def search( kind = "path" snippet = f"Exact file match in {relative_path}" - content = self._read_text_file(path, max_chars=None) + try: + content = self._read_text_file(path, max_chars=None) + except (OSError, ValueError): + continue lower_content = content.lower() content_score = _score_text_match(query_tokens, lower_content) @@ -4739,6 +4760,44 @@ def request_memory_merge( source_ids=normalized_source_ids, ).to_dict() + def invoke_operator_tool(self, tool_name: str, args: dict | None = None) -> dict[str, Any]: + """Invoke one AAIS operator tool (MCP stdio when enabled, else local adapter).""" + from src.aais_tools_mcp_adapter import invoke_aais_operator_tool + + root = self.workspace_tools._resolve_workspace_root() + return invoke_aais_operator_tool(tool_name, args, workspace_root=root) + + def _handle_aais_operator_tool_request( + self, + tool_name: str, + args: dict | None = None, + *, + runtime_context: str = "live_runtime", + ) -> dict[str, Any]: + """Wrap AAIS operator tools into a chat-safe tool_result envelope.""" + invocation = self.invoke_operator_tool(tool_name, args) + result = dict(invocation.get("result") or {}) + ok = bool(result.get("ok", True)) + summary = ( + f"AAIS operator tool '{tool_name}' via {invocation.get('transport') or 'unknown'}." + ) + if invocation.get("mcp_fallback"): + summary = f"{summary} MCP failed open to local adapter." + response = summary + if not ok: + response = result.get("error") or result.get("reason_code") or summary + return { + "response": response, + "tool_result": { + "type": "aais_operator_tool", + "tool": tool_name, + "status": "ok" if ok else "error", + "summary": summary, + "runtime_context": runtime_context, + "invocation": invocation, + }, + } + def handle_tool_request( self, tool_name: str, @@ -4749,6 +4808,29 @@ def handle_tool_request( """Execute one structured Jarvis tool request.""" normalized_tool = _normalize_name(tool_name) payload = dict(args or {}) + try: + from src.aais_tools_mcp_adapter import AAIS_OPERATOR_TOOL_NAMES + except Exception: # noqa: BLE001 — keep chat alive if MCP package missing + AAIS_OPERATOR_TOOL_NAMES = frozenset() + if normalized_tool in AAIS_OPERATOR_TOOL_NAMES: + try: + return self._handle_aais_operator_tool_request( + normalized_tool, + payload, + runtime_context=runtime_context, + ) + except Exception as exc: # noqa: BLE001 — fail-open like /proc walk guards + return { + "response": f"AAIS operator tool '{normalized_tool}' unavailable: {exc}", + "tool_result": { + "type": "aais_operator_tool", + "tool": normalized_tool, + "status": "error", + "summary": "Operator tool path failed open.", + "error": str(exc), + "runtime_context": runtime_context, + }, + } return self.capability_bridge.handle_tool_request( normalized_tool, payload, @@ -5167,21 +5249,25 @@ def build_workspace_context( return None preferred_project = self.workspace_tools._preferred_project_name() - search_result = self.workspace_tools.search( - query, - limit=result_limit, - project_name=preferred_project, - prefer_project=preferred_project, - ) - results = search_result.get("results", []) - scoped_project = preferred_project if results else None - if not results: + try: search_result = self.workspace_tools.search( query, limit=result_limit, + project_name=preferred_project, prefer_project=preferred_project, ) results = search_result.get("results", []) + scoped_project = preferred_project if results else None + if not results: + search_result = self.workspace_tools.search( + query, + limit=result_limit, + prefer_project=preferred_project, + ) + results = search_result.get("results", []) + except OSError: + # Fail-open: blocked /proc map reads must not abort a Jarvis reply. + return None if not results: return None @@ -5345,21 +5431,24 @@ def _build_visual_workspace_context( return None preferred_project = self.workspace_tools._preferred_project_name() - search_result = self.workspace_tools.search( - cleaned_query, - limit=result_limit, - project_name=preferred_project, - prefer_project=preferred_project, - ) - results = search_result.get("results", []) - scoped_project = preferred_project if results else None - if not results: + try: search_result = self.workspace_tools.search( cleaned_query, limit=result_limit, + project_name=preferred_project, prefer_project=preferred_project, ) results = search_result.get("results", []) + scoped_project = preferred_project if results else None + if not results: + search_result = self.workspace_tools.search( + cleaned_query, + limit=result_limit, + prefer_project=preferred_project, + ) + results = search_result.get("results", []) + except OSError: + return None if not results: return None @@ -6105,7 +6194,13 @@ def handle_command( if lower.startswith("search workspace for "): query = cleaned[21:].strip() - search_result = self.workspace_tools.search(query, limit=6) + try: + search_result = self.workspace_tools.search(query, limit=6) + except OSError: + return { + "response": "Workspace search is unavailable in this runtime (filesystem introspection blocked).", + "tool_result": {"type": "workspace_search", "query": query, "results": []}, + } lines = [ f"- {result['relative_path']}: {result['snippet']}" for result in search_result["results"] @@ -6122,7 +6217,13 @@ def handle_command( if lower.startswith("find file "): query = cleaned[10:].strip() - search_result = self.workspace_tools.search(query, limit=6) + try: + search_result = self.workspace_tools.search(query, limit=6) + except OSError: + return { + "response": "File search is unavailable in this runtime (filesystem introspection blocked).", + "tool_result": {"type": "workspace_search", "query": query, "results": []}, + } lines = [ f"- {result['relative_path']}: {result['snippet']}" for result in search_result["results"] diff --git a/src/patch_apply_engine.py b/src/patch_apply_engine.py index e8d48702..4763d71d 100644 --- a/src/patch_apply_engine.py +++ b/src/patch_apply_engine.py @@ -3,12 +3,12 @@ from datetime import datetime from src.datetime_compat import UTC import hashlib -import os from pathlib import Path from typing import Any +from src.workspace_root import resolve_workspace_root + -WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" DISALLOWED_AFTER_SNIPPET_MARKERS = ( "review-first patch placeholder", "review first patch placeholder", @@ -34,11 +34,10 @@ def configure_workspace_root(self, workspace_root: str | Path | None) -> None: self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _resolve_target_path(self, relative_path: str) -> tuple[Path, str]: root = self._resolve_workspace_root() diff --git a/src/patch_execution_preview.py b/src/patch_execution_preview.py index f20d643b..a05b670b 100644 --- a/src/patch_execution_preview.py +++ b/src/patch_execution_preview.py @@ -1,12 +1,12 @@ from __future__ import annotations import hashlib -import os from pathlib import Path from typing import Any +from src.workspace_root import resolve_workspace_root + -WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" MAX_PREVIEW_BYTES = 256_000 MAX_EXCERPT_CHARS = 220 @@ -32,11 +32,10 @@ def configure_workspace_root(self, workspace_root: str | Path | None) -> None: self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _resolve_target_path(self, relative_path: str) -> tuple[Path, str]: root = self._resolve_workspace_root() diff --git a/src/workspace_root.py b/src/workspace_root.py new file mode 100644 index 00000000..bebb471d --- /dev/null +++ b/src/workspace_root.py @@ -0,0 +1,92 @@ +"""Safe workspace-root resolution for Jarvis local file tools. + +Mythic: Operator Workshop Boundary +Engineering: WorkspaceRootResolver + +Inputs: optional env override, optional explicit root, calling module path +Outputs: absolute Path confined to the repo (never filesystem root) +Constraints: read-only resolution; never walk `/`, `/proc`, `/sys`, `/dev` +Failure modes: missing/invalid override → fall back to repo root; EPERM on +proc map introspection must not reach chat (walkers skip unsafe dirs) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" + +# Never descend into these when walking a workspace. Container sandboxes often +# raise EPERM on `/proc//map_files/...` (PID 1 is common under Docker). +UNSAFE_WALK_DIR_NAMES = frozenset( + { + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", + } +) + + +def _looks_like_repo_root(candidate: Path) -> bool: + return (candidate / "pyproject.toml").is_file() or ( + (candidate / "src").is_dir() and ((candidate / "app").is_dir() or (candidate / "aais").is_dir()) + ) + + +# Mythic: Locate the workshop floor from a module inside src/. +# Engineering: Walk parents of module_file until repo markers; else return src's parent. +# Invariant: returned Path is absolute (resolved from the start file). +# Boundary: does not read env overrides or reject filesystem root. +def default_repo_root(*, module_file: Path | None = None) -> Path: + """Locate the Project Infinity repo root from a module under ``src/``.""" + start = (module_file or Path(__file__)).resolve() + for parent in start.parents: + if _looks_like_repo_root(parent): + return parent + # Modules directly under src/ → parents[1]; nested src// → parents[2]. + if start.parent.name == "src": + return start.parents[1] + if len(start.parents) > 1 and start.parents[1].name == "src": + return start.parents[2] + return start.parents[1] + + +def _is_filesystem_root(path: Path) -> bool: + resolved = path.resolve() + return resolved == Path(resolved.anchor) + + +# Mythic: Stake the operator workshop boundary so local tools never walk the machine. +# Engineering: Prefer env override, then explicit root, then inferred repo root; reject `/` and non-dirs. +# Invariant: returned Path is never filesystem root (`/`). +# Boundary: read-only resolution; does not create directories or mutate env. +def resolve_workspace_root( + explicit: str | Path | None = None, + *, + env_var: str = WORKSPACE_ROOT_ENV, + module_file: Path | None = None, +) -> Path: + """Resolve the operator-visible workspace root. + + Prefer ``AAIS_WORKSPACE_ROOT`` (or ``env_var``), then an explicit constructor + root, then the repo root inferred from ``module_file``. Filesystem root + (``/``) is rejected because workspace walks would enter ``/proc`` and fail + chat turns with ``[Errno 1] Operation not permitted: '/proc/1/map_files/...'``. + """ + fallback = default_repo_root(module_file=module_file or Path(__file__)) + configured = os.getenv(env_var) + if configured: + candidate = Path(configured).expanduser().resolve() + elif explicit is not None: + candidate = Path(explicit).expanduser().resolve() + else: + candidate = fallback + + if _is_filesystem_root(candidate) or not candidate.is_dir(): + return fallback + return candidate diff --git a/tests/test_aais_tools_mcp_adapter.py b/tests/test_aais_tools_mcp_adapter.py new file mode 100644 index 00000000..009b8b18 --- /dev/null +++ b/tests/test_aais_tools_mcp_adapter.py @@ -0,0 +1,33 @@ +"""Repo-level smoke for the Jarvis AAIS tools MCP adapter shim.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + + +class AaisToolsMcpAdapterTests(unittest.TestCase): + def test_invoke_read_via_src_shim(self) -> None: + import os + from unittest.mock import patch + + from src.aais_tools_mcp_adapter import get_aais_operator_tools, invoke_aais_operator_tool + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sample = root / "note.txt" + sample.write_text("adapter-ok\n", encoding="utf-8") + cap = get_aais_operator_tools(root) + self.assertIn("read_file", cap.list_tools()) + with patch.dict(os.environ, {"AAIS_JARVIS_TOOLS_MCP": "0"}, clear=False): + result = invoke_aais_operator_tool( + "read_file", {"path": "note.txt"}, workspace_root=root + ) + self.assertEqual(result["transport"], "local_adapter") + self.assertTrue(result["result"]["ok"]) + self.assertIn("adapter-ok", result["result"]["content"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_aais_tools_mcp_client.py b/tests/test_aais_tools_mcp_client.py new file mode 100644 index 00000000..912274ee --- /dev/null +++ b/tests/test_aais_tools_mcp_client.py @@ -0,0 +1,243 @@ +"""Tests for AAIS Tools MCP stdio client and Jarvis transport selection.""" + +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from src.aais_tools_mcp_client import ( + AaisOperatorToolsStdioClient, + AaisToolsMcpClientError, + jarvis_tools_mcp_enabled, + resolve_mcp_command, +) + + +def _rpc_result(request_id: int, result: dict) -> str: + return json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n" + + +class _CapturingStdin(io.StringIO): + def __init__(self) -> None: + super().__init__() + self.captured = "" + + def write(self, data: str) -> int: + self.captured += data + return super().write(data) + + def close(self) -> None: + # Keep captured text readable after client.close() closes stdin. + if not self.closed: + super().close() + + +class FakeProc: + def __init__(self, lines: list[str]) -> None: + self.stdin = _CapturingStdin() + self._lines = list(lines) + self.stdout = self + self.stderr = io.StringIO("") + self.returncode = None + + def readline(self) -> str: + if not self._lines: + return "" + return self._lines.pop(0) + + def poll(self): + return self.returncode + + def terminate(self) -> None: + self.returncode = 0 + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def kill(self) -> None: + self.returncode = -9 + + def __iter__(self): + return iter(()) + + +class JarvisToolsMcpFlagTests(unittest.TestCase): + def test_flag_off_by_default(self) -> None: + self.assertFalse(jarvis_tools_mcp_enabled({})) + + def test_flag_on_values(self) -> None: + for value in ("1", "true", "YES", "on"): + self.assertTrue(jarvis_tools_mcp_enabled({"AAIS_JARVIS_TOOLS_MCP": value})) + + def test_resolve_mcp_command_override(self) -> None: + self.assertEqual( + resolve_mcp_command({"AAIS_TOOLS_MCP_CMD": "python -m aais_tools_mcp"}), + ["python", "-m", "aais_tools_mcp"], + ) + + +class AaisToolsMcpClientProtocolTests(unittest.TestCase): + def test_handshake_and_call_tool(self) -> None: + lines = [ + _rpc_result( + 1, + { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "aais-tools-mcp", "version": "0.1.0"}, + }, + ), + _rpc_result( + 2, + { + "content": [ + { + "type": "text", + "text": json.dumps({"ok": True, "content": "hello-from-mcp"}), + } + ], + "isError": False, + }, + ), + ] + fake = FakeProc(lines) + + with patch("src.aais_tools_mcp_client.subprocess.Popen", return_value=fake) as popen: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "note.txt").write_text("x", encoding="utf-8") + client = AaisOperatorToolsStdioClient( + workspace_root=root, + command=["python", "-m", "aais_tools_mcp"], + cwd=root, + timeout_sec=2, + ) + result = client.call_tool("read_file", {"path": "note.txt"}) + client.close() + + self.assertEqual(result["transport"], "mcp_stdio") + self.assertTrue(result["result"]["ok"]) + self.assertIn("hello-from-mcp", result["result"]["content"]) + popen.assert_called_once() + env = popen.call_args.kwargs["env"] + self.assertEqual(env["AAIS_WORKSPACE_ROOT"], str(root.resolve())) + written = fake.stdin.captured + self.assertIn('"method": "initialize"', written) + self.assertIn('"method": "notifications/initialized"', written) + self.assertIn('"method": "tools/call"', written) + + def test_spawn_failure_raises(self) -> None: + with patch( + "src.aais_tools_mcp_client.subprocess.Popen", + side_effect=OSError("boom"), + ): + client = AaisOperatorToolsStdioClient( + workspace_root=Path("."), + command=["python", "-m", "aais_tools_mcp"], + timeout_sec=1, + ) + with self.assertRaises(AaisToolsMcpClientError) as ctx: + client.start() + self.assertEqual(ctx.exception.reason_code, "MCP_SPAWN_FAILED") + + +class AaisToolsMcpAdapterTransportTests(unittest.TestCase): + def test_flag_off_uses_local_adapter(self) -> None: + from src.aais_tools_mcp_adapter import invoke_aais_operator_tool + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "note.txt").write_text("adapter-path\n", encoding="utf-8") + with patch.dict(os.environ, {"AAIS_JARVIS_TOOLS_MCP": "0"}, clear=False): + with patch( + "src.aais_tools_mcp_adapter.invoke_aais_operator_tool_stdio" + ) as stdio: + result = invoke_aais_operator_tool( + "read_file", + {"path": "note.txt"}, + workspace_root=root, + ) + stdio.assert_not_called() + self.assertEqual(result["transport"], "local_adapter") + self.assertTrue(result["result"]["ok"]) + self.assertIn("adapter-path", result["result"]["content"]) + + def test_flag_on_invokes_stdio_client(self) -> None: + from src.aais_tools_mcp_adapter import invoke_aais_operator_tool + + fake = { + "capability_id": "aais_operator_tools", + "tool": "list_dir", + "result": {"ok": True, "entries": []}, + "transport": "mcp_stdio", + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with patch.dict(os.environ, {"AAIS_JARVIS_TOOLS_MCP": "1"}, clear=False): + with patch( + "src.aais_tools_mcp_adapter.invoke_aais_operator_tool_stdio", + return_value=fake, + ) as stdio: + result = invoke_aais_operator_tool( + "list_dir", + {"path": "."}, + workspace_root=root, + ) + stdio.assert_called_once() + self.assertEqual(result["transport"], "mcp_stdio") + + def test_flag_on_fail_open_to_adapter(self) -> None: + from src.aais_tools_mcp_adapter import invoke_aais_operator_tool + from src.aais_tools_mcp_client import AaisToolsMcpClientError + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "note.txt").write_text("fallback-ok\n", encoding="utf-8") + with patch.dict(os.environ, {"AAIS_JARVIS_TOOLS_MCP": "1"}, clear=False): + with patch( + "src.aais_tools_mcp_adapter.invoke_aais_operator_tool_stdio", + side_effect=AaisToolsMcpClientError("spawn failed", reason_code="MCP_SPAWN_FAILED"), + ): + result = invoke_aais_operator_tool( + "read_file", + {"path": "note.txt"}, + workspace_root=root, + ) + self.assertEqual(result["transport"], "local_adapter") + self.assertTrue(result["mcp_fallback"]) + self.assertEqual(result["mcp_reason_code"], "MCP_SPAWN_FAILED") + self.assertTrue(result["result"]["ok"]) + self.assertIn("fallback-ok", result["result"]["content"]) + + +class JarvisOperatorToolRoutingTests(unittest.TestCase): + def test_handle_tool_request_routes_operator_tools(self) -> None: + from src.jarvis_operator import JarvisOperator + + operator = JarvisOperator.__new__(JarvisOperator) + operator.workspace_tools = MagicMock() + operator.workspace_tools._resolve_workspace_root.return_value = Path(".") + operator.capability_bridge = MagicMock() + + fake = { + "capability_id": "aais_operator_tools", + "tool": "git_status", + "result": {"ok": True, "output": "## main"}, + "transport": "local_adapter", + } + with patch.object(operator, "invoke_operator_tool", return_value=fake) as invoke: + out = operator.handle_tool_request("git_status", {}) + + invoke.assert_called_once_with("git_status", {}) + operator.capability_bridge.handle_tool_request.assert_not_called() + self.assertEqual(out["tool_result"]["type"], "aais_operator_tool") + self.assertEqual(out["tool_result"]["status"], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_root.py b/tests/test_workspace_root.py new file mode 100644 index 00000000..c85e62cd --- /dev/null +++ b/tests/test_workspace_root.py @@ -0,0 +1,83 @@ +"""Regression tests for workspace root resolution and proc-map fail-open.""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from src.jarvis_operator import WorkspaceTools +from src.workspace_root import default_repo_root, resolve_workspace_root + + +class TestWorkspaceRootResolver(unittest.TestCase): + def test_default_repo_root_is_project_infinity_not_filesystem_parent(self): + root = default_repo_root(module_file=Path(__file__)) + self.assertTrue((root / "pyproject.toml").is_file()) + self.assertTrue((root / "src" / "jarvis_operator.py").is_file()) + self.assertNotEqual(root, Path(root.anchor)) + + def test_workspace_tools_default_root_stays_inside_repo(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("AAIS_WORKSPACE_ROOT", None) + tools = WorkspaceTools() + root = tools._resolve_workspace_root() + self.assertTrue((root / "src" / "jarvis_operator.py").is_file()) + self.assertNotEqual(str(root), "/") + + def test_filesystem_root_override_falls_back_to_repo(self): + with patch.dict(os.environ, {"AAIS_WORKSPACE_ROOT": "/"}, clear=False): + root = resolve_workspace_root(module_file=Path(__file__)) + self.assertTrue((root / "pyproject.toml").is_file()) + self.assertNotEqual(root, Path("/")) + + +class TestWorkspaceWalkFailOpen(unittest.TestCase): + def test_iter_files_skips_proc_map_files_tree(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "notes.md").write_text("hello jarvis", encoding="utf-8") + map_dir = root / "proc" / "1" / "map_files" + map_dir.mkdir(parents=True) + (map_dir / "5f76c53f6000-5f76c53f7000").write_text("do-not-read", encoding="utf-8") + + tools = WorkspaceTools(workspace_root=root) + found = [path.name for path in tools._iter_files()] + + self.assertIn("notes.md", found) + self.assertNotIn("5f76c53f6000-5f76c53f7000", found) + + def test_is_text_file_fail_open_on_eperm(self): + tools = WorkspaceTools(workspace_root=Path("/tmp")) + blocked = Path("/proc/1/map_files/5f76c53f6000-5f76c53f7000") + error = PermissionError(errno.EPERM, "Operation not permitted", str(blocked)) + with patch.object(Path, "is_file", side_effect=error): + self.assertFalse(tools._is_text_file(blocked)) + + def test_search_survives_eperm_during_walk(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "readme.md").write_text("remember project notes", encoding="utf-8") + tools = WorkspaceTools(workspace_root=root) + real_walk = os.walk + + def walk_with_eperm(top, *args, **kwargs): + onerror = kwargs.get("onerror") + if onerror is not None: + onerror( + PermissionError( + errno.EPERM, + "Operation not permitted", + "/proc/1/map_files/5f76c53f6000-5f76c53f7000", + ) + ) + yield from real_walk(top, *args, **kwargs) + + with patch("src.jarvis_operator.os.walk", side_effect=walk_with_eperm): + result = tools.search("remember project", limit=5) + + self.assertGreaterEqual(len(result["results"]), 1) + self.assertIn("remember project notes", result["results"][0]["snippet"])