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>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:12:48 +02:00
parent 671e49cae8
commit 0a4531f02d
3 changed files with 33 additions and 67 deletions
+10 -13
View File
@@ -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;
+10 -22
View File
@@ -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/<pid>/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
+13 -32
View File
@@ -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,