rowan.id.au— Piers Rowan

HomePiROS › ARCHITECTURE.md

PiROS architecture

Principles

  1. 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.

  1. 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).

  1. 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.

  1. 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:

SymbolMeaning
Athe program's manifest (name, version, image hash)
Bthe program's image, hashed and compared to the manifest
Can ed25519 signature over A, verifiable with the PUBLIC key

To run a program the loader requires both:

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:

ActorHoldsCanCannot
tools/manifest (authority)PRIVATE keymint certs = change(never needs to run)
kernel loaderPUBLIC keyverify certs = runforge 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 capability has a kind, and the invocation form must match it:

InvocationRequires
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:

  1. Territory — whose subtree. Bob owns all of /data/Bob.
  2. Write-rolehow 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.

  1. 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).

(/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.)

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.

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.)

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)

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).

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.

user: root and Bob hold iamgood; Mary does not, so iamgood iambad is denied for her and permitted for them.

(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.)

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:

  1. 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.

  1. Context switch. switch(from, to) saves the caller's rsp onto 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.

  1. 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

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).

(may grant capabilities) are equal today but named separately, ready to diverge.

present a mutual certificate binding caller + callee + version — the A + B = C exchange between two running programs, on every cross-program invocation.

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:

Component map (this repo → the vision)

This repoBecomes
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

  1. Done — boot, serial console HAL, echo gated by manifest verification, tamper

denial, host signer + kernel verifier sharing one schema.

  1. Done$user-owned session, /data/$user/{files,logs,tmp} with the

write-role rules, capability grants + services (deny-by-default), program-authored logs.

  1. Done — microkernel + signed /bin ramdisk; 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).

  1. Done — ring-3 isolation (unprivileged tools; int 0x80 trap; 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.

  1. 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.

  1. 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.)

  1. 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.

  1. 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 signinstall → persists.)

  1. Done — userland heap + scripting language. pros-rt provides 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.

  1. Resource/port access — generalize resource.rs from files to services/ports;

the user@host:port model + external-exposure ladder (needs networking).

  1. Per-call certificates — the mutual A + B = C exchange on every cross-program

call, checked against /manifest/db.

  1. HAL breadth — more backends (VGA text, PS/2 keyboard for real-hardware I/O,

timer/interrupts) behind the existing APIs; live grant revoke.

  1. North star — a Rust compiler running inside PiROS, feeding the manifest.