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 c3d6660881
commit fe7227af62
21 changed files with 2452 additions and 7 deletions
+16
View File
@@ -5,6 +5,12 @@ what ``init_db()`` relies on to create the schema at startup. Any new model
module must be imported here or its table will silently never be created.
"""
from lembas.db.models.agent import (
AUTH_KEY,
AUTH_METHODS,
AUTH_PASSWORD,
SshProfile,
)
from lembas.db.models.attachment import (
KIND_DOCUMENT,
KIND_IMAGE,
@@ -12,6 +18,9 @@ from lembas.db.models.attachment import (
Attachment,
)
from lembas.db.models.chat import (
KIND_AGENT,
KIND_CHAT,
KINDS,
ROLE_ASSISTANT,
ROLE_SYSTEM,
ROLE_TOOL,
@@ -68,8 +77,14 @@ from lembas.db.models.user import (
__all__ = [
"AUTHOR_MODEL",
"AUTH_KEY",
"AUTH_METHODS",
"AUTH_PASSWORD",
"AUTHOR_USER",
"Attachment",
"KINDS",
"KIND_AGENT",
"KIND_CHAT",
"KIND_DOCUMENT",
"KIND_IMAGE",
"KIND_TEXT",
@@ -111,6 +126,7 @@ __all__ = [
"Setting",
"Share",
"Skill",
"SshProfile",
"SkillRevision",
"Suggestion",
"User",
+103
View File
@@ -0,0 +1,103 @@
"""SSH connections an agent chat can act through.
User-owned, like a `Note` and unlike a `Connection`. That is the opposite of
the rule custom tools and MCP servers follow, and the difference is the point:
those are instance configuration an administrator could grant themselves in one
click anyway, while this is somebody's own machine and somebody's own key.
"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.
**Nothing an agent does runs on the LLeMbas machine.** A local sandbox was
designed and dropped: every hard problem in it came from executing on the host
that holds the database and the encryption key. Over SSH, isolation is whatever
host somebody points this at -- which means the security of an agent chat is the
security of that host, and nothing here can tell a throwaway container from a
production server. The admin copy says so out loud.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
if TYPE_CHECKING: # pragma: no cover - annotation only
from lembas.db.models.user import User
# How the connection authenticates.
AUTH_KEY = "key"
AUTH_PASSWORD = "password"
AUTH_METHODS = (AUTH_KEY, AUTH_PASSWORD)
class SshProfile(UUIDPrimaryKey, Timestamps, Base):
"""One host somebody can point an agent chat at."""
__tablename__ = "ssh_profiles"
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_ssh_profile_name"),)
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=22, nullable=False)
username: Mapped[str] = mapped_column(String(120), nullable=False)
auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, default="")
private_key_encrypted: Mapped[str] = mapped_column(Text, default="")
key_passphrase_encrypted: Mapped[str] = mapped_column(Text, default="")
# One OpenSSH known_hosts line, captured the first time this host answered
# and shown as a fingerprint to be confirmed, then pinned. Empty means
# "never seen". Handed to asyncssh as `known_hosts=<these bytes>` and never
# as None, which turns host key checking off altogether.
host_key: Mapped[str] = mapped_column(Text, default="")
# The SHA256 fingerprint of the above, so the profile page can show what was
# accepted without parsing the line again on every render.
host_fingerprint: Mapped[str] = mapped_column(String(120), default="")
# Where a chat starts by default. A chat records its own, chosen when it is
# created and fixed thereafter; this is only the suggestion in the picker.
default_dir: Mapped[str] = mapped_column(String(500), default="")
connect_timeout: Mapped[int] = mapped_column(Integer, default=15, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# What the last connection attempt found, for the list. `server_banner` is
# whatever the host said about itself -- useful for telling two containers
# apart.
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
owner: Mapped[User] = relationship()
@property
def label(self) -> str:
return self.name or f"{self.username}@{self.host}"
@property
def address(self) -> str:
return f"{self.username}@{self.host}" + (f":{self.port}" if self.port != 22 else "")
@property
def verified(self) -> bool:
"""Whether this host's key has been seen and pinned."""
return bool(self.host_key)
def __repr__(self) -> str:
return f"<SshProfile {self.name} {self.address}>"
__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "SshProfile"]
+41
View File
@@ -22,6 +22,17 @@ ROLE_USER = "user"
ROLE_ASSISTANT = "assistant"
ROLE_TOOL = "tool"
# What a conversation is allowed to be. A plain chat can never act; an agent
# chat is pointed at a machine before it starts and stays pointed there.
KIND_CHAT = "chat"
KIND_AGENT = "agent"
KINDS = (KIND_CHAT, KIND_AGENT)
# Duplicated from services/agent/policy.py rather than imported: a model module
# importing a service would invert the dependency, and this is only the column
# default. policy.MODES is the vocabulary; this is what a row starts as.
MODE_MANUAL = "manual"
class Folder(UUIDPrimaryKey, Timestamps, Base):
"""A user-owned, arbitrarily nested container for chats."""
@@ -109,6 +120,31 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# --- Agent chats ---------------------------------------------------------
# Whether this conversation may act, and where. Chosen on the new-chat
# screen and fixed once there is a message: the harness, the tools offered
# and the approval loop all differ, so a chat that changed kind halfway
# would have a transcript whose earlier turns were produced under other
# rules. The connection is locked with it -- a shell history and a project
# directory do not transplant to another machine.
kind: Mapped[str] = mapped_column(String(16), default=KIND_CHAT, nullable=False)
# A plain id rather than a ForeignKey, for the reason `compacted_through_id`
# below gives: migrations.py compiles only the column type, so a REFERENCES
# clause would exist on a fresh database and not on an upgraded one.
# Validated on read instead.
ssh_profile_id: Mapped[str | None] = mapped_column(String(32))
# Where commands start on the far side, and what file paths resolve against.
project_dir: Mapped[str] = mapped_column(String(500), default="")
# Which of the four permission modes is in force. The one agent field that
# IS switchable mid-chat: it decides what gets asked about, not what the
# conversation is.
agent_mode: Mapped[str] = mapped_column(String(16), default=MODE_MANUAL, nullable=False)
# Set when a turn was edited or regenerated in an agent chat. The project
# directory is deliberately NOT rewound with the transcript -- it is
# somebody's real working tree and deleting their work would be far worse
# than an inconsistency -- so the harness says so instead.
rewound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# --- Compaction ----------------------------------------------------------
# A summary of the turns up to `compacted_through_id`, sent in their place.
# The messages themselves are kept and still shown; they simply stop being
@@ -174,6 +210,11 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# A plan produced in Plan mode: {"title": str, "steps": [str, ...]}. Marked
# on the row rather than parsed back out of the prose, so the Execute button
# sends exactly what was proposed and not an approximation of it.
plan_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Non-empty when generation failed. Rendered as a styled error in the
# thread so a failed turn is never an unexplained blank bubble.
error: Mapped[str] = mapped_column(Text, default="")