diff --git a/pyproject.toml b/pyproject.toml index 344eb54..854525f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,12 @@ dev = [ # is already a core dependency. Without this the provider is offered in the # admin UI with an install hint rather than silently missing. 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] lembas = "lembas.cli:app" diff --git a/src/lembas/api/admin_agents.py b/src/lembas/api/admin_agents.py new file mode 100644 index 0000000..d86ee84 --- /dev/null +++ b/src/lembas/api/admin_agents.py @@ -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) diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py index 8e25f71..4b15a85 100644 --- a/src/lembas/api/admin_models.py +++ b/src/lembas/api/admin_models.py @@ -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) diff --git a/src/lembas/api/agents.py b/src/lembas/api/agents.py new file mode 100644 index 0000000..3e97300 --- /dev/null +++ b/src/lembas/api/agents.py @@ -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 + ) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index 1f5d752..8f7a313 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -5,6 +5,12 @@ what ``init_db()`` relies on to create the schema at startup. Any new model module must be imported here or its table will silently never be created. """ +from lembas.db.models.agent import ( + AUTH_KEY, + AUTH_METHODS, + AUTH_PASSWORD, + SshProfile, +) from lembas.db.models.attachment import ( KIND_DOCUMENT, KIND_IMAGE, @@ -12,6 +18,9 @@ from lembas.db.models.attachment import ( Attachment, ) from lembas.db.models.chat import ( + KIND_AGENT, + KIND_CHAT, + KINDS, ROLE_ASSISTANT, ROLE_SYSTEM, ROLE_TOOL, @@ -68,8 +77,14 @@ from lembas.db.models.user import ( __all__ = [ "AUTHOR_MODEL", + "AUTH_KEY", + "AUTH_METHODS", + "AUTH_PASSWORD", "AUTHOR_USER", "Attachment", + "KINDS", + "KIND_AGENT", + "KIND_CHAT", "KIND_DOCUMENT", "KIND_IMAGE", "KIND_TEXT", @@ -111,6 +126,7 @@ __all__ = [ "Setting", "Share", "Skill", + "SshProfile", "SkillRevision", "Suggestion", "User", diff --git a/src/lembas/db/models/agent.py b/src/lembas/db/models/agent.py new file mode 100644 index 0000000..47d4463 --- /dev/null +++ b/src/lembas/db/models/agent.py @@ -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=` 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"" + + +__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "SshProfile"] diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index 091b472..a44c765 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -22,6 +22,17 @@ ROLE_USER = "user" ROLE_ASSISTANT = "assistant" ROLE_TOOL = "tool" +# What a conversation is allowed to be. A plain chat can never act; an agent +# chat is pointed at a machine before it starts and stays pointed there. +KIND_CHAT = "chat" +KIND_AGENT = "agent" +KINDS = (KIND_CHAT, KIND_AGENT) + +# Duplicated from services/agent/policy.py rather than imported: a model module +# importing a service would invert the dependency, and this is only the column +# default. policy.MODES is the vocabulary; this is what a row starts as. +MODE_MANUAL = "manual" + class Folder(UUIDPrimaryKey, Timestamps, Base): """A user-owned, arbitrarily nested container for chats.""" @@ -109,6 +120,31 @@ class Chat(UUIDPrimaryKey, Timestamps, Base): unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # --- Agent chats --------------------------------------------------------- + # Whether this conversation may act, and where. Chosen on the new-chat + # screen and fixed once there is a message: the harness, the tools offered + # and the approval loop all differ, so a chat that changed kind halfway + # would have a transcript whose earlier turns were produced under other + # rules. The connection is locked with it -- a shell history and a project + # directory do not transplant to another machine. + kind: Mapped[str] = mapped_column(String(16), default=KIND_CHAT, nullable=False) + # A plain id rather than a ForeignKey, for the reason `compacted_through_id` + # below gives: migrations.py compiles only the column type, so a REFERENCES + # clause would exist on a fresh database and not on an upgraded one. + # Validated on read instead. + ssh_profile_id: Mapped[str | None] = mapped_column(String(32)) + # Where commands start on the far side, and what file paths resolve against. + project_dir: Mapped[str] = mapped_column(String(500), default="") + # Which of the four permission modes is in force. The one agent field that + # IS switchable mid-chat: it decides what gets asked about, not what the + # conversation is. + agent_mode: Mapped[str] = mapped_column(String(16), default=MODE_MANUAL, nullable=False) + # Set when a turn was edited or regenerated in an agent chat. The project + # directory is deliberately NOT rewound with the transcript -- it is + # somebody's real working tree and deleting their work would be far worse + # than an inconsistency -- so the harness says so instead. + rewound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # --- Compaction ---------------------------------------------------------- # A summary of the turns up to `compacted_through_id`, sent in their place. # The messages themselves are kept and still shown; they simply stop being @@ -174,6 +210,11 @@ class Message(UUIDPrimaryKey, Timestamps, Base): tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + # A plan produced in Plan mode: {"title": str, "steps": [str, ...]}. Marked + # on the row rather than parsed back out of the prose, so the Execute button + # sends exactly what was proposed and not an approximation of it. + plan_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + # Non-empty when generation failed. Rendered as a styled error in the # thread so a failed turn is never an unexplained blank bubble. error: Mapped[str] = mapped_column(Text, default="") diff --git a/src/lembas/main.py b/src/lembas/main.py index 60da32f..8312f5b 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from lembas import __version__ from lembas.api import ( admin, + admin_agents, admin_audio, admin_models, admin_prompts, @@ -21,6 +22,7 @@ from lembas.api import ( admin_suggestions, admin_tools, admin_users, + agents, audio, auth, chats, @@ -114,6 +116,7 @@ def create_app() -> FastAPI: app.include_router(files.router) app.include_router(folders.router) app.include_router(library.router) + app.include_router(agents.router) app.include_router(admin.router) app.include_router(admin_users.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_suggestions.router) app.include_router(admin_tools.router) + app.include_router(admin_agents.router) register_error_handlers(app) return app diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py index cc9c844..ba38292 100644 --- a/src/lembas/security/permissions.py +++ b/src/lembas/security/permissions.py @@ -102,6 +102,23 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = ( True, "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( "tools.ask", "Be asked questions", diff --git a/src/lembas/services/agent/base.py b/src/lembas/services/agent/base.py new file mode 100644 index 0000000..e08f4f4 --- /dev/null +++ b/src/lembas/services/agent/base.py @@ -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", +] diff --git a/src/lembas/services/agent/ssh.py b/src/lembas/services/agent/ssh.py new file mode 100644 index 0000000..812a0db --- /dev/null +++ b/src/lembas/services/agent/ssh.py @@ -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", +] diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index 0f096a3..014fcec 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -471,3 +471,9 @@ a.tabs__tab { text-decoration: none; } background: var(--code-bg); 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); } diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index f88de21..3a02485 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -47,6 +47,10 @@ {{ icon("link", "icon--sm") }} Tools + + {{ icon("sparkle", "icon--sm") }} + Agents + {{ icon("server", "icon--sm") }} MCP servers @@ -70,13 +74,6 @@ -