"""MCP unsafe_exec tool handler.
Executes unrestricted shell commands in the real workspace directory.
Only version control commands (git, hg, svn) are blocked.
Execution goes through the SAME bounded process-manager path as ``exec``
(``run_command``): output is capped (and spilled to a file when oversized rather
than buffered unbounded in memory) and the process tree is killed on timeout. The
sync handler is dispatched off the asyncio event loop by the production
``_FallbackHttpHandler`` via the saturated-dispatch seam
(``ralph.mcp.server._saturated_dispatch``), so a long shell command cannot
freeze the server.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from ralph.mcp.tools._exec_execution_error import ExecutionError
from ralph.mcp.tools._exec_output_spill import format_or_spill
from ralph.mcp.tools.coordination import (
CapabilityDeniedError,
CoordinationSessionLike,
InvalidParamsError,
ToolContent,
ToolResult,
require_capability,
)
from ralph.mcp.tools.exec import resolve_spill_dir, run_command
from ralph.timeout_defaults import EXEC_DEFAULT_TIMEOUT_MS, EXEC_MAX_TIMEOUT_MS
if TYPE_CHECKING:
from collections.abc import Mapping
from ralph.mcp.tools._exec_run_deps import ExecRunDeps
PROCESS_EXEC_UNBOUNDED_CAPABILITY: Final = "ProcessExecUnbounded"
_VCS_COMMANDS: frozenset[str] = frozenset({"git", "hg", "svn"})
[docs]
def handle_unsafe_exec(
session: CoordinationSessionLike,
workspace: object,
params: Mapping[str, object],
deps: ExecRunDeps | None = None,
) -> ToolResult:
"""Execute an unrestricted shell command in the real workspace directory."""
require_capability(session, PROCESS_EXEC_UNBOUNDED_CAPABILITY, "Unsafe command execution")
command_value = params.get("command")
if not isinstance(command_value, str) or not command_value.strip():
raise InvalidParamsError("'command' must be a non-empty string")
command = command_value.strip()
first_token = command.split()[0].lower()
if first_token in _VCS_COMMANDS:
raise CapabilityDeniedError(
f"Command '{first_token}' is blocked: version control operations "
"are not permitted via unsafe_exec"
)
# Require a strictly positive timeout: 0/negative/non-int falls back to the
# default. Zero must NOT mean "unbounded" — that would make unsafe_exec a
# blocking-forever call (an agent-controllable hang).
timeout_value = params.get("timeout_ms", EXEC_DEFAULT_TIMEOUT_MS)
timeout_ms = (
timeout_value
if isinstance(timeout_value, int) and timeout_value > 0
else EXEC_DEFAULT_TIMEOUT_MS
)
# Cap the per-call override: the MCP client request timeout is derived to exceed
# EXEC_MAX_TIMEOUT_MS, so this call can never outrun the client and re-trigger
# the -32001 "Request timed out" storm.
timeout_ms = min(timeout_ms, EXEC_MAX_TIMEOUT_MS)
try:
# Run the arbitrary command through a shell, but via the bounded
# process-manager path (capped output + process-tree kill on timeout).
output = run_command("sh", ["-c", command], workspace, timeout_ms, deps=deps)
except ExecutionError as exc:
if not exc.timed_out:
raise
# A timeout becomes an actionable, non-retryable is_error result rather
# than a propagated -32603 protocol error the agent re-issues forever (the
# retry-storm pathology). The rendered message teaches both meanings of a
# timeout (raise the limit vs. fix a genuinely stuck command).
return ToolResult(
content=[ToolContent.text_content(str(exc))],
is_error=True,
)
stdout = output.stdout.decode("utf-8", errors="replace")
stderr = output.stderr.decode("utf-8", errors="replace")
text = (
f"Command: {command}\n"
f"Exit code: {output.returncode}\n\n"
f"Stdout:\n{stdout}\n\n"
f"Stderr:\n{stderr}"
)
return format_or_spill(
text,
returncode=output.returncode,
truncated=output.truncated,
spill_dir=resolve_spill_dir(workspace, deps),
)
__all__ = [
"PROCESS_EXEC_UNBOUNDED_CAPABILITY",
"_VCS_COMMANDS",
"handle_unsafe_exec",
]