Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Capability Model

How capabilities work in capOS.

What is a Capability

A capability in capOS is a reference to a kernel object that carries:

  • An interface (what methods can be called), defined by a Cap’n Proto schema
  • A permission (the object it references, enforced by the kernel)
  • A wire format (Cap’n Proto serialized messages for all invocations)

A process can only access a resource if it holds a capability to it. There is no ambient authority – no global namespace, no “open by path” syscall, no implicit resource access.

Identity Terms and Authority

capOS documentation uses identity terms as policy metadata, not as kernel authorization primitives. A user is human-facing prose. A principal is the stable identity metadata used by authentication, policy, audit, and ownership records. An account is planned durable local record state for a principal, including credential references, roles, attributes, storage-root references, and default profile names. A session is the live context that receives a concrete CapSet. Policy profiles and resource profiles select bundle fragments, approval eligibility, and quotas that a trusted broker may use when minting capabilities.

None of those terms is kernel authority: the kernel dispatches through generation-tagged CapId entries, not users, roles, accounts, groups, UIDs, or profile names. Account-store behavior, durable profile records, and broader quota policy remain future work tracked in the local users backlog.

Quantitative Authority

A capability determines which typed object and methods a holder may use. It does not by itself promise an amount of memory, CPU time, queue space, network traffic, or service progress. Quantitative spend authority comes from a generation-bound owner ledger or resource grant at the component that owns the finite resource. A trusted broker may use a resource profile to select that grant, but the profile name, principal, account id, session metadata, badge, cookie, or peer address is policy data rather than spend authority. Transport peer/forwarded addresses and connection tuples are reachability, proxy-trust, anti-abuse, or audit metadata; they are never principal/session identity, and a forwarded value is trusted only from an explicitly admitted proxy capability or transport range.

Work performed before authentication – including connection state, parsing, failed-cookie handling, password hashing, and rejection responses – belongs to a service or anonymous-ingress ledger. Later authenticated work may consume an explicit session donation; successful authentication does not retroactively authorize or charge earlier work. See Resource Governance for structural ceilings, quotas, reservations, admission, backpressure, and fairness.

Session-Bound Invocation Context

Services should not infer authority from caller-supplied identity fields. A request parameter such as user, principal, client, or role is data. The active model is one immutable session context per process plus explicit capabilities granted by a broker or supervisor.

The general pattern is:

  • authentication or admission creates a live SessionContext;
  • process spawn installs exactly one immutable session context in the child;
  • AuthorityBroker grants service roots/facets appropriate to that session;
  • endpoint calls carry privacy-preserving caller-session metadata by default;
  • subject details such as global principal id, display name, profile class, or external claims are disclosed only through explicit client disclosure and a matching broker/service disclosure scope. The current endpoint CALL path implements this as a disclosure request mask intersected with cap-held disclosure scope.

The kernel role is narrower. It verifies that a process holds a live cap-table entry, that the process session is live, and that transfer/spawn obey session scope. It may deliver an opaque service-scoped caller-session reference and freshness result to endpoint servers, but it must not disclose broader subject details by default. It does not decide that a process is Alice, an operator, a moderator, or an NPC. Those are policy facts maintained by session, broker, account, and application services.

Opaque receiver selectors may still exist in the IPC implementation and in historical service-object routing tests. A receiver selector is not identity metadata, not shell syntax, not a user field, not a disclosure channel, and not a role bit. New shared-service identity should use the caller session context and broker-granted service facets, not caller-selected numeric labels. The chat demo now follows this rule for membership: the server receives the endpoint caller metadata and keys member records by an opaque live caller-session reference, while chat handles remain request data and visible member labels are assigned by the service. The shared chat/adventure endpoint helper now exposes caller-session metadata through EndpointCaller instead of a badge field; the old badge-named user-data type remains only as a source-compatible alias. Terminal output and shell-serviced stdio bridges are also gated by live caller-session metadata.

Schema as Contract

Capability interfaces are defined in .capnp schema files under schema/. The schema is the canonical interface definition. Currently defined:

interface Console {
    write @0 (data :Data) -> ();
    writeLine @1 (text :Text) -> ();
}

interface TerminalSession {
    write @0 (data :Data) -> ();
    writeLine @1 (text :Text) -> ();
    readLine @2 (request :LineRequest) -> (status :LineStatus, line :Data);
}

interface FrameAllocator {
    allocFrame @0 () -> (handleIndex :UInt16);
    allocContiguous @1 (count :UInt32) -> (handleIndex :UInt16);
}

interface MemoryObject {
    info @0 () -> (pageCount :UInt32, sizeBytes :UInt64);
    map @1 (hint :UInt64, offset :UInt64, size :UInt64, prot :UInt32) -> (addr :UInt64);
    unmap @2 (addr :UInt64, size :UInt64) -> ();
    protect @3 (addr :UInt64, size :UInt64, prot :UInt32) -> ();
}

interface VirtualMemory {
    map @0 (hint :UInt64, size :UInt64, prot :UInt32) -> (addr :UInt64);
    unmap @1 (addr :UInt64, size :UInt64) -> ();
    protect @2 (addr :UInt64, size :UInt64, prot :UInt32) -> ();
}

interface Endpoint {}

interface ProcessSpawner {
    spawn @0 (name :Text, binaryName :Text, grants :List(CapGrant)) -> (handleIndex :UInt16);
}

interface ProcessHandle {
    wait @0 () -> (exitCode :Int64);
}

interface BootPackage {
    manifestSize @0 () -> (size :UInt64);
    readManifest @1 (offset :UInt64, maxBytes :UInt32) -> (data :Data);
}

