Trust Boundaries

The supervisor is trusted but constrained. It can only grant access to files it can open itself (standard Unix permissions apply), and protected nono state roots are checked before any approval backend is consulted.
In supervised mode, audit recording also lives in this trust boundary. The sandboxed child does not write its own audit log. Instead, the trusted parent records session metadata, supervisor-observed events, session-local integrity data, the global audit ledger entry for that session, and any optional audit attestation signature.
In plain terms: the child can generate events by doing things, but it does not control the audit writer. The trusted parent is the component that records the session, commits the event log into the integrity structure, and optionally signs the completed session. That is why the child cannot directly tamper with its own recorded audit trail.
When proxy mode is active, a fourth domain exists:
The proxy runs in the unsandboxed parent process alongside the supervisor. The sandboxed child can only reach
localhost:<port> — all other outbound TCP is blocked at the kernel level. A session token (256-bit random) prevents other localhost processes from using the proxy.
Attach and Detach Security Model
A natural question is: if the child is structurally sandboxed, how cannono attach reconnect to its terminal later?
The answer is that attach does not connect to the sandboxed child directly.
The PTY architecture is:
- The sandboxed child runs on the slave side of a PTY
- The trusted supervisor owns the master side of that PTY
nono attachconnects to a local Unix socket exposed by the supervisor- The supervisor relays terminal I/O between the attached client and the PTY master
What attach does not do
- It does not disable or relax the kernel sandbox
- It does not give the child new filesystem or network rights
- It does not create a backchannel from the child to arbitrary host terminals
- It does not bypass supervisor mediation or protected-root checks
Why this does not weaken the sandbox boundary
In supervised mode, the supervisor is already part of the trusted computing base. It already:- owns the seccomp-notify fd
- mediates approvals
- may run rollback snapshots
- records audit events and session metadata
- may compute audit-log integrity metadata
- may run the network proxy
- manages the session registry and PTY
- sandbox boundary: kernel-enforced restrictions on the child
- attach boundary: who is allowed to connect to the supervisor’s PTY relay
Local authentication model
Current attach is local-only and same-user only. The supervisor protects attach with:- a private session registry directory
- owner-only attach socket permissions
- kernel peer-credential checks on accepted Unix socket peers
- another local user should not be able to attach
- a peer with the wrong uid is rejected before terminal replay or client attach proceeds
Session metadata on disk
Session JSON files in$XDG_STATE_HOME/nono/sessions/ (default ~/.local/state/nono/sessions/) contain supervisor and child PIDs, the command line, profile name, working directory, and network mode. These files are protected by directory permissions (0o700) and file permissions (0o600), but are readable by any process running as the same user.
nono applies best-effort redaction before persisting command arguments in session metadata, audit records, rollback metadata, and audit attestations. It scrubs common secret-bearing forms such as --token VALUE, --api-key=VALUE, sensitive HTTP headers, URL userinfo, and sensitive query parameters.
The default redaction policy can be extended in ~/.config/nono/config.toml with [redaction].extra_flags, [redaction].extra_headers, and [redaction].extra_query_keys. Removing a built-in default is treated as unsafe debugging behavior: [redaction].allow_unredacted_defaults is rejected unless [redaction].unsafe_redaction_overrides = true is also set. When a session uses a non-default redaction policy, audit events and audit attestations include a redaction-policy diff.
This is a safety net, not a secret-management boundary. Avoid passing secrets as CLI arguments — use keystore-backed credential injection or proxy credential injection instead.
The Two-Layer Architecture
Layer 1: Landlock (the floor)
Landlock LSM provides the hard security floor. Oncerestrict_self() is called, the child process is permanently restricted to its initial capability set. No API exists to expand or remove the restrictions. Child processes inherit them. The only escape is a kernel exploit.
Landlock is:
- Unprivileged — any process can sandbox itself without
CAP_SYS_ADMINor root - Irreversible — the kernel provides no undo mechanism
- Inherited — all child processes and threads inherit the ruleset
- Available — present on any kernel 5.13+ (Ubuntu 22.04+, Fedora 35+, Debian 12+)
Layer 2: seccomp-notify (the gate)
seccomp user notification (SECCOMP_RET_USER_NOTIF) provides dynamic capability expansion on top of the Landlock floor. A BPF filter traps openat and openat2 syscalls before they reach Landlock and routes them to the supervisor for a decision.
The critical property: seccomp runs before Landlock. When the child calls open(), the seccomp filter fires first, suspending the syscall and notifying the supervisor. The supervisor can then:
- Deny — return
EPERMto the child (the syscall never reaches Landlock) - Approve — open the file itself and inject the fd into the child via
SECCOMP_IOCTL_NOTIF_ADDFD
open() call returns a valid file descriptor. The child never executes its own openat — the supervisor’s open() is the single point of truth. The agent does not need to know nono exists; its standard file operations succeed after a brief pause during approval.
Why This Ordering Matters

