danos/docs/security-track-plan.md

33 KiB
Raw Blame History

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, H1H3, HS, P1P4) — the M-number sequence is left alone (M19M22 are reserved by the logging/USB-lifecycle track).

Status

Live state — updated on main after every phase, so this file read from a plain main checkout always tells the truth about where the work is.

Working on HS — SYSRET canonical-RIP guard
Branch carrying it feat/security-group-4 (pushed to origin)
On main everything through P4c — groups 1, 2 and 3 merged
Awaiting merge H2 — lands with the group 4 merge
Suite 112 cases, all passing
Last updated 2026-08-01

A checkbox below means the phase met its definition of green and was committed — on the branch named above, which reaches main at the next group boundary.

  • Phase 0 — baseline: suite green on main (106/106, 2026-07-31; zig build + zig build test clean at 9a32380), 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 /system subtrees, FAT's /var mount split in two; suite 106/106)
  • H1 — the user-memory module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an mmio_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; vfs NodeKind.protocol + open-reply-capability; client Channel (mechanics only, nothing converted; suite unchanged at 107)
  • P2 — registry in init; /protocol reserved; ServiceId flag-day (11 binds, 17 lookups; protocol.csv grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
  • P3 — open grants: protocol.csv enforcement, denial test. onOpen consults the manifest with the same chain-attested identity a bind uses, and a refused caller gets the same answer as one naming a contract nobody bound — -ENOENT, no capability, the same reply bytes, no log line, and both questions asked on every open so there is nothing to time. Twenty-seven open rows cover the whole live client set. One wrinkle the plan had not foreseen: the driver tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation is one hop, so a legitimate grandchild read exactly like a laundering deputy; the manifest gained a third permission, supervise, which names an authorized supervising task per contract and is deliberately open-only, leaving P2's bind attestation and every refusal it makes untouched (suite 109/109)
  • merge group 2 → main, push
  • P4a — clean protocols rebased onto Define (vfs, block, display, scanout, input; display's one overloaded request split per-operation and its field abuse ended, scanout's bogus 64-byte maximum deleted, directory EOF re-spelled as a nameless entry, input moved onto the service harness; new protocol-conformance case asks every reachable provider for describe and requires -ENOSYS for an undefined verb; suite 110/110)
  • P4b — misfit protocols rebased (device-manager, power, usb-transfer; every leading operation byte folded into the header, and with it the device_id/device_token that followed it — Header.target now carries the device in all three. device-manager's own enumerate/subscribe became the reserved verbs and its three {status, reserved} reply structs the envelope's Status; ChildAdded is one struct under two numbers, a call and an event, landing exactly on the 64-byte push floor. power's kinds became one declared event each, the input protocol's shape, so init reads what happened from the header; usb-transfer's control data stage moved to the packet tail in both directions, which made Status.len the transferred length and actual_length redundant. The two silent-breakage sites — init's byte-offset power parse and acpi's message[0] dispatch — are gone, the shutdown badge gate unchanged; three more rows in the conformance table. Suite 110/110)
  • P4c — harness subscriber lift + badge-scoped per-client integers (the subscriber table, the reserved subscribe/unsubscribe verbs, the fan-out and the dead-subscriber sweep are service.Subscribers now; input, acpi and device-manager deleted three hand-rolled variants and their three different ideas of when a subscriber goes away, standardizing on published exit notifications — acpi had no sweep at all and input polled the process list on every subscribe. The three guessable-id namespaces are scoped to the opening badge: FAT node ids on every verb that names one, xHCI device tokens on open, control, bulk and interrupt_subscribe, display layers on configure, fill, blit, damage and destroy — each refusing a wrong owner with the same answer as an id nobody holds. New badge-scope case, two processes of one fixture, every refusal paired with a control; suite 111/111)
  • merge group 3 → main, push
  • H2 — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; -cpu max added to the harness since QEMU's default model has neither bit; new fault-smep case; suite 112/112)
  • HS — SYSRET canonical-RIP guard
  • H3 — SMAP + boot-patched clac; -cpu max in 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.

  1. 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: ChildAdded re-lays to 60 bytes (its packed operation byte and device_id become Header.operation/.target); InterruptReport puts device_token in Header.target and 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.
  2. Bind/open authorization is chain-attested identity: the kernel-stamped binary name PLUS the supervision chain, both read from the kernel's process records (ProcessDescriptor carries name and supervisor; init walks the chain with process_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 (ungated spawn, 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.
  3. Grants live in a new manifest, /system/configuration/protocol.csv (rows: binary-path, supervisor, bind|open, protocol-name, where supervisor is the binary expected in the caller's supervision chain — init for init's own children, kernel for 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. (P2 spelling: the supervisor column carries the binary exactly as the kernel stamped it, so init's own children say /system/services/init and the drivers say /system/services/device-manager; kernel stays a bare word because a kernel task has no binary. A trailing * on any field matches a subtree, which is how decision 4's /test/ rule is expressed.) (Clarification, 2026-08-01: the supervisor column names the authorized supervising task, matched by identity — the binary is how the row spells it, but init checks the task id. kernel is satisfied only by supervisor id 0 (which only the kernel confers — user system_spawn always stamps the caller); init's own path only by this init's task id; any other path only by a task init spawned itself or one the kernel spawned. Matching the supervisor by name alone is defeated by a laundering deputy — an attacker runs its own instance of /system/services/init, has that spawn /system/services/input, and both stamped names satisfy the row while the chain is entirely the attacker's. Walking to the root of the chain does not fix it either, since the laundered chain still roots at the real PID 1.) (P3 amendment: a third permission, supervise, joins bind|open. One-hop attestation cannot express the one three-deep chain in the tree — the device manager starts the PS/2 bus, and the bus starts the keyboard and mouse drivers — and nothing structural tells that chain apart from the laundering deputy, since both are a granted binary spawned by a granted binary. Only policy can: a supervise row names the authorized supervising task the way every other row names a claimant (binary, its own supervisor, the contract it concerns), and an open row may then name that task in its supervisor column. The delegate is itself attested the ordinary strict way, so the chain still anchors in init or the kernel one hop above it and the recursion stops there. It is open-only on purpose — a delegate may vouch for what its children reach, never for what they claim — so the bind path is byte-for-byte P2's and the laundering-deputy refusal is untouched.)
  4. 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.
  5. Rebind after provider death: a bind hitting an existing binding succeeds only if the current owner process is dead (init checks liveness); otherwise -EBUSY. Init also unbinds in restartChild before respawning its own children. This preserves collision-refusal while making restart work for providers init does not supervise.
  6. 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.
  7. The envelope module is library/protocol/envelope/envelope.zig (module name envelope) — the one protocol-package module not ending in -protocol, because it is not a protocol. Wired as a new addModule row in library/protocol/build.zig with its host tests in that package's test step.
  8. The QEMU harness gains -cpu max (in qemu_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.
  9. 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. (P2 resolution: init gained a registry argv role — it mounts /protocol, reads the grants, and starts no services — and each affected case calls spawnRegistry(rd) before its own providers. Every case keeps its own spawn set, so no scenario had to be re-shaped.)
  10. The capsule-staleness caveat is documented, not fixed. On-volume edits to /system/configuration/*.csv do 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:

  1. 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).
  2. build.zig:309-311: bundled entries etc/...system/configuration/... (this alone re-shapes the image, manifest, and capsule — tools/make-fat-image.py and the EFI loader need nothing; the tree-walk fallback even starts picking the CSVs up, a bonus fix).
  3. system/kernel/vfs.zig mountBackend (:332-340): allow exactly /system/configuration and /system/logs as backend prefixes beneath the initrd /system mount; keep refusing everything else under /system and /test.
  4. system/services/fat/fat.zig: mount_point/volumes/usb (:25); replace the /var mount (:155) with two mountRewritten calls for /system/configuration and /system/logs; update the mount log lines (the harness matches them).
  5. system/services/init/init.zig:76 and system/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).
  6. system/services/logger/logger.zig:44: base = "/system/logs" (buffers derive from base.len comptime — nothing else changes).
  7. system/kernel/tests.zig:2808-2810: exclude /system/configuration/ from the spawn-everything sweep (the CSVs are not programs).
  8. Tests: fat-test.zig and vfs-test.zig /mnt/usb literals → /volumes/usb; harness regexes test/qemu_test.py:175,211,632,717.
  9. 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:

  • copyFromUser moves from ipc-synchronous.zig (which re-exports or imports it); new copyToUser(user_as, user_va, source) bool — the mechanical mirror (kernel-source copyAcross already does this for IPC replies at ipc-synchronous.zig:431,460).
  • The page walk gains leaf U/S and writable checks: paging.translateIn (architecture/x86_64/paging.zig:513-525) tests only present today — add a flags-accumulating variant (2 MiB leaves included); reads require U/S, writes require U/S+W. Closes the TODO at ipc-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 the fs_node flows through vfs.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 comptime Define(.{name, version, operations, events}) generating request/reply types, encode/decode, a provider dispatch table (automatic describe, -ENOSYS for unknown verbs), and compile-time size checks: request/reply ≤ 256, each .events entry ≤ 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 new Channel type in library/kernel (or library/client): open("/protocol/<name>") → resolve, vfs open, receive the reply capability → a Channel wrapping the handle with call/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 vfs open/readdir over /protocol plus the bind operation (name payload + capability). Mount /protocol before 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 (walk supervisor links) checked against the grant row's expected supervisor. Unbind on child death in restartChild; dead-owner rebind rule (decision 5). Provenance: readdir/diagnostics show name → pid → binary path.
  • Kernel: reserve /protocolmountBackend refuses mounts at or under it once bound, installMount's remount-replace path refuses it, and fs_unmount refuses it (vfs.zig:164-181,332-351, process.zig:1879-1889). First mount wins (init is PID 1).
  • Harness: library/kernel/service.zig Callbacks.service: ?abi.ServiceId becomes 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-664 and the registry sweep in :121-140; the wrappers library/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.csv open rows enforced in the registry's open handler, 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.

Landed. Four things the plan did not foresee, recorded because P4 and P5 inherit them:

  • supervise — decision 3's amendment. The PS/2 keyboard and mouse drivers are started by the PS/2 bus driver, which the device manager started: the tree's one three-deep chain, and one hop deeper than attestation reaches. Nothing structural separates it from the laundering deputy, so the manifest says which delegate is authorized, per contract. Open-only, so P2's bind attestation is unchanged.
  • Indistinguishability is a claim about work, not only about bytes. onOpen refreshes the process table, identifies the caller, scans the grants and scans the bindings on every open and forms one verdict at the end; and it logs nothing on any branch, because klog_read is ungated (a line written on one branch is a line the refused caller can read) and a serial line is milliseconds it could time. The operator's diagnosis is the pair the namespace publishes anyway: readdir /protocol for what is bound, the manifest for who may reach it.
  • The fixture is protocol-denied-test, and its scenario boots the input service so the forbidden name is genuinely bound — the fixture reads the namespace listing to prove it before asking for it. Without a live provider the case would be comparing two boot races and asserting nothing.
  • Two channels stay open by design, named rather than papered over: readdir over /protocol lists every bound name to anyone (deliberate — the tree is diagnosable), and /system/configuration/protocol.csv is world-readable on the /system mount. Stage one hides neither the set of contracts nor the policy; what it removes is the oracle in the reply, which is what stage two's parked and faked opens depend on.

P4a — clean protocols onto Define

vfs, block, display, scanout, input — the modules whose shapes map directly (grounding inventory §1,3,4,6,8):

  • vfs: nodetarget; Reply.node (open's result) moves to reply payload — library/kernel/file-system.zig decoders change; readdir stays a protocol verb.
  • block: pure renumber; attach's DMA cap rides the call as today.
  • display: the overloaded 40-byte Request becomes per-operation structs (attach_scanout's field abuse dies); layertarget; blit payload grows to 224 bytes.
  • scanout: renumber; drop its bogus message_maximum=64 (sync floor is 256); fix virtio-gpu's hard-coded service.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; ChildAdded splits 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's message[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_tokentarget (already layout-identical); InterruptReport re-lays onto the Header (device_tokentarget, inline data trimmed 48 → 40 — largest real report is 8); control/bulk budgets re-verified by Define (Status absorbs actual_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.

Landed. Three judgment calls the plan left open, recorded because a reader of the wire formats will want them:

  • ChildAdded is 48 bytes, not the 44 the "60 B" estimate assumed: three u64s give the struct eight-byte alignment, so 41 bytes of content round up whatever order the fields sit in. The packet is therefore exactly 64 — on the push floor, not under it — which Define accepts and the module pins in a test. The fields are ordered small-tail-last deliberately, so the slack the rounding pays for is where the small ones live.
  • power's events are declared per kind (power_button, lid, ac, battery, notify), not one event with the kind in the payload. That is the shape P4a gave input — "the class is the header's operation, so a subscriber reads the kind from the packet rather than from a tag inside the payload" — and it is what makes Header.operation carry information here at all. It also kept every call site's spelling: Protocol.Event is re-exported as the protocol's own Event, with the members it always had.
  • the conformance case still checks two providers, and the three new rows report as unbound. All three P4b contracts arrive with the device manager — it is the first, it spawns the discovery service that binds the second, and the xHCI driver that binds the third — so booting one means booting the driver tree, and the fixture takes one snapshot of /protocol: a scenario whose bound set depends on how far that tree got would make the case's own summary line a boot race. The rows still earn their place — a future scenario that binds one gets it checked with no edit here, and /test/* already holds the open grant for device-manager.

P4c — harness subscriber lift + badge scoping

  • library/kernel/service.zig grows the subscriber table, exit- notification sweep, and fan-out loop declared via Define(.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.

Landed. Four things the plan had not foreseen:

  • One sweep idiom means one more kernel subscriber per provider, and the kernel's published-exit table held eight. A normal boot now fields six (fat, input, power, device-manager, display, and one per xHCI controller), so the table grew to sixteen. It is not a table anyone notices until a service silently loses its sweep, which is exactly the failure the old ceiling was two subscriptions away from.
  • The device manager hears each of its drivers die twice now — it is both the supervisor its spawn named and, through the harness, a subscriber to published exits — and the notify ring delivers the two badges separately. Untreated, one death counted as two: the restart backoff doubled and the crash-loop cap fired at half the deaths it names. onDriverExit therefore retires the dead process id before it decides anything, and the second notification finds nothing to act on. (The driver-restart and pci-scan drills are what would have caught it.)
  • Refusal-equals-absence has a corollary for the verbs that release: FAT's close used to answer 0 for an unknown node, so scoping it had to change that too — a foreign node and a free one both answer -ENOENT, or the pair would have been an oracle for which ids are live. The same applies to the harness's unsubscribe.
  • Ownership is per task, not per process, because the badge is: the kernel stamps the sending thread's id, which is already the granularity of the exit sweep that releases the state (a worker thread's death releases the handles that worker opened). Nothing in the tree shares an id across its own threads today; a per-process notion would need the kernel to stamp the leader, and belongs with P5's spawner-wired namespaces if it is ever wanted.

H2 — SMEP

  • Generalize the cpuid helper (apic.zig:351-365, private, subleaf-0) to a shared probe; gate on cpuid(0).eax >= 7.
  • Set CR4 bit 20 in per-cpu.zig:initSystemCall (or a sibling called from both cpu.zig:148 and smp.zig:181 — the one path both BSP and every AP already execute). Log enabled/absent (fail-open, IOMMU style).
  • Harness: add -cpu max to qemu_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.s syscall exit (:256): validate RCX canonicality before sysretq; non-canonical → iretq fallback (or kill), per the hazard note at isr.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

  • clac patch site at isr_common (isr.s:367, before the CPL test — ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch to clac at boot through the physmap (the process.zig:1990-1995 / smp.zig:79-111 precedent), 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; no stac anywhere, 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.