# Management-only introspection. Ordinary handle release uses the system
# transport opcode CAP_OP_RELEASE, not a method here.
interface CapabilityManager {
    list @0 () -> (capabilities :List(CapabilityInfo));
    revoke @1 (capId :UInt32) -> ();
    grant @2 (sourceCapId :UInt32) -> (targetCapId :UInt32);
}

Each interface has a unique 64-bit TYPE_ID generated by the Cap’n Proto compiler. TYPE_ID is the schema constant. interface_id is the runtime metadata used by CapSet/bootstrap descriptions and endpoint delivery headers. Method dispatch uses the interface assigned to the capability entry plus method_id; method_id selects a method inside that schema.

This is not capability identity. A CapId is the authority-bearing handle in a process table, analogous to an fd. Multiple capabilities can expose the same interface:

  • cap_id=3 -> serial-backed Console
  • cap_id=4 -> log-buffer-backed Console
  • cap_id=5 -> Console proxy served by another process

All three use the same Console TYPE_ID, but they are different objects with different authority. The manifest/CapSet should record the expected schema TYPE_ID as interface metadata for typed handle construction. Normal CALL SQEs do not need to repeat it because the kernel or serving transport can derive it from the target capability entry. CapSqe keeps reserved tail padding for ABI stability.

The kernel exposes the initial CapSet to each process as a read-only 4 KiB page mapped at capos_config::capset::CAPSET_VADDR and passes its address in RDX to _start. The page starts with a CapSetHeader { magic, version, count } and is followed by CapSetEntry { cap_id, name_len, interface_id, name: [u8; 32] } records in manifest declaration order. Userspace looks up caps by the manifest name rather than by numeric index (capos_config::capset::find), so grants can be reordered in system.cue without breaking clients. The mapping is installed without WRITABLE so userspace cannot mutate its own bootstrap authority map.

Security invariant: a CapTable entry exposes one public interface. If the same backing state must be available through multiple interfaces, mint multiple capability entries, each wrapping the same state with a narrower interface. Do not grant one handle that accepts unrelated interface_id values; that makes hidden authority easy to miss during review.

Invocation Path

Capabilities are invoked via a shared-memory capability ring (io_uring- inspired). Each process has a submission queue (SQ) and completion queue (CQ) mapped into its address space. Two invocation paths exist:

Caller builds capnp params message
    → serialize to bytes (write_message_to_words)
    → write CALL SQE to SQ ring (pure userspace memory write)
    → advance SQ tail
    → caller invokes cap_enter for ordinary capability methods
      (timer polling only runs explicitly interrupt-safe CALL targets)
    → kernel reads SQE, validates user buffers
    → CapTable.call(cap_id, method_id, bytes)
    → kernel writes CQE to CQ ring
    ... caller reads CQE after cap_enter, or spin-polls only for
        interrupt-safe/non-CALL ring work ...
    → caller reads CQE result

CapObject::call does not receive a caller-supplied interface ID. The cap table derives the invoked interface from the target entry before invoking the object. The SQE carries only the capability handle and method ID because each capability entry owns one public interface:

#![allow(unused)]
fn main() {
pub trait CapObject: Send + Sync {
    fn interface_id(&self) -> u64;
    fn label(&self) -> &str;
    fn call(
        &self,
        method_id: u16,
        params: &[u8],
        result: &mut [u8],
        reply_scratch: &mut dyn ReplyScratch,
    ) -> capnp::Result<CapInvokeResult>;
}
}

All communication goes through serialized capnp messages, even when caller and callee are in the same address space. This ensures the wire format is always exercised and makes the transition to cross-address-space IPC seamless.

The result buffer is supplied by the caller (the user-validated SQE result region). Implementations serialize directly into it and return the number of bytes written, so the kernel’s dispatch path does not allocate an intermediate Vec<u8> per invocation.

Endpoint Message Size: the Receive Buffer Is the Contract

An endpoint server posts a receive buffer with each CAP_OP_RECV. That buffer declares the largest message the server accepts on that RECV. The message is the EndpointMessageHeader, then the caller’s params, then one CapTransferResult per transferred capability, so a caller must leave room for all three.

A CALL whose message would not fit is caller-attributable and terminal: the kernel fails it with CAP_ERR_MESSAGE_TOO_LARGE and the server never observes it. The caller may retry with a smaller message; the capability stays usable and the server is unaffected. The rule holds on both delivery paths:

  • Delivered to a parked RECV. The kernel checks the fit before delivery. The over-sized CALL fails and the RECV stays parked, unaware.
  • Drained from the queue. At CALL time no RECV is posted, so the kernel has no buffer to size against and the call is queued (subject to the usual endpointQueueLimit and per-call/queue byte ceilings). The RECV that later drains it applies the check, fails the caller, drops the call, and continues to the call queued behind it.

A call drained from the queue must also fit the receiving process’s ring input scratch (ringScratchLimitBytes), because the kernel stages the params through it on the way to the receive buffer. A call delivered straight to a parked RECV is staged through the caller’s scratch instead and so never meets that bound. Both bounds are declared by the receiver, and a server that keeps its receive buffer within its own ringScratchLimitBytes – the sane configuration – makes them coincide and the difference unobservable. A server that posts a receive buffer larger than its scratch limit gets a size bound that depends on whether a RECV happened to be parked; it does not get an unsafe outcome, but it does get an inconsistent one.

The second path is why an unacceptable call is dropped rather than requeued. The queue is FIFO and the receiver’s buffer is fixed for the duration of a RECV, so a requeued over-sized call would sit at the head forever, undrainable, starving every call behind it: one hostile CALL would wedge the endpoint. Since servers in practice size their receive buffer from a constant rather than growing it per call, “restore it for a later RECV with a bigger buffer” is not a real recovery.

