Commit Graph
466 Commits
Author SHA1 Message Date
Daniel Samson 8e388e048a docs: one line still had init minting the root grants 2026-08-08 16:35:54 +01:00
Daniel Samson ef793d1320 docs: the device manager is the root of device authority, not init
The design had init minting device grants and handing them on, because init
is what starts display and display claims the framebuffer. That was the
wrong shape. init has nothing to do with devices: it is the first process
and it starts the rest of the system. The device manager owns hardware.

So the kernel mints the root grants to the device manager, and display asks
the manager for its framebuffer like any other driver. The exception that
drove the earlier draft disappears instead of being accommodated — one
authority for hardware, not two.

The kernel recognises the manager by the chain attestation the security
track already settled on: the binary path it stamped itself, AND a
supervisor of PID 1. Path alone is forgeable, because system_spawn is
deliberately ungated and any process may spawn any bundled binary — but a
rogue copy's supervisor is the rogue, and PID 1 is the kernel's own first
process.

Costs restated for the corrected model. display gains a boot-order
dependency on the manager, mitigated by its existing ability to attach a
backend late. The kernel gains one piece of knowledge about one binary,
which is the minimum: authority has to enter somewhere, and the
alternatives are a file parser in the kernel or first-to-ask, which is the
hole again.
2026-08-08 16:35:32 +01:00
Daniel Samson 139c4f624f docs: mechanism, policy and configuration are three things
The phase 2 design called devices.csv "policy". It is not. The CSV is
configuration — declarative data an operator edits. The policy is the
component that decides using it: the device manager matching a device to a
driver, init reading protocol.csv and granting a binding. The kernel holds
mechanism, the check that a grant exists before a mapping is made.

Corrected where the document conflated them, and the three are now tabulated
so the rest of the design can lean on the distinction.

It also sharpens the open question. It was "manifest or not"; it is really
whether the grant list should be configuration from the start, or whether
init should hand the device manager the root grants and let it match by the
devices.csv it already reads. A device-grant file adds a second place an
operator must keep correct, and earns itself only when something needs to
differ from "the manager gets the hardware" — holding a device back for a
test or a bare-metal driver, say.
2026-08-08 16:31:50 +01:00
Daniel Samson 3b24b541b0 docs: phase 2 design — you hold what you were given
device_claim checks that a device exists and is free. That is all. Any
process may claim any unclaimed device, and a claim is what gates mmio_map
and irq_bind — a licence to map physical memory and take interrupts. The
device manager's matching is real but advisory: it spawns a driver with the
device id in argv[1] and nothing binds that decision to the kernel's grant.

maximum_children_per_parent is the visible cost. It exists because a driver
that claimed one device could loop device_register under it, and it is a
poor defence — an attacker burns 16 slots, claims another device, burns 16
more — while reliably refusing a legitimate PCI bus with more than 16
functions. Closing the hole is what retires the constant.

The principles decide the split: matching is policy and stays in the device
manager; enforcing that a driver holds only what it was given is security
and stays in the kernel, and is the whole of what the kernel needs.

The mechanism is decided by an awkward fact. Five of six claimants are
device-manager children spawned with their device id. display is not — init
spawns it, and it finds its framebuffer by enumerating for a display-class
node and claiming whatever it finds. So "record the device named at spawn"
closes the hole for five and breaks the sixth, and the sixth is not an
oddity to special-case: it shows authority must be delegable rather than
welded to the moment of spawn.

So: a device grant is a capability, minted by the kernel to init for the
devices firmware discovery found, delegated by init to the device manager
and to display, and passed by the manager to each driver it spawns. The
cap-passing path already exists and already carries shared memory and DMA
regions. init is already the grantor for /protocol, and protocol.csv already
records init granting the device manager its binding.

Costs named rather than buried: five drivers must hello before claiming
(pci-bus claims first today), and init grows a device role on top of the
protocol registry. A device-grant manifest mirroring protocol.csv would be
the natural symmetry and is deliberately not proposed yet.
2026-08-08 12:58:19 +01:00
Daniel Samson 5d55217212 usb: a slot the controller granted is always handed back
Both device-setup paths issued a successful Enable Slot and then returned
null if allocateDevice failed, without disabling it. A slot the driver
forgets is one the controller never reissues, so each attempt lost one
permanently for the boot. The hub path did it with no log line at all.

Both now release the slot through a shared disableSlot, extracted from
tearDownDevice, and the hub path warns like the root-port path does.

tearDownDevice also now frees the interface list. That allocation arrived
with the previous commit, so an unplug would have leaked it — found while
reading the teardown path for this fix rather than by a test.

No regression test, and it is recorded as open question 5 rather than
implied. After the slot count became the controller's own figure, reaching
this path needs more devices than the controller has slots: QEMU offers four
against sixty-four. What was verified is that the new path RUNS correctly —
pinning tracking to 2 with four devices attached produced "port 6 setup: no
free device slot", the first two devices enumerated normally, and no Disable
Slot error or timeout appeared, which is how disableSlot reports failure.

Suite 116/116.
2026-08-08 12:16:42 +01:00
Daniel Samson 729b40ece7 usb: a device has as many interfaces as it declares
max_interfaces was 4. A composite device — a headset, a webcam with audio, a
dock, a multifunction printer — routinely has more, and the fifth did not
merely go missing. parseConfiguration's cap branch had no `else`, so when the
count was reached `current` kept pointing at interface 3 and the fifth
interface's endpoint descriptors were appended to interface 3's array. A
class driver bound to interface 3 could then be handed an endpoint belonging
to something else entirely, and subscribe or bulk-transfer on it. The
alternate-setting arm one line above cleared `current` correctly, which is
what the cap branch should have done.

