Skip to content

A move names the rights it delivers: a ring and a manifest's SysCap stay where they were given - #1204

Merged
Japabu merged 4 commits into
mainfrom
handle-model-security
Nov 11, 2026
Merged

Japabu merged 4 commits into
mainfrom
handle-model-security

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Two of the open severity: [security] handle-model defects, both read against main and true there:

  • A moved handle was always re-movable. Both moves, SYS_MESSAGE_SEND and a spawn's endowment, needed TRANSFER on the handle and carried its rights unchanged, so everything that arrived by a move could be moved on. "You may map this and nobody else may" had no spelling. soundserver's per-client ring was re-sendable by its client, and every manifest-granted SysCap arrived carrying TRANSFER because the supervisor could not endow it otherwise.
  • A ring handed to another process resolved its watches in the receiver's table. An inbox handle carried TRANSFER; its page is mapped into its creator alone and inbox_submit resolves a watch's handle in the caller's table.

Resulting behavior

  • A moved handle is a TransferEntry { handle, rights } on both paths: SYS_MESSAGE_SEND takes [TransferEntry], and a spawn's endowment is a SpawnEndow carrying one. The word is the mover's own set (TransferEntry::UNCHANGED) or a subset. A set the handle lacks is PermissionDenied, a word that is no set InvalidArgument, with nothing moved. HandleTable::rights_to_move decides it for both; a send the connection refuses gives each handle back with the rights it had.
  • EndowEntry stays what a child reads back; SpawnEndow is the request, which the kernel decodes once as that type.
  • A manifest's SysCap arrives with exactly its row's rights. syscap_rights no longer adds TRANSFER, and the supervisor endows a duplicate narrowed to the set. Only dup lets a capability leave its program, and it is now DUP|TRANSFER: handing a cap to a child takes both.
  • SDK: Moved is a handle and what it arrives with; any OwnedHandle converts unchanged and OwnedHandle::arriving_with narrows. Connection::send_moved and toyos::spawn::Spawn::endow take it; std has CommandExt::endow_narrowed. SharedMemory::lend is a region for one peer (MAP|WRITE): soundserver's ring, whose bell arrives READ|WAIT.
  • An inbox handle carries no TRANSFER, so a ring never leaves the process that made it. Nothing in the tree moved one.
  • The loader's boot names, which the supervisor only maps, arrive MAP alone.

Decisions worth a reviewer's eye

  • The manual changed (docs/src/architecture/authority.md): its bullet said "a move carries the rights unchanged" while its own diagram said "unchanged or narrowed". It now says a move may narrow, that moving on is itself a right, that an inbox is not moved, and that nothing takes a handle back from a running process, the ruling whose only home was the record this closes.
  • dup now means DUP|TRANSFER. With TRANSFER gone from every other set, a row naming dup could duplicate its cap and move the copy nowhere. The test estate is its only holder.
  • CommandExt::endow_narrowed takes the rights as bits and panics on a bit no right uses. std's toyos-abi is the sysroot's and not its caller's, so the type cannot cross.
  • A launch's job goes with the rights it has. A launch asked to narrow it is refused Invalid: the job travels in a message to the launcher, and nothing needs it narrowed.
  • send_moved stays beside the *_with sends. Taking impl Into<Moved> in the six was tried first and stops existing callers inferring (lib/blockserve/src/sessions.rs:207, E0283).
  • RIGHTS_UNCHANGED is TransferEntry::UNCHANGED, so SYS_HANDLE_DUP's word for "unchanged" is u32::MAX where it was u64::MAX.
  • Not fixed here: issues/a-received-handle-has-no-knowable-type.md. It is taken up as its own PR after this lands ((handle, ObjectKind) at receive); its record carries that and the review's three constraints.

Tests

moved_rights (new, tests/guest/rust-tests) is a kernel-boundary test: what a handle carries is observable only by what its holder's syscalls are refused, and the endowment arm needs a second process. It is a shared-boot member, so it runs on the metal testcases boot; the QEMU profile runs no shared member. Arms, each judged alone so one red names every claim that broke:

  1. a pipe end sent as WRITE alone writes, and can be neither duplicated nor moved; sent unchanged it moves again;
  2. a send asking for a right the handle lacks, or a word that is no set, is refused and the handle stays whole;
  3. a narrowed send to a peer that has gone gives the handle back with its rights;
  4. the same properties for an endowment, read from the child's exit code;
  5. a ring is refused by both moves, as is its duplicate.

