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.
AuthorityBrokeris a kernel capability implemented inkernel/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.authorityBrokerPolicyin the boot manifest.capos-configvalidates that referenced binaries exist, duplicate entries are rejected, worker service grant names are explicit, and unknown worker service origins fail closed. ProcessHandlesupportswait, 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
ProcessSpawnerbefore 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
- Shell starts anonymous and does not hold
ShutdownControl. - User runs
login. - Shell obtains an operator bundle from the userspace broker.
- If policy allows, the bundle includes
ShutdownControl. - User runs
shutdown. - Shell invokes
ShutdownControl.shutdown. - Init stops accepting new service operations.
- Init asks managed services to terminate in dependency order.
- Init waits for all service handles to exit.
- Init releases remaining capabilities and exits.
- 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
UserspaceAuthorityBrokerandShutdownControl. Distinct from the kernelAuthorityBroker, which stays temporarily for compatibility. - Keep the manifest-owned
authorityBrokerPolicyshim 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 foroperatoranyway, and is refused again through an unbadged facet; an operator-badged process is transferredShutdownControland 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 :UInt32is unresolvable in userspace. Cap ids index the caller’s private cap table, and only the kernel’scall_with_tabledispatch seam reads them – which is whykernel/src/cap/authority_broker.rscan accept one and an endpoint server cannot. A caller-supplied id would be an unverifiable claim, so the interface does not carry one.EndpointCallerSessiondoes 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’ssystemsession.
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.terminateor equivalent single-process lifecycle primitive. - Make init terminate and wait for managed services before exiting.
- Add QEMU proof that
shutdownafter login exits QEMU cleanly.
Phase 3: Userspace Authority Broker
- Implement
authority-brokeras 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.shutdownbe one-way, or should it return staged progress events before init exits? - Should services receive a graceful
StdIOclose, a typed lifecycle signal, or onlyProcessHandle.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:
shutdownis 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.