|
|
|
@@ -24,12 +24,22 @@ Elicited login secrets are kept only in this process's RAM with a short TTL
|
|
|
|
|
they are dropped on an authentication failure so the next attempt re-prompts.
|
|
|
|
|
|
|
|
|
|
sudo (two methods per alias, set on ``add_alias``):
|
|
|
|
|
* ``nopasswd`` — ``sudo -n``: non-interactive, fails fast if NOPASSWD is not
|
|
|
|
|
configured on the host (no hung channel). No secret stored anywhere.
|
|
|
|
|
* ``prompt`` — ``sudo -S``: the password is requested on demand via **MCP
|
|
|
|
|
elicitation** (Skald shows a masked field in the Agent Inbox), fed to
|
|
|
|
|
sudo's stdin, kept only in this process's RAM with a short TTL, never sent
|
|
|
|
|
to the LLM and never written to disk.
|
|
|
|
|
* ``nopasswd`` — ``sudo -n`` only: non-interactive, fails fast with an
|
|
|
|
|
explicit message if NOPASSWD is not configured on the host (no hung
|
|
|
|
|
channel). No secret stored anywhere.
|
|
|
|
|
* ``prompt`` — ``sudo -n`` is **still tried first**; only if the host really
|
|
|
|
|
demands a password is one requested via **MCP elicitation** (Skald shows a
|
|
|
|
|
masked field in the Agent Inbox), fed to sudo's stdin, kept only in this
|
|
|
|
|
process's RAM with a short TTL, never sent to the LLM and never written to
|
|
|
|
|
disk. ``SSH_MCP_SUDO_PASSWORD`` is a non-interactive override for
|
|
|
|
|
unattended runs (no human to answer the Inbox prompt).
|
|
|
|
|
|
|
|
|
|
``exec``/``systemd`` never nest sudo: a leading ``sudo`` (with its usual flags,
|
|
|
|
|
including ``-u USER``) is stripped from ``command`` and turned into
|
|
|
|
|
``sudo=true``, so an agent that writes ``sudo systemctl restart x`` gets the
|
|
|
|
|
same, working behaviour as ``sudo=true`` + ``systemctl restart x``. Under sudo
|
|
|
|
|
the command runs as ``sh -c '<command>'`` so pipes and redirections are also
|
|
|
|
|
privileged.
|
|
|
|
|
|
|
|
|
|
Connections are pooled per alias with lazy TTL eviction. Host keys are verified
|
|
|
|
|
against ``~/.ssh/known_hosts`` (unknown hosts are rejected unless the alias was
|
|
|
|
@@ -66,6 +76,10 @@ LOGIN_PW_TTL = int(os.environ.get("SSH_MCP_LOGIN_PW_TTL", "300")) # in-RAM lo
|
|
|
|
|
CONNECT_TIMEOUT = int(os.environ.get("SSH_MCP_CONNECT_TIMEOUT", "15"))
|
|
|
|
|
DEFAULT_CMD_TIMEOUT = int(os.environ.get("SSH_MCP_COMMAND_TIMEOUT", "120"))
|
|
|
|
|
|
|
|
|
|
# Non-interactive sudo password (unattended runs, where nobody can answer the
|
|
|
|
|
# elicitation prompt in the Agent Inbox). Empty/unset ⇒ elicitation only.
|
|
|
|
|
SUDO_PASSWORD_ENV = os.environ.get("SSH_MCP_SUDO_PASSWORD") or None
|
|
|
|
|
|
|
|
|
|
# Mirror the native list_files skip set so remote listings match local ones.
|
|
|
|
|
SKIP_DIRS = {"target", ".git", "node_modules", ".venv", "__pycache__", "secrets"}
|
|
|
|
|
|
|
|
|
@@ -107,13 +121,32 @@ def readline() -> dict | None:
|
|
|
|
|
|
|
|
|
|
_eid = itertools.count(1)
|
|
|
|
|
|
|
|
|
|
# Messages received while blocked on an elicitation reply — replayed by the main
|
|
|
|
|
# loop instead of being dropped, so a concurrent tools/call is never lost.
|
|
|
|
|
_deferred: list[dict] = []
|
|
|
|
|
|
|
|
|
|
# Whether the client advertised the `elicitation` capability at initialize. A
|
|
|
|
|
# client without it can never supply a password, so we fail fast with a useful
|
|
|
|
|
# message instead of blocking on a request nobody will answer.
|
|
|
|
|
_client_can_elicit = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def next_message() -> dict | None:
|
|
|
|
|
"""Next inbound message: deferred ones first, then stdin."""
|
|
|
|
|
if _deferred:
|
|
|
|
|
return _deferred.pop(0)
|
|
|
|
|
return readline()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def elicit(message: str, requested_schema: dict) -> dict:
|
|
|
|
|
"""Send an ``elicitation/create`` request and block until the reply arrives.
|
|
|
|
|
|
|
|
|
|
Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). While
|
|
|
|
|
waiting, any other inbound message is ignored (v1: serial processing).
|
|
|
|
|
Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). Other
|
|
|
|
|
inbound messages are queued in ``_deferred`` and handled once the reply
|
|
|
|
|
lands (v1: still serial, but nothing is discarded).
|
|
|
|
|
"""
|
|
|
|
|
if not _client_can_elicit:
|
|
|
|
|
return {"action": "cancel", "_reason": "unsupported"}
|
|
|
|
|
eid = f"ssh-elicit-{next(_eid)}"
|
|
|
|
|
send({
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
@@ -124,10 +157,11 @@ def elicit(message: str, requested_schema: dict) -> dict:
|
|
|
|
|
while True:
|
|
|
|
|
msg = readline()
|
|
|
|
|
if msg is None:
|
|
|
|
|
return {"action": "cancel"}
|
|
|
|
|
return {"action": "cancel", "_reason": "disconnected"}
|
|
|
|
|
if msg.get("id") == eid:
|
|
|
|
|
return msg.get("result", {"action": "cancel"})
|
|
|
|
|
log(f"ignoring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}")
|
|
|
|
|
log(f"deferring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}")
|
|
|
|
|
_deferred.append(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ok(req_id: Any, result: Any) -> dict:
|
|
|
|
@@ -141,6 +175,17 @@ def _text_result(req_id: Any, text: str, is_error: bool = False) -> dict:
|
|
|
|
|
return {"jsonrpc": "2.0", "id": req_id, "result": res}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _int(args: dict, key: str, default: int) -> int:
|
|
|
|
|
"""Integer argument tolerant of null / numeric strings (LLMs send both)."""
|
|
|
|
|
v = args.get(key)
|
|
|
|
|
if v is None or v == "":
|
|
|
|
|
return default
|
|
|
|
|
try:
|
|
|
|
|
return int(v)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Alias store (auto-managed, 0600) ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _load_aliases() -> dict:
|
|
|
|
@@ -216,6 +261,13 @@ def _login_password(alias: str, kind: str = "login") -> str | None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _no_secret_reason() -> str:
|
|
|
|
|
"""Why an elicited secret never arrived — the two cases look identical to the
|
|
|
|
|
caller but need very different fixes from the user."""
|
|
|
|
|
return ("this MCP client does not support elicitation" if not _client_can_elicit
|
|
|
|
|
else "the user declined it or the request timed out")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_login_pw(alias: str) -> None:
|
|
|
|
|
"""Drop any cached login password / passphrase for ``alias``."""
|
|
|
|
|
for k in [k for k in _login_pw_cache if k.startswith(f"{alias}:")]:
|
|
|
|
@@ -259,7 +311,8 @@ def _connect(cfg: dict, paramiko):
|
|
|
|
|
password = _login_password(alias, "login")
|
|
|
|
|
if password is None:
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"login password required for alias '{alias}' (user declined or timed out)"
|
|
|
|
|
f"login password required for alias '{alias}' but none was provided "
|
|
|
|
|
f"({_no_secret_reason()})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def attempt(passphrase):
|
|
|
|
@@ -301,7 +354,8 @@ def _connect(cfg: dict, paramiko):
|
|
|
|
|
passphrase = _login_password(alias, "passphrase")
|
|
|
|
|
if passphrase is None:
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"key passphrase required for alias '{alias}' (user declined or timed out)"
|
|
|
|
|
f"key passphrase required for alias '{alias}' but none was provided "
|
|
|
|
|
f"({_no_secret_reason()}) — set SSH_MCP_KEY_PASSPHRASE for unattended runs"
|
|
|
|
|
)
|
|
|
|
|
return attempt(passphrase)
|
|
|
|
|
except (paramiko.AuthenticationException, paramiko.SSHException) as e:
|
|
|
|
@@ -380,34 +434,89 @@ def _get_sftp(alias: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_with_stdin(client, command: str, timeout: int, stdin_data: str | None = None):
|
|
|
|
|
"""Run a remote command; return (stdout, stderr, exit_code). Raises on timeout."""
|
|
|
|
|
"""Run a remote command; return (stdout, stderr, exit_code).
|
|
|
|
|
|
|
|
|
|
stdout and stderr are drained **together**: they share one SSH channel
|
|
|
|
|
window, so reading stdout to EOF first stalls as soon as a chatty stderr
|
|
|
|
|
fills that window. ``timeout`` is a wall-clock deadline for the whole run
|
|
|
|
|
(not an idle timeout), and stdin is always closed so commands that read it
|
|
|
|
|
see EOF instead of hanging.
|
|
|
|
|
"""
|
|
|
|
|
chan = client.get_transport().open_session(timeout=CONNECT_TIMEOUT)
|
|
|
|
|
try:
|
|
|
|
|
chan_in, chan_out, chan_err = client.exec_command(command, timeout=timeout)
|
|
|
|
|
if stdin_data is not None:
|
|
|
|
|
try:
|
|
|
|
|
chan_in.write(stdin_data)
|
|
|
|
|
chan_in.flush()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
out = chan_out.read().decode("utf-8", "replace")
|
|
|
|
|
err = chan_err.read().decode("utf-8", "replace")
|
|
|
|
|
code = chan_out.channel.recv_exit_status()
|
|
|
|
|
return out, err, code
|
|
|
|
|
chan.settimeout(timeout)
|
|
|
|
|
chan.exec_command(command)
|
|
|
|
|
try:
|
|
|
|
|
if stdin_data:
|
|
|
|
|
chan.sendall(stdin_data.encode())
|
|
|
|
|
chan.shutdown_write()
|
|
|
|
|
except Exception as e: # closed early by the remote end
|
|
|
|
|
log(f"stdin write failed: {e}")
|
|
|
|
|
|
|
|
|
|
out, err = bytearray(), bytearray()
|
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
settled = 0
|
|
|
|
|
while True:
|
|
|
|
|
idle = True
|
|
|
|
|
while chan.recv_ready():
|
|
|
|
|
out += chan.recv(65536)
|
|
|
|
|
idle = False
|
|
|
|
|
while chan.recv_stderr_ready():
|
|
|
|
|
err += chan.recv_stderr(65536)
|
|
|
|
|
idle = False
|
|
|
|
|
if not idle:
|
|
|
|
|
settled = 0
|
|
|
|
|
continue
|
|
|
|
|
if chan.exit_status_ready():
|
|
|
|
|
# Exit status can arrive before the last data. Wait for the
|
|
|
|
|
# remote EOF, or — should it close without one — for three
|
|
|
|
|
# consecutive empty polls, rather than truncating the output.
|
|
|
|
|
if chan.eof_received or chan.closed or settled >= 3:
|
|
|
|
|
break
|
|
|
|
|
settled += 1
|
|
|
|
|
if time.time() > deadline:
|
|
|
|
|
raise ToolError(f"command timed out after {timeout}s")
|
|
|
|
|
time.sleep(0.02)
|
|
|
|
|
return (out.decode("utf-8", "replace"),
|
|
|
|
|
err.decode("utf-8", "replace"),
|
|
|
|
|
chan.recv_exit_status())
|
|
|
|
|
except socket.timeout:
|
|
|
|
|
raise ToolError(f"command timed out after {timeout}s")
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
chan.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── sudo ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _sudo_password(alias: str) -> str | None:
|
|
|
|
|
"""Return the sudo password for ``alias`` from RAM cache, or elicit it.
|
|
|
|
|
def _cached_sudo_password(alias: str) -> str | None:
|
|
|
|
|
"""Live RAM-cache entry for ``alias``, if any.
|
|
|
|
|
|
|
|
|
|
Never persisted. Returns None if the user declines/cancels/times out.
|
|
|
|
|
Deliberately ignores ``SSH_MCP_SUDO_PASSWORD``: a cache hit lets ``_run_sudo``
|
|
|
|
|
skip its ``sudo -n`` probe, and on a NOPASSWD host sudo would then not read
|
|
|
|
|
the password line at all — feeding it straight into the command's stdin. A
|
|
|
|
|
RAM entry only ever exists because sudo already demanded a password once.
|
|
|
|
|
"""
|
|
|
|
|
cached = _sudo_pw_cache.get(alias)
|
|
|
|
|
if cached and (time.time() - cached[1] <= SUDO_PW_TTL):
|
|
|
|
|
return cached[0]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sudo_password(alias: str) -> str | None:
|
|
|
|
|
"""Return the sudo password for ``alias`` from RAM cache / env, or elicit it.
|
|
|
|
|
|
|
|
|
|
Never persisted. Returns None if the user declines/cancels/times out, or if
|
|
|
|
|
the client cannot elicit at all.
|
|
|
|
|
"""
|
|
|
|
|
now = time.time()
|
|
|
|
|
cached = _sudo_pw_cache.get(alias)
|
|
|
|
|
if cached and (now - cached[1] <= SUDO_PW_TTL):
|
|
|
|
|
return cached[0]
|
|
|
|
|
cached = _cached_sudo_password(alias)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
return cached
|
|
|
|
|
if SUDO_PASSWORD_ENV:
|
|
|
|
|
return SUDO_PASSWORD_ENV
|
|
|
|
|
|
|
|
|
|
result = elicit(
|
|
|
|
|
f"Enter the sudo password for SSH alias '{alias}'.",
|
|
|
|
@@ -430,21 +539,119 @@ def _sudo_password(alias: str) -> str | None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sudo_prefix(alias: str, cfg: dict, sudo_user: str | None):
|
|
|
|
|
"""Build the sudo prefix for ``cfg``. Returns (prefix, stdin_password).
|
|
|
|
|
# sudo refusing to run because it wants a password. sudo prints these *instead*
|
|
|
|
|
# of running the command, so probing with `-n` is always side-effect free.
|
|
|
|
|
_SUDO_NEEDS_PW = (
|
|
|
|
|
"a password is required", "no password was provided", "a terminal is required",
|
|
|
|
|
"no tty present", "askpass",
|
|
|
|
|
)
|
|
|
|
|
# sudo ran but the password we fed it was wrong.
|
|
|
|
|
_SUDO_BAD_PW = ("sorry, try again", "incorrect password attempt", "authentication failure")
|
|
|
|
|
|
|
|
|
|
Raises ToolError when sudo is disabled or the password is unavailable.
|
|
|
|
|
|
|
|
|
|
def _sudo_says(err: str, code: int, markers: tuple) -> bool:
|
|
|
|
|
"""True if a `sudo:`-prefixed stderr line matches one of ``markers``."""
|
|
|
|
|
if code == 0:
|
|
|
|
|
return False
|
|
|
|
|
for line in err.splitlines():
|
|
|
|
|
low = line.strip().lower()
|
|
|
|
|
if low.startswith("sudo:") and any(m in low for m in markers):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Leading `sudo` (plus its common flags) that an agent put in `command` itself.
|
|
|
|
|
# Anything unrecognised simply doesn't match and is left untouched.
|
|
|
|
|
_SUDO_OPT = (
|
|
|
|
|
r"""(?:-p\s*(?:'[^']*'|"[^"]*"|\S+)""" # -p PROMPT (takes an argument)
|
|
|
|
|
r"|--prompt(?:=|\s+)\S+"
|
|
|
|
|
r"|-[EHnSbik]+" # flag bundles without arguments
|
|
|
|
|
r"|--(?:preserve-env|set-home|non-interactive|stdin|login|shell|background|remove-timestamp))"
|
|
|
|
|
)
|
|
|
|
|
_LEADING_SUDO = re.compile(
|
|
|
|
|
rf"""^\s*(?:/usr/bin/|/bin/)?sudo
|
|
|
|
|
(?:\s+{_SUDO_OPT})*
|
|
|
|
|
(?:\s+(?:-u\s*|--user(?:=|\s+))(?P<user>[A-Za-z0-9_.\-]+))?
|
|
|
|
|
(?:\s+{_SUDO_OPT})*
|
|
|
|
|
(?:\s+--)?
|
|
|
|
|
\s+(?P<rest>\S.*)$""",
|
|
|
|
|
re.X | re.S,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _split_leading_sudo(command: str) -> tuple[str, bool, str | None]:
|
|
|
|
|
"""Split a leading ``sudo …`` off ``command``.
|
|
|
|
|
|
|
|
|
|
Returns ``(command_without_sudo, had_sudo, sudo_user)``. Agents routinely
|
|
|
|
|
write ``sudo systemctl restart x``; running that through the sudo machinery
|
|
|
|
|
would nest a second sudo, whose password prompt has no tty and dies. So the
|
|
|
|
|
prefix is peeled off here and expressed as ``sudo=true`` instead.
|
|
|
|
|
"""
|
|
|
|
|
m = _LEADING_SUDO.match(command)
|
|
|
|
|
if not m:
|
|
|
|
|
return command, False, None
|
|
|
|
|
rest = m.group("rest").strip()
|
|
|
|
|
if rest.startswith("-"):
|
|
|
|
|
# A sudo option we don't know about — stripping here would hand sudo a
|
|
|
|
|
# mangled command line, so leave the whole thing untouched.
|
|
|
|
|
return command, False, None
|
|
|
|
|
return rest, True, m.group("user")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sudo_wrap(flags: str, sudo_user: str | None, command: str) -> str:
|
|
|
|
|
"""``sudo <flags> [-u user] sh -c '<command>'`` — the whole command line,
|
|
|
|
|
pipes and redirections included, runs with the elevated privileges."""
|
|
|
|
|
u = f"-u {shlex.quote(sudo_user)} " if sudo_user else ""
|
|
|
|
|
return f"sudo {flags} {u}sh -c {shlex.quote(command)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_sudo(alias: str, cfg: dict, client, command: str,
|
|
|
|
|
sudo_user: str | None, timeout: int):
|
|
|
|
|
"""Run ``command`` under sudo. Returns (stdout, stderr, exit_code).
|
|
|
|
|
|
|
|
|
|
``sudo -n`` is always attempted first: on a host that grants this user
|
|
|
|
|
NOPASSWD it succeeds outright, so no password is ever requested — which is
|
|
|
|
|
the only thing that works in unattended runs, where nobody is watching the
|
|
|
|
|
Agent Inbox. A password is elicited only when the host actually demands one.
|
|
|
|
|
"""
|
|
|
|
|
method = (cfg.get("sudo") or {}).get("method", "prompt")
|
|
|
|
|
u = f"-u {shlex.quote(sudo_user)} " if sudo_user else ""
|
|
|
|
|
if method == "none":
|
|
|
|
|
raise ToolError(f"sudo is disabled for alias '{alias}'")
|
|
|
|
|
if method == "nopasswd":
|
|
|
|
|
return f"sudo -n {u}", None
|
|
|
|
|
pw = _sudo_password(alias)
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"sudo is disabled for alias '{alias}' — re-add it with sudo='prompt' to enable it"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
pw = _cached_sudo_password(alias) if method != "nopasswd" else None
|
|
|
|
|
if pw is None:
|
|
|
|
|
raise ToolError("sudo password required (user declined or timed out)")
|
|
|
|
|
return f"sudo -S -p '' {u}", pw
|
|
|
|
|
out, err, code = _run_with_stdin(
|
|
|
|
|
client, _sudo_wrap("-n", sudo_user, command), timeout)
|
|
|
|
|
if not _sudo_says(err, code, _SUDO_NEEDS_PW):
|
|
|
|
|
return out, err, code
|
|
|
|
|
if method == "nopasswd":
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"sudo on '{alias}' requires a password, but the alias is configured with "
|
|
|
|
|
f"sudo='nopasswd' (which only ever runs 'sudo -n' and never prompts). "
|
|
|
|
|
f"Re-add the alias with sudo='prompt', or grant this user a NOPASSWD rule "
|
|
|
|
|
f"in the remote /etc/sudoers."
|
|
|
|
|
)
|
|
|
|
|
pw = _sudo_password(alias)
|
|
|
|
|
if pw is None:
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"sudo password required for alias '{alias}' but none was provided "
|
|
|
|
|
f"({_no_secret_reason()}). Either answer the sudo prompt in the Agent Inbox, or — for "
|
|
|
|
|
f"unattended runs — set SSH_MCP_SUDO_PASSWORD in the connector settings, or "
|
|
|
|
|
f"grant this user NOPASSWD sudo on the host."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
out, err, code = _run_with_stdin(
|
|
|
|
|
client, _sudo_wrap("-S -p ''", sudo_user, command), timeout, pw + "\n")
|
|
|
|
|
if _sudo_says(err, code, _SUDO_BAD_PW) or _sudo_says(err, code, _SUDO_NEEDS_PW):
|
|
|
|
|
_sudo_pw_cache.pop(alias, None) # drop it so the next call re-prompts
|
|
|
|
|
raise ToolError(
|
|
|
|
|
f"sudo password rejected on '{alias}'"
|
|
|
|
|
+ (" (from SSH_MCP_SUDO_PASSWORD)" if SUDO_PASSWORD_ENV else
|
|
|
|
|
" — the cached password was discarded, retry to be asked again")
|
|
|
|
|
)
|
|
|
|
|
return out, err, code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SFTP helpers ───────────────────────────────────────────────────────────────
|
|
|
|
@@ -628,7 +835,7 @@ def _tool_list_files(args: dict) -> str:
|
|
|
|
|
alias, path = args.get("alias"), args.get("path")
|
|
|
|
|
if not alias or not path:
|
|
|
|
|
return "Error: 'alias' and 'path' are required"
|
|
|
|
|
max_depth = int(args.get("depth", 3))
|
|
|
|
|
max_depth = _int(args, "depth", 3)
|
|
|
|
|
dirs_only = bool(args.get("dirs_only", False))
|
|
|
|
|
sftp = _get_sftp(alias)
|
|
|
|
|
|
|
|
|
@@ -675,8 +882,8 @@ def _tool_grep_files(args: dict) -> str:
|
|
|
|
|
if not alias or not path or pattern is None:
|
|
|
|
|
return "Error: 'alias', 'path' and 'pattern' are required"
|
|
|
|
|
mode = args.get("output_mode", "content")
|
|
|
|
|
ctx = min(int(args.get("context_lines", 0) or 0), 10)
|
|
|
|
|
maxr = int(args.get("max_results", 100))
|
|
|
|
|
ctx = min(_int(args, "context_lines", 0), 10)
|
|
|
|
|
maxr = _int(args, "max_results", 100)
|
|
|
|
|
client = _get_client(alias)
|
|
|
|
|
|
|
|
|
|
flags = _grep_flags(args)
|
|
|
|
@@ -840,32 +1047,38 @@ def _tool_exec(args: dict) -> str:
|
|
|
|
|
return "Error: 'alias' and 'command' are required"
|
|
|
|
|
sudo = bool(args.get("sudo", False))
|
|
|
|
|
sudo_user = args.get("sudo_user")
|
|
|
|
|
timeout = int(args.get("timeout_sec", DEFAULT_CMD_TIMEOUT))
|
|
|
|
|
timeout = _int(args, "timeout_sec", DEFAULT_CMD_TIMEOUT)
|
|
|
|
|
cfg = _find_alias(alias)
|
|
|
|
|
if not cfg:
|
|
|
|
|
return f"Error: unknown alias '{alias}'"
|
|
|
|
|
|
|
|
|
|
pw = None
|
|
|
|
|
wrapped = command
|
|
|
|
|
if sudo:
|
|
|
|
|
prefix, pw = _sudo_prefix(alias, cfg, sudo_user)
|
|
|
|
|
wrapped = prefix + command
|
|
|
|
|
# A `sudo` the agent typed into `command` is the same intent as sudo=true —
|
|
|
|
|
# honour it here rather than nesting a second, tty-less sudo remotely.
|
|
|
|
|
command, inline_sudo, inline_user = _split_leading_sudo(command)
|
|
|
|
|
if inline_sudo:
|
|
|
|
|
sudo = True
|
|
|
|
|
sudo_user = sudo_user or inline_user
|
|
|
|
|
if sudo_user:
|
|
|
|
|
sudo = True # `sudo -u X` is meaningless without sudo itself
|
|
|
|
|
if not command.strip():
|
|
|
|
|
return "Error: 'command' is empty"
|
|
|
|
|
|
|
|
|
|
client = _get_client(alias)
|
|
|
|
|
try:
|
|
|
|
|
chan_in, chan_out, chan_err = client.exec_command(wrapped, timeout=timeout)
|
|
|
|
|
if pw is not None:
|
|
|
|
|
try:
|
|
|
|
|
chan_in.write(pw + "\n")
|
|
|
|
|
chan_in.flush()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
out = chan_out.read().decode("utf-8", "replace")
|
|
|
|
|
err = chan_err.read().decode("utf-8", "replace")
|
|
|
|
|
code = chan_out.channel.recv_exit_status()
|
|
|
|
|
except socket.timeout:
|
|
|
|
|
return f"Error: command timed out after {timeout}s"
|
|
|
|
|
return json.dumps({"stdout": out, "stderr": err, "exit_code": code})
|
|
|
|
|
if sudo:
|
|
|
|
|
out, err, code = _run_sudo(alias, cfg, client, command, sudo_user, timeout)
|
|
|
|
|
else:
|
|
|
|
|
out, err, code = _run_with_stdin(client, command, timeout)
|
|
|
|
|
|
|
|
|
|
result = {"stdout": out, "stderr": err, "exit_code": code}
|
|
|
|
|
if _sudo_says(err, code, _SUDO_NEEDS_PW):
|
|
|
|
|
# A sudo buried mid-command (e.g. `cd /x && sudo …`) that we could not
|
|
|
|
|
# peel off. Don't silently re-run the whole line as root — tell the LLM.
|
|
|
|
|
result["hint"] = (
|
|
|
|
|
"this command used sudo internally and sudo asked for a password on a "
|
|
|
|
|
"tty it does not have. Re-run it with sudo=true and no 'sudo' inside "
|
|
|
|
|
"`command` (with sudo=true the whole command line runs as root)."
|
|
|
|
|
)
|
|
|
|
|
return json.dumps(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tool_systemd(args: dict) -> str:
|
|
|
|
@@ -884,11 +1097,8 @@ def _tool_systemd(args: dict) -> str:
|
|
|
|
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
if action != "status":
|
|
|
|
|
prefix, pw = _sudo_prefix(alias, cfg, None)
|
|
|
|
|
out, err, code = _run_with_stdin(
|
|
|
|
|
client, f"{prefix}systemctl {action} {qsvc}", DEFAULT_CMD_TIMEOUT,
|
|
|
|
|
(pw + "\n") if pw else None,
|
|
|
|
|
)
|
|
|
|
|
out, err, code = _run_sudo(
|
|
|
|
|
alias, cfg, client, f"systemctl {action} {qsvc}", None, DEFAULT_CMD_TIMEOUT)
|
|
|
|
|
parts.append(f"$ systemctl {action} {service} (exit {code})")
|
|
|
|
|
if out.strip():
|
|
|
|
|
parts.append(out.strip())
|
|
|
|
@@ -1064,7 +1274,7 @@ TOOLS = [
|
|
|
|
|
"auth": {"type": "string", "enum": ["key", "password"],
|
|
|
|
|
"description": "Login auth. key: SSH key/agent (default). password: login password asked on demand via elicitation, kept only in RAM."},
|
|
|
|
|
"sudo": {"type": "string", "enum": ["nopasswd", "prompt", "none"],
|
|
|
|
|
"description": "How sudo authenticates. Use 'prompt' unless you KNOW otherwise — it is the safe default: runs 'sudo -S' and asks the user for the sudo password on demand via elicitation, so it works on any host where the login user is a normal sudoer. Only pick 'nopasswd' when the remote /etc/sudoers actually grants THIS user passwordless sudo (a NOPASSWD: rule): it runs 'sudo -n' and NEVER prompts, so on a normal host every sudo call fails immediately with 'a password is required'. 'none' disables sudo. Default prompt."},
|
|
|
|
|
"description": "How sudo authenticates. Keep the default 'prompt' unless you KNOW otherwise: it tries 'sudo -n' first (so a host with a NOPASSWD rule never prompts) and only asks the user for the sudo password via elicitation if the host actually demands one. 'nopasswd' runs 'sudo -n' only and NEVER prompts — pick it just to forbid prompting, since on a host without a NOPASSWD rule every sudo call then fails. 'none' disables sudo entirely. Default prompt."},
|
|
|
|
|
"accept_new_host_key": {"type": "boolean", "description": "Trust the host key on first connect (TOFU). Default false."},
|
|
|
|
|
},
|
|
|
|
|
"required": ["alias", "hostname", "username"],
|
|
|
|
@@ -1161,14 +1371,20 @@ TOOLS = [
|
|
|
|
|
{
|
|
|
|
|
"name": "exec",
|
|
|
|
|
"title": "Execute Command",
|
|
|
|
|
"description": "Run a command on the remote host. Set sudo=true to run via sudo (method per alias).",
|
|
|
|
|
"description": (
|
|
|
|
|
"Run a command on the remote host. For anything needing root, set sudo=true and "
|
|
|
|
|
"write the command WITHOUT a 'sudo' prefix — with sudo=true the entire command "
|
|
|
|
|
"line (pipes and redirections included) runs as root. A leading 'sudo' left in "
|
|
|
|
|
"`command` is stripped and treated as sudo=true anyway, but a 'sudo' in the middle "
|
|
|
|
|
"of a command (e.g. 'cd /x && sudo …') has no tty and will fail."
|
|
|
|
|
),
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"alias": _ALIAS,
|
|
|
|
|
"command": {"type": "string", "description": "Shell command."},
|
|
|
|
|
"sudo": {"type": "boolean", "description": "Run via sudo (default false)."},
|
|
|
|
|
"sudo_user": {"type": "string", "description": "Target user for sudo -u (optional)."},
|
|
|
|
|
"command": {"type": "string", "description": "Shell command. Do NOT prefix it with 'sudo' — use the sudo argument."},
|
|
|
|
|
"sudo": {"type": "boolean", "description": "Run the whole command as root, via sudo (default false)."},
|
|
|
|
|
"sudo_user": {"type": "string", "description": "Target user for sudo -u (optional; implies sudo)."},
|
|
|
|
|
"timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."},
|
|
|
|
|
},
|
|
|
|
|
"required": ["alias", "command"],
|
|
|
|
@@ -1248,10 +1464,16 @@ def handle_message(msg: dict) -> dict | None:
|
|
|
|
|
req_id = msg.get("id")
|
|
|
|
|
|
|
|
|
|
if method == "initialize":
|
|
|
|
|
# Remember whether the client can collect input mid-call: without the
|
|
|
|
|
# elicitation capability we must never block on a password prompt.
|
|
|
|
|
global _client_can_elicit
|
|
|
|
|
caps = (msg.get("params") or {}).get("capabilities") or {}
|
|
|
|
|
_client_can_elicit = "elicitation" in caps
|
|
|
|
|
log(f"client elicitation capability: {'yes' if _client_can_elicit else 'no'}")
|
|
|
|
|
return _ok(req_id, {
|
|
|
|
|
"protocolVersion": "2025-06-18",
|
|
|
|
|
"capabilities": {"tools": {}},
|
|
|
|
|
"serverInfo": {"name": "ssh", "version": "1.0.0"},
|
|
|
|
|
"serverInfo": {"name": "ssh", "version": "1.1.0"},
|
|
|
|
|
})
|
|
|
|
|
if method == "notifications/initialized":
|
|
|
|
|
return None
|
|
|
|
@@ -1283,7 +1505,7 @@ def main() -> None:
|
|
|
|
|
log("starting SSH MCP server")
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
msg = readline()
|
|
|
|
|
msg = next_message()
|
|
|
|
|
if msg is None:
|
|
|
|
|
break
|
|
|
|
|
resp = handle_message(msg)
|
|
|
|
|