Why Not Other Mechanisms
Why not SECCOMP_USER_NOTIF_FLAG_CONTINUE?
CONTINUE tells the kernel to let the child’s original syscall proceed. For syscalls that take pointer arguments (like openat, which takes a path string), this creates a TOCTOU race: the child can change the path in memory between the supervisor’s check and the kernel’s execution. The child could get the supervisor to approve /tmp/harmless and then swap the pointer to /etc/shadow before the kernel reads it.
fd injection via SECCOMP_IOCTL_NOTIF_ADDFD eliminates this entirely. The supervisor opens the file itself — whatever path it read from the child’s memory is what gets opened. The child’s memory contents after that point are irrelevant.
nono therefore avoids CONTINUE for any path that requires a supervisor authorization decision. The only Linux supervised-mode exceptions are tightly scoped compatibility cases where Landlock is already the enforcement floor:
- Initial allow-list hits outside procfs: if the requested path is already inside the initial capability set, the supervisor can let the child’s original syscall proceed because Landlock will independently enforce the same boundary.
- Missing-path probes (
ENOENT/ENOTDIR): runtimes often probe optional files (/$bunfs/..., loader fallback paths, locale assets). Letting the original syscall continue preserves the kernel’s native “not found” behavior instead of converting those probes into policy denials.
/proc/self/... in the child context and uses supervisor-opened fds so that procfs symlinks such as /proc/<pid>/fd/* cannot bypass target-path validation.
Why not mount namespaces?
A mount namespace with a minimal filesystem view would eliminate the information leak surface (the child could notstat paths outside the namespace). However:
unshare(CLONE_NEWUSER | CLONE_NEWNS)requires unprivileged user namespaces- Unprivileged user namespaces are disabled by default on Debian, restricted by AppArmor on Ubuntu 23.10+, and turned off in many enterprise configurations
- Building a core security boundary on a mechanism that distro maintainers are actively restricting is fragile
Why not DYLD interposition on macOS?
Transparent capability expansion on macOS would require interceptingopen() calls via DYLD_INSERT_LIBRARIES. This fails for three reasons:
- SIP strips the variable — Apple’s System Integrity Protection removes
DYLD_INSERT_LIBRARIESfrom Apple Platform Binaries (/usr/bin/env,/bin/bash,/bin/sh). Any command that routes through these interpreters loses the interposition. - Version manager shims break the chain — Tools like
pyenvandrbenvuse shims that exec through SIP-protected interpreters. - Calling convention mismatch — The variadic
open(const char*, int, ...)function cannot be safely interposed on arm64 without matching the exact register layout, which causes crashes.
sandbox_init()) provides the kernel enforcement layer on macOS, with the same irreversibility guarantees as Landlock on Linux.
macOS Claude Code keychain scope
Theclaude-code profile on macOS grants read-write access to the user keychain root:
~/Library/Keychains
~/Library/Keychains/login.keychain-db~/Library/Keychains/metadata.keychain-db
/Library/Keychains.
Security implication:
- a process running under the macOS
claude-codeprofile can interact with the user’s keychain subtree, not just Claude-specific entries
claude-code profile.
For users who want the Claude profile shape without macOS keychain access, you need to create a full custom profile rather than a simple extension. The keychain access in nolabs-ai/claude comes from filesystem.allow and filesystem.bypass_protection entries in the pack profile itself — not from the claude_code_macos group — so excluding the group alone is not sufficient.
The correct approach:
-
Inspect the current pack profile:
-
Copy all
filesystem.allow,filesystem.read,filesystem.allow_file, and group entries into a new local profile, omitting$HOME/Library/Keychainsfromfilesystem.allowand removing it fromfilesystem.bypass_protectionentirely. -
Save the profile and run with it:
deny_keychains_macos required group is always active in every profile. Without the filesystem.allow + filesystem.bypass_protection pair for ~/Library/Keychains, the deny wins and keychain access is fully blocked at both the file level and via Mach IPC.
The fd Injection Model
When the supervisor approves a request, it does not tell the child “go ahead and open it yourself.” It opens the file and hands the child a file descriptor. This distinction is fundamental to the security model.What the supervisor does on approval
- Reads the requested path from
/proc/CHILD/mem - Validates the notification is still live (
SECCOMP_IOCTL_NOTIF_ID_VALID) - Checks the path against protected nono state roots
- Canonicalizes the path to resolve symlinks
- Re-checks protected roots on the canonical path (a symlink from an innocuous path could point to a protected target)
- Walks the canonical path component-by-component using
openatwithO_NOFOLLOWat each step (prevents symlink substitution between canonicalization and open) - Injects the resulting fd into the child via
SECCOMP_IOCTL_NOTIF_ADDFDwithSECCOMP_ADDFD_FLAG_SEND
SECCOMP_ADDFD_FLAG_SEND flag is critical: it atomically injects the fd and completes the child’s syscall in one operation. The child’s open() returns the injected fd directly.
What the supervisor does NOT do
- Does not pass
O_CREAT— the supervisor cannot be tricked into creating files that do not exist - Does not pass
O_TRUNC— the child cannot use the supervisor as a proxy to truncate files; it receives a plain writable fd and can seek/write within the file, but truncation is an explicit operation on an fd the user approved - Does not use
SECCOMP_USER_NOTIF_FLAG_CONTINUEfor supervisor-approved paths — authorization decisions still resolve to supervisor-opened fds, not the child’s ownopenat
Scope of an approved fd
Once injected, the child holds the fd until it closes it. There is no revocation mechanism — this is an inherent property of Unix file descriptors. The approval grants access for the remainder of the session.Syscall Scope
The seccomp filter traps onlyopenat and openat2. All other syscalls pass through at full speed with zero overhead.
The 3-10 microsecond overhead on file opens is negligible for agent workloads. Agents open files infrequently relative to reading and writing them.
Information leak surface
Becausestat and access are not trapped, the sandboxed child can enumerate filesystem structure — file existence, types, permissions — without triggering a supervisor notification. For cooperative agents (the target use case), this is acceptable. For adversarial code, this could enable reconnaissance. The optional mount namespace layer (when available) would close this gap.
Failure Modes
The invariant is: if anything goes wrong, the child does not get access. The system fails closed at every decision point.
Audit Integrity Limits
The audit subsystem has a narrower security claim than the sandbox itself.- The supervisor is trusted to record events honestly and completely. The sandboxed child cannot write its own audit log, but the audit log still depends on supervisor correctness.
- Session-local hashes are not, by themselves, sufficient history integrity. nono therefore records audited sessions into a global audit ledger as sessions complete.
- The current ledger is still local host state. Without a future signature or external anchor, a host attacker who can rewrite both the session files and the ledger can forge a self-consistent history.
- For supervised sessions, the supervisor hashes the resolved main executable binary and binds that
{ resolved_path, sha256 }identity into session metadata and the global audit ledger. - When
--audit-sign-keyis configured, the supervisor also signs the session’s audit Merkle root and session context using a keyed DSSE/in-toto attestation. The signature is written into the session directory and can later be checked with the corresponding public key. - That signature is produced once, at session finalization. nono does not sign each individual audit event.
- Command arguments in session metadata and signed audit predicates are best-effort redacted before persistence. The redaction list is intentionally conservative for common flag, header, and URL patterns, but it is not a complete secret detector. User config may add local redaction names; removing defaults requires an explicit unsafe redaction override and is recorded as a policy diff in audit data.
- That executable identity is not full runtime provenance. Shared libraries, interpreter chains, scripts passed as arguments, and dynamically loaded runtime dependencies are not covered by that binary hash.
- The executable hash is computed before
exec, not from the final kernel-loaded file descriptor. A privileged attacker with write access to the executable path could still race between hash and execution. - The keyed audit attestation is only as strong as the signing key distribution model. If the verifier does not pin the expected public key, a rewritten session could also rewrite the embedded public key and remain self-consistent.
nono audit verify --public-key-file <FILE>is the path that pins verification to an expected signer key. Without that, attestation verification checks the keyed signature against the public key recorded in session metadata.- Filesystem Merkle roots under
--audit-integrityor--rollbackcommit tracked writable paths, not the full machine state.
- sandbox enforcement: kernel-enforced and fail-closed
- audit trail: supervisor-recorded and tamper-evident within the local integrity model
- attestation: keyed signing is available for session audit roots, but external anchoring and timestamping are still future work
What If the Supervisor Is Compromised?
A reasonable question: the supervisor can open any file and inject it into the child. If an attacker compromises the supervisor, can they use it as a proxy to feed arbitrary files to the sandboxed agent? The answer depends on where the attacker is.From the child (inside the sandbox)
The child cannot compromise the supervisor because the supervisor never runs untrusted code. The agent runs in the child. The supervisor is nono’s own Rust binary — the parent process afterfork().
The child’s communication channels to the supervisor are:
- seccomp notification fd — kernel-mediated. The child cannot forge or manipulate these; the kernel generates them from trapped syscalls.
- Unix socket — length-prefixed JSON parsed by serde in memory-safe Rust. Malformed messages are rejected. Valid messages are checked against protected roots, rate-limited, and require user approval.
ptrace the parent (blocked by yama ptrace_scope on most distributions, and the child’s own seccomp filter restricts its syscalls). There is no shared memory, no signal-based control channel, and no way to inject code into the supervisor process.
Compromising the supervisor from the child would require a memory corruption bug in nono’s Rust code (memory-safe by default, no unsafe in the IPC path) or a kernel exploit.
From outside (an external attacker)
The supervisor runs as the same user who invokednono run. It is not setuid, does not run as root, and holds no elevated capabilities. It can only open files the invoking user can already open.
If an external attacker can compromise the supervisor process, they already have user-level code execution on the host. At that point, they can open the same files directly — the supervisor grants them nothing they do not already have. The supervisor is a privilege boundary in the downward direction (restricting the child), not the upward direction.
The supervisor’s external attack surface is minimal:
There is no network-exposed surface, no filesystem-visible socket, and no way to interact with the supervisor without already having the user’s terminal session or the ability to inject code into the supervisor’s address space.
What if the supervisor has a vulnerability?
Even in an unprivileged supervisor, a memory corruption vulnerability could allow the child to escape the sandbox by hijacking the supervisor’s control flow. The question is how realistic this is. Rust eliminates the most common vulnerability classes. Buffer overflows, use-after-free, double-free, and format string attacks are structurally impossible in safe Rust. The compiler prevents them, not programmer discipline. The IPC message parsing uses serde JSON with no manual buffer management — there is nosprintf into a stack buffer, no memcpy with an attacker-controlled length.
The unsafe surface is small and does not parse complex input. The unsafe blocks in the supervisor path are limited to libc FFI calls: openat, poll, seccomp ioctls, and SCM_RIGHTS fd passing. These are thin wrappers around syscalls with fixed-size arguments, not parsing routines operating on attacker-controlled data.
The child can only deliver a payload through two narrow channels:
There is no complex protocol, no nested binary format, and no state machine with edge cases. A serde deserialization vulnerability would be a CVE affecting the entire Rust ecosystem, not a nono-specific bug.
Even a successful exploit has limited blast radius. If an attacker chains together a hypothetical memory corruption in an
unsafe FFI block with a delivery mechanism from the child, they achieve user-level code execution in the supervisor. This is the same privilege level the invoking user already has — the attacker has escaped the sandbox but has not escalated privileges. This is meaningful (a sandbox escape is a real security event) but it is not the catastrophic outcome of compromising a root-level supervisor.
The risk is not zero — nothing is. But Rust’s memory safety guarantees make the traditional exploit classes structurally impossible across the vast majority of the codebase, the remaining unsafe surface is small and constrained, and the worst-case outcome is lateral movement to the user’s own privilege level rather than privilege escalation.
The key distinction
The supervisor is not a privilege escalation target because it does not hold privileges the user does not already have. This is a deliberate design choice. nono runs entirely unprivileged — no root, noCAP_SYS_ADMIN, no setuid. An architecture where the supervisor ran with elevated privileges (as some container runtimes do) would make supervisor compromise a serious escalation vector. nono avoids this by design.
Network Proxy Security Model
When--network-profile or --allow-domain is used, nono starts an HTTP proxy in the supervisor process and restricts the child to ProxyOnly mode — only localhost:<port> is reachable from inside the sandbox.
Enforcement Layers
The kernel enforcement ensures the child cannot bypass the proxy by connecting directly to upstream hosts, even if it knows the IP address. There is no userspace workaround —
connect() to any address other than 127.0.0.1:<port> returns EPERM.
Session Token Authentication
Every proxy session generates a 256-bit random token (viagetrandom). The child receives it as NONO_PROXY_TOKEN. Every request must include this token:
- CONNECT mode:
Proxy-Authorization: Bearer <token> - Reverse proxy mode:
X-Nono-Token: <token>
DNS Rebinding Protection
The proxy resolves DNS itself and checks all resolved IP addresses against the link-local range before connecting. This prevents attacks where:- An attacker controls DNS for an allowed hostname
- DNS returns a link-local address (e.g.,
169.254.169.254) - The proxy would connect to the cloud metadata service thinking it’s an allowed external API
Credential Isolation
In reverse proxy mode, API credentials are loaded from the system keyring at proxy startup and stored in the supervisor’s memory asZeroizing<String>. They are never passed to the sandboxed child:
- The child sees
OPENAI_BASE_URL=http://127.0.0.1:<port>/openai— a local HTTP URL with no key - The proxy injects
Authorization: Bearer sk-...when forwarding to the upstream over TLS - The child cannot read the credential from the proxy’s memory (separate process, no shared memory, no
ptrace)
Proxy Failure Modes
The invariant matches the filesystem model: if anything goes wrong, the child does not get access.
Audit Durability Boundary
Network proxy events are captured during the session, but the durable append-only audit log is finalized after the session ends rather than updated on every proxied request. This is an intentional performance tradeoff: the live request path only pays the cost of lightweight event capture, while the append-only hash chain and Merkleized audit summary are written during session finalization. The result is good post-run audit integrity, but not per-request durability. The security implication is narrow but real: if the supervisor or proxy is forcibly terminated mid-session, recent network events may be lost before they are committed into the append-only audit record. This does not weaken sandbox enforcement or credential isolation, but it does leave a temporary gap in forensic durability. Tightening that window is future work.macOS Model
On macOS, Seatbelt provides the kernel enforcement layer viasandbox_init(). The security properties are equivalent to Landlock:
- Irreversible once applied
- Enforced by the XNU kernel
- Inherited by child processes
- No userspace escape mechanism
Isolation Scope and Deployment Model
nono provides fine-grained, kernel-enforced capability control, but it is not a monolithic isolation stack. Understanding this distinction matters when deciding how to set policy and deploy nono.What nono is
nono is a capability-based sandbox that provides fine-grained isolation controls and operates at the OS syscall level. It uses Landlock on Linux and Seatbelt on macOS. It gives precise, per-path, per-domain, per-socket, per-env-var, per-operation control over what a process can touch and see. It’s designed for the nuances of an agent acting within its operating context—something not practical or even possible with a microVM or container.What nono is not
nono is not Firecracker, not a hypervisor, and not a container runtime. It does not provide a separate kernel boundary, hardware-level memory isolation, or full filesystem namespace separation. The sandboxed process shares the host kernel with everything else running on the machine. This has a direct practical implication: nono’s effectiveness is proportional to the accuracy of the policy applied to the environment it runs in. Operating systems and Linux distributions vary significantly in their default filesystem layouts. Package managers, init systems, and service daemons leave files in locations that differ between distributions, and/run is a clear example, where contents and structure can vary widely. nono ships with sensible defaults for common layouts, but no default set can anticipate every variation, and an incorrectly scoped policy can leave gaps.
This is not a weakness unique to nono. SELinux, AppArmor, and seccomp profiles all require tuning to the environment and face the same problem. The difference is that nono makes policy explicit and declarative.
nono can act as a complete isolation layer when its policy is correctly tuned to the environment it runs in. It is not a turnkey solution that works identically out of the box across every distribution and service configuration, it is a primitive that rewards correct configuration.
Combining nono with hardware isolation
For deployments that require the strongest possible guarantees, nono composes cleanly with existing isolation layers. Running nono inside a container or microVM gives you both layers simultaneously:- The hardware-level boundary, kernel isolation, and namespace separation of the container or VM
- The fine-grained per-path, per-operation capability control of nono inside that boundary
The recommended posture for the highest-assurance deployments is: use a lightweight VM (Firecracker, etc) or hardened container runtimes (Edera, Kata) for the outer perimeter, and nono inside for fine-grained capability control. For most development, CI, and local agent use cases, nono alone, correctly configured for the host environment, provides meaningful, kernel-enforced isolation without the operational overhead of a VM.
Summary
The architecture optimizes for three properties simultaneously:- Unprivileged deployment — no root, no
CAP_SYS_ADMIN, no kernel configuration changes. Works on any kernel 5.14+ out of the box. - Defense in depth — Landlock provides a hard floor that catches failures in the dynamic layer. The supervisor can only grant what it can open. Protected-root checks ensure internal nono state remains off-limits to dynamic grants.
- Transparency — the sandboxed agent does not need to know about nono. Standard
open()calls succeed after supervisor approval. No retries, no special APIs, no agent modifications.