Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- An explicitly opt-in `run_shell` MCP tool supports bounded same-user shell
execution for trusted remote MCP deployments. The tool is absent unless
`COMPUTER_USE_LINUX_ENABLE_SHELL=1`, clears ambient credentials, requires
visible environment additions, enforces timeout/output limits and process-
group cleanup, and emits command-digest audit records.

## [0.4.10] - 2026-08-22

### Fixed
Expand Down
71 changes: 71 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ rmcp = { version = "1.5.0", features = ["transport-io"] }
schemars = "1.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sha2 = "0.11.0"
tokio = { version = "1.51.1", features = ["io-util", "macros", "process", "rt", "sync", "time"] }
wayland-client = "0.31.11"
wayland-protocols = { version = "0.32.9", features = ["client", "staging"] }
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Targeted `press_key`/`type_text` results append focused-element feedback from AT
- `activate_window` — focus a window by `window_id`, `pid`, `app_id`, `wm_class`, `title`, or terminal selectors
- `move_window` / `resize_window` — reposition or resize a window in desktop coordinates (GNOME Shell extension backend); useful to recover windows that are partially off-screen

**Conditional host execution**

- `run_shell` — same-user `/bin/sh -lc` execution, registered only when the server operator starts the MCP process with `COMPUTER_USE_LINUX_ENABLE_SHELL=1`. It is deliberately absent by default and is not a sandbox.

### MCP safety contract

`computer-use-linux` is not a read-only data source. It can observe the local desktop and, when a mutating tool is called, can change real application state. The `tools/list` response includes MCP `ToolAnnotations` so hosts can surface this distinction before invocation:
Expand All @@ -81,9 +85,12 @@ Targeted `press_key`/`type_text` results append focused-element feedback from AT
| Local setup mutators | `setup_accessibility`, `setup_window_targeting` | `readOnlyHint=false`, `destructiveHint=false`, `idempotentHint=true`; modifies user desktop configuration by enabling accessibility or installing/enabling the GNOME window-targeting extension. |
| UI state mutators | `activate_window`, `move_window`, `resize_window`, `scroll`, `screenshot` | `readOnlyHint=false`, `destructiveHint=false`; changes focus, geometry, or scroll position in the live desktop, or raises a window to capture it. |
| Desktop action mutators | `click`, `drag`, `press_key`, `type_text`, `perform_action`, `set_value` | `readOnlyHint=false`, `destructiveHint=true`, `openWorldHint=true`; can trigger arbitrary actions in whatever local application is targeted. |
| Conditional host-code execution | `run_shell` | Absent unless `COMPUTER_USE_LINUX_ENABLE_SHELL=1`; when enabled, `readOnlyHint=false`, `destructiveHint=true`, `idempotentHint=false`, `openWorldHint=true`. Runs with the MCP server user's host permissions. |

Annotations are safety hints, not an authorization system. MCP hosts should still ask the user before calls that could submit, delete, send, purchase, overwrite, or otherwise commit state.

`run_shell` is an explicit trust-boundary opt-in, not a restricted command runner. Enabling it grants an approved MCP call the same file and network authority as the user running the server. The tool clears the ambient environment and inherits only a small desktop/runtime allowlist (`PATH`, home/user/locale fields, display/session-bus fields); additional variables must be supplied in the visible call payload. Commands use a fixed `/bin/sh`, an existing canonical working directory, a 30-second default / 120-second hard timeout, process-group cleanup, bounded collection, 512 KiB per returned stream, and stderr audit records keyed by the command SHA-256 rather than command text. These controls bound accidental leakage and runaway work; they do not make arbitrary shell code safe.

The binary also exposes the same capabilities from the CLI for scripting and debugging:

