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
+91
View File
@@ -0,0 +1,91 @@
"""Whether agent chats exist here at all, and what they may spend.
An administrator's half of the feature. The other half -- which machines, whose
credentials -- belongs to whoever owns them and lives at `/agents`.
Nothing here is about isolation, because there is none to configure: commands
run on a host somebody chose, and its containment is that host's. The settings
are budgets, and the two lists that decide what a mode asks about.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import AdminUser, Db
from lembas.db.models import SshProfile
from lembas.services import settings_store
from lembas.services.agent import policy
from lembas.services.agent import ssh as ssh_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
def _lines(text: str) -> list[str]:
"""One pattern per line, blanks dropped."""
return [line.strip() for line in (text or "").splitlines() if line.strip()]
@router.get("")
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
values = settings_store.agents(db)
return render(
request,
"admin/agents.html",
{
"values": values,
"allow_text": "\n".join(values.get("allow_default") or []),
"deny_text": "\n".join(values.get("deny_default") or []),
"problem": ssh_service.available(),
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
"saved": saved,
},
)
@router.post("")
async def save_agents(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
default_timeout: int = Form(60),
max_timeout: int = Form(600),
max_output_bytes: int = Form(64 * 1024),
max_steps: int = Form(40),
max_wall_seconds: int = Form(900),
max_total_output_bytes: int = Form(1024 * 1024),
approval_timeout: int = Form(900),
allow_default: str = Form(""),
deny_default: str = Form(""),
ask_free_text: bool = Form(False),
) -> Response:
settings_store.update(
db,
{
"enabled": enabled,
# Clamped here as well as on read. A number with no bound is a way
# to break the instance from a form, which is the same reasoning
# the search settings carry.
"default_timeout": min(max(default_timeout, 1), 3600),
"max_timeout": min(max(max_timeout, 1), 3600),
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
"max_steps": min(max(max_steps, 1), 200),
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
"approval_timeout": min(max(approval_timeout, 60), 3600),
"allow_default": _lines(allow_default),
"deny_default": _lines(deny_default),
"ask_free_text": ask_free_text,
},
key=settings_store.AGENTS,
)
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+1
View File
@@ -42,6 +42,7 @@ TOOL_CAPABILITIES = (
("tool_custom", "Custom tools"),
("tool_mcp", "MCP servers"),
("tool_ask", "Ask the reader"),
("tool_agent", "Agent execution"),
)
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
+348
View File
@@ -0,0 +1,348 @@
"""SSH connections, kept by the people who own them.
Not an admin screen. These are somebody's own machines and somebody's own keys,
so the pages sit beside the library rather than under `/admin` -- an
administrator decides only whether the feature exists at all.
Trust on first use, made explicit. Adding a host does not connect to it; the
**Check** button looks at its key, shows the fingerprint, and waits. Only when
that is accepted is the key pinned, and only then will anything authenticate.
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
that has not been accepted is never offered a username, let alone a credential.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
from lembas.services import settings_store
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import ExecError
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
)
def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
"""One profile belonging to this person.
Ownership is the whole authorisation. `sharing.py` is deliberately not
involved: it grants reading, and a host somebody else can read is a host
they can log in to.
"""
profile = db.get(SshProfile, profile_id)
if profile is None or profile.owner_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
return profile
def _owned(db: Db, user_id: str) -> list[SshProfile]:
return list(
db.scalars(
select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
)
)
def _back(message: str = "") -> Response:
target = f"/agents?saved={message}" if message else "/agents"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
def _number(raw, *, default: int, low: int, high: int) -> int:
text = str(raw or "").strip()
if not text.isdigit():
return default
return min(max(int(text), low), high)
def _apply(profile: SshProfile, form) -> None:
"""Copy a submitted form onto a profile.
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
`x`, and an absent one is exactly what an unticked box sends.
"""
profile.name = str(form.get("name") or "").strip()[:120]
profile.host = str(form.get("host") or "").strip()[:255]
profile.username = str(form.get("username") or "").strip()[:120]
profile.port = _number(form.get("port"), default=22, low=1, high=65535)
profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
profile.default_dir = str(form.get("default_dir") or "").strip()[:500]
method = str(form.get("auth") or "").strip()
profile.auth = method if method in AUTH_METHODS else profile.auth
profile.enabled = "enabled" in form
def _detail(
request: Request,
db: Db,
user: RequiredUser,
profile: SshProfile,
*,
is_new: bool,
error: str = "",
saved: str = "",
):
# The user is passed rather than read off the profile: a draft has never
# been attached to a session, so `profile.owner` is None on the one page
# that most needs a sidebar.
return render(
request,
"agents/detail.html",
{
**sidebar_context(db, user),
"profile": profile,
"is_new": is_new,
"error": error,
"saved": saved,
"unchanged": UNCHANGED_SENTINEL,
"masked_password": mask(decrypt(profile.password_encrypted))
if profile.password_encrypted
else "",
"has_key": bool(profile.private_key_encrypted),
"problem": ssh_service.available(),
},
)
@router.get("/agents")
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
return render(
request,
"agents/index.html",
{
**sidebar_context(db, user),
"profiles": _owned(db, user.id),
"saved": saved,
"problem": ssh_service.available(),
"enabled": bool(settings_store.agents(db).get("enabled")),
},
)
# Registered before /{profile_id}: FastAPI matches in registration order, so
# with the parameterised route first "new" is captured as an id. This has been
# a bug once already, in /admin/models.
@router.get("/agents/new")
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
draft = SshProfile(
owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
)
return _detail(request, db, user, draft, is_new=True)
@router.post("/api/agents")
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
form = await request.form()
profile = SshProfile(owner_id=user.id)
_apply(profile, form)
if problem := _problem(db, profile, user.id):
return _detail(request, db, user, profile, is_new=True, error=problem)
profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
db.add(profile)
db.commit()
log.info("%s added ssh profile %s", user.email, profile.name)
return RedirectResponse(
f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
status_code=status.HTTP_303_SEE_OTHER,
)
def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
if not profile.name:
return "A connection needs a name."
if not profile.host:
return "A connection needs a host."
if not profile.username:
return "A connection needs a username to log in as."
clash = db.scalar(
select(SshProfile).where(
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
)
)
if clash is not None and clash.id != existing_id:
return f"You already have a connection called “{profile.name}”."
return ""
@router.get("/agents/{profile_id}")
async def profile_page(
request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
):
profile = _profile(db, user, profile_id)
return _detail(request, db, user, profile, is_new=False, saved=saved)
@router.post("/api/agents/{profile_id}/check")
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
"""Look at the host's key, and connect if it has already been accepted.
Two steps in one button, because they are one question: *is this the machine
I meant, and will it let me in?* An unseen key comes back as a fingerprint
to accept; an accepted one is used to log in and run something harmless.
"""
profile = _profile(db, user, profile_id)
try:
line, fingerprint = await ssh_service.capture_host_key(
profile.host, profile.port, timeout=profile.connect_timeout
)
except ExecError as exc:
profile.last_error = exc.message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request, "agents/_check.html", {"profile": profile, "error": exc.message}
)
if not profile.host_key:
# First sight. Nothing is pinned until a person says so.
return render(
request,
"agents/_check.html",
{"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
)
if line.strip() != profile.host_key.strip():
message = (
"This host is presenting a different key than the one you accepted. "
"Nothing was sent to it. If you rebuilt the machine, forget the key "
"below and check again; if you did not, stop and find out why."
)
profile.last_error = message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request,
"agents/_check.html",
{
"profile": profile,
"error": message,
"offer": {"line": line, "fingerprint": fingerprint, "changed": True},
},
)
try:
found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
except ExecError as exc:
profile.last_error = exc.message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request, "agents/_check.html", {"profile": profile, "error": exc.message}
)
profile.last_error = ""
profile.last_checked_at = datetime.now(UTC)
profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
db.commit()
return render(request, "agents/_check.html", {"profile": profile, "found": found})
@router.post("/api/agents/{profile_id}/accept")
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
"""Pin the fingerprint that was just shown.
The line is re-fetched rather than taken from the form: a value that made a
round trip through a browser is not what should end up as the thing every
future connection is checked against.
"""
profile = _profile(db, user, profile_id)
try:
line, fingerprint = await ssh_service.capture_host_key(
profile.host, profile.port, timeout=profile.connect_timeout
)
except ExecError as exc:
return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})
profile.host_key = line
profile.host_fingerprint = fingerprint
profile.last_error = ""
db.commit()
log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)
return render(
request,
"agents/_check.html",
{"profile": profile, "accepted": fingerprint},
)
@router.post("/api/agents/{profile_id}/forget")
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
profile = _profile(db, user, profile_id)
profile.host_key = ""
profile.host_fingerprint = ""
db.commit()
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
@router.post("/api/agents/{profile_id}/delete")
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
profile = _profile(db, user, profile_id)
name = profile.name
db.delete(profile)
db.commit()
log.info("%s deleted ssh profile %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/api/agents/{profile_id}")
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
profile = _profile(db, user, profile_id)
form = await request.form()
before = (profile.host, profile.port)
_apply(profile, form)
if problem := _problem(db, profile, user.id, existing_id=profile.id):
db.rollback()
return _detail(
request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
)
profile.password_encrypted = keep_or_replace(
str(form.get("password") or ""), profile.password_encrypted
)
profile.private_key_encrypted = keep_or_replace(
str(form.get("private_key") or ""), profile.private_key_encrypted
)
profile.key_passphrase_encrypted = keep_or_replace(
str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
)
if profile.auth == AUTH_PASSWORD:
profile.private_key_encrypted = ""
profile.key_passphrase_encrypted = ""
# A pinned key belongs to a host and a port. Moving either means this is a
# different machine until proven otherwise, and silently keeping the old
# key would be the one mistake this whole mechanism exists to prevent.
if (profile.host, profile.port) != before and profile.host_key:
profile.host_key = ""
profile.host_fingerprint = ""
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
db.commit()
return RedirectResponse(
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
)