This is a deliberate authority choice. The alternative – reporting the size mismatch to the server as a receive-buffer error – makes message size an input the caller controls and the server must survive, which is exactly the shape of a denial-of-service primitive against every serve loop. Attributing it to the caller keeps the untrusted party’s mistake in the untrusted party’s completion.

Two related failures stay server-attributable and are retried from the queue, because they are transient and not caller-controlled: a receive buffer that overlaps the ring, and a copy fault into the receiver’s buffer. Both keep the caller’s CALL pending for a later, sound RECV.

CAP_ERR_MESSAGE_TOO_LARGE is best-effort in one respect: if the caller’s completion queue cannot take it at the moment of failure, the caller is completed through the deferred-cancellation path with the generic CAP_ERR_INVOKE_FAILED instead. The call still fails and the server is still unaffected; only the precision of the code is lost.

The server never observes an over-sized call even under concurrent load. A single CAP_OP_RECV drains every call the receiver cannot accept off the queue head under one endpoint-lock hold, then delivers the first acceptable call or parks. Because the whole head-of-queue drain is atomic, a caller refilling the queue from another CPU cannot race it: the refiller blocks on the endpoint lock until the drain finishes, and its calls land after it. The drain is therefore bounded by the queue’s current length – which the blocked refiller cannot extend – so it is neither a receiver-visible failure (no drain budget for a refiller to exhaust) nor a dispatch-side CPU denial of service, and it never leaves an unacceptable call stranded behind a parked RECV. Each drained call’s caller is failed with CAP_ERR_MESSAGE_TOO_LARGE after the lock is dropped, so the per-caller failure work stays out from under the endpoint lock.

Proof: make run-endpoint-oversized-params drives both delivery paths and a queue saturated with over-sized calls; make run-endpoint-recv-drain-refill-race drives the concurrent refill race under SMP, with one thread serving a small receive buffer while another floods the endpoint with over-sized calls from a different CPU and the server never observes a fatal completion.

Capability Table

Each process has its own capability table (CapTable), created at process startup. The kernel also maintains a global table (KERNEL_CAPS) for kernel-internal use. Each table maps a CapId (u32) to a boxed CapObject.

CapId encoding: [generation:8 | index:24]. The generation counter increments when a slot is freed, so stale CapIds (from a previous occupant of the slot) are rejected with CapError::StaleGeneration rather than accidentally referring to a different capability.

Generation wrap must not resurrect old authority. The implemented table retires a slot permanently when its 8-bit generation would wrap from 255 back to 0; that slot is not returned to the free list. Heavy churn can therefore exhaust a table even when many retired slots are empty, but the failure mode is CapError::TableFull, not stale-cap revalidation. Future widening of CapId generation bits is an ABI change and belongs in the schema/ring ABI evolution track.

Operations:

  • insert(obj) – register a new capability, returns its CapId
  • get(id) – look up a capability by ID (validates generation)
  • remove(id) – revoke a capability, bumps slot generation
  • call(id, method_id, params) – dispatch a method call against the interface assigned to the capability entry

Every current boot manifest gives only initConfig.init a kernel-built capability table. The default system.cue manifest boots the standalone init binary, which reads BootPackage, validates initConfig.services, and spawns capos-shell, the remote-session CapSet gateway, and resident demo services through ProcessSpawner. The Telnet gateway fixture is retired with the kernel socket owner. Focused shell-led manifests such as system-smoke.cue and system-shell.cue still boot capos-shell directly as initConfig.init for narrow login/shell proofs. Focused init-executor manifests such as system-spawn.cue also boot the standalone init binary with Console, BootPackage, and ProcessSpawner for isolated ProcessSpawner coverage. Child capabilities are assembled from explicit spawn grants in declaration order: raw grants preserve the source capability metadata, legacy endpoint-client grants attenuate an endpoint owner or ProcessSpawner endpoint result source to a client facet while preserving delegated receiver metadata, and child-local Endpoint, FrameAllocator, and VirtualMemory grants are minted for the child’s process. Endpoint kernel grants return parent-side client facets as result caps; init uses those facets for later service imports and releases them before waiting on children. Kernel bootstrap now builds only initConfig.init kernel-sourced caps; CapSource::Service resolution stays in init’s BootPackage executor path. CapRef.source is structured CUE inside initConfig.services, not an authority string:

{
    name:                "client"
    expectedInterfaceId: 0xacf0c15a7b2e0041
    source: service: {
        service: "endpoint-server"
        export:  "client"
    }
}

The source selector chooses the object or authority to grant. The expectedInterfaceId value is a schema compatibility check against the constructed object, not the authority selector itself. This distinction matters because different objects can implement the same interface.

Transport-Level Capability Lifetime

Cap’n Proto applications do not usually model capability lifetime as an application method on every interface. The RPC transport owns capability reference bookkeeping.

The standard Cap’n Proto RPC protocol is stateful per connection. Each side keeps four tables: questions, answers, imports, and exports. Import/export IDs are connection-local, not global object names. When an exported capability is sent over the connection, the export reference count is incremented. When the importing side drops its last local reference, the transport sends Release to decrement the remote export count. Implementations may batch these releases. If the connection is lost, in-flight questions fail, imports become broken, and exports/answers are implicitly released. Persistent capabilities, when implemented, are a separate SturdyRef mechanism and should not be treated as owned pointers.

References:

This distinction matters for capOS:

  • close() is application protocol. A File.close() method can flush dirty state, commit metadata, or tell a server that a session should end.
  • Release / cap drop is transport protocol. It removes one reference from the caller’s local capability namespace and eventually lets the serving side reclaim the object if no references remain.
  • Process exit is bulk transport cleanup. Dropping the process must release all caps in its table, cancel pending calls, and wake peers waiting on those calls.

