21 KiB
Security track execution plan: paths, protocol namespace, SMEP/SMAP
The design is settled in communication.md, protocol-namespace.md, file-system-hierarchy.md, and smep-smap.md. This file is the build order — one phase at a time, each phase green before the next starts. Delete or archive this file when the last milestone lands.
Context a fresh session should read first: the four design docs above, then this plan's Settled decisions section — those decisions came out of a full-code grounding pass (2026-07-31) and must not be re-derived or reopened.
Definition of green, every phase: zig build clean, zig build test
clean, python3 test/qemu_test.py passes (existing scenarios plus the
phase's new ones — record the suite count in the checkbox), and the relevant
design doc's status/known-gap lines updated in the same commit. Commit per
green phase, style area: lower-case declarative summary, no co-author
trailers. On a suite failure, read
zig-out/qemu-test/<case>-failed-serial.log before changing anything.
Workflow: work in a dedicated git worktree on feature branches cut from
main (one branch per milestone group as marked below); when a group's
phases are all green, merge to main and push. The loop marks a phase [x]
in the same commit that lands it.
Numbering note: milestones use the design docs' own names (PM, H1–H3, HS, P1–P4) — the M-number sequence is left alone (M19–M22 are reserved by the logging/USB-lifecycle track).
Status
- Phase 0 — baseline: suite green on
main(106/106, 2026-07-31;zig build+zig build testclean at9a32380), plan committed - PM — path-migration flag-day (
/etc→/system/configuration,/var/log→/system/logs,/mnt/usb→/volumes/usb; vfs carve-out for the two writable/systemsubtrees, FAT's/varmount split in two; suite 106/106) - H1 — the
user-memorymodule; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so anmmio_map'd buffer cannot fault ring 0 — this also closes the same hazard on the IPC path;fs_resolve's out-capacity bound made overflow-safe; suite 107/107) - merge group 1 → main, push (
f3bc23c, 2026-07-31) - P1 — envelope module +
Define; vfsNodeKind.protocol+ open-reply-capability; clientChannel(mechanics only, nothing converted; suite unchanged at 107) - P2 — registry in init;
/protocolreserved; ServiceId flag-day (11 binds, 17 lookups) - P3 — open grants:
protocol.csvenforcement, denial test - merge group 2 → main, push
- P4a — clean protocols rebased onto
Define(vfs, block, display, scanout, input) - P4b — misfit protocols rebased (device-manager, power, usb-transfer)
- P4c — harness subscriber lift + badge-scoped per-client integers
- merge group 3 → main, push
- H2 — SMEP on every core
- HS — SYSRET canonical-RIP guard
- H3 — SMAP + boot-patched
clac;-cpu maxin the harness; negative tests - merge group 4 → main, push
Settled decisions (grounding pass, 2026-07-31 — do not reopen)
These resolve every open wrinkle the code inventory surfaced. Where one amends a design doc, the amendment lands in the same commit as the phase that implements it.
- Every packet — request, reply, and event — begins with the envelope
Header, exactly as the design says; the header is FOLDED, never stacked. It absorbs each protocol's existing operation/id fields rather than sitting on top of them, so the two apparent 64-byte-limit offenders fit:ChildAddedre-lays to 60 bytes (its packed operation byte anddevice_idbecomeHeader.operation/.target);InterruptReportputsdevice_tokeninHeader.targetand trims inline data 48 → 40 bytes (largest real report today is 8). A headerless-events variant was considered and REJECTED (2026-07-31): it re-invents per-protocol mini-headers and breaks uniform tooling. No design-doc amendment;Define's event check stays ≤ 64 including the header. - Bind/open authorization is chain-attested identity: the
kernel-stamped binary name PLUS the supervision chain, both read from
the kernel's process records (
ProcessDescriptorcarriesnameandsupervisor; init walks the chain withprocess_enumerate— no new protocol). A grant row names the binary and the supervisor expected in its chain, so a malicious process re-spawning a granted binary (ungatedspawn, hostile argv — the confused deputy) is refused: its chain roots at the attacker, not at init or device-manager. Name alone is NOT sufficient — that was considered and rejected (2026-07-31). Pure delegation (device-manager forwarding driver binds as capabilities — "option B") is deliberately deferred to P5, whose spawner-wired namespaces subsume it. Amends protocol-namespace.md's "Authorization" bullet in P2. - Grants live in a new manifest,
/system/configuration/protocol.csv(rows:binary-path, supervisor, bind|open, protocol-name, wheresupervisoris the binary expected in the caller's supervision chain —initfor init's own children,kernelfor harness-spawned fixtures), not in extra init.csv columns — today every post-path init.csv field is argv, and overloading that is ambiguous. init parses both files. - Test fixtures bind under
/protocol/test/..., granted to any binary whose path starts/test/— the subtree-scoping rule from the design doc, dogfooded.shared_memory_test(the borrowed-ServiceId hack) becomes/protocol/test/shared-memory; process-test's child gets/protocol/test/process. - Rebind after provider death: a
bindhitting an existing binding succeeds only if the current owner process is dead (init checks liveness); otherwise-EBUSY. Init also unbinds inrestartChildbefore respawning its own children. This preserves collision-refusal while making restart work for providers init does not supervise. - Cross-thread service access (the display mouse-listener's
per-thread self-lookup,
display.zig:512): threads resolve and open/protocol/<name>like any client — once, at thread startup. No special mechanism. - The envelope module is
library/protocol/envelope/envelope.zig(module nameenvelope) — the one protocol-package module not ending in-protocol, because it is not a protocol. Wired as a newaddModulerow inlibrary/protocol/build.zigwith its host tests in that package's test step. - The QEMU harness gains
-cpu max(inqemu_args,test/qemu_test.py:66-83) so TCG exposes SMEP/SMAP — without it the enabled paths never execute in CI. Landed in H2 so the flag soaks before H3 depends on it. - Scenario fixtures that need the registry are init-driven. Kernel test cases that today spawn providers directly (shared-memory, process-test) either spawn init first or move to init.csv-driven scenario boots — resolved per-case in P2 with the suite as the arbiter.
- The capsule-staleness caveat is documented, not fixed. On-volume
edits to
/system/configuration/*.csvdo not reach the initrd copy the loader boots (capsule shadows tree). Same drift exists today with/etc; PM adds the note to file-system-hierarchy.md and moves on.
PM — path-migration flag-day
One commit, everything moves together. The authoritative site inventory is the grounding pass; the checklist order:
- Move repo
etc/→configuration/sources; fix the three CSVs' self-referencing headers (etc/init.csv:1,12,etc/devices.csv:1,etc/init-diagnose.csv:1). build.zig:309-311: bundled entriesetc/...→system/configuration/...(this alone re-shapes the image, manifest, and capsule —tools/make-fat-image.pyand the EFI loader need nothing; the tree-walk fallback even starts picking the CSVs up, a bonus fix).system/kernel/vfs.zigmountBackend(:332-340): allow exactly/system/configurationand/system/logsas backend prefixes beneath the initrd/systemmount; keep refusing everything else under/systemand/test.system/services/fat/fat.zig:mount_point→/volumes/usb(:25); replace the/varmount (:155) with twomountRewrittencalls for/system/configurationand/system/logs; update the mount log lines (the harness matches them).system/services/init/init.zig:76andsystem/services/device-manager/device-manager.zig:48: open the new CSV paths; update the message strings (init.zig:77,92,device-manager.zig:49,61-63,454).system/services/logger/logger.zig:44:base = "/system/logs"(buffers derive frombase.lencomptime — nothing else changes).system/kernel/tests.zig:2808-2810: exclude/system/configuration/from the spawn-everything sweep (the CSVs are not programs).- Tests:
fat-test.zigandvfs-test.zig/mnt/usbliterals →/volumes/usb; harness regexestest/qemu_test.py:175,211,632,717. - Comment sweep (init, device-manager, logger, fat, engine, vfs, abi,
file-system, csv, device, protocol/device-manager, drivers, acpi,
build.zig — full list in the grounding inventory); delete vestigial
repo
var/.
Test: no new case — the existing 106 are the test, since fat/logger/ init/device-manager scenarios all assert the new paths through their regexes. Suite stays 106.
H1 — user-memory copy discipline
New kernel module system/kernel/user-memory.zig:
copyFromUsermoves from ipc-synchronous.zig (which re-exports or imports it); newcopyToUser(user_as, user_va, source) bool— the mechanical mirror (kernel-sourcecopyAcrossalready does this for IPC replies atipc-synchronous.zig:431,460).- The page walk gains leaf U/S and writable checks:
paging.translateIn(architecture/x86_64/paging.zig:513-525) tests onlypresenttoday — add a flags-accumulating variant (2 MiB leaves included); reads require U/S, writes require U/S+W. Closes the TODO atipc-synchronous.zig:20-22. - Convert the nine stragglers (table in smep-smap.md). Read direction is
local to
process.zig; the write direction restructures callees with kernel bounce buffers:scheduler.enumerate(scheduler.zig:1209),devices_broker.enumerate(devices-broker.zig:136),log.readAt(log.zig:209), and thefs_nodeflows throughvfs.nodeRead/nodeStatus/nodeReaddir(vfs.zig:257/269/289).
Test: kernel unit coverage in system/kernel/tests.zig for
copyToUser bounds/permission refusals; one new QEMU case user-memory —
a fixture passes an unmapped-but-in-range buffer to klog_read,
process_enumerate, and fs_resolve and asserts -EFAULT returns with
the system still alive (today each would oops the kernel). Suite 107.
P1 — envelope, vfs additions, Channel
library/protocol/envelope/envelope.zig:Header{operation:u32, pad, target:u64},Status, reserved verbs (describe=0, enumerate=1, subscribe=2, unsubscribe=3, protocol verbs from 16),packet_maximum= 256 /post_maximum= 64 (the floor constants protocols compile against — nothing exports them today), and comptimeDefine(.{name, version, operations, events})generating request/reply types, encode/decode, a provider dispatch table (automaticdescribe,-ENOSYSfor unknown verbs), and compile-time size checks: request/reply ≤ 256, each.eventsentry ≤ 64 including its Header (decision 1). Host unit tests in the protocol package's test step.library/protocol/vfs/vfs-protocol.zig:NodeKind.protocol = 7; the open-reply-may-carry-capability convention documented in the module. Rewrite the value-pinning unit test (:108-117) to pin the new stable values.library/kernel/file-system.zig+ a newChanneltype inlibrary/kernel(orlibrary/client):open("/protocol/<name>")→ resolve, vfs open, receive the reply capability → aChannelwrapping the handle withcall/typed helpers. Nothing uses it yet — P2 converts the world.- Docs: vfs-protocol.md's NodeKind table gains value 7 (no protocol-namespace.md amendment — decision 1 conforms to it as written).
Test: host unit tests only (envelope round-trips, size-check compile
errors via error tests, Channel plumbing against a mock). Suite stays
107.
P2 — the registry; ServiceId flag-day
The single biggest phase; one branch, may be several commits, green at the end of each.
- init as registry backend (
system/services/init/init.zig): a second endpoint (the supervision endpoint's reply-empty loop is unsuitable for a vfs backend); serve vfsopen/readdirover/protocolplus thebindoperation (name payload + capability). Mount/protocolbefore spawning children. Parse/system/configuration/protocol.csv(decision 3). Authorization by chain-attested identity (decision 2): badge → kernel process records → binary name and supervision chain (walksupervisorlinks) checked against the grant row's expected supervisor. Unbind on child death inrestartChild; dead-owner rebind rule (decision 5). Provenance: readdir/diagnostics show name → pid → binary path. - Kernel: reserve
/protocol—mountBackendrefuses mounts at or under it once bound,installMount's remount-replace path refuses it, andfs_unmountrefuses it (vfs.zig:164-181,332-351,process.zig:1879-1889). First mount wins (init is PID 1). - Harness:
library/kernel/service.zigCallbacks.service: ?abi.ServiceIdbecomes a protocol name; the register call (:49-51) becomes bind-with-retry via the registry. - Flag-day conversion — all 11 registration sites and 17 lookup sites
from the grounding inventory: providers (input:123, ps2-bus:223,
device-manager:569, acpi:193, usb-xhci-bus:676, usb-storage:205,
fat:307, display:699, virtio-gpu:550, shared-memory-server:43,
process-test:130 →
/protocol/test/...per decision 4); clients (input-client:53, display-client:28, driver.zig:173, usb.zig:139, block.zig:72+87, ps2-bus keyboard:35 + mouse:34, virtio-gpu:478, display:314+512 (decision 6), acpi:212, init:218+245 — init short-circuits its own registry, shared-memory-client:22, process-test:85, device-list:22, crash-test:32). Retry loops keep their cadence, wrapping resolve+open instead of lookup. - Delete:
abi.zig:36-37(syscall ids — leave holes),abi.zig:287-303(enum),process.zig:223-224,314-343,ipc-synchronous.zig:41-43,646-664and the registry sweep in:121-140; the wrapperslibrary/kernel/ipc.zig:33-35,47-50; comment sweep (irq.zig:50, tests.zig:3744, vdso.md's syscall table, the docs list in the inventory). - Kernel-spawned test scenarios made init-driven where they need the registry (decision 9).
Test: new QEMU case protocol-registry: a fixture asserts (a) bind of
an ungranted name → -EPERM, (b) bind collision with a live owner →
-EBUSY, (c) provider kill → re-resolve reaches the restarted instance.
Every existing scenario doubles as conversion proof. Suite 108.
P3 — open grants (restriction stage one)
protocol.csvopenrows enforced in the registry'sopenhandler, same name-based identity as bind. Default rows grant what today's clients need (from the P2 conversion table); a deliberate hole for the test fixture.- Docs: protocol-namespace.md stage-one section gets its "landed" line.
Test: new QEMU case protocol-denied: a fixture granted
/protocol/test/shared-memory but not /protocol/display asserts open of
the first succeeds and the second fails identically to not-found. Suite
109.
P4a — clean protocols onto Define
vfs, block, display, scanout, input — the modules whose shapes map directly (grounding inventory §1,3,4,6,8):
- vfs:
node→target;Reply.node(open's result) moves to reply payload —library/kernel/file-system.zigdecoders change; readdir stays a protocol verb. - block: pure renumber;
attach's DMA cap rides the call as today. - display: the overloaded 40-byte
Requestbecomes per-operation structs (attach_scanout's field abuse dies);layer→target; blit payload grows to 224 bytes. - scanout: renumber; drop its bogus
message_maximum=64(sync floor is 256); fix virtio-gpu's hard-codedservice.run(256, …)to the generated constant. - input: subscribe merges into reserved subscribe; publish renumbers; the event re-lays onto the Header folded (operation = event kind, target = 0; 16 + 28-byte payload = 44 ≤ 64); input moves onto the service harness (it is the last hand-rolled loop, no ping/terminate compliance today).
Test: new QEMU case protocol-conformance: a fixture opens every
registered protocol and asserts describe answers (name, version) and an
unknown verb returns -ENOSYS. Existing input/display/fat scenarios prove
the rebase. Suite 110.
P4b — misfit protocols onto Define
device-manager, power, usb-transfer (inventory §2,5,7 — the u8-operation re-layouts and raw-offset readers):
- device-manager: u8 operations → Header; its enumerate=4/subscribe=5
merge into the reserved verbs;
ChildAddedsplits its dual role — request struct and event, both Header-first (folded to 60 B ≤ 64);ChildRemoved's (parent, bus_address) addressing stays payload. - power: u8 operations → Header; subscribe merges; init's raw
byte-offset event parsing (
init.zig:171-173) and acpi'smessage[0]dispatch (acpi.zig:435-467) are rewritten against the generated types — the two silent-breakage sites, called out so the loop treats them as first-class conversions, not collateral. - usb-transfer:
device_token→target(already layout-identical);InterruptReportre-lays onto the Header (device_token→target, inline data trimmed 48 → 40 — largest real report is 8); control/bulk budgets re-verified byDefine(Status absorbsactual_length).
Test: existing scenarios are the proof (device hot-add, power button, USB storage/HID all exercise these wires); the conformance case now covers three more providers. Suite 110.
P4c — harness subscriber lift + badge scoping
library/kernel/service.ziggrows the subscriber table, exit- notification sweep, and fan-out loop declared viaDefine(.events); input (:33-116), acpi (:67-68,393-406), and device-manager (:155-166) delete their hand-rolled variants. One sweep idiom: exit notifications (fat's pattern), replacing input's process-list polling and acpi's none-at-all.- Badge-scoped per-client integers (the guessable-id holes): fat node ids
gain owner checks on every operation (
fat.zig:72-76), xhci device tokens validate sender and sweep on exit (usb-xhci-bus.zig:66-88,479), display layers gain an owner field.
Test: extend the fat scenario: a second fixture guesses the first's node id and asserts refusal; kernel-side unit test for the harness sweep. Suite 111.
H2 — SMEP
- Generalize the cpuid helper (
apic.zig:351-365, private, subleaf-0) to a shared probe; gate oncpuid(0).eax >= 7. - Set CR4 bit 20 in
per-cpu.zig:initSystemCall(or a sibling called from bothcpu.zig:148andsmp.zig:181— the one path both BSP and every AP already execute). Log enabled/absent (fail-open, IOMMU style). - Harness: add
-cpu maxtoqemu_args(decision 8).
Test: new QEMU case fault-smep mirroring the fault-* injector
pattern (tests.zig:3906-3938): ring-0 call through a pointer into a
user-mapped page; expect page fault (vector 14) + error code : 0x11 +
kernel-half IP, machine reports the exception (deliberate-exception cases
put the text in expect, per qemu_test.py:189). Suite 112.
HS — SYSRET canonical-RIP guard
isr.ssyscall exit (:256): validate RCX canonicality beforesysretq; non-canonical →iretqfallback (or kill), per the hazard note atisr.s:192-194.
Test: kernel unit case driving a thread whose return RIP is forged non-canonical via the syscall path if constructible cheaply; otherwise the review-level proof plus the existing fault cases regression. Suite 112.
H3 — SMAP
clacpatch site atisr_common(isr.s:367, before the CPL test — ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch toclacat boot through the physmap (theprocess.zig:1990-1995/smp.zig:79-111precedent), BSP-only before AP bring-up.- Set CR4 bit 21 in the same per-CPU init as SMEP.
- Coding standards: kernel code touches user memory only through
user-memory; nostacanywhere, ever.
Test: new QEMU case fault-smap: ring-0 deliberate read of a mapped
user page; expect vector 14 + error code : 0x1 + kernel IP. And the
whole suite becomes the tripwire — any missed straggler now fails loudly.
Suite 113.
Explicitly out of scope (own tracks, after this plan): P5 restriction
stage two (spawn's initial capability, namespace views, parked replies,
dedicated killable channels — needs a design session on the spawn
contract), file-path namespacing, trusted UI (display track), pipes/FIFOs
(Python track), /applications and its storage, fs_mount/spawn/
klog_read gating beyond the /protocol reserved prefix, KPTI, IPC
priority inheritance.