Files
LLeMbas/src/lembas/services/settings_store.py
T
Jaroslav Beneš 0a4531f02d Agents run over SSH only; put the hardening back
The local sandbox is dropped before it was built. Every hard problem in it
came from running on the machine that holds the database and the encryption
key: the service user cannot traverse /home, granting it needs ACLs,
RLIMIT_NPROC is counted per uid so a fork bomb starves the server too,
--size only applies to tmpfs so there is no disk quota, and the bind list
is a standing invitation to widen until the sandbox is decoration.

Over SSH, isolation is somebody's considered choice of host -- a throwaway
container with one project mounted into it -- using tools far better at it
than anything that could be built here. It is also the only version that is
honestly multi-user: each person brings their own credentials and their own
machine, and picks a project directory on it.

So ProtectKernelTunables goes back. It was removed for exactly one reason,
that bubblewrap cannot mount /proc without it, and that reason is gone. The
agents settings group loses everything bwrap-shaped with it.

What this costs, and the admin copy has to say so: there was a network:False
switch that made exfiltration from a compromised reply impossible, and over
SSH there is no equivalent, because the network belongs to the far side.
The security of an agent chat is now the security of the host behind its
profile, and LLeMbas cannot tell a scratch container from a live server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:12:48 +02:00

227 lines
8.2 KiB
Python