Host: toyos-abi tests the word's decoding, and toyos-manifest that no name but dup gives a set TRANSFER or DUP. The supervisor's narrowed endowment needs no test of its own, since each link has a detector. syscap_rights' unit test pins the set, and moved_rights arm 4 pins that an endowment arrives with what it named. A silent failure to narrow in the std or SDK path would leave the cap with the supervisor's whole set, and test_rs_endowment_denied on testcases reds on that: the estate's row names no rt, and the test asserts its cap cannot enter the real-time band. A set the cap lacked would refuse the spawn, which every boot shows.

Evidence

All at 23f692c, rebased onto origin/main 2ec4ad9. The head, 6469b0d, differs from it by two doc comments (review r2), at the same line count, so no check is rerun and the staged images stand.

check command result
host, every part, lint included cargo run -- --ci host (macOS) exit 1: 109 of 110 steps green. The red step is the build system; its failure list is llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key and the llvm::tests::keyed_and_built it reruns, the macOS defect in issues/the-llvm-environment-test-breaks-git-on-macos.md. clippy, warnings denied: clean. log
image cargo run -- --build-only exit 0. log
guest binaries compile cargo test --test toyos-build -- --metal --list exit 0. log
guest rows, x86-64 and AArch64 guest-rows run 42987228938: session_lifecycle a_launch_gives_each_unsent_stdio_slot_a_null a_spawn_from_an_image_frees_it_when_the_child_ends libc_children virt_smp virt_jobs_at_el2 green, 6 of 6. log
metal, the branch testcases and shared-debug, whole, on the T14, judged from a tree at 23f692c every member this change is held to is green; 280 passed, 8 failed, 1 timing, none of them this change's (below). verdicts, quoted lines
negative controls each mutant's boot of moved_rights both red with the moved_rights: red: line. Mutant A names arms 1, 2, 4, 5 as predicted. Mutant B names arms 1 and 3, not 3 alone (below). A, B

No Linux host run exists: the PR's own checks are skipped while it is a draft, and the macOS run cannot clear the one red step.

What the metal boots show. On testcases: test_rs_moved_rights exit 0 with all five claim lines and moved_rights: ok; test_rs_endowment_denied, test_rs_messages_are_whole, test_rs_abuse_handle_table, test_rs_abuse_inbox, test_rs_audio_tone, test_rs_audio_tone_resampled and test_rs_cpal_drop_unreleased exit 0. On shared-debug: test_rs_handle_lifetime, test_rs_spawn_child_ends_first, test_rs_spawn_lands_claimed and test_rs_spawn_returns_the_census exit 0, and no member ended nonzero.

The reds on testcases, and why none is this change's:

  • test_rs_served_file_offset (exit 101, served_file_offset.rs:124) asserts Helper::spawn(None) answers Syscall(InvalidArgument). On main at 2ec4ad9 it answers Refused::Invalid before any syscall (sdk/toyos/src/spawn.rs:207). This branch touches neither line. A defect on main, unrecorded.
  • 206_libc_refusals (exit 1) prints sysconf _SC_ARG_MAX: 2093056 against an expected 65536. _SC_ARG_MAX is toyos::startup::MAX_BLOCK on main since A child's arguments and environment are memory its parent lays out #1200 and the .expect was not updated. This branch touches neither. A defect on main, unrecorded.
  • i219_messages_on_its_msi_vector: "no frame arrived on a message under traffic", which is issues/i219-msi-passes-only-when-the-network-sends-it-a-frame.md.
  • idle_entered, amdvi_translates_the_boot_controller, amdvi_remaps_the_boot_controller, amdvi_usbd_drives_its_own_entry: AMD-bench rows, taken because the boot was judged whole on the T14 ("this row runs on the AMD bench"; usbd_spare: the function has no MSI-X capability). console_uart_driven is red on that same binary's panic line.
  • boot.testcases.back_secs 124 against 50 recorded, ceiling 100. The boot's own work ended at 67 s of uptime with all 256 jobs; the rest is the bench's turnaround of a 521 MB image. I have no baseline of a whole testcases boot to compare, so this one is unattributed, not cleared.
  • test_rs_wake_onto_busy exit 49 is a measurement, as its registration says.

"No member newly red" is argued by cause, not by a same-day baseline of main, which I do not have.

Mutant B reds arm 1 as well as arm 3, where the review and I both predicted 3 alone. Arm 1 panics a send narrowed to WRITE: PermissionDenied. A first send on a connection with no frames is refused Unsent::Claim by send_now after the handles are taken, they are given back, and the send commits through a claim after checking them again. Without the restore the handle comes back narrowed, without TRANSFER, and the second check refuses it. So the restore is exercised by an ordinary narrowed send, not only by a refused one, and arm 1 detects its loss too. The control is red for the mutation it names; the prediction was wrong, not the test.