Interfaces are now counted from the block in a first pass and allocated to
exactly that number, so the ceiling is bNumInterfaces' u8 — the USB
specification's. The missing `else` is added too, though after this the bug
is unreachable by construction: interface_count cannot reach interfaces.len
mid-parse when the list was sized from the same walk.

max_configured_endpoints was max_interfaces * max_endpoints_per_interface =
16, a derived guess that moved whenever either input moved. It is now 31,
which is the xHCI specification's own limit: a Device Context holds a slot
context plus at most 31 endpoint contexts, because the Context Entries field
addressing them is 5 bits.

max_endpoints_per_interface stays at 4 with its reason recorded — the
usb-transfer wire protocol reports exactly max_reported_endpoints (4) per
interface, so widening it alone would change nothing a class driver sees.
Lifting it is a protocol change.

No direct test, and that is written down as open question 5 rather than
glossed. The parser is pure and wants a host unit test, but
usb-xhci-library.zig imports memory, mmio and time so it cannot be a
standalone test root, and QEMU offers nothing that reaches the path — the
largest device available is usb-audio,multi=on at 2 interfaces and 211
bytes. The alternate-setting path that shares the same `current = null`
logic is exercised by that device.

Suite 116/116.
2026-08-08 12:05:33 +01:00
Daniel Samson 6328823ef1 usb: a configuration block is as long as the device says it is
The driver read the first 512 bytes of a configuration block into a fixed
buffer and parsed those. The block's length is the device's own choice
(wTotalLength, a u16), so anything larger was silently cut: interfaces past
the cut did not exist as far as the host was concerned, while the
SET_CONFIGURATION that follows still configured the device for all of them.
A headset is 500-900 bytes, a UVC webcam 1-3 KB, a multifunction printer
600+.

Now allocated at the declared length, so the ceiling is the field's u16 —
the specification's number rather than one of ours. A block shorter than its
own 9-byte header is refused rather than trusted.

The bring-up line reports the declared length and the bytes actually read,
so a truncation can never again be invisible, and usb-large-descriptor
asserts they match with a backreference.

That case has an honest limit, recorded in its comment: QEMU cannot produce
a block over 512 bytes. The boot keyboard, mouse and stick are 34-44, and
the largest device available is usb-audio in multi-channel mode at 211 —
which is exactly why the suite never caught this, and why it cannot now
reproduce the original trigger. What it does catch is the class: any clamp
below the attached device's block fails it, verified by pinning the buffer
to 128 and watching "config block 211 bytes, read 128" turn the case red.

Suite 115 -> 116.
2026-08-08 11:53:01 +01:00
Daniel Samson 69fbef40c0 usb: the controller says how many device slots it has
max_devices was 8, with the comment "QEMU presents a handful; a fuller
machine would grow this" — a number chosen against the test rig, waiting for
a real machine, which is the pattern docs/bounds-track-plan.md exists to
stop.

The driver already knew the true figure. It reads HCSPARAMS1.MaxSlots at
bring-up and writes it straight into op_config, so every slot the controller
offers has always been *enabled*; only the array tracking them was 8. QEMU's
xHCI reports 64, so seven eighths of the controller was live and invisible,
and the ninth device — a keyboard, mouse, webcam, headset, hub and two
sticks reach that without trying — disappeared on a hub-attached path that
logs nothing at all.

The array becomes a slice allocated from max_slots at bring-up. A controller
claiming zero slots cannot address anything, so that is now a dead
controller rather than an empty allocation failing mysteriously later. The
Device Context Base Address Array is a page, 511 usable entries, so it
already covered the 255-slot maximum.

The bring-up line reports both numbers, and usb-hid asserts they are equal
with a backreference rather than a magic number, so the test cannot drift
from the hardware. Pinning tracking back to 8 fails it: "64 slots,
tracking 8".

Suite 115/115.
2026-08-08 11:39:49 +01:00
Daniel Samson f4eb88e7d2 build: a new compile-time ceiling declares itself or does not land
The convention that tunables live in system/parameters.zig with their
reasoning attached predates this and got 2% compliance — 5 of 235. A
convention with no teeth is how a bare `const maximum_devices = 64` reached
an AMD desktop and cost it USB and storage. This is the same rule with a
gate behind it.

tools/check-bounds.py finds every bound-shaped declaration — a `maximum_*`
const with a literal value, or a type with a literal array length — and
requires the five-field block above it: what it counts, who decides its
size, what it protects, what happens at the limit, and how anyone finds out.

The at-limit vocabulary is closed: refuse, degrade, truncate, grow. There is
deliberately no way to spell "silent", no way to spell "drop", and nothing
meaning "allow", so the behaviours that did the damage cannot be written
down. Truncation is legal only carrying a marker the reader can see, which
is why klog_maximum_message qualifies and a USB descriptor cut at 512 bytes
does not.

An array length that names a declared bound is not itself a bound; only
literal lengths are flagged, which pushes ceilings toward having names.

The 273 that predate the rule are allowlisted so this lands without a
tree-wide sweep in front of it, and that list may only shrink: declaring a
bound means deleting its line, and the check fails on a stale entry too.
Nothing may be added.

Wired into `zig build test` and available alone as `zig build bounds`. Not
in the default build — it reads the whole tree, and a red bounds check
should not stop you booting a kernel.

Five are now declared rather than allowlisted. Writing them out is its own
argument: maximum_devices reads "protects: nothing — this is a sizing guess
about someone else's computer", and maximum_tasks now carries the fact that
it has been raised twice, each time by something that outgrew it.