capOS therefore needs a system transport layer in the userspace runtime (capos-rt / later language runtimes), not just raw SQE helpers. That transport should own typed client handles, local reference counts, promise-pipelined answers, and broken-cap state. When the last local handle is dropped, it should queue a transport-level release operation that is flushed through the kernel ring at an explicit runtime boundary.

Ordinary handle release is a transport concern, not an application method. The target design: the generated client drops the last local handle (RAII / GC / finalizer), the runtime transport queues CAP_OP_RELEASE, an explicit runtime flush or later ring-client boundary submits it, and the kernel removes the caller’s CapTable slot with mutable access to that table. Encoding ordinary local release as a regular method call on CapabilityManager was rejected because it would mutate the same table used to dispatch the call; CapabilityManager is therefore management-only (list(), child-scoped revoke(capId), and manager-mediated grant(sourceCapId)), not the default release path. CAP_OP_FINISH remains reserved in the same transport opcode namespace for application-level “end of work” signals that the transport must deliver reliably, so the kernel can tell them apart from a truly malformed opcode.

Current status: the kernel dispatches CAP_OP_RELEASE as a local cap-table slot removal and fails closed for stale or non-owned cap IDs. capos-rt bootstrap handles remain explicitly non-owning, while adopted owned handles queue CAP_OP_RELEASE on final drop and expose Runtime::flush_releases() for callers that need to force the queued releases. Result-cap adoption validates the kernel-supplied interface ID before producing an owned typed handle. CAP_OP_FINISH remains reserved and returns CAP_ERR_UNSUPPORTED_OPCODE. Process exit remains the fallback cleanup path for unreleased local slots.

Manager-mediated post-spawn grants

Holding a CapabilityManager is authority over one child capability table, fixed by pid plus process generation when ProcessSpawner creates the child. grant(sourceCapId) names a capability in the manager caller’s own table and copies it into that fixed child. It does not accept a target pid, an interface override, receiver metadata, or rights flags, and it never moves the source.

The copied hold keeps the source interface, badge/receiver metadata, and transfer/disclosure scopes. A grant wrapper preserves one-way source revocation: revoking the source invalidates the child hold, while the child-local epoch prevents CapabilityManager.revoke from revoking the caller’s source or sibling copies. Only a live Copy hold is admitted. The same session-transfer rule used for spawn applies, including the explicit service-regrant case for a trusted fixed-session spawner; the child’s cap-slot and resource ledgers account the insertion. The target session and pid/generation must remain live, and reply failure rolls the insertion back. Every manager grant outcome is emitted as an ordered kernel cap_grant audit record.

The result is a child-local targetCapId. It is deliberately absent from the child’s immutable bootstrap CapSet, so the manager and child must communicate that integer through an already-authorized channel. A numeric slot id from the manager’s process is not authority in the child: lookup occurs only in the calling process’s table. make test-capability-manager-grant proves both that namespace rejection and invocation through a post-spawn granted Console.

Re-delegating an endpoint facet as a result capability

Sending a capability onward is transport protocol too, and the transport decides it from the sender’s hold rather than from the interface. An endpoint RETURN may carry result caps, but each one is prepared with CapTable::prepare_copy_transfer under CapHoldOrigin::ResultCap, which admits only a CapTransferMode::Copy hold. That is what decides whether a userspace intermediary – a broker assembling a bundle for its caller – can hand back a facet of a service it does not own.

Three spawn grant modes mint an endpoint facet, and they differ in exactly two things: what source authority they admit, and what the child may then do with the facet.

Grant modeMinting sourceChild’s holdChild may pass it on over IPC
clientEndpointendpoint owner, or a ProcessSpawnerEndpointResult holdNonTransferable, source’s scopeno
delegableClientEndpointendpoint owner, or a ProcessSpawnerEndpointResult holdCopy, SameSessionwithin its own session
crossSessionDelegableClientEndpointendpoint owner, or a ProcessSpawnerEndpointResult holdCopy, source’s scopeyes, including across sessions
serviceObjectendpoint owner onlyCopy, source’s scopeyes

All four mint the same non-owner facet: it posts calls and can never RECV or RETURN, so no facet holder can impersonate the exporting service, whichever mode minted it. The ProcessSpawnerEndpointResult hold is the cap a spawner receives for a {kernel: "endpoint"} grant, which is how init holds a manifest-declared service export; it may mint client facets of that export but deliberately may not mint a serviceObject, because that installs a served object.

The “minting source” column is about minting – stamping a facet with a chosen interface id and receiver cookie. Separately, a holder with no minting authority may pass on a facet it already holds, pinned to the source’s own interface id and cookie, so long as the child hold is no wider than the source’s own. Passing on the same transfer mode is the base case: a Copy facet holder can request serviceObject or delegableClientEndpoint for an endpoint it does not own, because both hand the child the same copy-transferable client facet. A Copy facet holder may also attenuate – request clientEndpoint, handing the child a narrower NonTransferable facet the child cannot itself pass on. Only widening is reserved to a minting source: a NonTransferable facet can never turn itself into a delegable one. Monotone attenuation is the direction a capability system should always permit, so the pass-on rule admits it directly.

The authority decision is the pure resolve_endpoint_facet_delegation in capos-lib/src/cap_table.rs, host-tested across the mode/source/transfer-mode matrix. That kernel rule – owner, endpoint-result origin, or an already-delegable hold the mode passes on no wider than the source holds – is the actual gate. The manifest opt-in

{name: "control", source: {service: {service: "control-service", export: "control", delegable: true}}}