Unresolved

  • Two defects on main the whole-boot judge exposed, test_rs_served_file_offset and 206_libc_refusals, both from A child's arguments and environment are memory its parent lays out #1200's side and neither recorded. Not fixed or filed here.
  • The back_secs timing above is unattributed.
  • Judging: my first judge named boot:testcases, which also takes a row needing the unrun testcases-watchdog boot, so the harness restaged and rewrote the two branch image.img files after they had booted. The readbacks were not touched (their times precede it) and the judge reads only them, but the booted image bytes are no longer on disk to compare.
  • An exported stream end is still endowed with TRANSFER and dropped to a held handle by the child's SDK. The review judged it no defect; the rights word would let the spawn do it.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Owner decision wanted: issues/a-received-handle-has-no-knowable-type.md

Not fixed on this branch. Its record leaves the choice to the owner, and the brief names a typed transfer as a decision to return.

It still holds on origin/main d49f483, read from the code, not exercised:

  • SYS_MESSAGE_RECV answers handle numbers and a count; fstat answers Unknown for Process, Namespace, Connector, Inbox, SysCap, Job, Notice, both event sides, the pager kinds and DeviceSet, and Pipe for an Acceptor (kernel/src/object/ops.rs).
  • A typed lookup of the wrong kind is a handle fault everywhere but HandleTable::get_sent (four call sites).
  • The record understates the reach. It names toyos::launch alone. Event sides are typed too (sys_event_signal, sys_event_take) and cross connections routinely: a client takes bells from its server in sdk/toyos/src/audio.rs, sdk/toyos/src/net/stream.rs, sdk/toyos/src/net/socket.rs and lib/link/src/driver.rs, and uses them typed. A peer that sends a pipe end where a bell is owed ends whoever takes or signals it. I did not trace which of these a less trusted party can reach.

Options

  1. The receive reports the kind. SYS_MESSAGE_RECV writes (RawHandle, ObjectKind) pairs; ObjectKind is already ABI (the census). The SDK's Batch yields a received handle that becomes a typed wrapper only through a checked conversion, so "a requested Rust wrapper does not establish a received object's type" is held by the type. Cost: one ABI shape change, in-tree only, and about twenty SDK and service receive sites move to the checked conversion. It mirrors this PR's TransferEntry on the send side.
  2. Ask afterwards. fstat answers every kind, or a new syscall does. No receive change, but the check is a separate call a receiver can forget, which is the present defect with a remedy available.
  3. The kernel remembers that a handle was received. A table entry installed by a receive answers a wrong-typed use InvalidArgument instead of a fault, as get_sent does for four lookups today. No ABI or SDK change. It weakens fail-fast for exactly the handles a correct program cannot vouch for, and the bit must be defined across dup and a later move. A narrower form of the record's rejected option 3.

Recommendation: (1). It is the record's own, the only one that makes the unchecked use unrepresentable in the SDK, and the ABI is already open here. I would do it as its own PR after this one lands, since it rewrites receive sites in files #1200 and #1202 hold.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1204 at 3cddff6, round 1

Read: the commit, the whole diff, every changed file, kernel/src/message.rs, kernel/src/object/ops.rs, the dispatch, sys_shm_sub/sys_handle_dup, the supervisor's SysCap endowment, lib/manifest, every SharedMemory::share caller, the audio client, both deleted records, the manual page, and the evidence at c0836853. Nothing was built or run.

Architecture

The right owner and the simplest shape. The kernel decides once (HandleTable::rights_to_move) for both movers, under the hold that moves; the ring is fixed by representation (no TRANSFER in initial_rights) rather than by a check. The ABI is the minimal one: a sentinel is needed because userland cannot read what a handle carries, and refusing a wider set rather than clamping matches dup_narrowed. Splitting SpawnEndow (request) from EndowEntry (answer) is defensible. Growth: production +250/-80 (kernel +79/-46, toyos-abi +89/-10, SDK and soundserver +82/-24), tests +264/-12 (one new guest binary), issues -102.

Verified by inspection:

  • No widening by any route. A send decodes every word before any lock (ipc.rs:463-470), validates every entry in checked and only then takes any; commit validates every endowment before its first remove (start.rs:160-193). arriving_with asserts the subset again. Dup-then-move is a subset of a subset. sys_shm_sub copies the source's rights, so a MAP|WRITE ring yields no re-movable sub-region. No other kernel site mints a handle from an existing one.
  • Give-back. Both sinks (send_now, SendClaim::commit) return the batch whole and in order on every refusal; transfer restores each entry's saved rights before give_back, all inside the process lock, so no other thread sees a narrowed entry. Refusals before transfer take nothing. The retry after Unsent::Claim recomputes rights under its own hold.
  • Ring. Nothing in the tree moves an inbox; dup cannot add TRANSFER.
  • soundserver. The client only fstats (no right) and maps read-write (MAP|WRITE), so the lent ring suffices.
  • Consumers. No EndowEntry request remains outside the kernel's read-back; sdk/std, sdk/libc, the rust pin and the fork clones do not touch the changed shapes. This matters because endow_ptr is untyped: a stale _pad: 0 would now mean "no rights".

