A server that runs code it did not write has a problem older than AI: the code runs with the server's own authority. An inference backend, a build tool, a plugin host, a job runner. Each one is somebody else's binary, and you hand it the process credentials, the credentials in memory, and the network. Review every binary and you're still one upstream compromise away from losing the host.

I spent a stretch adding an engine sandbox to a server that launches third-party inference runtimes as subprocesses: llama.cpp, whisper.cpp, stable-diffusion.cpp, vLLM and FastFlowLM on the NPU. It was an experimental proposal. This is a reflection on it. What stayed with me is that nono gets described as an agent sandbox, which makes it sound like a CLI for wrapping Claude Code. Under the description it's a small kernel-capability library with a CLI on top, and the library is what makes it useful to anything that spawns a process it doesn't trust.

This is a tour of nono as a general-purpose sandbox: what it enforces, the shapes an application can integrate it in, what each shape costs, and when it's the wrong tool. The examples come from that server, but the same shapes apply to a CI runner, a plugin host, a notebook kernel, or a game server loading a mod.

What it is

A capability sandbox, not a box

nono starts from nothing for the filesystem and devices and adds only what a process needs. Linux gets Landlock plus a seccomp filter; macOS gets Seatbelt, the framework underneath the App Store's sandbox; Windows gets WSL2, which runs a real Linux kernel, so the Linux path applies there. No root, no daemon, no images. The process applies it to itself.

One default surprises people: network is allowed. Filesystem and device grants start empty, but egress does not. If you want the workload offline you ask for it, with --block-net or the SDK's block_network() in Rust and Python, blockNetwork() in TypeScript. Everything below assumes you did.

What matters when you embed it:

  • Applied by the process, to itself. No privileged helper to trust.
  • Irrevocable inside that process. You can tighten it, never loosen it. In supervised mode the parent stays outside the child and can still delegate new authority, which is a deliberate security decision, not an escape.
  • Inherited. Children and grandchildren get it. A sandboxed process cannot spawn a free one.
  • Fail-closed. If applying it fails, nothing runs.
  • Cheap. Landlock enforces the path rules inside the kernel at the VFS layer, so an open is checked without a context switch or a userspace round trip. The seccomp filter is what closes the surfaces Landlock does not cover, raw sockets and io_uring. It traps openat only when runtime capability elevation is enabled, and that path does cost a few microseconds per open. The optional network proxy is a userspace hop in the parent.

It also blocks sensitive paths by default even when you grant the parent. Hand over a home directory and ~/.ssh, ~/.aws, ~/.kube, ~/.npmrc, shell history and shell configs stay out of reach. Landlock is allow-only and cannot carve a deny out of a granted tree, so the blocklist is a userspace policy layer that keeps those paths out of the ruleset, not a kernel negation. Without it, "allow the project directory" quietly becomes "allow the credentials two directories over".

Capability, not perimeter

A container or a microVM draws a perimeter and flattens everything inside it. Get into the box and you can reach every file, credential and process in the box. nono draws no box. The workload stays on the shared kernel and each asset it can reach is an individual grant. That makes it good at least privilege inside one working context. It is not the tool for separating hostile tenants.

Perimeter isolation versus capability isolation
Fig 1. A perimeter decides what is inside the box. A capability sandbox decides what the process may touch, on the same kernel.

The limits matter as much:

  • No separate kernel or hardware boundary. A kernel exploit escapes it, as it escapes any sandbox on that kernel.
  • No process isolation. The sandboxed process can still see the rest of the process table.
  • No CPU or disk limit. On Linux it caps memory and process count through cgroup v2, supervised only, and it needs a delegated systemd user session or refuses rather than ignoring the request. nono wrap rejects those flags. macOS has no equivalent.
  • No protection for data inside a path you granted.
  • A small check-to-use window between canonicalising a path and applying the ruleset.
  • On WSL2 the filesystem story is full, but the supervisor's proxy is opt-in and per-port filtering wants a newer kernel, so supervised mode loses much of its advantage.
  • On native Windows, no. WSL2 only.

It fits a workload that needs real tools and must not reach past the files, devices and hosts you named. If your threat is code that will hunt a kernel exploit, put a stronger perimeter underneath. This composes with one; it doesn't replace one.

Shapes

Where the enforcement call goes

The policy is the same everywhere, from read and write paths to device nodes, a network posture and a port to bind; what changes is who builds it, who applies it, and what is left in your process tree.

There are two axes. One: do you want a trusted supervisor providing audit, rollback, a network proxy and runtime diagnostics, or the smallest possible surface with none of that. Two: do you link the sandbox into your binary, or shell out to it. Those give three placements, plus one variant for a specific fork hazard.