"""Instance-wide settings that administrators can change at runtime.
Distinct from ``lembas.config``, which holds deployment configuration read from
the environment at startup. Anything here is editable from the admin UI and
lives in the ``settings`` table.
Environment variables act as the *initial* value only. Once an administrator
sets something in the UI, the stored value wins -- otherwise a toggle in the
interface would silently revert on the next restart, which is worse than not
offering the toggle at all.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings as env_settings
from lembas.db.models import Setting
GENERAL = "general"
AUDIO = "audio"
SEARCH = "search"
PROMPTS = "prompts"
AGENTS = "agents"
def _general_defaults() -> dict[str, Any]:
return {
"allow_signup": env_settings.allow_signup,
# When on, new accounts land in the `pending` role and cannot sign in
# until an administrator approves them. Reserved for the users pass.
"require_approval": False,
"instance_name": "LLeMbas",
# Applied to every chat that has no model or chat prompt of its
# own. See services.chat.effective_system_prompt.
"system_prompt": "",
# Percentage of a model's context length at which the earlier turns are
# summarised automatically. 0 turns it off; the Compact button still
# works, because a person asking for it does not need a threshold.
# Never fires for a model whose context_length is 0, since that is
# "unknown" rather than "small". See services/compaction.py.
"compact_threshold": 95,
}
def _agents_defaults() -> dict[str, Any]:
"""Agentic execution: running commands on a machine reached over SSH.
Deliberately never on the machine LLeMbas runs on. Executing here would put
the blast radius on the host holding the database and the encryption key,
and buying it back needs a sandbox, a bind list, a second unix account and
an argument about every one of them. Over SSH, isolation is somebody's
considered choice of host -- a throwaway container with one project mounted
into it, or a VM -- made with tools far better at it than anything that
could be built here.
"""
return {
# Off until an administrator turns it on. Not caution for its own sake:
# a model reads web pages, files and command output, all of them
# untrusted, so a shell is a capability somebody chooses on purpose.
"enabled": False,
# Per command.
"default_timeout": 60,
"max_timeout": 600,
"max_output_bytes": 64 * 1024,
# Per reply. See services/agent/policy.py:Limits.
"max_steps": 40,
"max_wall_seconds": 900,
"max_total_output_bytes": 1024 * 1024,
# How long a reply waits for someone to answer. Clamped on read: a zero
# here would park a background task forever.
"approval_timeout": 900,
"allow_default": ["file_read", "file_list", "ls *", "pwd", "git status"],
"deny_default": ["shutdown *", "reboot *", "mkfs*"],
"ask_free_text": True,
}
def _audio_defaults() -> dict[str, Any]:
"""Speech-to-text and text-to-speech endpoints.
Two separate endpoints rather than one, because they usually are: a local
install runs whisper.cpp for one and Kokoro for the other. Both speak the
OpenAI audio API, so the shape below is the same on each side.
"""
return {
"stt_enabled": False,
"stt_base_url": "",
"stt_api_key_encrypted": "",
"stt_model": "whisper-1",
# Empty means "let the server detect it", which is what whisper does
# best. A forced language is an override, not a default.
"stt_language": "",
"tts_enabled": False,
"tts_base_url": "",
"tts_api_key_encrypted": "",
"tts_model": "tts-1",
"tts_voice": "",
"tts_format": "mp3",
"tts_speed": 1.0,
# The instance-wide starting point for the per-user toggle, not a
# setting that forces anything on anyone.
"tts_autoplay": False,
}
def _search_defaults() -> dict[str, Any]:
return {
"enabled": False,
"provider": "ddgs",
"max_results": 5,
"region": "wt-wt",
"safesearch": "moderate",
"searxng_base_url": "",
"firecrawl_base_url": "https://api.firecrawl.dev",
"firecrawl_api_key_encrypted": "",
"timeout": 20.0,
# Whether saving a link may reach addresses on this machine or this
# network. Off, because a server that fetches any URL it is handed can
# be pointed at a router's admin page or at LLeMbas itself, and the URL
# can come from a model. See services/fetch.py.
"allow_private_fetch": False,
}
def _prompts_defaults() -> dict[str, Any]:
"""Deliberately carries no prompt text.
The default wording of every fragment lives in ``services/prompts.py``, and
only an administrator's *override* is stored here. That is what lets a later
release improve a default and have the improvement reach every instance that
never touched that fragment -- copying the defaults in here at first save
would freeze them forever.
"""
return {
# 0 means "use services.harness.MAX_HARNESS_CHARS".
"max_harness_chars": 0,
}
_DEFAULTS: dict[str, Any] = {
GENERAL: _general_defaults,
AUDIO: _audio_defaults,
SEARCH: _search_defaults,
PROMPTS: _prompts_defaults,
AGENTS: _agents_defaults,
}
def defaults(key: str = GENERAL) -> dict[str, Any]:
"""The built-in values for a settings group, with nothing stored applied."""
factory = _DEFAULTS.get(key)
return factory() if factory else {}
def get_group(db: DBSession, key: str = GENERAL) -> dict[str, Any]:
"""Stored settings for a group, with defaults filled in for absent keys."""
values = defaults(key)
row = db.get(Setting, key)
if row is not None and isinstance(row.value, dict):
values.update(row.value)
return values
def get(db: DBSession, name: str, *, key: str = GENERAL) -> Any:
return get_group(db, key).get(name)
def update(db: DBSession, changes: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]:
"""Merge changes into a settings group and persist them."""
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value={})
db.add(row)
# Reassigned rather than mutated: SQLAlchemy only reliably detects a change
# to a JSON column when the whole value is replaced.
row.value = {**(row.value or {}), **changes}
db.commit()
return get_group(db, key)
def replace(db: DBSession, values: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]:
"""Set a settings group to exactly these values, dropping anything absent.
`update` merges, which is right for a form that posts a fixed set of fields
and wrong for one whose fields come and go -- the prompt editor stores only
the fragments an administrator has actually changed, so "no longer present"
has to mean "no longer stored". There is no other way to delete a key.
"""
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value={})
db.add(row)
row.value = dict(values)
db.commit()
return get_group(db, key)
def signup_allowed(db: DBSession) -> bool:
return bool(get(db, "allow_signup"))
def audio(db: DBSession) -> dict[str, Any]:
return get_group(db, AUDIO)
def search(db: DBSession) -> dict[str, Any]:
return get_group(db, SEARCH)
def agents(db: DBSession) -> dict[str, Any]:
"""Agent settings, with the two numbers that must not be zero clamped.
`approval_timeout` of 0 would park a background task on a question nobody
is going to answer, and nothing else prunes a generation that is not
finished. Clamped on read rather than on save, so a value already stored by
an earlier version cannot bite either.
"""
values = get_group(db, AGENTS)
values["approval_timeout"] = min(max(int(values.get("approval_timeout") or 0), 60), 3600)
values["max_timeout"] = min(max(int(values.get("max_timeout") or 0), 1), 3600)
return values