is how init chooses a mode for a manifest-declared grant, not a boundary the kernel enforces: ProcessSpawner is an IPC surface, so any holder of an unrestricted ProcessSpawner composes CapGrant messages itself and is bound by the kernel rule alone. RestrictedLauncher workers are unaffected, because validate_worker_service_grant admits clientEndpoint by allowlist and so refuses both delegable modes by construction.

Delegability and session reach are separate authorities, and the manifest asks for them separately. delegable: true mints a SameSession facet: the holder may re-delegate it, but only to a peer in its own session. Reaching another session takes a second, explicit opt-in:

{
	name: "control"
	source: {service: {
		service:        "control-service"
		export:         "control"
		delegable:      true
		delegableScope: "cross-session"
	}}
}

delegableScope without delegable: true is a manifest error rather than a silently ignored field, and the only accepted spellings are "same-session" (the default) and "cross-session".

The scope a mode asks for is a ceiling, never a widening. crossSessionDelegableClientEndpoint inherits the source hold’s scope, so it grants reach only where the source already had it: a holder passing on a facet that is itself SameSession mints a SameSession child hold under either delegable mode. In practice only the endpoint owner and the spawner’s endpoint-result facet – both CrossSessionShareable – can hand out cross-session reach at all.

This split exists because scope is inert until delegability is granted. The endpoint-result facet behind a manifest {service:} export is CrossSessionShareable, and a NonTransferable facet never consults its scope: every copy transfer fails first. Making a facet Copy is what makes its scope live, so a single delegable: true opt-in would otherwise have bought cross-session re-delegation as a side effect of asking for delegation.

The two refusals do not fail the same way. A transfer-mode refusal is caught before the call is dequeued, so the call stays in flight and the intermediary still owes its caller an answer. A scope refusal happens after the call is dequeued: the kernel cancels the call and posts CAP_ERR_TRANSFER_NOT_SUPPORTED to the caller itself (fail_endpoint_return_preflight_notify_caller in kernel/src/cap/ring.rs).

A re-delegated facet grants no more than the intermediary’s own facet did: it carries the same interface and receiver cookie, and it still cannot RECV. What it does change is reach – the set of processes holding a facet of that endpoint is no longer fixed at spawn time by the manifest, so an endpoint’s client set becomes as wide as its delegable holders choose to make it. That is the authority the delegable: true opt-in is buying, and it is why the default stays non-transferable, and why the session boundary is a separate opt-in on top.

make run-endpoint-facet-redelegation proves both halves against one facet: the broker returns its delegable: true facet to an in-session client, which receives it and reaches control through it, and the identical RETURN of that same facet to a guest-session child is refused. Only the facet granted delegableScope: "cross-session" crosses, and control reports the badge that names which facet each call arrived through. The same proof also exercises attenuation: the broker holds a delegable, cross-session Copy facet and, at spawn time, grants the guest-session child a clientEndpoint (NonTransferable) facet of it – a non-owner narrowing that the pre-attenuation rule refused. The child reaches control through that attenuated facet, and control reports its distinct badge.

Facet holders can outlive the exporting process. A facet keeps the endpoint object alive, so once the owner exits there is no receiver: calls posted afterwards stay queued rather than completing, and the recipient gains no service. Calls already queued or in flight when the owner dies are reaped and reported as CAP_ERR_SERVER_DIED. A caller that must not block on a departed service therefore needs a bounded wait; queued-forever is the current behavior for a post-exit call, not a fail-closed error, and it applies to every endpoint facet rather than to delegable ones specifically. Proven by make run-endpoint-facet-redelegation.

Queued release is not immediate revocation. A dropped runtime handle no longer provides local typed access in that runtime, but the kernel cap-table slot is removed only after the release SQE is flushed and processed, or during process exit cleanup. Security-sensitive flows that need to invalidate authority for other holders or peers must use explicit revoke/epoch semantics such as CapabilityManager.revoke, session expiry, object epochs, or service-specific close/revoke methods; they must not rely on destructor timing.

Session expiry is also not a substitute for every revocation shape. The target session lifecycle model has separate layers:

  • a mutable session liveness cell for live, logged_out, revoked, expired, and recovery_only state behind the immutable process SessionContext;
  • broker grant leases for bundle fragments and elevated or temporary caps;
  • object/facet epochs for invalidating a live target generation.

Renewal acts on the first two layers. It may extend session liveness or mint fresh grant leases, but it must not make old ordinary grants fresh merely because the session renewed. Object/facet revocation remains an independent target-side operation.

Service authors should make this distinction explicit in protocol design:

  • Use ordinary handle drop or runtime flush_releases() only to stop this process from using one local cap slot.
  • Use a service close method when the service must observe application-level shutdown, flush durable state, or publish an orderly end-of-session result.
  • Use CapabilityManager.revoke, session expiry, object epochs, or a service-specific revoke method when existing peers or delegated holders must lose authority before the service proceeds.
  • Treat destructor/finalizer timing as advisory cleanup. It is not a security boundary, and it is not proof that another process has stopped using a cap.

Minting an endpoint facet locally at runtime

The four grant modes above mint a facet only as a side effect of ProcessSpawner.spawn. An endpoint owner can also mint a client facet of its own endpoint without spawning a process, through the CAP_OP_MINT_SERVICE_FACET ring opcode. It stamps an owner-selected interface id and receiver cookie onto a fresh client facet and returns it as a result cap in the owner’s own cap-table – the serviceObject authority a spawner would have exercised, expressed as a bounded, local cap-table operation. This exists for services that mint one facet per event: the network stack, for instance, minting one accepted-socket facet per connection, previously had to create a helper process and quiesce shared endpoint traffic across the blocking spawn round trip, and the local opcode removes both.

