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