Design Note: Gating Introspection Behind a Capability (§3.1)

Status: BUILT. InspectKernel and TaskStat are gated by the INTROSPECT capability (INTROSPECT_RESOURCE, kernel/src/capability/mod.rs), and the adversarial suite pins the denial (A11, introspection_denied_without_cap).

(This said "approved, not yet implemented" after it had been implemented AND pinned by a test.) Date: 2026-06-03 Pins: §3.1 (no ambient authority), §3.3 (authority is explicit), invariant 1, §26.9 (authority stays visible and scoped).


1. Problem

The two kernel introspection syscalls were ambient - any task could call them holding no capability:

  • InspectKernel (syscall 13) - alloc bytes, live-endpoint count, frame counts, per-core ticks, core count, endpoint generation by name, TSC.
  • TaskStat (syscall 16) - full per-task snapshot for any scheduler slot: name, core, state, memory used/limit, queue depth, restart generation.

handle_task_stat said so explicitly in source: "No capability required - read-only kernel state." This was a deliberate convenience for the §22 property/perf harness, which calls these from many probe instances.

But it is a standing exception to the constitution. Invariant 1 (§3.1) is "no ambient authority - every privileged action requires an explicit capability," and kernel/src/syscall/CLAUDE.md states the rule with "there are no exceptions." Enumerating every task's name, memory, and restart count, or probing another named service's lifecycle generation, is information-disclosure authority - a confidentiality boundary the capability model is supposed to mediate. Today it is not mediated. This note closes that gap.

It surfaced while deciding the architecture of the observe utility (utilities/1_observe.md §7): observe is a least-authority service, but "least authority" is hollow if the authority it needs (read system metrics) is something every service already has for free.


2. Decision summary

#DecisionChoice
1What to gateDisclosure of another task's or system-wide state requires the cap; reading only your own state or a hardware clock stays ambient.
2MechanismA holds-resource check - the kernel verifies the calling task holds INTROSPECT_RESOURCE (READ) - not a passed cap-slot.
3DenialReturn a distinct denied code (CapNotHeld); SDK wrappers are unchanged (legitimate holders never see it).

3. Scope - what is gated, what stays ambient

Rule: disclosing another task's or system-wide state requires INTROSPECT; reading only your own state, or a hardware clock, does not.

Gated (require INTROSPECT_RESOURCE with Rights::READ)

Syscall / queryDiscloses
TaskStat (16)any task's full snapshot
InspectKernel 1live endpoint count (system)
InspectKernel 2another named endpoint's generation
InspectKernel 4free physical frames (system)
InspectKernel 5total physical frames (system)
InspectKernel 6a core's active ticks (system)
InspectKernel 7a core's total ticks (system)
InspectKernel 8ready core count (system)
InspectKernel 24the endpoint a given task owns
InspectKernel 25the endpoint a given task is blocked in Call awaiting
InspectKernel 26the count of fault diagnostics emitted without the serial lock
InspectKernel 27the boot record (the first 32 KiB ever logged; CLAUDE.md 11.4)

Ambient (no capability)

Syscall / queryWhy it stays open
InspectKernel 0the caller's own allocated bytes - its own state
InspectKernel 3the cycle counter - a hardware clock, not anyone's state
InspectKernel 13whether the CALLER owns the console foreground - caller-specific, like 0
InspectKernel 10, 11, 12, 16, 17board-neutral timing: input-ready, the RTC now/boot reads, TSC-per-quantum, deglitched monotonic seconds
InspectKernel 14, 15, 18, 19, 20, 21, 23board facts and transport: NIC identity and BAR, driver-presence bits, a hardware random word, the EMMC base clock, one byte off the COM2 operator channel, the board's own MAC
InspectKernel 28the pending power-off or reboot - system-wide, but ungated deliberately so a pending shutdown can never be hidden (docs/shutdown.md 6)

Queries 9 and 22 were deleted (console geometry moved to the console service, clock provenance to time) and are no longer in the ungated list, so like any unknown query they answer CapNotHeld to a task without the cap and -1 to a holder.

The gate is a single matches! on the ungated set in handle_inspect_kernel, so a query added without thought lands on the gated side - the right default. The line is chosen so the migration cost is identical to a narrower one (the same services need the cap regardless), making the complete version free: gate the whole cross-task and system surface, keep self-state, the clock and task-neutral board facts open.


4. Mechanism - holds-resource gate, not a cap-slot

Why not the existing slot pattern

Every gated syscall today (Log, Spawn, ConsoleRead, ConsolePush) passes a cap_slot argument and the handler does current_task_lookup_cap(slot, right) + resource_id check. That does not fit here: TaskStat (slot / buf_ptr / buf_len) and InspectKernel query 2 (query_id / name_ptr / name_len) already consume all three ABI argument registers. There is no spare slot to pass a cap-slot without adding a fourth ABI argument (which would touch the asm syscall entry).

The gate

Add a stable kernel resource:

#![allow(unused)]
fn main() {
// kernel/src/capability/mod.rs
/// Introspection authority - read another task's or system-wide kernel state
/// via InspectKernel (13, system queries) and TaskStat (16). Self-state queries
/// (own alloc bytes, TSC) remain ungated. Gating prevents an arbitrary service
/// from enumerating every task's name/memory/restart count (§3.1).
pub const INTROSPECT_RESOURCE: ResourceId = ResourceId(5);
// ... register_resource(INTROSPECT_RESOURCE) in init()
}

Each gated handler, before doing anything, checks that the calling task holds INTROSPECT_RESOURCE with READ:

