Repository navigation
Conversation
…ees it Stage 1 of launch-time consent: grants by command. - `toyos_manifest::grants`: the store the supervisor alone writes (`/state/supervisor/grants`), keyed on the user, the package and the SHA-256 of the exact binary the supervisor starts, so an update holds no grant until it is granted again; the folder rules (a canonical directory inside the session's home, never the home, nor `Apps` or anything in it, within the 54-byte grant root and std's four-component capability depth), each refusal a whole sentence; `decide`, the stored grant for that binary at most the image's ceiling; `cwd`, the caller's own inside the folder and the folder's root otherwise; and the request codec of the `grants` port. - `[apps] folder = "read-write"` is the ceiling, and until signed package entries carry a package's own request, what every package asks for. It lives in `[apps]` and not beside each package because `pkg`, which writes a package's `manifest.toml`, is outside this change, gbae's release carries no request, and a per-package line in `system.toml` would be the image naming an app. The build refuses any other spelling, and `[apps]` now refuses an unknown key, so a misspelt `folder` is no silent ceiling. - The supervisor serves `grants`, minted per start with the holder's row and session like a launcher, endowed under a label of its own and never in `svc`; it answers a login session alone. `add` checks the folder rules and the ceiling, reads the package's manifest, checks the folder is a directory and not a link, and hashes the program, all on its file worker; the store is written beside itself and renamed over. A launch of a package hashes its binary on the worker with the launch's other files, and a grant for it appends the folder to the package's view and prepares the launch again in the folder; a granted folder no longer a directory refuses the launch. - `/system/bin/grants list | add <package> <folder> [read-only] | revoke`: the one row the build lets receive `grants`; the shell and sshserver list it. - The fileserver already follows no symlink at a grant's root: DATA's flat namespace lists nothing through one and makes nothing under one. A host test holds that, and the resolver's escape suite runs under a granted folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…t wide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…st one answer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
The negative control, the whole change reverted, has no /system/bin/grants; its launch failing is now one named arm and the others still run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
Collaborator
Author
|
Mutations and the negative control for stage 1, at head summary.txtWhat each guest red namedrun.sh#!/bin/zsh
# Each arm: apply a checked patch, run what must see it, record the exit, restore.
cd <worktree>
L=<scratch>/logs
M=$L/mut
BASE=2b1a0e746
summary=$M/summary.txt
echo "head $(git rev-parse --short HEAD) $(uptime)" > $summary
restore() { git checkout -q HEAD -- . ; git status --porcelain --ignore-submodules=none > $M/status-after-$1.txt; echo " tree after $1: $(wc -l < $M/status-after-$1.txt) changed path(s)" >> $summary; }
host() { # name, command
git apply --check $M/$1.patch && git apply $M/$1.patch || { echo "$1: patch does not apply" >> $summary; return; }
zsh -c "$2" > $M/$1.log 2>&1; e=$?; echo "$1: EXIT=$e ($2)" >> $summary; restore $1
}
guest() { # name
git apply --check $L/harness.patch && git apply $L/harness.patch || { echo "$1: harness does not apply" >> $summary; return; }
if [ "$1" = "NC-whole-change-reverted" ]; then
git checkout -q $BASE -- Cargo.toml Cargo.lock src/build.rs system.toml toyos-manifest userland/supervisor userland/fileserver tests/proctreecase/system.toml && git rm -rq userland/grants
elif [ "$1" != "G0-harness-alone" ]; then
git apply --check $M/$1.patch && git apply $M/$1.patch || { echo "$1: patch does not apply" >> $summary; restore $1; return; }
fi
cargo test --test toyos-build -- app_view_qemu > $M/$1.log 2>&1; e=$?; echo "$1: EXIT=$e (app_view_qemu)" >> $summary; restore $1
}
host H1-decide-ignores-the-binary "cargo test -p toyos-manifest --lib grants::"
host H2-apps-folder-allowed "cargo test -p toyos-manifest --lib grants::"
host H3-grants-port-not-held-alone "cargo test -p toyos-build --lib only_their_holders_may_hold"
guest G0-harness-alone
guest G1-machine-session-answered
guest G2-folder-not-in-the-view
guest G3-launch-ignores-the-binary
guest NC-whole-change-reverted
echo "done $(uptime)" >> $summaryharness.patchdiff --git a/tests/toyos.rs b/tests/toyos.rs
index 008c522ec..a10bbb6d4 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -334,6 +334,7 @@ const MACHINE_TESTS: &[&str] = &[
// reads it have no host build, and the T14 boots from a stick beside an
// NVMe disk that is another system's.
"nvme_disk_keeps_log_and_home",
+ "app_view_qemu",
];
/// **The metal profile**: which registrations run on the ThinkPad T14, what
@@ -3200,6 +3201,7 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
"bar_map_again" => bar_map_again(test_config),
"console_image_boots" => console_image_boots(),
"nvme_disk_keeps_log_and_home" => nvme_disk_keeps_log_and_home(test_config),
+ "app_view_qemu" => app_view_qemu(),
other => Err(format!("unknown machine test {other}")),
}
}
@@ -3239,6 +3241,18 @@ fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result
Ok(())
}
+/// `app_view` under QEMU, for mutations and the negative control only: the
+/// verdict is the `app_view` metal row's.
+fn app_view_qemu() -> Result<(), String> {
+ let case = compile::repo_root().join("tests/proctreecase");
+ let (_, job) = suite_bin(qemu::SUITE_ARCH, "app_view");
+ let mut qemu =
+ QemuInstance::boot_with_options(&case, &[], &[("app_view".to_string(), job)], BootOptions::default());
+ let said = job_said(&mut qemu, "test_rs_app_view")?;
+ eprintln!("{said}");
+ Ok(())
+}
+
/// Run `command` as a job of the boot, to exit 0: what it said.
fn job_said(qemu: &mut QemuInstance, command: &str) -> Result<String, String> {
let result = qemu.run_test(command, Duration::from_secs(60));H1-decide-ignores-the-binarydiff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs
index 337b2c3cb..6d243269b 100644
--- a/toyos-manifest/src/grants.rs
+++ b/toyos-manifest/src/grants.rs
@@ -133,7 +133,7 @@ pub fn folder(path: &str) -> Result<(), String> {
/// ceiling, no grant, or a grant to another binary.
pub fn decide(stored: Option<&Entry>, binary: &str, ceiling: Option<Access>) -> Option<Folder> {
let ceiling = ceiling?;
- let entry = stored.filter(|entry| entry.binary == binary)?;
+ let entry = stored?;
Some(Folder { path: entry.folder.path.clone(), access: entry.folder.access.min(ceiling) })
}
H2-apps-folder-alloweddiff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs
index 337b2c3cb..8ecd6db3a 100644
--- a/toyos-manifest/src/grants.rs
+++ b/toyos-manifest/src/grants.rs
@@ -116,7 +116,7 @@ pub fn folder(path: &str) -> Result<(), String> {
let Some(inside) = path.strip_prefix(&home).and_then(|rest| rest.strip_prefix('/')) else {
return Err(format!("{path} is not inside {home}"));
};
- if inside.split('/').next() == Some("Apps") {
+ if inside.split('/').next() == Some("Apps-never") {
return Err(format!("{path} is {home}/Apps or inside it, where every app keeps its own folder"));
}
if path.len() - 1 > MAX_ROOT {H3-grants-port-not-held-alonediff --git a/src/build.rs b/src/build.rs
index 6aae599de..1d5333242 100644
--- a/src/build.rs
+++ b/src/build.rs
@@ -590,7 +590,7 @@ const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power", toyos_manifest::
/// the swap port, whose holder replaces any service's binary, and the grants
/// port, whose holder writes which folder of the home a package sees.
const HELD_ALONE: &[(&str, &str)] =
- &[(toyos_swap::PORT, toyos_swap::HOLDER), (toyos_manifest::grants::PORT, toyos_manifest::grants::HOLDER)];
+ &[(toyos_swap::PORT, toyos_swap::HOLDER)];
/// Who may hold the authorities that change what the machine runs or what a
/// package sees: each of [`HELD_ALONE`]'s ports, its holder and nothing elseG1-machine-session-answereddiff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs
index 1269b9dda..521586de9 100644
--- a/userland/supervisor/src/main.rs
+++ b/userland/supervisor/src/main.rs
@@ -892,7 +892,7 @@ impl<'a> Supervisor<'a> {
/// and the machine's session is every service's.
fn grant(&mut self, caller: &Caller, msg_type: u32, payload: &[u8]) -> Result<String, String> {
let request = grants::Request::decode(msg_type, payload).ok_or("the request is not one this port reads")?;
- if !matches!(caller.session, Session::Login(_)) {
+ if false && !matches!(caller.session, Session::Login(_)) {
return Err("only a login session may ask for grants".to_string());
}
let user = toyos_manifest::USER;G2-folder-not-in-the-viewdiff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs
index 1269b9dda..53c77261e 100644
--- a/userland/supervisor/src/main.rs
+++ b/userland/supervisor/src/main.rs
@@ -1775,7 +1775,7 @@ impl Supervisor<'_> {
if let Some(binary) = binary {
match self.granted(&program.name, binary, command, request.cwd) {
Ok((granted, prepared)) => {
- program.folder = granted;
+ program.folder = granted.filter(|_| false);
command = prepared;
}
Err(why) => {G3-launch-ignores-the-binarydiff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs
index 1269b9dda..ed618e67b 100644
--- a/userland/supervisor/src/main.rs
+++ b/userland/supervisor/src/main.rs
@@ -1853,7 +1853,7 @@ impl Supervisor<'_> {
mut command: Command,
cwd: &str,
) -> Result<(Option<grants::Folder>, Command), String> {
- let binary = binary?;
+ let binary = self.store.find(toyos_manifest::USER, name).map_or(binary?, |e| e.binary.clone());
let Some(folder) = grants::decide(self.store.find(toyos_manifest::USER, name), &binary, self.system.folder) else {
if self.system.folder.is_some() {
say!("supervisor: launcher: {name} holds no folder; `grants add {name} <folder>` grants it one"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft: stage 1 of 2. Launch-time consent, staged as the design has it. The owner ruled that stages 1 and 2 land together ("popup in one go"). This branch holds stage 1 only: grants by command, with gbae's browsing and saving a granted folder, and its tests. It stops at the brief's named clean boundary. Stage 2 (the parked launch, the compositor's prompt layer, filepicker's consent mode and the desktop guest test) is not started, so this pull request must not land as it stands. See Not done.
Head:
c190ac79b, on2b1a0e746(main with #807).What changed, per decision
toyos_manifest::grants):/state/supervisor/grants, a header linetoyos-grants 1, then onegrant <user> <package> <sha256> <read-only|read-write> <folder>line per grant.Store::parserefuses by name anythingrendercannot have written: an unknown record, a duplicate, an oversized store, a non-hex digest, a bad name, a bad folder. Any declared row can write/state.decidereturns the stored grant for that binary, at most the image's ceiling, or nothing.grants::folder): a canonical directory inside the session user's home. Never the home itself, norAppsor anything in it. Within the 54-byte grant root (the supervisor asserts it againsttoyos::fs::MAX_GRANT_ROOT) and std's 4-component capability depth. Each refusal is a whole sentence.[apps] folder = "read-write"is the image's ceiling. Until signed package entries carry a package's own request, it is also what every package asks for.[apps]and not a declaration per package:pkgwrites each package'smanifest.tomland is outside this change, and gbae's release carries no request. A per-package line insystem.tomlwould be the image naming an app.grants add <package> <folder> read-onlygrants less. A grant past the ceiling is refused.read-onlyandread-write.[apps]now refuses an unknown key, so a misspeltfoldercannot become a silent ceiling.grantsport (supervisor-served, inSUPERVISOR_SERVED).svc.refused grants in the machine's session: only a login session may ask for grants.grantsrow alone receive the port, and never[apps].held_by_their_holders_alonenow takes a list of (port, holder) pairs.addchecks, in this order: the folder rules, then the ceiling. Then, on the worker: the package's manifest, as a launch would start it (so a package named after a declared row is none); that the folder is a directory and not a link (symlink_metadata); and the program's hash.Viewis appended to the package's view (Program::folder, set only on the row the supervisor synthesizes for that launch).grants::cwd).HOMEstays the package's own folder.<package> holds no folder; \grants add ` grants it one`./system/bin/grants list | add <package> <folder> [read-only] | revoke <package>: a new row. The shell and sshserver list it instarts. It isexempt.manages, since grants are ToyOS's own.mkdired under it. A host test (a_symlink_at_a_grant_s_root_is_followed_by_nothing) holds that. The resolver's escape suite now also runs under a granted folder.bfe8dab). It browsesstd::env::current_dir()(src/main.rs:471) withlist_directory(src/menu.rs:308). It saves<rom>.savbeside the ROM (:157), after acanonicalizethat falls back to the given path (:156). From the granted folder, its../lists nothing, since the home is not in its view.Checks: this is a security boundary
Negative control. The whole production change was reverted onto
2b1a0e746, the base the green arm G0 was measured on:toyos-manifest,userland/supervisor,userland/fileserver,src/build.rs,system.toml,Cargo.toml,Cargo.lockandtests/proctreecase/system.toml, withuserland/grantsremoved.app_view.rs, its judge and the temporary QEMU harness were kept.app_viewred, EXIT=1. It names every grant arm:grantsis missing; the granted launch sawcwd=/ roms=[] saved=Err; notest.savwas written beside the ROM.Mutations. Each was applied as a checked patch, run, and restored in the same script, leaving the tree clean. All of them, and the control, were run at
c190ac79b. The patches, the runner, the summary and each red's named arms are posted as a comment.decideignores the binarytoyos-manifesthost testAppsallowedtoyos-manifesthost testsgrantsnot held by its holder alonesrc/build.rshost testapp_viewunder QEMUapp_viewunder QEMUgrants in the machine's session was answered … appview is grantedapp_viewunder QEMUgranted, it saw cwd=/home/toy/Games roms=[] saved=Errapp_viewunder QEMUanother binary saw cwd=/home/toy/Games roms=["test.gba"] saved=Okapp_viewunder QEMUIndependent oracle. Two pieces:
..-through-a-link cases), run under a root inside the home.list_directory, verbatim, as the client whose behaviour the grant has to serve. The guest test spells every expected path itself and asks nothing oftoyos-manifest.There is no external specification for this boundary.
Tests
toyos_manifest::grants: the store's round trip at every bound (the longest folder at the longest name, a folder with a space, a full store), and every refusal by name; every folder refused by name;decideas a table (binary, ceiling, access); the cwd rule; revoke takes exactly one grant; the request codec.toyos-manifest: a package's view with a granted folder, spelled out whole at both accesses.src/build.rs: the ceiling's two spellings and its refusals;grantsheld by its holder alone, never[apps].app_view, thetests/proctreecasemetal row, extended.grants addfrom the job's own session, which is the machine's, is refused.Apps/appviewis refused by name, from the login session a shell opens./home/toy/Games, the package run asgameuses gbae's ownlist_directoryand its save. It starts in the folder, liststest.gba, and writestest.savbeside it.guest / suite. It ran under QEMU only through An installed app sees its own package read-only and its own folder as HOME, and nothing else of /apps or /home #807's temporaryapp_view_qemuharness (posted), for G0, the mutations and the control.Gates (head
c190ac79b)cargo run -- --ci hostcargo run -- --build-onlycargo test --test toyos-builduptimeload averages 26.99 / 22.51 / 17.77 before, 35.73 / 26.23 / 19.53 aftercargo test --test toyos-build -- --metal --metal-readback <dir> boot:testcases boot:proctreecaseproctreecasede5b9032…6dda84ae,testcases5d47aa4f…90fb6968,testcases-watchdog18a86b20…b8ad8922), each sha256 inrequest.txt. The T14 reading is the orchestrator's.Earlier, at
04750cc1b(before the size asserts andapp_view's red for a missinggrants): the suite was 41/41, EXIT=0. Theapp_viewQEMU run at the first commit was EXIT=0.Net lines:
git diff --shortstat origin/main...HEADgives 14 files, +1178 −64.toyos-manifest/src/grants.rs: 292 production and 171 test lines.--numstat: supervisor +256 −36,userland/grants+95,src/build.rs+64 −16 (about 26 of them tests),toyos-manifest/src/lib.rs+47 −6 (20 tests),system.toml+14 −2. Tests outside those:app_view.rs+169, the fileserver's two tests +45,tests/proctreecase+11, the judge +6.Not done: stage 2, and why this stops here
Stage 2 is the owner's popup. Its parts:
loginrow's other launches never do, so sshserver's shells, even one started from a desktop shell, never prompt.decide's Ask arm and the stored Deny.promptport gives a layer abovetopmostand the taskbar. While a prompt is up, the pure rules route every key and every hit to it, refuse chords and paste, swallow a press made before its first frame was composited, and refuse a second prompt.toyos-windowgains a constructor on a named port. That crate is outside this brief's fence.filepicker's consent mode, with Always, Once, Deny and Skip, and a chooser bounded by the folder rules.The guest test alone needs harness pieces this tree does not have: a desktop session driven by keys, and pixel assertions on a composited screen. None of stage 2 is in this branch. The issue
issues/an-installed-gbae-browses-to-no-rom.mdstays open, since its exit is a ROM picked from the desktop.What I am unsure of
/state, which any declared row can write. This is the same weakness as/apps(issues/whether-pkg-alone-writes-apps.md): known, tracked, and still true. Isolation stage 2 closes it. A row that writes the store can grant a package any folder the store's parser accepts. It already holds the whole tree itself.preparereads it again; std gives no way to start the bytes it hashed. A row that rewrites/apps/<name>between the two reads gets the grant for other bytes. Only a declared row can write/apps, and it already holds the whole tree, so it gains nothing it lacks. This is the/appshole above.issues/bcachefs-crate-is-not-bcachefs.md).grants listdoes not say that a revoked package still running keeps its folder until it ends. The supervisor keeps no process handle for a launch.🤖 Generated with Claude Code
https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C