Which one is right is not, I think, a technical question. Both are good, and the deciding factors are the ones around the software rather than inside it: how the application ships, what dependencies you can put on a user, how much setup you're willing to make them do. If you ship a single binary to people who will not install a Rust toolchain, or run a second process, or learn a second set of flags, that has decided the answer before any of the security tradeoffs get a vote.

The technical differences are real. They're the smaller half.

Do not sandbox a long-lived server

Apply-to-self is right for a one-shot process and wrong for a server. Apply it to a server and you have confined your own process manager, log writer, config loader and every future child, permanently.

The tempting fix, apply in the forked child, has its own problem. Building a capability set resolves paths and reads the filesystem, and applying it opens a directory descriptor for every granted path and allocates the ruleset. In a multithreaded process, both steps can deadlock on a lock another thread held at the moment of the fork, so the child cannot be trusted with either one. Two patterns are safe. Do the resolution and descriptor work in the parent before the fork and leave the child with raw syscalls, or spawn a single-threaded executor that builds and applies cleanly. The second is the trampoline.

The call is identical in every shape; only its placement changes:

RUST
use nono::{AccessMode, CapabilitySet, Sandbox};

fn run_workload(model_dir: &str, scratch: &str) -> Result<(), Box<dyn std::error::Error>> {
    let mut caps = CapabilitySet::new();
    caps.allow_path(model_dir, AccessMode::Read)?;
    caps.allow_path(scratch, AccessMode::ReadWrite)?;
    caps.block_network(); // network is on unless you say otherwise

    // One-shot process: apply, then continue in this process.
    // Server: do not call this in a forked child of a multithreaded
    // process. Building and applying both allocate and touch the
    // filesystem. Hand the policy to a single-threaded executor instead.
    Sandbox::apply(&caps)?;
    run_workload_in_process()
}

1. Wrap it, with a supervisor: nono run

The default entry point forks first, then sandboxes the child. The parent stays outside because it provides services the child cannot: a session audit with a tamper-evident hash chain, on by default; filesystem snapshots and rollback; a network proxy that enforces a host allowlist; and on Linux, the ability to grant additional authority at runtime.

bash
❯nono run --allow /srv/model --write /tmp/scratch --block-net -- ./engine --port 8080

The parent is the cost.

It runs with your authority and it is part of the trusted computing base, so the attack surface is larger than the direct mode below. In exchange you get audit, rollback, proxy and diagnostics without writing any of it.

2. Wrap it, replace yourself: nono wrap

nono wrap applies the sandbox and then execs into the target. nono disappears from the tree; there is no parent. Smallest surface, smallest feature set. No audit, no rollback, no proxy, no runtime authority, no resource limits, no diagnostic footer.

bash
❯nono wrap --allow /srv/model --write /tmp/scratch --block-net -- ./engine --port 8080

For a server that is already a process manager, this is the tempting one: one extra exec, nothing new to supervise. It is also the shape that gives up the proxy, which is the only way to enforce a host allowlist rather than an all-or-nothing switch.

3. Embed the primitive in your own code

