This document explains how Orva isolates user-supplied code from the host and from other functions. It is descriptive, not prescriptive — every claim below is grounded in a specific file/line that you can audit.
Orva is single-tenant: one operator, many functions, possibly many end-users hitting those functions. The platform's security goals, ordered by importance:
- A function cannot read or write another function's data
- A function cannot read or write the host filesystem outside its
own
/codemount and its private/tmp - A function cannot escalate to host root, even though it appears to run as UID 0 inside its sandbox (more on this below)
- A function cannot exhaust host resources beyond its declared memory / CPU / pid limits
- A function cannot make arbitrary network calls when network mode
is
none(default — isolated net namespace, loopback only). Functions that need outbound HTTPS opt in by settingnetwork_mode: egress
Orva is not designed to defend against:
- A malicious operator running on the same host (they own the keys)
- A kernel-level zero-day (no seccomp filter is bulletproof against
kernel exploits — see
nsjailupstream's stance) - Side-channel attacks (Spectre / Rowhammer / cache timing) — these are out of scope for any container-grade isolation
Each worker receives a process-signed SDK credential containing a token
version and its immutable function ID. Orvad verifies the signature on KV,
function invoke/streaming, job enqueue, cron upsert, and user-span requests;
it never trusts X-Orva-Caller-Function for authorization or attribution.
The signing key is random per process, so credentials copied from an old worker
become invalid after restart.
KV access is limited to the credential's function namespace, cron upsert can only change that function's schedules, and user spans can only attach to an active execution owned by that function. Functions may intentionally invoke or enqueue work for another function, but the caller recorded in traces and jobs always comes from the signed claim. The SDK credential cannot authenticate to operator APIs.
host kernel
└─ docker container "orva" ← UID 0 inside container; needs CAP_SYS_ADMIN
└─ orvad (Go server) to construct sandboxes
└─ nsjail process ← unshare(CLONE_NEWUSER) drops effective caps
└─ user namespace
├─ chroot to runtime rootfs (read-only)
├─ /code bind-mount ← read-only, function-private
├─ tmpfs /tmp ← private, wiped on worker exit
├─ cgroup v2: memory + CPU + pids
├─ seccomp filter ← ~150 syscalls blocked
└─ user code (node / python)
The gap between layers is enforced by the Linux kernel, not by Orva code. Orva configures the boundaries and trusts the kernel to keep them.
User code sees getuid() == 0. It is NOT root on the host.
The mechanism is CLONE_NEWUSER + UID mapping. nsjail's -Mo flag
(internal/sandbox/sandbox.go, buildArgs()) creates a new user namespace where
inside-UID 0 is mapped to host-UID 65534 (nobody). All capability
checks happen against the user-namespace's UID, but actions that cross
the namespace boundary (writing to a file owned by host-root,
mounting a filesystem outside the chroot, sending a signal to a host
PID) are denied because the function has zero effective capabilities
outside its namespace.
Verify it yourself:
# Deploy a fn that introspects /proc/self/status
KEY=$(docker exec orva cat /var/lib/orva/.admin-key)
curl -X POST -H "X-Orva-API-Key: $KEY" -H 'content-type: application/json' \
http://localhost:8443/api/v1/functions \
-d '{"name":"whoami","runtime":"node","memory_mb":128,"cpus":1}'
FID=$(curl -s -H "X-Orva-API-Key: $KEY" http://localhost:8443/api/v1/functions \
| jq -r '.functions[] | select(.name=="whoami") | .id')
curl -X POST -H "X-Orva-API-Key: $KEY" -H 'content-type: application/json' \
http://localhost:8443/api/v1/functions/$FID/deploy-inline \
-d '{"code":"const fs=require(\"fs\");module.exports=async()=>{return fs.readFileSync(\"/proc/self/status\",\"utf8\").split(\"\\n\").filter(l=>l.startsWith(\"Cap\")||l.startsWith(\"Uid\"));}"}'
curl -X POST -H "X-Orva-API-Key: $KEY" \
http://localhost:8443/fn/${FID} -d '{}'Expected output (the meaningful lines):
Uid: 0 0 0 0
Gid: 0 0 0 0
CapInh: 0000000000000000 ← 0: no inheritable caps
CapPrm: 0000000000000000 ← 0: no permitted caps
CapEff: 0000000000000000 ← 0: no effective caps
CapBnd: 0000000000000000 ← 0: nothing in the bounding set
CapAmb: 0000000000000000 ← 0: nothing ambient
UID 0 with all 64 capability bits cleared. The function can do nothing root-on-the-host could do.
Configured at internal/sandbox/sandbox.go:154-160:
"-Mo", // standalone-once mode; userns by default
"--chroot", rootfs, // pivot_root into the runtime's rootfs
"-R", cfg.CodeDir + ":/code", // read-only bind mount of the function's code
"-T", "/tmp", // private tmpfs at /tmp- The runtime rootfs (
/var/lib/orva/rootfs/<runtime>/) is the only filesystem visible. It contains the language runtime + standard libs. /codeis a read-only bind mount of the function's code directory (<dataDir>/functions/<id>/current→ symlink toversions/<hash>/). Read-only means the function cannot write back to its own code dir, which prevents persistent compromise of the deployment artifact./tmpis a fresh tmpfs per spawn — wiped when the worker exits or is reaped. Functions can write here freely; nothing escapes.- No other host paths are visible. The procfs, sysfs, and cgroup filesystems are masked by the chroot.
By default nsjail puts the function in a user namespace where it appears to
have UID 0 but holds zero capabilities outside that namespace. On a
bare-metal host that blocks unprivileged user namespaces, the installer uses
nsjail file capabilities for setup and starts it with
--disable_clone_newuser; nsjail drops privileges before running the adapter,
while the remaining mount, PID, network, IPC, UTS, chroot, and seccomp
boundaries stay enabled.
nsjail does not need to call prctl(PR_SET_NO_NEW_PRIVS) separately —
the user namespace gives the same effect for cross-namespace operations.
Inside the namespace, the function's CapEff reads as 0
(/proc/self/status). This means even calls that the kernel checks
against effective caps (CAP_NET_ADMIN, CAP_SYS_PTRACE,
CAP_SYS_MODULE, CAP_SYS_BOOT, etc.) all fail.
internal/sandbox/seccomp.go defines a Kafel-syntax policy passed to
nsjail via --seccomp_string (sandbox.go:198). Three policies ship:
default(used by all functions unless overridden): allows the curated syscalls modern Node/Python need and returnsEPERMfor any syscall outside the allowlist. Notably blocks:mount,umount,pivot_root,unshare,clone(CLONE_NEWUSER),bpf,kexec_load,init_module,delete_module,iopl,swapon,reboot,setns,userfaultfd.strict: tightensdefaultfurther by blocking many*atcalls and most ioctls. Use for high-trust untrusted code.permissive: extendsdefaultwith the outbound networking syscall set. It remains an allowlist and continues to deny privileged kernel operations such asmount,bpf, and module loading.
The policy is compiled by BuildSeccompPolicy and passed inline with
DEFAULT ERRNO(1). Returning an error rather than killing the process lets
Node and Python fall back when an optional facility such as io_uring is
unavailable. Linux seccomp's LOG action is deliberately not used as a
default because it records an event but still permits the syscall.
Per-function caps are enforced in two places:
- Per-process:
--rlimit_as maxpreserves the service user's existing hard address-space limit; it is a fallback, not the declared function memory budget. - Per-cgroup (when cgroup v2 delegation is available — the
cgroupv2Delegate()branch ofsandbox.go'sbuildArgs()):memory.maxat 1.5× the declaredmemory_mb. The 0.5× headroom lets the kernel reclaim via PSI pressure before OOM-killing. The 1.5× factor matches the autoscaler's per-worker admission budget.pids.maxat the configuredMaxPids(default 32).cpu.maxviacgroup_cpu_ms_per_sec(e.g.,cpus: 0.5→ 500 ms of CPU per 1000 ms wall — fractional CPU as bandwidth, not affinity, so the scheduler can load-balance freely).
The host-wide concurrency cap (cfg.Sandbox.MaxConcurrent, see the
TOO_MANY_REQUESTS error) is enforced at the Go layer in
internal/sandbox/limiter.go — sandbox spawns wait or fail-fast there
before any nsjail process is created.
network_mode: none (default for new functions) sets up the function in
a fresh network namespace with no interfaces other than loopback.
Outbound HTTP from a handler fails with ENETUNREACH — DNS, TCP, UDP all
blocked. This is the safe default; flip a function to egress only if it
genuinely needs to call out.
network_mode: egress — opt-in per function. Adds nsjail's
--user_net flag, which gives the sandbox a userspace TCP/UDP stack
(the bundled NSTUN backend) that NATs out via the host. Host network
interfaces are still not exposed; the function can dial outbound but
can't see (or be seen by) other tenants on the same node. Use this for
handlers that talk to Stripe, OpenAI, your DB, or any external API. Every
egress worker also carries the compiled egress policy described below —
it cannot spawn without one.
Switching the toggle drains the warm pool so the next invocation respawns with the new mode within seconds. The Functions list in the UI shows an "egress" badge on rows that have it on, so operators can audit at a glance which functions can talk to the network.
Future modes (egress+allowlist / private) would extend the same
field without another schema migration.
When a function is in egress mode, an instance-wide blocklist applies
on top: IPs/CIDRs/hostnames that no function can reach regardless of the
per-function toggle. The list is managed from the dashboard's Egress
controls page (/web/firewall), or via POST /api/v1/firewall/rules /
orva firewall add — there is no config file. Rules live in the
egress_blocklist SQLite table.
Enforcement is per sandbox, inside nsjail. internal/firewall
compiles the enabled rows into nsjail NSTUN user_net { rule4 / rule6 }
rules, writes them to an immutable generation file under
<dataDir>/firewall/policy/egress-<gen>.cfg, and every egress worker is
spawned with that file as --config (argv[0..1], before any other flag —
nsjail's config loader overwrites everything set earlier). There is no
host firewall table, no nft invocation, and no packet filter outside the
sandbox's own network namespace.
Rule order is a security control. NSTUN is default-ALLOW and first-match-wins, so the compiler emits carve-outs before rejects:
- nsjail's own NSTUN gateway (
10.255.255.1,fc00::1) — it lives inside10.0.0.0/8, so a private-network reject would otherwise cut the guest off from its own default route. - The control plane: orvad's internal SDK address, exact host, exact port, TCP only (see the bug fix below).
- The configured DNS resolvers, port 53, UDP + TCP.
- The operator's blocklist, as REJECT rules — routed by address family, so
an IPv4 target becomes a
rule4and an IPv6 target arule6. A hostname rule contributes a/32per resolved v4 answer and a/128per v6 one.
The policy is a blocklist in both families: there is no wholesale IPv6 deny. A destination you block by IPv4 address is not automatically blocked over IPv6 — if a service is reachable on both, block both, exactly as you would with any other pair of addresses.
Every address is re-parsed and canonicalised by Orva before it reaches the config file, and anything ambiguous is refused rather than emitted. That is not defensive politeness: NSTUN's rule compilation is fail-open — an out-of-range prefix yields a zero mask, and a zero mask matches every address. A malformed rule in nsjail does not become a stricter rule, it becomes a wildcard.
Fail-closed. If no policy has compiled successfully, an egress sandbox
refuses to start (sandbox.ErrEgressPolicyMissing — the invocation fails
rather than running unfiltered; see ERRORS.md for the slug).
status.enforced: false is the signal to watch for. If a recompile fails,
the last known-good generation stays in force and the status reports
policy_stale: true with last_compile_error. A malformed config is fatal
to nsjail (exit 255), so a corrupt policy also fails closed.
Policy is fixed at worker start. NSTUN reads its rules once, at spawn. Editing a rule publishes a new generation and retires the warm egress pools so the next spawn picks it up; an in-flight worker keeps the exact generation it started with. Recycles are rate-limited to one per 60 s, so a flapping DNS answer behind a hostname rule cannot turn into a cold-start storm. Expect a few seconds — and a cold start — between saving a rule and seeing it applied.
Where to look: GET /api/v1/firewall/rules returns a status object with
backend (always nstun), enforced, policy_generation,
policy_rule_counts, policy_stale, control_plane_allow, and
unenforced_rules. The old nftables_available field is gone, with no
compatibility alias — see API.md.
What changed from the nftables-based build. This replaced a host-wide
nft implementation. The differences are behavioural, not cosmetic:
-
Scope narrowed. The old build installed a host table (
inet orva_firewall) with anoutputhook, which filtered every process in the daemon's network namespace. The new policy only exists inside each egress sandbox. -
The daemon is filtered separately. So that narrowing does not become a hole, orvad's own outbound connections are checked at the dialer against the same compiled rule set, in the same order — outbound webhooks, builder package installs (
npm install/pip install), and anything a cron trigger causes. Loopback and the control-plane address on the internal API port are exempt so orvad cannot cut off its own internal calls.The AI gateway is the exception: the embedded Bifrost gateway owns its own transport and exposes no hook to install Orva's dialer, so Orva checks a provider endpoint against the same compiled rule set before the turn rather than at dial time. A destination the operator has blocklisted is refused there, naming the subsystem —
ai-gateway: destination blocked by egress policy: tcp 10.1.1.20:11555.Bifrost's own dialer then independently re-resolves at dial time and unconditionally refuses link-local (
169.254.0.0/16,fe80::) and unspecified addresses. That is not configurable and is what closes the cloud-metadata rebinding case.RFC1918 is the one part Orva decides. Bifrost refuses private destinations unless
AllowPrivateNetworkis set, and Orva sets it only when the operator's own configured base URL is itself private — an IP literal in a private range, or a hostname that resolves entirely to private addresses. For a public provider (api.openai.com) it stays off, so a public hostname can never be rebound onto a private address mid-turn. This is what makes a LAN-hosted endpoint — ollama, vLLM, an internal gateway — reachable at all; before it, Bifrost's blanket default silently overrode the operator's explicit configuration, and the refusal surfaced as nothing more useful than "failed to execute HTTP request to provider API". The operator's blocklist remains the authority either way: block10.0.0.0/8and the preflight refuses a LAN provider even though the flag would have permitted the dial.Residual risk: a public address the operator has blocklisted could still be reached by rebinding within a turn, because the preflight resolves earlier than the dial. Changing provider configuration requires admin, and an attacker holding the provider hostname's DNS already receives the API key.
-
Blocked destinations now report
ECONNREFUSED, notEHOSTUNREACH. Handlers (and log-scraping alerts) that string-match the old errno need updating. -
Wildcard rules are unsupported and rejected. Creating or enabling a
*.example.comrule fails with400 VALIDATION. Packets carry addresses, not names. The old build silently resolved only the domain apex — it blockedexample.comand none of its subdomains while the UI claimed otherwise. Rows that already exist are left exactly as they are (theirenabledflag is never rewritten), excluded from the compiled policy, and listed instatus.unenforced_ruleswith a reason. Use a CIDR or an exact hostname instead. -
A silent breakage is fixed. Enabling any RFC1918 suggestion used to break
orva.kv,orva.jobs, and function-to-functionorva.invoke, because the internal SDK base handed to sandboxes is deliberately a non-loopback address (from inside the jail,127.0.0.1is the jail's own loopback) and is normally RFC1918. A narrow control-plane ALLOW — exact address, exact port, TCP — now precedes the blocklist. If that address cannot be determined at startup, the policy refuses to compile rather than shipping one that breaks the SDK.
Sharp edge — the RFC1918 suggestions now also apply to orvad.
Because the daemon is filtered by the same rules, enabling the shipped
suggested rules — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
100.64.0.0/10 — blocks orvad's own outbound calls to those ranges as
well as your functions'. That breaks, for example:
- an internal npm or PyPI mirror used at build time,
- a LAN-hosted LLM endpoint configured as an AI provider,
- an outbound webhook target on your private network.
The data model is a pure blocklist: there is no allow-exception rule you
can add on top. The remedy is a narrower CIDR — block the subnet you
actually want denied instead of the whole RFC1918 range (e.g.
10.42.0.0/16 rather than 10.0.0.0/8). All four suggestions ship
disabled — opt in deliberately, and check that nothing orvad needs lives
in the range first. The two default rules that ship enabled
(169.254.0.0/16, fd00:ec2::254/128 — cloud metadata) are addresses
nothing on the platform needs.
Honest note on the previous behaviour. Egress enforcement in the nftables
build was a silent no-op on any host without the nftables package or
without CAP_NET_ADMIN — the rules were stored, the UI listed them, and
nothing filtered packets. It had
never worked on Alpine/OpenRC at all: the shipped OpenRC service
granted no capabilities, so orvad could not program nftables there under
any configuration. Availability also gated the manager's poll loop, which
meant operator DNS changes stopped reaching sandboxes on those hosts too,
while boot-time behaviour made it look like they had applied.
The NSTUN policy has no such host dependency: it needs /dev/net/tun
(already required by network_mode: egress itself) and nothing else. If you
relied on the Egress controls page for filtering on a host in either
category, treat this release as the point where it started working — not as a
change to a working feature.
The orvad process inside the orva container runs as UID 0 inside
the container. This is intentional and required: nsjail needs
CAP_SYS_ADMIN to set up the user namespace, mount namespace, and
cgroup hierarchy that isolate the function. Without those caps, the
sandbox can't be constructed and there's no isolation at all.
The container itself is launched with:
--pid host
--cgroupns host
--cap-add SYS_ADMIN
--security-opt seccomp=unconfined
--security-opt apparmor=unconfined
--security-opt systempaths=unconfined
--device /dev/net/tun
--pid host + --cgroupns host let nsjail enroll each sandbox PID in the
host cgroup hierarchy (per-function memory.max / cpu.max); without them
sandbox spawn fails. On the kata-clh runtime the guest kernel provides this
delegation, so those two are not needed there.
--device /dev/net/tun exists for nsjail's --user_net: it opens the TUN
device and configures the interface inside each sandbox's fresh network
namespace. It is not used to program a host firewall — orvad does not touch
host network configuration at all.
NET_ADMIN is deliberately not granted. nsjail creates the TAP device
inside its own user namespace, where it already holds the capability, so the
container never needs it. Verified by running the sandbox with SYS_ADMIN and
the TUN device only. The one exception is forcing ORVA_DISABLE_USERNS=1
inside a container: that removes the user namespace, and TUNSETIFF then
requires real CAP_NET_ADMIN from the initial namespace.
These apply only to the orva container, not to functions. Within the container, only the orvad process and its child nsjails benefit; user functions inherit nothing through nsjail's capability drop.
If you want to run orvad itself as a non-root UID inside its container, that's a separate hardening exercise (rootless Docker is the standard path). Tracking issue / future work — let us know if this matters for your deployment.
"I saw `WARNING: Running pip as the 'root' user' in the build log earlier. Was my function being installed as root?"
The pip command runs inside the orva container during the build
phase, NOT inside your function's sandbox. pip sees getuid() == 0
because orvad is UID 0 inside the container; the warning is pip
complaining about its own environment, not anything about your code.
Suppressed in current builds via --root-user-action=ignore. Your
function still runs in the user-namespace-isolated sandbox at runtime.
"Can a function read another function's secrets?"
No. Secrets are decrypted by orvad and injected as --env KEY=VAL
flags at sandbox spawn time (sandbox.go, buildArgs()). Each sandbox sees
only its own function's secrets in its environment; the secret
material never leaves orvad and is never readable from any sandbox
filesystem.
"Can a function read another function's code?"
No. Each function's code lives at
<dataDir>/functions/<id>/versions/<hash>/. Only the active version's
directory is bind-mounted into the sandbox at /code, and the bind is
read-only. There is no shared /functions/ mount.
"What happens if a function tries to fork-bomb?"
cgroup pids.max (default 32) caps the process tree. Spawning past
that limit fails with EAGAIN inside the sandbox. The orvad scheduler
also tracks per-pool memory reservations and refuses to admit new
workers when host memory budget is exhausted (see
internal/pool/hostmem.go).
"What can the in-product AI assistant do, and where do its provider keys live?"
The assistant (the dashboard's AI section) is gated behind the
admin permission — every /api/v1/ai/* route requires it (the
dashboard session resolves to admin; a non-admin API key gets 401/403).
It operates the instance through the same operator tools the MCP
server exposes, so it can do anything an admin can do via the API, and
nothing more. Two gates sit in front of every mutation: the
per-conversation approval policy (all_writes / destructive_only
/ auto), which can pause a write for human approval before it runs,
and a code-enforced confirm=true requirement on destructive tools.
Conversations are a shared operator space: because the feature is single-operator and admin-only, all admin credentials see the same conversation list (they are not isolated per credential). BYO provider API keys are encrypted at rest with the same AES-256-GCM cipher as function secrets, are decrypted only in orvad at request time, and are never returned by the API or echoed into the chat (asserted by the e2e suite). The system prompt also instructs the model never to print secret/key/token values it encounters while operating the instance.
A provider's base_url is intentionally unrestricted (it may point at a
local endpoint such as Ollama or LM Studio — a first-class homelab use
case). Since configuring a provider is admin-only, the SSRF surface this
opens is an admin-only one and is accepted by design.
Orva already gives you defense in depth on a single host: a Docker container around orvad, nsjail-per-function inside it, seccomp + cgroup limits inside nsjail. Operators sometimes ask about wrapping the whole stack in another isolation layer.
Hypervisor-class (Kata Containers, Firecracker, full VMs). Compose cleanly with Orva — they put a real Linux kernel under the entire orvad container, so nsjail's namespace API works unchanged inside the guest. Cost is a guest kernel per Orva instance.
Kata is the configuration we've verified end-to-end (2026-05-13, Kata
3.30.0). Both QEMU and Cloud Hypervisor work; recommend
docker run --runtime=kata-clh … (smaller Rust-based hypervisor TCB,
~14 s faster container-start, throughput identical to QEMU within
noise). Orva itself is unchanged underneath. Full operator guide
including measured performance cost: docs/KATA.md.
gVisor (runsc) — does NOT work. End-to-end testing on 2026-05-13
(gVisor release-20260504.0, both ptrace and kvm platforms)
confirmed that nsjail's per-function sandbox setup needs
clone(CLONE_NEW…) for seven namespaces, and gVisor's user-space
kernel rejects the combination with EINVAL. The Orva daemon starts
under runsc and the HTTP API is reachable, but every function
invocation fails with WORKER_CRASHED. This is architectural, not a
bug — gVisor doesn't expose nested-namespace primitives, and nsjail
can't run without them. Full reproduction + alternatives in
docs/GVISOR.md.
internal/sandbox/sandbox.go— nsjail invocationinternal/sandbox/seccomp.go— syscall policyinternal/sandbox/limiter.go— host-wide concurrency capinternal/pool/hostmem.go— memory admission controlinternal/proxy/proxy.go— request → sandbox bridge
If you find a security issue please open a private security advisory on the GitHub repository (Settings → Security → Advisories) rather than a public issue.