diff --git a/continuityos/remote_mcp_server.py b/continuityos/remote_mcp_server.py index 429e1f89..c326520d 100644 --- a/continuityos/remote_mcp_server.py +++ b/continuityos/remote_mcp_server.py @@ -67,6 +67,12 @@ "Report the ContinuityOS Remote Commander capability boundary: " "enabled state, allowed roots, read limits, and governed execution path." ), + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, "inputSchema": { "type": "object", "additionalProperties": False, @@ -79,6 +85,12 @@ "Read-only host identity and runtime information. Does not return environment " "variables, credentials, or process contents." ), + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, "inputSchema": { "type": "object", "additionalProperties": False, @@ -91,6 +103,12 @@ "List one directory inside an explicitly allowed remote root. Sensitive files " "and credential directories are omitted. Read-only." ), + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, "inputSchema": { "type": "object", "additionalProperties": False, @@ -111,6 +129,12 @@ "Read a bounded UTF-8 text file inside an explicitly allowed remote root. " "Known credential/key paths are denied. Read-only." ), + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, "inputSchema": { "type": "object", "additionalProperties": False, @@ -130,6 +154,21 @@ TOOLS = [*BASE_TOOLS, *REMOTE_TOOLS] +TOOL_PROFILE_FULL = "full" +TOOL_PROFILE_CHATGPT_PRO_READONLY = "chatgpt-pro-readonly" +_REMOTE_TOOL_NAMES = frozenset(tool["name"] for tool in REMOTE_TOOLS) +_TOOL_PROFILES = { + TOOL_PROFILE_FULL: None, + TOOL_PROFILE_CHATGPT_PRO_READONLY: _REMOTE_TOOL_NAMES, +} + + +def _tool_profile(value: str | None) -> str: + profile = (value or TOOL_PROFILE_FULL).strip().lower() + if profile not in _TOOL_PROFILES: + raise ValueError(f"unknown remote tool profile: {profile}") + return profile + def _env_enabled(value: str | None) -> bool: return (value or "").strip().lower() in {"1", "true", "yes", "on"} @@ -317,19 +356,46 @@ def __init__( *, remote_enabled: bool | None = None, remote_roots=None, + tool_profile: str = TOOL_PROFILE_FULL, ): super().__init__(db, policy_path, db_source) self.remote = RemoteSurface( enabled=remote_enabled, roots=remote_roots, ) + self.tool_profile = _tool_profile(tool_profile) + + @property + def tools(self) -> list[dict]: + allowed = _TOOL_PROFILES[self.tool_profile] + if allowed is None: + return list(TOOLS) + return [tool for tool in TOOLS if tool["name"] in allowed] + + def _require_tool_visible(self, name: str) -> None: + allowed = _TOOL_PROFILES[self.tool_profile] + if allowed is not None and name not in allowed: + raise PermissionError( + f"tool hidden by remote tool profile {self.tool_profile}: {name}" + ) def call(self, name, args): + self._require_tool_visible(name) if name == "capability_status": self.turns += 1 - return json.dumps( - self.remote.status(), ensure_ascii=False, indent=2 - ) + status = self.remote.status() + status["tool_profile"] = self.tool_profile + status["advertised_tools"] = [tool["name"] for tool in self.tools] + if self.tool_profile == TOOL_PROFILE_CHATGPT_PRO_READONLY: + status["mode"] = "read_only_host_surface" + status["mutating_execution"] = { + "available": False, + "direct_shell": False, + "reason": "hidden_by_tool_profile", + } + else: + status["mutating_execution"]["available"] = True + return json.dumps(status, ensure_ascii=False, indent=2) if name == "system_info": self.turns += 1 return json.dumps( @@ -372,6 +438,17 @@ def main() -> None: help="Path to one JSON policy, or YAML when PyYAML is installed", ) parser.add_argument("--enable-remote", action="store_true", default=None) + parser.add_argument( + "--tool-profile", + choices=[TOOL_PROFILE_FULL, TOOL_PROFILE_CHATGPT_PRO_READONLY], + default=os.environ.get( + "CONTINUITYOS_REMOTE_TOOL_PROFILE", TOOL_PROFILE_FULL + ), + help=( + "Advertised/callable tool surface. chatgpt-pro-readonly exposes " + "only bounded read-only Remote Commander tools." + ), + ) parser.add_argument( "--remote-root", action="append", @@ -384,6 +461,7 @@ def main() -> None: args.policy, remote_enabled=args.enable_remote, remote_roots=args.remote_root, + tool_profile=args.tool_profile, ) for line in sys.stdin: @@ -418,7 +496,7 @@ def main() -> None: { "jsonrpc": "2.0", "id": message_id, - "result": {"tools": TOOLS}, + "result": {"tools": server.tools}, } ) elif method == "tools/call": diff --git a/docs/REMOTE_COMMANDER_R2_TUNNEL.md b/docs/REMOTE_COMMANDER_R2_TUNNEL.md new file mode 100644 index 00000000..259c7520 --- /dev/null +++ b/docs/REMOTE_COMMANDER_R2_TUNNEL.md @@ -0,0 +1,99 @@ +# ContinuityOS Remote Commander R2 — Secure MCP Tunnel + +## Baseline + +R1 is merged to `master` as +`c184ff7280a6e310cdaa2b6903b45209bfc4e8f0`. +R2 is synchronized on top of that merged baseline. + +R2 does not require ChatGPT Pro to build or validate locally. + +## Purpose + +R2 connects the private stdio Remote Commander MCP to OpenAI Secure MCP Tunnel +without exposing an inbound port. + +Data path: + +`ChatGPT/OpenAI -> OpenAI tunnel control plane <- outbound HTTPS tunnel-client -> local stdio ContinuityOS Remote MCP` + +The tunnel is transport only. ContinuityOS remains the authority boundary. + +Official implementation reference: +`https://github.com/openai/tunnel-client`. + +## Pro read-only profile + +When launched with: + +`--tool-profile chatgpt-pro-readonly` + +the server advertises and accepts only: + +- `capability_status` +- `system_info` +- `fs_list` +- `fs_read` + +The restriction is enforced twice: + +1. `tools/list` omits every base memory/write/execution tool. +2. `tools/call` rejects hidden tool names even if a client attempts a direct call. + +This means `remember`, `upsert`, `forget`, `preflight_exec`, and +`execute_preflight` are unavailable on this profile. + +## Windows launcher + +Use: + +`scripts/windows/ContinuityOS-RemoteTunnel.ps1` + +Safe pre-Pro validation: + +```powershell +.\scripts\windows\ContinuityOS-RemoteTunnel.ps1 -Mode Plan -RemoteRoot C:\path\to\allowed\root +``` + +After OpenAI tunnel credentials exist, set them only in the current process +environment. Do not commit them or place them in the MCP command: + +```powershell +$env:CONTROL_PLANE_API_KEY = "" +$env:CONTROL_PLANE_TUNNEL_ID = "" + +.\scripts\windows\ContinuityOS-RemoteTunnel.ps1 -Mode Init -RemoteRoot C:\path\to\allowed\root +.\scripts\windows\ContinuityOS-RemoteTunnel.ps1 -Mode Doctor +.\scripts\windows\ContinuityOS-RemoteTunnel.ps1 -Mode Run +``` + +The launcher passes no API key or bearer token on the command line. The official +`tunnel-client` inherits the runtime key from the environment. + +`CONTROL_PLANE_TUNNEL_ID` must match `tunnel_` plus exactly 32 lowercase hex +characters. The runtime API key should be restricted to Tunnels Read + Use; + +## Security invariants + +- no public MCP listener is created by ContinuityOS; +- no public MCP endpoint is required; +- tunnel-client health/UI is explicitly pinned to loopback `127.0.0.1:8080`; +- no arbitrary `--mcp-command` input is accepted by the launcher; +- the MCP child command is fixed to `continuityos.remote_mcp_server`; +- the remote root must already exist; +- credential/key paths remain denied by R1; +- Pro tunnel surface is read-only at the server, not merely in the ChatGPT UI; +- R2 does not merge, deploy, trade, touch wallets, or grant capital authority. + +## Qualification + +R2 is code-complete only when: + +1. R1 review-gates are green on its synchronized head; +2. R2 unit/static tests pass on Linux and Windows CI; +3. R2 CodeQL and P0 checks are green; +4. Windows CI proves credential-free `Plan` mode and loopback-only listener settings; +5. once tunnel credentials are available, `Init -> Doctor -> Run` is tested with + the official OpenAI tunnel-client. + +The final step is runtime qualification and cannot be claimed from CI alone. diff --git a/scripts/windows/ContinuityOS-RemoteTunnel.ps1 b/scripts/windows/ContinuityOS-RemoteTunnel.ps1 new file mode 100644 index 00000000..71c0f0e2 --- /dev/null +++ b/scripts/windows/ContinuityOS-RemoteTunnel.ps1 @@ -0,0 +1,117 @@ +[CmdletBinding()] +param( + [ValidateSet("Plan", "Init", "Doctor", "Run")] + [string]$Mode = "Plan", + + [string]$TunnelId = $env:CONTROL_PLANE_TUNNEL_ID, + [string]$Profile = "continuityos-remote", + [string]$RemoteRoot = (Get-Location).Path, + [string]$TunnelClient = "tunnel-client", + [string]$Python = "python" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Require-Command { + param([Parameter(Mandatory = $true)][string]$Name) + $resolved = Get-Command $Name -ErrorAction Stop + if (-not $resolved.Source) { + throw "Cannot resolve executable path for $Name" + } + return $resolved.Source +} + +function Require-ControlPlaneKey { + if ([string]::IsNullOrWhiteSpace($env:CONTROL_PLANE_API_KEY)) { + throw "CONTROL_PLANE_API_KEY must be supplied through the process environment." + } +} + +function Validate-TunnelId { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "TunnelId is required for Init." + } + if ($Value -notmatch '^tunnel_[0-9a-f]{32}$') { + throw "TunnelId has an invalid format." + } +} + +if ( + [string]::IsNullOrWhiteSpace($Profile) -or + $Profile.Length -gt 64 -or + $Profile -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' +) { + throw "Profile has an invalid format." +} + +$pythonExe = Require-Command -Name $Python +$root = (Resolve-Path -LiteralPath $RemoteRoot).Path +if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw "RemoteRoot must be an existing directory." +} + +# The child MCP is deliberately fixed. No arbitrary shell text is accepted. +# The Pro profile is enforced again inside remote_mcp_server for both tools/list +# and tools/call; the tunnel is transport only. +$quotedPython = '"' + $pythonExe.Replace('"', '""') + '"' +$quotedRoot = '"' + $root.Replace('"', '""') + '"' +$mcpCommand = ( + $quotedPython + + " -m continuityos.remote_mcp_server" + + " --enable-remote" + + " --tool-profile chatgpt-pro-readonly" + + " --remote-root " + $quotedRoot +) + +$plan = [ordered]@{ + schema = "continuityos.remote_tunnel_plan/v1" + mode = $Mode + profile = $Profile + tunnel_id_present = -not [string]::IsNullOrWhiteSpace($TunnelId) + control_plane_key_present = -not [string]::IsNullOrWhiteSpace($env:CONTROL_PLANE_API_KEY) + remote_root = $root + python = $pythonExe + mcp_transport = "stdio" + mcp_tool_profile = "chatgpt-pro-readonly" + public_mcp_listener = $false + health_listener = "127.0.0.1:8080" + health_listener_scope = "loopback" + direct_shell = $false +} + +if ($Mode -eq "Plan") { + $plan | ConvertTo-Json -Depth 4 + exit 0 +} + +$tunnelExe = Require-Command -Name $TunnelClient +Require-ControlPlaneKey + +switch ($Mode) { + "Init" { + Validate-TunnelId -Value $TunnelId + & $tunnelExe init ` + --sample sample_mcp_stdio_local ` + --profile $Profile ` + --tunnel-id $TunnelId ` + --health-listen-addr 127.0.0.1:8080 ` + --mcp-command $mcpCommand + if ($LASTEXITCODE -ne 0) { + throw "tunnel-client init failed with exit code $LASTEXITCODE" + } + } + "Doctor" { + & $tunnelExe doctor --profile $Profile --explain + if ($LASTEXITCODE -ne 0) { + throw "tunnel-client doctor failed with exit code $LASTEXITCODE" + } + } + "Run" { + & $tunnelExe run --profile $Profile + if ($LASTEXITCODE -ne 0) { + throw "tunnel-client run failed with exit code $LASTEXITCODE" + } + } +} diff --git a/tests/test_remote_mcp_pro_profile.py b/tests/test_remote_mcp_pro_profile.py new file mode 100644 index 00000000..e691c9f9 --- /dev/null +++ b/tests/test_remote_mcp_pro_profile.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from continuityos.remote_mcp_server import ( + TOOL_PROFILE_CHATGPT_PRO_READONLY, + RemoteServer, +) + + +def _server(tmp_path: Path, *, profile: str = TOOL_PROFILE_CHATGPT_PRO_READONLY): + return RemoteServer( + db=":memory:", + remote_enabled=True, + remote_roots=[tmp_path], + tool_profile=profile, + ) + + +def test_pro_profile_advertises_only_bounded_host_read_tools(tmp_path: Path): + server = _server(tmp_path) + assert [tool["name"] for tool in server.tools] == [ + "capability_status", + "system_info", + "fs_list", + "fs_read", + ] + + +def test_pro_profile_tools_are_annotated_read_only(tmp_path: Path): + server = _server(tmp_path) + assert server.tools + for tool in server.tools: + annotations = tool["annotations"] + assert annotations["readOnlyHint"] is True + assert annotations["destructiveHint"] is False + assert annotations["idempotentHint"] is True + assert annotations["openWorldHint"] is False + +@pytest.mark.parametrize( + "name,args", + [ + ("remember", {"text": "must not write"}), + ("upsert", {"text": "x", "key": "k"}), + ("forget", {"id": 1}), + ( + "preflight_exec", + {"request_id": "r1", "argv": ["echo", "x"], "cwd": "."}, + ), + ("execute_preflight", {"request_id": "r1"}), + ], +) +def test_pro_profile_rejects_hidden_write_or_execution_tools( + tmp_path: Path, name: str, args: dict +): + server = _server(tmp_path) + with pytest.raises(PermissionError, match="tool hidden by remote tool profile"): + server.call(name, args) + + +def test_capability_status_reports_effective_profile(tmp_path: Path): + server = _server(tmp_path) + value = json.loads(server.call("capability_status", {})) + assert value["tool_profile"] == TOOL_PROFILE_CHATGPT_PRO_READONLY + assert value["advertised_tools"] == [ + "capability_status", + "system_info", + "fs_list", + "fs_read", + ] + assert value["mode"] == "read_only_host_surface" + assert value["mutating_execution"] == { + "available": False, + "direct_shell": False, + "reason": "hidden_by_tool_profile", + } + + +def test_unknown_profile_fails_closed(tmp_path: Path): + with pytest.raises(ValueError, match="unknown remote tool profile"): + _server(tmp_path, profile="anything-goes") diff --git a/tests/test_remote_mcp_pro_stdio.py b/tests/test_remote_mcp_pro_stdio.py new file mode 100644 index 00000000..dfaf01b0 --- /dev/null +++ b/tests/test_remote_mcp_pro_stdio.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + + +def test_pro_readonly_stdio_protocol_hides_and_rejects_write_tools(tmp_path: Path): + requests = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "remember", "arguments": {"text": "no"}}, + }, + { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "capability_status", "arguments": {}}, + }, + ] + payload = "".join(json.dumps(item) + "\n" for item in requests) + completed = subprocess.run( + [ + sys.executable, + "-m", + "continuityos.remote_mcp_server", + "--db", + ":memory:", + "--enable-remote", + "--tool-profile", + "chatgpt-pro-readonly", + "--remote-root", + str(tmp_path), + ], + input=payload, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert completed.returncode == 0, completed.stderr + responses = {item["id"]: item for item in map(json.loads, completed.stdout.splitlines())} + + tools = responses[2]["result"]["tools"] + assert [tool["name"] for tool in tools] == [ + "capability_status", + "system_info", + "fs_list", + "fs_read", + ] + assert all(tool["annotations"]["readOnlyHint"] is True for tool in tools) + + denied = responses[3]["result"] + assert denied["isError"] is True + assert "tool hidden by remote tool profile" in denied["content"][0]["text"] + + status_text = responses[4]["result"]["content"][0]["text"] + status = json.loads(status_text) + assert status["tool_profile"] == "chatgpt-pro-readonly" + assert status["mutating_execution"]["available"] is False diff --git a/tests/test_remote_tunnel_windows_script.py b/tests/test_remote_tunnel_windows_script.py new file mode 100644 index 00000000..f0aca2cb --- /dev/null +++ b/tests/test_remote_tunnel_windows_script.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess + +import pytest + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "windows" + / "ContinuityOS-RemoteTunnel.ps1" +) + + +def _source() -> str: + if not SCRIPT.is_file(): + pytest.skip("source-tree-only Windows tunnel launcher is not shipped in the wheel") + return SCRIPT.read_text(encoding="utf-8") + + +def test_windows_tunnel_launcher_keeps_key_out_of_command_line(): + source = _source() + assert "CONTROL_PLANE_API_KEY" in source + assert "--api-key" not in source + assert "--token" not in source + assert "Set-Content" not in source + assert "Add-Content" not in source + + +def test_windows_tunnel_launcher_uses_stdio_and_pro_readonly_profile(): + source = _source() + assert "sample_mcp_stdio_local" in source + assert "-m continuityos.remote_mcp_server" in source + assert "--enable-remote" in source + assert "--tool-profile chatgpt-pro-readonly" in source + assert 'mcp_transport = "stdio"' in source + assert 'public_mcp_listener = $false' in source + assert 'health_listener = "127.0.0.1:8080"' in source + assert 'health_listener_scope = "loopback"' in source + + +def test_windows_tunnel_launcher_accepts_no_arbitrary_mcp_command(): + source = _source() + assert "[string]$McpCommand" not in source + assert "[string]$Command" not in source + assert "The child MCP is deliberately fixed" in source + + +def test_windows_tunnel_launcher_bounds_profile_identifier(): + source = _source() + assert "Profile has an invalid format." in source + assert "^[A-Za-z0-9][A-Za-z0-9._-]*$" in source + + +@pytest.mark.skipif(os.name != "nt", reason="Windows PowerShell smoke test") +def test_windows_tunnel_launcher_plan_executes_without_credentials(tmp_path: Path): + if not SCRIPT.is_file(): + pytest.skip("source-tree-only Windows tunnel launcher is not shipped in the wheel") + env = os.environ.copy() + env.pop("CONTROL_PLANE_API_KEY", None) + env.pop("CONTROL_PLANE_TUNNEL_ID", None) + completed = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(SCRIPT), + "-Mode", + "Plan", + "-RemoteRoot", + str(tmp_path), + ], + check=False, + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert completed.returncode == 0, completed.stderr + plan = json.loads(completed.stdout) + assert plan["mcp_transport"] == "stdio" + assert plan["mcp_tool_profile"] == "chatgpt-pro-readonly" + assert plan["public_mcp_listener"] is False + assert plan["health_listener"] == "127.0.0.1:8080" + assert plan["health_listener_scope"] == "loopback" + assert plan["direct_shell"] is False + assert plan["control_plane_key_present"] is False + + +def test_windows_tunnel_launcher_binds_official_tunnel_id_format(): + source = _source() + assert "^tunnel_[0-9a-f]{32}$" in source + assert "TunnelId has an invalid format." in source + + +def test_windows_tunnel_launcher_pins_health_listener_to_loopback(): + source = _source() + assert "--health-listen-addr 127.0.0.1:8080" in source + assert "--allow-remote-ui" not in source