The opcode gates on ownership directly and is strictly owner-only. It fails closed for every non-owner source – a client, result, or delegated facet, including the ProcessSpawnerEndpointResult hold that init carries for a {kernel: "endpoint"} grant. This is deliberately stricter than the four spawn-grant modes, which admit a non-owner in the pass-on case (a holder handing a Copy facet it already holds to a spawned child): a local mint into the caller’s own table has no child to pass a facet to, so honoring pass-on would let any Copy-facet holder re-mint an identical copy of a facet it holds without owning the endpoint. The opcode still computes the child hold through the shared capos-lib resolvers (resolve_endpoint_facet_delegation / resolve_service_object_facet) with the owner already established, so the minted facet’s shape cannot drift from the spawn path. It also fails closed, with no facet installed, on a stale or missing source cap, a too-small or unwritable result buffer, or an exhausted cap-table/heap, and rolls the inserted facet back on a post-insert copy failure. The minted facet is the same non-owner facet the grant modes mint: it posts calls and can never RECV or RETURN, and its transfer scope is inherited from the owner’s hold and never widened. It is not a revocation or identity-minting escape hatch: it hands out a client facet of an endpoint the caller already owns, and a non-owner still cannot mint a sibling identity.

Stale-Handle and Revoke Patterns

Not all kernel cap families use the same model for handling stale or revoked capabilities. The correct pattern depends on the semantics of the object, not on a blanket epoch test. Using the wrong model produces incorrect tests or incorrect behavior expectations.

Category A — Exception-based stale guard

The cap exposes an ensure_*_live guard or an equivalent consumed-state check that returns a stable typed exception (not a silent success) on a stale or consumed cap.

  • UserSession (kernel/src/cap/user_session.rs): info()/auditContext() fail closed with a stable exception message after logout(); second logout is idempotent. Proved by run-ssh-public-key-session.
  • SchedulingContext, CpuIsolationLease: expose an explicit revoke method returning staleGeneration. Subsequent info, bind_caller_thread, activation_preflight, create, and drain_notifications calls fail closed on the staled cap. Proved by run-scheduling-context (demos/scheduling-context-smoke/src/main.rs:285-313, 1129-1141) and run-scheduler-cpu-isolation-lease (demos/cpu-isolation-lease-smoke/src/main.rs:201-237).
  • ThreadHandle (kernel/src/cap/thread_handle.rs): join (sched.rs:1038-1057) returns AlreadyJoined on the second call (hard fail, not silent success) and returns TargetNotLive (sched.rs:1371,1377,1385) if the thread record is absent post-cleanup. exitCode (sched.rs:1418-1420) is a non-consuming idempotent read. The join_or_register consumed-state check is the stale guard; the joined flag is the epoch. Proved by run-thread-lifecycle (demos/thread-lifecycle/src/main.rs:293-298).

Per-cap epoch tests are applicable only to Category A caps.

Category B — Idempotent-stale-target

The cap returns silent success (or a latched result) on a stale target. No ensure_*_live guard is present by design.

  • ProcessHandle (kernel/src/cap/process_spawner.rs): terminate on an already-exited process returns Complete(0); wait re-reads the latched exit code. Writing fail-closed tests for Category B caps would test the opposite of intended behavior.

Category C — Soft-EOF / zero-write

The cap uses v0 ExceptionType policy: closing one side causes the other to drain and receive EOF; writes return zero bytes rather than an error.

  • Pipe (kernel/src/cap/pipe.rs): close causes read to drain + EOF, write returns zero bytes (schema lines 2429-2433). No epoch test needed.

Category D — No revoke verb (kernel singletons)

These caps expose no revoke or close method in the schema. The backing object lives for the process lifetime.

CredentialStore, AuthorizedKeyStore, SshHostKey, EntropySource, SystemInfo, AuditLog, HardwareAuditLog, SessionManager, AuthorityBroker, RestrictedLauncher, BootPackage. Nothing to test for stale-handle behavior.

Category E — DDF caps with release/scrub semantics

These caps use internal handle epoch validation. The full stale-handle behavior for each requires targeted per-cap investigation when a behavior gap is identified.

DmaBuffer, DeviceMmio, Interrupt.

Open residuals

  • UserSession expiry path (Category A): the expiresAtMs/anonymousMs- driven expiry path is not yet covered by a focused smoke. run-ssh-public-key-session covers the explicit logout() close-side path. Note that run-session-context is flaking on TCG-only hosts — a stability fix is needed before that smoke can be strengthened.

Access Control: Interfaces, Not Rights Bitmasks

capOS deliberately does not use a rights bitmask (READ/WRITE/EXECUTE) on capability entries, despite this being standard in Zircon and seL4. The reason is that Cap’n Proto typed interfaces already serve as the access control mechanism, and a parallel rights system creates an impedance mismatch.

Why rights bitmasks exist in other systems: Zircon and seL4 use rights because their syscall interfaces are untyped – a handle is an opaque reference to a kernel object, and the kernel needs something to decide which fixed syscalls are allowed. capOS has typed interfaces where the .capnp schema defines exactly what methods exist.

capOS’s approach: the interface IS the permission. To restrict what a caller can do, grant a narrower capability:

  • Fetch (full HTTP) → HttpEndpoint (scoped to one origin)
  • Store (read-write) → Store wrapper that rejects write methods
  • Namespace (full) → Namespace scoped to a prefix

The “restricted” capability is a different CapObject implementation that wraps the original. The kernel doesn’t know or care – it dispatches to whatever CapObject is in the slot. Attenuation is userspace/schema logic, not a kernel mechanism.

Session transfer scope: capability holds now carry reference-level transfer scope. same_session caps cannot move into another process session through raw IPC, endpoint return, or spawn grants. cross_session_shareable caps may cross and then invoke under the receiver process session. service_regrant_only caps require a trusted fixed-session broker/launcher path. These meta-rights are about the reference, not the referenced object, and do not overlap with interface-level method access control.

