diff --git a/deploy/README.md b/deploy/README.md index f7b689e..7f93505 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -81,21 +81,18 @@ delivers it in one lump at the end, which is indistinguishable from streaming being broken. `proxy_read_timeout` is raised to an hour because a model can think for minutes before the first token. -**Hardening is deliberately moderate.** `ProtectSystem=full`, not `strict`, -because agent chats run commands. Two lines in the unit are load-bearing and -worth knowing before anyone tidies them: +**Nothing an agent does runs on this machine.** Agent chats execute their +commands over SSH, on a host somebody added and prepared — a container, a VM, +another machine. That is the whole isolation story, and it is why the unit can +stay locked down instead of being opened up to make room for a sandbox. -- **`ProtectKernelTunables` is absent on purpose.** With it, bubblewrap cannot - start at all — it bind-mounts `/proc/sys` read-only, and the kernel then - refuses `mount -t proc` inside a user namespace. The unit says why, and why - the obvious workaround is worse. -- **`TasksMax` and `MemoryMax` bound the whole service**, because a sandbox has - no cgroup of its own and `RLIMIT_NPROC` is counted per uid — the same uid the - server runs as. +`ProtectSystem=full` rather than `strict` only because the data directory must +be writable and `strict` would mean listing every path. -Local agent execution is off until an administrator turns it on, and the -sandbox never binds the deployment prefix, so a command cannot read the -database or the encryption key. +The practical consequence for whoever runs this: **the security of an agent +chat is the security of the host behind its SSH profile.** A throwaway +container with the one project mounted into it is a very different thing from a +key to a production server, and LLeMbas cannot tell them apart. **Use a real certificate if this is exposed beyond a trusted LAN.** The self-signed cert exists so the install works with no external dependencies; diff --git a/deploy/lembas.service b/deploy/lembas.service index 0b180a1..74b1e50 100644 --- a/deploy/lembas.service +++ b/deploy/lembas.service @@ -28,37 +28,25 @@ RestartSec=5 # installer sets to 127.0.0.1: reachable through nginx, never directly. # --- Hardening ------------------------------------------------------------- -# Moderate rather than maximal. The agentic features need to run commands, and -# a lockdown that has to be torn out again is worse than one that was never -# applied. +# Agent chats run their commands over SSH, on a machine somebody chose and +# prepared -- a container, a VM, another host. Nothing an agent does executes +# here, which is what lets this stay locked down rather than being opened up to +# make room for a sandbox. # -# ProtectKernelTunables is deliberately ABSENT, and putting it back breaks -# agent chats outright. It bind-mounts /proc/sys read-only, which leaves a -# locked submount under /proc; the kernel then refuses `mount -t proc` inside a -# user namespace, and bubblewrap fails with -# -# bwrap: Can't mount proc on /newroot/proc: Operation not permitted -# -# The tempting workaround is worse than the disease: binding the host /proc -# into the sandbox would expose /proc//environ of this process, and this -# unit reads LEMBAS_SECRET_KEY out of an EnvironmentFile. The setting only -# guards against a *root* write to /proc/sys, and this service is unprivileged -# with NoNewPrivileges, so little is given up. -# -# NoNewPrivileges is fine alongside bubblewrap because bwrap is not setuid here -# -- it uses an unprivileged user namespace with a single-uid map, which needs -# no /etc/subuid entry for the service account. +# ProtectSystem stays `full` rather than `strict` only because the data +# directory has to be writable and `strict` would need every path spelled out. NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=full +ProtectKernelTunables=yes ProtectControlGroups=yes RestrictSUIDSGID=yes ReadWritePaths=__PREFIX__ LimitNOFILE=65535 -# A sandbox gets no cgroup of its own, and RLIMIT_NPROC is per *uid* -- the -# same uid as this service. Bounding the whole unit is what stops a runaway -# command in an agent chat from taking the server down with it. +# Bounds on the service as a whole. Not aimed at anything in particular; a web +# application that has grown a habit of holding network connections open is +# worth a ceiling. TasksMax=2048 MemoryMax=8G diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index 9e40781..294d727 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -46,48 +46,29 @@ def _general_defaults() -> dict[str, Any]: def _agents_defaults() -> dict[str, Any]: - """Agentic execution: running commands, on this machine or over SSH. + """Agentic execution: running commands on a machine reached over SSH. - Local execution is an instance decision rather than a personal one, because - the sandbox runs on this machine and its blast radius is this machine. SSH - profiles belong to whoever made them, but whether SSH exists here at all - does not. - - Everything is off until an administrator turns it on. That is not caution - for its own sake: a model reads web pages, files and command output, all of - which are untrusted, so shell access is a capability somebody has to choose - on purpose. + 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 { - "local_enabled": False, - "ssh_enabled": False, - "bwrap_path": "bwrap", - # Read-only paths every sandbox sees, on top of /usr and the /lib - # symlinks. The deployment prefix is never here, and a bind containing - # the data directory is refused when the sandbox is built rather than - # trusted to a careful administrator. - "ro_binds": [ - "/etc/ssl", - "/etc/ca-certificates", - "/etc/resolv.conf", - # /etc/resolv.conf is a symlink into here on a systemd-resolved box, - # and binding the symlink alone leaves it dangling. - "/run/systemd/resolve", - ], - # Off by default, and the single most valuable setting in this group: an - # instruction injected through a file the model read cannot send - # anything anywhere from a sandbox with no network. - "network": False, + # 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, - "ulimit_fsize_mb": 64, - "ulimit_nproc": 128, # Per reply. See services/agent/policy.py:Limits. "max_steps": 40, "max_wall_seconds": 900, "max_total_output_bytes": 1024 * 1024, - "workspace_max_bytes": 512 * 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,