BLOCKER

  1. lib/manifest/src/lib.rs:213-215,271 with system/supervisor/src/main.rs:3243-3251 — every manifest-granted SysCap still arrives carrying TRANSFER, and this PR deletes the only record of that class without fixing or recording the instance. syscap_rights adds TRANSFER to every set for the one reason the closed record described: the endowment could not narrow. That reason ends with this PR and the workaround stays. Consequence: a grantee can hand its grant to a process the manifest never named, and for rt that is not proxyable (the receiver enters the real-time band itself); dup is documented as the way a row "says out loud" it hands the cap on, yet every row can do so once. It needs a misbehaving image program, so it is a hardening weakness, not an app-reachable hole. The PR body lists it under "candidates seen", which does not survive the merge (root: no silent debt). Resolve by either fixing it (drop TRANSFER from the arriving set; it would be the first production user of the endowment's rights word, which today only the test narrows) or filing a record with evidence, owner and exit. Given A child's arguments and environment are memory its parent lays out #1200 holds the spawn surface, a record now and the fix after A child's arguments and environment are memory its parent lays out #1200 is acceptable. The record should also name that toyos::spawn and std's Command::endow have no narrowing spelling.

SUGGESTION

  • system/soundserver/src/client.rs:217 — the bell's wait side travels unchanged (DUP|TRANSFER|WAIT|READ) in the same message as the lent ring. The comment above it claims dead-client detection "by construction" because the wait side "goes when its table does", which a re-movable, duplicable bell does not give. READ|WAIT is what the client uses. One line; the control connection's hang-up is a second detector, so not required.
  • sdk/toyos/src/ipc.rs:196 — send_moved is a seventh entry beside six *_with variants, and a narrowed non-blocking or byte send needs more. All 52 callers pass concrete OwnedHandle collections, so the six could take impl IntoIterator<Item = impl Into<Moved>>, and send_moved and the moved() shim would go.
  • kernel/src/loader/start.rs:164-166 — builds a TransferEntry from hand-indexed bytes only to call .rights(), and the request is chunked by the answer's size (ENDOW_ENTRY_LEN, tied by a size assert). Decoding the chunk as a SpawnEndow, as send decodes its TransferEntry, reads more directly. Fine to fold into the A child's arguments and environment are memory its parent lays out #1200 rebase.
  • docs/src/architecture/authority.md:34-35 — "holds until it closes it or ends" omits moving it on; "until it gives it up or ends" is exact. The rest of the manual text is accurate and names no code. Nothing but a holder's own close or move, and the two never-told paths in ipc.rs, removes a handle from a live table, so the no-revocation sentence is true of the kernel.
  • sdk/abi/src/handle.rs:394 — TransferEntry::UNCHANGED and RIGHTS_UNCHANGED (syscall.rs:2457) are two spellings of one notion; defining one from the other would say so.

The questions asked

  • SysCap with TRANSFER: a real weakness; record in this PR, fix after A child's arguments and environment are memory its parent lays out #1200 (blocker 1). The body's "DUP|TRANSFER" is imprecise: TRANSFER always, DUP only for a row naming dup.
  • Exported stream end: not a defect. The child's own SDK drops TRANSFER from its own handle; no boundary rests on it. It is a simplification for the same follow-up.
  • Other SharedMemory::share callers: neither fix nor record. Each is a point-to-point region whose receiver is the more trusted side or receives data it could copy, and netstack's regions must stay duplicable and movable for exports, so share and lend are both needed. No design claims these are non-transitive.

Tests and evidence

moved_rights is a justified kernel-boundary test: rights are observable only through refusals, and the handle table is not host-buildable. Each arm has one claim and a positive probe. The partial-list case is settled by inspection (validation is a separate pass). Mutant A is the whole kernel change reverted in the only form that still builds the test; mutant B isolates the restore. I traced both: A reds arms 1, 2, 4, 5 and leaves 3 green; B reds 3 alone; neither trips a kernel assert.

Evidence checked: host log exit 1 with one red, llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, matching the recorded macOS defect and independent of this branch; the ABI unit test and the SDK ipc tests ran in it; no host step builds rust-tests, so the later edits to two guest binaries do not invalidate it. Image build, guest-binary compile (test_rs_moved_rights is a testcases member) and both mutant builds exit 0. Guest rows green at this head, 6 of 6, both architectures. The PR's own checks are skipped because it is a draft, so no Linux host run exists yet.

Nothing new is verified until metal. Must be green before landing, at the head rebased onto #1200 (the rebase edits SpawnArgs and the test's raw spawn, which invalidates arms 4 and 5 run earlier):

  • testcases: test_rs_moved_rights exit 0 with all five claim lines; test_rs_messages_are_whole, test_rs_abuse_handle_table, test_rs_abuse_inbox; test_rs_audio_tone and test_rs_cpal_drop_unreleased (the lent ring end to end); no member newly red, since every spawn and handle-carrying message in the boot uses the new shapes.
  • shared-debug: test_rs_handle_lifetime, test_rs_spawn_child_ends_first, test_rs_spawn_lands_claimed, test_rs_spawn_returns_the_census.
  • Controls: mutant A red with moved_rights: red: naming exactly arms 1, 2, 4, 5; mutant B naming exactly arm 3. A red without that line (a kernel death, a boot failure) is not the control.
  • Linux host checks on the undrafted PR.

The third defect, (handle, ObjectKind)

I agree with option 1, as its own PR. It is the only option where the kind is read in the same hold that installs the handle, so there is no second call to forget and no window, and the SDK can make a typed wrapper reachable only through a checked conversion. Three cautions. ObjectKind becomes a contract rather than a census detail: values must never be renumbered, and the SDK must refuse a kind it does not know. It does not replace fstat for regions, where lent also needs RAM-ness and size. And keep the pair to the kind: a missing right already answers PermissionDenied without ending anyone, so reporting rights has no caller.

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
… unchanged

soundserver's bell arrives READ|WAIT, so the wait side really does go
when its client's table does, as the comment beside it claimed.
`RIGHTS_UNCHANGED` is `TransferEntry::UNCHANGED`: `SYS_HANDLE_DUP`'s
word for the source's own set is u32::MAX where it was u64::MAX, and
its two SDK wrappers are the only writers. The manual says a holder
keeps a handle until it gives it up or ends.

issues/a-received-handle-has-no-knowable-type.md records that it is
taken up as (handle, ObjectKind) at receive, the three constraints its
review set, and that event sides widen its reach.

The spawn-side fixes of this round follow in the rebase onto #1200.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r1, at f05b251

Blocker 1 (a manifest SysCap arrives with TRANSFER): accepted, to be fixed in this PR and not recorded. Not in this push: it needs toyos::spawn's narrowing spelling, and #1200 holds that surface and lands first. In the rebase onto #1200: syscap_rights and the supervisor's endowment drop TRANSFER from the arriving set, DUP stays only for a row naming dup, and toyos::spawn and std's Command::endow get the spelling. The body no longer lists it as a candidate and says TRANSFER always, DUP only for dup. The blocker is open until that push.

Suggestions:

  • The bell: taken. It arrives READ|WAIT, and the comment now says why the claim holds.
  • send_moved into impl Into<Moved>: declined, on a build result. It was the first shape I wrote, and it stops existing callers inferring: lib/blockserve/src/sessions.rs:207 passes [client_ring.into(), answered.into()], which failed E0283 with the bound generic. The callers pass OwnedHandle collections, but several build them with .into() and rely on the parameter to name the target.
  • SpawnEndow decode in start.rs: taken, in the rebase.
  • Manual: taken, "until it gives it up or ends".
  • One unchanged word: taken. RIGHTS_UNCHANGED is TransferEntry::UNCHANGED as u64, which changes SYS_HANDLE_DUP's word from u64::MAX to u32::MAX; the two SDK wrappers are its only writers and nothing else in the tree or the rust pin spells the syscall.

The third defect's record now carries the plan and your three cautions.

Checks at this head: --build-only exit 0, cargo test -p toyos-abi exit 0, guest-rows run 42937126539 green (session_lifecycle libc_children virt_smp), run because the dup word reaches every duplicate. Metal is not restaged: one staging follows at the rebased head, against your must-be-green list and both controls.

Japabu and others added 3 commits November 11, 2026 10:09
Both moves, a message's handles and a spawn's endowments, required
TRANSFER and carried the source's rights unchanged, so every handle that
arrived by a move could be moved on: "you may map this and nobody else
may" had no spelling, and soundserver's per-client ring was re-sendable
by its client. Dup-then-move could not express it, since the move needs
the right it would have to drop.

A moved handle is now a `TransferEntry { handle, rights }` on both
paths: `SYS_MESSAGE_SEND` takes `[TransferEntry]` and a spawn's
endowment is a `SpawnEndow` carrying one. The word is the mover's own
set or a subset; a set the handle lacks is PermissionDenied and a word
that is no set InvalidArgument, with nothing moved. The table decides
it in one place (`HandleTable::rights_to_move`), and a send its
connection refuses gives each handle back with the rights it had.
`EndowEntry` stays what a child reads back.

The SDK's sends take `Moved`, which any `OwnedHandle` converts to
unchanged; `OwnedHandle::arriving_with` narrows. soundserver lends its
ring as MAP|WRITE (`SharedMemory::lend`). The SDK's spawn endows
unchanged: nothing in the tree endows less yet.

An inbox handle no longer carries TRANSFER. Its page is mapped into its
creator alone and `inbox_submit` resolves a watch's handle in the
caller's table, so a process handed a ring ran its creator's
submissions against its own handle numbers. Nothing in the tree moved
one.

The manual says a move may narrow, that moving on is a right, that an
inbox is not moved, and carries the no-revocation ruling the closed
record held.

Closes issues/a-moved-handle-is-always-re-movable.md and
issues/a-ring-handed-to-another-process-resolves-its-watches-in-the-receivers-table.md.
`moved_rights` is the guest test of both.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… unchanged

soundserver's bell arrives READ|WAIT, so the wait side really does go
when its client's table does, as the comment beside it claimed.
`RIGHTS_UNCHANGED` is `TransferEntry::UNCHANGED`: `SYS_HANDLE_DUP`'s
word for the source's own set is u32::MAX where it was u64::MAX, and
its two SDK wrappers are the only writers. The manual says a holder
keeps a handle until it gives it up or ends.

issues/a-received-handle-has-no-knowable-type.md records that it is
taken up as (handle, ObjectKind) at receive, the three constraints its
review set, and that event sides widen its reach.

The spawn-side fixes of this round follow in the rebase onto #1200.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every manifest-granted SysCap arrived carrying TRANSFER, added by
`syscap_rights` because an endowment could not narrow: the supervisor
had to keep the right on the duplicate to move it at all. A grantee
could hand its grant to a process the manifest never named, and for
`rt` the receiver entered the real-time band itself.

The endowment's rights word is what removes it. `syscap_rights` is now
exactly what the capability arrives with, and the supervisor endows a
duplicate narrowed to it. Only `dup` lets a capability leave its
program, so that name is DUP|TRANSFER: handing a cap on to a child
takes duplicating it and moving the duplicate.

The spelling: `toyos::spawn::Spawn::endow` carries `Moved`, and std's
`CommandExt::endow_narrowed` takes the rights' bits, since std's own
`toyos-abi` is not its caller's. A launch's job goes with the rights
it has, and one named narrowed is refused.

The kernel decodes a spawn's request as `SpawnEndow`s once, in
`build_child_handles`, where it read a byte window twice by hand; the
size assert that tied the request to `EndowEntry` goes.

The loader's boot names, which the supervisor only maps, arrive MAP
alone. The two boot images keep what they had: the supervisor hands
them to the file server.

`moved_rights` follows #1200's `SpawnArgs`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the handle-model-security branch from f05b251 to 23f692c Compare November 11, 2026 08:27
@Japabu Japabu changed the title A move names the rights it delivers, and a ring is never moved A move names the rights it delivers: a ring and a manifest's SysCap stay where they were given Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1, the spawn-side half: head 23f692c

Rebased onto origin/main 2ec4ad9 (#1200 in). Three commits; the third, 23f692c91, is everything new since f05b25198 apart from conflict resolution. For a focused review:

Blocker 1, fixed.

  • lib/manifest/src/lib.rs: syscap_rights starts from NONE, so a set is what its names say. dup is DUP|TRANSFER: with TRANSFER gone elsewhere, a dup row could duplicate and move the copy nowhere, and the test estate hands its cap to children. Its unit test now asserts no other name yields TRANSFER or DUP.
  • system/supervisor/src/main.rs: the row's cap is a plain duplicate endowed with endow_narrowed(.., rights.bits()).
  • sdk/toyos/src/spawn.rs: Spawn::endow is Vec<(&str, Moved)>. A launch's job named narrowed is refused Invalid.
  • sdk/std/os/process.rs, sdk/std/sys/process.rs: CommandExt::endow_narrowed(label, handle, rights: u32). Bits, since std's toyos-abi is not the caller's crate; it panics on a bit no right uses.

SpawnEndow decode, taken. SpawnEndow is user_safe!; build_child_handles decodes the window once into Vec<SpawnEndow>, and commit and child_job read fields. ENDOW_ENTRY_LEN is private to the read-back again and the size assert is gone.

New, asked by the orchestrator: kernel/src/loader/mod.rs installs the supervisor's boot:names with MAP alone (#1210 added it with a region's initial rights; the supervisor only maps it). The two boot images keep theirs, since the supervisor endows them on.

Rebase resolutions: SpawnArgs' doc beside the new name fields; spawn.rs imports; abuse_handle_table and spawn_child_ends_first take #1200's shape with SpawnEndow; moved_rights' raw spawn names itself and lays out its argv through image::laid.

Checks at this head: full --ci host (one red step, the recorded macOS llvm::tests pair; clippy clean), --build-only exit 0, guest binaries compile, guest-rows 42987228938 green 6 of 6. Metal is staged once at this head: testcases and shared-debug whole, plus both controls, which still apply and build. Not run; the benches are reserved.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1204 at 23f692c, round 2 (focused)

Read: git range-diff of round 1's commit across the rebase, commits 2c0d17993 and 23f692c91 in full, every file they change, the supervisor's start path and Boot::take, spawn_supervisor, sys_handle_dup, every syscap row in images/ and tests/images/, every holder of a SysCap in system/, sdk/ and the test estate, what main took from d49f483 to cb3f9c0, and the evidence at 4d72aabd r2/. Nothing was built or run. Round 1's settled parts (the kernel's rights check, the give-back, the ring) were not reopened; the range-diff shows the first commit changed only by #1200's SpawnArgs and test shapes.