Verified the gate refuses an undeclared bound, a declared one using
forbidden vocabulary, and an allowlist entry that has since been declared.
Suite 115/115.
2026-08-08 11:27:27 +01:00
Daniel Samson 568823a4fb docs: L1 was wrong — reclamation is not a death-sweep problem
The first step of the unattended run was "a dead task's registrations die
with its claims". Implementing it would have broken the restart path it was
meant to protect.

The broker keeps entries on purpose: they describe hardware, which did not go
away when a driver died. And device ids must stay stable across a bus
restart, because device-manager dedupes re-reports by device_id so a
restarted bus does not spawn a second driver instance — stability that comes
from the idempotency scan returning the existing id. Removing entries on
death would hand a restarted bus fresh ids and duplicate every driver.

Everything else a task holds is already reclaimed on every path out: IRQ
bindings, IOMMU domains, DMA regions, then its claims.

The leak the audit found is real but has two other sources: a device that
genuinely goes away has no retirement path, and a bus that enumerates
differently on restart strands its old entries. Both belong to the device
manager's inventory, and both need the id-stability question settled first —
tombstone-and-reuse aliases ids another process still holds, generation
tagging changes the id encoding, which is ABI. Recorded as an open question
rather than guessed at.
2026-08-08 11:15:11 +01:00
Daniel Samson 4398eb7cc4 docs: scope the bounds track's unattended run
Six steps an agent can execute: reclamation, the bounds build check, and
the four user-space USB/xHCI bounds where the hardware already reports the
number we guessed.

Deliberately excluded: the authorisation gate and moving the device
inventory to the manager. Both decide whether the OS is secure and both are
a direction rather than a specification — what a device capability is, which
syscalls change, what replaces device_claim for its seven callers. They want
a design session, not an agent.

Three open questions are written down rather than guessed: device_enumerate
most likely narrows to the firmware-discovered roots rather than retiring
(the manager cannot ask itself for the PCI host bridge); a manager restart
has no re-enumerate handshake, so it comes back blind while its buses live;
and "add adversarial tests" is not executable until the attacks are named.
2026-08-08 11:13:20 +01:00
Daniel Samson a86559648e kernel: a refusal names its rule, and two bounds stop failing open
An AMD Ryzen booted to a working compositor with no USB and no storage,
and the log said only "register refused". A tree-wide audit of every
compile-time ceiling followed: 235 of them, 139 on quantities the machine
or a file decides rather than us, 5 documented anywhere, 171 silent when
reached. docs/fixed-bounds-audit.md has the inventory.

Errno attribution. The errno space was split between the kernel and the
envelope, free to drift; it is now one list in system/abi.zig, restated on
both sides, with a comptime check in library/device/driver where the two
halves are visible. device_register's six refusals and device_claim's three
are distinct codes, so a bus driver can say which rule stopped it, and
BadParent splits into NoSuchParent and NotYourParent. pci-bus reconciles
found against registered instead of counting refused functions as found.

Idempotency ordering. The child cap was checked before the identity match,
so a restarted bus was refused its own devices — the supervision restart the
system leans on ratcheted toward a degraded machine. A re-registration
consumes no slot and is now admitted first.

IOMMU fail-closed. confineDevice returned success for a device id past the
confinement table, leaving the device outside every domain while the caller
believed it confined — unreachable only while ids stop at 64, which both the
inventory move and a hardware-reported domain count would change. It refuses
now, and the coupling to the broker's device cap is a comptime assert rather
than a sentence in a comment.

PCI apertures. The bridge's MMIO apertures are derived from the holes in the
firmware memory map, and the derivation copied sub-4 GiB entries into a
fixed [64] array and skipped the rest. A skipped region is not merely lost:
the gap finder concludes it is free, so a real machine's 60-200 entry map
yields an aperture over live RAM, and containment then admits a child BAR
covering kernel memory. Rewritten to walk the map in place, with the hole
finder extracted as a pure function and driven by a synthetic 100-entry map
in a new test case. Both new tests were verified to fail on the old code.

parameters.zig gains the rationale it was missing and loses a stale sentence
pointing at the wrong file; vdso.md documents the errno space, including
EPEER, which had no written meaning anywhere.

docs/os-development/bounds.md is how a ceiling is declared from here.
docs/bounds-track-plan.md is the plan to remove the ones we invented.

Suite 114 -> 115.
2026-08-08 11:09:54 +01:00
Daniel Samson 7db5fba884 kernel: boot on AMD — SYSRET puts the RPL in the STAR base
A Ryzen 3 3200G triple-faulted on its first timer tick after reaching init.
Three defects in a chain, each hiding the one beneath it.

STAR's SYSRET base was 0x10, so SS came back as base+8 = 0x18 with RPL 0
while CS carried RPL 3. Intel ORs RPL 3 into SS on SYSRET; AMD only does so
for CS. Ring 3 ran fine — RPL is not checked on data access — and died the
moment an interrupt tried to IRETQ back, where SS.RPL must equal CS.RPL.
The base now carries the RPL (0x13), as Linux does.

Two fixes below it, both of which made the first one unreadable:

scheduler() read IA32_GS_BASE and dereferenced it without testing for zero,
so every fault reporter faulted in turn — a panic inside a panic, and the
machine reset before printing anything. Cast after the null test, plus a
re-entrancy guard in the panic handler.