```
Expand Down Expand Up @@ -340,6 +347,7 @@ Most setups need none of these — `doctor` and the installers pick sensible def
| `COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER` / `…_KEYBOARD` | Always route pointer / keyboard through `ydotool`, skipping the portal and KDE clipboard paths; pointer forcing also skips native-X11 `xdotool` coordinate clicks. |
| `COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD` | Prefer `xdotool`/XTEST keyboard input when `DISPLAY` is available. `COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD=1` takes precedence. |
| `COMPUTER_USE_LINUX_SCREENSHOT_BACKEND` | Force a single screenshot backend, skipping the fallback chain. Accepts `gnome-shell`, `portal`, or `gnome-screenshot`. Pin `gnome-screenshot` for background/systemd contexts where the GNOME Shell and portal DBus paths are denied. |
| `COMPUTER_USE_LINUX_ENABLE_SHELL` | Set exactly to `1` before starting the MCP server to register the destructive `run_shell` tool. Unset by default. Do not enable for untrusted or unattended MCP hosts. |

**Build-time identity overrides** (set while compiling a downstream embedded
bundle): `CUL_GNOME_EXTENSION_UUID`, `CUL_DBUS_SERVICE`, and
Expand Down
80 changes: 74 additions & 6 deletions scripts/mcp_safety_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import json
import os
import pathlib
import re
import select
Expand Down Expand Up @@ -33,6 +34,7 @@
"perform_action",
"set_value",
}
SHELL_TOOL = "run_shell"

INJECTION_PATTERNS = [
re.compile(pattern, re.IGNORECASE)
Expand All @@ -54,6 +56,7 @@
"eval",
"shell",
"run_command",
SHELL_TOOL,
"terminal",
"read_file",
"write_file",
Expand Down Expand Up @@ -97,6 +100,7 @@
"type_text",
"perform_action",
"set_value",
SHELL_TOOL,
}

NON_DESTRUCTIVE_MUTATING_TOOLS = EXPECTED_TOOLS - READ_ONLY_TOOLS - DESTRUCTIVE_MUTATING_TOOLS
Expand All @@ -109,22 +113,26 @@
"resize_window",
}

OPEN_WORLD_TOOLS = EXPECTED_TOOLS - {
OPEN_WORLD_TOOLS = (EXPECTED_TOOLS | {SHELL_TOOL}) - {
"doctor",
"setup_accessibility",
"setup_window_targeting",
}


class McpClient:
def __init__(self, binary: pathlib.Path):
def __init__(self, binary: pathlib.Path, extra_env: dict[str, str] | None = None):
child_env = os.environ.copy()
if extra_env:
child_env.update(extra_env)
self.process = subprocess.Popen(
[str(binary), "mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=child_env,
)
self.next_id = 1

Expand Down Expand Up @@ -239,7 +247,9 @@ def main() -> int:
raise AssertionError(f"binary does not exist: {binary}")

version = package_version(repo)
annotation_partition = READ_ONLY_TOOLS | NON_DESTRUCTIVE_MUTATING_TOOLS | DESTRUCTIVE_MUTATING_TOOLS
annotation_partition = (
READ_ONLY_TOOLS | NON_DESTRUCTIVE_MUTATING_TOOLS | DESTRUCTIVE_MUTATING_TOOLS
) - {SHELL_TOOL}
if annotation_partition != EXPECTED_TOOLS:
raise AssertionError(
"tool annotation classes do not cover the expected MCP tool set: "
Expand Down Expand Up @@ -288,13 +298,13 @@ def main() -> int:
name = tool["name"]
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
raise AssertionError(f"tool name is not provider-safe snake_case: {name!r}")
if name in DANGEROUS_TOOL_NAMES:
if name in DANGEROUS_TOOL_NAMES and name != SHELL_TOOL:
raise AssertionError(f"unexpected dangerous tool name exposed: {name}")
description = tool.get("description") or ""
assert_no_injection_text(f"{name} description", description)
assert_tool_annotations(tool)
props = schema_properties(tool)
if "env" in props or "shell" in props or "command" in props:
if name != SHELL_TOOL and ("env" in props or "shell" in props or "command" in props):
raise AssertionError(f"{name} exposes a raw process-control parameter: {sorted(props)}")
if name in {"press_key", "type_text", "activate_window"} and not FOCUS_SELECTORS <= props:
raise AssertionError(f"{name} is missing focus target selectors: {sorted(FOCUS_SELECTORS - props)}")
Expand All @@ -314,7 +324,65 @@ def main() -> int:
finally:
client.close()

print(f"MCP safety check passed: {len(EXPECTED_TOOLS)} tools, version {version}")
shell_client = McpClient(
binary,
{
"COMPUTER_USE_LINUX_ENABLE_SHELL": "1",
"COMPUTER_USE_LINUX_TEST_SECRET": "must-not-be-inherited",
},
)
try:
shell_client.request(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "computer-use-linux-shell-ci", "version": "0"},
},
)
shell_client.notify("notifications/initialized", {})
tools = shell_client.request("tools/list", {})["result"].get("tools") or []
names = {tool.get("name") for tool in tools}
expected = EXPECTED_TOOLS | {SHELL_TOOL}
if names != expected:
raise AssertionError(
f"unexpected opt-in tools: missing={expected - names}, extra={names - expected}"
)
shell_tool = next(tool for tool in tools if tool.get("name") == SHELL_TOOL)
assert_tool_annotations(shell_tool)
shell_props = schema_properties(shell_tool)
required_shell_props = {"command", "cwd", "env", "timeout_seconds"}
if not required_shell_props <= shell_props:
raise AssertionError(
f"{SHELL_TOOL} is missing bounded execution controls: {sorted(required_shell_props - shell_props)}"
)
result = shell_client.request(
"tools/call",
{
"name": SHELL_TOOL,
"arguments": {
"command": 'test -z "${COMPUTER_USE_LINUX_TEST_SECRET-}" && printf %s "$EXPLICIT"',
"cwd": str(repo),
"env": {"EXPLICIT": "shell-ok"},
"timeout_seconds": 5,
},
},
)["result"]
content = result.get("content") or []
if not content or content[0].get("type") != "text":
raise AssertionError(f"{SHELL_TOOL} did not return text content: {result!r}")
shell_result = json.loads(content[0].get("text") or "{}")
if shell_result.get("ok") is not True or shell_result.get("stdout") != "shell-ok":
raise AssertionError(f"{SHELL_TOOL} smoke failed: {shell_result!r}")
if len(shell_result.get("command_sha256") or "") != 64:
raise AssertionError(f"{SHELL_TOOL} did not return an audit digest: {shell_result!r}")
finally:
shell_client.close()

print(
f"MCP safety check passed: {len(EXPECTED_TOOLS)} default tools, "
f"{len(EXPECTED_TOOLS) + 1} with shell opt-in, version {version}"
)
return 0


Expand Down
Loading
Loading