Architecture

Round 1's blocker is fixed in the right place. The manifest's set is now exactly what arrives, the supervisor stops compensating for an endowment that could not narrow, and the workaround's TRANSFER is gone rather than recorded. The kernel reads the request once as its own type. Since round 1: production +149/-102, tests +5/-6 (whole branch: production +375/-158, tests +263/-12, issues and manual +32/-104).

BLOCKER

  1. sdk/abi/src/syscall.rs:2471-2472 and sdk/toyos/src/syscap.rs:224-225 — two doc sentences say the supervisor narrows a program's SysCap by a narrowed duplicate ("This is how the supervisor hands a program an RT-only SysCap while keeping the full one"; "How the supervisor gives a program the RT band and nothing else"). This PR removes that: the supervisor takes a plain duplicate and the endowment narrows it (system/supervisor/src/main.rs:3234-3235), and SysCap::narrowed has no caller left outside three guest tests. Both sentences are false at this head, made so by this change, and they send a reader to a mechanism that would put TRANSFER back (a narrowed duplicate must keep it to be endowed at all). Delete or correct both. Comment-only: no check needs rerunning and the staged metal images stay valid.

SUGGESTION

  • kernel/src/loader/mod.rs:308 — Rights is already in scope in this function (Rights::DUP at the cap); Some(Rights::MAP) reads shorter than the full path.