NT is now masked in SFMASK alongside the rest, and isr.s exports
isr_return_iretq at the faulting instruction so a frame dump can say which
IRETQ died and print the CS/SS it was about to load. That dump is what
identified the RPL mismatch.
2026-08-08 11:07:55 +01:00
Daniel Samson a85f29edc9 merge: security track group 4 — SMEP, the SYSRET guard, and SMAP
# Conflicts:
#	docs/security-track-plan.md
2026-08-01 12:20:54 +01:00
Daniel Samson 5a5146ec13 kernel: ring 0 reaches user memory only through the checked copy
SMAP makes the rule the copy layer has followed since it was written into a
rule the hardware keeps. A ring-0 read or write of a user page now faults,
so any code that reaches for a user pointer directly fails the first time it
runs rather than the first time someone attacks it — and the suite becomes
the enforcement test, because every case exercises the kernel with the bit
on. Nothing had to be fixed to turn it on, which is the retrospective proof
that the nine stragglers converted earlier were all of them.

The interrupt entry needed one instruction first. Hardware does not clear
the alignment-check flag on its way into a handler, and ring 3 sets that
flag freely, so a process could have taken an interrupt with SMAP suspended
for the duration. The system call path was already covered — its flag mask
clears it — but the interrupt path needed a `clac`, which cannot simply be
assembled in: it is an invalid instruction on a processor without SMAP, and
danos boots on those too. So the entry ships as a three-byte NOP and is
patched at boot, through the physmap, because the kernel maps its own text
read-only.

The ordering that makes that safe is enforced rather than described: the
patch sets a flag, and no core will set the SMAP bit until it is true. A
translation that fails, or bytes that read back wrong through the address
they will actually be fetched from, leave the machine unhardened and saying
so — which is the same posture the IOMMU takes, and better than enforcing
over an entry path that cannot comply. The patch runs before interrupts are
enabled and before any second core exists; a comment says so, because the
three bytes pass through an encoding that must never be executed and a
future change that moves this later has to deal with that first.

Suite 114/114, with a case that reads a user page from ring 0 and requires
the fault, and the multi-core case asserting every core that ran work had
the bit — the same shape SMEP got, for the same reason: CR4 is per-core, and
a hardening is only as wide as its narrowest core.
2026-08-01 12:20:44 +01:00
Daniel Samson 1365a245ba docs: security track live state — HS green at 113 2026-08-01 11:22:08 +01:00
Daniel Samson cb30faf15f kernel: a hostile return address cannot fault the kernel
SYSRETQ with a non-canonical RIP raises a general protection fault in ring
0 — on the kernel stack, an instruction after the swapgs that installed the
user's GS base. It is one of the better-known escalation primitives, and
ring 3 reaches it without any kernel bug at all: the processor saves the
address of the instruction after SYSCALL, so a program whose SYSCALL is the
last two bytes of the last canonical page returns to the first
non-canonical address. The new test does exactly that.

The exit path now sign-extends the return address from bit 47 and compares;
if the value changed, it returns through IRETQ instead, which commits the
privilege change before fetching the new address, so the fault arrives from
ring 3 and the process dies like any other. Four register-only operations
and a branch that a correct program can never take — it could not have
executed at a non-canonical address in the first place. Bit 47 is the right
pivot because danos builds four-level page tables and nothing sets the
five-level bit; a future port must move the pivot, and the comment says so.

SFMASK grows one bit while we are here. SYSCALL, unlike an interrupt gate,
does not clear the nested-task flag, so the kernel had been running every
system call with whatever ring 3 last chose — harmless while the only exit
was SYSRETQ, and a question worth not having now that one exit is IRETQ.
The kernel is never nested; ring 3 still gets its own flag back.

Suite 113/113. The new case asserts the refusal counter rather than the
dying process: the emulator we test on kills it either way, so only the
counter distinguishes a guard that ran from one that did not.
2026-08-01 11:22:07 +01:00
Daniel Samson ec154a975f docs: security track live state — H2 green at 112 2026-08-01 10:05:59 +01:00
Daniel Samson 36a7cc5fe9 kernel: ring 0 cannot execute a user page
SMEP turns the classic escalation — divert kernel control flow into a page
the attacker wrote — from a silent takeover into an immediate fault with
the offending address in the log. The bit is per-core state, so it is set
where the syscall MSRs already are: in the per-CPU bring-up both the boot
processor and every application processor run on their way in. A core that
climbed the trampoline without it would be a hole no boot log would show,
which is why the SMP case now reads CR4 on each core it lands on and
requires every one of them to be hardened, not just the one that printed
the banner.

Enabling it that early is only safe because nothing ring 0 executes is
mapped for ring 3, and that had to be established rather than assumed:
kernel text carries only its ELF flags, the physmap is no-execute, the
trampoline page is mapped supervisor and the core running it has not
enabled the bit yet, and the boot processor turns it on while still on the
loader's tables — which map nothing user-accessible at all. The one
indirect call in the kernel takes a kernel address.

The CPUID probing that was scattered across the timer code becomes a small
shared helper, since the feature question is now asked from two places and
each wanted the same maximum-leaf guard. Absence is tolerated and reported,
like the IOMMU: danos still boots on a machine without the feature, and
says which one it is.

The test harness starts asking QEMU for a CPU that has the bit at all —
its default model has neither SMEP nor SMAP, so the code would otherwise
have been unreachable in every run. No case behaved differently under the
richer model.

Suite 112/112, with a new case that maps an executable user page, calls
into it from the kernel, and requires the fault the CPU is supposed to
raise.
2026-08-01 10:05:58 +01:00
Daniel Samson b3011fbb40 merge: security track group 3 — every protocol on the envelope, ids owned
# Conflicts:
#	docs/security-track-plan.md
2026-08-01 09:05:38 +01:00
Daniel Samson 1b1c587c14 library: the harness keeps the subscribers, and an id belongs to whoever opened it
Three services had each written the same thing and got it three different
ways: input polled the process list to notice a dead subscriber, and only
when someone else subscribed; the power service never noticed at all; the
device manager noticed drivers but not subscribers. The harness owns the
table now, driven by the events a protocol declares — it registers on the
reserved verb, frames each event once, posts to everyone interested without
waiting on any of them, and reclaims a slot when the kernel says its owner
died. Interest masks moved to the envelope, so a subscriber that wants only
mice asks the same way everywhere.

