ssh: fix sudo failing with "password required" (v6 / 1.1.0)
The connector asked for a sudo password on every privileged call and failed whenever nobody answered it, which is every unattended run. - Always probe `sudo -n` first, even for aliases set to sudo="prompt". `_sudo_prefix` used to elicit unconditionally, so a host granting this user NOPASSWD still opened an Agent Inbox prompt; with no human there it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and surfaced as "sudo password required (user declined or timed out)". sudo refuses before running anything when it wants a password, so the probe is side-effect free. - Strip a leading `sudo` from `command` and turn it into sudo=true. Agents write `exec(command="sudo systemctl restart x")`: with sudo=false that ran a tty-less sudo, with sudo=true it nested `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone. sudo_user now implies sudo=true. - Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and redirections are elevated too instead of only the first word. - Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It is consulted only after `sudo -n` proved a password is needed, so on a NOPASSWD host it never lands in the command's own stdin. - Actionable errors for every sudo failure mode, and a `hint` on a nested sudo we could not peel off. General review of the same server: - Drain stdout and stderr together and make timeout_sec a real wall-clock deadline. Both streams share one SSH channel window, so reading stdout to EOF first stalled once a chatty stderr filled it. Command stdin is now closed after the optional password. - Queue messages that arrive while awaiting an elicitation reply instead of discarding them, so a concurrent tools/call is not lost. - Record the client's `elicitation` capability at initialize and fail fast when it is absent rather than blocking on a prompt nobody can answer. - Tolerate null/string integer arguments (depth, max_results, context_lines, timeout_sec). - Realign the version across both manifests: fragment.json said 5/1.0.4 while connector.json said 2/1.0.1, so the feed was permanently ahead of the installed version and offered an update forever. Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the single source of truth, docs/connector.manifest_guide.md retired to a pointer, CLAUDE.md audited against the repo, compile.py docstring fixed, and an opencode.json config.
This commit is contained in:
@@ -63,6 +63,14 @@
|
||||
"required": false,
|
||||
"secret": true,
|
||||
"example": ""
|
||||
},
|
||||
{
|
||||
"name": "SSH_MCP_SUDO_PASSWORD",
|
||||
"label": "Sudo password (non-interactive override)",
|
||||
"description": "Sudo password used when the remote host demands one and nobody can answer the Agent Inbox prompt (unattended/scheduled runs). Leave empty to always ask interactively. Only used after 'sudo -n' has proved the host really requires a password.",
|
||||
"required": false,
|
||||
"secret": true,
|
||||
"example": ""
|
||||
}
|
||||
],
|
||||
"setup_instructions": [
|
||||
@@ -90,7 +98,8 @@
|
||||
"SSH_MCP_CONNECT_TIMEOUT": "{ENV:SSH_MCP_CONNECT_TIMEOUT}",
|
||||
"SSH_MCP_LOGIN_PW_TTL": "{ENV:SSH_MCP_LOGIN_PW_TTL}",
|
||||
"SSH_MCP_SUDO_PW_TTL": "{ENV:SSH_MCP_SUDO_PW_TTL}",
|
||||
"SSH_MCP_KEY_PASSPHRASE": "{SECRET:SSH_MCP_KEY_PASSPHRASE}"
|
||||
"SSH_MCP_KEY_PASSPHRASE": "{SECRET:SSH_MCP_KEY_PASSPHRASE}",
|
||||
"SSH_MCP_SUDO_PASSWORD": "{SECRET:SSH_MCP_SUDO_PASSWORD}"
|
||||
}
|
||||
},
|
||||
"homepage": "",
|
||||
@@ -109,7 +118,7 @@
|
||||
"auth": {
|
||||
"type": "none"
|
||||
},
|
||||
"version": 2,
|
||||
"version_string": "1.0.1",
|
||||
"version_release_date": "2026-07-21"
|
||||
"version": 6,
|
||||
"version_string": "1.1.0",
|
||||
"version_release_date": "2026-09-03"
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"type": "none"
|
||||
},
|
||||
"folder": "ssh",
|
||||
"version": 5,
|
||||
"version_string": "1.0.4",
|
||||
"version_release_date": "2026-07-21"
|
||||
"version": 6,
|
||||
"version_string": "1.1.0",
|
||||
"version_release_date": "2026-09-03"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user