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

Proposal: Userspace Authority Broker and Init-Owned Shutdown

Problem

The current shell authentication path uses a kernel AuthorityBroker capability. The shell starts with anonymous authority, calls the broker for an anonymous bundle, then calls it again after password login for an operator bundle. That works, but it places session policy, launcher policy, and shell bundle construction inside the kernel.

That is the wrong long-term boundary. The kernel should provide primitive mechanisms: process creation, capability transfer, endpoint rendezvous, memory, terminal I/O, and process lifecycle. Login policy, operator profiles, service allowlists, and shell bundles are userspace policy and should be owned by init or an init-managed service.

Shutdown exposes the same issue. A shutdown command should not be a raw kernel poweroff capability passed to the shell. The natural capOS behavior is that the kernel halts when init and all remaining processes are gone. Shutdown policy should therefore be implemented as init-owned lifecycle orchestration: stop services, wait for them, release authorities, and then let init exit.

Current State

Implemented pieces today:

  • The kernel starts one init process from the boot manifest.
  • Init reads BootPackage, validates the init-owned service graph, spawns services, records exported capabilities, and waits for children.
  • The shell receives a terminal and anonymous authority, then upgrades after password login.
  • AuthorityBroker is a kernel capability implemented in kernel/src/cap/authority_broker.rs.
  • Demo launcher policy that used to live as kernel-side binary and worker allowlist constants is now carried by kernelParams.authorityBrokerPolicy in the boot manifest. capos-config validates that referenced binaries exist, duplicate entries are rejected, worker service grant names are explicit, and unknown worker service origins fail closed.
  • ProcessHandle supports wait, but not termination.
  • There is no init-owned lifecycle control capability yet.

The consequence is a mixed trust boundary: init owns service graph execution, but the kernel still owns shell session bundle policy.

Goals

  • Move authority-broker policy out of the kernel.
  • Make init, or an init-managed broker service, responsible for authenticated shell bundles.
  • Keep shell unauthenticated authority minimal.
  • Make shutdown an init-owned control operation, not a direct kernel shutdown cap.
  • Preserve the kernel rule that the system halts naturally when the last process exits.
  • Keep all authority transfer explicit and inspectable through capabilities.

Non-Goals

  • Do not add ambient service names or a global service registry.
  • Do not give shell raw ProcessSpawner before authentication.
  • Do not add a kernel “kill everything” syscall.
  • Do not introduce restart policy, persistence, or crash recovery in this proposal.
  • Do not solve multi-user policy; this proposal only moves the current local operator/anonymous policy out of the kernel.

Proposed Architecture

Init starts two policy-facing services:

  • authority-broker: userspace service that owns shell bundle policy.
  • shell: interactive shell, initially anonymous.

Init also keeps a private lifecycle table for services it spawned. That table contains process handles, service names, restart policy state, and shutdown ordering metadata. Init does not expose the raw table. It exposes attenuated control capabilities.

Capability Graph

flowchart TD
    Kernel[Kernel primitives] --> Init[init]
    Kernel --> Terminal[TerminalSession]
    Kernel --> Spawner[ProcessSpawner]
    Kernel --> Sessions[SessionManager]
    Kernel --> Audit[AuditLog]
    Kernel --> Creds[CredentialStore]

    Init --> Broker[authority-broker service]
    Init --> Shell[shell]
    Init --> Services[managed services]

    Broker --> ShellAnon[anonymous shell bundle]
    Broker --> ShellOp[operator shell bundle after login]
    Init --> Shutdown[init-owned ShutdownControl]
    Broker --> ShellOp
    Shutdown --> ShellOp

The shell talks to the broker over an endpoint. Before login, the broker returns an anonymous bundle with no service-management authority. After login, the broker returns an operator bundle that includes a restricted launcher and, if policy allows, an init-owned shutdown control capability.

Interfaces

The exact schema can evolve, but the minimum shape should separate broker policy from init lifecycle control.

interface AuthorityBroker {
    shellBundle @0 (sessionCapId :UInt32, profile :Text)
        -> (launcherIndex :UInt16,
            sessionIndex :UInt16,
            hasShutdownControl :Bool,
            shutdownControlIndex :UInt16);
}