Two consequences the plan had not foreseen. The device manager now hears a
supervised child's death twice, once as its supervisor and once as a
subscriber, so restart backoff counted every crash twice and gave up after
half as many; it retires the id before counting. And the kernel's published
exit table had eight slots for what is now six subscriptions in a plain
boot, so it holds sixteen.

The other half is a hole the design named early and left standing: a
backend handed out a small integer and then honoured it from anyone. A
process that guessed a file's node id read another client's file; a display
layer had no owner at all, so any client could reconfigure or destroy any
layer; a USB device token was never checked against the client that opened
it. Each is now bound to the task that opened it, and a wrong owner gets
exactly what an unknown id gets — the refusal must not become the oracle
the identical answers elsewhere were designed to remove. Closing a file
changed with it: it used to succeed unconditionally, which would have told
a caller which ids existed.

Suite 111/111, with a new case in which one process holds a file and a
layer, hands both ids to a second process, and finds them untouched after
that process has tried everything with them.
2026-08-01 09:05:26 +01:00
Daniel Samson 776b04184e docs: security track live state — P4b green at 110 2026-08-01 07:20:39 +01:00
Daniel Samson 2719b93530 library: the last three protocols speak the envelope
These were the awkward ones. Each began with an operation packed into a
single byte — two of them with a version wedged in beside it — so there was
no wrapping them: the layouts had to be rebuilt. The device manager's own
enumerate and subscribe become the reserved verbs that mean the same thing
everywhere, its replies lose three status structs the envelope already
carries, and a device id becomes the packet's target. Power drops the
version it repeated on every request, because describe is the handshake,
and stops claiming a 64-byte ceiling it never needed for calls. USB moves a
control transfer's data to the packet tail in both directions, which makes
the status length the transferred length and retires a field that had been
saying the same thing twice.

The danger in this one was not the protocols but their readers. Init
recognised a power button by two bytes at the head of a message, the ACPI
service dispatched on the first byte, the xHCI driver read its operation
with a raw integer load, and the HID drivers reinterpreted a report
wholesale — none of which would have failed to compile once the layouts
moved. They would simply have stopped: no shutdown on the power button, no
reports from the keyboard. Every one of them now reads through the
generated types, and the shutdown gate that answers only a subscriber is
the same code it was.

Two sizes were decided by measuring rather than assuming. The child-added
message is both a request and the event broadcast to subscribers, and
alignment rounds it to 48 bytes, which puts its packet exactly on the
64-byte push floor — a test pins that, because a field added carelessly
would now overflow it. The interrupt report gives up eight bytes of inline
room to make space for the header; the two drivers that produce reports
send eight and four.

Suite 110/110.
2026-08-01 07:20:37 +01:00
Daniel Samson e250d8cd79 docs: security track live state — P4a green at 110 2026-08-01 06:16:15 +01:00
Daniel Samson d2dfbcabf8 library: five protocols speak the envelope
The folded header stops being a rule in a document and becomes the layout
on the wire. Verbs number from sixteen, leaving describe, enumerate,
subscribe and unsubscribe reserved and answered the same way by every
provider — none of them writes a line to do it. What each protocol used to
carry in a field of its own now travels in the header: a vfs node and a
display layer are the packet's target, and a reply opens with a status the
envelope stamps rather than one each protocol spelled for itself.

Display gains the most. One forty-byte request had served eleven verbs, so
attach_scanout smuggled stride through x, refresh through y and format
through colour, and every coordinate crossed as a bitcast. Per-operation
structs end all three: the fields have their own names and their own signs,
and the tile payload grows to 224 bytes because the prefix shrank. Scanout
loses a message maximum of 64 it had no business declaring — it answers
calls, and the floor for a call is 256 — and virtio-gpu stops hard-coding
that number at its harness.

Two changes are semantic rather than notational. A directory now ends at an
entry with no name, because the fixed part of a reply always travels and a
zero-length reply no longer exists to mean anything. And input joins the
service harness, the last loop in the tree that answered no ping and heard
no terminate; its subscriber table, its pruning and its fan-out are the
same code, and a shutdown now asks it to stop instead of killing it.

A new conformance case reads the registry's own listing and asks every
protocol it finds for its name, its version and its verb count, then offers
a verb nobody defines and requires -ENOSYS — the envelope's promise,
checked against providers rather than against itself. What it cannot reach
in that boot it names on the serial line instead of passing quietly.

Suite 110/110.
2026-08-01 06:15:25 +01:00
Daniel Samson b004b9c3eb merge: security track group 2 — the protocol namespace replaces ServiceId
# Conflicts:
#	docs/security-track-plan.md
2026-08-01 04:45:14 +01:00
Daniel Samson 3e6e21bf0a docs: security track group 2 merged 2026-08-01 04:44:57 +01:00
Daniel Samson 0fbd2c8f12 init: a protocol you were not granted does not exist
The registry consults the open rows it has been parsing since P2, so
reaching a contract now takes a grant as well as a binding. A caller
without one is answered exactly as it would be for a name nobody ever
bound: same status, same empty reply, same absent capability, byte for
byte, and no log line on either path — klog_read is ungated, so a line on
one and not the other would be the oracle the design set out to remove.
Refusal and absence being one answer is what lets a supervisor later
narrow, fake or park a child's namespace without the child learning what
it was denied.

