Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/app-hub/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/app-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions crates/app-policy/src/listing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,25 @@ pub fn privacy_summary(manifest: &AppManifest) -> Vec<String> {
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{}.",
Expand Down
54 changes: 54 additions & 0 deletions crates/app-policy/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions crates/app-policy/src/services.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
51 changes: 51 additions & 0 deletions crates/app-policy/tests/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
23 changes: 23 additions & 0 deletions docs/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down