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
+6
View File
@@ -49,6 +49,12 @@ dev = [
# is already a core dependency. Without this the provider is offered in the # is already a core dependency. Without this the provider is offered in the
# admin UI with an install hint rather than silently missing. # admin UI with an install hint rather than silently missing.
search = ["ddgs>=9.0"] search = ["ddgs>=9.0"]
# Agent chats, which run their commands on a machine reached over SSH. Optional
# on the same terms as `search`: an instance that never turns agents on should
# not carry the dependency, and one that does gets told how to install it rather
# than finding the feature silently missing. `bcrypt` is what decrypts a
# passphrase-protected OpenSSH key -- without it, pasting one fails opaquely.
ssh = ["asyncssh[bcrypt]>=2.14"]
[project.scripts] [project.scripts]
lembas = "lembas.cli:app" lembas = "lembas.cli:app"
+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_custom", "Custom tools"),
("tool_mcp", "MCP servers"), ("tool_mcp", "MCP servers"),
("tool_ask", "Ask the reader"), ("tool_ask", "Ask the reader"),
("tool_agent", "Agent execution"),
) )
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES) 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
)
+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. 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 ( from lembas.db.models.attachment import (
KIND_DOCUMENT, KIND_DOCUMENT,
KIND_IMAGE, KIND_IMAGE,
@@ -12,6 +18,9 @@ from lembas.db.models.attachment import (
Attachment, Attachment,
) )
from lembas.db.models.chat import ( from lembas.db.models.chat import (
KIND_AGENT,
KIND_CHAT,
KINDS,
ROLE_ASSISTANT, ROLE_ASSISTANT,
ROLE_SYSTEM, ROLE_SYSTEM,
ROLE_TOOL, ROLE_TOOL,
@@ -68,8 +77,14 @@ from lembas.db.models.user import (
__all__ = [ __all__ = [
"AUTHOR_MODEL", "AUTHOR_MODEL",
"AUTH_KEY",
"AUTH_METHODS",
"AUTH_PASSWORD",
"AUTHOR_USER", "AUTHOR_USER",
"Attachment", "Attachment",
"KINDS",
"KIND_AGENT",
"KIND_CHAT",
"KIND_DOCUMENT", "KIND_DOCUMENT",
"KIND_IMAGE", "KIND_IMAGE",
"KIND_TEXT", "KIND_TEXT",
@@ -111,6 +126,7 @@ __all__ = [
"Setting", "Setting",
"Share", "Share",
"Skill", "Skill",
"SshProfile",
"SkillRevision", "SkillRevision",
"Suggestion", "Suggestion",
"User", "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_ASSISTANT = "assistant"
ROLE_TOOL = "tool" 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): class Folder(UUIDPrimaryKey, Timestamps, Base):
"""A user-owned, arbitrarily nested container for chats.""" """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: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: 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 ---------------------------------------------------------- # --- Compaction ----------------------------------------------------------
# A summary of the turns up to `compacted_through_id`, sent in their place. # 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 # 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) tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) 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 # Non-empty when generation failed. Rendered as a styled error in the
# thread so a failed turn is never an unexplained blank bubble. # thread so a failed turn is never an unexplained blank bubble.
error: Mapped[str] = mapped_column(Text, default="") error: Mapped[str] = mapped_column(Text, default="")
+4
View File
@@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from lembas import __version__ from lembas import __version__
from lembas.api import ( from lembas.api import (
admin, admin,
admin_agents,
admin_audio, admin_audio,
admin_models, admin_models,
admin_prompts, admin_prompts,
@@ -21,6 +22,7 @@ from lembas.api import (
admin_suggestions, admin_suggestions,
admin_tools, admin_tools,
admin_users, admin_users,
agents,
audio, audio,
auth, auth,
chats, chats,
@@ -114,6 +116,7 @@ def create_app() -> FastAPI:
app.include_router(files.router) app.include_router(files.router)
app.include_router(folders.router) app.include_router(folders.router)
app.include_router(library.router) app.include_router(library.router)
app.include_router(agents.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(admin_models.router) app.include_router(admin_models.router)
@@ -122,6 +125,7 @@ def create_app() -> FastAPI:
app.include_router(admin_prompts.router) app.include_router(admin_prompts.router)
app.include_router(admin_suggestions.router) app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router) app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
register_error_handlers(app) register_error_handlers(app)
return app return app
+17
View File
@@ -102,6 +102,23 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True, True,
"Chat", "Chat",
), ),
PermissionDef(
"agent.ssh",
"Save SSH connections",
"Keep connection profiles for machines of their own. The credential is "
"encrypted here, and whoever saves it decides which host it opens.",
False,
"Agent",
),
PermissionDef(
"tools.agent",
"Run commands",
"Let a model read files, write files and run commands on one of their "
"SSH connections. What it may do without asking depends on the chat's "
"mode. Nothing runs on this server.",
False,
"Agent",
),
PermissionDef( PermissionDef(
"tools.ask", "tools.ask",
"Be asked questions", "Be asked questions",
+119
View File
@@ -0,0 +1,119 @@
"""What an agent chat needs from the machine it acts on.
One interface, currently one implementation. It exists as an interface anyway
because the *snapshot* is the load-bearing part: a generation outlives the
request that started it, so everything a runner needs -- the host, the decrypted
credential, the mode, the project directory -- has to be read while the session
is open and carried, not looked up later. That is the same reason `Endpoint` is
a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than
a `User`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Protocol
# What a command may weigh before it is cut off. Per call; the reply also has a
# total, in policy.Limits.
DEFAULT_MAX_BYTES = 64 * 1024
DEFAULT_TIMEOUT = 60.0
# Terminal escape sequences, stripped from anything a command produced. They are
# inert in escaped HTML, but this text also re-enters the model's context, where
# they are a known way of hiding instructions, and it may end up in a log a
# person later cats, where they hijack the terminal.
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")
@dataclass(frozen=True)
class ExecRequest:
"""One command to run."""
command: str
cwd: str = ""
timeout: float = DEFAULT_TIMEOUT
max_bytes: int = DEFAULT_MAX_BYTES
@dataclass(frozen=True)
class ExecResult:
"""What running it produced.
`output` is stdout and stderr interleaved, because a shell transcript is
what the model needs to read and separating them loses the ordering that
makes an error make sense.
"""
exit_status: int
output: str
truncated: bool = False
timed_out: bool = False
duration_ms: int = 0
@property
def ok(self) -> bool:
return self.exit_status == 0 and not self.timed_out
class ExecError(Exception):
"""Nothing could be run at all: the host refused, or the credential did.
Distinct from a command that ran and failed -- that is an `ExecResult` with
a non-zero status, which the model should read and react to. This is the
reply not being able to act, which is a message for a person.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass(frozen=True)
class Target:
"""A machine an agent chat acts on, read while the session was open.
Holds the decrypted credential and nothing else does. `generation` clears it
when the reply ends, because a finished `Generation` lingers for five
minutes so late followers get the final frames, and a private key should not
linger with it.
"""
kind: str
label: str
project_dir: str = ""
spec: dict[str, Any] = field(default_factory=dict)
class Executor(Protocol):
"""How a target is acted on. See `ssh.py`; there is no local variant."""
async def run(self, request: ExecRequest) -> ExecResult: ...
async def read_file(self, path: str, *, max_bytes: int) -> str: ...
async def write_file(self, path: str, text: str) -> int: ...
async def list_dir(self, path: str) -> list[str]: ...
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
text = _ANSI.sub("", text)
if len(text) <= limit:
return text, False
return text[:limit].rstrip() + "\n… (truncated)", True
__all__ = [
"DEFAULT_MAX_BYTES",
"DEFAULT_TIMEOUT",
"ExecError",
"ExecRequest",
"ExecResult",
"Executor",
"Target",
"clean_output",
]
+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",
]
+6
View File
@@ -471,3 +471,9 @@ a.tabs__tab { text-decoration: none; }
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--code-border); border: 1px solid var(--code-border);
} }
/* --- The permission modes, explained on the agents page ------------------- */
.mode-list { margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.mode-list__row { display: flex; gap: var(--sp-3); align-items: baseline; }
.mode-list__row dt { flex: 0 0 5rem; color: var(--ink); }
.mode-list__row dd { margin: 0; color: var(--ink-muted); font-size: var(--text-sm); }
+4 -7
View File
@@ -47,6 +47,10 @@
{{ icon("link", "icon--sm") }} {{ icon("link", "icon--sm") }}
<span class="nav-item__label">Tools</span> <span class="nav-item__label">Tools</span>
</a> </a>
<a class="nav-item {{ 'is-active' if section == 'agents' }}" href="/admin/agents">
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Agents</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'mcp' }}" href="/admin/mcp"> <a class="nav-item {{ 'is-active' if section == 'mcp' }}" href="/admin/mcp">
{{ icon("server", "icon--sm") }} {{ icon("server", "icon--sm") }}
<span class="nav-item__label">MCP servers</span> <span class="nav-item__label">MCP servers</span>
@@ -70,13 +74,6 @@
</a> </a>
</div> </div>
<div class="nav-group">
<div class="nav-group__label">Not yet built</div>
<span class="nav-item is-disabled">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Agents</span>
</span>
</div>
</nav> </nav>
<div class="sidebar__footer"> <div class="sidebar__footer">
+184
View File
@@ -0,0 +1,184 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "agents" %}
{% block title %}Agents - LLeMbas{% endblock %}
{% block heading %}Agents{% endblock %}
{% block admin_content %}
<p class="admin-lede">
An <strong>Agent</strong> chat can read files, write files and run commands on
a machine reached over SSH. Nothing runs on this server. People add their own
connections under <strong>Connections</strong>; what you decide here is
whether the feature exists and what one reply may spend.
</p>
<div class="alert">
{{ icon("shield", "icon--sm") }}
<span>
There is no sandbox to configure, and that is deliberate: containment is
whatever host somebody points a connection at. A container built for the
job is a very different thing from a key to a live server, and LLeMbas
cannot tell them apart. What a model reads — a web page, a file, the output
of the last command — is untrusted, and in <strong>Auto</strong> mode
nothing stands between that and a command running.
</span>
</div>
{% if problem %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span></div>
{% endif %}
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Saved.</span></div>
{% endif %}
<form method="post" action="/admin/agents" class="form-grid">
<section class="card">
<h2 class="card__title">Switch</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
<span>Allow agent chats</span>
</label>
<p class="field__hint">
Off, nobody can start one and no agent tool is offered, whatever
permissions they hold. {{ profile_count }} connection{{ '' if profile_count == 1 else 's' }}
saved across all accounts.
</p>
</div>
<p class="field__hint">
People also need the <strong>Run commands</strong> permission, a model
flagged <strong>Agent execution</strong>, and a connection of their own.
</p>
</section>
<section class="card">
<h2 class="card__title">The modes</h2>
<p class="field__hint">
Set per chat and switchable at any time. This is what each one means; the
two lists below adjust them.
</p>
<dl class="mode-list">
{% for value, label, hint in modes %}
<div class="mode-list__row">
<dt><strong>{{ label }}</strong></dt>
<dd>{{ hint }}</dd>
</div>
{% endfor %}
</dl>
</section>
<section class="card">
<h2 class="card__title">What never needs asking</h2>
<div class="field">
<label class="field__label" for="allow_default">Always allow</label>
<textarea class="textarea input--mono" id="allow_default" name="allow_default" rows="5"
spellcheck="false">{{ allow_text }}</textarea>
<p class="field__hint">
One per line: a tool name like <code>file_read</code>, or a command with
wildcards like <code>git *</code>. A command containing anything that
joins two commands together — a semicolon, a pipe, backticks — can never
match one of these, so <code>git *</code> does not quietly also mean
<code>git status; curl … | sh</code>.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">What always needs asking</h2>
<div class="field">
<label class="field__label" for="deny_default">Always ask</label>
<textarea class="textarea input--mono" id="deny_default" name="deny_default" rows="5"
spellcheck="false">{{ deny_text }}</textarea>
<p class="field__hint">
Checked before everything, including <strong>Auto</strong>. Treat it as
a guard against an accident rather than against an adversary:
<code>rm -rf /*</code> here does not stop <code>/bin/rm -rf /</code>, and
nothing pattern-shaped could.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">What one command may spend</h2>
<div class="field">
<label class="field__label" for="default_timeout">Timeout (seconds)</label>
<input class="input" id="default_timeout" name="default_timeout"
value="{{ values.default_timeout }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="max_timeout">Longest a command may ask for</label>
<input class="input" id="max_timeout" name="max_timeout"
value="{{ values.max_timeout }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="max_output_bytes">Most output to keep</label>
<input class="input" id="max_output_bytes" name="max_output_bytes"
value="{{ values.max_output_bytes }}" inputmode="numeric">
<p class="field__hint">
Characters. The rest is cut off and the model is told so.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">What one reply may spend</h2>
<p class="field__hint">
Three separate bounds, because they fail differently: steps stop a loop,
the clock stops one slow command eating an afternoon, and output stops a
model filling its own context with build logs and having no room to answer.
</p>
<div class="field">
<label class="field__label" for="max_steps">Most rounds of tool calls</label>
<input class="input" id="max_steps" name="max_steps"
value="{{ values.max_steps }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="max_wall_seconds">Longest a reply may take</label>
<input class="input" id="max_wall_seconds" name="max_wall_seconds"
value="{{ values.max_wall_seconds }}" inputmode="numeric">
<p class="field__hint">Time spent waiting for you to answer does not count.</p>
</div>
<div class="field">
<label class="field__label" for="max_total_output_bytes">Most output across a reply</label>
<input class="input" id="max_total_output_bytes" name="max_total_output_bytes"
value="{{ values.max_total_output_bytes }}" inputmode="numeric">
</div>
</section>
<section class="card">
<h2 class="card__title">Asking you things</h2>
<div class="field">
<label class="field__label" for="approval_timeout">How long a question waits</label>
<input class="input" id="approval_timeout" name="approval_timeout"
value="{{ values.approval_timeout }}" inputmode="numeric">
<p class="field__hint">
Seconds. After this the reply carries on without an answer and says so.
At least a minute, whatever is typed here.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="ask_free_text" value="true"
{{ 'checked' if values.ask_free_text }}>
<span>Let people write their own answer</span>
</label>
<p class="field__hint">
When a model asks a question it can offer answers to pick from, and by
default a box to write something else. Turn this off if you would rather
nobody typed free text into a prompt a model composed.
</p>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save changes</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,72 @@
{% from "_macros.html" import icon %}
{#
What looking at a host found.
Everything here came from the far side and is escaped accordingly. The
fingerprint especially: it is the one string a person is being asked to
compare against something they know, so it is shown plainly and never
reformatted.
#}
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% endif %}
{% if offer %}
<div class="card" style="margin-top: var(--sp-4)">
<h3 class="card__title">
{{ "This host's key has changed" if offer.changed else "Is this the machine you meant?" }}
</h3>
<p class="field__hint">
{% if offer.changed %}
Accept only if you know why it changed — a rebuilt container will do this,
and so will something pretending to be your machine.
{% else %}
Compare this with what the host reports for itself. On the machine, that
is <code>ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub</code>.
{% endif %}
</p>
<pre class="tool-result__text">{{ offer.fingerprint }}</pre>
<div class="btn-row">
<button class="btn btn--primary" type="button"
hx-post="/api/agents/{{ profile.id }}/accept"
hx-target="#check-result"
hx-confirm="Accept this fingerprint and pin it? Every future connection will be checked against it."
data-confirm-label="Accept"
data-confirm-danger="{{ 'true' if offer.changed else 'false' }}">
{{ icon("check", "icon--sm") }} Accept and pin
</button>
{% if profile.verified %}
<button class="btn" type="button"
hx-post="/api/agents/{{ profile.id }}/forget"
hx-target="#check-result">
Forget the old key
</button>
{% endif %}
</div>
</div>
{% endif %}
{% if accepted %}
<div class="alert alert--success">
{{ icon("check", "icon--sm") }}
<span>Pinned <code>{{ accepted }}</code>. Check again to log in.</span>
</div>
{% endif %}
{% if forgotten %}
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>Key forgotten. Check again to see the new one.</span>
</div>
{% endif %}
{% if found %}
<div class="alert alert--success">
{{ icon("check", "icon--sm") }}
<span>Connected{% if found.system %} to {{ found.system }}{% endif %}.</span>
</div>
<p class="field__hint">What it said:</p>
<pre class="tool-result__text">{{ found.output }}</pre>
{% endif %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{#
Connections, kept by the person who owns them.
Shares the chat sidebar with the library for the same reason: this is part of
using LLeMbas, not administering it. You come here to add a machine and go
straight back to a conversation.
#}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{% block heading %}Connections{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header>
<div class="admin-scroll">
<div class="admin-page">
{% block agents_content %}{% endblock %}
</div>
</div>
</main>
</div>
{% endblock %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "agents/_layout.html" %}
{% from "_macros.html" import icon %}
{% block title %}{{ "New connection" if is_new else profile.name }} - LLeMbas{% endblock %}
{% block heading %}{{ "New connection" if is_new else profile.name }}{% endblock %}
{% block agents_content %}
<nav class="crumbs">
<a class="crumbs__back" href="/agents">
{{ icon("chevron-right", "icon--sm crumbs__icon") }} All connections
</a>
</nav>
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% endif %}
{% if problem %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span></div>
{% endif %}
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not is_new %}
<section class="card">
<h2 class="card__title">Check it</h2>
<p class="field__hint">
{% if profile.verified %}
Confirms the host is still the one you accepted, then logs in and runs
something harmless to see that it works.
{% else %}
Looks at this host's key and shows you its fingerprint. Nothing is sent to
it — not your username, not your credential — until you accept.
{% endif %}
</p>
<div class="btn-row">
<button class="btn" type="button"
hx-post="/api/agents/{{ profile.id }}/check"
hx-target="#check-result">
{{ icon("refresh", "icon--sm") }} Check
</button>
</div>
<div id="check-result">
{% if profile.verified %}
<p class="field__hint">
Accepted fingerprint: <code>{{ profile.host_fingerprint }}</code>
</p>
{% endif %}
</div>
</section>
{% endif %}
<form method="post" class="form-grid"
action="{{ '/api/agents' if is_new else '/api/agents/' ~ profile.id }}">
<section class="card">
<h2 class="card__title">The machine</h2>
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" id="name" name="name" value="{{ profile.name }}" required
maxlength="120" placeholder="Project container">
<p class="field__hint">What you will pick from when starting an agent chat.</p>
</div>
<div class="field">
<label class="field__label" for="host">Host</label>
<input class="input input--mono" id="host" name="host" value="{{ profile.host }}" required
maxlength="255" placeholder="127.0.0.1">
</div>
<div class="field">
<label class="field__label" for="port">Port</label>
<input class="input" id="port" name="port" value="{{ profile.port }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="username">Log in as</label>
<input class="input input--mono" id="username" name="username" required
value="{{ profile.username }}" maxlength="120" placeholder="root">
</div>
<div class="field">
<label class="field__label" for="default_dir">Project directory</label>
<input class="input input--mono" id="default_dir" name="default_dir"
value="{{ profile.default_dir }}" maxlength="500" placeholder="/project">
<p class="field__hint">
Where a chat starts by default. Each chat records its own when it is
created, so changing this later does not move a conversation already
under way.
</p>
</div>
<div class="field">
<label class="field__label" for="connect_timeout">Connect timeout (seconds)</label>
<input class="input" id="connect_timeout" name="connect_timeout"
value="{{ profile.connect_timeout }}" inputmode="numeric">
</div>
</section>
<section class="card">
<h2 class="card__title">How it logs in</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="radio" name="auth" value="key"
{{ 'checked' if profile.auth != 'password' }}>
<span>A private key</span>
</label>
<label class="checkbox">
<input type="radio" name="auth" value="password"
{{ 'checked' if profile.auth == 'password' }}>
<span>A password</span>
</label>
</div>
</div>
<div class="field">
<label class="field__label" for="private_key">Private key</label>
<textarea class="textarea input--mono" id="private_key" name="private_key" rows="5"
spellcheck="false"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
>{{ unchanged if has_key else '' }}</textarea>
<p class="field__hint">
{% if has_key %}
A key is saved. Leave the dots alone to keep it, or clear the box to
remove it.
{% else %}
Pasted whole, encrypted at rest, and never shown again.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="key_passphrase">Key passphrase</label>
<input class="input input--mono" id="key_passphrase" name="key_passphrase"
type="password" autocomplete="off" placeholder="If the key has one">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input input--mono" id="password" name="password" type="password"
autocomplete="off" placeholder="No password set"
value="{{ unchanged if profile.password_encrypted else '' }}">
<p class="field__hint">
{% if profile.password_encrypted %}
Currently <code>{{ masked_password }}</code>. Leave the dots alone to
keep it, or clear the field to remove it.
{% else %}
Only used when this connection logs in with a password.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Availability</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if profile.enabled }}>
<span>Enabled — can be picked when starting an agent chat</span>
</label>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ "Add connection" if is_new else "Save changes" }}
</button>
<a class="btn btn--ghost" href="/agents">Back to all connections</a>
{% if not is_new %}
<button class="btn btn--danger" type="submit" formnovalidate
formaction="/api/agents/{{ profile.id }}/delete"
data-confirm-button="Delete the connection “{{ profile.name }}”? Chats that used it keep their transcripts.">
Delete
</button>
{% endif %}
</div>
</form>
{% endblock %}
@@ -0,0 +1,94 @@
{% extends "agents/_layout.html" %}
{% from "_macros.html" import icon %}
{% block title %}Connections - LLeMbas{% endblock %}
{% block heading %}Connections{% endblock %}
{% block actions %}
<a class="btn btn--primary btn--sm" href="/agents/new">
{{ icon("plus", "icon--sm") }} Add a connection
</a>
{% endblock %}
{% block agents_content %}
<p class="admin-lede">
Machines an <strong>Agent</strong> chat can work on. A model with one of these
can read files, write files and run commands <em>there</em> — never here.
Which of those it may do without asking you first is the chat's mode.
</p>
<div class="alert">
{{ icon("shield", "icon--sm") }}
<span>
Whatever this connection can reach, a model in an agent chat can reach. A
container built for the job, with one project mounted into it, is a very
different thing from a key to a machine you care about — and LLeMbas cannot
tell them apart.
</span>
</div>
{% if problem %}
<div class="alert alert--error">
{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span>
</div>
{% elif not enabled %}
<div class="alert alert--error">
{{ icon("warning", "icon--sm") }}
<span>
Agent chats are switched off for this instance. You can still add
connections here, but nothing will use them until an administrator turns
them on.
</span>
</div>
{% endif %}
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not profiles %}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No connections yet</h2>
<p class="empty__text">
Add the host, the user to log in as, and a key or password. Then press
<strong>Check</strong> — you will be shown its fingerprint to confirm before
anything is sent to it.
</p>
<a class="btn btn--primary" href="/agents/new">
{{ icon("plus", "icon--sm") }} Add a connection
</a>
</div>
{% else %}
<div class="model-rows">
{% for profile in profiles %}
<div class="model-row {{ 'is-off' if not profile.enabled }}">
<div class="model-row__main">
<div class="model-row__title">
<a class="model-row__name" href="/agents/{{ profile.id }}">{{ profile.name }}</a>
{% if profile.verified %}
<span class="badge badge--leaf">key confirmed</span>
{% else %}
<span class="badge badge--danger">not checked</span>
{% endif %}
{% if not profile.enabled %}<span class="badge">disabled</span>{% endif %}
{% if profile.auth == "password" %}<span class="badge">password</span>{% endif %}
</div>
<code class="model-row__id">
{{ profile.address }}{% if profile.default_dir %} · {{ profile.default_dir }}{% endif %}
</code>
{% if profile.last_error %}
<p class="text-xs danger">{{ profile.last_error }}</p>
{% elif profile.server_info.system %}
<p class="text-xs faint">{{ profile.server_info.system }}</p>
{% endif %}
</div>
<div class="model-row__actions">
<a class="btn btn--sm" href="/agents/{{ profile.id }}">Open</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
@@ -77,6 +77,13 @@
</a> </a>
{% endif %} {% endif %}
{% if can.get("agent.ssh") %}
<a class="nav-item" href="/agents">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Connections</span>
</a>
{% endif %}
<a class="nav-item" href="/settings"> <a class="nav-item" href="/settings">
{{ icon("user", "icon--sm") }} {{ icon("user", "icon--sm") }}
<span class="nav-item__label">{{ user.name }}</span> <span class="nav-item__label">{{ user.name }}</span>
+445
View File
@@ -0,0 +1,445 @@
"""SSH connections, and who may do what with them.
The pages are user-facing rather than admin, because these are somebody's own
machines and somebody's own keys. Most of what is worth pinning here is about
that ownership, and about the host key -- the one thing standing between "this
is my container" and "this is something answering on its address".
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import SshProfile, User
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
asyncssh = pytest.importorskip("asyncssh")
def _form(**overrides) -> dict:
base = {
"name": "Project box",
"host": "127.0.0.1",
"port": "2222",
"username": "root",
"default_dir": "/project",
"connect_timeout": "15",
"auth": "key",
"enabled": "true",
}
base.update(overrides)
return {k: v for k, v in base.items() if v is not None}
@pytest.fixture
def ssh_host():
"""A real SSH server, on a thread and an event loop of its own.
Its own loop matters: these tests drive the app through the synchronous
TestClient, so a server sharing the test's loop could not accept a
connection while a `client.post(...)` was blocking it, and the check would
time out rather than succeed. Yields the port it is listening on.
"""
import asyncio
import threading
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
async def start():
server = await asyncssh.create_server(
asyncssh.SSHServer,
"127.0.0.1",
0,
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
)
return server, next(iter(server.sockets)).getsockname()[1]
server, port = asyncio.run_coroutine_threadsafe(start(), loop).result(10)
try:
yield port
finally:
async def stop():
server.close()
await server.wait_closed()
asyncio.run_coroutine_threadsafe(stop(), loop).result(10)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
def _grant(db, user_id: str) -> None:
"""Give a plain account the two agent permissions."""
settings_store.update(
db, {"default_permissions": {"agent.ssh": True, "tools.agent": True}}
)
@pytest.fixture
def second_user(client: TestClient, db, registered):
"""Another signed-in account, so ownership can be tested."""
client.post("/auth/logout")
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
user.role = "user"
user.active = True
db.commit()
_grant(db, user.id)
client.post(
"/auth/login",
data={"email": "sam@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
return user
# --- Guards --------------------------------------------------------------------
def test_the_pages_need_the_permission(client: TestClient, db, registered):
"""Administrators pass everything, so this needs a plain account."""
client.post("/auth/logout")
client.post(
"/auth/register",
data={"name": "Merry", "email": "m@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "m@shire.test"))
user.role = "user"
user.active = True
db.commit()
client.post(
"/auth/login",
data={"email": "m@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
assert client.get("/agents").status_code == 403
assert client.post("/api/agents", data=_form()).status_code == 403
def test_new_is_not_parsed_as_a_profile_id(client: TestClient, registered):
response = client.get("/agents/new")
assert response.status_code == 200
assert "New connection" in response.text
# --- Creating and editing ------------------------------------------------------
def test_creating_a_connection(client: TestClient, db, registered):
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
profile = db.scalar(select(SshProfile))
assert profile.name == "Project box"
assert profile.port == 2222
assert profile.default_dir == "/project"
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
assert profile.verified is False, "nothing is trusted until a key is accepted"
def test_a_duplicate_name_is_refused_per_person(client: TestClient, db, registered):
client.post("/api/agents", data=_form(), follow_redirects=False)
response = client.post("/api/agents", data=_form(), follow_redirects=False)
assert "already have a connection" in response.text
assert len(list(db.scalars(select(SshProfile)))) == 1
def test_two_people_may_use_the_same_name(client: TestClient, db, registered, second_user):
"""The uniqueness is per owner. Two people each calling theirs "box" is not
a conflict, and treating it as one would be a surprise."""
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
client.post("/auth/logout")
client.post(
"/auth/login",
data={"email": "frodo@shire.test", "password": "speak-friend-and-enter"},
follow_redirects=False,
)
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
assert len(list(db.scalars(select(SshProfile)))) == 2
@pytest.mark.parametrize(
("field", "message"),
[("name", "needs a name"), ("host", "needs a host"), ("username", "username")],
)
def test_the_essentials_are_required(client: TestClient, db, registered, field, message):
response = client.post("/api/agents", data=_form(**{field: ""}), follow_redirects=False)
assert message in response.text
assert db.scalar(select(SshProfile)) is None
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
client.post("/api/agents", data=_form(private_key="SUPER SECRET KEY"), follow_redirects=False)
profile = db.scalar(select(SshProfile))
page = client.get(f"/agents/{profile.id}").text
assert "SUPER SECRET KEY" not in page
assert UNCHANGED_SENTINEL in page
def test_leaving_the_dots_alone_keeps_the_key(client: TestClient, db, registered):
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
profile = db.scalar(select(SshProfile))
client.post(
f"/api/agents/{profile.id}",
data=_form(private_key=UNCHANGED_SENTINEL),
follow_redirects=False,
)
db.refresh(profile)
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
def test_switching_to_a_password_drops_the_key(client: TestClient, db, registered):
"""Keeping a key that is no longer used would leave a credential lying in
the database with nothing pointing at it."""
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
profile = db.scalar(select(SshProfile))
client.post(
f"/api/agents/{profile.id}",
data=_form(auth="password", password="hunter2", private_key=UNCHANGED_SENTINEL),
follow_redirects=False,
)
db.refresh(profile)
assert profile.private_key_encrypted == ""
assert decrypt(profile.password_encrypted) == "hunter2"
# --- Ownership ------------------------------------------------------------------
def test_another_account_cannot_see_or_touch_your_connection(
client: TestClient, db, registered, second_user
):
"""`sharing.py` is deliberately not involved: it grants reading, and a host
somebody else can read is a host they can log in to."""
mine = SshProfile(
owner_id=db.scalar(select(User).where(User.email == "frodo@shire.test")).id,
name="Not yours",
host="10.0.0.5",
username="root",
)
db.add(mine)
db.commit()
# `second_user` is the one signed in.
assert client.get(f"/agents/{mine.id}").status_code == 404
assert client.post(f"/api/agents/{mine.id}", data=_form()).status_code == 404
assert client.post(f"/api/agents/{mine.id}/delete").status_code == 404
assert client.post(f"/api/agents/{mine.id}/check").status_code == 404
assert client.get("/agents").text.count("Not yours") == 0
def test_deleting_a_connection(client: TestClient, db, registered):
client.post("/api/agents", data=_form(), follow_redirects=False)
profile = db.scalar(select(SshProfile))
client.post(f"/api/agents/{profile.id}/delete", follow_redirects=False)
assert db.scalar(select(SshProfile)) is None
# --- The host key ----------------------------------------------------------------
def test_checking_an_unseen_host_offers_a_fingerprint_and_pins_nothing(
client: TestClient, db, registered, ssh_host
):
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
profile = db.scalar(select(SshProfile))
response = client.post(f"/api/agents/{profile.id}/check")
assert "SHA256:" in response.text
assert "Accept and pin" in response.text
db.refresh(profile)
assert profile.host_key == "", "looking is not accepting"
def test_accepting_pins_the_key_and_the_fingerprint(
client: TestClient, db, registered, ssh_host
):
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
profile = db.scalar(select(SshProfile))
response = client.post(f"/api/agents/{profile.id}/accept")
assert "Pinned" in response.text
db.refresh(profile)
assert profile.verified is True
assert profile.host_fingerprint.startswith("SHA256:")
assert "ssh-ed25519" in profile.host_key
def test_a_host_whose_key_changed_is_reported_and_not_silently_accepted(
client: TestClient, db, registered, ssh_host
):
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
profile = db.scalar(select(SshProfile))
# Pin something else entirely.
profile.host_key = f"[127.0.0.1]:{ssh_host} ssh-ed25519 {'A' * 68}\n"
profile.host_fingerprint = "SHA256:old"
db.commit()
response = client.post(f"/api/agents/{profile.id}/check")
assert "different key" in response.text
assert "Nothing was sent" in response.text
db.refresh(profile)
assert profile.host_fingerprint == "SHA256:old", "the old pin is left alone"
def test_moving_a_connection_to_another_host_forgets_its_key(client: TestClient, db, registered):
"""A pinned key belongs to a host and a port. Keeping it across a move is the
one mistake the whole mechanism exists to prevent."""
client.post("/api/agents", data=_form(), follow_redirects=False)
profile = db.scalar(select(SshProfile))
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
profile.host_fingerprint = "SHA256:whatever"
db.commit()
client.post(f"/api/agents/{profile.id}", data=_form(host="10.0.0.9"), follow_redirects=False)
db.refresh(profile)
assert profile.host_key == ""
assert profile.host_fingerprint == ""
def test_editing_something_harmless_keeps_the_key(client: TestClient, db, registered):
client.post("/api/agents", data=_form(), follow_redirects=False)
profile = db.scalar(select(SshProfile))
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
db.commit()
client.post(
f"/api/agents/{profile.id}", data=_form(default_dir="/elsewhere"), follow_redirects=False
)
db.refresh(profile)
assert profile.host_key == "127.0.0.1 ssh-ed25519 AAAA\n"
assert profile.default_dir == "/elsewhere"
def test_forgetting_a_key_clears_it(client: TestClient, db, registered):
client.post("/api/agents", data=_form(), follow_redirects=False)
profile = db.scalar(select(SshProfile))
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
db.commit()
client.post(f"/api/agents/{profile.id}/forget")
db.refresh(profile)
assert profile.host_key == ""
# --- The admin half --------------------------------------------------------------
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, db, registered):
client.post("/auth/logout")
client.post(
"/auth/register",
data={"name": "Pip", "email": "p@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "p@shire.test"))
user.role = "user"
user.active = True
db.commit()
client.post(
"/auth/login",
data={"email": "p@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
assert client.get("/admin/agents").status_code == 403
def test_agents_are_off_until_an_administrator_says_otherwise(client: TestClient, db, registered):
assert settings_store.agents(db)["enabled"] is False
client.post(
"/admin/agents",
data={
"enabled": "true",
"default_timeout": "30",
"max_timeout": "600",
"max_output_bytes": "65536",
"max_steps": "40",
"max_wall_seconds": "900",
"max_total_output_bytes": "1048576",
"approval_timeout": "900",
"allow_default": "file_read\ngit *\n\n",
"deny_default": "shutdown *",
"ask_free_text": "true",
},
follow_redirects=False,
)
values = settings_store.agents(db)
assert values["enabled"] is True
assert values["default_timeout"] == 30
assert values["allow_default"] == ["file_read", "git *"], "blank lines dropped"
assert values["deny_default"] == ["shutdown *"]
def test_the_numbers_are_clamped(client: TestClient, db, registered):
client.post(
"/admin/agents",
data={
"enabled": "true",
"default_timeout": "0",
"max_timeout": "99999",
"max_output_bytes": "1",
"max_steps": "9999",
"max_wall_seconds": "1",
"max_total_output_bytes": "1",
"approval_timeout": "0",
"allow_default": "",
"deny_default": "",
},
follow_redirects=False,
)
values = settings_store.agents(db)
assert values["default_timeout"] == 1
assert values["max_timeout"] == 3600
assert values["max_steps"] == 200
assert values["approval_timeout"] == 60, "a zero would park a task forever"
def test_an_unticked_checkbox_turns_it_off(client: TestClient, db, registered):
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
client.post(
"/admin/agents",
data={
"default_timeout": "60",
"max_timeout": "600",
"max_output_bytes": "65536",
"max_steps": "40",
"max_wall_seconds": "900",
"max_total_output_bytes": "1048576",
"approval_timeout": "900",
"allow_default": "",
"deny_default": "",
},
follow_redirects=False,
)
assert settings_store.agents(db)["enabled"] is False
def test_encrypted_credentials_never_appear_in_the_database_in_the_clear(db, user_id):
profile = SshProfile(
owner_id=user_id,
name="box",
host="h",
username="u",
password_encrypted=encrypt("hunter2"),
)
db.add(profile)
db.commit()
raw = db.execute(
select(SshProfile.password_encrypted).where(SshProfile.id == profile.id)
).scalar_one()
assert "hunter2" not in raw
assert decrypt(raw) == "hunter2"
+327
View File
@@ -0,0 +1,327 @@
"""Acting on a machine over SSH.
Driven against a real asyncssh server on 127.0.0.1 with a generated host key, so
nothing here touches an outside network and the host-key path is exercised for
real rather than mocked.
"""
from __future__ import annotations
import asyncio
import pytest
from lembas.services.agent import ssh
from lembas.services.agent.base import ExecError, ExecRequest
asyncssh = pytest.importorskip("asyncssh")
# --- A server to talk to -------------------------------------------------------
def _generate_key():
return asyncssh.generate_private_key("ssh-ed25519")
class _Server(asyncssh.SSHServer):
def begin_auth(self, username: str) -> bool:
return False # no authentication wanted; every login succeeds
async def _handler(process):
"""A shell that understands just enough to be tested against."""
command = process.command or ""
if "sleep" in command:
await asyncio.sleep(5)
process.exit(0)
return
if "flood" in command:
process.stdout.write("x" * 200_000)
process.exit(0)
return
if "fail" in command:
process.stderr.write("it went wrong\n")
process.exit(3)
return
if "colour" in command:
process.stdout.write("\x1b[31mred\x1b[0m\n")
process.exit(0)
return
process.stdout.write(f"ran: {command}\n")
process.exit(0)
async def _start(host_key=None):
server = await asyncssh.create_server(
_Server,
"127.0.0.1",
0,
server_host_keys=[host_key or _generate_key()],
process_factory=_handler,
sftp_factory=True,
)
port = next(iter(server.sockets)).getsockname()[1]
return server, port
def _spec(port: int, host_key: str, **overrides) -> dict:
base = {
"id": "p1",
"label": "test box",
"host": "127.0.0.1",
"port": port,
"username": "tester",
"auth": "key",
"password": "",
"private_key": "",
"key_passphrase": "",
"host_key": host_key,
"connect_timeout": 5,
}
base.update(overrides)
return base
@pytest.fixture
async def project(tmp_path):
"""A directory of its own.
Not `tmp_path` itself: the autouse database fixture points the data
directory there, so a listing would find lembas.db and the uploads folder.
"""
path = tmp_path / "project"
path.mkdir()
return path
@pytest.fixture
async def box(project):
"""A running server, its pinned host key line, and a project directory."""
key = _generate_key()
server, port = await _start(key)
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
try:
yield {
"port": port,
"host_key": line,
"fingerprint": fingerprint,
"dir": str(project),
"server": server,
}
finally:
server.close()
await server.wait_closed()
# --- The four defaults that must never be left to asyncssh --------------------
def test_the_dangerous_defaults_are_all_passed_explicitly():
"""Every LLeMbas user shares one unix account, so "whatever the account has
lying around" is never the right answer. This is the most important test in
the file and it needs no server at all."""
kwargs = ssh._connect_kwargs(_spec(22, "127.0.0.1 ssh-ed25519 AAAA\n"))
# Bytes, never None: None turns host key checking off entirely.
assert isinstance(kwargs["known_hosts"], bytes)
assert kwargs["known_hosts"] != b""
# Not left to load ~/.ssh/id_*, which could be another person's key.
assert kwargs["client_keys"] == []
# Not left to read ~/.ssh/config, where ProxyCommand could redirect us.
assert kwargs["config"] is None
# Not left to use $SSH_AUTH_SOCK.
assert kwargs["agent_path"] is None
def test_a_profile_with_no_confirmed_host_key_refuses_to_connect():
with pytest.raises(ExecError, match="host key has not been confirmed"):
ssh._connect_kwargs(_spec(22, ""))
def test_a_password_profile_sends_a_password_and_no_keys():
kwargs = ssh._connect_kwargs(
_spec(22, "h ssh-ed25519 AAAA\n", auth="password", password="hunter2")
)
assert kwargs["password"] == "hunter2"
assert kwargs["client_keys"] == []
def test_a_key_profile_sends_no_password():
kwargs = ssh._connect_kwargs(_spec(22, "h ssh-ed25519 AAAA\n", password="stale"))
assert kwargs["password"] is None
# --- Trust on first use --------------------------------------------------------
async def test_the_first_look_captures_a_key_and_a_fingerprint(box):
assert box["host_key"].startswith(f"[127.0.0.1]:{box['port']} ssh-ed25519 ")
assert box["fingerprint"].startswith("SHA256:")
async def test_capturing_a_key_offers_no_credential():
"""get_server_host_key completes the key exchange and stops, which is what
makes accepting a fingerprint from a button safe: nothing is sent to a host
that has not been accepted yet.
The server here records every authentication attempt, so an empty list is
evidence rather than an absence of it.
"""
attempts: list[str] = []
class Watchful(asyncssh.SSHServer):
def begin_auth(self, username: str) -> bool:
attempts.append(username)
return False
server = await asyncssh.create_server(
Watchful, "127.0.0.1", 0, server_host_keys=[_generate_key()]
)
port = next(iter(server.sockets)).getsockname()[1]
try:
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
assert line and fingerprint.startswith("SHA256:")
assert attempts == [], "a host not yet accepted was offered a username"
finally:
server.close()
await server.wait_closed()
async def test_a_host_that_answers_with_a_different_key_is_refused(box):
"""The pinned key is the whole of the protection. A host presenting another
one is either rebuilt or is not the host."""
other_line, _ = await ssh.capture_host_key("127.0.0.1", box["port"])
wrong = other_line.rsplit(" ", 1)[0] + " " + "A" * 68 + "\n"
executor = ssh.SshExecutor(_spec(box["port"], wrong), box["dir"])
with pytest.raises(ExecError):
await executor.run(ExecRequest(command="echo hi", timeout=5))
async def test_an_unreachable_host_says_so():
with pytest.raises(ExecError, match="Could not reach"):
await ssh.capture_host_key("127.0.0.1", 1, timeout=3)
# --- Running commands ----------------------------------------------------------
async def test_a_command_runs_and_comes_back(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
assert result.ok
assert result.exit_status == 0
assert "echo hi" in result.output
async def test_the_working_directory_is_set_and_never_spliced_in(box):
"""Every command is a fresh shell, so `cd` cannot carry between calls. The
directory is quoted, because `cwd` may come from the model."""
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/a dir")
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
assert "cd '/tmp/a dir' && echo hi" in result.output
async def test_a_directory_with_a_quote_in_it_cannot_end_the_quoting(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/it's; rm -rf /")
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
assert "'/tmp/it'\\''s; rm -rf /'" in result.output
async def test_a_failing_command_is_a_result_not_an_error(box):
"""A command that ran and failed is something the model should read and
react to. Only being unable to act at all is an ExecError."""
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
result = await executor.run(ExecRequest(command="fail", timeout=5))
assert result.exit_status == 3
assert result.ok is False
assert "it went wrong" in result.output, "stderr is interleaved, not dropped"
async def test_a_slow_command_times_out_and_says_so(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
result = await executor.run(ExecRequest(command="sleep", timeout=1))
assert result.timed_out is True
assert result.ok is False
assert "was stopped" in result.output
async def test_a_flood_of_output_is_capped(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
result = await executor.run(ExecRequest(command="flood", timeout=10, max_bytes=2000))
assert result.truncated is True
assert len(result.output) < 2200
assert result.output.endswith("(truncated)")
async def test_terminal_escapes_are_stripped(box):
"""Inert in escaped HTML, but this text re-enters the model's context, where
they are a known way to hide instructions -- and a log a person later cats."""
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
result = await executor.run(ExecRequest(command="colour", timeout=5))
assert "red" in result.output
assert "\x1b" not in result.output
# --- Files, over SFTP ----------------------------------------------------------
async def test_a_file_is_written_and_read_back(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
written = await executor.write_file("hello.txt", "a mallorn tree\n")
assert written == len("a mallorn tree\n")
assert await executor.read_file("hello.txt") == "a mallorn tree\n"
async def test_a_relative_path_resolves_against_the_project_directory(box, project):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
await executor.write_file("nested.txt", "here")
assert (project / "nested.txt").read_text() == "here"
async def test_a_missing_file_is_reported_plainly(box):
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
with pytest.raises(ExecError, match="no file at"):
await executor.read_file("nope.txt")
async def test_a_directory_lists(box, project):
(project / "one.txt").write_text("1")
(project / "two.txt").write_text("2")
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
assert await executor.list_dir() == ["one.txt", "two.txt"]
async def test_reading_a_file_is_capped(box, project):
(project / "big.txt").write_text("y" * 50_000)
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
text = await executor.read_file("big.txt", max_bytes=1000)
assert len(text) <= 1000
# --- The check a person presses ------------------------------------------------
async def test_check_reports_what_it_found(box):
found = await ssh.check(_spec(box["port"], box["host_key"]), box["dir"])
assert found["ok"] is True
assert "uname" in found["output"]
# --- The snapshot --------------------------------------------------------------
def test_spec_from_decrypts_the_credential_and_the_row_does_not_hold_it(db, user_id):
from lembas.db.models import SshProfile
from lembas.services.crypto import encrypt
profile = SshProfile(
owner_id=user_id,
name="box",
host="10.0.0.5",
username="root",
private_key_encrypted=encrypt("PRIVATE KEY MATERIAL"),
host_key="h ssh-ed25519 AAAA\n",
)
db.add(profile)
db.commit()
spec = ssh.spec_from(profile)
assert spec["private_key"] == "PRIVATE KEY MATERIAL"
assert "PRIVATE KEY MATERIAL" not in profile.private_key_encrypted