The manifest gains a third permission for a shape the plan did not
foresee: attestation is one hop, but the driver tree is three deep — the
PS/2 keyboard and mouse are spawned by ps2-bus, which the device manager
spawned — so no row could name them and PS/2 input would simply stop.
A supervise grant lets a delegate vouch for what its children *reach*,
never for what they claim; the bind path is untouched, and the laundering
deputy is still refused.

The review found the receive side of a rule this track had already
written down. Every process holds a sendable handle to the registrar —
resolve installs one for anyone who asks — and ipc_reply_wait never asked
who owned the endpoint, so a stranger could dequeue there: take the
provider endpoints riding bind requests, and answer other clients' opens
in the registrar's name. Receiving is the owner's privilege, like binding
a signal or a timer; sending remains anyone's.

Suite 109/109.
2026-08-01 04:44:45 +01:00
Daniel Samson 73aea4582b docs: the security track plan carries its live state on main
The plan is the only progress view from a plain main checkout, and the work
lands on a feature branch that reaches main a group at a time — so between
merges the file said less than was true. It now opens with where the work
is, which branch carries it, and what main itself holds, refreshed on main
after every phase rather than at merges alone.
2026-08-01 04:01:39 +01:00
Daniel Samson 1379b699f3 init: /protocol replaces the ServiceId registry
A protocol is reached by name now, not by a compile-time integer. Init is
PID 1 and already knows which binary it started, so init serves /protocol
as a vfs backend: bind claims a contract with the provider's endpoint
attached, open answers with that endpoint as the reply's capability, and
readdir lists what is bound with the task and binary behind it. The kernel
reserves the prefix — nothing may mount over it, under it, or unmount it —
and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers
left vacant.

A bind is authorized by who the caller *is*: the kernel-stamped binary
together with the supervising task's identity, matched against
/system/configuration/protocol.csv. Identity, not spelling — spawn is
ungated, so an attacker can run any bundled binary, and a name-only rule
would have let it launder grants through an init of its own making. A name
a live process holds is refused to everyone else; a dead one's is released.

Three review rounds against a hostile ring-3 process found what 108 green
tests could not, because the suite contains no attacker. Publishing init's
supervision endpoint as the registry put PID 1's mailbox in every process's
hands, where two forged bytes reached the shutdown path: privileged traffic
is now believed only from the task that holds the contract it speaks for.
A capability arriving on a request outlived every path that ignored it,
one handle per call until the table was full — in init, and in the harness
ten services share — so the arriving capability is owned by the turn and
released unless a handler says otherwise. And the kernel let anyone holding
an endpoint handle aim signals, timers, exit notices and interrupts at it:
binding now requires having created it.

Suite 108/108. The new protocol-registry case asserts eleven properties,
each one an attack that must fail.
2026-08-01 02:39:07 +01:00
Daniel Samson 1ff0991452 library: the envelope — one packet shape for every protocol
A protocol is now defined through envelope.Define rather than beside it.
Every packet begins with the same 16-byte prefix: a Header of {operation,
target} for a request or event, a Status for a reply. The header is folded
into each protocol's own layout, never stacked on top of it — Define walks
a spec's types and refuses one that carries a Header or Status field, so
the rule cannot be missed by reading. Operations number from 16, leaving
describe, enumerate, subscribe and unsubscribe reserved and answerable the
same way everywhere; describe the envelope answers itself, and an unknown
verb is -ENOSYS without a provider writing a line.

The size floor becomes something protocols can compile against: 256 bytes
for a call, 64 for a push, matching the kernel's own limits, checked at the
Define call with the prefix counted in. A protocol that outgrows either
fails the build with the reason and the remedy — shrink, or move the bulk
to shared memory, because packets never fragment. Events carry the header
too and number in their own space, so appending an operation can never
renumber a shipped event.

Beside it, the client half of the model: a Channel is what opening a
/protocol name yields, wrapping the endpoint capability so a program holds
a conversation rather than a handle. Nothing speaks through either yet —
the vfs protocol gains NodeKind.protocol and the convention that an open
reply may carry a capability, and that is the whole runtime change.

Suite 107/107, unchanged: P1 converts nothing.
2026-07-31 21:48:03 +01:00
Daniel Samson ac01f627d1 docs: security track group 1 merged 2026-07-31 20:58:20 +01:00
Daniel Samson f3bc23cb81 merge: security track group 1 — path flag-day and checked user copies 2026-07-31 20:58:10 +01:00
Daniel Samson 8d4a7cf240 kernel: user memory is reached only through a checked copy
A new user-memory module owns every kernel touch of a user buffer:
copyFromUser, the new copyToUser, and the resolve behind both. The walk
accumulates the U/S and writable bits down all four levels with the MMU's
own AND rule — folding a 2 MiB leaf in before it resolves and refusing a
1 GiB leaf outright — so a copy honours what ring 3 itself would be
allowed, closing the presence-only trust model the IPC layer carried since
bring-up. It then confirms the frame is physmap-backed, because that is how
the copy reaches it: an mmio_map'd BAR passes the permission walk and would
otherwise fault ring 0 on an alias the physmap never mapped, on the IPC path
as much as the new one.

The nine stragglers that dereferenced user pointers raw now route through
it, so a bad pointer returns -EFAULT where it used to fault the kernel.
The write direction restructures its callees around kernel bounce buffers:
scheduler and devices-broker enumerate from a slot cursor (a task exiting
between chunks can neither duplicate nor lose an entry), klog_read drains
the ring in chunks, and fs_node stages headers and names contiguously.
fs_resolve copies out before installing the endpoint handle, so a faulting
copy cannot strand a capability; its out-capacity bound no longer adds an
unbounded ring-3 length to the base, which wrapped and trapped the kernel's
own overflow check. debug_write reads the caller's message once.

