Cowboy Runtime — original design (historical)
Status: historical design document, kept for the rationale. The runtime this
doc motivated is implemented in crates/runtime and packaged in the flake as
cowboy-runtime (Linux-only); montana and the OCI bundle producer landed
before it. The implemented contract is specs/OCI.md (verbs, flags,
annotations, lifecycle), with specs/README.md mapping each contract to its
implementation and crates/runtime/README.md tracking status. The prose below
describes the tree as it stood before the runtime existed — it is the
problem statement and design rationale, not a description of today. Where the
shipped runtime diverged, an inline (Shipped: …) note says how.
What shipped, and where it diverged from this design:
- The gap is closed.
cowboy-runtimeexecutes the cowboy bundle and enforces its policy on the container path today; “nothing executes the bundle” below is the pre-runtime problem statement. - Narrower verb surface. The public ABI is
features/create/start/state/kill/delete(+--version). The plannedexec,update,events, andpswere not built and are explicitly excluded (specs/OCI.md). - Seccomp
Denyshipped (2026-07-09) as an opt-in —COWBOY_RUNTIME_SECCOMP=denyor thers.cowboy.policy.seccompannotation, fail-closed — with the default stillUnsafepending enforcement soak. - Montana is linked in, not launched.
__initis PID 1 inside the cage and forks montana in-process as a library; the image ships only the wasm component, no montana binary. - containerd goes through Imageless. Instead of pointing the stock
runc-shim at our binary directly, containerd invokes the external
imageless-runcinterposer (github:dmadisetti/imageless), which materializes the bundle andexecscowboy-runtimeas its delegate — still under the stock runc-v2 shim. - Egress is AF_UNIX, not a netns sidecar. The cage runs
NetworkMode::None; egress rides shared-volume UNIX sockets to a companion container (redis + mitmproxy). - The gofer/NOTIFY listener stayed deferred. Verb mediation ships as
native per-tool
sentry_binarywrapping plus an ERRNO syscall boundary installed in__init;seccomp.listenerPathremains unwired.
Thesis
At the time of writing, we already emitted a conformant OCI bundle for a
cowboy agent, but nothing executed it on the container path. The bundle
was consumed two ways (modules/lib/policy.nix): cowboy’s own launcher
lowered it to systemd-run + native sheepdog + a joined netns (deliberately
dropping file isolation), and a stock runtime (runc/crun) could run it
as a bundle. But when an operator did the obvious thing — docker run cowboy:latest, or a k8s Pod — Docker/runc read only the image config
(Cmd/Env/WorkingDir/Volumes) and generated its own runtime spec from CLI
flags. Our carefully-produced runtime-spec policy (namespaces,
linux.resources, maskedPaths/readonlyPaths, seccomp, and the
rs.cowboy.policy.* verb rules) was never consulted. It was real and
conformant, but inert on the path most people would actually use.
The proposed runtime closes that gap. It is a small runtime binary —
--runtime=cowboy — that executes our shape of OCI bundle, enforcing
the policy we already describe, with montana as the workload and
nucleus used as a library to supply the isolation primitives
cowboy did not then apply on the container path. The goal is that docker run --runtime=cowboy and a k8s RuntimeClass: cowboy give you the isolation the
bundle has always described, instead of the runtime defaults.
Nucleus (nucleus-container on crates.io) is a lightweight,
Nix-native, security-hardened container runtime in Rust — namespaces, cgroups v2,
pivot_root, capabilities, seccomp, and landlock, with SHA-256 rootfs
attestation and fail-closed production semantics. It exposes a [lib] nucleus
target, so we consume those primitives directly rather than shelling out to its
CLI.
Background: the pre-runtime tree (the state this was written against)
- The shape producer already existed.
modules/lib/policy.nix::mkOciPolicyis the single producer of an OCI Runtime Spec v1.2 document;modules/lib/oci.nix::mkOciConfigassembles it into a full bundle (standard proc/sys/dev mounts, the full namespace set, a real closure-as-rootfs,noNewPrivileges, resources);modules/container.nixemits both the runnable bundle (services.cowboy.ociBundle— “runc run -b <out> cowboy”) and a loadable Docker image (flake.nixdocker-image). - Cowboy-specific policy already rode in-band. Verb-granular filesystem
rules have no native OCI slot (seccomp argument matching is scalar — the whole
reason the sheepdog gofer exists), so they travel as annotations under
rs.cowboy.policy.deny/.allow. The gofer-mediated syscall set (goferSyscalls) and aseccomp.listenerPathwere emitted only for the container consumer;listenerPathwas null by default and unwired — and the gofer/NOTIFY file boundary remains deferred today. - Montana had no isolation. The headless embedder (
crates/montana) ran the agent component and executed leaf tools via a plainstd::process::Commandon a scratch thread — no landlock, seccomp, namespaces, chroot, or rlimits — and its nativeexecescaped even the WASI capability model (it ran directly on the host). Subagents were additional component instances in-process, so they shared whatever boundary montana had (then: none). (Shipped: montana stays isolation-agnostic as a crate by design, but undercowboy-runtimeit runs inside the nucleus cage — landlock, namespaces,pivot_root, resource limits, the stacked seccomp layers — with sheepdog wrapping every tool argv.) - Sheepdog is a core concern, not a montana one. The sentry/argv-wrapping
lives in
crates/core(ranch/sentry.rs) and is inert unlessSENTRY_BINARY/ asentry_binaryconfig key is set. Montana just runs the argv it is handed; ifsentry_binaryis configured, core wraps tool argv before calling the hostexeceffect.
The net gap, then: on the container path we got the image’s process config and
Docker’s default cage, plus (only if configured) sheepdog’s per-tool mediation
inside the guest. The outer OCI runtime policy — the part mkOciConfig
describes — was not applied by anyone. Closing this is what crates/runtime
now does.
Goals
- Execute the cowboy OCI shape natively under Docker and k8s, enforcing the
runtime-spec policy the bundle already carries: namespaces,
linux.resources,maskedPaths/readonlyPaths, the seccomp block-list, and thers.cowboy.policy.*verb layer. - Use nucleus as a library to supply the isolation primitives cowboy lacked
on the container path (the “stop-gaps”): mount-ns +
pivot_root(file isolation cowboy opted out of), pid-ns, user-ns (the rootless enabler), cgroups, landlock, and boundary seccomp. Nucleus is a dependency of the runtime, not the runtime and not a separate container. - Keep montana the workload, unchanged. The runtime provides the cage montana
lacks; montana stays isolation-agnostic. Its then-unsandboxed
execbecomes structurally bounded by the cage and (via sheepdog) semantically mediated. - Make the runtime a closed execution domain. It runs attested/marked cowboy payloads and refuses everything else, so it cannot be repurposed as a general-purpose hardened sandbox.
- k8s-first. Deploy as a
RuntimeClassthat coexists withrunc/gvisor, with per-Pod opt-in and node steering.
Non-goals
- Not a general-purpose runtime. We do not compete with
runcfor arbitrary workloads; non-cowboy payloads are intentionally rejected (see Anti-abuse). - Not a replacement for sheepdog. The cage is structural (reachability); sheepdog remains the semantic layer (read/edit/create/delete verbs, egress, exec trusted-prefix). They compose.
- Not the WASM-native shim (runwasi) model. Montana is the workload process; the wasm component lives inside it. Making the wasm component itself the first-class OCI artifact is a possible later pivot, noted under Alternatives, not this design.
- Not per-tool re-caging. Isolation is applied once at the container boundary (entry seam) and inherited; we do not build a fresh cage per leaf command.
The cowboy OCI shape
“A specific shape of OCI made intentionally for cowboy payloads” means: a standard
OCI Runtime Spec v1.2 bundle that a stock runtime can still run (in degraded form),
plus a set of cowboy annotations our runtime additionally honors. Concretely
the shape is the existing mkOciConfig output extended with:
- Workload descriptor. The process is montana; the shape must carry (or the
image must embed) the wasm component path and the flat agent config
(provider/model/keys/dirs) montana passes to
load(). Montana took these as a config map; the shape formalizes where they live (image env + an annotation or a mounted config file). - The verb layer (
rs.cowboy.policy.deny/.allow) — already produced; consumed by sheepdog. - The mediation wiring (
goferSyscallsNOTIFY set +seccomp.listenerPath) — already produced but unwired; the runtime is what would finally bind the listener. (Shipped: still unwired — the gofer/NOTIFY file boundary is deferred; verb mediation is nativesentry_binarywrapping.) - A cowboy marker. A required annotation (and, when the threat model warrants,
an attested rootfs digest) that identifies the bundle as a legitimate cowboy
payload. This is the gate that makes the runtime a closed domain. (Shipped
as
rs.cowboy.payload— a workload-type gate, not an authentication boundary;specs/OCI.md.)
Design property: the shape stays a valid OCI bundle. A stock runtime ignores
the rs.cowboy.* annotations and runs a degraded-but-safe container (ERRNO-only
seccomp, structural isolation, no verb mediation). Our runtime reads the
annotations and enforces the full policy. Forward- and backward-compatible by
construction — the same discipline gVisor/Kata use for their own annotations.
The runtime
Form. A binary implementing the runc CLI ABI, planned as create /
start / state / kill / delete / exec, plus update / events /
ps as needed. Speaking this ABI means it slots into Docker --runtime= and
into k8s via the stock containerd runc-shim pointed at our binary — we write
no containerd shim. Invocations are stateless processes that share
per-container state under a --root directory, as every OCI runtime does.
(Shipped: the public surface is features / create / start / state /
kill / delete / --version only; exec, update, events, and ps are
excluded, and unknown commands fail with usage rather than falling through —
specs/OCI.md. The k8s path runs through the imageless-runc interposer; see
Deployment.)
Lifecycle mapping. The runtime’s verbs bracket montana’s existing startup:
create— read the bundle; validate the cowboy marker (fail closed otherwise); build the cage with nucleus-lib from the consumedconfig.json(namespaces, cgroups, mounts, pivot, seccomp, landlock); prepare montana as the init process, blocked before it runs.start— release montana into its event loop.state/kill/delete— the standard reporting and teardown;killmaps signals to montana (graceful quit vs. hard stop);deletereaps and frees the cage.exec—kubectl exec/ liveness-probe support (an additional process in the cage), or a stub initially. (Shipped: not built;specs/OCI.mdexcludes it.)
Nucleus as the isolation library. The runtime translates the consumed
config.json into calls on nucleus’s primitives — the same document
mkOciConfig produces. Nucleus supplies exactly the stop-gaps cowboy’s own
launcher drops: pivot_root + mount-ns (real file isolation into the closure
rootfs), pid-ns, user-ns (so the pivot/pid work rootless), cgroups (from
linux.resources), boundary seccomp (from linux.seccomp), and landlock (new —
no equivalent existed anywhere in cowboy). Nucleus is linked as a
dependency; the runtime owns lifecycle and policy translation, nucleus owns the
syscalls.
Sheepdog integration — two paths, same policy. The verb layer is enforced by sheepdog, which the runtime can wire two ways:
- Native. Set
sentry_binaryin montana’s config so core wraps tool argv with sheepdog and it installs its own filter per tool — mediation inside the guest. Simplest first step; works without touching the seccomp listener. - Agent mode. The runtime installs the NOTIFY seccomp filter from the bundle and
binds the
seccomp.listenerPathsocket to sheepdog’s already-implemented runc-agent receiver, so verb rules enforce at the container boundary. The runtime is the natural (and then-missing) driver for this socket.
(Shipped: the native path, plus sheepdog’s default-ALLOW + ERRNO(ENOSYS)
escape boundary installed in __init over montana; agent mode — the NOTIFY
listener — remains deferred, see crates/runtime/README.md for why.)
Montana as workload. Unchanged. It runs inside the cage; its in-process
subagents inherit the cage automatically (one cage per montana = one trust
domain, consistent with per-agent-isolation.md). The runtime provides what
montana structurally lacks; montana provides the embedder + comms it already
has. (Shipped: montana is linked into the runtime as a library and forked
in-process by __init — there is no montana binary in the image, only the
wasm component.)
Isolation model
- Entry seam, single cage. Built once at
create, inherited by montana, every tool it execs, and every in-process subagent. We do not re-cage per operation. - Cage derived from the bundle’s mounts. The landlock allowlist is the union
of the declared OCI mounts (rw/ro as declared) + the rootfs +
/nix/store(ro, exec). This makes PVC / NFS / FUSE / emptyDir volumes auto-covered: the same mount list that defines the container filesystem defines the cage, so they can’t drift. - Toggle model with k8s deferral. On bare Docker the runtime applies the full
set from the bundle. On k8s the Pod sandbox already provides netns/pid-ns/cgroups
(CRI shaped the
config.jsonaccordingly); the runtime honors what’s there and adds the layers k8s doesn’t give per-workload — landlock, fine seccomp, and the cowboy verb layer — rather than double-building namespaces. - Relationship to sheepdog. Nucleus/the cage is the structural wall (what is reachable at all); sheepdog is the semantic policy within it (which verb on which path, egress, exec trusted-prefix). Complementary — and landlock meaningfully shrinks sheepdog’s trusted computing base (a path blocked structurally can’t be reached even if a mediation race slips).
Seccomp posture: Unsafe now, Deny later
(This split shipped as designed: both modes are built. Deny landed
2026-07-09 as an opt-in — COWBOY_RUNTIME_SECCOMP=deny or the annotation,
fail-closed — and the default stays Unsafe until the harvested allowlist
survives enforcement soak on varied real workloads; the flip is a one-line
change in crates/runtime/src/spec.rs.)
The syscall layer is an explicit, named mode (rs.cowboy.policy.seccomp /
COWBOY_RUNTIME_SECCOMP, resolved env > annotation > default) rather than an
accident of whatever profile the bundle carried.
-
Unsafe(the default, then and now). nucleus atTrace(allow-all + a per-syscall NDJSON firehose) with cowboy’s own escape-denylist floor stacked underneath via sheepdog in__init(the kernel takes the most-restrictive per syscall: escape syscalls →ERRNO, everything else → allowed + logged). This is deliberately permissive — it is the data-collection posture: the firehose records the real syscall closure of montana + wasmtime JIT + tokio so the allowlist can be derived from ground truth rather than guessed. It is emphatically not “seccomp off”: the escape floor (mount family, ptrace, bpf, kexec, module loading,unshare/setns, …) always applies. Everycreateannounces the mode; it is recorded instate.json. The floor is cowboy-authored (rs.cowboy.policy.seccomp-block, or a canonicalDEFAULT_SECCOMP_BLOCK) — the runtime deliberately ignores the bundle’slinux.seccomp, because under docker that field is docker’s own default profile, and harvesting it forwarded aclone3ERRNOentry that sheepdog’s fail-closed map couldn’t resolve, bricking__initbefore montana ever ran. -
Deny(planned here; built 2026-07-09, opt-in). A default-DENY allowlist: nucleusEnforce+ a cowboy-supplied profile derived from theUnsafefirehose, with nucleus’s separate “would-have-denied” audit sink on and the sheepdog floor reduced to a backstop. Enforce + a verbatim profile file (not nucleus’s built-in allowlist, which is too narrow and drags theallow_network/resolv.conf bug) lets cowboy own the allowlist while reusing nucleus’s mature profile loader + sha256 pinning + deny logger. Fail-closed at every seam: an unresolvable, empty, or poisoned allowlist abortscreate— never a silent downgrade toUnsafe(a requested tightening that isn’t available must fail-closed). The value is kernel-attack-surface reduction (perf_event_open/io_uring/userfaultfd/keyctl-class), driven by measured data, not curation. (Shipped as designed: the harvestedDEFAULT_SECCOMP_ALLOWseed plus ars.cowboy.policy.seccomp-allowoverride, opt-in while the default soaks atUnsafe.) Precursor noted at the time: replace sheepdog’s hand-maintainedsyscall_numbermap with libseccomp per-arch resolution — theclone3incident showed even a ~50-name denylist is fragile to a mis-typed or arch-shifted name.
Anti-abuse: a closed execution domain
The concern is hygiene, not (initially) adversaries: --runtime=cowboy should not
become a general hardened sandbox that unrelated workloads quietly adopt.
- Runtime create-gate (the real control). At
create, assert the cowboy marker (and optionally an attested rootfs digest). Missing → exit non-zero with a clear, helpful message pointing operators atrunc. Because the concern is accidental reuse, the marker can be a plain annotation (forgeable is fine); it escalates to a signature/attestation only if the threat model shifts to multi-tenant. Hard-fail, not pass-through — the point is to make the runtime useless for anything but cowboy. - k8s admission (UX). A
ValidatingAdmissionPolicythat scopes who may selectruntimeClassName: cowboy(a namespace/label) rejects misuse at apply time with a clean error, before scheduling. Backstopped by the runtime gate, which also covers Docker/Podman where there is no admission layer. - Reverse direction is safe. A cowboy payload accidentally run under stock
runcjust runs montana without the extra hardening — degraded, not dangerous. So we only gate the forward direction.
Deployment
- Docker. Register
runtimes.cowboyindaemon.jsonpointing at the binary;docker run --runtime=cowboy. - k8s. Register a containerd runtime handler (stock runc-shim with our binary
as
BinaryName) + aRuntimeClass(handler: cowboy); Pods setruntimeClassName: cowboy. Node steering via the RuntimeClassschedulingblock so cowboy Pods land only on nodes where the runtime and a suitable kernel are present. Coexists withrunc/gvisor/kataon the same cluster. (Shipped: the handler’s binary is the externalimageless-runcinterposer (github:dmadisetti/imageless) — it materializes the bundle from its release annotation andexecscowboy-runtimeas its delegate, still under the stock runc-v2 shim; seecrates/runtime/README.md.) - Node requirements. Landlock (kernel ≥ 5.13; some managed node images ship it off — use best-effort application and log when it no-ops so we never believe we’re caged when we aren’t); cgroup v2; seccomp user-notification + ADDFD (≥ 5.9) for sheepdog agent mode.
Storage
All backends reduce to entries in the bundle’s mounts, honored by the runtime and
auto-covered by the derived cage; the isolation model is indifferent to which is
chosen. Guidance:
- Durable per-agent state (memory, sessions, data dir) → a block PVC (RWO).
- Shared cross-pod / multi-agent state → NFS (kernel client, RWX, gVisor-safe) — but mind NFS locking (keep lock-sensitive state off RWX), uid squash vs. the agent uid, and that NFS I/O bypasses the agent’s egress proxy (the mount lives above the agent netns).
- Object-store-backed workspaces → FUSE, mounted outside the sandbox by a privileged CSI/sidecar and bind-mounted in (agents can’t mount — sheepdog blocks it); mind mount propagation, and note FUSE is incompatible with a gVisor escalation.
- Scratch + the runtime sockets → emptyDir/tmpfs (unix sockets want node-local ephemeral storage).
Ownership must line up: the agent uid needs to own its PVC (fsGroup/runAsUser)
and its per-agent socket, and map to NFS exports.
Comms & credentials
The runtime owns only the lifecycle plane. The cowboy control plane is unchanged and rides beside it, wired via the mounts/netns the runtime honors:
- Driver (intents in ↔ frames out): montana’s ndjson socket (unix 0600 /
TCP). On k8s expose it as a
containerPort+ Service; mirror frames to stdout as ndjson for freekubectl logs. - Inter-agent bus: the per-agent redis socket, bind-mounted in; identity keyed
by which socket accepted the connection (topology, not credential), which
survives containers exactly as
per-agent-isolation.mdanticipated. Wired as a shared volume + redis sidecar or a cluster Service. - Egress + credentials: the mitmproxy sidecar + a NetworkPolicy; provider keys
are stubbed with the
proxyplaceholder and injected at the network layer, so the agent process never holds them. (Shipped: the cage runsNetworkMode::None— no netns to police with a NetworkPolicy; egress rides a shared-volume AF_UNIX socket to a companion container carrying the redis bus and the credential-injecting mitmproxy. Same policy, different transport.)
Key risks & open questions
- Seccomp stacking (highest). Three seccomp layers can coexist: the k8s
RuntimeDefaultprofile, the bundle’s boundary filter, and sheepdog’s notify filter. They compose (most-restrictive wins), so we must verify the Pod default does not block what sheepdog itself needs (seccomp,/proc/<pid>/memreads, the NOTIF ioctls /ADDFD). This is the most likely silent breakage. - Landlock availability on managed nodes — best-effort + explicit logging.
- PID-1 duties. When montana is the container init (pid-ns on, or the Pod’s pid namespace), it must reap zombies and forward signals — a small but non-optional obligation.
- Wiring
seccomp.listenerPathand choosing the default sheepdog path under the runtime. (Resolved: nativesentry_binaryshipped first; the listener remains deferred.) - Identity/uid reconciliation across PVC
fsGroup, the redis socket owner, and NFS squash. - Marker strength. Plain annotation is right for hygiene; revisit toward attestation if we ever run untrusted third-party agents.
Alternatives considered
- Nucleus as the runtime binary (rather than a library the cowboy runtime calls). Rejected for now: we want the runtime to own the cowboy-specific concerns (marker gate, montana workload, verb wiring) and treat nucleus as an isolation toolkit. Revisit if the two collapse cleanly.
- A containerd shim (montana as the runwasi-style workload). More work and containerd-specific; its unique payoff is making the wasm component the first-class OCI artifact, which we don’t need while montana is always the host for comms/subagents. Deferred, not foreclosed.
- Do nothing / rely on
docker run+ baked policy. This was the state at the time of writing: degraded and confusing (the bundle looked enforced but wasn’t on the container path). Rejected — closing that gap was the point, and the shipped runtime closed it.
Relationship to existing code (reuse, don’t rebuild)
- Reuse
policy.nix/oci.nix/container.nixas the shape producer; add only the cowboy marker annotation and the workload descriptor, and wire the existingseccompListenerPathoption. - New: the runtime binary, and a nucleus library dependency
(
nucleus-container) for its isolation primitives. - Unchanged: sheepdog (native or its already-implemented runc agent mode);
montana (the workload — optionally gains a
sentry_binaryconfig value to turn on native mediation).
Phasing (rough)
- Shape. Extend
mkOciConfigwith the cowboy marker + workload descriptor; wireseccompListenerPath. (nix only) - Runtime skeleton. runc CLI ABI over
--root; lifecycle verbs; launch montana as workload; the marker gate. No isolation yet — parity with “real lifecycle, Docker’s cage.” - Nucleus stop-gaps. Apply cgroups + pivot/mount-ns + landlock (derived from
mounts) + boundary seccomp from the consumed
config.json. - Sheepdog wiring. Native (
sentry_binary) first; then agent mode (bindlistenerPath). - Docker smoke.
docker run --runtime=cowboy; verify enforcement (masked paths denied, limits applied, landlock scoping). - k8s. RuntimeClass, toggle deferral, storage, comms sidecars.
- Hardening. Seccomp-stacking verification, PID-1 reaper, admission policy, optional attestation.
Implementation status against these phases lives in crates/runtime/README.md;
the shipped contract is specs/OCI.md.