sandbox — the kernel-level shell around executors
The sandbox is layer 3 of the Metnos architecture (ch. 6 of the Architecture): it wraps the execution of an executor in bubblewrap to isolate it from the rest of the system. The module is small (~180 lines) because all that is needed is to map the executor's manifest onto bwrap flags; there is no daemon and no persistent state.
bwrap profile is derived from the manifest; the executor runs confined, with the network isolated when no capability requires it.
The runtime's pseudo-sandbox — path and host filtering inside the executor wrappers, in cooperation with the Vaglio — remains as the first line of defence: it performs checks before the subprocess is even launched. bwrap adds, on top of it, a kernel-level shell: even if an executor managed to evade the application-level checks, it would find separate namespaces, a read-only filesystem and no network.
sandbox.wrap_command(executor, cmd) and gets back a wrapped command ready for subprocess.run. If bwrap is missing, the command is passed through unchanged.
The runtime/sandbox.py module exposes four public functions. No classes: global state is nil except for shutil.which's cache.
| Function | What it does | Citation |
|---|---|---|
bwrap_available |
True if bwrap is in PATH. Result cached at first access via shutil.which. |
runtime/sandbox.py:30-32 |
sandbox_disabled |
True if the user has explicitly disabled the sandbox via METNOS_SANDBOX (recognised values: 0|off|no|false, case-insensitive). |
runtime/sandbox.py:35-40 |
wrap_command(executor, cmd, autonomy="supervised", extra_ro=None, extra_rw=None) |
Main function. Returns the command wrapped in bwrap if available and not disabled; otherwise the unchanged command. executor must expose code_path (Path) and capabilities (list, manifest format). |
runtime/sandbox.py:178-207 |
status |
Dict with bwrap_available, bwrap_path, disabled_via_env, active. For dashboards and debug. |
runtime/sandbox.py:212-219 |
Internally, wrap_command delegates to three private helpers:
_expand_hints_to_paths(hints) — truncates globs to their ancestor (runtime/sandbox.py:45-69);_capability_kind(cap) and _capability_mode(cap) — extract family (fs, network, code, …) and mode (read, write, http, …) from the capability name (runtime/sandbox.py:72-93);_build_bwrap_args(code_path, capabilities,...) — builds the full list of bwrap flags from the manifest (runtime/sandbox.py:106-175).
The heart of the module is _build_bwrap_args: it takes the executor's code_path and its capability list, and produces the exact sequence of flags to pass to bwrap. The rules, in order.
Bwrap starts from an empty root: the system paths needed by the Python interpreter and libraries must be mounted explicitly. The module mounts read-only only those that actually exist (otherwise bwrap errors out):
_SYSTEM_RO_PATHS = ( "/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/opt", "/var/lib/python3", )
For each, if Path(p).exists, --ro-bind p p is appended. On minimal systems (e.g. an Alpine container without /lib32) missing paths are skipped without error (runtime/sandbox.py:100-124).
Three mandatory mounts, always present:
--proc /proc — a minimal /proc generated by bwrap, no access to /proc/<pid> of foreign processes;--dev /dev — a minimal /dev (null, zero, random, …), no /dev/sda;--tmpfs /tmp — the executor's private /tmp, mounted in RAM, no overlap with the host's /tmp.
Citation: runtime/sandbox.py:127-129.
The executor's Python file must be readable. The whole containing directory is mounted read-only:
code_dir = code_path.parent args += ["--ro-bind", str(code_dir), str(code_dir)]
This way the executor can import accessory modules that live in the same package, but cannot modify its own code (runtime/sandbox.py:131-133).
For each capability in the manifest, the module looks at kind (family) and mode:
| Capability | Effect |
|---|---|
fs:read with hint | for each hint, the ancestor is computed (see 3.5) and --ro-bind <path> <path> is appended. |
fs:write with hint | same as above, but --bind (read-write). |
network:* | no extra bind, but the flag has_network = True is set (see 3.6). |
code:exec | no extra bind: usual tools already come from /usr/bin per 3.1. |
other families (mail, time, …) | no effect on the sandbox. |
Only paths that exist are actually mounted: a hint pointing to a not-yet-created folder is skipped silently, no error (runtime/sandbox.py:135-155).
Hints in the manifest are glob-like (e.g. ~/notes/**, /tmp/**): bwrap does not understand them, it mounts directories. _expand_hints_to_paths truncates each hint at the first glob segment, expands ~, deduplicates:
"~/notes/**" → "/home/user/notes" "/tmp/**" → "/tmp" "/tmp/*" → "/tmp" "~/Pictures" → "/home/user/Pictures"
Result: bwrap mounts the entire root, not the individual matching files. Fine-grained gating remains the runtime's application-level filter (runtime/sandbox.py:45-69).
If no capability has family network, --unshare-net is appended: the executor starts in an empty network namespace, no interface beyond a down lo. If at least one capability is network:*, the flag is not added and the executor inherits the host's network (runtime/sandbox.py:166-167).
runtime/capabilities.py first computes effective capabilities from the
final argument value and, when absent, the schema default. The accepted
when shape is closed: {arg, values}, with a declared argument
and string values compatible with any enum. A malformed or non-matching clause is
inactive.
For a conforming executor, sandbox.invocation_skills extracts bindings
only from provider:access and accepts them only when they belong to
vocab.PROVIDER_SKILLS. That single result opens the network, mounts only
the skill home read-write (required for OAuth refresh), and prevents the invocation
from moving to a device. Legacy executors temporarily retain the five historical
signals as an explicit compatibility fallback.
Regardless of the manifest, every sandbox includes:
--unshare-user --unshare-ipc --unshare-uts --die-with-parent
--unshare-user — separate user namespace, the executor does not see host UIDs;--unshare-ipc — no shared IPC semaphore or queue;--unshare-uts — separate hostname and domainname;--die-with-parent — if the runtime dies, the executor dies with it, no orphan processes.
Citation: runtime/sandbox.py:170-173.
The sandbox must be a benefit, not a blocker. Three fallback levels ensure the system keeps working even when bwrap is absent:
| Case | Behaviour | Citation |
|---|---|---|
(a) bwrap not installed (bwrap is optional; the sandbox degrades gracefully without it) |
bwrap_available returns False, wrap_command returns the unchanged command. |
runtime/sandbox.py:30-32, 195-196 |
(b) METNOS_SANDBOX=0|off|no|false |
sandbox_disabled returns True, wrap_command returns the unchanged command. Useful for local debugging or CI without bwrap. |
runtime/sandbox.py:35-40, 195-196 |
(c) shutil.which exceptions |
The exception surfaces as a False from bwrap_available: command unchanged. No crash on a "broken PATH" case. |
runtime/sandbox.py:32, 195 |
In all three cases, the runtime's pseudo-sandbox (path/host filter inside executor wrappers + Vaglio) stays active: the application-level defence does not disappear because the kernel-level one is missing. The outer shell is lost, not the inner filter.
boot.py that sandbox.status["active"] is True), but the module itself does not demand it.
agent_runtime
The planner calls the sandbox at a single point: the invoke_executor function. Here is the code (runtime/agent_runtime.py:194-212):
def invoke_executor(executor, args, timeout_s=30, *, autonomy="supervised"): """Invoke an executor, optionally inside a bubblewrap sandbox. If `bwrap` is installed and `METNOS_SANDBOX` is not disabled, the command is wrapped; otherwise it runs as a plain Python subprocess (the runtime's pseudo-sandbox stays active: path/host filter + Vaglio). """ import sandbox as _sandbox # lazy: avoids circular import and overhead for modules that do not use it payload = json.dumps(args) base_cmd = ["python3", str(executor.code_path)] cmd = _sandbox.wrap_command(executor, base_cmd, autonomy=autonomy) result = subprocess.run( cmd, input=payload, capture_output=True, text=True, timeout=timeout_s, )...
Three details of the code deserve attention.
Lazy import. The sandbox module is imported inside the function, not at the top of the file. This avoids two problems: import cycles (the sandbox module does not depend on agent_runtime, but the lazy pattern is defensive) and overhead for modules that use agent_runtime but never call invoke_executor (e.g. tests that exercise only the dry-run ReAct loop). Python's internal cache makes the cost of the lazy import negligible after the first call.
Constant base command. base_cmd is always ["python3", <code_path>]. The sandbox prefixes it with ["bwrap", *flags, "--",...]; without sandbox, base_cmd stays intact. subprocess.run does not distinguish the two cases: it works on the final list.
Pass-through autonomy. Today wrap_command receives autonomy but does not use it to differentiate flags (see ch. 6). It accepts it as a reserved parameter: when separate profiles arrive, only _build_bwrap_args needs to change, no call-site does.
The call from the ReAct loop is at runtime/agent_runtime.py:540 (obs = invoke_executor(executor, args)): no explicit autonomy parameter, default "supervised".
The Architecture, ch. 12 defines three autonomy levels — ReadOnly, Supervised, Full — with different policies for system access. The sandbox exposes the autonomy parameter but does not apply separate profiles: today every wrap derives the same scheme from the manifest, regardless of the level.
This is a stated choice. The manifest already carries the needed capabilities and their hints; introducing a second axis "profile per level" here would produce duplication (each capability would be filtered twice) and would push the policy decision into the wrong module. The right place for an autonomy×capability table is policy.html: the runtime, depending on the level Roberto picks, will pass to wrap_command the profile that policy has computed. Then autonomy will become a real selector, not a pass-through parameter.
Once the policy integration is complete, _build_bwrap_args will receive a derived profile argument and will apply differentiated restrictions (e.g. ReadOnly forces --ro-bind even for capabilities declaring fs:write; Full disables --unshare-net regardless of declared capabilities).
Cluster sandbox in the runtime test framework: 9/9 green. The cases are designed to exercise every derivation rule and every fallback level without requiring bwrap to be installed.
| # | Case | What it verifies |
|---|---|---|
| 1 | status_torna_dict | status returns a dict with the expected keys (bwrap_available, bwrap_path, disabled_via_env, active). |
| 2 | wrap_command_no_bwrap_passa_invariato | When bwrap_available is False, wrap_command returns exactly the input command (equal list). |
| 3 | sandbox_disabled_rispetta_env | METNOS_SANDBOX=0 (and variants) disables wrapping even when bwrap is present. |
| 4 | expand_hints_tronca_al_glob | Glob-like hints (e.g. /tmp/**) are truncated at the first glob separator; ~ is expanded; duplicates are removed. |
| 5 | capability_kind_e_mode_parse | _capability_kind and _capability_mode recognise fs:read, network:http, code:exec; both dict and string forms. |
| 6 | build_bwrap_args_isola_rete_se_no_network_cap | Manifest without network:* capability → args contain --unshare-net. |
| 7 | build_bwrap_args_lascia_rete_se_network_cap | Manifest with network:http → args do not contain --unshare-net. |
| 8 | build_bwrap_args_bind_rw_per_fs_write | Capability fs:write with hint produces --bind; fs:read produces --ro-bind. |
| 9 | build_bwrap_args_include_code_dir_ro | Args always include --ro-bind <code_dir> <code_dir> derived from executor.code_path.parent. |
Cases 6-9 exercise _build_bwrap_args without invoking bwrap: the produced flag list is verified. This way the cluster runs green even on a development server where bwrap is not installed, while still covering the derivation rules that are the module's true contract.
If bwrap is not installed, the sandbox module uses the
fallback described in ch. 4: the command runs directly, with no kernel-level
wrapper, while the application-level checks inside the executors and Vaglio
remain active. The result must declare this condition instead of hiding it.
Activation requires a single system operation:
# Debian/Ubuntu sudo apt install bubblewrap # Fedora/RHEL sudo dnf install bubblewrap # Arch sudo pacman -S bubblewrap
No code changes, no runtime restart. On the next access, bwrap_available returns True (cached), and from that moment every invoke_executor wraps automatically. status will reflect active: True.
bwrap before running synthesised executors or code that has not
yet been exercised thoroughly. Application-level checks cover normal cases; the
kernel-level shell is the safety net when an executor leaves the expected path.
| limit | When it is removed |
|---|---|
No landlock. Fine-grained filesystem filtering via landlock requires kernel ≥ 5.13 and dedicated syscalls. For now we rely on bwrap binds. |
, when the approval pipeline with mature dispatcher callbacks stabilises. Landlock can replace some read-only binds with finer permissions (read yes, exec no, etc.). |
| No Docker namespaces. For cases requiring even stronger isolation (e.g. executors running local LLMs with heavy native dependencies), a Docker or podman container would be more appropriate. | When a specific executor will demand full isolation (e.g. CUDA, native scientific computing libraries): a second backend will be added, selectable from the manifest (sandbox_backend = "docker"). |
No custom seccomp. Bwrap's default syscall filter is used (already restrictive: blocks ptrace, kexec, …). No custom policy per executor family. |
When concrete threats arise that the default does not cover. Today the complexity of maintaining seccomp profiles per capability is not worth it. |
Separate profiles per autonomy level. autonomy is pass-through and everything is derived from the manifest identically for every level. |
Integration of the policy autonomy×capability table will let the runtime pass a computed profile to wrap_command. |
No network whitelist. A network:* capability today leaves the network fully open; no per-host filtering (e.g. only *.example.com). |
When the executor pool contains enough web callers to make per-host filtering a net win. Implementation: nftables inside the network namespace, or a dedicated LAN proxy that performs enforcement. |
The sandbox is a small component (~180 lines) but central to the Metnos security posture. Its smallness is the point: all the complexity lives in the executor's manifest, which is the readable contract. The sandbox.py module is pure mechanical translation.
The graceful fallback, in particular, reflects an ethical as well as a pragmatic choice: security must not become an entry barrier. On a developer laptop or in a minimal container, the system runs all the same, with the application-level pseudo-sandbox active. When moving to the production server, an apt install bubblewrap adds the kernel-level shell without touching the code.
Every chapter above talks about a single place: the Metnos server, where bwrap wraps executors synthesised at home. But an executor can also run on a paired remote device — your laptop, a PC in another room — driven by the Rust client (client-rs/). There is no bwrap there. The client itself picks the containment, and it changes with the device's operating system.
The core idea is simple. The server does not know (and does not want to know) how each device isolates the code: it trusts a single contract. Each operating system has its own module — sandbox_linux.rs, sandbox_windows.rs, sandbox_macos.rs — but they all expose the exact same function. The engine under the hood changes; the steering wheel stays the same.
sandbox_<os>.rs module exposes this signature:
run_sandboxed(exec, python, shim, args, env, limits) -> OutputThe
limits.wall field is the deadline: the maximum time the run is allowed. The caller does not need to know which isolation was used: it gets back a uniform Output that states, in plain fields, which containment was applied. References: ADR 0011 / 0037 / 0046.
Every operating system offers different isolation tools. The client uses the strongest one available on that platform. Here is the full picture.
| System | Containment | Strength | How it is compensated |
|---|---|---|---|
| Linux | bubblewrap + landlock + seccomp + mount namespace |
strong (parity with the Metnos server) | — (no compensation needed) |
| macOS | sandbox-exec + entitlements |
medium | not yet implemented (tier-2) |
| Windows | Job Object (AppContainer in the future) | medium; tree-kill of the whole process tree guaranteed | server signature verified before execution; only read-only, self-contained executors; mutating ones that are not bundlable are denied |
On Linux the remote device uses the very same shell as the server: bwrap + landlock + seccomp + mount namespace. If bwrap is absent on that device, the same honest fallback of ch. 4 applies: the run happens directly, the fact is logged (§2.8), and the result declares it with sandbox:"none". No pretending: whoever reads the result knows the shell was not there.
Windows has no equivalent of bwrap. It needs another system tool, called the Job Object. It is a container in the heart of Windows (the NT kernel) to which one or more processes are bound.
The intuition is this: a Job Object is like a room locked with a key. You put the executor's process inside it. If that process spawns others (child processes), they too are born inside the same room: no one can leave. And when you lock the room, everything inside it is shut down for certain — children too, grandchildren too. No process stays running in secret.
The Windows client uses this primitive to build containment around the executor. Let us see, step by step, how the client creates the room, puts the process inside it and closes it when the time limit expires.
Two details make this scheme robust. First: the process is created frozen (CREATE_SUSPENDED) and placed into the room before it starts. If it started at once, for an instant it could spawn children outside the container; born frozen, by the time it is resumed it is already inside. Second: step 2 sets the flag JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. It means: "when the last handle to this room closes, shut everything down". This guarantees the tree-kill of the entire process tree even if the client itself crashes and dies: as it closes, it takes everything with it. The result declares it with sandbox:"job-object".
limits.wall), the Job Object is closed: the tree is dead. The result is ok:false, error_class:"timeout", with an empty payload — never a half result passed off as complete (§2.8). Verified live: right after the room is locked, the device is immediately ready and healthy for the next run.
In fairness: the Job Object is strong at shutting processes down, but it does not isolate disk and network the way bwrap does on Linux. The stronger Windows defence is AppContainer with explicit file and network permissions. Until that level is active, Metnos compensates before running, by carefully choosing what to send to the device:
In practice: the device is trusted with both reads and writes, but only work that is contained and undoable; the containment and device-aware undo do the rest.
A practical problem: the remote device may not have Python installed at all. The client solves it on its own. It downloads on demand a ready-made Python (python-build-standalone) and verifies its integrity with the sha256 fingerprint pinned by the server. Then it unpacks it with tar + flate2, in pure Rust, without depending on external programs.
The download is built to survive unstable networks: it proceeds in 8 MB chunks (Range requests), resumes from where it stopped thanks to a .part file, and if the fingerprint does not match it re-fetches and compares two copies until they agree. It is the same discipline already used by the server in downloads.py::robust_fetch.
sandbox:"job-object". Linux uses the same bwrap model as the
server when available; macOS (sandbox-exec + entitlements) remains a
planned backend and is not enabled yet.