This is the shape the usual wrapper-or-library question misses. nono is a Rust crate first; the CLI is a consumer of it. You can build the capability set and apply it from your own process, then exec the workload yourself. There are first-class bindings for Python, TypeScript and Go (the Go SDK is CGo over nono's C FFI), and a C ABI for anything that can call C, which is how it lands in a C++ server.

Embedding gives you the primitive, and only the primitive. The CLI layers the sensitive-path denylist, environment scrubbing, credential brokering, the audit trail and the proxy on top of it. Assume those are yours to build. That is what the engine sandbox ended up doing: it wrote its own credential-root gate and its own environment scrubber. The trade is that the policy lives in your code, where you can review it, test it and report on it.

Table
Concernnono run (CLI)Embedding the primitive
Path, device and network grantsFlags and profilesCapabilitySet in your code
Sensitive-path denylistBuilt in (46 paths)Yours to build
Environment scrubbingBuilt inYours to build
Credential brokeringBuilt inYours, or point at the standalone nono proxy
Audit trail and tamper evidenceBuilt inYours to build
Rollback snapshotsBuilt inYours to build
Policy validationProfile schema and validateYours to build

The build cost has a shape.

Linking a Rust static library into a C++ or Go binary adds weight (the reference build measured roughly 12 to 20 MB; strip, LTO and --gc-sections move it, though not to nothing), and not every distribution has a current enough rustc. The pattern that worked was three tiers: a prebuilt static library, then a local Cargo build, then a stub that keeps everything else compiling and reports that enforcement is absent. For air-gapped builds, vendor and pin the crate. The consequence to plan for is that a statically linked sandbox cannot be patched without rebuilding and reshipping the host. A CVE in the sandbox becomes a release, not a package update. Pinning for reproducibility and patching for security pull in opposite directions; pick which one your deployment can live with.

4. Delegate to a trampoline

The fourth is a variant of the second, for the fork hazard above. A tiny out-of-process executor builds and applies the sandbox and then execs the target, which is what nono wrap already is. Because the executor is single-threaded, there is no fork to survive and no lock to inherit. On macOS the engine sandbox used posix_spawn to launch a small executor that re-validated the policy and applied Seatbelt before execing the engine. Reach for this when the host is large and concurrent.

Four integration shapes for nono
Fig 2. The same policy, four placements of the enforcement call. The tradeoff is where the trusted code lives and what stays in the process tree.

The engine map

What the sandbox found

The server's trick was that a user could point it at a backend from a URL, which is remote code execution by design. A sandbox is the only defense-in-depth a copy-paste user has, and the more useful output of writing one is an inventory. You can't grant what you haven't named, so you end up writing down every path, device and host each engine touches, and that list is the first honest description of your own product's dependencies. Mapping five engines I thought I understood found three things I had wrong.

FastFlowLM's egress was the loud one: it fetches its own weights, and I had assumed we did that for it.

The device grants are per platform, not per engine: /dev/dri, /dev/kfd, /dev/dxg, /dev/accel, plus driver and runtime paths that differ between ROCm, CUDA, DirectML, Vulkan and XRT. Derive the profile from the same accelerator selection the launcher uses, or you get an engine that can open the device but not find the driver.

And the caches. Every engine writes somewhere nobody thinks about until it is denied: vLLM's Triton and MIOpen caches, a Mesa shader cache, FastFlowLM's $HOME directories. None are ambient, so each has to be declared.

The rest are the lessons that were not specific to inference:

Policy as data, assembled additively. The clean design kept a declarative struct, mode, path grants, device grants, network posture, an environment allowlist, separate from the code that enforces it. Build it in a fixed order from zero: the system runtime paths every process needs to start, the hardware profile for the detected accelerator, the workload's own files, then the backend's declared delta. A backend can narrow what the profile allows; it cannot widen it. Absent fields grant nothing. It serialises to JSON, so it can be reviewed at install time and shipped to an out-of-process executor.

A gate separate from the mechanism. Path canonicalisation is not validation. A credential-root check belongs in one place every policy passes through, and it should name the roots explicitly and fail fatally rather than silently trimming. Two independent secret controls, a path gate and an environment scrubber, are worth having because they fail differently.

Report what is enforced, not what was asked. Declared and enforced diverge constantly: a kernel below Landlock v4 cannot express per-port egress, macOS cannot filter bind by port, and a build without the Rust library sandboxes nothing. The design that survives users has at least two modes. auto degrades and reports; enforced fails the load instead of running unconfined. The failure to avoid is the silent one, where a user believes they are sandboxed and are not.

The device is the hole. These engines exist to reach a GPU or NPU, so the device node has to be granted, and once it is, the engine reaches the driver. That is the realistic escape: a driver CVE, GPU memory exhaustion your cgroup story does not cover (a memory cap tracks host pages, not driver-allocated VRAM on a discrete accelerator), and ioctl filtering that is unavailable on older kernels and on WSL2. The honest claim is not that the host cannot be compromised. It is that the damage is scoped to the device and exactly what you granted.

If you need the driver out of the trusted computing base, you need a VM.

A sandbox is not a process manager. Sandboxing a child does not clean up a tree. Multi-process workloads orphan workers if you signal only the leader; the fixes are setpgid plus a group kill on Unix, and a Job object with kill-on-close on Windows. (The engine sandbox wrote its own AppContainer and Job object path for native Windows, because nono does not cover that platform.) Teardown is a separate concern with its own tests.

Test the boundary, not the happy path. Preview a policy with --dry-run, ask why a decision goes a given way with nono why --path <path> --op <read|write>, and check a set without applying it with the SDK's QueryContext. Then write negative tests: grant a directory, assert the credential root beside it is denied, assert egress is refused when you blocked it.

The positive path tells you nothing. When a denial shows up in production, triage it at the kernel, with dmesg | grep -i landlock on Linux or the system log on macOS, before you touch the policy.

Assembly is additive, enforcement is reported

The assembly order exists so no contributor can skip a step. Deny-all is the floor, the runtime and hardware profiles are non-negotiable, the workload assets are added, and only then does the backend get a say, and only to narrow for devices. The reporting exists because enforcement is always platform-specific. Show the operator the platform's truth, not the configuration's intention.

Policy assembly from deny-all to enforcement
Fig 3. Grants accumulate from a deny-all baseline; the backend delta is last and can only narrow. The reported state is what the platform enforces, which is not always what was declared.

Boundaries

When not to reach for it

nono is the wrong tool when what you need is not fine-grained capability control. The cases are specific.

Table
SituationWhy notInstead
Mutually hostile tenants on one hostSharing a kernel means one escape is everyone'smicroVM per tenant (Firecracker, Kata), or gVisor
You need PID or network namespace isolationnono shares the host namespace and filters paths and egresscontainers, or a VM
You need CPU, disk or I/O limitsmemory and process count only, Linux only, supervised onlycgroups directly, or a container
Native Windows without WSL2nono's Windows story is WSL2AppContainer plus Job objects, or a small VM
Code that will target the kernel or a GPU driverno kernel sandbox survives a kernel or driver buga separate kernel, and the cost that comes with it
You own the code and only need to constrain computea whole OS process is heavy for pure functionsWasmtime or another in-process WASM sandbox
The distro build has no modern Rust and cannot vendor itthe embedded path needs at least a stub and honest reportingthe CLI wrapper, or ship with enforcement visibly off
The threat is a buggy in-process pluginnono sandboxes processes, not host memorya process boundary, or an in-process interpreter

The last row is the one people get wrong. A plugin in your address space can be constrained by a language runtime. Landlock will not touch it.

Comparison

Against the alternatives

The useful comparison is the boundary each one draws, because that sets the threat model.

Table
ToolBoundaryPlatformsSelf-appliedCharacter
nonoPath, device and egress grants on the shared kernelLinux, macOS, WSL2YesA library you link and apply; fine-grained, fast, no root
Landlock or Seatbelt by handThe same primitives, wired yourselfLinux, macOSYesA few hundred lines and no dependency; you own the policy and its bugs
bubblewrapNamespaces plus seccomp, building a minimal filesystemLinuxNo, wraps a childThe Flatpak primitive; coarse mounts, strong namespace story
firejailsetuid helper plus namespace and seccomp profilesLinuxNoGood for desktop app profiles; a setuid binary in the path
AppArmor / SELinuxAdmin-configured mandatory access controlLinuxNo, system policyThe default MAC on most servers; needs root, not per-spawn
systemd sandboxingUnit-scoped kernel and namespace restrictionsLinuxNo, admin configGood for daemons you own; per-spawn via systemd-run --scope
Containers (Docker, Podman)Namespaces plus cgroups, separate filesystemLinux, macOS, WindowsNoEnvironment isolation first; coarse mounts, 100 to 500 ms startup
gVisorA user-space kernel intercepting syscallsLinuxNoStronger guest/host boundary without a full VM
microVM (Firecracker, Kata)Separate guest kernel and hardware isolationBroadNoStrongest and heaviest; the multi-tenant answer
WASM (Wasmtime)Language-level capability sandbox in-processBroadYesOnly for code you can compile to WASM

Against containers, nono is a different layer, not a competitor. The container gives namespaces, resource limits and a separate filesystem. nono gives path-level control and credential blocking inside it. They compose, and the composition is the defense-in-depth answer for untrusted code. For a device-bound workload the container's marginal gain on the device axis is small, because the GPU or NPU node is mounted in from the host either way. What the container adds is the process and resource boundary around it.

Against doing it by hand, nono is a dependency trade. Landlock and Seatbelt are exposed to unprivileged processes and a focused implementation is a few hundred lines. What you buy with the dependency is the cross-platform matrix, the ABI fallbacks, the sensitive-path defaults, the proxy and the audit trail. What you take on is a supply chain and an upgrade cadence. If you need one platform and filesystem confinement, rolling it yourself is legitimate.

Application-level policy filters are a different thing entirely. A filter inside the same process it is meant to constrain can be bypassed by a bug or an unexpected code path. A kernel rule cannot be argued with from inside. That is why node's --experimental-permission and Python's audit hooks are a guard against mistakes and not a boundary against an adversary.

Takeaway

The shape to remember

nono is closer to a linking choice than to a command. The policy is data, the enforcement is a kernel call, and the SDKs let you put that call in your own process, in a wrapper, or in a small executor. What the engine sandbox left me with is the inventory: the list of what your workloads actually touch is the first honest description of your own product, and it is worth the work on its own.

Sources

What this leans on

  1. nono documentation, the CLI, the core Rust library and the SDKs
  2. nono security model and WSL2 feature matrix, the enforcement rules and the WSL2 limits
  3. The Lemonade backend gateway, the design write-up the engine map came from
  4. Lemonade RFC #3596, the archived sandbox contract, including the per-platform enforcement matrix