#![allow(unused)]
fn main() {
if !scheduler::current_task_holds_resource(INTROSPECT_RESOURCE, Rights::READ) {
    return cap_err_to_i64(CapError::CapNotHeld);
}
}

This needs one new scheduler/CapTable helper - current_task_holds_resource(rid, right) -> bool - that scans the calling task's cap table for a live cap on rid carrying right. (The cap table already supports per-slot iteration via for_each_active_cap.)

Deviation, documented

This is a holds-resource check rather than the documented CapTable::get(slot, right) slot check. The intent of the §3.1 rule - validate a capability before performing the privileged action - is fully satisfied; only the calling convention differs, because read syscalls that consume all argument registers have no slot to pass. kernel/src/syscall/CLAUDE.md will be updated to record this as the sanctioned form for argument-saturated read syscalls.

SDK is unchanged

Because the gate reads the task's holdings rather than a passed slot, the SDK introspection wrappers (inspect_*, task_stat) keep their exact signatures and bodies. No caller code changes except declaring the capability (§6).


5. Denial semantics

A denied call returns cap_err_to_i64(CapError::CapNotHeld) - a distinct negative, separable from the existing -1 "not found / invalid args" the inspect handlers already use.

The three legitimate callers (§6) all hold the cap, so they never observe denial; denial only bites an undeclared or adversarial service. The current SDK wrappers coerce a negative return to a default (0, or valid: false for task_stat), which means denial is, today, quiet. We accept that for now because no legitimate caller hits it.

Follow-up (logged, out of scope here): "loud over silent" (§26.7) argues for making the introspection wrappers fallible (Result/Option) so a denied call is surfaced rather than coerced to a default. Deferred - it is SDK API churn with no current consumer, and would be pulled into existence by the first service that must distinguish "denied" from "zero."


6. Migration

The grant travels in the spawn request's privilege word (privbits::INTROSPECT), and the kernel mints the cap only if the SPAWNER holds the same authority itself (privileges_caller_lacks). It began as a name match in the kernel's own service_privileges, which was a hole once probe names became caller-supplied - "call yourself observe-x and get introspection". The decision is still made by name, but in the supervisor (probes::privileges_of), by a principal that holds INTROSPECT and may pass it on; the kernel no longer infers authority from a string. The services that legitimately read cross-task/system state:

ServiceNeeds the cap because it callsNotes
shelltask_stat (status), inspect_core_count (cores)privileged broker already
observetask_stat + aggregatesthe metrics utility itself
probeinspect_endpoint_generation (query 2)test harness; query 0 + TSC stay ambient so most probe paths are unaffected

Since written, chaos, control, power, ask and ask-net hold it too (their rows in the supervisor's spawn table, services/supervisor/src/main.rs), so the table above is the original three, not the full list.

The kernel mints INTROSPECT_RESOURCE (READ) into the cap table of each task whose spawn request carries privbits::INTROSPECT, at spawn (§14.1). The grant lands on exactly the probes that need query 2 and on no others: the prop- and stress- drivers read their victims' generations, while adv-a11 - whose whole subject is a service WITHOUT the cap - is excluded by name, which is why it cannot be "every probe".


7. Constitutional & doc impact

  • Not an amendment - an alignment. This removes a standing exception to §3.1; it strengthens conformance rather than changing an invariant, so no CLAUDE.md invariant change is needed. (A one-line note may be added to the §7 capability section listing INTROSPECT_RESOURCE among the stable resources.)
  • kernel/src/syscall/CLAUDE.md: add INTROSPECT_RESOURCE to the syscall table and document the holds-resource form for argument-saturated read syscalls.
  • audits/unsafe-audit.md: unaffected - no new unsafe (the gate is safe Rust).
  • utilities/1_observe.md §7/§8: update from "introspection is currently ambient (verify)" to "introspection is gated by INTROSPECT_RESOURCE; observe's contract declares it," restoring the accuracy of observe's least-authority story.

8. Testing

  • Regression (must stay green): the full §22 suite - identity, property (P2/P4/P5/P8 use the gated/ambient queries), perf (TSC stays ambient), stress - with the three services now holding the cap. A pass proves the gate is transparent to legitimate holders.
  • New adversarial test (the property this unlocks): a service that does not declare introspect calls task_stat / a gated InspectKernel query and is denied (CapNotHeld), while a self query (own alloc bytes) and TSC still succeed. This is the first test that can assert introspection is not ambient - it was impossible to write before. Fits the §22 Adversarial (A-series) bar.

9. Implementation checklist

  1. capability/mod.rs: add INTROSPECT_RESOURCE = ResourceId(5) + register it.
  2. task/scheduler.rs (+ capability/table.rs as needed): current_task_holds_resource(rid, right) -> bool.
  3. syscall/dispatch.rs: gate handle_task_stat unconditionally, and gate handle_inspect_kernel by listing the UNGATED query ids - so anything new is gated by default (§3).
  4. task/mod.rs: carry the grant in the spawn request's privilege word (privbits::INTROSPECT), refuse any bit the spawner does not hold itself, and mint the cap at spawn for the services whose row asks for it.
  5. Docs: update syscall/CLAUDE.md, the §7 capability list, and utilities/1_observe.md §7/§8.
  6. Build (osdev image), run the identity/property/perf/adversarial suites; add the new "introspection denied without cap" adversarial test.
  7. Hardware sanity on the T630 if the suite passes in QEMU.

All seven are done, and observe was built on top of them - a least-authority service whose introspection authority is one declared capability.