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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ owns those.
[`card-host` fails to build](docs/DEVELOPMENT.md#card-host-fails-to-build)
lists the builds that need the patches.
- [Versions on crates.io](crates/app-contract/README.md#versions-on-cratesio)
lists the `octosense-app-contract` releases; 1.6.0 matches this tree. Do
lists the `octosense-app-contract` releases; this tree is 1.7.0, which is
not on crates.io yet (the newest there is 1.6.0). Do
not take a published contract as proof that a released host implements its
APIs. This workspace's `[patch.crates-io]` points the crate at
`crates/app-contract`. Never tell readers that a lock file holding 1.2.0 or
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ each one today.
- OctoSense serves `calendar`, `llm` and `news` only to its own system apps.
For `photos` and `youtube` it serves only a `notify` method, to `os.photos`
and `os.youtube`. Not yet: media services for store apps.
- No release serves `wasm` (an app's own `fns/*.wasm` functions, contract
1.7.0). OctoSense runs them only in builds with its `wasm-lab` feature.
- OctoSense runs an app agent's granted `implemented_by: "host-service"`
tools, including tools mapped to a reviewed shared service with
`host_method`, and loads `AGENT.md` and skills as guidance
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ OCTOSENSE_PREVIEW_SIZE=1200x860 OCTOSENSE_APP_DATA="$(mktemp -d)" target/release
- 该 PR 还在 macOS 和 Android 上向声明了 `host-api-v1` 和相应能力的应用提供设备权限方法:`camera.permission.*`、`microphone.permission.*` 和 `location.permission.*`,各含 `status`、`request` 和 `revoke`。在 Android 上,它还提供 `location.get`。只有在前台的应用才能请求权限,并由用户在宿主面板上批准,如果操作系统要求,还要在系统提示中确认([宿主 API 兼容性](docs/HOST-API.zh-CN.md))。
- OctoSense `main` 还要求亲手点按才能批准 GitHub 或 Google 日历的保存,拒绝应用 Agent 发布可执行 Splash(`script`)卡片,并且只保留从 30 天前到 366 天后的 Google 日历日程(尚未进入任何发布版本)。OctoSense 桌面版 0.1.0-beta.2 不具备其中任何一项。
- OctoSense 只向自己的系统应用提供 `calendar`、`llm` 和 `news`。对于 `photos` 和 `youtube`,它只向 `os.photos` 和 `os.youtube` 提供一个 `notify` 方法。尚未提供:面向商店应用的媒体服务。
- 目前没有任何发布版本提供 `wasm`(应用 `fns/*.wasm` 中自带的函数,契约 1.7.0)。OctoSense 只在启用 `wasm-lab` 特性的构建中运行它们。
- OctoSense 运行应用 Agent 已获授权的 `implemented_by: "host-service"` 工具,包括用 `host_method` 映射到已审核共享服务的工具,并把 `AGENT.md` 和技能作为指引加载([应用的 Agent 与工具](docs/PUBLISHING.zh-CN.md#应用的-agent-与工具))。
- 目前还没有任何 OctoSense 发布版本运行 `implemented_by: "app"` 的工具:桌面版 0.1.0-beta.2 会拒绝它们,返回 `app_tool_unavailable`。PR #360 的实现提供 `app_tools.dispatch@1`,会在已打开的应用中运行这类工具,前提是应用声明了 `requires: ["script-tools-v1"]`;应用关闭时返回 `app_not_running`([脚本工具执行](docs/PUBLISHING.zh-CN.md#脚本工具执行script-tools-v1))。

Expand Down
11 changes: 10 additions & 1 deletion crates/app-contract/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@
within `1.x` it only grows.

crates.io has 1.0.0, 1.1.0, 1.2.0, 1.5.0 and 1.6.0; 1.3.0 and 1.4.0 are
unpublished, and 1.5.0 includes their changes.
unpublished, and 1.5.0 includes their changes. 1.7.0 is not published yet.
[README.md](README.md#versions-on-cratesio) shows how to update a lock file
that holds 1.2.0.

## 1.7.0

- `KNOWN_CAPABILITIES` gains `wasm`: the app's own functions, WebAssembly
modules in its bundle's `fns/`, run by the host's `wasm` service in a
sandbox with a deadline and a memory cap. A function gets its input and
returns its output; it reaches no file, network, clock or other app. The
grant implies no other capability. When 1.7.0 was cut, OctoSense served
`wasm` only in builds with its `wasm-lab` feature, and no release had it.

## 1.6.0

- Adds signed `host_api` requirements with exact ABI-major versions, implemented
Expand Down
2 changes: 1 addition & 1 deletion crates/app-contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# and no path dependencies. Within 1.x it only grows (see README.md).
[package]
name = "octosense-app-contract"
version = "1.6.0"
version = "1.7.0"
edition = "2021"
license = "Apache-2.0"
description = "The OctoSense app contract: app manifests, the policy an app gets, package integrity and running a package"
Expand Down
3 changes: 2 additions & 1 deletion crates/app-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ limits.

crates.io has 1.0.0, 1.1.0, 1.2.0, 1.5.0 and 1.6.0. Versions 1.3.0 and 1.4.0 exist
only in this repository ([CHANGELOG.md](CHANGELOG.md)); 1.5.0 includes their
changes. A lock file that still holds 1.2.0 refuses every capability added
changes. 1.7.0, which adds `wasm`, is in this repository and not yet on
crates.io. A lock file that still holds 1.2.0 refuses every capability added
since, such as `auth`:

```text
Expand Down
5 changes: 5 additions & 0 deletions crates/app-contract/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ pub const KNOWN_CAPABILITIES: &[&str] = &[
// inside its domain lists. More reach than `research`, which only reads
// search results; neither implies the other.
"crawl",
// Run the app's own WebAssembly functions (its bundle's `fns/*.wasm`)
// through the host's `wasm` service: computing only, in a sandbox with a
// deadline and a memory cap. A function reaches no file, network, clock
// or other app; it gets its input and returns its output.
"wasm",
// Host services reached by exact name (App Hub's `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
Expand Down
15 changes: 15 additions & 0 deletions crates/app-contract/tests/growth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ fn media_services_require_exact_separate_grants() {
}
}

#[test]
fn an_apps_own_functions_are_a_grant_of_their_own() {
let limits = HostLimits::system();
let manifest = parse(&manifest_with(r#""capabilities":["wasm"]"#)).unwrap();
let policy = resolve(&manifest, &limits).unwrap();
assert!(policy.allows("wasm"));
for other in ["net", "storage", "model", "glance", "mail", "runtime"] {
assert!(!policy.allows(other), "wasm must not imply {other}");
}
for near in ["wasm.*", "wasm.find_slots", "WASM"] {
let manifest = parse(&manifest_with(&format!(r#""capabilities":["{near}"]"#))).unwrap();
assert!(resolve(&manifest, &limits).is_err(), "{near} must be unknown");
}
}

#[test]
fn an_unknown_required_feature_needs_a_newer_host() {
let err = parse(&manifest_with(r#""requires":["storage.encrypted"]"#)).unwrap_err();
Expand Down
22 changes: 22 additions & 0 deletions crates/app-hub/src/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ pub fn validate(root: &Path, files: &[BundleFile]) -> (Vec<Finding>, Vec<Resourc
}),
"png" | "jpg" | "jpeg" | "webp" => validate_bitmap(&path, &extension, is_icon),
"svg" => validate_svg(&path, &name, &mut refs, is_icon),
"wasm" => validate_wasm(&path, &name),
_ => Ok(()),
};
if let Err(error) = result {
Expand Down Expand Up @@ -424,6 +425,27 @@ fn check_svg_css(css: &str) -> Result<(), String> {
Ok(())
}

/// An app's own function module, run by the host's `wasm` service under the
/// `wasm` capability: a WebAssembly core module at `fns/<name>.wasm`. Its
/// imports and exports are the host's to check when it loads the module.
fn validate_wasm(path: &Path, name: &str) -> Result<(), String> {
let stem = name
.strip_prefix("fns/")
.and_then(|rest| rest.strip_suffix(".wasm"))
.ok_or("a WebAssembly module belongs in fns/, as fns/<name>.wasm")?;
if stem.is_empty()
|| stem.len() > 64
|| !stem.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
return Err("a function module is named fns/<name>.wasm, the name [a-z0-9_-] and at most 64 characters".into());
}
let header = read_bounded(path, MAX_BUNDLE_BYTES)?;
if !header.starts_with(b"\0asm\x01\0\0\0") {
return Err("not a WebAssembly core module (magic and version 1)".into());
}
Ok(())
}

fn validate_svg(path: &Path, name: &str, refs: &mut Vec<ResourceReference>, icon: bool) -> Result<(), String> {
let text = read_text(path, MAX_TEXT_BYTES)?;
let document = roxmltree::Document::parse_with_options(&text, roxmltree::ParsingOptions { nodes_limit: 4096, ..Default::default() })
Expand Down
33 changes: 31 additions & 2 deletions crates/app-hub/src/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ use std::path::Path;

/// Everything a bundle may hold besides its manifest, by extension. A bundle
/// is cards, data and artwork; anything else is a refusal, so a publisher
/// cannot smuggle a payload the checks do not understand.
/// cannot smuggle a payload the checks do not understand. The one exception
/// is the app's own functions, `fns/*.wasm` (below).
const ALLOWED_EXTENSIONS: &[&str] = &["card", "json", "l0", "octoscript", "splash", "svg", "png", "jpg", "jpeg", "webp", "ttf", "otf", "txt", "md"];

/// The most WebAssembly modules (`fns/*.wasm`) a bundle may carry.
pub const MAX_FUNCTION_MODULES: usize = 8;

/// Ceiling for a whole bundle. Cards are text and artwork; a bundle bigger
/// than this is either shipping something it should not, or should be split.
pub const MAX_BUNDLE_BYTES: u64 = 8 * 1024 * 1024;
Expand Down Expand Up @@ -167,12 +171,15 @@ pub fn check_bundle(

// ---- contents -------------------------------------------------------
let mut total = 0u64;
let mut modules = 0usize;
for file in list_files(bundle)? {
let path = bundle.join(&file);
let size = std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
total += size;
let extension = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) {
if extension == "wasm" {
modules += 1;
} else if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) {
findings.push(Finding::refuse(
"contents",
format!("{} has extension {extension:?}, which a bundle may not hold", file.display()),
Expand All @@ -183,6 +190,28 @@ pub fn check_bundle(
findings.push(Finding::refuse("size", format!("the bundle is {total} bytes, over the {MAX_BUNDLE_BYTES} ceiling")));
}

// ---- the app's own functions ----------------------------------------
// A WebAssembly module is the one executable a bundle may carry: the
// app's own functions in `fns/` (admission checks each name and header),
// run by the host's `wasm` service in a sandbox, under the `wasm`
// capability a store shows the person.
let declares_wasm = manifest.capabilities.iter().any(|c| c == "wasm");
if modules > 0 && !declares_wasm {
findings.push(Finding::refuse(
"functions",
format!("the bundle carries {modules} WebAssembly module(s) but does not declare the wasm capability"),
));
}
if modules > MAX_FUNCTION_MODULES {
findings.push(Finding::refuse(
"functions",
format!("the bundle carries {modules} WebAssembly modules, over the {MAX_FUNCTION_MODULES} it may"),
));
}
if declares_wasm && modules == 0 {
findings.push(Finding::warn("functions", "the bundle declares the wasm capability but carries no fns/*.wasm"));
}

// ---- assets are local ----------------------------------------------
// ADR 0002's prototype found a card with no network grant fetching nine
// images over HTTP, because the resource loader is not the network module
Expand Down
1 change: 1 addition & 0 deletions crates/app-hub/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ impl Entry {
"news" => "Read news the device collects from its feeds and topics".to_string(),
"photos" => "Read Photos's own library and publish collections".to_string(),
"youtube" => "Search YouTube and manage music recommendations".to_string(),
"wasm" => "Run its own sandboxed functions on this device".to_string(),
"glance" => "Show cards on your glance screen".to_string(),
"model" => "Send what you give it to the AI provider you configured, within a daily budget".to_string(),
"research" => format!("Search {}", octosense_app_policy::search_words(&self.manifest.shown_research_scope())),
Expand Down
62 changes: 62 additions & 0 deletions crates/app-hub/tests/bundle_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,65 @@ fn theme_font_token_is_checked_even_without_a_component_reference() {
assert!(!report.passed());
assert!(report.findings.iter().any(|r| r.check == "resource-invalid" && r.path.as_deref() == Some("kit/native/light/kit.json/tokens/typography.body.font_src/value")));
}

// ---- the app's own functions (fns/*.wasm, the wasm capability) ----------

/// The smallest valid WebAssembly core module: magic and version, no sections.
const MODULE: &[u8] = b"\0asm\x01\0\0\0";

fn with_functions(capability: bool, files: &[(&str, &[u8])]) -> octosense_app_hub::GateReport {
let mut f = Fixture::new();
for (name, bytes) in files {
let path = f.bundle.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
if capability {
f.manifest.capabilities.push("wasm".into());
}
f.sign();
f.report(None)
}

#[test]
fn an_apps_own_functions_pass_only_under_the_wasm_capability() {
let report = with_functions(true, &[("fns/rank.wasm", MODULE)]);
assert!(report.passed(), "{}", report.render());
let report = with_functions(false, &[("fns/rank.wasm", MODULE)]);
assert!(!report.passed());
assert!(report.findings.iter().any(|f| f.check == "functions"), "{}", report.render());
}

#[test]
fn a_module_out_of_place_misnamed_or_not_wasm_is_refused() {
for (name, bytes) in [
("rank.wasm", MODULE),
("lib/rank.wasm", MODULE),
("fns/deep/rank.wasm", MODULE),
("fns/Rank.wasm", MODULE),
("fns/.wasm", MODULE),
("fns/rank.wasm", b"\0asm\x02\0\0\0".as_slice()),
("fns/rank.wasm", b"\0asm".as_slice()),
("fns/rank.wasm", b"#!/bin/sh\necho hi\n".as_slice()),
] {
let report = with_functions(true, &[(name, bytes)]);
assert!(!report.passed(), "{name} {bytes:?} must be refused: {}", report.render());
}
}

#[test]
fn a_bundle_carries_at_most_eight_modules_and_hears_about_none() {
let max = octosense_app_hub::gate::MAX_FUNCTION_MODULES;
let names: Vec<String> = (0..=max).map(|i| format!("fns/f{i}.wasm")).collect();
let files: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), MODULE)).collect();
let report = with_functions(true, &files[..max]);
assert!(report.passed(), "{}", report.render());
assert!(!with_functions(true, &files).passed());
let report = with_functions(true, &[]);
assert!(report.passed(), "{}", report.render());
assert!(
report.findings.iter().any(|f| f.check == "functions" && f.severity == octosense_app_hub::Severity::Warning),
"{}",
report.render()
);
}
2 changes: 1 addition & 1 deletion crates/app-policy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ description = "Signed app manifests resolved into splash-isolate settings and oc
# consumer that takes this crate from git (the OctoSense shell) must link the
# same crates.io copy as every app (Rinx), or Cargo builds two contracts. The
# workspace's [patch.crates-io] points it at crates/app-contract here.
octosense-app-contract = "1.6"
octosense-app-contract = "1.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Only for the splash adapter: the policy core never touches makepad, so the
Expand Down
8 changes: 8 additions & 0 deletions crates/app-policy/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,14 @@ fn check_host_method(tool: &ToolSpec) -> Result<(), String> {
if method.split('.').any(|part| part == "sheet") {
return Err("host_method cannot target a host sheet or approve an action".into());
}
// The app's own WebAssembly function (`wasm.<function>`, contract 1.7):
// the host runs the bundle's module in a sandbox where a function
// reaches nothing but its input, so no shared data sets a risk floor or
// calls for `private_data`. `check` still requires the app to declare
// the `wasm` capability.
if method.split('.').count() == 2 && method.starts_with("wasm.") {
return Ok(());
}
let Some((_, minimum)) = SHARED_HOST_METHODS.iter().find(|(name, _)| *name == method) else {
return Err(format!(
"host_method {method:?} is not in the reviewed shared-service tool contract"
Expand Down
1 change: 1 addition & 0 deletions crates/app-policy/src/listing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ pub fn privacy_summary_with_tools(manifest: &AppManifest, tools: &[ToolSpec]) ->
("news", "Reads news the device collects from its feeds and topics."),
("photos", "Reads Photos's own library and publishes photo collections."),
("youtube", "Searches YouTube videos and manages music recommendations; playback requires a tap."),
("wasm", "Runs its own functions in a sandbox on this device; they reach no files, network or other apps."),
("glance", "Shows short cards on your glance screen; each opens only this app."),
("model", "Sends what you give it to the AI provider you configured, for one-off answers within a daily budget; it never sees your API keys."),
] {
Expand Down
34 changes: 34 additions & 0 deletions crates/app-policy/tests/agent_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,40 @@ fn shared_local_mutations_keep_risk_and_private_data_floors() {
.contains("private_data: true"));
}

#[test]
fn an_apps_own_wasm_function_is_a_host_method_behind_the_wasm_capability() {
// `wasm.<function>` runs the bundle's own module, so it is in no shared
// list and has no risk or private-data floor.
let make = |method: &str| {
json!({"schema":1,"tools":[{
"name":"inbox.slots","description":"Find free slots in a day.",
"input_schema":{"type":"object"},"output_schema":{"type":"object"},
"risk":"read","implemented_by":"host-service","host_method":method
}]})
.to_string()
};
let (tools, _) =
ToolManifest::load(&make("wasm.find_slots"), "inbox", ToolHost::Contained, false).unwrap();
assert_eq!(tools.tools[0].service_method(), "wasm.find_slots");
for method in ["wasm.sheet", "wasm.find_slots.extra", "wasm.Find", "wasm."] {
assert!(
ToolManifest::load(&make(method), "inbox", ToolHost::Contained, false).is_err(),
"{method}"
);
}
let dir = scratch("wasm-host-method");
edit_tools(&dir, |tools| {
tools["tools"][0]["host_method"] = json!("wasm.find_slots");
});
let manifest = stamp(&dir, |_| {});
refused_with(&dir, &manifest, "requires the declared \"wasm\" service capability");
let manifest = stamp(&dir, |m| {
m["capabilities"].as_array_mut().unwrap().push(json!("wasm"))
});
let loaded = AgentBundle::load(&dir, &manifest).unwrap().unwrap();
assert_eq!(loaded.tool("news.list").unwrap().service_method(), "wasm.find_slots");
}

#[test]
fn omitted_host_method_keeps_legacy_serialization_and_dispatch() {
let original = read_json(&fixture().join(TOOLS_FILE));
Expand Down
Loading
Loading