From 1b0b6d6a633a4ef6303edc6d1c29a69c186b3c97 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:51:54 -0700 Subject: [PATCH] app-policy: publish the Matrix and Octos host-service capabilities Admit the exact A2App-compatible `matrix.*` service names and the four `octos.*` assistant services (`octos.session.open`, `octos.session.history`, `octos.turn.start`, `octos.turn.interrupt`) that Rinx serves to mini apps. Admission matches exact registered names: a prefix such as `octos.` or an invented `octos.admin` stays an unknown capability, and history access does not grant starting a turn. The store tells the person what each service allows in plain words, and the privacy summary separates Matrix reads from Matrix actions. This is the capability contract ADR 0007 (Rinx) relies on for host-owned Octos app peers. It replaces the untested revision b0591e2c (#8). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/app-hub/src/index.rs | 5 +- crates/app-policy/src/lib.rs | 2 + crates/app-policy/src/listing.rs | 19 ++++ crates/app-policy/src/manifest.rs | 54 ++++++++++ crates/app-policy/src/services.rs | 150 +++++++++++++++++++++++++++ crates/app-policy/tests/admission.rs | 51 +++++++++ docs/PUBLISHING.md | 23 ++++ 7 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 crates/app-policy/src/services.rs diff --git a/crates/app-hub/src/index.rs b/crates/app-hub/src/index.rs index 2fa05fa..d94d321 100644 --- a/crates/app-hub/src/index.rs +++ b/crates/app-hub/src/index.rs @@ -91,7 +91,10 @@ impl Entry { "library" => "Save to your photo library, where other apps can see it".to_string(), "mail" => "Read and send mail from accounts you sign in to on the device".to_string(), "llm" => "Manage the assistant's AI providers, whose keys stay with the device".to_string(), - other => format!("Use {other}"), + other => match octosense_app_policy::service_words(other) { + Some(words) => words.to_string(), + None => format!("Use {other}"), + }, }); } if let Some(agent) = &self.manifest.agent { diff --git a/crates/app-policy/src/lib.rs b/crates/app-policy/src/lib.rs index 4da32e7..6c3024f 100644 --- a/crates/app-policy/src/lib.rs +++ b/crates/app-policy/src/lib.rs @@ -36,6 +36,7 @@ pub mod listing; pub mod splash_adapter; pub mod manifest; pub mod policy; +pub mod services; pub mod verify; pub use assets::{rewrite_assets, AssetServer, StaticAssets}; @@ -45,6 +46,7 @@ pub use entry::{script_source, ASSETS_PLACEHOLDER, SCRIPT_ENTRY}; pub use listing::{privacy_summary, Listing, Publisher, LISTING_FILE}; pub use manifest::{AgentSpec, AppManifest, ProfileMode, KNOWN_CAPABILITIES, SCHEMA}; pub use policy::{AgentPolicy, AppPolicy, HostLimits}; +pub use services::{is_host_service, service_words, MATRIX_ACTIONS, OCTOS_SERVICES}; pub use verify::{admit, admit_digest, bundle_digest, RefuseAllSignatures, SignatureVerifier}; /// The whole admission path for a bundle that is a directory: parse, check diff --git a/crates/app-policy/src/listing.rs b/crates/app-policy/src/listing.rs index 8d36651..e86f16f 100644 --- a/crates/app-policy/src/listing.rs +++ b/crates/app-policy/src/listing.rs @@ -167,6 +167,25 @@ pub fn privacy_summary(manifest: &AppManifest) -> Vec { lines.push(text.to_string()); } } + let matrix_reads = manifest + .capabilities + .iter() + .any(|c| c.starts_with("matrix.") && !crate::services::MATRIX_ACTIONS.contains(&c.as_str())); + let matrix_acts = manifest + .capabilities + .iter() + .any(|c| crate::services::MATRIX_ACTIONS.contains(&c.as_str())); + if matrix_reads { + lines.push("Reads from your Matrix account, only in the rooms you allow.".to_string()); + } + if matrix_acts { + lines.push("Acts on your Matrix account, only in the rooms you allow.".to_string()); + } + if has("octos.turn.start") { + lines.push("Asks the device's assistant to work for it; the assistant's keys stay with the device.".to_string()); + } else if has("octos.session.open") || has("octos.session.history") { + lines.push("Opens or reads its own conversations with the device's assistant, but cannot ask it to work.".to_string()); + } match &manifest.agent { Some(agent) => lines.push(format!( "Runs an assistant limited to this app's own data{}.", diff --git a/crates/app-policy/src/manifest.rs b/crates/app-policy/src/manifest.rs index 5eacdc4..849a492 100644 --- a/crates/app-policy/src/manifest.rs +++ b/crates/app-policy/src/manifest.rs @@ -47,6 +47,60 @@ pub const KNOWN_CAPABILITIES: &[&str] = &[ // service. Keys are typed, shown as a QR and scanned only on the host's // own sheets; the app sees masked status, never a key. "llm", + // Host services reached by exact name (see [`crate::services`]). Each is + // a separate consent: a host adapter checks the exact name, the person's + // per-instance grant and its own ceilings on every request. A prefix is + // never a grant: `octos.` or `matrix.` alone is an unknown capability, + // and history access does not imply starting a turn. + "matrix.account_info", + "matrix.device", + "matrix.dm_find", + "matrix.dm_open", + "matrix.event", + "matrix.favorite", + "matrix.ignored_users", + "matrix.invite", + "matrix.invite_respond", + "matrix.invites", + "matrix.join", + "matrix.low_priority", + "matrix.mark_unread", + "matrix.older_messages", + "matrix.permalink", + "matrix.pin", + "matrix.pinned_events", + "matrix.power_levels", + "matrix.profile", + "matrix.react", + "matrix.read_messages", + "matrix.read_receipt", + "matrix.read_receipts", + "matrix.reply", + "matrix.room_info", + "matrix.room_members", + "matrix.room_preview", + "matrix.room_threads", + "matrix.rooms_info", + "matrix.rooms_list", + "matrix.rooms_messages", + "matrix.rooms_search", + "matrix.rooms_send", + "matrix.search_room", + "matrix.search_rooms", + "matrix.send_message", + "matrix.space_info", + "matrix.space_rooms", + "matrix.spaces", + "matrix.successor", + "matrix.thread_replies", + "matrix.thread_reply", + "matrix.typing", + "matrix.unread", + "matrix.user_profile", + "octos.session.open", + "octos.session.history", + "octos.turn.start", + "octos.turn.interrupt", ]; /// The permission profiles an app's agent session may ask for. Full access is diff --git a/crates/app-policy/src/services.rs b/crates/app-policy/src/services.rs new file mode 100644 index 0000000..0c96990 --- /dev/null +++ b/crates/app-policy/src/services.rs @@ -0,0 +1,150 @@ +//! Host services an app reaches by exact name: `matrix.*` for the person's +//! Matrix account (Rinx) and `octos.*` for the device's assistant. +//! +//! These names are the published contract between a bundle and every host +//! that runs it. A host admits a bundle only when each requested name is in +//! [`crate::KNOWN_CAPABILITIES`] exactly; a prefix is never a grant, so +//! `octos.` or `octos.admin` is an unknown capability. Admission is not +//! dispatch either: the host still intersects the declaration with the +//! services it supports, its own policy and the person's per-instance grant, +//! and checks the resulting lease on every request. Reading assistant +//! history does not imply starting a turn; each service is its own consent. +//! +//! The assistant's model provider, credentials and kernel belong to the host. +//! No `octos.*` service lets an app choose a provider, submit a key or reach +//! the kernel's raw protocol. + +/// The assistant services, in the order a store lists them. +pub const OCTOS_SERVICES: &[&str] = &[ + "octos.session.open", + "octos.session.history", + "octos.turn.start", + "octos.turn.interrupt", +]; + +/// The Matrix services that change something on the person's account or in +/// a room (as opposed to reading). A store summarises them separately so a +/// person sees that the app can act, not only look. +pub const MATRIX_ACTIONS: &[&str] = &[ + "matrix.dm_open", + "matrix.favorite", + "matrix.invite", + "matrix.invite_respond", + "matrix.join", + "matrix.low_priority", + "matrix.mark_unread", + "matrix.pin", + "matrix.react", + "matrix.read_receipt", + "matrix.reply", + "matrix.rooms_send", + "matrix.send_message", + "matrix.thread_reply", + "matrix.typing", +]; + +/// Whether `capability` is one of the exact Matrix or assistant service names. +pub fn is_host_service(capability: &str) -> bool { + service_words(capability).is_some() +} + +/// What a store tells the person a service capability allows, in plain words. +/// `None` for anything that is not an exact service name. +pub fn service_words(capability: &str) -> Option<&'static str> { + Some(match capability { + "matrix.account_info" => "See which Matrix account you are using", + "matrix.device" => "See this device's Matrix session details", + "matrix.dm_find" => "Find your direct chats with a person", + "matrix.dm_open" => "Start a direct chat on your Matrix account", + "matrix.event" => "Read a single message in rooms you allow", + "matrix.favorite" => "Mark rooms as favourites", + "matrix.ignored_users" => "See whom you have ignored", + "matrix.invite" => "Invite people to rooms you allow", + "matrix.invite_respond" => "Accept or decline room invitations", + "matrix.invites" => "See your room invitations", + "matrix.join" => "Join rooms on your Matrix account", + "matrix.low_priority" => "Mark rooms as low priority", + "matrix.mark_unread" => "Mark rooms as read or unread", + "matrix.older_messages" => "Read earlier messages in rooms you allow", + "matrix.permalink" => "Make links to messages in rooms you allow", + "matrix.pin" => "Pin messages in rooms you allow", + "matrix.pinned_events" => "Read pinned messages in rooms you allow", + "matrix.power_levels" => "See who may do what in rooms you allow", + "matrix.profile" => "See your Matrix name and picture", + "matrix.react" => "React to messages in rooms you allow", + "matrix.read_messages" => "Read messages in rooms you allow", + "matrix.read_receipt" => "Mark messages as read in rooms you allow", + "matrix.read_receipts" => "See who has read messages in rooms you allow", + "matrix.reply" => "Reply to messages in rooms you allow", + "matrix.room_info" => "See details of rooms you allow", + "matrix.room_members" => "See the members of rooms you allow", + "matrix.room_preview" => "Preview rooms before joining", + "matrix.room_threads" => "List threads in rooms you allow", + "matrix.rooms_info" => "See details of your rooms", + "matrix.rooms_list" => "List your rooms", + "matrix.rooms_messages" => "Read recent messages across rooms you allow", + "matrix.rooms_search" => "Search your rooms", + "matrix.rooms_send" => "Send messages to rooms you allow", + "matrix.search_room" => "Search messages in rooms you allow", + "matrix.search_rooms" => "Search public rooms", + "matrix.send_message" => "Send messages in rooms you allow", + "matrix.space_info" => "See details of your spaces", + "matrix.space_rooms" => "List the rooms in your spaces", + "matrix.spaces" => "List your spaces", + "matrix.successor" => "Follow upgraded rooms to their new room", + "matrix.thread_replies" => "Read thread replies in rooms you allow", + "matrix.thread_reply" => "Reply in threads in rooms you allow", + "matrix.typing" => "Show that you are typing in rooms you allow", + "matrix.unread" => "See unread counts for your rooms", + "matrix.user_profile" => "See other people's Matrix names and pictures", + "octos.session.open" => "Open its own conversation with the assistant", + "octos.session.history" => "Read its own conversations with the assistant", + "octos.turn.start" => { + "Ask the assistant to work for it, using the device's AI settings" + } + "octos.turn.interrupt" => "Stop assistant work it started", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::KNOWN_CAPABILITIES; + + #[test] + fn every_service_name_is_a_known_capability_and_has_words() { + let services: Vec<&str> = KNOWN_CAPABILITIES + .iter() + .copied() + .filter(|c| c.starts_with("matrix.") || c.starts_with("octos.")) + .collect(); + assert_eq!(services.len(), 49, "45 Matrix and 4 assistant services"); + for capability in services { + assert!(service_words(capability).is_some(), "{capability} has no words"); + } + for capability in OCTOS_SERVICES.iter().chain(MATRIX_ACTIONS) { + assert!(KNOWN_CAPABILITIES.contains(capability), "{capability} is not published"); + } + } + + #[test] + fn a_prefix_or_a_near_name_is_not_a_service() { + for name in [ + "octos.", + "octos", + "octos.session", + "octos.session.open.all", + "octos.admin", + "octos.kernel.rpc", + "OCTOS.turn.start", + " octos.turn.start", + "matrix.", + "matrix.admin", + "matrix.send_message ", + ] { + assert!(!is_host_service(name), "{name:?} must not be a service"); + assert!(!KNOWN_CAPABILITIES.contains(&name), "{name:?} must not be known"); + } + } +} diff --git a/crates/app-policy/tests/admission.rs b/crates/app-policy/tests/admission.rs index 1676f1e..6c9f44b 100644 --- a/crates/app-policy/tests/admission.rs +++ b/crates/app-policy/tests/admission.rs @@ -230,3 +230,54 @@ fn a_directory_bundle_is_admitted_by_its_precomputed_digest() { .unwrap_err(); assert!(err.contains("does not match the manifest"), "{err}"); } + +// ------------------------------------------------------- host services + +#[test] +fn each_published_assistant_service_is_admitted_by_its_exact_name() { + for service in OCTOS_SERVICES { + let policy = resolve(&format!(r#""capabilities":["{service}"]"#)).unwrap(); + assert!(policy.allows(service), "{service}"); + assert_eq!(policy.capabilities.len(), 1, "{service} grants nothing else"); + } +} + +#[test] +fn an_assistant_prefix_or_invented_service_is_refused() { + for name in ["octos.", "octos.session", "octos.admin", "octos.peer.prepare", "octos.session.open.all", "Octos.turn.start"] { + let err = resolve(&format!(r#""capabilities":["{name}"]"#)).unwrap_err(); + assert!(err.contains("unknown capability"), "{name}: {err}"); + } +} + +#[test] +fn reading_assistant_history_does_not_grant_starting_a_turn() { + let policy = resolve(r#""capabilities":["octos.session.open","octos.session.history"]"#).unwrap(); + assert!(policy.allows("octos.session.history")); + assert!(!policy.allows("octos.turn.start")); + assert!(!policy.allows("octos.turn.interrupt")); + assert!(policy.agent.is_none(), "a service grant is not an app agent"); +} + +#[test] +fn matrix_services_are_exact_and_reading_does_not_grant_sending() { + let policy = resolve(r#""capabilities":["matrix.read_messages","matrix.profile"]"#).unwrap(); + assert!(policy.allows("matrix.read_messages")); + assert!(!policy.allows("matrix.send_message")); + let err = resolve(r#""capabilities":["matrix.admin"]"#).unwrap_err(); + assert!(err.contains("unknown capability"), "{err}"); +} + +#[test] +fn the_store_says_what_a_service_grant_allows() { + let manifest = AppManifest::parse(&manifest_with( + r#""capabilities":["matrix.read_messages","matrix.send_message","octos.session.history"]"#, + )) + .unwrap(); + let lines = privacy_summary(&manifest); + assert!(lines.iter().any(|l| l.starts_with("Reads from your Matrix account")), "{lines:?}"); + assert!(lines.iter().any(|l| l.starts_with("Acts on your Matrix account")), "{lines:?}"); + assert!(lines.iter().any(|l| l.contains("cannot ask it to work")), "{lines:?}"); + let manifest = AppManifest::parse(&manifest_with(r#""capabilities":["octos.turn.start"]"#)).unwrap(); + assert!(privacy_summary(&manifest).iter().any(|l| l.contains("keys stay with the device"))); +} diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index f6d1fa2..668fa40 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -139,6 +139,29 @@ they install. Location, camera and clipboard are each a separate consent; none implies another. +**Host services by exact name** (`crates/app-policy/src/services.rs`). A host +that offers the Matrix account (Rinx) or the device's assistant (Octos) serves +these to a bundle that requests them. Each name is its own consent, checked +exactly: a prefix such as `octos.` or `matrix.`, or any name not listed here, +is an unknown capability and the manifest is refused. Admission is not +dispatch: the host also intersects the request with the services it supports, +its policy and the person's per-instance grant (for Matrix, the rooms they +allow), and checks that lease on every call. A host that does not offer a +requested service shows the app as unavailable with the reason. + +| Capability | Grants | The store says | +| --- | --- | --- | +| `octos.session.open` | Open the app's own conversation with the host's assistant. The host binds it to this app and the current account; the app never names a profile, provider or workspace. | Open its own conversation with the assistant | +| `octos.session.history` | Read that conversation's history. Does not allow starting a turn. | Read its own conversations with the assistant | +| `octos.turn.start` | Send a request the assistant works on, under the host's AI settings and tool limits. The model provider and its keys stay with the host. | Ask the assistant to work for it, using the device's AI settings | +| `octos.turn.interrupt` | Stop a turn this app started. | Stop assistant work it started | +| `matrix.*` (45 names) | One Matrix operation each, on the person's current account, in the rooms they allow: reads such as `matrix.read_messages`, `matrix.room_members`, `matrix.profile`; actions such as `matrix.send_message`, `matrix.react`, `matrix.join`. The exact list is `KNOWN_CAPABILITIES`. | One plain line per name, for example "Read messages in rooms you allow" | + +Sending room data to the assistant needs both the Matrix read grant and the +assistant grant. No `octos.*` service chooses a model provider, submits a key +or reaches the kernel's raw protocol. + + **Network**: `net` plus an exact host list. The list is enforced on every path out of the isolate: the network module, artwork loading and data fetches. An empty list with `net` reaches nothing. Hosts match exactly: listing