The questions asked

  • endow_narrowed panics on a bit no right uses: right. The word is the calling program's own constant, built from its typed Rights; a stray bit is that program's bug, not external input, and the builder has no error to return. It is documented under # Panics and std's ToyOS extensions already panic on misuse (sdk/std/os/io.rs). The check is complete: Rights::from_bits refuses every bit outside ALL (bits 0 to 20, the 21 named rights), and so refuses TransferEntry::UNCHANGED, which a const assert pins. The kernel refuses the same word InvalidArgument regardless. The raw u32 is justified: std links the sysroot's toyos-abi, and userland cannot read a handle's rights to offer a "without TRANSFER" spelling instead.
  • dup as DUP|TRANSFER: the right meaning. The manifest word has always meant "this program hands the cap to its children" (its own comment), and that takes both rights. Split, neither half has a holder: DUP alone makes copies that cannot leave, TRANSFER alone gives away the only one. Checked every row: all 15 rows naming dup are test-runner; no row in images/ names it. test-runner duplicates and endows unchanged, so its jobs receive what they received before. metalcase.toml's runner (logread, no dup) already took PermissionDenied from its duplicate and hands nothing down, unchanged.
  • No production program loses a right it uses. The holders are logkeeper, soundserver, hda, compositor, inspect, trace and toybox. None duplicates, sends or endows its SysCap: the compositor makes jobs with it and sends the job, whose handle keeps its own TRANSFER. The supervisor's own cap is the kernel's, with DUP|TRANSFER, and every start, session owners and launches included, goes through the one start. Every name's set is inside what the kernel mints, so no row's spawn is refused.
  • A launch's job named narrowed is refused: sdk/toyos/src/spawn.rs:351-354, Refused::Invalid, the handle closed as any refused endowment is. Three lines; inspection suffices.
  • SpawnEndow decode: build_child_handles bounds the count before allocating, reads each entry once through user_safe! (the macro checks padding and field types), and commit and child_job read fields. The label bounds, the validation-before-removal order and the refusals are unchanged.
  • boot:names with MAP alone: the supervisor fstats it (no right), maps it read-only (MAP) and leaks the mapping (main.rs:3814); it never duplicates or moves it. The two images are handed to the file server and keep their rights.
  • RIGHTS_UNCHANGED as u32::MAX: the kernel compares the whole u64 and otherwise requires the word to fit u32 (kernel/src/syscall/handles.rs:61-65), so the old u64::MAX is refused InvalidArgument, not read as a set. Writers: the two wrappers only. The socket2 fork calls syscall::dup, and it and std's sysroot resolve toyos-abi to this tree by [patch].
  • The bell: the client takes (READ) and waits (WAIT) and nothing else; the comment's claim now holds. The manual sentence and the third defect's record are accurate.
  • Rebase and main: nothing main took since round 1 adds a mover of a ring, a holder that passes a SysCap on, or a writer of the changed shapes. The branch merges cleanly with cb3f9c0; The x86-64 wall clock is the loader's reading of firmware, and the CMOS driver is gone #1213 touches none of this.

