Security model¶
Interactive sessions are not per-command policy gates
Per-action policy and approval decisions apply to calls routed through the control plane's governed tool APIs (REST, MCP, dashboard shell/file/code controls, and SDK adapters). The interactive terminal, optional browser IDE (code-server), and commands started directly inside a sandbox receive sandbox, network, and resource controls plus terminal recording / Chronicle open-close for IDE sessions, but Rune Box does not intercept each shell command or IDE keystroke for a policy verdict. Use governed tool calls when individual command approval and signed verdict evidence are required.
Rune Box's job is to reduce the blast radius of an autonomous agent. Knowing what it does — and does not — protect against is essential to using it safely.
For reporting vulnerabilities, see SECURITY.md. Please disclose privately; do not open a public issue.
What Rune Box provides¶
- Isolation. Each environment runs in a container (Docker/Podman), a Pod
(Kubernetes) or its own microVM (
host.type = "microvm": Firecracker or libkrun) with its own workspace. Docker environments are hardened by default: all Linux capabilities dropped,no-new-privileges, a--pids-limit, and default memory/CPU ceilings (overridable viaRUNEWARD_SANDBOX_MEMORY,RUNEWARD_SANDBOX_CPUS,RUNEWARD_SANDBOX_PIDS; set to0to disable). Settinghost.read_only = truemounts the root filesystem read-only (with a writable/tmpand the writable workspace) on both Docker and Kubernetes.host.seccomp/host.apparmorpin a seccomp/AppArmor profile (Docker--security-opt; Kubernetes Localhost profiles), and Kubernetes pods default to the runtime's seccomp profile rather than Unconfined. A Charter can explicitly override an application image's entrypoint withhost.command; Docker/Podman creation requires the process to remain alive across post-start checks before the Citadel is reported as running. - Control-plane authentication.
runeward servebinds127.0.0.1by default and refuses any non-loopback--bindunless authentication is set (an API token via--token/RUNEWARD_API_TOKEN, or an RBAC store). When set it is required on every request — REST,/mcp, the terminal WebSocket, and the dashboard. A non-loopback listener also refuses plaintext HTTP unless TLS is configured with--tls-cert/--tls-keyor the operator explicitly passes--allow-insecure-httpbehind a trusted TLS-terminating proxy. Request bodies are size-capped to bound memory use. - RBAC / multi-principal auth. Setting
RUNEWARD_AUTHZ_FILEto a JSON store of principals (each with its own token, an allowed-profile glob list, and launch, approval-profile, and admin scopes) upgrades the single shared token to per-principal access: the server enforces which profiles a caller may launch and whether it may resolve approvals, and records the principal name as the audit actor./v1/whoamireports honestcan_launch/can_approveflags;/v1/chartersand policy simulation are scoped to launchable Charters for non-admins. A principal has a resource-owningtenantand an independently attributed actorname; several agents can therefore collaborate without sharing one identity. Each Citadel records its owning tenant; a non-admin can see and act on only its tenant's Citadels (an ownership guard enforces this on every/v1/citadels/{id}route), while admins see all. The dashboard has an interactive token login (backed by/v1/whoami) that gates create/approve controls to what the caller is permitted; the static dashboard shell loads without a token so the login screen can render, but the API always requires one. Recovery snapshots are tenant-scoped and persisted. HTTP MCP uses the same per-request principal and ownership rules as REST; stdio MCP binds one process to the principal selected byRUNEWARD_MCP_DEFAULT_TOKENor the credential saved byruneward auth login. - HTTP hardening. Responses set
nosniff, frame denial, no-referrer, and a restrictive Permissions Policy. API/MCP responses are no-store and use a deny-all content policy; dashboard assets use the explicit dashboard CSP. - OIDC authentication.
RUNEWARD_OIDC_ISSUERplusRUNEWARD_OIDC_AUDIENCEenables RS256 JWT verification from the provider's JWKS. Rune Box maps only signed claims:runeward_tenant,runeward_profiles,runeward_approval_profiles,runeward_can_approve, andruneward_admin. Issuer, audience, expiry, not-before, algorithm, key id, and signature are checked. Non-loopback issuer and JWKS endpoints must use HTTPS. - Signed Cohort leases. Claim returns a short-lived HMAC-signed
lease_tokenbound to Cohort, task, actor, and expiry. Heartbeat returns a refreshed token; complete/fail require the current token. Listing a task never exposes its lease capability. - Durable state. Cohort boards, snapshots, and provider-neutral Run records
use private atomic state-file replacement. A run still marked active after a
control-plane restart is closed as
interruptedrather than appearing live. - Cost / token budgets. Agents or Cohort workers report model usage to
POST /v1/citadels/{id}/usage; usage accrues per Citadel and per Charter (surfaced in Prometheus and the Citadel view). A Charter'srationing.max_tokens/rationing.max_cost_usdcaps are enforced fail-closed — once exceeded, further governed tool calls are denied. - Attributed approvals. Resolving an approval records who decided it (the
RBAC principal name, else
X-Runeward-Actor, else the peer address) in the audit ledger. - Deny-by-default egress, enforced at L3 on both backends. Network access is
denied unless explicitly allowlisted. Cooperative mode points the sandbox at
the proxy via
HTTP(S)_PROXY(the host proxy requires a per-cell credential); strict mode (network.enforce = "strict") enforces transparently at the kernel: on Kubernetes via an iptables init container + sidecar sharing the pod netns, and on Docker via aNET_ADMINegress sidecar that owns the netns (the sandbox joins it with--network container:…). In strict mode all TCP is redirected through the proxy regardless of proxy env, so code that ignores it can't bypass the allowlist. The strict path also drops non-DNS UDP (blocking QUIC/HTTP3 bypass) and IPv6 egress; settingRUNEWARD_DNS_RESOLVERS(comma-separated IPs) additionally confines DNS (UDP+TCP :53) to those resolvers, closing DNS as a covert exfil channel. - Credential broker. A Charter's
[[credential]]s are resolved by the control plane (fromenv://,vault://,aws://,gcp://or 1Passwordop://references) and never enter the Citadel: the agent gets an opaquerwph_…placeholder inplaceholder_env, and the egress proxy swaps it for the real secret only on requests to the[[endpoint]]that owns it (and refuses it, asplaceholder-misuse, anywhere else). Inspected endpoints are TLS-terminated with a per-Citadel CA the sandbox trusts (installed at/etc/runeward/ca/, withSSL_CERT_FILE,NODE_EXTRA_CA_CERTS,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEandGIT_SSL_CAINFOpointing at a bundle of the image's roots plus that CA) and checked per request.require-approvalrequests wait in Conclave like governed tool calls, and every broker decision is Chronicled as toolegressand listed on the Citadel's perimeter log.RUNEWARD_BROKER_UPSTREAM_CAadds private upstream roots. - Endpoint binaries. An
[[endpoint]]withbinariesis usable only by those executables: before forwarding a byte or attaching a credential, the broker asks the in-sandbox supervisor which executable holds the connection and refuses anything else (reason codebinary). It is enforced, not advisory, and fails closed; see Endpoint binaries below. - In-sandbox confinement. A Charter with
[filesystem]or[process]runs the agent underruneward-supervisoras PID 1: Landlock limits the agent to the listed paths, a seccomp filter blocks ptrace, namespace creation and similar escapes, and the agent runs as the Charter's non-root identity with every capability cleared. Governed execs go throughruneward-supervisor execwith the same confinement. The supervisor's enforcement report is Chronicled and raised on the Citadel;landlock = "required"or[process]refuse to start on a kernel that cannot enforce it (the guest kernel under Kata, gVisor's own kernel under runsc), and a required Landlock that is not enforced fails the create. The sandbox's root holds onlySETUID,SETGIDandCHOWN, with no privilege escalation, until the supervisor drops them. - Docker/Podman run the binary the sandbox image carries
(
deploy/Dockerfile.sandbox); a best-effort[filesystem]on an image without it runs unconfined, and that is recorded. - Kubernetes never takes the binary from the sandbox image: an init
container from the egress image (
deploy/Dockerfile.egress), running as root with every capability dropped, copies it into a memory-backed emptyDir mounted read-only at/run/runeward/binand writes the config documents into the root-only/run/runeward; under strict egress the iptables init container does this instead (see isolation for the Firecracker drive budget). Pod exec cannot pick a user, so the backend's own operations (workspace tar streams, file projection, the isolation probe) also go through the supervisor, as the agent identity without Landlock or seccomp, as Docker'sexec -udoes. Kubernetes cannot read an image'sUSER, so the agent identity must come from the Charter ([process] run_as_userorhost.user). The control plane runs the image's ownsh,chownandcatas the container's root (to set an exec's working directory, hand the workspace to the agent and read the report), as Docker runs itschown: the agent cannot change root-owned image files. - Per-action policy and approvals.
allow/deny/require-approvalverdicts, with human-in-the-loop gates for risky operations. - Guardrails. Hard caps on wall-clock, exec count, egress requests, and token/spend budgets, plus retry-loop detection.
- Tamper-evident audit. An append-only, hash-chained, ed25519-signed ledger,
independently verifiable offline. Events can also stream in real time to a
webhook or file sink (
RUNEWARD_AUDIT_WEBHOOK_URL/RUNEWARD_AUDIT_FILE) for SIEM ingestion, over a non-blocking queue that never stalls the ledger. A built-in anomaly detector flags novel egress targets, exec bursts, and denial spikes (RUNEWARD_ANOMALY_*). - Terminal session recording. With
RUNEWARD_RECORD_TERMINALS=1, governed terminal sessions are captured as asciinema v2 casts under the state dir and can be replayed withruneward replayas part of the audit trail. - Read-only Live chat. Conversation turns are opt-in messages published by an agent harness, not automatic capture of private agent UI text. Rune Box bounds each Citadel's in-memory history, applies declared-secret and credential-pattern redaction, strips terminal controls, and streams through an ownership-checked, same-origin WebSocket using a short-lived scoped ticket. The observer socket accepts no application input. Live chat is operational visibility; the signed Chronicle remains the authoritative action evidence.
- No host mounts.
copy_fromcopies into the sandbox; the host tree is never mounted. Request-time overrides are administrator-only. SetRUNEWARD_COPY_FROM_ROOTS(a colon-separated allowlist) to confine which host directoriescopy_frommay read; sources outside the roots fail creation. - Kubernetes multi-tenancy. The managed namespace carries Pod Security
Admission labels (
RUNEWARD_K8S_PSA_ENFORCE, or the chart'spodSecurityStandard), Citadel containers always dropALLcapabilities and disable privilege escalation (a supervised one adds backSETUID,SETGIDandCHOWNfor the supervisor, which thebaselinestandard allows, and runs as root until the supervisor drops to the agent, whichrestricteddoes not), and an optional default-deny NetworkPolicy (DNS-only egress) isolates Citadel pods (RUNEWARD_K8S_NETWORK_POLICY, or the chart'snetworkPolicy.enabled) so environments can't reach each other or the control plane laterally. - Admission enforcement defaults. The validating ClusterPolicy webhook is
fail-closed (
failurePolicy: Fail) so webhook outages block admission for governed resources. The mutating default-profile webhook is best-effort (failurePolicy: Ignore) and only fills missingspec.profile. - Supply-chain assurance. Releases are cosign-signed (keyless) with SBOMs,
and CI runs SAST (gosec, CodeQL), dependency/vuln scanning (govulncheck, Trivy),
per-image CVE scans, and a DAST baseline, with Dependabot keeping dependencies
current. Gosec uploads the complete SARIF report for triage and blocks releases
on every unsuppressed HIGH-severity result; accepted architecture findings use
narrow, source-local
#noseccomments with justification.
In scope (please report)¶
- Citadel escape from an environment to the host or another environment.
- Bypass of the egress allowlist, policy engine, or approval gates.
- Audit-ledger forgery or silent tampering that verification would miss.
- Path traversal / writes outside the intended workspace (e.g. tar-slip).
- Auth/authorization flaws in the REST API, WebSocket terminal, browser IDE proxy, or admission webhook.
- Secret leakage in logs, the ledger, or the dashboard.
Operator responsibility (out of scope)¶
- Security of the container runtime, host kernel, and Kubernetes cluster — keep them patched.
- Trustworthiness of images referenced by Charters and of the agents/CLIs you run inside an environment.
- Secrets you place in Charters; Rune Box redacts declared secret values from
the ledger and additionally masks common credential shapes (API keys, bearer
tokens, PEM keys,
password=/token=pairs) wherever they appear, but pattern matching is best-effort and can't catch every custom format. - Network exposure of
runeward serve. It binds127.0.0.1and requires an API token before any non-loopback bind, but you still choose the token strength, terminate TLS appropriately, and configure static RBAC or OIDC rather than a shared token for team deployments. - Denial of service from workloads you explicitly grant large resource limits.
Operational notes¶
One writer per ledger
The audit ledger is single-writer, protected by a file lock. Give each running
instance its own RUNEWARD_STATE_DIR. Two processes sharing one ledger produce
out-of-order/duplicate records, permanently breaking the hash chain so
verification reports tampering.
Same-origin WebSocket
The dashboard terminal WebSocket enforces a same-origin check to prevent
cross-site hijacking, and state-changing REST requests reject mismatched
browser Origins. Per-IP rate limiting defaults to 50 requests/sec; tune
RUNEWARD_RATE_LIMIT or explicitly set it to off for trusted benchmarks.
Front the control plane with TLS in production.
Experimental browser IDE
With RUNEWARD_ENABLE_EXPERIMENTAL_IDE=1 and Charter [ide] enabled, serve
reverse-proxies code-server inside the environment. That session is workspace-equivalent
interactive access (not per-keystroke policy). Cursor/Claude Desktop/Codex
GUIs are not embedded; GitHub Copilot is not first-class on code-server
(Open VSX). Details and limits: Browser IDE.
Rune Box is defense-in-depth, not a hard isolation boundary. Its default
container backend shares the host kernel, so a determined escape via a kernel or
runtime vulnerability is possible. For untrusted or adversarial workloads, add
VM-grade isolation by setting host.runtime_class in the profile to a
sandboxed runtime. On Kubernetes this maps to runtimeClassName (e.g. gvisor
or kata); on Docker it maps to docker run --runtime (e.g. runsc for
gVisor; for Kata, the name it is registered under in daemon.json, such as
kata pointing at the containerd-shim-kata-v2 or kata-runtime binary, or
the containerd runtime handler io.containerd.kata-qemu.v2). The runtime must
first be installed and registered with your engine. runeward runtime install
gvisor --apply downloads and registers gVisor for Docker; Kata itself is not
installed by Rune Box (runtime install kata --apply only registers an
existing Kata install with Docker, or applies a RuntimeClass on Kubernetes). A
name the engine doesn't recognize fails environment creation rather than silently falling back to the
shared-kernel runtime. runeward runtime check probes Docker and Kubernetes for
registered runsc/kata runtimes and runeward runtime guide prints the setup
steps. microVM runtimes (Kata Containers with QEMU or Cloud Hypervisor, and
Firecracker on Kubernetes; krun is refused because it cannot exec into a
running guest) are supported the same way, and the reported tier is derived from
evidence collected inside the sandbox, not from the runtime's name: see
Isolation and runtimes. host.type = "microvm" boots a
Firecracker or libkrun microVM per Citadel directly, with no container engine:
see Native microVMs below. For the strictest cases, also
use a disposable host.
Rune Box's sweet spot is governing a cooperative-but-fallible agent, not caging
code whose goal is to break out.
Endpoint binaries¶
[network]
default = "deny"
enforce = "strict"
[[endpoint]]
name = "github"
host = "api.github.com"
protocol = "rest"
credential = "github"
binaries = ["/usr/bin/gh", "/usr/bin/git"]
What a binary is. The executable file the kernel runs for a process, the
target of /proc/<pid>/exe, matched by file identity (device and inode)
against the listed paths as they were when the Citadel started. A listed
symlink matches the file it points to (/usr/bin/python3 matches a process
running /usr/bin/python3.11), a hard link matches, a copy of an allowed
binary at another path does not, and a file put at a listed path after start
does not. For an interpreter the binary is the interpreter: python3
script.py and a #!/usr/bin/env python3 script are both python3.11, so
listing an interpreter (or a shell, env, busybox) allows every program it
runs; runeward validate warns about that. Scripts are never identified by
name, since argv is chosen by the caller and proves nothing.
How it is enforced. For every connection to such an endpoint the broker
sends the in-sandbox attribution helper the connection's sandbox-side address
and destination with a fresh nonce. The helper finds the socket in
/proc/net/tcp{,6}, every process in the Citadel holding it (each thread's
descriptor table), and each holder's executable, and answers with an
HMAC-SHA256 tag under a per-Citadel key. The broker forwards only if the
answer verifies and every holder is a listed binary; in mode = "audit" it
records audit-would-deny and forwards. Each decision records the attributed
binary and binary_enforced (true when enforced, false in audit mode), on
the perimeter list and in the Chronicle.
The helper is started by runeward-supervisor (PID 1) before the agent, as a
separate process: it pins the listed binaries, binds an abstract Unix socket
in the Citadel's network namespace (@runeward-attribution, which the
strict-egress sidecar shares), drops to the agent's uid (needed to read the
agent's /proc/<pid>/fd without CAP_SYS_PTRACE), sets no_new_privs,
installs the supervisor's seccomp filter and turns non-dumpable. It has no
Landlock domain on purpose: Landlock only lets a process inspect processes in
its own domain, so a Landlocked helper could not see the agent at all (and the
same rule keeps a Landlocked agent from inspecting the helper). The key
reaches the helper on its stdin from the root-only supervisor config and the
sidecar in its root-only broker document; it is never in an environment
variable, on a command line, in the report or in a log line.
Threat model. The agent is untrusted and shares the helper's uid. It
cannot forge or replay an answer: it never holds the key, cannot read the
helper's memory or descriptors (non-dumpable; ptrace, process_vm_readv
and pidfd_getfd are denied by its seccomp filter), and every answer is
bound to the broker's nonce and the connection's addresses. It can connect to
the helper's socket, or take its name if the helper dies, but answers it
produces fail verification. Everything that goes wrong fails closed: no
answer, a bad tag, a socket nobody in the Citadel holds, a holder the helper
cannot inspect (another uid such as an operator's root docker exec, or a
non-dumpable process), and any holder that is not listed all refuse the
connection.
Where it is enforced, and where it is refused. Attribution needs the broker to share the Citadel's network namespace and kernel, and the supervisor to run, so:
- Docker and Podman with strict egress and a container runtime (runc,
crun): enforced. Cooperative egress cannot attribute (the broker runs in
the control plane and sees only NATed connections), so a Charter with
binariesand without strict egress is an error inruneward validateand refused at create, the same waylandlock = "required"is refused on a kernel without Landlock. gVisor and VM runtimes keep the Citadel's sockets out of the sidecar's sight and are refused at create. - Kubernetes with strict egress: enforced. The sidecar shares the Pod's
network namespace, the broker document carries the attribution socket and
key, and the supervisor (installed from the egress image by the init
container) starts the helper. The sidecar's decisions are read back from
its log, so they reach the perimeter list and the Chronicle as on Docker.
Verified on kind with runc (see
Endpoint binaries on Kubernetes).
Under Kata the whole Pod runs in one VM, so the sidecar and the helper
share the guest's network namespace too, but that has not been run yet;
gVisor (
runsc) has not been tested either. - Native microVMs (
host.type = "microvm"): not supported. The broker runs on the host, outside the VM, so it cannot reach the helper inside the guest;runeward validatereports an error and the create is refused.
binaries requires the supervisor even without [filesystem] or
[process], so the agent needs a non-root identity ([process] run_as_user,
host.user, or an image USER).
Residual risks.
- Binary identity is not code identity. An allowed interpreter runs any
script, and an allowed dynamically linked binary can be made to load other
code (
LD_PRELOAD, plugins, extensions,githooks and helpers). Prefer static, single-purpose binaries, and pairbinarieswith request rules. - Attribution happens when the connection is accepted. A holder that later
hands the socket to another process (
SCM_RIGHTS, an inherited descriptor) is not re-checked; that needs the allowed binary's cooperation. - The agent can kill the helper (same uid). That only makes every binary-restricted endpoint refuse until the Citadel is restarted.
- A process the helper cannot inspect anywhere in the Citadel, while it holds the socket, makes the connection refuse; an operator running a root shell that holds such a socket will see refusals.
Native microVMs¶
With host.type = "microvm" each Citadel is its own VM (Firecracker or
libkrun on KVM), so a guest kernel or runtime exploit lands in that VM, not
on the host kernel. What crosses the boundary, and why:
- Disks. The image disk is attached read-only and shared by the VMs of that image; the guest writes into memory. The workspace is a per-Citadel disk image. The host never mounts either: workspace export, snapshots and file reads go through the guest agent, so the host does not interpret a filesystem the guest could have corrupted. Image layers are untrusted input: they are verified against their digests and unpacked into a staging directory without ever following a link out of it, as an unprivileged user.
- No network device. The guest has none (libkrun's socket-impersonation
networking is disabled). Its only way out is a vsock port the host maps to
a per-Citadel Unix socket served by the Charter's egress proxy, so
default = "deny"and the request broker cannot be bypassed from inside the guest, even by its root; non-HTTP traffic has no path at all. The in-cell IDE is therefore unavailable. - The guest agent (
runeward-vmagent, PID 1) is inside the boundary: a root process in the guest can replace or impersonate it. That only affects that Citadel's own exec, files and isolation evidence; the verdict is still capped by the monitor (it can never claim more thanvm), the host side confirms the monitor process it launched, and egress enforcement is on the host. Commands and file operations run as the Citadel's user after an identity switch, not as the agent's root. - The monitor runs as the Rune Box user with access to
/dev/kvm. Firecracker applies its built-in seccomp filters; it is not run under its jailer. libkrun runs in theruneward-krunhelper process. Neither gets a network device or a shared host directory. - Secrets. Environment values (including broker placeholders) reach the guest over the agent's vsock stream, never on the kernel command line or in a file on the host; broker credentials stay in the control plane.
Broker secrets in the egress sidecar¶
In cooperative mode on Docker/Podman the broker runs inside the control plane,
so resolved credentials never leave it. Strict mode and Kubernetes run the
proxy in a sidecar, which needs the secrets to inject them. The control plane
hands the sidecar a runeward.egress.broker.v1 document (endpoints, resolved
credential values, the placeholders already set in the Citadel, and the
per-Citadel CA certificate and private key) through runeward-egress
--broker-config <file>:
- Docker/Podman strict mode: a
0600root-owned file copied into the stopped sidecar container before it starts; the sidecar reads it while still root, removes it, then drops to the proxy uid. The Citadel shares only the sidecar's network namespace, not its filesystem or processes. - Kubernetes: a per-Citadel Secret mounted into the egress container only (world-readable inside that one mount, because kubelet makes Secret files root-owned while the proxy runs unprivileged); the sandbox container mounts only the CA certificate key of it.
- The document is never put on a command line or in an environment variable.
Anyone who can read the sidecar's filesystem or memory, or read Secrets in the
Citadel namespace, can read those credentials: the container engine, node
root, and Kubernetes principals with Secret read or pods/exec on the egress
container. Grant that access accordingly, or use cooperative mode on
Docker/Podman when credentials must stay on the control-plane host. Approval
routing is not available in the sidecar yet: a require-approval request
there is refused. Strict-mode decisions are read back from the sidecar's log
stream into the perimeter list and the Chronicle, on Docker/Podman and on
Kubernetes. If the control plane restarts while a Citadel runs, its later
decisions are recorded again only once the Citadel is woken.