PiROS architecture
Principles
- Everything hardware is virtualized to the lowest common denominator. The
kernel never touches a device directly — it calls a HAL API. Replace the backend behind that API with any other implementation of the same API and the kernel is unaffected. Today the console is a 16550 UART; tomorrow it could be VGA text, a virtio console, or a hosted stub — the kernel does not change.
- A program runs only if it can prove it is unaltered. Identity is bound to
bytes by a signature. The proof is checked at load time (and, later, at every privileged operation).
- Two disjoint powers, never held together. The authority that can change a
program can never run one; the runtime that can run a program can never change one. This is enforced by asymmetric cryptography, not by convention.
- No shared libraries. Programs are statically linked, self-contained images.
Nothing can be swapped underneath a verified binary.
The certificate model: A + B = C
The original intuition — "two facts combine into a checkable third" — is realized with a one-way combiner so the third cannot be forged from the first two:
| Symbol | Meaning |
|---|---|
| A | the program's manifest (name, version, image hash) |
| B | the program's image, hashed and compared to the manifest |
| C | an ed25519 signature over A, verifiable with the PUBLIC key |
To run a program the loader requires both:
- Authenticity — C verifies against the authority's public key ⇒ A is genuine.
- Integrity —
SHA-256(image) == manifest.code_hash⇒ B is exactly what was signed.
Either check failing is a denial. (Verified live: flipping one byte of the image yields image does not match signed hash and the program is refused.)
Why this maps cleanly onto the two key-sets
The design called for two key-sets: one that can change a program but never run it (D + E = F), and one that can run but never change it (A + B = C). Asymmetric keys give this for free, because you can verify a signature without being able to produce one:
| Actor | Holds | Can | Cannot |
|---|---|---|---|
tools/manifest (authority) | PRIVATE key | mint certs = change | (never needs to run) |
kernel loader | PUBLIC key | verify certs = run | forge a cert = change |
The kernel physically cannot alter a program and have it still verify, because it does not possess the signing key. That is the whole guarantee.
Authorization: grants, services, and the "contract with the user" (implemented)
Program integrity answers "is this the real echo?". Authorization answers "may this user run it?" — the same A + B = C shape, same trust root:
- A = a signed grant
(user, capability, kind)— the user's licence. - B = the program/service identity being invoked.
- C = the authority's signature binding them, verified with the public key.
A capability has a kind, and the invocation form must match it:
| Invocation | Requires |
|---|---|
grep … | a direct tool grant for grep |
text_tools grep … | a cluster grant for text_tools, and grep ∈ text_tools |
A service is a namespace, not a macro. Holding text_tools does not let you type grep bare; you either speak through the service (text_tools grep) or hold a separate direct grant. So a privileged tool can be reachable only through an audited service entrypoint and explicitly denied when called bare. Everything is deny-by-default. (Demonstrated live: iambad bare is denied; iamgood iambad runs the same binary via the cluster grant.)
Ownership vs. outputs: three orthogonal axes
The /data/$user write rules keep three things separate so "who owns the process" and "who owns its outputs" don't collide:
- Territory — whose subtree. Bob owns all of
/data/Bob. - Write-role — how a write was initiated: user-directed (Bob named the
path with a > redirect) lands in files/; program-autonomous writes land in logs//tmp/. A user-directed redirect to logs/ or tmp/ is refused.
- Execute-authority — the grant system above.
The payoff: because only programs (never the user) may author logs/, a log is provenance-preserving — attributable to the program and unforgeable by the user, who may read but not write it. Bob owns the territory; the program owns the authorship of its artifacts.
Program model: microkernel + signed /bin ramdisk (implemented)
Tools are no longer kernel code. Each is a separately-compiled freestanding binary; the kernel keeps only hardware/IO, auth/verify, the loader, and shell builtins (help, whoami, quit).
- Delivery — ramdisk. The bootloader hands the kernel a signed archive of programs
(/bin). The kernel reads and executes it but never writes it; only the host toolchain⊕manifest pipeline produces it. Consequence: the kernel binary is byte-identical when a tool changes — OS and tool development decouple. (Verified: editing a tool and rebuilding leaves the kernel .elf hash unchanged.)
- ABI. A shared
pros-abicrate defines the entry signature (StartInfo) and the
SysTable of extern "C" calls a tool may make. Kernel and every tool compile against it, so the two sides cannot drift. A tool reaches the system only through this table — the whole surface a program has.
- Loading.
x86_64-unknown-nonetools are linked at a fixed base; the kernel maps
one RWX window (a minimal frame allocator + page mapper over the boot memory map), copies each program's ELF segments in, verifies the ELF against its signed manifest (A + B = C now covers real executable code), and calls the entry point. Tamper the ELF in the ramdisk and the kernel refuses it. (Verified.)
- Isolated (ring 3). A loaded tool runs unprivileged. Its only path to the
kernel is a real int 0x80 trap through a single syscall gate; every user pointer it passes is range-checked. A tool that executes a privileged instruction or touches memory it doesn't own takes a CPU fault, which the kernel turns into killing that process and returning to the shell — not a crash. (Verified: a tool running cli is killed with protection violation; the shell survives.) The syscall gate is where per-operation manifest checks will eventually live.
The toolchain⊕manifest relationship generalizes: the compiler produces bytes, the manifest holds the write key and is the sole admitter of programs into /bin. The day a Rust compiler runs inside PiROS, the same rule holds — rustc emits, manifest signs and places.
Identity, ownership, and the vault (implemented)
- Users are authority-signed. Each user is a signed record (name + password
hash). Login checks the presented password against the hash — plaintext is never stored, and only the authority can mint a user (same key that signs programs/grants).
- Ownership is structural. Each user has an isolated
/data/$userstore; syscalls
act only on the current user's store, and paths are always relative to it. There is no path Mary's process can name that reaches Bob's files — cross-user access will require the (future) resource-grant mechanism, not a raw path.
- Grants are per-user. The same grant/service model, now keyed by the logged-in
user: root and Bob hold iamgood; Mary does not, so iamgood iambad is denied for her and permitted for them.
- Data at rest is encrypted. Every file is sealed with ChaCha20 + HMAC-SHA256
(encrypt-then-MAC) under a per-user key before it is stored, and opened only for the owning user; a wrong key fails the MAC. What sits in the store is ciphertext. (Vendored, like SHA-256 — the RustCrypto poly1305 backend fails to lower on the bare target, so ChaCha20 + HMAC-SHA256 gives the same AEAD property, dependency-free.) (The HMAC now streams the whole message rather than truncating at 1024 bytes — the old truncation was a forgery hole, since both vault seals and SSH packet auth verify with it.)
- Cached file descriptors. A RAM cache slab (
cache.rs,memory::CACHE_SIZE= 32 MiB)
holds a per-open-file plaintext descriptor exposed to ring-3 as file descriptors. Six syscalls — SYS_FOPEN/FREAD/FWRITE/FFLUSH/FCLOSE/FSIZE (30–35) — let a program open a file in its own store into the cache, read and write it by offset (random access), then flush/close it back through the vault-sealed vfs. This turns the vfs's whole-file, path-in / path-out API into random-access, cached, write-back descriptors; the same slab also stages OS upgrades.
Multitasking, processes, and services (implemented)
The kernel began single-process (one program window, run-to-completion). It now has a real process model, built from three composable primitives:
- Per-process address space.
new_address_space()builds a page table per process that
shares every kernel L4 entry but gives the user slot (the window/stack/heap, all in L4[0]) its own private frames. A CR3 write switches address spaces. Isolation is hardware-enforced: a process's page table simply doesn't map any other process's memory.
- Context switch.
switch(from, to)saves the caller'srsponto its own kernel stack
and loads another's — a paused task's whole state is its registers + stack, so resuming is just bookkeeping. init_stack pre-loads a fresh stack so its first switch-in runs an entry.
- Cooperative scheduler. A task table +
spawn+yield_now(round-robin). Tasks yield
when they'd block on I/O (accept/recv/getc), so servers give up the CPU exactly when idle — no timer preemption yet.
spawn_program composes all three: a private address space, the ELF loaded into it, a kernel stack whose trampoline sets TSS.RSP0 + CR3 and drops to ring 3. The scheduler restores each process's syscall identity on switch, so a service's file ops hit its own /data.
Services are root-managed background daemons. A service is a machine-signed no-login user (all-zero password hash) whose only grant is to run its own binary; admin service start <name> spawns it as a sandboxed ring-3 process running as that user. sshd is the exception — a privileged, kernel-hosted service, because it authenticates users and runs each session's shell as the authenticated user. It spawns one worker task per accepted connection (192 KiB kernel stack each) rather than running a session to completion before accepting the next, so multiple users hold concurrent sessions — each with its own identity and privileges — bounded by MAX_TASKS (8, ~3–5 live sessions); a full task table refuses a new connection with a one-line hint. syslog (a ring buffer of events + resource snapshots) captures the run-up to any crash. (Design note: docs/design/ssh-per-connection.md.)
Reclaim + preemption (implemented). Exited processes now return their frames (intrusive free-list) and sockets (per-owning-task) to the pool — a service start/stop cycle returns to baseline. A PIT timer (100 Hz) preempts all ring-3 code — any tool or process — so a runaway can't starve the box and can still be reaped; the kernel keeps interrupts off (IF=0), so the timer never fires in kernel code and there's no lock-deadlock hazard. Preempting synchronous tool runs is made safe by giving every task its own trap stack (RSP0) and its own saved copy of the KERNEL_RSP + sink globals across a switch.
Remaining PoC debts: the idle scheduler busy-yields instead of hlt; and the machine seed is stored in the clear. (The prano-over-SSH render freeze is fixed, and tintin — the ring-3 HTTP service — now serves over the public internet, with a proper four-way TCP teardown and a packet-logging firewall choke point; see net/filter.rs and the netlog command.) See the roadmap.
Where it goes next
- Live grant issue/revoke. Grants are static and embedded today; they move to an
authority-only /manifest/db with manifest grant/revoke, plus a revocation set the kernel consults (revocation is the genuinely hard part — a cached grant keeps verifying otherwise).
- Split trust roots.
PACKAGE_PUBKEY(may change programs) andPOLICY_PUBKEY
(may grant capabilities) are equal today but named separately, ready to diverge.
- Per-call certificates. When programs become separate processes, each call will
present a mutual certificate binding caller + callee + version — the A + B = C exchange between two running programs, on every cross-program invocation.
- Whole-package manifests. A central manifest (the D + E = F authority) will hold
hashes of every file of a program and continuously attest the on-disk set is unchanged — and be the only writer permitted in a program's directory.
Runtime directory tree (target)
The repository above is the build layout. The runtime filesystem the kernel will present draws from UNIX/Linux, adapted so integrity and ownership are structural:
/os kernel + core
/os/hal swappable lowest-common-denominator backends
/os/schema cert + syscall schema the kernel enforces
/bin
/bin/<prog>/ a program is a DIRECTORY, not a file
<prog>.img statically-linked image (read-only at runtime)
manifest.cert signed hashes + run certificate
/manifest
/manifest/bin the authority tooling — the ONLY writer under /bin and /os
/manifest/schema manifest + cert format definitions
/manifest/db installed programs, versions, their F certificates
/data the only writable subtree
/data/<user>/files
/data/<user>/logs
/data/<user>/tmp
Write rules the kernel will enforce:
/os,/bin,/manifest/{schema,db}— writable only by the manifest authority./bin/<prog>/*— read-only to the program itself and to everyone else./data/<user>/*— writable only by processes owned by$user.- Every process is owned by a
$user; that ownership decides what it may write.
Component map (this repo → the vision)
| This repo | Becomes |
|---|---|
kernel/ | /os — the kernel |
kernel/src/hal/ | /os/hal — the HAL backends |
crates/abi/ | /os/schema — the program ABI + syscall contract |
crates/cert/ | /manifest/schema — the manifest format + verifier |
crates/rt/ | the tool runtime every program links |
tools/manifest/ | /manifest/bin — the package authority (holds the key) |
tools/ramdisk/ | the packer that produces /bin |
programs/<name>/ | /bin/<name>/ — a signed program (built independently) |
| the boot ramdisk | /bin — read-only to the kernel, written only by manifest |
| process owner | /data/$user/ — per-user writable state |
Roadmap
- Done — boot, serial console HAL,
echogated by manifest verification, tamper
denial, host signer + kernel verifier sharing one schema.
- Done —
$user-owned session,/data/$user/{files,logs,tmp}with the
write-role rules, capability grants + services (deny-by-default), program-authored logs.
- Done — microkernel + signed
/binramdisk; tools are separately-compiled ELF
binaries loaded/verified/run via a shared ABI + syscall table; core ambient namespace. Tools: echo, cat, ls, grep, iambad; services: core, text_tools, iamgood. OS and toolchain decoupled (kernel unchanged when a tool changes).
- Done — ring-3 isolation (unprivileged tools;
int 0x80trap; faults kill the
process, not the kernel); multi-user identity (authority-signed users root/Bob/Mary, login/su/logout, password-hash auth); per-user /data with ownership enforced at the syscall trap (Mary cannot name Bob's files); data-at-rest vault (ChaCha20 + HMAC-SHA256 encrypt-then-MAC, per-user key). Verified: cross-user isolation, per-user grants, bad-password rejection, privileged-instruction kill.
- Done — dev tools + persistent storage. New syscalls (
getc,write_file,
delete); tools cp, rm, and prano (a full-screen editor). An ATA PIO block driver (hal/ata.rs) persists /data to a disk image, write-through, auto-mounted at boot — and because the store is vault-sealed, the disk holds ciphertext. Verified: files survive reboot; plaintext is absent from the raw disk. free/df report footprint (~118 MiB usable, 2 MiB reserved). Write-role evolved: tools may write files/+tmp/, logs/ stays kernel-only.
- Done (files) — publish pathway for files.
publish <file> <user|*>shares a
file to a specific user (owner-authorized) or, as root only, to everyone; wget owner:name fetches it if the caller is in the audience. The kernel mediates the read — decrypting with the owner's key — so no user gets raw access to another's store. Syscalls publish/fetch, registry in resource.rs. Verified: Mary→Bob share + read; a non-audience user (root) denied. (Publishing programs into /bin and external exposure still to come.)
- Done — shell scripting + history.
run <file>executes a file of commands
(feeds each line through the same dispatcher as the prompt); history + ↑/↓ recall a ring buffer of recent commands (login line never recorded). All console-side — no heap, no new syscalls. Verified: prano-authored script runs; ↑ recalls + re-runs.
- Done — persistent
/bin+ install. A writable PRDK overlay on the data disk
(past /data), read at boot and merged with the baked ramdisk (bin.rs; core wins, overlay adds). Host install tool writes a signed program into the overlay of data.img — no OS rebuild. Verified: a program granted in core but absent is unknown; after install it runs and persists across reboots; a tampered install is denied at load (writable /bin is safe because integrity is the signature check, not read-only-ness). Policy (grant) and artifact (binary) are separate — grant first, install later. (This is the path for a compiled Rust browser: cross-compile → manifest sign → install → persists.)
- Done — userland heap + scripting language.
pros-rtprovides a global allocator
over a kernel-mapped heap region, so tools can use alloc (Vec/String/Box/format!). psh is a PHP/bash-style interpreter tool (variables, arithmetic, if/while, string interpolation, read/write builtins). Per-file cap raised 512 → 4096 B. Verified: heap tool (Vec/String), Fibonacci + file-I/O psh scripts.
- Resource/port access — generalize
resource.rsfrom files to services/ports;
the user@host:port model + external-exposure ladder (needs networking).
- Per-call certificates — the mutual A + B = C exchange on every cross-program
call, checked against /manifest/db.
- HAL breadth — more backends (VGA text, PS/2 keyboard for real-hardware I/O,
timer/interrupts) behind the existing APIs; live grant revoke.
- North star — a Rust compiler running inside PiROS, feeding the manifest.