Tests and evidence

No test is owed for the supervisor's narrowed endowment. Each link already has a detector. syscap_rights' unit test pins the set. moved_rights arm 4 pins that an endowment arrives with what it named. If the std or SDK path lost the narrowing, the cap would arrive with the supervisor's whole set, and test_rs_endowment_denied on testcases would red at endowment_denied.rs:283-287: that estate's row names no rt, and the test asserts its cap cannot enter the real-time band. A set the cap lacked refuses the spawn, which every boot would show. The PR body names only the last; the endowment_denied arm is the one that guards against a silent failure to narrow, and the metal request already lists it.

Evidence checked. Host log: exit 1, 1 of 110 steps red, the build system; its failure list is llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key and the keyed_and_built it runs, failing on xcodebuild -sdk /no/such.sdk, the recorded macOS defect, independent of this branch; 384 others in that step passed. Every clippy shape ran with no warning or error, and the ABI and manifest unit tests ran green. The log names no revision; the manifest test's new name exists only at this head's third commit. Guest rows: run 42987228938 checked out 23f692c, 6 of 6 green. No personal or machine identifier in r2/ (the runner's machine name is masked).

Still unverified until metal, as the body says. Must be green before landing, on the images staged at 23f692c:

  • testcases: test_rs_moved_rights exit 0 with all five claim lines; test_rs_endowment_denied; test_rs_messages_are_whole, test_rs_abuse_handle_table, test_rs_abuse_inbox; test_rs_audio_tone and test_rs_cpal_drop_unreleased (the lent ring and the READ|WAIT bell); no member newly red.
  • shared-debug: test_rs_handle_lifetime, test_rs_spawn_child_ends_first, test_rs_spawn_lands_claimed, test_rs_spawn_returns_the_census.
  • Controls: mutant A red with moved_rights: red: naming exactly arms 1, 2, 4, 5; mutant B naming exactly arm 3. A red without that line is not the control. Both patches still match this head's code.
  • Linux host checks on the undrafted PR.

SEND BACK

…aches its program

Two doc sentences still said the supervisor hands a program its SysCap
as a narrowed duplicate. It takes a plain duplicate and the endowment
narrows it, and a narrowed duplicate would have to keep TRANSFER to be
endowed at all. Comments only, at the same line count.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator, before queueing at 6469b0d.

  • Review r2's one blocker (two doc sentences) is resolved: 23f692c91..6469b0de6 is doc comments in sdk/abi/src/syscall.rs and sdk/toyos/src/syscap.rs, four lines for four; I read the diff. No built byte changes, so the images staged at 23f692c stand.
  • Metal. I ran the four T14 boots; the implementer judged them from a tree at 23f692c. Every member r2 listed is green: testcases — moved_rights exit 0 with its five claim lines, endowment_denied, messages_are_whole, abuse_handle_table, abuse_inbox, audio_tone, cpal_drop_unreleased; shared-debug — handle_lifetime, spawn_child_ends_first, spawn_lands_claimed, spawn_returns_the_census. Mutant A is red naming exactly arms 1, 2, 4, 5.
  • Mutant B names arms 1 and 3, not 3 alone as r2 and the author predicted. The author's account, which I accept by reading: a first send on a connection with no frames is refused Unsent::Claim after the handles are taken, they are given back and re-checked before the send commits through a claim, so without the restore an ordinary narrowed send (arm 1) loses TRANSFER too. The control is red for the mutation it names; the prediction was wrong, not the control.
  • Other reds in the whole testcases boot, none this branch's: two stale tests on main since A child's arguments and environment are memory its parent lays out #1200 (test_rs_served_file_offset expects a syscall error where the SDK now refuses before the syscall; 206_libc_refusals expects the old _SC_ARG_MAX) — this branch touches neither path, and a fix is being dispatched separately; i219_messages_on_its_msi_vector (the recorded network-dependent issue); rows registered for the other bench; and boot.testcases.back_secs 124 s against 100, where the boot's own work ended at 67 s and the rest is the bench's turnaround.

Queueing.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 12:26
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit b0a0346 Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the handle-model-security branch November 11, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant