SSH connections, kept by the people who own them

An agent chat will act on a machine you choose, so this is the screen where
you choose it. User-owned like a note, not admin-owned like a connection:
these are somebody's own machines and somebody's own keys, and "anyone in
this group may log in to my server" is a different feature with a different
blast radius. services/sharing.py is deliberately not involved either --
sharing grants reading, and a host somebody else can read is a host they
can log in to.

Trust on first use, made explicit rather than assumed. Adding a host does
not connect to it. Check looks at its key and shows you the fingerprint;
nothing is sent until you accept, because get_server_host_key completes the
key exchange and stops -- no username, no credential. Accepting pins it,
and a host that later presents a different key is refused with the reason
rather than quietly trusted. Moving a profile to another host or port
forgets the pin, since a key belongs to the machine it came from.

Four asyncssh defaults are actively wrong here and all four are passed
explicitly: every LLeMbas user shares one unix account, so `known_hosts`
would be a shared trust store, `client_keys` would authenticate one person
with another's key, `config` would let a ProxyCommand redirect the
connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a
test for exactly that, and it needs no server.

Files go over SFTP rather than through a shell. The SSH exec protocol
carries one command *string* that the far side parses, with no argv form at
all, so a model-supplied path in a command line is unavoidably a quoting
problem. Over SFTP a path is a path.