Suite 107/107 (new user-memory case: seven bad pointers refused, each
paired with a sound call that must still succeed).
2026-07-31 20:57:49 +01:00
Daniel Samson c4f16a5448 build: the unix paths retire — configuration, logs, and volumes move into the danos tree
/etc/init.csv and /etc/devices.csv become /system/configuration/*.csv (the
repo's etc/ moves to system/configuration/, mirroring the runtime tree),
/var/log becomes /system/logs, and /mnt/usb becomes /volumes/usb. The
kernel VFS gains a carve-out so FAT may serve exactly /system/configuration
and /system/logs beneath the initrd-backed /system while /system and /test
themselves stay unshadowable; FAT's single /var mount splits into those two
rewritten mounts. The kvfs readdir check learns /system's third child and
the ramdisk spawn sweep skips the configuration tree.

Suite 106/106.
2026-07-31 19:41:35 +01:00
Daniel Samson e4da4e0610 docs: security track phase 0 — baseline green (106/106) 2026-07-31 19:19:53 +01:00
Daniel Samson 9a3238025d docs: the security-track execution plan — path flag-day, namespace phases, kernel hardening
The /loop work list for the committed design set: Phase 0 baseline, PM
(unix-path flag-day), H1 copy discipline, P1 envelope + Channel, P2
registry + ServiceId retirement, P3 open grants, P4a-c protocol rebase,
H2 SMEP, HS sysret guard, H3 SMAP. Ten settled decisions from the
2026-07-31 grounding pass, two corrected on review: every packet carries
the envelope header folded (headerless events rejected), and bind/open
authorization is chain-attested identity (name alone rejected — ungated
spawn makes it a confused deputy). smep-smap.md gains the verified
straggler table: nine syscalls, klog_status the ninth, with the
callee-writer restructuring notes.
2026-07-31 19:10:23 +01:00
Daniel Samson c96ef87714 docs: the communication stack — /protocol namespace, layered model, non-unix hierarchy, SMEP/SMAP plan
The security-track design set. communication.md is the model: four layers
(namespace / protocol / channel / transport), packets and signals, parties
addressed by the channel and objects by target, transports as replaceable
buffer+doorbell mechanisms. protocol-namespace.md is L3+L2: /protocol names
contracts, resolution establishes a channel, init is the registrar,
restriction is per-process namespace delegation (with the microphone-prompt
worked example), the envelope is the universal packet header, migration
P1-P5 retires ServiceId. file-system-hierarchy.md replaces the unix-FHS
spec with the danos-native tree (/applications, /protocol, /system,
/volumes) and its migration table. ipc.md is re-cut as the kernel-ipc
transport document. smep-smap.md designs the kernel hardening: copy
discipline for the eight raw user-pointer syscalls, then SMEP, then SMAP
as a permanent tripwire.
2026-07-31 18:11:28 +01:00
Daniel Samson de9870175f docs: the Python track — CPython via zig cc, the C layer, streams, dynamic libraries
Five design-and-milestone notes for making danos programmable before Zig
self-hosts:

- python-on-danos: why CPython, the WASI precedent, static-only interop
- python-on-danos-milestones: P0–P5 (libc → seam → CPython → terminal+REPL →
  danos module → process control + shell) with tests and exit criteria
- c-library-compatibility: the libdanos-c sysroot — musl computation lifted,
  Zig plumbing over runtime; staged road to full coverage (fork never comes)
- character-devices-and-tty: stream nodes over the existing VFS; first device
  is an in-memory loopback (COM1 off the critical path); no pty object — the
  terminal serves its children directly
- dynamic-libraries: post-P5 D1–D4, application-layer only

The size doctrine runs through all of them: the OS stays lean (kernel in
kilobytes, services small and static, never linking the libc); applications
have their own budget.
2026-07-31 13:41:40 +01:00
Daniel Samson be04ebe954 build: delete module_homes — imports resolve through the declared zon
The module-to-domain table in build-support duplicated what each
domain's build.zig already states with its addModule exports. userBinary
now resolves each named import by searching the packages the binary
declared in its own build.zig.zon (b.available_deps), which also makes
the zon the literal include path: an import can only be satisfied by a
domain the binary claims, and naming a module whose domain is missing
fails the build graph with the domain to declare. build-support is down
to the recipe alone. All build variants green; manifest unchanged.
2026-07-30 07:49:19 +01:00
Daniel Samson 62d6a7a150 build: the root's ship list becomes a declarative table
Each shipped binary was registered twice in the root build (a
dependency/artifact line plus a bundled row repeating its name and
path). The uniform rows — dependency name = artifact name = boot-path
leaf — collapse into production_ship, one line per binary via the
service()/driver()/driverArtifact() helpers; only the genuinely
non-uniform entries stay spelled out (init's -Dserial, the -Ddiscovery
pick, the /etc data files). Selecting what goes into a build is now
selecting table rows, and an unselected package's build file is never
loaded. The boot manifest is set-identical; its order shifts (discovery
and the /etc entries move) — every lookup is by name, and the
boot-order QEMU smoke passes.
2026-07-30 07:36:21 +01:00
Daniel Samson fa8203cdba kernel: the IOMMU backends move behind the architecture boundary
VT-d and AMD-Vi are x86 hardware, but lived in the architecture-neutral
kernel tree and leaked further: the core's public Kind enum named both
vendors, and the ACPI parser read the VT-d version/capability registers
(raw volatile MMIO inside table discovery). Now the vendor backends
live in architecture/x86_64/ behind architecture.iommu — the core hands
over the discovery facts plus an injected environment (frame allocation
+ the log sink, the same pattern enablePaging uses) and receives the
hardware vtable back, so the backends never import kernel internals and
an ARM port supplies its SMMU with no core change. Discovery keeps
table facts only; the live-unit register check moved into VT-d detect
(version reading zero now stays fail-open). The unused kindOf() is
gone. Log shapes the harness pins (iommu online, DANOS-IOMMU-FAULT)
are unchanged; all five IOMMU QEMU cases pass.
2026-07-30 07:16:08 +01:00
Daniel Samson e53d6ebafb library: client modules end in -client, like protocols end in -protocol
display-client and input-client (files and module names), so a service,
its wire contract, and its client never share a name: `display` the
service, `display-protocol` the contract, `display-client` a program's
view of it. The nine consumers' imports and their packages' declared
lists follow (regenerated from the source scan); build-support's
module_homes table carries the new names.
2026-07-30 06:56:12 +01:00
Daniel Samson 4476208361 build: exact per-binary imports — the pre-wired default set is gone
Every binary's build.zig now names precisely the modules its source
imports (derived by scanning each artifact's sources, transitively
through same-directory files), and its zon carries only the domains
those come from — kernel stays implicit (the root shim + link script
live there). build-support's userBinary resolves each name through one
module-to-domain table (module_homes); Domains/domains()/defaultImports
and the raw recipe entry point are deleted. An undeclared @import is a
compile error (verified: injecting @import("xkeyboard-config") into
logger fails with 'no module named ... available within module
program'), and e.g. xkeyboard-config now appears in exactly two
manifests — the two keyboard drivers. Availability never bloated the
emitted binaries (Zig compiles only what a program imports); this makes
the declared interfaces honest. Production and -Dtest-case manifests
byte-identical; all build variants and standalone package builds
green.
2026-07-30 06:42:31 +01:00
Daniel Samson c621b649f6 build: lazy dependencies — a build loads only what it ships
The 13 /test fixtures and the acpi/fdt discovery pair are .lazy in the
root zon, resolved with b.lazyDependency only when a build actually
bundles them: a plain `zig build` neither compiles the fixtures nor
loads their build files, and only the -Ddiscovery-selected package ever
loads. Fixture packages are uniform (dependency name = artifact name =
boot-path leaf), so the bundled list shrinks to a name loop. Production
manifest byte-identical; the -Dtest-case manifest carries the same 13
entries in the same order; -Ddiscovery=fdt exercises the lazy fdt path.
Discharges the plan's deferred what-this-buys #4.
2026-07-30 06:26:22 +01:00
Daniel Samson d27670ec39 tests: fix the two stale kernel self-tests the FHS boot tree broke
initial_ramdisk's spawn-everything sweep counted the /etc data files
(devices.csv, init.csv) as spawnable programs — BadElf ever since the
boot tree started ferrying them — so it now counts only the /system and
/test trees. The init test waited for two raw user writes before
checking the last write for the heartbeat text; init's boot chatter
(heap ok, the /etc/init.csv lookup) satisfies the count long before the
first beat, so it now waits for the heartbeat itself. Both cases pass
again; these failures predate the build-packages work.
2026-07-30 06:23:31 +01:00
Daniel Samson ab7594df6e docs: commit the new-driver checklist, step 2 rewritten for packages
The checklist (with the Intel UHD 750 worked example) existed only as
an untracked file in the main checkout — the plan referenced it but no
branch could see it. It lands here with its build step rewritten
against the finished package template: create the driver's own
build.zig/zon from the pci-bus template, then one dependency + one
boot-tree row + one zon line in the root build. devices-csv.md's
adding-a-driver section and the plan's checklist references point at
it again.
2026-07-30 04:41:41 +01:00
Daniel Samson d03942b543 docs: catch the docs up with the finished package split
The plan doc's status records completion (all waves + phase 3) and its
execution notes describe the finished shape; the fresh-session pointer
names build-support's userBinary instead of the deleted
addUserBinaryImpl, and the size-check carry-along note is discharged.
README gains the build/ directory in the layout tree and splits the
source-map row across root build.zig / build/images.zig /
build/qemu.zig. testing.md points at the distributed per-package test
steps; threading.md, threading-plan.md, driver-model.md, efi.md,
display.md, system-requirements.md, and devices-csv.md's adding-a-
driver checklist stop describing the pre-package build.
2026-07-30 04:24:34 +01:00
Daniel Samson 3f9b6813f7 build: thread-test package opts back into threading
Wave C dropped the .threaded flag the old addThreadedUserBinary call
carried, so thread-test compiled single_threaded: the heap mutex was
compiled out and atomics lowered to plain ops while the fixture spawns
real kernel threads — the QEMU thread-alloc case caught it (corruption
under concurrent allocation), and the completion review confirmed the
same finding independently. All 12 QEMU thread cases pass again.
2026-07-30 04:24:34 +01:00
Daniel Samson bc2eb67581 build: phase 3 — split image assembly and QEMU runners out of the root
build/images.zig owns everything between built binaries and a bootable
volume: the FHS zig-out install tree, boot manifest + capsule, both
FAT32 images, the release ISO, and their check steps. build/qemu.zig
owns run-x86-64 / run-x86-64-gpu and the OVMF probing. The root
build.zig (461 lines, from 1,242 pre-split) now only decides what
ships: kernel + loader, the package list, the bundled boot tree, and
the aggregate test step. The stale commented-out run scaffold is gone.
Boot-image file list unchanged; check-fat-image and check-iso-image
both green.
2026-07-30 04:11:22 +01:00