From c3d66608811c6c67730cb476f918d9475890d415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 1 Aug 2026 22:12:48 +0200 Subject: [PATCH] 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) --- deploy/README.md | 23 ++++++-------- deploy/lembas.service | 32 ++++++------------- src/lembas/services/settings_store.py | 45 ++++++++------------------- 3 files changed, 33 insertions(+), 67 deletions(-) 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,