Non-writable filesystem caps are forwardable to a same-session child; writable caps are not. Directory/File caps are minted Copy/same_session at the read-only and RAM mint sites, so a holder can forward an opened directory or file to a ProcessSpawner.spawn child within the same session – the kernel handoff that backs POSIX fd inheritance across fork/execve. The security argument is the same for all of them: the child gains no authority the parent does not already hold, same_session keeps the cap from escaping the session, and the spawn-grant epoch wrapper keeps a forwarded child cap from outliving a revoked parent. Two flavours exist:

  • Read-only views – the read-only filesystem (readonly_fs) and the packaged-image source (installable_image), plus their read_only_fs_root/ installable_image_source bootstrap roots. Their interfaces fail closed on every mutation, so forwarding shares a pure read view. Here the interface is the permission makes the share unambiguously benign.
  • The holder’s own RAM scratch namespace – the directory::transfer_result_cap results and the kernel:directory/kernel:file bootstrap sources (via boot_cap_hold). This Directory/File interface includes mutation methods, so the forwarded cap is shared read/write with the child, not a read view. It is still safe to forward because it is the parent’s own scratch tree shared within one session, not a privilege the parent lacked.

The disk-backed writable filesystem (writable_fs) and QEMU’s writable virtio-9p root are distinct CapObject types minted NonTransferable: a writable cap carries a single-writer claim, so forwarding it would let two processes hold that claim. A writable virtio-9p Directory.open result inherits that restriction; its File hold is also NonTransferable, while the same operation on the read-only root returns a Copy / same_session File. The ProcessSpawner Raw/Move grant modes and endpoint copy transfer both reject a NonTransferable source, so the single-writer policy is preserved by mint-time metadata rather than a separate check. Proven by make run-spawn-grant-directory and make test-virtio-9p-write.

A single-writer cap can still be minted for a child, just not forwarded to one. The two are different operations and only forwarding is what the NonTransferable hold prevents. The writable virtio-9p share (virtio_9p_root_writable) is granted to children exactly this way: the parent names the kernel source in a raw ProcessSpawner grant and the kernel mints a fresh writable root directly into the child’s table. The parent need not – and in the proof manifest does not – hold a writable share itself, so nothing is being shared; the child simply becomes the holder. The resulting hold is still NonTransferable, so the chain stops there, and the child cannot pass the root or a writable File result to a grandchild. The QEMU proof exercises the writable-file endpoint-copy and raw-spawn refusals with the actual result cap; its read-only control copies a read-only 9p File to a helper that reads the host fixture through it.

That shifts where the single-writer claim has to be enforced. Forwarding is guarded by the transfer mode, but minting is not, and a mint can happen at two independent sites: bootstrap resolution and a spawn grant. Manifest validation covers only the first, since spawn grants are runtime IPC no static check can see. So the writable 9p source additionally latches at the mint function itself (virtio_9p_fs::mount_root_writable), refusing every committed mint after the first regardless of site. For a spawn, the uniqueness claim is a prepare/commit reservation: exact-interface validation happens before reservation, every later failure rolls it back, and only the committed child makes the one-per-boot latch permanent. The general rule this illustrates: when authority is created rather than passed, transfer-mode attenuation is not sufficient, and the uniqueness claim belongs at the mint point. Proven by make test-virtio-9p-write-spawn.

TerminalSession is forwardable to a same-session child, parent-retained. The bootstrap TerminalSession cap is minted Copy/same_session (matching Console) in boot_cap_hold, so a holder can forward its terminal-backed stdout/stderr to a ProcessSpawner.spawn child without losing its own terminal. TerminalSessionCap is a stateless unit struct: write/writeLine dispatch onto the shared kernel terminal and readLine resolves the caller’s session context per call (requires_live_caller_session stays true), so there is no per-session ownership state to strip on a forward. The child gains no terminal authority the parent did not already hold, and same_session keeps the cap from escaping the session. This is the non-destructive capability-model realization of POSIX “all children share the controlling tty”; the prior Move/service_regrant_only mint was a policy default, not a state-ownership requirement, and a destructive Move would have stripped a shell of its terminal on its first child spawn under full fd inheritance. Two writers reaching the same terminal serialize at the shared kernel UART; sub-line interleaving between a parent and a child writing concurrently is an accepted research-surface limitation, not an authority leak. Proven by make run-posix-terminal-forward.

See research survey for the cross-system analysis that led to this decision (§1 Capability Table Design).

Remote Clients: Capability Forwarding vs the Data Bridge

Remote access to a capOS instance uses one of two deliberately different patterns, with different trust profiles.

Capability forwarding (the remote CapSet path spoken by tools/remote-session-client and the Python SDK): the remote peer speaks the capability protocol itself and interacts with forwarded capability surface. Compromising such a client compromises whatever authority the session actually forwards — which is why this path is bounded by session login, broker-issued profiles, and the narrow CapSet the session exposes.

The data bridge (the loopback web bridge remote-session-ui behind the browser UI and the JavaScript SDK): the client never receives anything it can invoke. This is what the phrase “all authority stays server-side: the client only exchanges DTOs and never holds an invokable capability” means concretely:

  • Live capability references and the kernel session exist only inside the bridge process. What crosses HTTP are data transfer objects — JSON snapshots of state, capability listings as names and types, MOTD text. Data cannot be invoked.
  • The client has no “call method M on capability X” operation. The bridge exposes a small fixed set of routes, each hard-mapped to one pre-chosen invocation that the bridge performs itself, with its own capabilities, after its own checks (session cookie, Origin allowlist, CSRF double-submit).
  • Consequence: compromising the browser client (XSS, a hostile page, a stolen session) yields exactly “can call those fixed routes inside an authenticated session” — a bounded, enumerable surface that can be audited line by line — rather than a held capability graph to wander.