interface ShutdownControl {
    shutdown @0 () -> ();
}

interface ProcessHandle {
    wait @0 () -> (exitCode :Int64);
    terminate @1 (reason :Text) -> ();
}

AuthorityBroker can be implemented as a userspace service using endpoint IPC instead of a kernel cap. ShutdownControl is produced by init, not by the kernel. ProcessHandle.terminate is a primitive lifecycle operation, but the kernel only targets one process handle; init owns the policy that decides which handles to terminate and in what order.

Shutdown Flow

  1. Shell starts anonymous and does not hold ShutdownControl.
  2. User runs login.
  3. Shell obtains an operator bundle from the userspace broker.
  4. If policy allows, the bundle includes ShutdownControl.
  5. User runs shutdown.
  6. Shell invokes ShutdownControl.shutdown.
  7. Init stops accepting new service operations.
  8. Init asks managed services to terminate in dependency order.
  9. Init waits for all service handles to exit.
  10. Init releases remaining capabilities and exits.
  11. The kernel observes no remaining runnable user processes and halts through the existing last-process-exited path.

This keeps final machine shutdown in the kernel, but keeps shutdown authority and orchestration in userspace.

Broker Migration Plan

Phase 1: Define Userspace Interfaces – landed

  • Schema for the endpoint-served UserspaceAuthorityBroker and ShutdownControl. Distinct from the kernel AuthorityBroker, which stays temporarily for compatibility.
  • Keep the manifest-owned authorityBrokerPolicy shim as the compatibility source for admitted demo binaries until the userspace broker owns equivalent policy directly.
  • Runtime clients for both interfaces.
  • QEMU proof make test-userspace-authority-broker: a process holding only an anonymous-badged broker facet is served a bundle with no shutdown authority, is refused when it asks for operator anyway, and is refused again through an unbadged facet; an operator-badged process is transferred ShutdownControl and invokes shutdown; every denial is audited.

What Phase 1 does not yet do: the interactive shell is not a client of this broker. The proof binds profiles to purpose-built demo processes through the boot manifest, which establishes the broker’s authority model and its denial rule, not an end-to-end operator-facing path. Wiring the real shell’s bundle to this broker is Phase 2/3 work, and only that will make the “an anonymous shell cannot call shutdown” claim true of the shipped shell rather than of the proof’s anonymous-badged stand-in.

How Phase 1 authorizes: the facet’s badge, not the caller’s claim

The kernel broker binds a requested profile to authority the caller must hold: validate_shell_profile_request requires a UserSession capability, reads the real profile from it, and refuses a request naming any other profile. The userspace broker must bind the same way, and the two mechanisms it might reach for first are both unavailable:

  • sessionCapId :UInt32 is unresolvable in userspace. Cap ids index the caller’s private cap table, and only the kernel’s call_with_table dispatch seam reads them – which is why kernel/src/cap/authority_broker.rs can accept one and an endpoint server cannot. A caller-supplied id would be an unverifiable claim, so the interface does not carry one.
  • EndpointCallerSession does not distinguish these callers. A process’s session context is fixed at spawn, so a shell that later logged in still presents its spawn-time profile, and every service a manifest spawns shares init’s system session.

What remains is unforgeable and sufficient: the badge init stamps on the facet of the broker endpoint that each process is granted. The kernel reports it to the server on every call, a holder can neither set nor alter it, and a copy transfer preserves it. So profile on the wire is a claim, the badge is the authority, and the broker refuses any request whose claim does not match its facet (UserspaceBrokerError.profileMismatch). An unbadged facet names no profile and is refused outright, so a manifest that forgets a badge fails closed rather than granting.

The grant graph in system-userspace-authority-broker.cue is therefore the whole policy: a process holding an anonymous-badged facet may ask for operator – the proof has it do exactly that – and is refused, because a caller cannot change which facet its call arrives through.

Both grants and denials are recorded in the system audit log (event=broker, result=ok / result=denied), matching the kernel broker they migrate from. Each record carries two identities, because the interesting records are the ones where they disagree: profile= is the caller’s claim, and principal= is the profile its facet actually authorizes. An escalation attempt is therefore legible in the log as principal=anonymous profile=operator rather than being indistinguishable from an ordinary refusal.