Chat gains its kind, connection, project directory and mode; the first
three are fixed once a chat has a message, because a transcript whose
earlier turns ran somewhere else is not one conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:34:58 +02:00
parent 0a4531f02d
commit 4ced049ff8
21 changed files with 2452 additions and 7 deletions
+349
View File
@@ -0,0 +1,349 @@
"""Acting on a machine over SSH.
Connections are made per call, for the reason MCP sessions are, plus one more: a
live `SSHClientConnection` is exactly the kind of state `ToolContext` exists so
that nothing holds. A command is already a network round trip inside a reply
that takes seconds, so a second one to open the channel is not the cost worth
optimising.
**Four asyncssh defaults are actively wrong here, and all four are passed
explicitly on every connection.** Every LLeMbas user shares one unix account, so
"whatever the account has lying around" is never the right answer:
* `known_hosts` unset reads that shared `~/.ssh/known_hosts` -- one trust store
for everybody. Set to `None` it disables host key checking altogether, which
is never correct and is the single easiest way to make this insecure.
* `client_keys` unset loads `~/.ssh/id_*`, so one person's chat could
authenticate with a key another person left there, or with the server's own.
* `config` unset reads `~/.ssh/config`, where a `Hostname` or `ProxyCommand`
can send the connection somewhere else entirely.
* `agent_path` unset silently uses `$SSH_AUTH_SOCK`.
`asyncssh` is an optional dependency, imported inside the functions that need it
so an instance with agents switched off never pays for it and an instance that
forgot to install it gets a sentence rather than an ImportError at startup.
"""
from __future__ import annotations
import logging
import time
from typing import Any
from lembas.db.models import AUTH_PASSWORD, SshProfile
from lembas.services.agent.base import (
ExecError,
ExecRequest,
ExecResult,
clean_output,
)
from lembas.services.crypto import decrypt
log = logging.getLogger(__name__)
# A file read into a model's context, and one written out of it. Both bounded:
# the first because a 40 MB log would fill the window, the second because
# nothing a model writes in one call should be larger than this.
MAX_READ_BYTES = 256 * 1024
MAX_WRITE_BYTES = 1024 * 1024
# How many entries a directory listing returns before it is cut short.
MAX_ENTRIES = 500
INSTALL_HINT = (
"SSH support is not installed. Run `pip install -e \".[ssh]\"` in the "
"LLeMbas virtual environment and restart."
)
def available() -> str:
"""Empty when SSH can be used, else why it cannot.
Shaped like `search.availability`, and used the same way: the feature stays
visible in the UI with an install hint rather than silently missing.
"""
try:
import asyncssh # noqa: F401
except ImportError:
return INSTALL_HINT
return ""
def spec_from(profile: SshProfile) -> dict[str, Any]:
"""A session-free snapshot of one profile, credential decrypted.
Called while the session is open. The plaintext lives in the returned dict
and nowhere else; `generation` drops it when the reply ends.
"""
return {
"id": profile.id,
"label": profile.label,
"host": profile.host,
"port": int(profile.port or 22),
"username": profile.username,
"auth": profile.auth,
"password": decrypt(profile.password_encrypted),
"private_key": decrypt(profile.private_key_encrypted),
"key_passphrase": decrypt(profile.key_passphrase_encrypted),
"host_key": profile.host_key,
"connect_timeout": int(profile.connect_timeout or 15),
}
def _connect_kwargs(spec: dict[str, Any]) -> dict[str, Any]:
"""Everything asyncssh must be told rather than left to discover.
See the module docstring: every one of these has a default that is wrong
when one unix account is shared by every user of the instance.
"""
if not spec.get("host_key"):
raise ExecError(
"This connection's host key has not been confirmed yet. Open it "
"under Agents and press Check, then accept the fingerprint."
)
keys: list = []
if spec.get("auth") != AUTH_PASSWORD and spec.get("private_key"):
import asyncssh
try:
keys = [
asyncssh.import_private_key(
spec["private_key"], passphrase=spec.get("key_passphrase") or None
)
]
except Exception as exc: # noqa: BLE001 - any failure here is one message
raise ExecError(f"That private key could not be read: {exc}") from exc
timeout = int(spec.get("connect_timeout") or 15)
return {
"username": spec["username"],
"port": int(spec.get("port") or 22),
# Bytes, never None. None turns host key checking off entirely.
"known_hosts": spec["host_key"].encode(),
"client_keys": keys,
"password": (spec.get("password") or None) if spec.get("auth") == AUTH_PASSWORD else None,
"config": None,
"agent_path": None,
"connect_timeout": timeout,
"login_timeout": timeout,
}
async def capture_host_key(host: str, port: int, *, timeout: int = 15) -> tuple[str, str]:
"""The host's key as a known_hosts line, and its SHA256 fingerprint.
`get_server_host_key` completes the key exchange and stops, so nothing is
offered to a host that has not been accepted yet -- no username, no
password, no key. That is what makes trust-on-first-use safe to do from a
button rather than only from a terminal.
"""
if problem := available():
raise ExecError(problem)
import asyncio
import asyncssh
try:
key = await asyncio.wait_for(
asyncssh.get_server_host_key(host, port=port), timeout=timeout
)
except TimeoutError as exc:
raise ExecError(f"{host} did not answer within {timeout}s.") from exc
except (OSError, asyncssh.Error) as exc:
raise ExecError(f"Could not reach {host}: {exc}") from exc
if key is None:
raise ExecError(f"{host} offered no host key.")
algorithm = key.get_algorithm()
encoded = key.export_public_key("openssh").decode().split()[1]
where = f"[{host}]:{port}" if port != 22 else host
return f"{where} {algorithm} {encoded}\n", key.get_fingerprint("sha256")
class SshExecutor:
"""One target, reached over SSH. A connection per call."""
def __init__(self, spec: dict[str, Any], project_dir: str = "") -> None:
self.spec = spec
self.project_dir = project_dir or ""
self.label = str(spec.get("label") or spec.get("host") or "the remote host")
def _connect(self):
if problem := available():
raise ExecError(problem)
import asyncssh
return asyncssh.connect(self.spec["host"], **_connect_kwargs(self.spec))
def _wrap(self, exc: Exception) -> ExecError:
import asyncssh
if isinstance(exc, asyncssh.HostKeyNotVerifiable):
return ExecError(
f"{self.label} presented a different host key than the one that "
"was confirmed. Nothing was sent. If the host was rebuilt, open "
"it under Agents and confirm the new fingerprint."
)
if isinstance(exc, asyncssh.PermissionDenied):
return ExecError(f"{self.label} refused the credential.")
return ExecError(f"Could not reach {self.label}: {exc}")
async def run(self, request: ExecRequest) -> ExecResult:
"""Run one command and read back what it said.
Every command is a fresh shell, so `cd` does not carry between calls --
the working directory is set here, from `cwd` or the chat's project
directory, and never spliced into the command string.
"""
import asyncssh
started = time.monotonic()
directory = request.cwd or self.project_dir
# A single-quoted path, with any embedded quote escaped. `cd` needs a
# shell, so this is the one place a path meets one -- and it is a path
# from the chat's own configuration, not from the model, except when the
# model passed `cwd`, which is why it is quoted rather than trusted.
command = request.command
if directory:
command = f"cd {_quote(directory)} && {command}"
try:
async with self._connect() as conn:
result = await conn.run(
command,
check=False,
timeout=request.timeout,
# Interleaved, because a shell transcript is what the model
# has to read and separating them loses the ordering.
stderr=asyncssh.STDOUT,
# A command that waits for input fails at once instead of
# sitting out its whole timeout in silence.
stdin=asyncssh.DEVNULL,
)
except TimeoutError:
elapsed = int((time.monotonic() - started) * 1000)
return ExecResult(
exit_status=-1,
output=f"The command was still running after {request.timeout:g}s and was stopped.",
timed_out=True,
duration_ms=elapsed,
)
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
output, truncated = clean_output(result.stdout or "", limit=request.max_bytes)
return ExecResult(
exit_status=result.exit_status if result.exit_status is not None else -1,
output=output,
truncated=truncated,
duration_ms=int((time.monotonic() - started) * 1000),
)
# --- Files go over SFTP, never through a shell ---------------------------
# The SSH exec protocol carries one command *string* that the far side's
# shell parses; there is no argv form. So a path in a command line is
# unavoidably a quoting problem, and a model-supplied path is exactly the
# input that must not become one. Over SFTP a path is a path.
async def read_file(self, path: str, *, max_bytes: int = MAX_READ_BYTES) -> str:
import asyncssh
try:
async with (
self._connect() as conn,
conn.start_sftp_client() as sftp,
sftp.open(self._resolve(path), "rb") as handle,
):
data = await handle.read(max_bytes + 1)
except asyncssh.SFTPNoSuchFile as exc:
raise ExecError(f"There is no file at {path}.") from exc
except asyncssh.SFTPPermissionDenied as exc:
raise ExecError(f"Not allowed to read {path}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
text, _truncated = clean_output(data[:max_bytes], limit=max_bytes)
return text
async def write_file(self, path: str, text: str) -> int:
import asyncssh
payload = text.encode("utf-8")[:MAX_WRITE_BYTES]
try:
async with (
self._connect() as conn,
conn.start_sftp_client() as sftp,
sftp.open(self._resolve(path), "wb") as handle,
):
await handle.write(payload)
except asyncssh.SFTPPermissionDenied as exc:
raise ExecError(f"Not allowed to write {path}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
return len(payload)
async def list_dir(self, path: str = "") -> list[str]:
import asyncssh
try:
async with self._connect() as conn, conn.start_sftp_client() as sftp:
target = self._resolve(path) if path else (self.project_dir or ".")
names = await sftp.listdir(target)
except asyncssh.SFTPNoSuchFile as exc:
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
visible = sorted(n for n in names if n not in (".", ".."))
return visible[:MAX_ENTRIES]
def _resolve(self, path: str) -> str:
"""A path relative to the project directory, unless it is absolute.
Deliberately *not* a containment check. The account on the far side is
the boundary -- a profile whose user can only see /srv/project can only
reach things under it -- and pretending otherwise here would be a
comfort rather than a control, since `shell_run` could walk out of it in
one line anyway.
"""
if not path:
return self.project_dir or "."
if path.startswith("/") or not self.project_dir:
return path
return f"{self.project_dir.rstrip('/')}/{path.lstrip('/')}"
def _quote(value: str) -> str:
return "'" + value.replace("'", "'\\''") + "'"
async def check(spec: dict[str, Any], project_dir: str = "") -> dict[str, Any]:
"""Connect, confirm the project directory, and report what was found.
Used by the Check button on a profile. Runs one harmless command rather than
only opening a connection, because "the credential works" and "the directory
is there" are the two things somebody is actually asking about.
"""
executor = SshExecutor(spec, project_dir)
result = await executor.run(
ExecRequest(command="uname -sr 2>/dev/null; pwd", timeout=15, max_bytes=4096)
)
lines = [line for line in result.output.splitlines() if line.strip()]
return {
"ok": result.ok,
"system": lines[0] if lines else "",
"cwd": lines[-1] if len(lines) > 1 else "",
"output": result.output,
}
__all__ = [
"INSTALL_HINT",
"MAX_READ_BYTES",
"SshExecutor",
"available",
"capture_host_key",
"check",
"spec_from",
]