The property is structural, so it is falsifiable. It would be violated by any of: a generic invoke route (/api/invoke?cap=...&method=...), returning a capability handle or bearer token the client can replay for arbitrary calls, or a client-extensible route list. A bearer token that grants arbitrary invocation is a capability, only with worse revocation properties. Review of bridge changes should check for exactly these shapes; the phrase carries no weight unless the route inventory next to it stays closed.

See Remote Session CapSet Client for the client protocol design and the web bridge’s DTO inventory.

Planned Enhancements (from research)

Tracked in Roadmap Stages 5-6:

  • Legacy badge / receiver selector – the current storage field is a u64 per capability hold edge, delivered to endpoint servers on invocation. Existing code still calls it a badge because it began as seL4-style client identity metadata. The active model keeps that field out of service identity: new service capability should use one immutable process session, broker-granted service roots/facets, privacy-preserving endpoint caller-session metadata, and explicit subject disclosure plus a matching disclosure scope when a service needs more than an opaque service-scoped session reference.
  • Epoch (from EROS) – per-object revocation epoch. Incrementing the epoch invalidates all outstanding references. O(1) revoke, O(1) check.

Current Limitations

  • Process-ring blocking remains process-level; private ParkSpace waits are per-thread. cap_enter(min_complete, timeout_ns) processes pending SQEs and can block one admitted thread per process until enough CQEs exist or a finite timeout expires. That ring wait is still process-owned and does not make the capability ring itself a per-thread completion queue. Separately, the implemented private ParkSpace path provides process-local per-thread wait/wake on userspace words through compact CAP_OP_PARK/CAP_OP_UNPARK operations. SharedParkSpace park-words and runtime safe park clients remain future work.
  • No persistence. Capabilities exist only at runtime.
  • Capability transfer is implemented for Endpoint CALL/RECV/RETURN. Transfer descriptors on the capability ring let callers and receivers copy or move transferable local caps through IPC messages. Delivery also enforces the cap hold’s session transfer scope; an unsupported cross-session transfer fails with CAP_ERR_TRANSFER_NOT_SUPPORTED and is reported to the caller instead of being requeued to the endpoint. See Storage and Naming “IPC and Capability Transfer” for the full design.
  • Transfer ABI (3.6.0 draft). Sideband transfer descriptors are defined in capos-config/src/ring.rs as CapTransferDescriptor:
    • cap_id is the sender-side local capability-table handle.
    • transfer_mode is either CAP_TRANSFER_MODE_COPY or CAP_TRANSFER_MODE_MOVE.
    • xfer_cap_count in CapSqe is the descriptor count.
    • For CALL/RETURN, descriptors are packed at addr + len after the payload bytes and must be aligned to CAP_TRANSFER_DESCRIPTOR_ALIGNMENT.
    • Result-cap insertion semantics are defined by CapCqe: result reports normal payload bytes, while cap_count reports how many CapTransferResult { cap_id, interface_id } records were appended immediately after those payload bytes in result_addr when CAP_CQE_TRANSFER_RESULT_CAPS is set. User space must bound-check result + cap_count * CAP_TRANSFER_RESULT_SIZE against its requested result_len.
    • Promise pipelining targets that sideband result-cap namespace: pipeline_dep names a process-local promised answer, and pipeline_field is a zero-based CapTransferResult record index in that answer’s completion. It is not a Cap’n Proto schema field number; the kernel must not traverse opaque result payload bytes to find a capability. Kernel-served answers are drain-scoped; endpoint answers are bound to the caller’s thread generation, a kernel epoch, and the originating batch’s frozen SQ tail until RETURN or fail-closed teardown resolves them.
    • Transfer-bearing SQEs are fail-closed:
      • unsupported transfer scope or object class: CAP_ERR_TRANSFER_NOT_SUPPORTED,
      • malformed descriptor metadata (invalid mode, reserved bits, non-zero _reserved0, misalignment, overflow): CAP_ERR_INVALID_TRANSFER_DESCRIPTOR,
      • all other reserved-field misuse remains CAP_ERR_INVALID_REQUEST.
  • Revocation propagates through object epochs. CapabilityManager.revoke invalidates child-local grant copies for the revoked object, and the ring maps revoked ordinary and endpoint use to typed Disconnected exceptions where a result buffer exists. Broader supervision/restart policy remains future work.
  • MemoryObject is the mapped bulk-data substrate. FrameAllocator returns owned MemoryObject result caps instead of raw physical addresses. The object exposes metadata plus caller-local map/unmap/protect operations for page-aligned ranges. File I/O, networking, GPU data planes, and zero-copy IPC still need service-level SharedBuffer operations built on this substrate. See Storage and Naming “Shared Memory for Bulk Data” for the broader interface design.

Future Directions

  • Broader capability-bearing services. Endpoint CALL/RECV/RETURN already carry copy/move sideband transfer descriptors and install result caps in the receiver’s local table. Remaining work is to use that transport in higher service layers: capability-bearing naming and persistence services, Directory/File and Namespace-style object models, deeper promise chains over result-cap indexes, and policy for durable references. See Storage and Naming.
  • Persistence. Persistent object references should be restored through a capability-bearing naming or persistence service that can authorize the request and mint a fresh live object. Do not serialize local cap-table handles, endpoint generations, receiver selectors, or server cookies as durable authority.
  • Network transparency. Remote capability transport should use connection-local export/import tables and explicit disconnect semantics. A remote Console capability can expose the same typed interface as a local one, but the portable authority is the live object reference, not a global URL or serialized local routing selector.