Two rules a userspace authority service inherits from Phase 1

Phase 2 and 3 add more of these services, so the two invariants Phase 1 got wrong first are worth stating rather than rediscovering:

  • Authority comes from what the kernel reports, never from what the caller says. A profile, a session id, or any other self-description in the request is a claim to be checked against something the caller cannot alter – here the facet’s badge, in the kernel broker a presented UserSession. A proof of a denial must therefore have the untrusted process attempt the escalation, not merely decline to; a client that politely asks only for what it should get proves nothing about what it would be given.
  • A clamp on untrusted text must be sized to the narrowest sink that text can reach, not the sink the author has in mind. These services treat a failed console write and a failed audit record as fatal, and those two sinks do not share a bound (1024 vs 32 bytes). Enumerate every sink an untrusted value reaches, take the minimum, and pin that inequality in a host test – otherwise the clamp that exists to stop a denial-of-service becomes the vector for one.

Returning that cap requires a delegable facet. An endpoint facet from a manifest {service: ...} grant is CapTransferMode::NonTransferable (kernel/src/cap/process_spawner.rs, SpawnGrantMode::ClientEndpoint), while CapTable::prepare_copy_transfer requires CapTransferMode::Copy – so a plain service facet cannot be copy-transferred out of an endpoint return at all. The delegableClientEndpoint grant mode (CapGrantMode::delegableClientEndpoint, selected by init from delegable: true on a manifest service cap source) mints the Copy facet this phase needs. The broker’s shutdown cap in system-userspace-authority-broker.cue is granted that way; dropping the flag makes the operator bundle fail, which is what the proof’s mutation check exercises.

Failure mode worth knowing when this is misconfigured: the rejection is silent. The return’s transfer preparation fails with a bare ? that never notifies the caller, and every in-tree serve loop discards the return’s completion, so the caller blocks forever rather than seeing an error.

Phase 2: Init-Owned Shutdown

  • Extend init with a lifecycle table for spawned services.
  • Add a private init service endpoint for shutdown requests.
  • Add ProcessHandle.terminate or equivalent single-process lifecycle primitive.
  • Make init terminate and wait for managed services before exiting.
  • Add QEMU proof that shutdown after login exits QEMU cleanly.

Phase 3: Userspace Authority Broker

  • Implement authority-broker as a userspace service.
  • Grant it only the policy inputs and capabilities needed to mint shell bundles.
  • Have shell obtain anonymous and operator bundles from that service.
  • Keep shell without raw ProcessSpawner; it should receive only restricted launch authority.
  • Add QEMU proof that pre-login shell cannot spawn privileged services and post-login shell can run the expected demo commands.

Phase 4: Retire Kernel Broker

  • Remove kernel/src/cap/authority_broker.rs.
  • Remove KernelCapSource::AuthorityBroker.
  • Remove kernel-side broker bundle construction and tests.
  • Update docs so the kernel boundary is again primitive-only.

Security Properties

  • Shell starts without shutdown authority.
  • Shutdown authority is granted only after an authenticated session is proven.
  • The broker cannot invent kernel powers; it can only delegate capabilities it received from init.
  • Init remains the root of service lifecycle policy.
  • Kernel process termination remains per-handle, not global.
  • Service shutdown is auditable because it flows through init and named process handles.

Open Questions

  • Should ShutdownControl.shutdown be one-way, or should it return staged progress events before init exits?
  • Should services receive a graceful StdIO close, a typed lifecycle signal, or only ProcessHandle.terminate?
  • Should the broker be a separate process, or should init directly expose the broker endpoint until service supervision is stronger?
  • How should restart policies interact with shutdown mode?
  • Should shutdown require a fresh authentication event, or is the current operator session sufficient?

Verification

Required QEMU proofs:

  • Anonymous shell: shutdown is denied or unavailable.
  • Operator shell: login returns shutdown authority.
  • Shutdown command causes init to terminate managed services and exit.
  • QEMU exits through the existing last-process halt path.
  • Existing adventure/chat demo still works before shutdown.

Host tests should cover:

  • Broker policy decisions for anonymous vs operator profiles.
  • Init shutdown ordering over a synthetic lifecycle table.
  • Manifest validation rejecting direct shell access to privileged lifecycle primitives before login.