diff --git a/crates/ai-host/src/contained.rs b/crates/ai-host/src/contained.rs index 61c235f2..fc960e2c 100644 --- a/crates/ai-host/src/contained.rs +++ b/crates/ai-host/src/contained.rs @@ -39,6 +39,18 @@ pub const UNAVAILABLE: &str = "The assistant is not available on this device"; pub const UNSUPPORTED_ARGS: &str = "Unsupported Octos arguments"; pub const BAD_TEXT: &str = "Provide text (at most 32 KiB)"; pub const NO_SHEET: &str = "The assistant has no sheet; octos calls come from the app"; +pub const NO_CONSENT: &str = "Waiting for the person to allow this app's agent (OctoSense asks the first time)"; + +/// The shell's consent at first use (ADR 0004 §4): whether an app may have +/// its agent now; asking the person the first time is the shell's part. +/// Unset (a host without a consent surface, and this crate's tests): no +/// gate beyond the policy switch. +static CONSENT: std::sync::OnceLock bool> = std::sync::OnceLock::new(); + +/// The shell installs its consent check once, at startup. +pub fn set_consent(check: fn(&str) -> bool) { + let _ = CONSENT.set(check); +} /// Where contained apps' peers come from. pub trait PeerFactory: Send + Sync { @@ -202,6 +214,9 @@ impl HostService for ContainedOctos { if !self.enabled { return reply.send(Err(TURNED_OFF.into())); } + if CONSENT.get().is_some_and(|granted| !granted(&call.app_id)) { + return reply.send(Err(NO_CONSENT.into())); + } let op = match parse(&call.service, &call.args) { Ok(op) => op, Err(e) => return reply.send(Err(e)), diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index adf1dfef..3dd22460 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -23,7 +23,8 @@ doctest = false [dependencies] # The status clock is formatted in-process (localtime_r + strftime), not by forking `date`. libc = "0.2" -# App storage (ADR 0004 §11): the account hash that names account folders. +# App storage (ADR 0004 §11): the account hash that names account folders; +# the approvals audit's digest of each call's arguments. sha2 = "0.10" serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/shell/src/ai_bus.rs b/crates/shell/src/ai_bus.rs index fd79a4a3..2b447c71 100644 --- a/crates/shell/src/ai_bus.rs +++ b/crates/shell/src/ai_bus.rs @@ -86,24 +86,56 @@ pub fn card_shows_in_full(args: &str) -> bool { /// The shell's rules for an app's assistant tools, from its native-apps.json /// entry (`agent.tool_policy`, ADR 0004 §8, §10): a `confirm: host` tool is -/// registered as destructive with no claim that the app confirms it, so the -/// pane parks every call on its live confirm card; and a call whose exact -/// arguments that card cannot show whole never reaches the app. Nothing on -/// the bus answers a confirmation for the person (there are no standing -/// rules yet); `auto_approvable: false` is what keeps it so when there are. +/// confirmed by the shell's approval router (`approvals/`), not by the +/// pane's card. It is registered with no claim that the app confirms it and +/// as `Act`, so the pane does not ask a second time; each call is held +/// ([`Route::Approval`]) until the router answers: developer mode first, +/// then the person on the shell's sheet, which shows the exact arguments in +/// full, or a standing rule where the tool is `auto_approvable`. The +/// Terminal's `run` is not, so every typed command asks the person (outside +/// developer mode). fn host_rules(app: &str) -> Option<&'static crate::native_apps::NativeApp> { crate::native_apps::find(app).filter(|entry| !entry.tools.is_empty()) } -fn host_confirmed(rules: Option<&crate::native_apps::NativeApp>, tool: &str) -> bool { - rules.and_then(|entry| entry.tool(tool)).is_some_and(|rule| rule.confirm == crate::native_apps::Confirm::Host) +fn host_confirmed(rules: Option<&'static crate::native_apps::NativeApp>, tool: &str) -> bool { + host_rule(rules, tool).is_some() } +fn host_rule(rules: Option<&'static crate::native_apps::NativeApp>, tool: &str) -> Option<&'static crate::native_apps::ToolPolicy> { + rules?.tool(tool).filter(|rule| rule.confirm == crate::native_apps::Confirm::Host) +} + +/// Tools that type a command for the person (ADR 0004 §10, §12): developer +/// mode answers them through `approves_command`. +const COMMAND_TOOLS: &[(&str, &str)] = &[("terminal", "run")]; + +/// A pane call to a `confirm: host` tool, held until the shell's approval +/// router answers it ([`AiBus::release`]). +#[derive(Clone, Debug, PartialEq)] +pub struct HeldCall { + /// The router's request id: `bus::`. + pub key: String, + /// The owning app (its native-apps.json id). + pub app: String, + pub tool: String, + /// The exact arguments, as the pane sent them. + pub args: String, + pub auto_approvable: bool, + pub command: bool, +} + +/// The router's request ids for the bus start with this. +pub const HELD_PREFIX: &str = "bus:"; + /// Apply an app's rules to the manifest it registers. -fn apply_rules(manifest: &mut ServiceManifest, rules: Option<&crate::native_apps::NativeApp>) { +fn apply_rules(manifest: &mut ServiceManifest, rules: Option<&'static crate::native_apps::NativeApp>) { for tool in &mut manifest.tools { if host_confirmed(rules, &tool.name) { - tool.risk = Risk::Destructive; + // The router confirms each call; the pane must not ask again. + if tool.risk == Risk::Destructive { + tool.risk = Risk::Act; + } tool.self_confirm = None; } } @@ -120,6 +152,9 @@ pub enum Route { /// A frame for an IN-PROCESS instance (a module the WM hosts itself): /// the host runs its executor and answers with `local_reply`. Local(ClientId, ServiceDown), + /// A `confirm: host` call: ask the approval router, then + /// [`AiBus::release`] it with the answer. + Approval(HeldCall), Drop, } @@ -136,6 +171,8 @@ pub struct AiBus { /// process by the app id the WM launched it as, an in-process module by /// its (trusted) id. rules: HashMap, + /// `confirm: host` calls waiting for the approval router, by key. + held: HashMap, /// Tests only: answers `auto_approve` instead of the process's /// developer mode. #[cfg(test)] @@ -370,15 +407,21 @@ impl AiBus { } } if let ServiceDown::Call(call) = &down.msg { - // No card in developer mode (it answers the confirmation), so - // nothing needs to fit on one. - let dev = self.manifests.get(&target).is_some_and(|m| self.auto_approves(&m.id)); - if !dev && host_confirmed(self.rules.get(&target).copied(), &call.tool) && !card_shows_in_full(&call.args) { - let refused = ToolResult::refused( - &call.call_id, - format!("the confirmation cannot show these arguments in full (over {CARD_ARGS_BYTES} bytes); send a shorter call"), - ); - return Route::ToPane(HostedUp { from: Some(self.endpoint_for(target)), msg: ServiceUp::Result(refused) }.to_json()); + // A `confirm: host` tool waits for the approval router (the + // shell's sheet shows the arguments in full, so no length + // limit applies; developer mode answers it there first). + if let (Some(entry), Some(rule)) = (self.rules.get(&target).copied(), host_rule(self.rules.get(&target).copied(), &call.tool)) { + let key = format!("{HELD_PREFIX}{}:{}", self.endpoint_for(target).as_str(), call.call_id); + let held = HeldCall { + key: key.clone(), + app: entry.id.to_string(), + tool: call.tool.clone(), + args: call.args.clone(), + auto_approvable: rule.auto_approvable, + command: COMMAND_TOOLS.contains(&(entry.id, rule.tool)), + }; + self.held.insert(key, (target, down.msg.clone())); + return Route::Approval(held); } } return if self.locals.contains(&target) { Route::Local(target, down.msg) } else { Route::ToClient(target, down.to_json()) }; @@ -405,12 +448,34 @@ impl AiBus { Route::ToPane(up.to_json()) } + /// The approval router answered a held call: on to the app, or refused + /// to the pane. A call whose app has gone is dropped. + pub fn release(&mut self, key: &str, approved: bool, reason: &str) -> Route { + let Some((target, msg)) = self.held.remove(key) else { return Route::Drop }; + if approved { + return if self.locals.contains(&target) { + Route::Local(target, msg) + } else { + Route::ToClient(target, HostedDown { to: Some(Self::endpoint_of(target)), msg }.to_json()) + }; + } + let ServiceDown::Call(call) = msg else { return Route::Drop }; + let refused = ToolResult::refused(&call.call_id, format!("not approved: {reason}")); + Route::ToPane(HostedUp { from: Some(self.endpoint_for(target)), msg: ServiceUp::Result(refused) }.to_json()) + } + + /// Calls still waiting for the approval router. + pub fn held(&self) -> usize { + self.held.len() + } + /// A client died: the pane hears an `Unregister` on its behalf. pub fn client_died(&mut self, client: ClientId) -> Option { if self.is_pane(client) { self.pane_client = None; return None; } + self.held.retain(|_, (target, _)| *target != client); self.rules.remove(&client); self.manifests.remove(&client)?; let from = Some(self.endpoint_for(client)); @@ -462,7 +527,7 @@ mod tests { } #[test] - fn the_terminal_types_only_behind_the_hosts_live_confirmation() { + fn the_terminal_types_only_behind_the_approval_routers_answer() { let terminal = |run: ToolDef| { ServiceManifest::new("terminal", "Terminal", "The live terminal.") .with_tool(ToolDef::new("read_screen", "Read the screen.", r#"{"type":"object"}"#, Risk::Read)) @@ -477,39 +542,54 @@ mod tests { }; let mut bus = AiBus { pane_client: Some(9), ..Default::default() }; // Process (w4) and in-process (m6) Terminals register the same way: - // `run` kept, destructive, confirmed by the host's card. + // `run` kept, confirmed by the shell's approval router (not the + // pane's card, and not the app's own sheet). let up = HostedUp { from: None, msg: ServiceUp::Register { manifest: terminal(under()), port_tag: 0 } }; let Route::ToPane(json) = bus.on_custom_from(4, Some("terminal"), &up.to_json()) else { panic!("expected ToPane") }; let local = registered(&bus.register_local(6, terminal(own_sheet()))); for manifest in [registered(&json), local] { assert_eq!(manifest.tools.iter().map(|t| t.name.as_str()).collect::>(), ["read_screen", "run"]); let run = manifest.tool("run").unwrap(); - assert_eq!(run.risk, Risk::Destructive, "the pane parks every call on its confirm card"); + assert_ne!(run.risk, Risk::Destructive, "the pane does not confirm a second time"); assert!(!run.confirms_itself(), "the host draws the confirmation, not the app"); assert_eq!(manifest.tool("read_screen").unwrap().risk, Risk::Read); } let rule = crate::native_apps::find("terminal").unwrap().tool("run").unwrap(); assert_eq!((rule.confirm, rule.auto_approvable), (crate::native_apps::Confirm::Host, false)); - // A confirmed command the card showed whole reaches the terminal. - let short = HostedDown { to: Some(EndpointId("w4".into())), msg: ServiceDown::Call(call("run", r#"{"command":"ls -la"}"#)) }; - assert!(matches!(bus.on_custom(9, &short.to_json()), Route::ToClient(4, _))); - let local_short = HostedDown { to: Some(EndpointId("m6".into())), msg: ServiceDown::Call(call("run", r#"{"command":"ls -la"}"#)) }; - assert!(matches!(bus.on_custom(9, &local_short.to_json()), Route::Local(6, _))); - // One the card could not show whole is refused to the pane, in either hosting. + // Every `run` is held for the router, whatever its length (the + // shell's sheet shows it in full), as a typed command no rule may + // approve; approved, it reaches the terminal in either hosting. let long = format!(r#"{{"command":"echo {}"}}"#, "x".repeat(80)); - for to in ["w4", "m6"] { - let down = HostedDown { to: Some(EndpointId(to.into())), msg: ServiceDown::Call(call("run", &long)) }; - let Route::ToPane(json) = bus.on_custom(9, &down.to_json()) else { panic!("expected a refusal for the pane") }; - let up = HostedUp::parse(&json).unwrap(); - assert_eq!(up.from, Some(EndpointId(to.into()))); - assert!(matches!(up.msg, ServiceUp::Result(ToolResult { outcome: ToolOutcome::Refused, .. }))); + for (to, args) in [("w4", r#"{"command":"ls -la"}"#.to_string()), ("m6", long.clone())] { + let down = HostedDown { to: Some(EndpointId(to.into())), msg: ServiceDown::Call(call("run", &args)) }; + let Route::Approval(held) = bus.on_custom(9, &down.to_json()) else { panic!("expected a held call") }; + assert_eq!(held.key, format!("{HELD_PREFIX}{to}:c")); + assert_eq!((held.app.as_str(), held.tool.as_str(), held.args.as_str()), ("terminal", "run", args.as_str())); + assert!(held.command && !held.auto_approvable); + match bus.release(&held.key, true, "approved on the sheet") { + Route::ToClient(4, _) => assert_eq!(to, "w4"), + Route::Local(6, _) => assert_eq!(to, "m6"), + _ => panic!("expected the call to go on"), + } + assert!(matches!(bus.release(&held.key, true, ""), Route::Drop), "released once"); } + // Denied: refused to the pane, from the terminal's endpoint. + let down = HostedDown { to: Some(EndpointId("w4".into())), msg: ServiceDown::Call(call("run", r#"{"command":"rm -rf x"}"#)) }; + let Route::Approval(held) = bus.on_custom(9, &down.to_json()) else { panic!("expected a held call") }; + let Route::ToPane(json) = bus.release(&held.key, false, "denied on the sheet") else { panic!("expected a refusal") }; + let up = HostedUp::parse(&json).unwrap(); + assert_eq!(up.from, Some(EndpointId("w4".into()))); + assert!(matches!(up.msg, ServiceUp::Result(ToolResult { outcome: ToolOutcome::Refused, .. }))); // Reads pass whatever their length; another app's `run` keeps its declaration. let read = HostedDown { to: Some(EndpointId("w4".into())), msg: ServiceDown::Call(call("read_screen", &long)) }; assert!(matches!(bus.on_custom(9, &read.to_json()), Route::ToClient(4, _))); let up = HostedUp { from: None, msg: ServiceUp::Register { manifest: terminal(under()), port_tag: 0 } }; let Route::ToPane(json) = bus.on_custom_from(5, Some("files"), &up.to_json()) else { panic!("expected ToPane") }; assert_eq!(registered(&json).tool("run").unwrap().risk, Risk::Act); + // A held call of a client that dies is dropped. + let Route::Approval(held) = bus.on_custom(9, &down.to_json()) else { panic!("expected a held call") }; + bus.client_died(4); + assert!(matches!(bus.release(&held.key, true, ""), Route::Drop)); } #[test] diff --git a/crates/shell/src/approvals/audit.rs b/crates/shell/src/approvals/audit.rs new file mode 100644 index 00000000..617c824e --- /dev/null +++ b/crates/shell/src/approvals/audit.rs @@ -0,0 +1,102 @@ +//! The approvals audit (ADR 0004 §8 "after the fact"): every decision, and +//! above all every automatic one, as one JSON line in [`AUDIT_FILE`] under +//! the home: append-only, owner-only (0600). Each entry names the rule, +//! the owning app, the tool, a digest of the exact arguments (never the +//! arguments), the caller, the time and the result. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Relative to the OctoSense home. +pub const AUDIT_FILE: &str = "logs/approvals-audit.jsonl"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + /// Unix seconds. + pub ts: u64, + pub id: String, + pub app: String, + pub tool: String, + pub args_digest: String, + pub caller: String, + pub trigger: String, + /// Who answered: `developer_mode`, `rule`, `person`, `app_sheet`, + /// `timeout`, `refused`. + pub by: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule: Option, + /// `approved` or `denied`. + pub result: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +impl Entry { + pub fn automatic(&self) -> bool { + self.by == "developer_mode" || self.by == "rule" + } +} + +#[derive(Debug, Default)] +pub struct AuditLog { + path: Option, + /// The session's entries, newest last (what Settings shows without + /// re-reading the file; a memory log keeps only these). + recent: Vec, +} + +const RECENT: usize = 200; + +impl AuditLog { + pub fn memory() -> AuditLog { + AuditLog::default() + } + /// The home's log; earlier sessions' last entries are read back. + pub fn in_home(home: &Path) -> AuditLog { + let path = home.join(AUDIT_FILE); + let recent = std::fs::read_to_string(&path) + .map(|s| s.lines().filter_map(|l| serde_json::from_str::(l).ok()).collect::>()) + .unwrap_or_default(); + let skip = recent.len().saturating_sub(RECENT); + AuditLog { path: Some(path), recent: recent.into_iter().skip(skip).collect() } + } + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + pub fn append(&mut self, entry: Entry) { + if let Some(path) = &self.path { + let line = format!("{}\n", serde_json::to_string(&entry).unwrap_or_default()); + if let Err(e) = append_private(path, line.as_bytes()) { + eprintln!("approvals: could not write the audit log: {e}"); + } + } + self.recent.push(entry); + if self.recent.len() > RECENT { + self.recent.remove(0); + } + } + + /// The newest automatic approvals first. + pub fn recent_automatic(&self, n: usize) -> Vec { + self.recent.iter().rev().filter(|e| e.automatic()).take(n).cloned().collect() + } + pub fn all(&self) -> &[Entry] { + &self.recent + } +} + +fn append_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + if let Some(dir) = path.parent() { + super::create_private_dir(dir)?; + } + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path)?.write_all(bytes) +} diff --git a/crates/shell/src/approvals/consent.rs b/crates/shell/src/approvals/consent.rs new file mode 100644 index 00000000..42a69319 --- /dev/null +++ b/crates/shell/src/approvals/consent.rs @@ -0,0 +1,206 @@ +//! Consent at first use (ADR 0004 §4): the first time an app asks for its +//! agent, the shell shows what the agent may read and use (from its manifest +//! and grants) and where the model runs; the person allows or denies, and +//! the answer is remembered per OctoSense home. Settings lists every app's +//! agent with an off switch. Developer mode asks nothing (§13). +//! +//! [`granted`] is what #106's contained apps (`Policy::contained_apps` per +//! app) and the Rinx/native offer path ask before handing an app its peer. + +use super::rules::ApprovalGesture; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Relative to the OctoSense home. +pub const CONSENT_FILE: &str = "approvals/consent.json"; + +/// What the first-use sheet shows about one app's agent. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct AgentSummary { + pub app: String, + pub name: String, + /// What it may read ("Your mail in this account's folder"). + pub reads: Vec, + /// What it may use (its own tools, granted tools of other apps, + /// toolbox tools, command execution). + pub uses: Vec, + /// Where the model runs ("OpenAI (api.openai.com), set in AI providers"). + pub model: String, +} + +impl AgentSummary { + /// From a script app's `manifest.json` (its `capabilities` and + /// `storage`) or a `native-apps.json` entry (`agent` and `storage`), + /// with the grants the person gave at install and the model's place. + pub fn from_manifest(app: &str, name: &str, manifest: &Value, granted: &[String], model: &str) -> AgentSummary { + let mut reads = Vec::new(); + let storage = manifest.get("storage"); + let workspace = storage.and_then(|s| s.get("agent_workspace")).and_then(|v| v.as_str()).unwrap_or("account"); + if workspace == "none" { + reads.push("No files: only what its tools return".to_string()); + } else { + let per_account = storage.and_then(|s| s.get("accounts")).and_then(|v| v.as_bool()).unwrap_or(true); + reads.push(if per_account { format!("{name}'s files for the signed-in account") } else { format!("{name}'s files on this device") }); + } + reads.push("Its own memory".to_string()); + let mut declared: Vec = Vec::new(); + if let Some(caps) = manifest.get("capabilities").and_then(|v| v.as_array()) { + declared.extend(caps.iter().filter_map(|c| c.as_str()).map(str::to_string)); + } + if let Some(octos) = manifest.get("agent").and_then(|a| a.get("octos")).and_then(|v| v.as_array()) { + declared.extend(octos.iter().filter_map(|c| c.as_str()).map(str::to_string)); + } + let mut uses: Vec = declared.iter().filter(|d| granted.iter().any(|g| g == *d)).map(|d| describe_capability(d)).collect(); + uses.dedup(); + if uses.is_empty() { + uses.push(format!("{name}'s own tools")); + } + AgentSummary { app: app.into(), name: name.into(), reads, uses, model: model.into() } + } +} + +fn describe_capability(cap: &str) -> String { + match cap { + "research" => "Web search and page reading (the system toolbox)".into(), + "crawl" => "Crawling websites (the system toolbox)".into(), + "model" => "One-shot model calls".into(), + "command" | "commands" => "Running commands, each approved by you".into(), + c if c.starts_with("octos.") => format!("Its agent ({c})"), + c if c.contains('.') => format!("Another app's tool: {c}"), + c => c.to_string(), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Undecided, + Allowed, + Denied, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct Record { + allowed: bool, + at: u64, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ConsentFile { + schema: u32, + apps: BTreeMap, +} + +#[derive(Debug)] +pub struct ConsentStore { + path: Option, + decided: BTreeMap, + /// Every app with an agent the shell knows of (for Settings). + known: BTreeMap, + /// First-use prompts waiting for the person, oldest first. + asking: Vec, + generation: u64, +} + +impl ConsentStore { + pub fn memory() -> ConsentStore { + ConsentStore { path: None, decided: BTreeMap::new(), known: BTreeMap::new(), asking: Vec::new(), generation: 0 } + } + pub fn in_home(home: &Path) -> ConsentStore { + let path = home.join(CONSENT_FILE); + let file: ConsentFile = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + ConsentStore { path: Some(path), decided: file.apps, ..ConsentStore::memory() } + } + pub fn generation(&self) -> u64 { + self.generation + } + pub fn state(&self, app: &str) -> State { + match self.decided.get(app) { + None => State::Undecided, + Some(r) if r.allowed => State::Allowed, + Some(_) => State::Denied, + } + } + /// Whether the app may have its agent now. `grants_all`: developer mode. + pub fn granted(&self, app: &str, grants_all: bool) -> bool { + grants_all || self.state(app) == State::Allowed + } + /// The shell learns of an app with an agent (Settings lists it). + pub fn register(&mut self, summary: AgentSummary) { + if self.known.get(&summary.app) != Some(&summary) { + self.known.insert(summary.app.clone(), summary); + self.generation += 1; + } + } + /// An app asks for its agent. Undecided: the first-use sheet is queued + /// (once) and the answer is `Undecided` until the person chooses. + pub fn ask(&mut self, summary: AgentSummary, grants_all: bool) -> State { + let app = summary.app.clone(); + self.register(summary); + if grants_all { + return State::Allowed; + } + let state = self.state(&app); + if state == State::Undecided && !self.asking.contains(&app) { + self.asking.push(app); + self.generation += 1; + } + state + } + /// The first-use sheet in front. + pub fn prompt(&self) -> Option<&AgentSummary> { + self.asking.first().and_then(|a| self.known.get(a)) + } + /// The person chose, on the first-use sheet or Settings' switch. + pub fn set(&mut self, _gesture: &ApprovalGesture, app: &str, allowed: bool, now: u64) { + self.decided.insert(app.to_string(), Record { allowed, at: now }); + self.asking.retain(|a| a != app); + self.generation += 1; + self.save(); + } + /// Turning an agent off needs no gesture (always allowed). + pub fn turn_off(&mut self, app: &str, now: u64) { + self.decided.insert(app.to_string(), Record { allowed: false, at: now }); + self.asking.retain(|a| a != app); + self.generation += 1; + self.save(); + } + /// Settings: every app's agent the shell knows of or has an answer for. + pub fn agents(&self) -> Vec<(String, String, State)> { + let mut apps: Vec = self.known.keys().cloned().collect(); + for a in self.decided.keys() { + if !apps.contains(a) { + apps.push(a.clone()); + } + } + apps.sort(); + apps.into_iter() + .map(|a| { + let name = self.known.get(&a).map(|s| s.name.clone()).unwrap_or_else(|| super::sheet::app_label(&a)); + let state = self.state(&a); + (a, name, state) + }) + .collect() + } + fn save(&self) { + let Some(path) = &self.path else { return }; + let file = ConsentFile { schema: 1, apps: self.decided.clone() }; + if let Ok(bytes) = serde_json::to_vec_pretty(&file) { + if let Err(e) = super::write_private(path, &bytes) { + eprintln!("approvals: could not save consent: {e}"); + } + } + } +} + +/// Whether `app` may have its agent now: the person allowed it, or +/// developer mode grants everything. False before the shell sets up. +pub fn granted(app: &str) -> bool { + super::consent_granted(app) +} + +/// An app asks for its agent; the first-use sheet shows if undecided. +pub fn ask(summary: AgentSummary) -> State { + super::consent_ask(summary) +} diff --git a/crates/shell/src/approvals/dev_hooks.rs b/crates/shell/src/approvals/dev_hooks.rs new file mode 100644 index 00000000..c18febe6 --- /dev/null +++ b/crates/shell/src/approvals/dev_hooks.rs @@ -0,0 +1,104 @@ +//! Developer mode's say, asked before anything else (ADR 0004 §13): +//! developer mode approves every call of the apps it covers, +//! `auto_approvable: false` included, with no sheet, no rule and no app's +//! own sheet. +//! +//! The router asks through [`DevModeHooks`], so tests use [`FixedDevMode`]. +//! [`ShellDevMode`] is the adapter to the shell's developer mode +//! (`crate::dev_mode`, OctoSense#118): `answers_approval` (as +//! `ApprovalKind::HostConfirm`), `overrides_app_confirm`, `approves_command` +//! and `grants_all`, and every automatic approval it gives is also written +//! to developer mode's own audit (`dev_mode::audit_auto_approval`). + +use super::types::Connection; + +/// The three hooks of #118 the router needs, plus the consent one. +pub trait DevModeHooks: Send { + /// Every approval kind but the two below (`confirm: host` sheets). + fn answers_approval(&self, owning_app: &str, auto_approvable: bool, connection: Connection) -> bool; + /// The `confirm: app` hand-off: skip the owning app's own sheet. + fn overrides_app_confirm(&self, owning_app: &str) -> bool; + /// Command execution (`terminal.run`, `dev.run`, granted commands). + fn approves_command(&self, owning_app: &str, connection: Connection) -> bool; + /// Every grant, and no first-use consent prompt (§13). + fn grants_all(&self, app: &str) -> bool; + /// Developer mode answered one: its own audit records it too. + fn audit_auto_approval(&self, _owning_app: &str, _tool: &str, _args: &str, _caller: &str, _kind: DevKind) {} +} + +/// Which of developer mode's approval kinds the router answered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DevKind { + HostConfirm, + AppConfirm, + Command, +} + +fn connection(c: Connection) -> crate::dev_mode::Connection { + match c { + Connection::Host => crate::dev_mode::Connection::Host, + Connection::External => crate::dev_mode::Connection::External, + } +} + +/// The shell's developer mode (`crate::dev_mode`). +#[derive(Clone, Copy, Debug, Default)] +pub struct ShellDevMode; + +impl DevModeHooks for ShellDevMode { + fn answers_approval(&self, owning_app: &str, auto_approvable: bool, c: Connection) -> bool { + crate::dev_mode::answers_approval(owning_app, crate::dev_mode::ApprovalKind::HostConfirm, auto_approvable, connection(c)) + } + fn overrides_app_confirm(&self, owning_app: &str) -> bool { + crate::dev_mode::overrides_app_confirm(owning_app) + } + fn approves_command(&self, owning_app: &str, c: Connection) -> bool { + crate::dev_mode::approves_command(owning_app, connection(c)) + } + fn grants_all(&self, app: &str) -> bool { + crate::dev_mode::grants_all(app) + } + fn audit_auto_approval(&self, owning_app: &str, tool: &str, args: &str, caller: &str, kind: DevKind) { + let kind = match kind { + DevKind::HostConfirm => crate::dev_mode::ApprovalKind::HostConfirm, + DevKind::AppConfirm => crate::dev_mode::ApprovalKind::AppConfirm, + DevKind::Command => crate::dev_mode::ApprovalKind::Command, + }; + crate::dev_mode::audit_auto_approval(owning_app, tool, args, caller, kind); + } +} + +/// The test double: on for the listed apps (or all), off otherwise. Like +/// #118, never for an external connection. +#[derive(Clone, Debug, Default)] +pub struct FixedDevMode { + pub all: bool, + pub apps: Vec, +} + +impl FixedDevMode { + pub fn off() -> FixedDevMode { + FixedDevMode::default() + } + pub fn all() -> FixedDevMode { + FixedDevMode { all: true, apps: Vec::new() } + } + fn covers(&self, app: &str) -> bool { + self.all || self.apps.iter().any(|a| a == app) + } +} + +impl DevModeHooks for FixedDevMode { + fn answers_approval(&self, owning_app: &str, _auto_approvable: bool, connection: Connection) -> bool { + connection == Connection::Host && self.covers(owning_app) + } + fn overrides_app_confirm(&self, owning_app: &str) -> bool { + self.covers(owning_app) + } + fn approves_command(&self, owning_app: &str, connection: Connection) -> bool { + connection == Connection::Host && self.covers(owning_app) + } + fn grants_all(&self, app: &str) -> bool { + self.covers(app) + } +} diff --git a/crates/shell/src/approvals/facts.rs b/crates/shell/src/approvals/facts.rs new file mode 100644 index 00000000..c4c5d0dd --- /dev/null +++ b/crates/shell/src/approvals/facts.rs @@ -0,0 +1,194 @@ +//! What rules and sheets read out of a call's exact arguments. +//! +//! The `tools.json` fields rules can match are still an open question of +//! ADR 0004 (agreed with App Hub later), so the router reads the +//! conventional argument names every system app uses today: +//! +//! | Fact | Arguments | +//! | --- | --- | +//! | recipients | `to`, `cc`, `bcc`, `recipients`, `invitees`, `attendees` (a string, a list of strings, or objects with `address`/`email`/`id`) | +//! | attachments | `attachments`, `files` (a non-empty list, or `true`) | +//! | amount | `amount`, `total`, `price` (a number or a numeric string) | +//! | count | `count`, else the number of `items`, else the number of recipients | +//! +//! Nothing here guesses in the call's favour: a missing fact fails the +//! condition that needs it. + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const RECIPIENT_KEYS: &[&str] = &["to", "cc", "bcc", "recipients", "invitees", "attendees"]; +const ATTACHMENT_KEYS: &[&str] = &["attachments", "files"]; +const AMOUNT_KEYS: &[&str] = &["amount", "total", "price"]; +/// Argument names redacted even when the schema does not type them. +const SECRET_NAMES: &[&str] = &["password", "passphrase", "secret", "token", "api_key", "apikey", "pin", "otp", "one_time_code", "private_key", "access_token", "refresh_token", "client_secret"]; +pub const REDACTED: &str = "••••••"; + +fn addresses(v: &Value, out: &mut Vec) { + match v { + Value::String(s) => { + for part in s.split([',', ';']) { + let part = part.trim(); + if part.is_empty() { + continue; + } + // "Ana " → the address. + let addr = match (part.rfind('<'), part.rfind('>')) { + (Some(a), Some(b)) if a < b => &part[a + 1..b], + _ => part, + }; + out.push(addr.trim().to_lowercase()); + } + } + Value::Array(items) => items.iter().for_each(|i| addresses(i, out)), + Value::Object(map) => { + if let Some(a) = ["address", "email", "id", "user_id"].iter().find_map(|k| map.get(*k)) { + addresses(a, out); + } + } + _ => {} + } +} + +/// Every recipient the call names, lower-cased. +pub fn recipients(args: &Value) -> Vec { + let mut out = Vec::new(); + for key in RECIPIENT_KEYS { + if let Some(v) = args.get(*key) { + addresses(v, &mut out); + } + } + out.sort(); + out.dedup(); + out +} + +/// Whether the call carries attachments. +pub fn has_attachments(args: &Value) -> bool { + ATTACHMENT_KEYS.iter().filter_map(|k| args.get(*k)).any(|v| match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Array(a) => !a.is_empty(), + Value::String(s) => !s.trim().is_empty(), + Value::Object(o) => !o.is_empty(), + Value::Number(_) => true, + }) +} + +/// The amount the call names, if any. +pub fn amount(args: &Value) -> Option { + AMOUNT_KEYS.iter().filter_map(|k| args.get(*k)).find_map(|v| match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.trim().trim_start_matches(['$', '€', '£', '¥']).replace(',', "").parse().ok(), + Value::Object(o) => o.get("value").and_then(|v| v.as_f64()), + _ => None, + }) +} + +/// How many things the call acts on. +pub fn count(args: &Value) -> u64 { + if let Some(n) = args.get("count").and_then(|v| v.as_u64()) { + return n; + } + if let Some(items) = args.get("items").and_then(|v| v.as_array()) { + return items.len() as u64; + } + recipients(args).len().max(1) as u64 +} + +/// Whether an argument name is a secret: declared by the schema, or named +/// like one. +pub fn is_secret(name: &str, declared: &[String]) -> bool { + let lower = name.to_lowercase(); + declared.iter().any(|d| d.eq_ignore_ascii_case(name)) || SECRET_NAMES.iter().any(|s| lower == *s || lower.ends_with(&format!("_{s}"))) +} + +/// The arguments with every secret-typed field's value replaced. +pub fn redact(args: &Value, declared: &[String]) -> Value { + match args { + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| { + let v = if is_secret(k, declared) { Value::String(REDACTED.into()) } else { redact(v, declared) }; + (k.clone(), v) + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(|v| redact(v, declared)).collect()), + v => v.clone(), + } +} + +/// The exact arguments as a sheet shows them: pretty-printed, one field per +/// line, secrets redacted. Key order is the call's own (serde_json keeps +/// insertion order only with `preserve_order`; sorted otherwise, which is +/// stable for the person too). +pub fn pretty(args: &Value, declared: &[String]) -> Vec { + let redacted = redact(args, declared); + serde_json::to_string_pretty(&redacted).unwrap_or_default().lines().map(str::to_string).collect() +} + +/// The audit's digest of the exact arguments: SHA-256 of their canonical +/// (sorted-key, compact) JSON. The log never holds the arguments. +pub fn digest(args: &Value) -> String { + fn canonical(v: &Value) -> Value { + match v { + Value::Object(map) => { + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + Value::Object(keys.into_iter().map(|k| (k.clone(), canonical(&map[k]))).collect()) + } + Value::Array(items) => Value::Array(items.iter().map(canonical).collect()), + v => v.clone(), + } + } + let bytes = serde_json::to_vec(&canonical(args)).unwrap_or_default(); + let hash = Sha256::digest(&bytes); + let mut out = String::from("sha256:"); + for b in hash { + out.push_str(&format!("{b:02x}")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn recipients_from_every_shape() { + let args = json!({"to": "Ana , bo@example.org", "cc": ["chen@example.org"], "attendees": [{"email": "ed@example.org"}]}); + assert_eq!(recipients(&args), vec!["ana@example.org", "bo@example.org", "chen@example.org", "ed@example.org"]); + assert!(recipients(&json!({"subject": "x"})).is_empty()); + } + + #[test] + fn attachments_amounts_counts() { + assert!(!has_attachments(&json!({"attachments": []}))); + assert!(has_attachments(&json!({"attachments": ["a.pdf"]}))); + assert_eq!(amount(&json!({"amount": "$1,250.50"})), Some(1250.5)); + assert_eq!(amount(&json!({"total": 9})), Some(9.0)); + assert_eq!(amount(&json!({})), None); + assert_eq!(count(&json!({"count": 7})), 7); + assert_eq!(count(&json!({"items": [1, 2, 3]})), 3); + assert_eq!(count(&json!({"to": ["a@x", "b@x"]})), 2); + } + + #[test] + fn secrets_are_redacted_by_schema_and_by_name() { + let args = json!({"user": "ana", "pw": "hunter2", "nested": {"api_key": "sk-1", "note": "hi"}}); + let lines = pretty(&args, &["pw".into()]).join("\n"); + assert!(!lines.contains("hunter2") && !lines.contains("sk-1"), "{lines}"); + assert!(lines.contains("ana") && lines.contains("hi")); + assert!(is_secret("smtp_password", &[])); + assert!(!is_secret("subject", &[])); + } + + #[test] + fn digest_is_canonical() { + assert_eq!(digest(&json!({"a": 1, "b": [1, 2]})), digest(&json!({"b": [1, 2], "a": 1}))); + assert_ne!(digest(&json!({"a": 1})), digest(&json!({"a": 2}))); + assert!(digest(&json!({})).starts_with("sha256:")); + } +} diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs new file mode 100644 index 00000000..d5047d02 --- /dev/null +++ b/crates/shell/src/approvals/mod.rs @@ -0,0 +1,354 @@ +//! The shell's approval surface (ADR 0004 §8, §4, §13; ADR 0002 §10 as +//! amended): only the person approves, live on a shell-drawn sheet or in +//! advance by a standing rule; developer mode overrides every approval. +//! +//! | Part | Module | +//! | --- | --- | +//! | the request, its caller and context; decisions | [`types`] | +//! | the seam with the octos#2567 relay (`approval_requested` in, `approval_decided` out) | [`relay`] | +//! | developer mode's hooks, asked first (the adapter to `dev_mode`) | [`dev_hooks`] | +//! | the router: precedence, sheets, `confirm: app` hand-off, timeouts | [`router`] | +//! | standing rules, their conditions, cap, time box, "all off", the person's gesture | [`rules`] | +//! | what rules and sheets read from the exact arguments; redaction; digest | [`facts`] | +//! | the sheet model | [`sheet`] | +//! | the append-only, owner-only audit | [`audit`] | +//! | consent at first use; `consent::granted(app)` | [`consent`] | +//! | the shell-drawn sheet, first-use sheet and time-box indicator | [`view`] | +//! | Settings → Assistant → Approvals | [`settings_page`] | +//! +//! Files, per OctoSense home: [`rules::RULES_FILE`], [`consent::CONSENT_FILE`] +//! and [`audit::AUDIT_FILE`], all owner-only. +//! +//! The shell calls [`init`] at startup, [`tick`] once a second (and shows +//! [`take_notices`] as notifications), and gives pointer events to +//! [`pointer`] before anything else while a sheet or the Settings page is +//! up. The relay calls [`approval_requested`] and installs itself with +//! [`set_relay`]; an app module registers its own confirmation sheet with +//! [`register_app_confirm`]. + +pub mod audit; +pub mod consent; +pub mod dev_hooks; +pub mod facts; +pub mod relay; +pub mod router; +pub mod rules; +pub mod settings_page; +pub mod sheet; +pub mod types; +pub mod view; + +#[cfg(test)] +mod tests; + +use makepad_widgets::*; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +pub use relay::{ApprovalIntake, ApprovalRelay, RecordingRelay}; +pub use router::{AppConfirm, AppConfirmRequest, Notice, Route, Router}; +pub use types::{Batch, Caller, Confirm, Connection, Decision, Request, RequestContext, RequestId, RuleId, ToolSpec, Trigger}; + +/// Everything the shell holds for approvals. +pub struct Approvals { + pub router: Router, + pub consent: consent::ConsentStore, + /// Settings → Assistant → Approvals is open. + pub settings_open: bool, + /// Decisions for requests other than the bus's, until the relay is + /// installed. + queue: RecordingRelay, + /// Decisions for the AI bus's held calls (`bus:` ids), drained by the + /// shell ([`take_bus_decisions`]). + bus: RecordingRelay, + external: Arc>>>, +} + +/// Where the router's decisions go: the AI bus's own held calls to the +/// shell, the rest to the octos#2567 relay (queued until it is installed). +struct Dispatch { + bus: RecordingRelay, + queue: RecordingRelay, + external: Arc>>>, +} + +impl ApprovalRelay for Dispatch { + fn approval_decided(&mut self, id: &RequestId, decision: Decision, reason: &str) { + if id.0.starts_with(crate::ai_bus::HELD_PREFIX) { + return self.bus.approval_decided(id, decision, reason); + } + match self.external.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { + Some(relay) => relay.approval_decided(id, decision, reason), + None => self.queue.approval_decided(id, decision, reason), + } + } +} + +impl Approvals { + pub fn in_home(home: &Path) -> Approvals { + Approvals::with_parts(rules::RuleStore::in_home(home), audit::AuditLog::in_home(home), consent::ConsentStore::in_home(home)) + } + pub fn memory() -> Approvals { + Approvals::with_parts(rules::RuleStore::memory(), audit::AuditLog::memory(), consent::ConsentStore::memory()) + } + fn with_parts(rules: rules::RuleStore, audit: audit::AuditLog, consent: consent::ConsentStore) -> Approvals { + let queue = RecordingRelay::default(); + let bus = RecordingRelay::default(); + let external = Arc::new(Mutex::new(None)); + let dispatch = Dispatch { bus: bus.clone(), queue: queue.clone(), external: external.clone() }; + let router = Router::new(rules, audit, Box::new(dev_hooks::ShellDevMode), Box::new(rules::NoContacts), Box::new(dispatch)); + Approvals { router, consent, settings_open: false, queue, bus, external } + } + /// Sheets, rules, consent and the page: one number for "redraw". + pub fn generation(&self) -> u64 { + let now = now(); + // The time-box indicator counts minutes down. + let minute = if self.router.rules.active_everything(now).is_empty() { 0 } else { now / 60 }; + self.router.generation() + self.consent.generation() + u64::from(self.settings_open) + minute + } + pub fn consent_granted(&self, app: &str) -> bool { + self.consent.granted(app, self.router.hooks().grants_all(app)) + } +} + +static STATE: Mutex> = Mutex::new(None); + +/// Run `f` on the shell's approvals, once [`init`] has run. +pub fn with(f: impl FnOnce(&mut Approvals) -> R) -> Option { + STATE.lock().unwrap_or_else(|e| e.into_inner()).as_mut().map(f) +} + +/// At startup, once: this home's rules, consent and audit. +pub fn init(home: &Path) { + let a = Approvals::in_home(home); + *STATE.lock().unwrap_or_else(|e| e.into_inner()) = Some(a); + // Contained apps' `octos` service asks consent at first use too. + crate::ai_host::contained::set_consent(consent_for_contained); +} + +/// For tests and headless runs: approvals kept in memory only. +pub fn init_memory() { + *STATE.lock().unwrap_or_else(|e| e.into_inner()) = Some(Approvals::memory()); +} + +pub fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +// ------------------------------------------------------------ the relay + +/// The relay's entry point (octos#2567): a call needs an approval. +pub fn approval_requested(app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { + with(|a| a.router.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) +} + +/// The relay installs itself; decisions made before are handed over first. +pub fn set_relay(mut relay: Box) { + with(|a| { + for (id, decision, reason) in a.queue.take() { + relay.approval_decided(&id, decision, &reason); + } + *a.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); + }); +} + +/// The AI bus's `confirm: host` calls (`ai_bus::Route::Approval`): each is +/// a request like any other; the shell drains the answers here and +/// releases the held call (`AiBus::release`). +pub fn bus_requested(held: &crate::ai_bus::HeldCall) -> Route { + let mut tool = ToolSpec::host(&held.tool); + tool.auto_approvable = held.auto_approvable; + if held.command { + tool = tool.command(); + } + let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); + // The pane is the person's own conversation with the system agent. + let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; + approval_requested(&held.app, tool, args, Caller::SystemAgent, context) +} + +pub fn take_bus_decisions() -> Vec<(RequestId, Decision, String)> { + with(|a| a.bus.take()).unwrap_or_default() +} + +/// An app module registers its own confirmation sheet (`confirm: app`). +pub fn register_app_confirm(app: &str, handler: Box) { + with(|a| a.router.register_app_confirm(app, handler)); +} +pub fn unregister_app_confirm(app: &str) { + with(|a| a.router.unregister_app_confirm(app, now())); +} +/// The owning app's sheet answered a `confirm: app` request. +pub fn app_confirm_answered(id: &RequestId, approved: bool, reason: &str) -> Result<(), String> { + with(|a| a.router.app_confirm_answered(id, approved, reason, now())).unwrap_or_else(|| Err("approvals are not set up".into())) +} + +// ------------------------------------------------------------ consent + +/// `consent::granted(app)`: whether `app` may have its agent now (the +/// person allowed it, or developer mode grants everything). For #106's +/// contained apps and the Rinx/native offer path. +pub fn consent_granted(app: &str) -> bool { + with(|a| a.consent_granted(app)).unwrap_or(false) +} + +/// An app asks for its agent: shows the first-use sheet if the person has +/// not decided yet. +pub fn consent_ask(summary: consent::AgentSummary) -> consent::State { + with(|a| { + let all = a.router.hooks().grants_all(&summary.app); + a.consent.ask(summary, all) + }) + .unwrap_or(consent::State::Undecided) +} + +/// The module host's gate for an in-process module's assistant: granted, +/// or the first-use sheet is shown (once) and this instance goes without. +pub fn consent_for_module(app: &str, label: &str, capabilities: &[&str]) -> bool { + // The module host's own tests run in parallel with these; the gate + // itself is tested on [`module_gate`]. + if cfg!(test) { + return true; + } + with(|a| module_gate(a, app, label, capabilities)).unwrap_or(false) +} + +/// [`consent_for_module`] on one `Approvals`. +pub fn module_gate(a: &mut Approvals, app: &str, label: &str, capabilities: &[&str]) -> bool { + let all = a.router.hooks().grants_all(app); + if a.consent.granted(app, all) { + return true; + } + let manifest = serde_json::json!({ "capabilities": capabilities }); + let granted: Vec = capabilities.iter().map(|c| c.to_string()).collect(); + let summary = consent::AgentSummary::from_manifest(app, label, &manifest, &granted, "The model set in AI providers"); + a.consent.ask(summary, all) == consent::State::Allowed +} + +/// #106's contained apps (`ai_host::contained`): the same gate, for a +/// Card runner app asking the `octos` service. +pub fn consent_for_contained(app: &str) -> bool { + consent_for_module(app, &sheet::app_label(app), &["octos.session.open", "octos.turn.start"]) +} + +// ------------------------------------------------------------ the shell + +/// Once a second. True when something visible changed. +pub fn tick() -> bool { + with(|a| a.router.tick(now())).unwrap_or(false) +} +pub fn take_notices() -> Vec { + with(|a| a.router.take_notices()).unwrap_or_default() +} +pub fn generation() -> u64 { + with(|a| a.generation()).unwrap_or(0) +} +/// Settings → Assistant → Approvals. +pub fn open_settings() { + with(|a| a.settings_open = true); +} +pub fn close_settings() { + with(|a| a.settings_open = false); +} + +/// Pointer events, before the rest of the shell. True when the approval +/// surface took the event (it is modal while a sheet or the page is up). +pub fn pointer(ui: &WidgetRef, cx: &mut Cx, event: &Event) -> bool { + if !matches!(event, Event::MouseDown(_) | Event::MouseUp(_) | Event::MouseMove(_) | Event::TouchUpdate(_) | Event::Scroll(_)) { + return false; + } + let settings = ui.widget(cx, ids!(shell_approvals_settings)); + let taken = settings.borrow_mut::().map(|mut s| s.pointer(cx, event)).unwrap_or(false); + if taken { + ui.redraw(cx); + return true; + } + let sheets = ui.widget(cx, ids!(shell_approvals)); + let taken = sheets.borrow_mut::().map(|mut s| s.pointer(cx, event)).unwrap_or(false); + if taken { + ui.redraw(cx); + } + taken +} + +/// `--test-action approval-sheet` / `approval-batch` / `approval-consent` +/// / `approvals-settings`: put a sample in front, for hidden-window runs. +/// Nothing here approves anything: the samples wait for the person. +pub fn test_action(name: &str) -> bool { + use serde_json::json; + let ctx = |id: &str| RequestContext { call_id: id.into(), trigger: Trigger::Person, ..RequestContext::default() }; + match name { + "approval-sheet" => { + approval_requested( + "os.mail", + ToolSpec::host("mail.send"), + json!({"to": ["ana@example.org"], "subject": "Tuesday", "body": "See you at 3.", "smtp_password": "not-shown"}), + Caller::AppAgent { app: "calendar".into() }, + ctx("sample-1"), + ); + } + "approval-batch" => { + let batch = Some(Batch { id: "plan-1".into(), plan: "Book Tue 3\u{2013}4 pm and invite 2".into() }); + for (i, to) in ["ana@example.org", "bo@example.org"].iter().enumerate() { + approval_requested( + "os.mail", + ToolSpec::host("mail.send"), + json!({"to": [to], "subject": "Meeting Tue 3 pm"}), + Caller::AppAgent { app: "calendar".into() }, + RequestContext { batch: batch.clone(), trigger: Trigger::SystemAgent, ..ctx(&format!("batch-{i}")) }, + ); + } + } + "approval-consent" => { + consent_ask(consent::AgentSummary { + app: "os.news".into(), + name: "News".into(), + reads: vec!["News's files for the signed-in account".into(), "Its own memory".into()], + uses: vec!["Web search and page reading (the system toolbox)".into(), "News's own tools".into()], + model: "The model set in AI providers".into(), + }); + } + "approvals-settings" => open_settings(), + _ => return false, + } + true +} + +pub fn script_mod(vm: &mut ScriptVm) { + view::script_mod(vm); + settings_page::script_mod(vm); +} + +// ------------------------------------------------------------ files + +pub(crate) fn create_private_dir(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(()) +} + +/// Write a whole file owner-only, atomically (a temporary file, renamed). +pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + if let Some(dir) = path.parent() { + create_private_dir(dir)?; + } + let tmp = path.with_extension("tmp"); + { + let mut options = std::fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut f = options.open(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path) +} diff --git a/crates/shell/src/approvals/relay.rs b/crates/shell/src/approvals/relay.rs new file mode 100644 index 00000000..5f7976a7 --- /dev/null +++ b/crates/shell/src/approvals/relay.rs @@ -0,0 +1,59 @@ +//! The seam with the octos#2567 relay (the shell's host connection, which +//! receives every `peer/tool/call` and octos approval, ADR 0004 §5 and §8). +//! +//! - **In:** the relay calls [`ApprovalIntake::approval_requested`] (the +//! router implements it; [`super::approval_requested`] is the global) for +//! every call that needs an approval, with the exact arguments, the +//! stamped caller and its context. +//! - **Out:** the router answers each request exactly once through +//! [`ApprovalRelay::approval_decided`], which the relay implements and +//! installs with [`super::set_relay`]. Until one is installed the +//! decisions wait in a [`RecordingRelay`] and are handed over on install, +//! so none is lost. +//! +//! [`RecordingRelay`] is also the test double. + +use super::types::{Caller, Decision, RequestContext, RequestId, ToolSpec}; +use serde_json::Value; +use std::sync::{Arc, Mutex}; + +/// Where the router sends each decision. Called once per request. +pub trait ApprovalRelay: Send { + fn approval_decided(&mut self, id: &RequestId, decision: Decision, reason: &str); +} + +/// What the router offers the relay. +pub trait ApprovalIntake { + /// A call needs an approval. `context.call_id` is the relay's id; the + /// decision comes back through [`ApprovalRelay::approval_decided`], + /// possibly before this returns (developer mode, a standing rule). + fn approval_requested( + &mut self, + app: &str, + tool: ToolSpec, + args: Value, + caller: Caller, + context: RequestContext, + ) -> super::router::Route; +} + +/// The test double: records every decision, shareable with the test. +#[derive(Clone, Default)] +pub struct RecordingRelay { + pub decided: Arc>>, +} + +impl RecordingRelay { + pub fn take(&self) -> Vec<(RequestId, Decision, String)> { + std::mem::take(&mut *self.decided.lock().unwrap()) + } + pub fn last(&self) -> Option<(RequestId, Decision, String)> { + self.decided.lock().unwrap().last().cloned() + } +} + +impl ApprovalRelay for RecordingRelay { + fn approval_decided(&mut self, id: &RequestId, decision: Decision, reason: &str) { + self.decided.lock().unwrap().push((id.clone(), decision, reason.to_string())); + } +} diff --git a/crates/shell/src/approvals/router.rs b/crates/shell/src/approvals/router.rs new file mode 100644 index 00000000..50556c08 --- /dev/null +++ b/crates/shell/src/approvals/router.rs @@ -0,0 +1,472 @@ +//! The approval router (ADR 0004 §8, §13): the shell's one place that +//! answers approval requests, on their exact arguments. +//! +//! For each request, in this order: +//! +//! 1. **Developer mode** ([`DevModeHooks`], the shell's `dev_mode`): it +//! approves everything of the apps it covers, `auto_approvable: false` +//! and `confirm: app` included, never for an external client. +//! 2. **`confirm: app`** tools go to the owning app's own sheet, with the +//! caller ([`AppConfirm`]); no rule answers them. An app that is not +//! running gets [`Router::app_wait_s`] to register, else the call is +//! refused visibly. +//! 3. **`auto_approvable: false`**, **`outcome_unknown`** and external +//! clients' calls always go to the person. +//! 4. **Standing rules** ([`RuleStore`]), which skip runs started by +//! incoming content unless a rule opts in. +//! 5. Otherwise a **sheet**: one per request, or the batched sheet of one +//! system-agent request in the system chat. +//! +//! Every decision reaches the relay once ([`ApprovalRelay`]) and the audit +//! ([`AuditLog`]); every automatic one is also a notice for the person. + +use super::audit::{AuditLog, Entry}; +use super::dev_hooks::{DevKind, DevModeHooks}; +use super::facts; +use super::relay::{ApprovalIntake, ApprovalRelay}; +use super::rules::{Contacts, ApprovalGesture, RuleDraft, RuleStore}; +use super::sheet::{app_label, caller_label, Answer, Line, Place, Sheet, Surfaced}; +use super::types::{Caller, Confirm, Connection, Decision, Request, RequestContext, RequestId, RuleId, ToolSpec}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; + +/// Who answered automatically. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AutoBy { + DeveloperMode, + Rule(RuleId), +} + +/// What the router did with a request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Route { + Approved(AutoBy), + /// On the shell's sheet (its id). + Sheet(u64), + /// On the owning app's own sheet. + HandedToApp, + /// The owning app is not running; refused at `until` unless it comes. + WaitingForApp { until: u64 }, + Refused(String), +} + +/// A `confirm: app` request, as the owning app's own sheet gets it. +#[derive(Clone, Debug, PartialEq)] +pub struct AppConfirmRequest { + pub id: RequestId, + pub tool: String, + /// The exact arguments (the owning app is their owner). + pub args: Value, + pub caller: Caller, + /// "Calendar's agent", for the app's sheet. + pub caller_label: String, + pub context_id: Option, +} + +/// The owning app's own confirmation sheet, registered by the app module +/// (Rinx's send sheet). It answers with [`Router::app_confirm_answered`] +/// (the global [`super::app_confirm_answered`]). +pub trait AppConfirm: Send { + fn confirm(&mut self, request: &AppConfirmRequest); +} + +/// Something the person is told (the shell shows it as a notification). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Notice { + pub title: String, + pub body: String, +} + +pub struct Router { + pub rules: RuleStore, + pub audit: AuditLog, + hooks: Box, + contacts: Box, + relay: Box, + /// Every request not yet decided. + pending: BTreeMap, + sheets: Vec, + next_sheet: u64, + app_confirms: HashMap>, + /// `confirm: app` requests on an app's sheet. + at_app: BTreeMap, + /// `confirm: app` requests waiting for their app, with a deadline. + waiting: BTreeMap, + notices: Vec, + /// How long a `confirm: app` call waits for its app (0: refused at once). + pub app_wait_s: u64, + /// A sheet line nobody answers is declined after this (ADR 0002 §10). + pub sheet_expiry_s: u64, + generation: u64, + next_request: u64, +} + +impl Router { + pub fn new(rules: RuleStore, audit: AuditLog, hooks: Box, contacts: Box, relay: Box) -> Router { + Router { + rules, + audit, + hooks, + contacts, + relay, + pending: BTreeMap::new(), + sheets: Vec::new(), + next_sheet: 1, + app_confirms: HashMap::new(), + at_app: BTreeMap::new(), + waiting: BTreeMap::new(), + notices: Vec::new(), + app_wait_s: 120, + sheet_expiry_s: 600, + generation: 0, + next_request: 1, + } + } + + /// Replace the relay (the octos#2567 relay installs itself); returns + /// the old one so queued decisions can be handed over. + pub fn set_relay(&mut self, relay: Box) -> Box { + std::mem::replace(&mut self.relay, relay) + } + pub fn set_hooks(&mut self, hooks: Box) { + self.hooks = hooks; + } + pub fn set_contacts(&mut self, contacts: Box) { + self.contacts = contacts; + } + pub fn hooks(&self) -> &dyn DevModeHooks { + &*self.hooks + } + + /// Bumped on every visible change (sheets, rules, indicator). + pub fn generation(&self) -> u64 { + self.generation + } + fn changed(&mut self) { + self.generation += 1; + } + pub fn sheets(&self) -> &[Sheet] { + &self.sheets + } + /// The sheet the person sees first. + pub fn front_sheet(&self) -> Option<&Sheet> { + self.sheets.first() + } + pub fn pending(&self) -> usize { + self.pending.len() + } + pub fn is_pending(&self, id: &RequestId) -> bool { + self.pending.contains_key(id) + } + pub fn take_notices(&mut self) -> Vec { + std::mem::take(&mut self.notices) + } + fn notice(&mut self, title: impl Into, body: impl Into) { + self.notices.push(Notice { title: title.into(), body: body.into() }); + } + + /// Route one request. The decision may be given before this returns. + pub fn request(&mut self, req: Request, now: u64) -> Route { + if self.pending.contains_key(&req.id) { + // octos approvals are once-only; a repeat is not a second ask. + return Route::Refused(format!("request {} is already pending", req.id)); + } + self.pending.insert(req.id.clone(), req.clone()); + let host = req.context.connection == Connection::Host; + let app = req.app.as_str(); + + // 1. Developer mode, before anything else. + let dev = if req.tool.command { + self.hooks.approves_command(app, req.context.connection) + } else if req.tool.confirm == Confirm::App { + host && self.hooks.overrides_app_confirm(app) + } else { + self.hooks.answers_approval(app, req.tool.auto_approvable, req.context.connection) + }; + if dev { + let kind = if req.tool.command { + DevKind::Command + } else if req.tool.confirm == Confirm::App { + DevKind::AppConfirm + } else { + DevKind::HostConfirm + }; + self.hooks.audit_auto_approval(app, &req.tool.name, &req.args.to_string(), &req.caller.as_audit(), kind); + self.decide(&req.id, Decision::ApproveOnce, "developer_mode", None, "developer mode", now); + return Route::Approved(AutoBy::DeveloperMode); + } + + // 2. `confirm: app`: the owning app's own sheet. + if req.tool.confirm == Confirm::App { + return self.hand_to_app(req, now); + } + + // 3. Always the person. + let always_person = if !req.tool.auto_approvable { + Some(Surfaced::NotAutoApprovable) + } else if req.context.outcome_unknown { + Some(Surfaced::OutcomeUnknown) + } else if !host { + Some(Surfaced::External) + } else { + None + }; + + // 4. Standing rules. + let surfaced = match always_person { + Some(s) => s, + None => match self.rules.find(&req, &*self.contacts, now) { + Some(rule) => { + self.rules.record_use(&rule, now); + self.decide(&req.id, Decision::ApproveByRule(rule.clone()), "rule", Some(&rule), "standing rule", now); + self.changed(); + return Route::Approved(AutoBy::Rule(rule)); + } + None if req.context.trigger.excluded_by_default() => Surfaced::IncomingContent, + None => Surfaced::NoRule, + }, + }; + + // 5. A sheet. + Route::Sheet(self.surface(&req, surfaced, now)) + } + + fn surface(&mut self, req: &Request, surfaced: Surfaced, now: u64) -> u64 { + let line = Line::for_request(req, surfaced, &*self.contacts); + let batch = req.context.batch.clone(); + self.notice(format!("Needs you: {}", line.heading()), format!("{} asks. Open the sheet to approve or deny.", line.caller)); + self.changed(); + if let Some(b) = &batch { + if let Some(sheet) = self.sheets.iter_mut().find(|s| s.batch_id() == Some(&b.id) && !s.done()) { + sheet.lines.push(line); + return sheet.id; + } + } + let id = self.next_sheet; + self.next_sheet += 1; + let place = match batch { + Some(b) => Place::SystemChat { batch: b.id, plan: b.plan }, + None => Place::AppConversation { app: req.app.clone() }, + }; + self.sheets.push(Sheet { id, place, lines: vec![line], opened: now }); + id + } + + fn hand_to_app(&mut self, req: Request, now: u64) -> Route { + if let Some(handler) = self.app_confirms.get_mut(&req.app) { + handler.confirm(&app_request(&req)); + self.at_app.insert(req.id.clone(), req.app.clone()); + return Route::HandedToApp; + } + if self.app_wait_s == 0 { + let reason = format!("{} isn't running", app_label(&req.app)); + self.refuse_visibly(&req.id, &reason, now); + return Route::Refused(reason); + } + let until = now + self.app_wait_s; + self.waiting.insert(req.id.clone(), until); + self.notice(format!("Waiting for {}", app_label(&req.app)), format!("{} wants {}; open {} to confirm it there.", caller_label(&req.app, &req.caller), req.tool.name, app_label(&req.app))); + self.changed(); + Route::WaitingForApp { until } + } + + fn refuse_visibly(&mut self, id: &RequestId, reason: &str, now: u64) { + let Some(req) = self.pending.get(id).cloned() else { return }; + self.decide(id, Decision::Deny, "refused", None, reason, now); + self.notice(format!("Refused: {} \u{00b7} {}", app_label(&req.app), req.tool.name), reason.to_string()); + self.changed(); + } + + /// The owning app registers its own sheet (at its module's start). + /// Requests waiting for it are handed over now. + pub fn register_app_confirm(&mut self, app: &str, mut handler: Box) { + let waiting: Vec = self.waiting.keys().filter(|id| self.pending.get(*id).is_some_and(|r| r.app == app)).cloned().collect(); + for id in waiting { + self.waiting.remove(&id); + if let Some(req) = self.pending.get(&id) { + handler.confirm(&app_request(req)); + self.at_app.insert(id, app.to_string()); + } + } + self.app_confirms.insert(app.to_string(), handler); + self.changed(); + } + + /// The app went away: what was on its sheet is refused visibly. + pub fn unregister_app_confirm(&mut self, app: &str, now: u64) { + self.app_confirms.remove(app); + let on_sheet: Vec = self.at_app.iter().filter(|(_, a)| a.as_str() == app).map(|(id, _)| id.clone()).collect(); + for id in on_sheet { + self.at_app.remove(&id); + self.refuse_visibly(&id, &format!("{} closed before it was confirmed", app_label(app)), now); + } + } + + /// The owning app's sheet answered. + pub fn app_confirm_answered(&mut self, id: &RequestId, approved: bool, reason: &str, now: u64) -> Result<(), String> { + if self.at_app.remove(id).is_none() { + return Err(format!("{id} is not on an app's sheet")); + } + let decision = if approved { Decision::ApproveOnce } else { Decision::Deny }; + self.decide(id, decision, "app_sheet", None, reason, now); + self.changed(); + Ok(()) + } + + /// The person answered one line of a shell-drawn sheet. + pub fn answer(&mut self, sheet: u64, request: &RequestId, answer: Answer, gesture: &ApprovalGesture, now: u64) -> Result, String> { + let s = self.sheets.iter().position(|s| s.id == sheet).ok_or("that sheet is closed")?; + let l = self.sheets[s].lines.iter().position(|l| l.request == *request).ok_or("that line is not on the sheet")?; + if self.sheets[s].lines[l].answer.is_some() { + return Err("already answered".into()); + } + let mut made = None; + match answer { + Answer::Once => self.decide(request, Decision::ApproveOnce, "person", None, "approved on the sheet", now), + Answer::Deny => self.decide(request, Decision::Deny, "person", None, "denied on the sheet", now), + Answer::Always(i) => { + let choice = self.sheets[s].lines[l].always.get(i).cloned().ok_or("no such choice")?; + let rule = self.rules.create(gesture, choice.draft, now)?; + self.rules.record_use(&rule, now); + self.decide(request, Decision::ApproveByRule(rule.clone()), "person", Some(&rule), &format!("approved and made a rule: {}", choice.label), now); + made = Some(rule); + } + } + self.sheets[s].lines[l].answer = Some(answer); + if made.is_some() { + self.reconsider(now); + } + self.sheets.retain(|s| !s.done()); + self.changed(); + Ok(made) + } + + /// A new rule may answer lines still open: the ones no rule answered. + fn reconsider(&mut self, now: u64) { + let open: Vec<(u64, RequestId)> = self + .sheets + .iter() + .flat_map(|s| s.open_lines().filter(|l| l.surfaced.rules_could_answer()).map(move |l| (s.id, l.request.clone()))) + .collect(); + for (sheet, id) in open { + let Some(req) = self.pending.get(&id).cloned() else { continue }; + if let Some(rule) = self.rules.find(&req, &*self.contacts, now) { + self.rules.record_use(&rule, now); + self.decide(&id, Decision::ApproveByRule(rule.clone()), "rule", Some(&rule), "standing rule", now); + if let Some(line) = self.sheets.iter_mut().find(|s| s.id == sheet).and_then(|s| s.lines.iter_mut().find(|l| l.request == id)) { + line.answer = Some(Answer::Once); + } + } + } + } + + /// The person creates a rule in Settings. + pub fn create_rule(&mut self, gesture: &ApprovalGesture, draft: RuleDraft, now: u64) -> Result { + let id = self.rules.create(gesture, draft, now)?; + self.reconsider(now); + self.sheets.retain(|s| !s.done()); + self.changed(); + Ok(id) + } + pub fn delete_rule(&mut self, id: &RuleId) -> bool { + let changed = self.rules.delete(id); + self.changed(); + changed + } + /// One tap: every rule off. + pub fn all_off(&mut self) -> usize { + let n = self.rules.all_off(); + if n > 0 { + self.notice("Approval rules are off", format!("{n} rule{} turned off. Every approval now asks you.", if n == 1 { "" } else { "s" })); + } + self.changed(); + n + } + + /// Once a second: rules that ran out, apps that never came, sheets + /// nobody answered. True when anything visible changed. + pub fn tick(&mut self, now: u64) -> bool { + let before = self.generation; + for rule in self.rules.expire(now) { + self.notice("Approval rule ended", format!("{} no longer approves on its own.", rule.describe())); + self.changed(); + } + let late: Vec = self.waiting.iter().filter(|(_, until)| now >= **until).map(|(id, _)| id.clone()).collect(); + for id in late { + self.waiting.remove(&id); + let app = self.pending.get(&id).map(|r| app_label(&r.app)).unwrap_or_default(); + self.refuse_visibly(&id, &format!("{app} wasn't opened in time to confirm it"), now); + } + let expired: Vec<(u64, RequestId)> = self + .sheets + .iter() + .filter(|s| now >= s.opened + self.sheet_expiry_s) + .flat_map(|s| s.open_lines().map(move |l| (s.id, l.request.clone()))) + .collect(); + for (sheet, id) in expired { + self.decide(&id, Decision::Deny, "timeout", None, "nobody answered; an expired request is declined", now); + if let Some(line) = self.sheets.iter_mut().find(|s| s.id == sheet).and_then(|s| s.lines.iter_mut().find(|l| l.request == id)) { + line.answer = Some(Answer::Deny); + } + self.changed(); + } + self.sheets.retain(|s| !s.done()); + self.generation != before + } + + /// The one exit: relay, audit, notice. + fn decide(&mut self, id: &RequestId, decision: Decision, by: &str, rule: Option<&RuleId>, reason: &str, now: u64) { + let Some(req) = self.pending.remove(id) else { return }; + self.waiting.remove(id); + self.at_app.remove(id); + let entry = Entry { + ts: now, + id: id.0.clone(), + app: req.app.clone(), + tool: req.tool.name.clone(), + args_digest: facts::digest(&req.args), + caller: req.caller.as_audit(), + trigger: req.context.trigger.as_str().into(), + by: by.into(), + rule: rule.map(|r| r.0.clone()), + result: if decision.approved() { "approved".into() } else { "denied".into() }, + reason: reason.into(), + }; + let automatic = entry.automatic(); + self.audit.append(entry); + if automatic { + let why = match rule.and_then(|r| self.rules.get(r)) { + Some(r) => format!("By your rule: {}", r.describe()), + None => "Developer mode approves everything.".to_string(), + }; + self.notice(format!("Approved automatically: {} \u{00b7} {}", app_label(&req.app), req.tool.name), format!("{why} Asked by {}.", caller_label(&req.app, &req.caller))); + } + self.relay.approval_decided(id, decision, reason); + } +} + +fn app_request(req: &Request) -> AppConfirmRequest { + AppConfirmRequest { + id: req.id.clone(), + tool: req.tool.name.clone(), + args: req.args.clone(), + caller: req.caller.clone(), + caller_label: caller_label(&req.app, &req.caller), + context_id: req.context.context_id.clone(), + } +} + +/// The request as the router keeps it. An empty `call_id` gets one. +pub fn make_request(app: &str, tool: ToolSpec, args: Value, caller: Caller, context: RequestContext, now: u64, fallback_id: u64) -> Request { + let id = if context.call_id.trim().is_empty() { RequestId(format!("shell-{fallback_id}")) } else { RequestId(context.call_id.clone()) }; + Request { id, app: app.to_string(), tool, args, caller, context, received: now } +} + +impl ApprovalIntake for Router { + fn approval_requested(&mut self, app: &str, tool: ToolSpec, args: Value, caller: Caller, context: RequestContext) -> Route { + let now = super::now(); + let n = self.next_request; + self.next_request += 1; + self.request(make_request(app, tool, args, caller, context, now, n), now) + } +} diff --git a/crates/shell/src/approvals/rules.rs b/crates/shell/src/approvals/rules.rs new file mode 100644 index 00000000..c65bfc72 --- /dev/null +++ b/crates/shell/src/approvals/rules.rs @@ -0,0 +1,466 @@ +//! Standing approvals (ADR 0004 §8): rules the person sets in advance, +//! keyed to (owning app, tool) whoever calls, checked mechanically on the +//! exact arguments. +//! +//! - **Scope:** one tool of one app ([`RuleScope::Tool`]), or everything +//! one app asks ([`RuleScope::Everything`]), which is always time-boxed +//! to at most [`MAX_EVERYTHING_MINUTES`]: there is no "everything, +//! forever" rule. +//! - **Conditions** ([`Conditions`]): recipients in contacts or in the +//! thread, no attachments, triggered by the person, amount and count +//! limits; a daily cap ([`Rule::daily_cap`], per UTC day). +//! - **Never:** `auto_approvable: false` tools and `outcome_unknown` calls +//! (the router checks those before any rule); runs started by incoming +//! content unless the rule opts in ([`Rule::include_incoming`]). +//! - **Only the person creates one:** [`RuleStore::create`] takes a +//! [`ApprovalGesture`], which only the Settings page and the shell-drawn +//! sheet construct (a test scans the sources). No agent or app can. +//! - **One tap turns every rule off** ([`RuleStore::all_off`]). +//! - **Persisted per OctoSense home** in [`RULES_FILE`], owner-only. + +use super::facts; +use super::types::{Request, RuleId, Trigger}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Relative to the OctoSense home. +pub const RULES_FILE: &str = "approvals/rules.json"; +/// The longest time-boxed "approve everything this app asks". +pub const MAX_EVERYTHING_MINUTES: u64 = 60; +/// What a rule made from a sheet's "always for …" is capped at per day. +pub const DEFAULT_DAILY_CAP: u32 = 20; +const DAY_S: u64 = 86_400; + +/// Proof that the person, not an agent or app, asked for a rule. Only the +/// shell's Settings page and the shell-drawn sheet construct one. +#[derive(Debug)] +pub struct ApprovalGesture { + origin: RuleOrigin, +} + +impl ApprovalGesture { + /// A tap on Settings → Assistant → Approvals. + pub(crate) fn settings_tap() -> ApprovalGesture { + ApprovalGesture { origin: RuleOrigin::Settings } + } + /// A tap on a shell-drawn approval sheet's "always for …". + pub(crate) fn sheet_tap() -> ApprovalGesture { + ApprovalGesture { origin: RuleOrigin::Sheet } + } + pub fn origin(&self) -> RuleOrigin { + self.origin + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuleOrigin { + Settings, + Sheet, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuleScope { + Tool(String), + /// Everything the app asks, time-boxed. + Everything, +} + +/// Every condition set must hold. A fact the call does not carry fails the +/// condition that needs it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Conditions { + /// Every recipient is in the person's contacts (or, with + /// `recipients_in_thread` also set, in contacts or the thread). + #[serde(default)] + pub recipients_in_contacts: bool, + /// Every recipient is in the thread the run is about. + #[serde(default)] + pub recipients_in_thread: bool, + #[serde(default)] + pub no_attachments: bool, + /// The run was started by the person. + #[serde(default)] + pub triggered_by_person: bool, + #[serde(default)] + pub max_amount: Option, + #[serde(default)] + pub max_count: Option, +} + +impl Conditions { + pub fn is_empty(&self) -> bool { + *self == Conditions::default() + } + /// For Settings and sheets: "people in my contacts, no attachments". + pub fn describe(&self) -> String { + let mut parts = Vec::new(); + match (self.recipients_in_contacts, self.recipients_in_thread) { + (true, true) => parts.push("people in my contacts or the thread".to_string()), + (true, false) => parts.push("people in my contacts".into()), + (false, true) => parts.push("people in the thread".into()), + _ => {} + } + if self.no_attachments { + parts.push("no attachments".into()); + } + if self.triggered_by_person { + parts.push("when I start it".into()); + } + if let Some(a) = self.max_amount { + parts.push(format!("up to {a}")); + } + if let Some(c) = self.max_count { + parts.push(format!("at most {c} at a time")); + } + parts.join(", ") + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Rule { + pub id: RuleId, + /// The owning app. + pub app: String, + pub scope: RuleScope, + #[serde(default)] + pub conditions: Conditions, + #[serde(default)] + pub daily_cap: Option, + /// Unix seconds; required for [`RuleScope::Everything`]. + #[serde(default)] + pub expires: Option, + /// Also answer runs started by incoming content (off by default). + #[serde(default)] + pub include_incoming: bool, + #[serde(default = "yes")] + pub enabled: bool, + pub created: u64, + pub origin: RuleOrigin, + /// Uses on `used_day` (UTC day number). + #[serde(default)] + pub used_day: u64, + #[serde(default)] + pub used: u32, +} + +fn yes() -> bool { + true +} + +/// What the person asks for; [`RuleStore::create`] validates it. +#[derive(Clone, Debug, PartialEq)] +pub struct RuleDraft { + pub app: String, + pub scope: RuleScope, + pub conditions: Conditions, + pub daily_cap: Option, + /// Minutes from now; required for [`RuleScope::Everything`]. + pub minutes: Option, + pub include_incoming: bool, +} + +impl RuleDraft { + pub fn tool(app: &str, tool: &str, conditions: Conditions) -> RuleDraft { + RuleDraft { app: app.into(), scope: RuleScope::Tool(tool.into()), conditions, daily_cap: Some(DEFAULT_DAILY_CAP), minutes: None, include_incoming: false } + } + pub fn everything(app: &str, minutes: u64) -> RuleDraft { + RuleDraft { app: app.into(), scope: RuleScope::Everything, conditions: Conditions::default(), daily_cap: None, minutes: Some(minutes), include_incoming: false } + } + pub fn for_minutes(mut self, minutes: u64) -> RuleDraft { + self.minutes = Some(minutes); + self + } +} + +/// Why a rule did not answer a request (for tests and the audit). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Miss { + OtherApp, + OtherTool, + Off, + Expired, + Incoming, + RecipientsNotKnown, + NoRecipients, + Attachments, + NotByPerson, + Amount, + Count, + CapReached, +} + +/// The person's contacts, for "recipients in contacts". +pub trait Contacts: Send { + fn contains(&self, address: &str) -> bool; +} + +/// Nobody is in contacts: the contacts condition never holds until the +/// shell has a contacts source. +#[derive(Default)] +pub struct NoContacts; + +impl Contacts for NoContacts { + fn contains(&self, _address: &str) -> bool { + false + } +} + +/// A fixed list (tests, and a host-provided snapshot). +#[derive(Default, Clone)] +pub struct ContactList(pub Vec); + +impl Contacts for ContactList { + fn contains(&self, address: &str) -> bool { + self.0.iter().any(|c| c.eq_ignore_ascii_case(address)) + } +} + +pub fn day_of(now: u64) -> u64 { + now / DAY_S +} + +impl Rule { + pub fn is_everything(&self) -> bool { + self.scope == RuleScope::Everything + } + pub fn expired(&self, now: u64) -> bool { + self.expires.is_some_and(|e| now >= e) + } + /// Uses today. + pub fn used_today(&self, now: u64) -> u32 { + if self.used_day == day_of(now) { + self.used + } else { + 0 + } + } + pub fn minutes_left(&self, now: u64) -> Option { + self.expires.map(|e| e.saturating_sub(now).div_ceil(60)) + } + + /// Whether this rule answers `req`, on its exact arguments. The router + /// has already excluded `auto_approvable: false` and unknown outcomes. + pub fn check(&self, req: &Request, contacts: &dyn Contacts, now: u64) -> Result<(), Miss> { + if self.app != req.app { + return Err(Miss::OtherApp); + } + if let RuleScope::Tool(t) = &self.scope { + if *t != req.tool.name { + return Err(Miss::OtherTool); + } + } + if !self.enabled { + return Err(Miss::Off); + } + if self.expired(now) || (self.is_everything() && self.expires.is_none()) { + return Err(Miss::Expired); + } + if req.context.trigger.excluded_by_default() && !self.include_incoming { + return Err(Miss::Incoming); + } + let c = &self.conditions; + if c.recipients_in_contacts || c.recipients_in_thread { + let recipients = facts::recipients(&req.args); + if recipients.is_empty() { + return Err(Miss::NoRecipients); + } + let in_thread = |r: &String| req.context.thread.iter().any(|t| t.eq_ignore_ascii_case(r)); + let ok = recipients.iter().all(|r| (c.recipients_in_contacts && contacts.contains(r)) || (c.recipients_in_thread && in_thread(r))); + if !ok { + return Err(Miss::RecipientsNotKnown); + } + } + if c.no_attachments && facts::has_attachments(&req.args) { + return Err(Miss::Attachments); + } + if c.triggered_by_person && req.context.trigger != Trigger::Person { + return Err(Miss::NotByPerson); + } + if let Some(max) = c.max_amount { + match facts::amount(&req.args) { + Some(a) if a <= max => {} + _ => return Err(Miss::Amount), + } + } + if let Some(max) = c.max_count { + if facts::count(&req.args) > max { + return Err(Miss::Count); + } + } + if let Some(cap) = self.daily_cap { + if self.used_today(now) >= cap { + return Err(Miss::CapReached); + } + } + Ok(()) + } + + /// "Mail · mail.send · people in my contacts" for Settings. + pub fn describe(&self) -> String { + match &self.scope { + RuleScope::Everything => format!("Everything {} asks", super::sheet::app_label(&self.app)), + RuleScope::Tool(t) => { + let app = super::sheet::app_label(&self.app); + let c = self.conditions.describe(); + if c.is_empty() { + format!("{app} · {t}") + } else { + format!("{app} · {t} · {c}") + } + } + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct RulesFile { + schema: u32, + next_id: u64, + rules: Vec, +} + +/// The rules of one OctoSense home. +#[derive(Debug)] +pub struct RuleStore { + path: Option, + next_id: u64, + rules: Vec, +} + +impl RuleStore { + /// In memory only (tests). + pub fn memory() -> RuleStore { + RuleStore { path: None, next_id: 1, rules: Vec::new() } + } + /// The home's rules; a missing or unreadable file is no rules. + pub fn in_home(home: &Path) -> RuleStore { + let path = home.join(RULES_FILE); + let file: RulesFile = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + RuleStore { path: Some(path), next_id: file.next_id.max(1), rules: file.rules } + } + pub fn rules(&self) -> &[Rule] { + &self.rules + } + pub fn get(&self, id: &RuleId) -> Option<&Rule> { + self.rules.iter().find(|r| r.id == *id) + } + + /// Only the person creates a rule. + pub fn create(&mut self, gesture: &ApprovalGesture, draft: RuleDraft, now: u64) -> Result { + let expires = match (&draft.scope, draft.minutes) { + (RuleScope::Everything, None) => return Err("there is no \u{201c}everything, forever\u{201d} rule: choose how many minutes".into()), + (RuleScope::Everything, Some(m)) if m == 0 || m > MAX_EVERYTHING_MINUTES => { + return Err(format!("everything an app asks can be approved for {MAX_EVERYTHING_MINUTES} minutes at most")) + } + (_, Some(0)) => return Err("a rule needs at least one minute".into()), + (_, m) => m.map(|m| now + m * 60), + }; + if draft.app.trim().is_empty() { + return Err("a rule needs the app that owns the tool".into()); + } + if let RuleScope::Tool(t) = &draft.scope { + if t.trim().is_empty() { + return Err("a rule needs a tool".into()); + } + } + let id = RuleId(format!("r{}", self.next_id)); + self.next_id += 1; + // A new time-boxed "everything" replaces the app's previous one. + if draft.scope == RuleScope::Everything { + self.rules.retain(|r| !(r.app == draft.app && r.is_everything())); + } + self.rules.push(Rule { + id: id.clone(), + app: draft.app, + scope: draft.scope, + conditions: draft.conditions, + daily_cap: draft.daily_cap, + expires, + include_incoming: draft.include_incoming, + enabled: true, + created: now, + origin: gesture.origin(), + used_day: day_of(now), + used: 0, + }); + self.save(); + Ok(id) + } + + /// The first rule that answers `req`. Narrow rules first, then the + /// app's time-boxed "everything". + pub fn find(&self, req: &Request, contacts: &dyn Contacts, now: u64) -> Option { + let narrow = self.rules.iter().filter(|r| !r.is_everything()); + let broad = self.rules.iter().filter(|r| r.is_everything()); + narrow.chain(broad).find(|r| r.check(req, contacts, now).is_ok()).map(|r| r.id.clone()) + } + + /// Count one automatic approval against the rule's daily cap. + pub fn record_use(&mut self, id: &RuleId, now: u64) { + let today = day_of(now); + if let Some(r) = self.rules.iter_mut().find(|r| r.id == *id) { + if r.used_day != today { + r.used_day = today; + r.used = 0; + } + r.used += 1; + } + self.save(); + } + + /// Removing a rule needs no gesture: turning approvals off is always + /// allowed. + pub fn delete(&mut self, id: &RuleId) -> bool { + let before = self.rules.len(); + self.rules.retain(|r| r.id != *id); + let changed = self.rules.len() != before; + if changed { + self.save(); + } + changed + } + + /// Turn a rule back on: the person again. + pub fn enable(&mut self, _gesture: &ApprovalGesture, id: &RuleId) -> bool { + let Some(r) = self.rules.iter_mut().find(|r| r.id == *id) else { return false }; + r.enabled = true; + self.save(); + true + } + + /// One tap: every rule off. Time-boxed "everything" rules end (they are + /// never turned back on); the others stay listed, off. + pub fn all_off(&mut self) -> usize { + let n = self.rules.iter().filter(|r| r.enabled).count(); + self.rules.retain(|r| !r.is_everything()); + for r in &mut self.rules { + r.enabled = false; + } + self.save(); + n + } + + /// Drop expired rules; returns them (so the person is told). + pub fn expire(&mut self, now: u64) -> Vec { + let (gone, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.rules).into_iter().partition(|r| r.expired(now)); + self.rules = keep; + if !gone.is_empty() { + self.save(); + } + gone + } + + /// The time-boxed "everything" rules in force: the visible indicator. + pub fn active_everything(&self, now: u64) -> Vec<&Rule> { + self.rules.iter().filter(|r| r.is_everything() && r.enabled && !r.expired(now)).collect() + } + + fn save(&self) { + let Some(path) = &self.path else { return }; + let file = RulesFile { schema: 1, next_id: self.next_id, rules: self.rules.clone() }; + let Ok(bytes) = serde_json::to_vec_pretty(&file) else { return }; + if let Err(e) = super::write_private(path, &bytes) { + eprintln!("approvals: could not save the rules: {e}"); + } + } +} diff --git a/crates/shell/src/approvals/settings_page.rs b/crates/shell/src/approvals/settings_page.rs new file mode 100644 index 00000000..6b3dafa6 --- /dev/null +++ b/crates/shell/src/approvals/settings_page.rs @@ -0,0 +1,367 @@ +//! Settings → Assistant → Approvals (ADR 0004 §8, §4): the person's view of +//! what approves on its own. +//! +//! - **Rules**: each standing rule with its conditions, today's use of its +//! daily cap and the minutes left of a time box; Delete (or Turn on for +//! one that is off); **Turn every rule off** in one tap. +//! - **App agents**: every app's agent with its consent and an off switch; +//! "Everything for 60 min" creates the time-boxed rule for that app. +//! - **Recent automatic approvals**: the audit's newest automatic entries +//! (rule or developer mode, app, tool, caller, result). +//! +//! Drawn by the shell like the approval sheet (`view.rs`), as a modal page. + +use makepad_widgets::*; + +use super::audit::Entry; +use super::consent::State; +use super::rules::{ApprovalGesture, Rule, RuleDraft, RuleOrigin, MAX_EVERYTHING_MINUTES}; +use super::sheet::app_label; +use super::types::RuleId; +use super::view::Buttons; +use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, ShellDraw}; +use crate::shell::{alpha, ShellTokens}; + +script_mod! { + use mod.prelude.widgets_internal.* + use mod.widgets.* + + mod.widgets.ShellApprovalsSettingsBase = #(ShellApprovalsSettings::register_widget(vm)) + mod.widgets.ShellApprovalsSettings = set_type_default() do mod.widgets.ShellApprovalsSettingsBase { + width: Fill + height: Fill + draw_bg +: {} + d +: {} + } +} + +const PAGE_MAX_W: f64 = 680.0; +const PAD: f64 = 20.0; +const ROW_H: f64 = 40.0; + +#[derive(Clone, Debug, PartialEq)] +enum Hit { + Close, + AllOff, + Delete(RuleId), + Enable(RuleId), + AgentOff(String), + AgentAllow(String), + Everything(String), + Page, +} + +#[derive(Default)] +struct Frame { + open: bool, + rules: Vec, + agents: Vec<(String, String, State)>, + log: Vec, + now: u64, +} + +fn frame() -> Frame { + super::with(|a| { + if !a.settings_open { + return Frame::default(); + } + Frame { + open: true, + rules: a.router.rules.rules().to_vec(), + agents: a.consent.agents(), + log: a.router.audit.recent_automatic(8), + now: super::now(), + } + }) + .unwrap_or_default() +} + +/// "3 min ago". +pub fn ago(now: u64, ts: u64) -> String { + let s = now.saturating_sub(ts); + match s { + 0..=59 => "just now".into(), + 60..=3599 => format!("{} min ago", s / 60), + 3600..=86_399 => format!("{} h ago", s / 3600), + _ => format!("{} d ago", s / 86_400), + } +} + +/// The row's second line: cap use, time left, where it came from, state. +pub fn rule_status(rule: &Rule, now: u64) -> String { + let mut parts = Vec::new(); + if let Some(cap) = rule.daily_cap { + parts.push(format!("{} of {cap} today", rule.used_today(now))); + } else { + parts.push(format!("{} today", rule.used_today(now))); + } + if let Some(m) = rule.minutes_left(now) { + parts.push(format!("{m} min left")); + } + if rule.include_incoming { + parts.push("also for incoming content".into()); + } + parts.push(match rule.origin { + RuleOrigin::Settings => "made in Settings".into(), + RuleOrigin::Sheet => "made on a sheet".into(), + }); + if !rule.enabled { + parts.push("off".into()); + } + parts.join(" \u{00b7} ") +} + +/// An audit line: "2 min ago · Mail · mail.send · rule r3 · Calendar's agent · approved". +pub fn log_line(e: &Entry, now: u64) -> String { + let by = match (&e.rule, e.by.as_str()) { + (Some(r), _) => format!("rule {r}"), + (None, "developer_mode") => "developer mode".into(), + (None, b) => b.to_string(), + }; + format!("{} \u{00b7} {} \u{00b7} {} \u{00b7} {by} \u{00b7} {} \u{00b7} {}", ago(now, e.ts), app_label(&e.app), e.tool, e.caller, e.result) +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ShellApprovalsSettings { + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[walk] + walk: Walk, + #[layout] + layout: Layout, + #[redraw] + #[live] + draw_bg: DrawShellFill, + #[live] + d: ShellDraw, + #[live] + tokens: ShellTokens, + #[rust] + area: Area, + #[rust] + hits: Vec<(Rect, Hit)>, + #[rust] + open: bool, + #[rust] + down: Option, + #[rust] + hover: Option, + #[rust] + pub shown: Vec, +} + +impl ShellApprovalsSettings { + fn hit_at(&self, p: Vec2d) -> Option { + self.hits.iter().find(|(r, h)| *h != Hit::Page && contains(*r, p)).or_else(|| self.hits.iter().find(|(r, _)| contains(*r, p))).map(|(_, h)| h.clone()) + } + + /// The shell's pointer hook. Modal while open; a press outside closes. + pub fn pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { + if !self.open { + return false; + } + let (down, up, moved) = match event { + Event::MouseDown(e) => (Some(e.abs), None, None), + Event::MouseUp(e) => (None, Some(e.abs), None), + Event::MouseMove(e) => (None, None, Some(e.abs)), + Event::TouchUpdate(e) => ( + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Start).map(|t| t.abs), + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Stop).map(|t| t.abs), + None, + ), + _ => (None, None, None), + }; + if let Some(p) = moved { + let hover = self.hits.iter().find(|(r, h)| *h != Hit::Page && contains(*r, p)).map(|(r, _)| *r); + if hover != self.hover { + self.hover = hover; + self.redraw(cx); + } + } + if let Some(p) = down { + self.down = Some(self.hit_at(p).unwrap_or(Hit::Close)); + } + if let Some(p) = up { + let hit = self.hit_at(p).unwrap_or(Hit::Close); + if self.down.take().as_ref() == Some(&hit) { + act(hit); + self.redraw(cx); + } + } + true + } + + fn draw_page(&mut self, cx: &mut Cx2d, screen: Rect) { + self.hits.clear(); + self.shown.clear(); + let f = frame(); + self.open = f.open; + if !f.open { + return; + } + let tok = self.d.tokens(self.tokens); + self.d.solid(cx, screen, Vec4f { x: 0.0, y: 0.0, z: 0.0, w: 0.45 }); + let w = PAGE_MAX_W.min(screen.size.x - 32.0).max(240.0); + let h = (screen.size.y - 64.0).max(200.0); + let page = rect(screen.pos.x + (screen.size.x - w) * 0.5, screen.pos.y + 32.0, w, h); + self.d.card(cx, page, &tok.popups); + self.hits.push((page, Hit::Page)); + let ink = tok.popups.text; + let dim = alpha(ink, 0.65); + let x = page.pos.x + PAD; + let cw = page.size.x - PAD * 2.0; + let bottom = page.pos.y + page.size.y - PAD; + let mut y = page.pos.y + PAD; + let mut hits = Vec::new(); + let mut shown = Vec::new(); + let mut b = Buttons { d: &mut self.d, tok, hover: self.hover }; + + b.d.label_elided(cx, rect(x, y, cw * 0.6, 14.0), false, tok.font.caption, dim, HAlign::Left, "Settings \u{203a} Assistant"); + let close_w = b.width(cx, "Close"); + let close = b.draw(cx, x + cw - close_w, y, close_w, "Close", false); + hits.push((close, Hit::Close)); + y += 16.0; + b.d.label_elided(cx, rect(x, y, cw - close_w - 8.0, 26.0), true, tok.font.display * 0.75, ink, HAlign::Left, "Approvals"); + shown.push("Approvals".to_string()); + y += 32.0; + b.d.label_elided(cx, rect(x, y, cw, 16.0), false, tok.font.body_small, dim, HAlign::Left, "Only you approve. Rules answer the approvals you choose, on their exact arguments; everything else asks you."); + y += 26.0; + + // Rules. + let section = |b: &mut Buttons, cx: &mut Cx2d, y: &mut f64, title: &str| { + b.d.label_elided(cx, rect(x, *y, cw, 20.0), true, tok.font.subtitle, ink, HAlign::Left, title); + *y += 24.0; + }; + section(&mut b, cx, &mut y, "Rules"); + let any_on = f.rules.iter().any(|r| r.enabled); + let off = b.draw(cx, x, y, cw, "Turn every rule off", any_on); + if any_on { + hits.push((off, Hit::AllOff)); + } + y += 28.0 + 10.0; + if f.rules.is_empty() { + b.d.label_elided(cx, rect(x, y, cw, 18.0), false, tok.font.body, dim, HAlign::Left, "No rules. Every approval asks you."); + shown.push("No rules. Every approval asks you.".into()); + y += 26.0; + } + for rule in &f.rules { + if y + ROW_H > bottom { + break; + } + let label = rule.describe(); + let status = rule_status(rule, f.now); + let del_w = b.width(cx, "Delete"); + let del = b.draw(cx, x + cw - del_w, y + 6.0, del_w, "Delete", false); + hits.push((del, Hit::Delete(rule.id.clone()))); + let mut text_w = cw - del_w - 12.0; + if !rule.enabled { + let on_w = b.width(cx, "Turn on"); + let on = b.draw(cx, del.pos.x - on_w - 8.0, y + 6.0, on_w, "Turn on", false); + hits.push((on, Hit::Enable(rule.id.clone()))); + text_w -= on_w + 8.0; + } + b.d.label_elided(cx, rect(x, y, text_w, 20.0), true, tok.font.body, if rule.enabled { ink } else { dim }, HAlign::Left, &label); + b.d.label_elided(cx, rect(x, y + 20.0, text_w, 16.0), false, tok.font.body_small, dim, HAlign::Left, &status); + shown.push(format!("{label} | {status}")); + y += ROW_H + 4.0; + } + y += 8.0; + + // App agents. + if y + 60.0 < bottom { + section(&mut b, cx, &mut y, "App agents"); + if f.agents.is_empty() { + b.d.label_elided(cx, rect(x, y, cw, 18.0), false, tok.font.body, dim, HAlign::Left, "No app has asked for its agent yet."); + y += 26.0; + } + for (app, name, state) in &f.agents { + if y + ROW_H > bottom { + break; + } + let state_text = match state { + State::Allowed => "Allowed", + State::Denied => "Off", + State::Undecided => "Not asked yet", + }; + let (switch, hit) = match state { + State::Allowed => ("Turn off", Hit::AgentOff(app.clone())), + _ => ("Allow", Hit::AgentAllow(app.clone())), + }; + let every = format!("Everything for {MAX_EVERYTHING_MINUTES} min"); + let sw = b.width(cx, switch); + let s = b.draw(cx, x + cw - sw, y + 6.0, sw, switch, false); + hits.push((s, hit)); + let ew = b.width(cx, &every); + let e = b.draw(cx, s.pos.x - ew - 8.0, y + 6.0, ew, &every, false); + hits.push((e, Hit::Everything(app.clone()))); + let tw = e.pos.x - x - 12.0; + b.d.label_elided(cx, rect(x, y, tw, 20.0), true, tok.font.body, ink, HAlign::Left, &format!("{name}'s agent")); + b.d.label_elided(cx, rect(x, y + 20.0, tw, 16.0), false, tok.font.body_small, dim, HAlign::Left, state_text); + shown.push(format!("{name}'s agent | {state_text}")); + y += ROW_H + 4.0; + } + y += 8.0; + } + + // Recent automatic approvals. + if y + 44.0 < bottom { + section(&mut b, cx, &mut y, "Recent automatic approvals"); + if f.log.is_empty() { + b.d.label_elided(cx, rect(x, y, cw, 18.0), false, tok.font.body, dim, HAlign::Left, "None yet."); + } + for e in &f.log { + if y + 20.0 > bottom { + break; + } + let line = log_line(e, f.now); + b.d.label_elided(cx, rect(x, y, cw, 18.0), false, tok.font.body_small, ink, HAlign::Left, &line); + shown.push(line); + y += 20.0; + } + } + self.hits.extend(hits); + self.shown = shown; + } +} + +/// What a press does on the page. Creating or re-enabling a rule and +/// allowing an agent are the person's gestures. +fn act(hit: Hit) { + let now = super::now(); + super::with(|a| match hit { + Hit::Close => a.settings_open = false, + Hit::AllOff => { + a.router.all_off(); + } + Hit::Delete(id) => { + a.router.delete_rule(&id); + } + Hit::Enable(id) => { + a.router.rules.enable(&ApprovalGesture::settings_tap(), &id); + } + Hit::AgentOff(app) => a.consent.turn_off(&app, now), + Hit::AgentAllow(app) => a.consent.set(&ApprovalGesture::settings_tap(), &app, true, now), + Hit::Everything(app) => { + if let Err(e) = a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything(&app, MAX_EVERYTHING_MINUTES), now) { + log!("approvals: {e}"); + } + } + Hit::Page => {} + }); +} + +impl Widget for ShellApprovalsSettings { + fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + cx.begin_turtle(walk, self.layout); + let screen = cx.turtle().rect(); + self.d.begin_surface(cx); + self.draw_page(cx, screen); + self.d.end_surface(cx); + cx.end_turtle_with_area(&mut self.area); + DrawStep::done() + } + + fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} +} diff --git a/crates/shell/src/approvals/sheet.rs b/crates/shell/src/approvals/sheet.rs new file mode 100644 index 00000000..7143c445 --- /dev/null +++ b/crates/shell/src/approvals/sheet.rs @@ -0,0 +1,211 @@ +//! The model of a shell-drawn approval sheet (ADR 0004 §8; ADR 0002 §10). +//! +//! One sheet per request, in the owning app's conversation; or one batched +//! sheet in the system chat for the `confirm: host` approvals of one of the +//! system agent's requests. Each line shows the **owning app, the tool, the +//! exact arguments** (pretty-printed, secret-typed fields redacted) and +//! **who is calling**, and is answered on its own: approve once, deny, or +//! "always for …" (which creates a standing rule; only offered where a rule +//! could ever answer the call). The view (`view.rs`) draws this model and +//! nothing else; an agent's own text is never an approval surface. + +use super::facts; +use super::rules::{Conditions, Contacts, RuleDraft, MAX_EVERYTHING_MINUTES}; +use super::types::{Caller, Connection, Request, RequestId, Trigger}; + +/// Why the router put a request in front of the person. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Surfaced { + /// The tool declares `auto_approvable: false`. + NotAutoApprovable, + /// octos marked the call `outcome_unknown`. + OutcomeUnknown, + /// A run started by incoming content; rules are off for it by default. + IncomingContent, + /// A Talk to Octos external client asked. + External, + /// No rule answered it. + NoRule, +} + +impl Surfaced { + pub fn note(&self) -> &'static str { + match self { + Surfaced::NotAutoApprovable => "This tool always needs you: no rule can approve it.", + Surfaced::OutcomeUnknown => "An earlier try may already have happened; it is never retried without you.", + Surfaced::IncomingContent => "Started by something someone else sent, so rules don't apply.", + Surfaced::External => "Asked by an outside client.", + Surfaced::NoRule => "", + } + } + /// Whether an "always for …" answer makes sense. + pub fn rules_could_answer(&self) -> bool { + matches!(self, Surfaced::NoRule | Surfaced::IncomingContent) + } +} + +/// Where the sheet is drawn. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Place { + AppConversation { app: String }, + SystemChat { batch: String, plan: String }, +} + +/// One "always for …" choice: the rule a tap on it creates. +#[derive(Clone, Debug, PartialEq)] +pub struct AlwaysChoice { + pub label: String, + pub draft: RuleDraft, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + Once, + Deny, + /// The index into [`Line::always`]. + Always(usize), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Line { + pub request: RequestId, + pub app: String, + pub app_label: String, + pub tool: String, + pub caller: String, + /// The exact arguments, one printed line each, secrets redacted. + pub args: Vec, + pub surfaced: Surfaced, + pub always: Vec, + pub answer: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + pub id: u64, + pub place: Place, + pub lines: Vec, + /// Unix seconds. + pub opened: u64, +} + +/// `os.mail` → `Mail`. +pub fn app_label(app: &str) -> String { + let id = app.strip_prefix("os.").unwrap_or(app); + let mut chars = id.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// "Calendar's agent", "The system agent", "Rinx's agent for weather". +pub fn caller_label(owning_app: &str, caller: &Caller) -> String { + match caller { + Caller::OwnAgent { client: None } => format!("{}'s agent", app_label(owning_app)), + Caller::OwnAgent { client: Some(c) } => format!("{}'s agent for {c}", app_label(owning_app)), + Caller::AppAgent { app } => format!("{}'s agent", app_label(app)), + Caller::SystemAgent => "The system agent".into(), + } +} + +impl Line { + pub fn for_request(req: &Request, surfaced: Surfaced, contacts: &dyn Contacts) -> Line { + let always = if surfaced.rules_could_answer() && req.tool.auto_approvable && !req.context.outcome_unknown && req.context.connection == Connection::Host { + always_choices(req, contacts) + } else { + Vec::new() + }; + Line { + request: req.id.clone(), + app: req.app.clone(), + app_label: app_label(&req.app), + tool: req.tool.name.clone(), + caller: caller_label(&req.app, &req.caller), + args: facts::pretty(&req.args, &req.tool.secret_fields), + surfaced, + always, + answer: None, + } + } + /// "Mail · mail.send". + pub fn heading(&self) -> String { + format!("{} \u{00b7} {}", self.app_label, self.tool) + } +} + +/// The rules a sheet offers for this call, narrowest first. +fn always_choices(req: &Request, contacts: &dyn Contacts) -> Vec { + let mut out = Vec::new(); + let recipients = facts::recipients(&req.args); + let no_attachments = !facts::has_attachments(&req.args); + let tool = &req.tool.name; + let with_attachments = |mut c: Conditions| { + c.no_attachments = no_attachments; + c + }; + if !recipients.is_empty() { + if recipients.iter().all(|r| contacts.contains(r)) { + let c = with_attachments(Conditions { recipients_in_contacts: true, ..Conditions::default() }); + out.push(AlwaysChoice { label: format!("Always for people in my contacts{}", if no_attachments { ", no attachments" } else { "" }), draft: RuleDraft::tool(&req.app, tool, c) }); + } + if recipients.iter().all(|r| req.context.thread.iter().any(|t| t.eq_ignore_ascii_case(r))) { + let c = with_attachments(Conditions { recipients_in_thread: true, ..Conditions::default() }); + out.push(AlwaysChoice { label: "Always for people in this thread".into(), draft: RuleDraft::tool(&req.app, tool, c) }); + } + } + if req.context.trigger == Trigger::Person { + let c = Conditions { triggered_by_person: true, ..Conditions::default() }; + out.push(AlwaysChoice { label: format!("Always when I start it"), draft: RuleDraft::tool(&req.app, tool, c) }); + } + out.push(AlwaysChoice { label: format!("Allow {tool} for 1 hour"), draft: RuleDraft::tool(&req.app, tool, Conditions::default()).for_minutes(60) }); + out.push(AlwaysChoice { + label: format!("Everything {} asks, next {MAX_EVERYTHING_MINUTES} min", app_label(&req.app)), + draft: RuleDraft::everything(&req.app, MAX_EVERYTHING_MINUTES), + }); + out +} + +impl Sheet { + pub fn title(&self) -> String { + match &self.place { + Place::SystemChat { plan, .. } => { + if plan.trim().is_empty() { + "The system agent asks".into() + } else { + plan.clone() + } + } + Place::AppConversation { app } => { + let tool = self.lines.first().map(|l| l.tool.as_str()).unwrap_or(""); + format!("{} wants to use {tool}", app_label(app)) + } + } + } + /// Under the title: where the sheet is. + pub fn subtitle(&self) -> String { + match &self.place { + Place::SystemChat { .. } => { + let n = self.open_lines().count(); + format!("In the system chat \u{00b7} {n} action{} to approve", if n == 1 { "" } else { "s" }) + } + Place::AppConversation { app } => format!("In {}'s conversation", app_label(app)), + } + } + pub fn line(&self, id: &RequestId) -> Option<&Line> { + self.lines.iter().find(|l| l.request == *id) + } + pub fn open_lines(&self) -> impl Iterator { + self.lines.iter().filter(|l| l.answer.is_none()) + } + /// Every line answered: the sheet closes. + pub fn done(&self) -> bool { + self.lines.iter().all(|l| l.answer.is_some()) + } + pub fn batch_id(&self) -> Option<&str> { + match &self.place { + Place::SystemChat { batch, .. } => Some(batch), + Place::AppConversation { .. } => None, + } + } +} diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs new file mode 100644 index 00000000..2927f86e --- /dev/null +++ b/crates/shell/src/approvals/tests.rs @@ -0,0 +1,612 @@ +//! The router's precedence, every rule condition, the cap, the time box, +//! the incoming-content exclusion, the sheet model, the `confirm: app` +//! hand-off, consent and the audit. + +use super::audit::AuditLog; +use super::consent::{AgentSummary, ConsentStore, State}; +use super::dev_hooks::FixedDevMode; +use super::relay::RecordingRelay; +use super::router::{make_request, AppConfirm, AppConfirmRequest, AutoBy, Route, Router}; +use super::rules::{Conditions, ContactList, ApprovalGesture, RuleDraft, RuleStore, DEFAULT_DAILY_CAP}; +use super::sheet::{Answer, Place, Surfaced}; +use super::types::*; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +const T0: u64 = 1_790_000_000; // a fixed "now", mid-day UTC +const MAIL: &str = "os.mail"; + +fn router_with(dev: FixedDevMode) -> (Router, RecordingRelay) { + let relay = RecordingRelay::default(); + let contacts = ContactList(vec!["ana@example.org".into(), "bo@example.org".into()]); + let r = Router::new(RuleStore::memory(), AuditLog::memory(), Box::new(dev), Box::new(contacts), Box::new(relay.clone())); + (r, relay) +} +fn router() -> (Router, RecordingRelay) { + router_with(FixedDevMode::off()) +} + +fn ctx(id: &str, trigger: Trigger) -> RequestContext { + RequestContext { call_id: id.into(), trigger, ..RequestContext::default() } +} +fn req(id: &str, tool: ToolSpec, args: Value, trigger: Trigger) -> Request { + make_request(MAIL, tool, args, Caller::AppAgent { app: "calendar".into() }, ctx(id, trigger), T0, 0) +} +fn send(id: &str, args: Value) -> Request { + req(id, ToolSpec::host("mail.send"), args, Trigger::Person) +} +fn rule(r: &mut Router, draft: RuleDraft) -> RuleId { + r.create_rule(&ApprovalGesture::settings_tap(), draft, T0).expect("rule") +} +fn contacts_rule() -> RuleDraft { + RuleDraft::tool(MAIL, "mail.send", Conditions { recipients_in_contacts: true, ..Conditions::default() }) +} + +// ---------------------------------------------------------------- precedence + +#[test] +fn no_rule_means_a_sheet() { + let (mut r, relay) = router(); + assert!(matches!(r.request(send("a", json!({"to": "ana@example.org"})), T0), Route::Sheet(_))); + assert!(relay.take().is_empty(), "nothing is decided before the person answers"); + assert_eq!(r.front_sheet().unwrap().lines[0].surfaced, Surfaced::NoRule); +} + +#[test] +fn developer_mode_first_even_for_not_auto_approvable_and_app_confirm() { + let (mut r, relay) = router_with(FixedDevMode::all()); + let del = req("del", ToolSpec::host("mail.delete_forever").not_auto_approvable(), json!({"id": 1}), Trigger::Unknown); + assert_eq!(r.request(del, T0), Route::Approved(AutoBy::DeveloperMode)); + let app = make_request("rinx", ToolSpec::app("rinx.message.send"), json!({}), Caller::SystemAgent, ctx("rx", Trigger::SystemAgent), T0, 0); + assert_eq!(r.request(app, T0), Route::Approved(AutoBy::DeveloperMode)); + let cmd = make_request("terminal", ToolSpec::host("terminal.run").command(), json!({"command": "ls"}), Caller::SystemAgent, ctx("cmd", Trigger::Person), T0, 0); + assert_eq!(r.request(cmd, T0), Route::Approved(AutoBy::DeveloperMode)); + let d = relay.take(); + assert_eq!(d.len(), 3); + assert!(d.iter().all(|(_, dec, reason)| *dec == Decision::ApproveOnce && reason == "developer mode")); + // Audited and notified. + assert!(r.audit.all().iter().all(|e| e.by == "developer_mode" && e.result == "approved")); + assert!(r.take_notices().iter().filter(|n| n.title.starts_with("Approved automatically")).count() == 3); +} + +#[test] +fn developer_mode_never_answers_an_external_client() { + let (mut r, relay) = router_with(FixedDevMode::all()); + let mut q = send("x", json!({"to": "ana@example.org"})); + q.context.connection = Connection::External; + assert!(matches!(r.request(q, T0), Route::Sheet(_))); + assert!(relay.take().is_empty()); + assert_eq!(r.front_sheet().unwrap().lines[0].surfaced, Surfaced::External); + assert!(r.front_sheet().unwrap().lines[0].always.is_empty(), "no rule for an external client"); +} + +#[test] +fn developer_mode_for_other_apps_changes_nothing() { + let (mut r, _) = router_with(FixedDevMode { all: false, apps: vec!["rinx".into()] }); + assert!(matches!(r.request(send("a", json!({"to": "ana@example.org"})), T0), Route::Sheet(_))); +} + +#[test] +fn not_auto_approvable_ignores_every_rule() { + let (mut r, relay) = router(); + rule(&mut r, RuleDraft::everything(MAIL, 30)); + rule(&mut r, RuleDraft::tool(MAIL, "mail.delete_forever", Conditions::default())); + let del = req("del", ToolSpec::host("mail.delete_forever").not_auto_approvable(), json!({"id": 1}), Trigger::Person); + assert!(matches!(r.request(del, T0), Route::Sheet(_))); + assert!(relay.take().is_empty()); + let line = &r.front_sheet().unwrap().lines[0]; + assert_eq!(line.surfaced, Surfaced::NotAutoApprovable); + assert!(line.always.is_empty(), "no \u{201c}always\u{201d} for a tool no rule may answer"); +} + +#[test] +fn unknown_outcome_always_goes_to_the_person() { + let (mut r, relay) = router(); + rule(&mut r, contacts_rule()); + let mut q = send("u", json!({"to": "ana@example.org"})); + q.context.outcome_unknown = true; + assert!(matches!(r.request(q, T0), Route::Sheet(_))); + assert!(relay.take().is_empty()); + assert_eq!(r.front_sheet().unwrap().lines[0].surfaced, Surfaced::OutcomeUnknown); +} + +#[test] +fn incoming_content_is_excluded_by_default() { + let (mut r, relay) = router(); + rule(&mut r, contacts_rule()); + let from = Trigger::IncomingContent { from: Some("eve@example.org".into()) }; + let q = req("in", ToolSpec::host("mail.send"), json!({"to": "ana@example.org"}), from.clone()); + assert!(matches!(r.request(q, T0), Route::Sheet(_))); + assert!(relay.take().is_empty()); + assert_eq!(r.front_sheet().unwrap().lines[0].surfaced, Surfaced::IncomingContent); + // An unstated trigger is treated the same. + let q = req("unk", ToolSpec::host("mail.send"), json!({"to": "ana@example.org"}), Trigger::Unknown); + assert!(matches!(r.request(q, T0), Route::Sheet(_))); + // A rule that opts in answers it. + let mut d = contacts_rule(); + d.include_incoming = true; + let id = rule(&mut r, d); + let q = req("in2", ToolSpec::host("mail.send"), json!({"to": "ana@example.org"}), from); + assert_eq!(r.request(q, T0), Route::Approved(AutoBy::Rule(id))); +} + +#[test] +fn rules_are_keyed_on_owning_app_and_tool_whoever_calls() { + let (mut r, relay) = router(); + let id = rule(&mut r, contacts_rule()); + for (i, caller) in [Caller::OwnAgent { client: None }, Caller::AppAgent { app: "calendar".into() }, Caller::SystemAgent].into_iter().enumerate() { + let q = make_request(MAIL, ToolSpec::host("mail.send"), json!({"to": "ana@example.org"}), caller.clone(), ctx(&format!("c{i}"), Trigger::SystemAgent), T0, 0); + assert_eq!(r.request(q, T0), Route::Approved(AutoBy::Rule(id.clone())), "{caller:?}"); + } + // Another tool, or the same tool name of another app, is not covered. + assert!(matches!(r.request(req("t", ToolSpec::host("mail.forward"), json!({"to": "ana@example.org"}), Trigger::Person), T0), Route::Sheet(_))); + let other = make_request("os.notes", ToolSpec::host("mail.send"), json!({"to": "ana@example.org"}), Caller::SystemAgent, ctx("o", Trigger::Person), T0, 0); + assert!(matches!(r.request(other, T0), Route::Sheet(_))); + let d = relay.take(); + assert_eq!(d.len(), 3); + assert!(d.iter().all(|(_, dec, _)| *dec == Decision::ApproveByRule(id.clone()))); +} + +// ---------------------------------------------------------------- conditions + +fn matches(r: &mut Router, id: &str, args: Value, trigger: Trigger, thread: &[&str]) -> bool { + let mut q = req(id, ToolSpec::host("mail.send"), args, trigger); + q.context.thread = thread.iter().map(|s| s.to_string()).collect(); + matches!(r.request(q, T0), Route::Approved(AutoBy::Rule(_))) +} + +#[test] +fn condition_recipients_in_contacts() { + let (mut r, _) = router(); + rule(&mut r, contacts_rule()); + assert!(matches(&mut r, "1", json!({"to": ["Ana ", "bo@example.org"]}), Trigger::Person, &[])); + assert!(!matches(&mut r, "2", json!({"to": ["ana@example.org", "eve@example.org"]}), Trigger::Person, &[])); + assert!(!matches(&mut r, "3", json!({"to": "ana@example.org", "bcc": "eve@example.org"}), Trigger::Person, &[]), "bcc counts"); + assert!(!matches(&mut r, "4", json!({"subject": "no recipients"}), Trigger::Person, &[]), "a missing fact fails"); +} + +#[test] +fn condition_recipients_in_thread_and_either() { + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { recipients_in_thread: true, ..Conditions::default() })); + assert!(matches(&mut r, "1", json!({"to": "eve@example.org"}), Trigger::Person, &["eve@example.org"])); + assert!(!matches(&mut r, "2", json!({"to": "ana@example.org"}), Trigger::Person, &["eve@example.org"]), "in contacts is not in the thread"); + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { recipients_in_thread: true, recipients_in_contacts: true, ..Conditions::default() })); + assert!(matches(&mut r, "3", json!({"to": ["ana@example.org", "eve@example.org"]}), Trigger::Person, &["eve@example.org"])); + assert!(!matches(&mut r, "4", json!({"to": ["zed@example.org"]}), Trigger::Person, &["eve@example.org"])); +} + +#[test] +fn condition_no_attachments() { + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { no_attachments: true, ..Conditions::default() })); + assert!(matches(&mut r, "1", json!({"to": "x@y", "attachments": []}), Trigger::Person, &[])); + assert!(!matches(&mut r, "2", json!({"to": "x@y", "attachments": ["contract.pdf"]}), Trigger::Person, &[])); +} + +#[test] +fn condition_triggered_by_the_person() { + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { triggered_by_person: true, ..Conditions::default() })); + assert!(matches(&mut r, "1", json!({"to": "x@y"}), Trigger::Person, &[])); + assert!(!matches(&mut r, "2", json!({"to": "x@y"}), Trigger::App, &[])); + assert!(!matches(&mut r, "3", json!({"to": "x@y"}), Trigger::SystemAgent, &[])); +} + +#[test] +fn condition_amount_and_count_limits() { + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { max_amount: Some(50.0), ..Conditions::default() })); + assert!(matches(&mut r, "1", json!({"amount": 49.5}), Trigger::Person, &[])); + assert!(matches(&mut r, "2", json!({"amount": "$50"}), Trigger::Person, &[])); + assert!(!matches(&mut r, "3", json!({"amount": 50.01}), Trigger::Person, &[])); + assert!(!matches(&mut r, "4", json!({"note": "no amount"}), Trigger::Person, &[]), "a missing amount fails"); + let (mut r, _) = router(); + rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions { max_count: Some(2), ..Conditions::default() })); + assert!(matches(&mut r, "5", json!({"to": ["a@x", "b@x"]}), Trigger::Person, &[])); + assert!(!matches(&mut r, "6", json!({"to": ["a@x", "b@x", "c@x"]}), Trigger::Person, &[])); + assert!(!matches(&mut r, "7", json!({"count": 3}), Trigger::Person, &[])); +} + +#[test] +fn daily_cap_then_the_next_day() { + let (mut r, _) = router(); + let mut d = contacts_rule(); + d.daily_cap = Some(2); + let id = rule(&mut r, d); + let args = json!({"to": "ana@example.org"}); + assert!(matches!(r.request(send("1", args.clone()), T0), Route::Approved(_))); + assert!(matches!(r.request(send("2", args.clone()), T0 + 5), Route::Approved(_))); + assert!(matches!(r.request(send("3", args.clone()), T0 + 10), Route::Sheet(_)), "the cap is reached"); + assert_eq!(r.rules.get(&id).unwrap().used_today(T0), 2); + let tomorrow = T0 + 86_400; + assert!(matches!(r.request(send("4", args), tomorrow), Route::Approved(_))); + assert_eq!(r.rules.get(&id).unwrap().used_today(tomorrow), 1); + // A sheet's rule gets the default cap. + assert_eq!(contacts_rule().daily_cap, Some(DEFAULT_DAILY_CAP)); +} + +#[test] +fn time_boxed_everything_expires_and_is_never_forever() { + let (mut r, _) = router(); + let g = ApprovalGesture::settings_tap(); + assert!(r.create_rule(&g, RuleDraft { minutes: None, ..RuleDraft::everything(MAIL, 1) }, T0).is_err(), "no forever"); + assert!(r.create_rule(&g, RuleDraft::everything(MAIL, 61), T0).is_err(), "60 minutes at most"); + let id = rule(&mut r, RuleDraft::everything(MAIL, 60)); + assert_eq!(r.rules.active_everything(T0).len(), 1, "the indicator shows it"); + assert_eq!(r.rules.get(&id).unwrap().minutes_left(T0 + 90), Some(59)); + let other_tool = req("a", ToolSpec::host("mail.archive"), json!({"id": 3}), Trigger::App); + assert_eq!(r.request(other_tool, T0 + 60), Route::Approved(AutoBy::Rule(id.clone()))); + // Not after the hour. + let late = T0 + 3600; + assert!(r.tick(late)); + assert!(r.rules.get(&id).is_none(), "expired rules are removed"); + assert!(r.take_notices().iter().any(|n| n.title == "Approval rule ended")); + assert!(matches!(r.request(req("b", ToolSpec::host("mail.archive"), json!({"id": 4}), Trigger::App), late), Route::Sheet(_))); + // A tool rule with a time box expires too, even before tick. + let id = rule(&mut r, RuleDraft::tool(MAIL, "mail.send", Conditions::default()).for_minutes(10)); + assert!(r.rules.get(&id).unwrap().expired(T0 + 600)); +} + +#[test] +fn narrow_rules_answer_before_the_time_box() { + let (mut r, _) = router(); + let broad = rule(&mut r, RuleDraft::everything(MAIL, 30)); + let narrow = rule(&mut r, contacts_rule()); + assert_eq!(r.request(send("1", json!({"to": "ana@example.org"})), T0), Route::Approved(AutoBy::Rule(narrow))); + assert_eq!(r.request(send("2", json!({"to": "eve@example.org"})), T0), Route::Approved(AutoBy::Rule(broad))); +} + +#[test] +fn one_tap_turns_every_rule_off() { + let (mut r, _) = router(); + let narrow = rule(&mut r, contacts_rule()); + rule(&mut r, RuleDraft::everything(MAIL, 30)); + assert_eq!(r.all_off(), 2); + assert!(r.rules.active_everything(T0).is_empty(), "the time box ends"); + assert!(!r.rules.get(&narrow).unwrap().enabled); + assert!(matches!(r.request(send("1", json!({"to": "ana@example.org"})), T0), Route::Sheet(_))); + // Only the person turns one back on. + assert!(r.rules.enable(&ApprovalGesture::settings_tap(), &narrow)); + assert!(matches!(r.request(send("2", json!({"to": "ana@example.org"})), T0), Route::Approved(_))); +} + +// ---------------------------------------------------------------- sheets + +#[test] +fn sheet_model_shows_app_tool_caller_and_redacted_args() { + let (mut r, _) = router(); + let tool = ToolSpec::host("mail.send").secret("signature_key"); + let args = json!({"to": ["ana@example.org"], "subject": "Tuesday", "signature_key": "k-123", "smtp_password": "hunter2"}); + let Route::Sheet(id) = r.request(req("s", tool, args, Trigger::Person), T0) else { panic!() }; + let sheet = r.front_sheet().unwrap(); + assert_eq!(sheet.id, id); + assert_eq!(sheet.place, Place::AppConversation { app: MAIL.into() }); + assert_eq!(sheet.title(), "Mail wants to use mail.send"); + let line = &sheet.lines[0]; + assert_eq!(line.heading(), "Mail \u{00b7} mail.send"); + assert_eq!(line.caller, "Calendar's agent"); + let text = line.args.join("\n"); + assert!(text.contains("\"subject\": \"Tuesday\"") && text.contains("ana@example.org"), "{text}"); + assert!(!text.contains("k-123") && !text.contains("hunter2"), "secrets redacted: {text}"); + // Offers: contacts (Ana is one), when I start it, 1 hour, the time box. + let labels: Vec<&str> = line.always.iter().map(|c| c.label.as_str()).collect(); + assert_eq!(labels[0], "Always for people in my contacts, no attachments"); + assert!(labels.contains(&"Always when I start it")); + assert!(labels.iter().any(|l| l.starts_with("Everything Mail asks"))); +} + +#[test] +fn a_batch_is_one_sheet_in_the_system_chat() { + let (mut r, relay) = router(); + let batch = Batch { id: "plan-7".into(), plan: "Book Tue 3 pm and invite 2".into() }; + let mut ids = Vec::new(); + for (i, to) in ["ana@example.org", "eve@example.org"].iter().enumerate() { + let mut q = send(&format!("b{i}"), json!({"to": to})); + q.context.batch = Some(batch.clone()); + q.caller = Caller::SystemAgent; + let Route::Sheet(id) = r.request(q, T0) else { panic!() }; + ids.push(id); + } + assert_eq!(ids[0], ids[1], "one sheet"); + assert_eq!(r.sheets().len(), 1); + let sheet = r.front_sheet().unwrap().clone(); + assert!(matches!(sheet.place, Place::SystemChat { .. })); + assert_eq!(sheet.title(), "Book Tue 3 pm and invite 2"); + assert_eq!(sheet.subtitle(), "In the system chat \u{00b7} 2 actions to approve"); + assert_eq!(sheet.lines[1].caller, "The system agent"); + // Answered line by line. + let g = ApprovalGesture::sheet_tap(); + r.answer(sheet.id, &RequestId("b0".into()), Answer::Once, &g, T0).unwrap(); + assert_eq!(r.sheets().len(), 1, "one line still open"); + r.answer(sheet.id, &RequestId("b1".into()), Answer::Deny, &g, T0).unwrap(); + assert!(r.sheets().is_empty()); + assert_eq!(relay.take(), vec![(RequestId("b0".into()), Decision::ApproveOnce, "approved on the sheet".into()), (RequestId("b1".into()), Decision::Deny, "denied on the sheet".into())]); + assert!(r.answer(sheet.id, &RequestId("b1".into()), Answer::Once, &g, T0).is_err(), "answered once"); +} + +#[test] +fn always_for_makes_a_rule_and_answers_the_matching_open_lines() { + let (mut r, relay) = router(); + let Route::Sheet(s1) = r.request(send("1", json!({"to": "ana@example.org"})), T0) else { panic!() }; + let Route::Sheet(_) = r.request(send("2", json!({"to": "bo@example.org"})), T0) else { panic!() }; + let Route::Sheet(_) = r.request(send("3", json!({"to": "eve@example.org"})), T0) else { panic!() }; + let made = r.answer(s1, &RequestId("1".into()), Answer::Always(0), &ApprovalGesture::sheet_tap(), T0).unwrap().expect("a rule"); + let rule = r.rules.get(&made).unwrap(); + assert!(rule.conditions.recipients_in_contacts && rule.conditions.no_attachments); + assert_eq!(rule.daily_cap, Some(DEFAULT_DAILY_CAP)); + let d = relay.take(); + assert_eq!(d[0], (RequestId("1".into()), Decision::ApproveByRule(made.clone()), "approved and made a rule: Always for people in my contacts, no attachments".into())); + assert_eq!(d[1].0, RequestId("2".into()), "Bo is in contacts too"); + assert_eq!(d.len(), 2, "Eve still asks"); + assert_eq!(r.sheets().len(), 1); + assert_eq!(r.rules.get(&made).unwrap().used_today(T0), 2); +} + +#[test] +fn an_unanswered_sheet_expires_declined() { + let (mut r, relay) = router(); + r.request(send("1", json!({"to": "eve@example.org"})), T0); + assert!(!r.tick(T0 + 10)); + assert!(r.tick(T0 + r.sheet_expiry_s)); + assert_eq!(relay.last().unwrap().1, Decision::Deny); + assert!(r.sheets().is_empty()); + assert_eq!(r.audit.all().last().unwrap().by, "timeout"); +} + +// ---------------------------------------------------------------- confirm: app + +#[derive(Clone, Default)] +struct AppSheet(Arc>>); +impl AppConfirm for AppSheet { + fn confirm(&mut self, request: &AppConfirmRequest) { + self.0.lock().unwrap().push(request.clone()); + } +} + +fn rinx_send(id: &str) -> Request { + let mut q = make_request("rinx", ToolSpec::app("rinx.message.send"), json!({"room": "!r:x", "body": "hi"}), Caller::AppAgent { app: "calendar".into() }, ctx(id, Trigger::SystemAgent), T0, 0); + q.context.context_id = Some("ctx-1".into()); + q +} + +#[test] +fn confirm_app_goes_to_the_owning_apps_sheet_with_the_caller() { + let (mut r, relay) = router(); + // Even a rule for it is not asked. + rule(&mut r, RuleDraft::everything("rinx", 30)); + let sheet = AppSheet::default(); + r.register_app_confirm("rinx", Box::new(sheet.clone())); + assert_eq!(r.request(rinx_send("m1"), T0), Route::HandedToApp); + let seen = sheet.0.lock().unwrap().clone(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].caller_label, "Calendar's agent"); + assert_eq!(seen[0].context_id.as_deref(), Some("ctx-1")); + assert!(relay.take().is_empty()); + r.app_confirm_answered(&RequestId("m1".into()), true, "sent from Rinx's sheet", T0).unwrap(); + assert_eq!(relay.last().unwrap().1, Decision::ApproveOnce); + assert_eq!(r.audit.all().last().unwrap().by, "app_sheet"); + assert!(r.app_confirm_answered(&RequestId("m1".into()), true, "", T0).is_err(), "once"); +} + +#[test] +fn confirm_app_waits_for_the_app_then_is_refused_visibly() { + let (mut r, relay) = router(); + let Route::WaitingForApp { until } = r.request(rinx_send("m1"), T0) else { panic!() }; + assert_eq!(until, T0 + r.app_wait_s); + // The app comes: it gets the request. + let sheet = AppSheet::default(); + r.register_app_confirm("rinx", Box::new(sheet.clone())); + assert_eq!(sheet.0.lock().unwrap().len(), 1); + r.unregister_app_confirm("rinx", T0 + 1); + assert_eq!(relay.last().unwrap().1, Decision::Deny, "closed before confirming"); + // It never comes: refused at the deadline, with a notice. + let Route::WaitingForApp { until } = r.request(rinx_send("m2"), T0) else { panic!() }; + r.take_notices(); + r.tick(until - 1); + assert!(r.is_pending(&RequestId("m2".into()))); + r.tick(until); + assert!(!r.is_pending(&RequestId("m2".into()))); + assert_eq!(relay.last().unwrap(), (RequestId("m2".into()), Decision::Deny, "Rinx wasn't opened in time to confirm it".into())); + assert!(r.take_notices().iter().any(|n| n.title.starts_with("Refused: Rinx"))); + // No waiting at all. + r.app_wait_s = 0; + assert!(matches!(r.request(rinx_send("m3"), T0), Route::Refused(_))); +} + +// ---------------------------------------------------------------- audit, files + +#[test] +fn every_automatic_approval_is_audited_and_notified() { + let (mut r, _) = router(); + let id = rule(&mut r, contacts_rule()); + let args = json!({"to": "ana@example.org", "body": "private text"}); + r.request(send("1", args.clone()), T0); + let e = r.audit.recent_automatic(1).pop().unwrap(); + assert_eq!((e.app.as_str(), e.tool.as_str(), e.caller.as_str(), e.by.as_str(), e.result.as_str()), (MAIL, "mail.send", "app/calendar", "rule", "approved")); + assert_eq!(e.rule.as_deref(), Some(id.0.as_str())); + assert_eq!(e.args_digest, super::facts::digest(&args)); + assert_eq!(e.ts, T0); + assert!(!serde_json::to_string(&e).unwrap().contains("private text"), "the log holds a digest, not the arguments"); + let n = r.take_notices(); + assert!(n.iter().any(|n| n.title == "Approved automatically: Mail \u{00b7} mail.send" && n.body.contains("people in my contacts") && n.body.contains("Calendar's agent"))); +} + +fn temp_home(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("octosense-approvals-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn rules_consent_and_audit_persist_per_home_owner_only() { + let home = temp_home("persist"); + { + let mut a = super::Approvals::in_home(&home); + a.router.set_contacts(Box::new(ContactList(vec!["ana@example.org".into()]))); + let id = a.router.create_rule(&ApprovalGesture::settings_tap(), contacts_rule(), T0).unwrap(); + a.router.request(send("1", json!({"to": "ana@example.org"})), T0); + assert_eq!(a.router.rules.get(&id).unwrap().used_today(T0), 1); + a.consent.set(&ApprovalGesture::sheet_tap(), "os.news", true, T0); + } + let a = super::Approvals::in_home(&home); + assert_eq!(a.router.rules.rules().len(), 1); + assert_eq!(a.router.rules.rules()[0].used_today(T0), 1, "the cap's count survives a restart"); + assert_eq!(a.consent.state("os.news"), State::Allowed); + assert_eq!(a.router.audit.recent_automatic(5).len(), 1, "the log is read back"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for f in [super::rules::RULES_FILE, super::consent::CONSENT_FILE, super::audit::AUDIT_FILE] { + let mode = std::fs::metadata(home.join(f)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{f}"); + } + } + // Append-only: a second session adds, never rewrites. + let before = std::fs::read_to_string(home.join(super::audit::AUDIT_FILE)).unwrap(); + let mut a = a; + a.router.set_contacts(Box::new(ContactList(vec!["ana@example.org".into()]))); + a.router.request(send("2", json!({"to": "ana@example.org"})), T0); + let after = std::fs::read_to_string(home.join(super::audit::AUDIT_FILE)).unwrap(); + assert!(after.starts_with(&before) && after.lines().count() == 2); + let _ = std::fs::remove_dir_all(&home); +} + +// ---------------------------------------------------------------- consent + +fn news() -> AgentSummary { + AgentSummary::from_manifest( + "os.news", + "News", + &json!({"capabilities": ["octos.session", "research", "crawl"], "storage": {"accounts": false, "agent_workspace": "account"}}), + &["octos.session".into(), "research".into()], + "The model set in AI providers", + ) +} + +#[test] +fn consent_at_first_use_is_asked_once_and_remembered() { + let mut c = ConsentStore::memory(); + let s = news(); + assert!(s.reads.iter().any(|r| r.contains("on this device"))); + assert!(s.uses.iter().any(|u| u.contains("toolbox")), "{:?}", s.uses); + assert!(!s.uses.iter().any(|u| u.contains("Crawling")), "only what was granted"); + assert!(!c.granted("os.news", false)); + assert_eq!(c.ask(s.clone(), false), State::Undecided); + assert_eq!(c.ask(s.clone(), false), State::Undecided); + assert_eq!(c.prompt().map(|p| p.app.as_str()), Some("os.news")); + c.set(&ApprovalGesture::sheet_tap(), "os.news", true, T0); + assert!(c.prompt().is_none()); + assert!(c.granted("os.news", false)); + assert_eq!(c.ask(s, false), State::Allowed, "no second prompt"); + // Settings' off switch. + c.turn_off("os.news", T0); + assert!(!c.granted("os.news", false)); + assert_eq!(c.agents(), vec![("os.news".to_string(), "News".to_string(), State::Denied)]); +} + +#[test] +fn developer_mode_skips_the_consent_prompt() { + let mut c = ConsentStore::memory(); + assert_eq!(c.ask(news(), true), State::Allowed); + assert!(c.prompt().is_none()); + assert!(c.granted("os.news", true)); + assert!(!c.granted("os.news", false), "nothing is remembered from developer mode"); +} + +// ---------------------------------------------------------------- who may create + +/// Only the shell's Settings page and its sheet make a ApprovalGesture: no +/// agent-, app- or relay-facing code can create a rule or give consent. +#[test] +fn only_settings_and_the_sheet_make_a_person_gesture() { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + } + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + walk(&src, &mut files); + let allowed = ["approvals/view.rs", "approvals/settings_page.rs", "approvals/rules.rs", "approvals/tests.rs"]; + for f in files { + let text = std::fs::read_to_string(&f).unwrap(); + let rel = f.strip_prefix(&src).unwrap().to_string_lossy().replace('\\', "/"); + if text.contains("ApprovalGesture::sheet_tap") || text.contains("ApprovalGesture::settings_tap") || text.contains("ApprovalGesture {") { + assert!(allowed.contains(&rel.as_str()), "{rel} makes a ApprovalGesture"); + } + } +} + +// ---------------------------------------------------------------- the relay seam + +#[test] +fn the_relay_gets_decisions_made_before_it_was_installed() { + super::init_memory(); + let route = super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); + assert!(matches!(route, Route::Sheet(_))); + let (sheet, id) = super::with(|a| { + let s = a.router.front_sheet().unwrap(); + (s.id, s.lines[0].request.clone()) + }) + .unwrap(); + super::with(|a| a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + let relay = RecordingRelay::default(); + super::set_relay(Box::new(relay.clone())); + assert_eq!(relay.take(), vec![(RequestId("g1".into()), Decision::Deny, "denied on the sheet".into())]); + // From now on, straight to it. + super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); + let (sheet, id) = super::with(|a| { + let s = a.router.front_sheet().unwrap(); + (s.id, s.lines[0].request.clone()) + }) + .unwrap(); + super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + assert_eq!(relay.take().len(), 1); + // The AI bus's held `confirm: host` calls come back to the shell, not + // to the relay: the Terminal's `run` asks the person, never a rule. + super::with(|a| a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0)).unwrap().unwrap(); + let held = crate::ai_bus::HeldCall { + key: "bus:w4:c1".into(), + app: "terminal".into(), + tool: "run".into(), + args: r#"{"command":"ls"}"#.into(), + auto_approvable: false, + command: true, + }; + assert!(matches!(super::bus_requested(&held), Route::Sheet(_))); + let (sheet, id, caller, args) = super::with(|a| { + let s = a.router.front_sheet().unwrap(); + (s.id, s.lines[0].request.clone(), s.lines[0].caller.clone(), s.lines[0].args.join(" ")) + }) + .unwrap(); + assert_eq!(id, RequestId("bus:w4:c1".into())); + assert_eq!(caller, "The system agent"); + assert!(args.contains("\"command\": \"ls\""), "{args}"); + super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + assert!(relay.take().is_empty()); + assert_eq!(super::take_bus_decisions(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); + assert!(!super::consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); +} + +/// The module host's gate: the first ask shows the first-use sheet and +/// offers nothing; once allowed, the next instance gets its agent. +#[test] +fn the_module_gate_asks_once_then_follows_consent() { + let mut a = super::Approvals::memory(); + assert!(!super::module_gate(&mut a, "rinx", "Rinx", &["octos.session.open", "octos.turn.start"])); + assert_eq!(a.consent.prompt().map(|p| p.name.as_str()), Some("Rinx")); + assert!(!super::module_gate(&mut a, "rinx", "Rinx", &["octos.session.open"]), "no second prompt, still no agent"); + a.consent.set(&ApprovalGesture::sheet_tap(), "rinx", true, T0); + assert!(super::module_gate(&mut a, "rinx", "Rinx", &["octos.session.open"])); + a.consent.turn_off("rinx", T0); + assert!(!super::module_gate(&mut a, "rinx", "Rinx", &["octos.session.open"])); + assert!(a.consent.prompt().is_none(), "a person's no is not asked again"); +} diff --git a/crates/shell/src/approvals/types.rs b/crates/shell/src/approvals/types.rs new file mode 100644 index 00000000..73efdfc1 --- /dev/null +++ b/crates/shell/src/approvals/types.rs @@ -0,0 +1,204 @@ +//! What an approval request carries and how it is answered (ADR 0004 §5, §8). + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The relay's id for one approval request: octos's call or approval id, so +/// [`super::relay::ApprovalRelay::approval_decided`] needs no mapping. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct RequestId(pub String); + +impl std::fmt::Display for RequestId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A standing rule's id (`r1`, `r2`, …), stable in the rules file. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct RuleId(pub String); + +impl std::fmt::Display for RuleId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The answer the shell gives the relay for one request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Decision { + /// The person approved this one call (on a sheet, or the owning app's + /// own sheet for `confirm: app`), or developer mode answered it. + ApproveOnce, + Deny, + /// A standing rule answered it. + ApproveByRule(RuleId), +} + +impl Decision { + pub fn approved(&self) -> bool { + !matches!(self, Decision::Deny) + } +} + +/// Who draws the confirmation for a tool (`tools.json` `confirm`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Confirm { + /// The shell's sheet; a standing rule may answer it. + #[default] + Host, + /// The owning app's own sheet (Rinx's send sheet), for every caller. + App, +} + +/// The declared facts of the tool being called, from the owning app's +/// `tools.json` (the relay has it; the router never trusts the caller for +/// these). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolSpec { + /// `mail.send`. + pub name: String, + pub confirm: Confirm, + /// `false` for permanent deletion, payments, sharing outside the + /// device, account and security changes, and typed commands: no rule + /// ever answers it (outside developer mode). + pub auto_approvable: bool, + /// Command execution (`terminal.run`, `dev.run`, granted commands). + pub command: bool, + /// Argument names the schema types as secrets (`"format": "password"`, + /// `"secret": true`), redacted on every sheet. + pub secret_fields: Vec, +} + +impl ToolSpec { + pub fn host(name: &str) -> ToolSpec { + ToolSpec { name: name.into(), confirm: Confirm::Host, auto_approvable: true, command: false, secret_fields: Vec::new() } + } + pub fn app(name: &str) -> ToolSpec { + ToolSpec { confirm: Confirm::App, ..ToolSpec::host(name) } + } + pub fn not_auto_approvable(mut self) -> ToolSpec { + self.auto_approvable = false; + self + } + pub fn command(mut self) -> ToolSpec { + self.command = true; + self.auto_approvable = false; + self + } + pub fn secret(mut self, field: &str) -> ToolSpec { + self.secret_fields.push(field.into()); + self + } +} + +/// Who is calling, stamped by the shell (ADR 0004 §5), never by the app. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Caller { + /// The owning app's own agent (on its own session, or in one of its + /// request contexts for `client`, e.g. a Rinx mini app). + OwnAgent { client: Option }, + /// Another app's agent (a cross-app call, §7). + AppAgent { app: String }, + /// The system agent. + SystemAgent, +} + +impl Caller { + /// The audit's short form. + pub fn as_audit(&self) -> String { + match self { + Caller::OwnAgent { client: None } => "own_agent".into(), + Caller::OwnAgent { client: Some(c) } => format!("own_agent/{c}"), + Caller::AppAgent { app } => format!("app/{app}"), + Caller::SystemAgent => "system_agent".into(), + } + } +} + +/// What started the run the call belongs to. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Trigger { + /// The person asked (a button, the chat, voice). + Person, + /// The app's own trigger (a schedule, a data or file change). + App, + /// Content someone else sent (an email, a message): excluded from + /// standing rules by default (§8). + IncomingContent { from: Option }, + /// The system agent's own plan. + SystemAgent, + /// Not said: treated as the least trusted, like incoming content for + /// rules, never as the person. + #[default] + Unknown, +} + +impl Trigger { + /// Whether standing rules skip this run unless a rule opts in. + pub fn excluded_by_default(&self) -> bool { + matches!(self, Trigger::IncomingContent { .. } | Trigger::Unknown) + } + pub fn as_str(&self) -> &'static str { + match self { + Trigger::Person => "person", + Trigger::App => "app", + Trigger::IncomingContent { .. } => "incoming_content", + Trigger::SystemAgent => "system_agent", + Trigger::Unknown => "unknown", + } + } +} + +/// Which kernel connection the call came in on (ADR 0003: an external +/// client's approvals are never answered by developer mode). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Connection { + #[default] + Host, + External, +} + +/// One request of the system agent's that several approvals belong to: +/// its `confirm: host` approvals are batched into one sheet in the system +/// chat. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Batch { + pub id: String, + /// The plan, as the system agent put it ("Book Tue 3–4 pm and invite 4"). + pub plan: String, +} + +/// Everything else the relay knows about the call. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RequestContext { + /// The relay's id for this request (octos's call or approval id). + pub call_id: String, + pub trigger: Trigger, + pub connection: Connection, + /// The kernel's request context, if any (§5). + pub context_id: Option, + pub account: Option, + /// The thread the run is about (its participants' addresses), for the + /// "recipients in the thread" condition. + pub thread: Vec, + /// octos marked the call `outcome_unknown` (a retry of a call that may + /// already have happened): always the person. + pub outcome_unknown: bool, + pub batch: Option, +} + +/// One request, as the router holds it. +#[derive(Clone, Debug, PartialEq)] +pub struct Request { + pub id: RequestId, + /// The app that owns the tool (`mail`). + pub app: String, + pub tool: ToolSpec, + /// The exact arguments. + pub args: Value, + pub caller: Caller, + pub context: RequestContext, + /// Unix seconds. + pub received: u64, +} diff --git a/crates/shell/src/approvals/view.rs b/crates/shell/src/approvals/view.rs new file mode 100644 index 00000000..de728ac9 --- /dev/null +++ b/crates/shell/src/approvals/view.rs @@ -0,0 +1,420 @@ +//! The shell-drawn approval surface (ADR 0004 §8, §4): drawn by the shell, +//! over everything, in the shell's own chrome kit (`shell/ui.rs`, the +//! pattern of the menu, the notifications and the developer banner). +//! +//! - **The approval sheet**: the router's front [`Sheet`]. Each open line +//! shows the owning app and tool, who is calling, the exact arguments +//! (secrets redacted) and why it needs the person, with Approve once, +//! Deny and up to two "Always …" choices. Modal while up. +//! - **The first-use sheet** ([`super::consent`]): what the agent may read +//! and use and where the model runs; Allow or Don't allow. Modal. +//! - **The time-box indicator**: while a rule approves everything one app +//! asks, a pill at the top says so, with the minutes left and Stop. +//! +//! A press on a button is the person's gesture: only here (and on the +//! Settings page) is a [`ApprovalGesture`] made. + +use makepad_widgets::*; + +use super::consent::AgentSummary; +use super::rules::{ApprovalGesture, Rule}; +use super::sheet::{app_label, Answer, Line, Sheet}; +use super::types::{RequestId, RuleId}; +use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, ShellDraw}; +use crate::shell::{alpha, rgb, CtrlState, ShellTokens}; + +script_mod! { + use mod.prelude.widgets_internal.* + use mod.widgets.* + + mod.widgets.ShellApprovalsBase = #(ShellApprovals::register_widget(vm)) + mod.widgets.ShellApprovals = set_type_default() do mod.widgets.ShellApprovalsBase { + width: Fill + height: Fill + draw_bg +: {} + d +: {} + } +} + +const CARD_MAX_W: f64 = 600.0; +const PAD: f64 = 18.0; +const BUTTON_H: f64 = 28.0; +const ARG_LINE_H: f64 = 17.0; +const MAX_ARG_LINES: usize = 12; +const INDICATOR_H: f64 = 30.0; + +/// What a press lands on. +#[derive(Clone, Debug, PartialEq)] +pub enum Hit { + Answer { sheet: u64, request: RequestId, answer: Answer }, + Consent { app: String, allow: bool }, + StopRule(RuleId), + /// The card itself (swallowed). + Card, +} + +/// A small button kit shared with the Settings page. +pub(crate) struct Buttons<'a> { + pub d: &'a mut ShellDraw, + pub tok: ShellTokens, + pub hover: Option, +} + +impl Buttons<'_> { + pub fn width(&mut self, cx: &mut Cx2d, label: &str) -> f64 { + self.d.measure(cx, false, self.tok.font.body, label) + 26.0 + } + /// A button; `primary` is the accent face. Returns its rect. + pub fn draw(&mut self, cx: &mut Cx2d, x: f64, y: f64, max_w: f64, label: &str, primary: bool) -> Rect { + let w = self.width(cx, label).min(max_w.max(40.0)); + let r = rect(x, y, w, BUTTON_H); + let hovered = self.hover.is_some_and(|h| h == r); + let ink = self.tok.popups.text; + if primary { + let accent = self.tok.notifications.countdown; + let fill = if hovered { alpha(accent, 0.85) } else { accent }; + self.d.bordered(cx, r, fill, fill, fill, 0.0, 0.0); + self.d.label_elided(cx, rect(r.pos.x + 10.0, r.pos.y, r.size.x - 20.0, r.size.y), true, self.tok.font.body, contrast(accent), HAlign::Center, label); + } else { + let state = if hovered { CtrlState::Hover } else { CtrlState::Normal }; + self.d.control(cx, r, &self.tok.controls, state); + self.d.label_elided(cx, rect(r.pos.x + 10.0, r.pos.y, r.size.x - 20.0, r.size.y), false, self.tok.font.body, ink, HAlign::Center, label); + } + r + } +} + +/// Black or white, whichever reads on `c`. +pub(crate) fn contrast(c: Vec4f) -> Vec4f { + let l = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + if l > 0.6 { + rgb(0x10, 0x10, 0x10) + } else { + rgb(0xFF, 0xFF, 0xFF) + } +} + +/// What the view draws, copied out of the shared state for one frame. +#[derive(Clone, Debug, Default)] +struct Frame { + sheet: Option, + more_sheets: usize, + consent: Option, + everything: Vec, + now: u64, +} + +fn frame() -> Frame { + super::with(|a| { + let now = super::now(); + Frame { + sheet: a.router.front_sheet().cloned(), + more_sheets: a.router.sheets().len().saturating_sub(1), + consent: a.consent.prompt().cloned(), + everything: a.router.rules.active_everything(now).into_iter().cloned().collect(), + now, + } + }) + .unwrap_or_default() +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ShellApprovals { + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[walk] + walk: Walk, + #[layout] + layout: Layout, + #[redraw] + #[live] + draw_bg: DrawShellFill, + #[live] + d: ShellDraw, + #[live] + tokens: ShellTokens, + #[rust] + area: Area, + #[rust] + hits: Vec<(Rect, Hit)>, + /// A modal card is up: every press is ours. + #[rust] + modal: bool, + #[rust] + down: Option, + #[rust] + hover: Option, + /// What was last drawn, for the control surface and tests. + #[rust] + pub shown: Vec, +} + +impl ShellApprovals { + fn hit_at(&self, p: Vec2d) -> Option { + // Buttons before the card they sit on. + self.hits.iter().rev().find(|(r, h)| *h != Hit::Card && contains(*r, p)).or_else(|| self.hits.iter().find(|(r, _)| contains(*r, p))).map(|(_, h)| h.clone()) + } + + /// The shell's pointer hook (`approvals::pointer`). True when taken. + pub fn pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { + let (down, up, moved) = match event { + Event::MouseDown(e) => (Some(e.abs), None, None), + Event::MouseUp(e) => (None, Some(e.abs), None), + Event::MouseMove(e) => (None, None, Some(e.abs)), + Event::TouchUpdate(e) => ( + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Start).map(|t| t.abs), + e.touches.iter().find(|t| t.state == makepad_widgets::makepad_platform::event::TouchState::Stop).map(|t| t.abs), + None, + ), + _ => (None, None, None), + }; + if let Some(p) = moved { + let hover = self.hits.iter().find(|(r, h)| *h != Hit::Card && contains(*r, p)).map(|(r, _)| *r); + if hover != self.hover { + self.hover = hover; + self.redraw(cx); + } + return self.modal; + } + if let Some(p) = down { + self.down = self.hit_at(p); + return self.modal || self.down.is_some(); + } + if let Some(p) = up { + let hit = self.hit_at(p); + let pressed = self.down.take(); + if let (Some(h), Some(d)) = (&hit, &pressed) { + if h == d { + act(h.clone()); + self.redraw(cx); + } + } + return self.modal || hit.is_some(); + } + self.modal + } + + fn draw_all(&mut self, cx: &mut Cx2d, screen: Rect) { + self.hits.clear(); + self.shown.clear(); + self.modal = false; + let f = frame(); + let tok = self.d.tokens(self.tokens); + self.draw_indicator(cx, screen, &f, tok); + if let Some(summary) = &f.consent { + self.modal = true; + self.draw_consent(cx, screen, summary, tok); + } else if let Some(sheet) = &f.sheet { + self.modal = true; + self.draw_sheet(cx, screen, sheet, f.more_sheets, tok); + } + } + + fn draw_indicator(&mut self, cx: &mut Cx2d, screen: Rect, f: &Frame, tok: ShellTokens) { + let mut y = screen.pos.y + 36.0; + for rule in &f.everything { + let left = rule.minutes_left(f.now).unwrap_or(0); + let text = format!("Approving everything {} asks \u{00b7} {left} min left", app_label(&rule.app)); + let px = tok.font.body; + let w = (self.d.measure(cx, true, px, &text) + 110.0).min(screen.size.x - 24.0); + let pill = rect(screen.pos.x + (screen.size.x - w) * 0.5, y, w, INDICATOR_H); + let ground = rgb(0xE0, 0x8A, 0x00); + let ink = rgb(0x1A, 0x12, 0x00); + self.d.bordered(cx, pill, ground, ground, ground, 0.0, 0.0); + let stop = rect(pill.pos.x + pill.size.x - 64.0, pill.pos.y + 4.0, 58.0, INDICATOR_H - 8.0); + self.d.solid(cx, stop, ink); + self.d.label(cx, stop, true, px, ground, HAlign::Center, "Stop"); + self.d.label_elided(cx, rect(pill.pos.x + 12.0, pill.pos.y, stop.pos.x - pill.pos.x - 20.0, INDICATOR_H), true, px, ink, HAlign::Left, &text); + self.hits.push((stop, Hit::StopRule(rule.id.clone()))); + self.hits.push((pill, Hit::Card)); + self.shown.push(text); + y += INDICATOR_H + 6.0; + } + } + + fn card_rect(screen: Rect, h: f64) -> Rect { + let w = CARD_MAX_W.min(screen.size.x - 32.0).max(200.0); + let h = h.min(screen.size.y - 48.0); + rect(screen.pos.x + (screen.size.x - w) * 0.5, screen.pos.y + ((screen.size.y - h) * 0.5).max(24.0), w, h) + } + + fn scrim(&mut self, cx: &mut Cx2d, screen: Rect) { + self.d.solid(cx, screen, Vec4f { x: 0.0, y: 0.0, z: 0.0, w: 0.45 }); + } + + fn line_height(line: &Line) -> f64 { + let args = line.args.len().min(MAX_ARG_LINES) as f64 * ARG_LINE_H + if line.args.len() > MAX_ARG_LINES { ARG_LINE_H } else { 0.0 }; + let note = if line.surfaced.note().is_empty() { 0.0 } else { 18.0 }; + let always = if line.always.is_empty() { 0.0 } else { BUTTON_H + 8.0 }; + 22.0 + 18.0 + 8.0 + args + 12.0 + note + BUTTON_H + 8.0 + always + 14.0 + } + + fn draw_sheet(&mut self, cx: &mut Cx2d, screen: Rect, sheet: &Sheet, more: usize, tok: ShellTokens) { + self.scrim(cx, screen); + let lines: Vec<&Line> = sheet.open_lines().collect(); + let header = 26.0 + 20.0 + 14.0; + let body: f64 = lines.iter().map(|l| Self::line_height(l)).sum(); + let footer = if more > 0 { 20.0 } else { 0.0 }; + let card = Self::card_rect(screen, PAD * 2.0 + header + body + footer); + self.d.card(cx, card, &tok.popups); + self.hits.push((card, Hit::Card)); + let ink = tok.popups.text; + let dim = alpha(ink, 0.65); + let x = card.pos.x + PAD; + let w = card.size.x - PAD * 2.0; + let bottom = card.pos.y + card.size.y - PAD; + let mut y = card.pos.y + PAD; + let title = sheet.title(); + self.d.label_elided(cx, rect(x, y, w, 24.0), true, tok.font.heading, ink, HAlign::Left, &title); + y += 26.0; + let sub = sheet.subtitle(); + self.d.label_elided(cx, rect(x, y, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, &sub); + y += 20.0 + 14.0; + self.shown.push(title); + self.shown.push(sub); + let mut buttons = Buttons { d: &mut self.d, tok, hover: self.hover }; + let mut hits = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let h = Self::line_height(line); + if y + h > bottom + 1.0 { + let rest = lines.len() - i; + buttons.d.label_elided(cx, rect(x, y, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, &format!("{rest} more below: answer these first")); + break; + } + if i > 0 { + buttons.d.solid(cx, rect(x, y - 8.0, w, 1.0), alpha(ink, 0.12)); + } + let heading = line.heading(); + buttons.d.label_elided(cx, rect(x, y, w, 20.0), true, tok.font.subtitle, ink, HAlign::Left, &heading); + y += 22.0; + let asked = format!("Asked by {}", line.caller); + buttons.d.label_elided(cx, rect(x, y, w, 16.0), false, tok.font.body_small, dim, HAlign::Left, &asked); + y += 18.0 + 8.0; + let shown_args = line.args.len().min(MAX_ARG_LINES); + let extra = if line.args.len() > MAX_ARG_LINES { 1 } else { 0 }; + let box_h = (shown_args + extra) as f64 * ARG_LINE_H + 8.0; + buttons.d.solid(cx, rect(x, y - 4.0, w, box_h), alpha(ink, 0.06)); + for a in line.args.iter().take(MAX_ARG_LINES) { + buttons.d.label_elided(cx, rect(x + 8.0, y, w - 16.0, ARG_LINE_H), false, tok.font.body_small, ink, HAlign::Left, a); + y += ARG_LINE_H; + } + if extra == 1 { + buttons.d.label_elided(cx, rect(x + 8.0, y, w - 16.0, ARG_LINE_H), false, tok.font.body_small, dim, HAlign::Left, &format!("\u{2026} {} more lines", line.args.len() - MAX_ARG_LINES)); + y += ARG_LINE_H; + } + y += 12.0; + let note = line.surfaced.note(); + if !note.is_empty() { + buttons.d.label_elided(cx, rect(x, y, w, 16.0), false, tok.font.body_small, rgb(0xE0, 0x8A, 0x00), HAlign::Left, note); + y += 18.0; + } + let mut bx = x; + let once = buttons.draw(cx, bx, y, w, "Approve once", true); + hits.push((once, Hit::Answer { sheet: sheet.id, request: line.request.clone(), answer: Answer::Once })); + bx += once.size.x + 8.0; + let deny = buttons.draw(cx, bx, y, x + w - bx, "Deny", false); + hits.push((deny, Hit::Answer { sheet: sheet.id, request: line.request.clone(), answer: Answer::Deny })); + y += BUTTON_H + 8.0; + if !line.always.is_empty() { + let mut bx = x; + for (k, choice) in line.always.iter().enumerate().take(2) { + let room = x + w - bx; + if room < 80.0 { + break; + } + let r = buttons.draw(cx, bx, y, if k == 0 { room * 0.6 } else { room }, &choice.label, false); + hits.push((r, Hit::Answer { sheet: sheet.id, request: line.request.clone(), answer: Answer::Always(k) })); + bx += r.size.x + 8.0; + } + y += BUTTON_H + 8.0; + } + y += 14.0; + self.shown.push(heading); + self.shown.push(asked); + self.shown.extend(line.args.iter().cloned()); + } + if more > 0 { + buttons.d.label_elided(cx, rect(x, bottom - 18.0, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, &format!("{more} more sheet{} after this one", if more == 1 { "" } else { "s" })); + } + self.hits.extend(hits); + } + + fn draw_consent(&mut self, cx: &mut Cx2d, screen: Rect, s: &AgentSummary, tok: ShellTokens) { + self.scrim(cx, screen); + let rows = 2 + s.reads.len() + 1 + s.uses.len() + 1 + 1; + let card = Self::card_rect(screen, PAD * 2.0 + 26.0 + 20.0 + 12.0 + rows as f64 * 20.0 + 12.0 + BUTTON_H); + self.d.card(cx, card, &tok.popups); + self.hits.push((card, Hit::Card)); + let ink = tok.popups.text; + let dim = alpha(ink, 0.65); + let x = card.pos.x + PAD; + let w = card.size.x - PAD * 2.0; + let mut y = card.pos.y + PAD; + let title = format!("Let {}'s agent start?", s.name); + self.d.label_elided(cx, rect(x, y, w, 24.0), true, tok.font.heading, ink, HAlign::Left, &title); + y += 26.0; + self.d.label_elided(cx, rect(x, y, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, "The first time an app asks for its agent, you decide. You can change it in Settings."); + y += 20.0 + 12.0; + let row = |d: &mut ShellDraw, cx: &mut Cx2d, y: &mut f64, bold: bool, text: &str| { + d.label_elided(cx, rect(x, *y, w, 18.0), bold, tok.font.body, if bold { ink } else { alpha(ink, 0.85) }, HAlign::Left, text); + *y += 20.0; + }; + row(&mut self.d, cx, &mut y, true, "It may read"); + for r in &s.reads { + row(&mut self.d, cx, &mut y, false, &format!("\u{2022} {r}")); + } + row(&mut self.d, cx, &mut y, true, "It may use"); + for u in &s.uses { + row(&mut self.d, cx, &mut y, false, &format!("\u{2022} {u}")); + } + row(&mut self.d, cx, &mut y, true, "Where the model runs"); + row(&mut self.d, cx, &mut y, false, &s.model); + y += 12.0; + let mut buttons = Buttons { d: &mut self.d, tok, hover: self.hover }; + let allow = buttons.draw(cx, x, y, w, "Allow", true); + let deny = buttons.draw(cx, x + allow.size.x + 8.0, y, w, "Don't allow", false); + self.hits.push((allow, Hit::Consent { app: s.app.clone(), allow: true })); + self.hits.push((deny, Hit::Consent { app: s.app.clone(), allow: false })); + self.shown.push(title); + self.shown.extend(s.reads.iter().cloned()); + self.shown.extend(s.uses.iter().cloned()); + self.shown.push(s.model.clone()); + } +} + +/// What a press does. Each answer here is the person's. +fn act(hit: Hit) { + let now = super::now(); + match hit { + Hit::Answer { sheet, request, answer } => { + let r = super::with(|a| a.router.answer(sheet, &request, answer, &ApprovalGesture::sheet_tap(), now)); + if let Some(Err(e)) = r { + log!("approvals: {e}"); + } + } + Hit::Consent { app, allow } => { + super::with(|a| a.consent.set(&ApprovalGesture::sheet_tap(), &app, allow, now)); + } + Hit::StopRule(id) => { + super::with(|a| a.router.delete_rule(&id)); + } + Hit::Card => {} + } +} + +impl Widget for ShellApprovals { + fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + cx.begin_turtle(walk, self.layout); + let screen = cx.turtle().rect(); + self.d.begin_surface(cx); + self.draw_all(cx, screen); + self.d.end_surface(cx); + cx.end_turtle_with_area(&mut self.area); + DrawStep::done() + } + + // The shell routes presses (`approvals::pointer`); nothing here. + fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 6408f074..73b6a5ac 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -12,6 +12,7 @@ pub mod ai_bus; pub mod app_storage; pub mod dev_mode; +pub mod approvals; pub mod apps; pub mod binds; pub mod clients; @@ -204,6 +205,11 @@ script_mod! { shell_menu := ShellMenu{} shell_notes := ShellNotifications{} shell_osd := ShellOsd{} + // The approval surface (approvals/): the shell's + // approval and first-use sheets, the time-box + // indicator, and Settings > Assistant > Approvals. + shell_approvals := ShellApprovals{} + shell_approvals_settings := ShellApprovalsSettings{} // Developer mode's banner (dev_mode.rs): over // everything, drawn only while the mode is on. shell_dev_banner := ShellDevBanner{} @@ -357,6 +363,9 @@ pub struct App { /// The developer-mode generation last acted on (dev_mode.rs). #[rust] pub dev_generation: u64, + /// The approvals' generation last drawn (approvals/). + #[rust] + pub approvals_generation: u64, #[rust] pub hub: Option, #[rust] @@ -2085,6 +2094,12 @@ impl App { self.send_to_pane(AiBus::os_reply(result)); } Route::Local(client, msg) => self.on_local_frame(cx, client, msg), + // A `confirm: host` tool (the Terminal's `run`): the approval + // router answers, now (developer mode, a rule) or from its sheet. + Route::Approval(held) => { + approvals::bus_requested(&held); + self.approvals_changed(cx); + } Route::Drop => {} } } @@ -2978,6 +2993,12 @@ impl App { /// What a menu row does. The ids are the jsonc's dotted paths, with /// `apps.` and `style.theme[.import].` from the providers. fn shell_menu_activate(&mut self, cx: &mut Cx, target: &str) { + if target == shell::menu::APPROVALS_ROW { + self.close_shell_menu(cx); + approvals::open_settings(); + self.approvals_changed(cx); + return; + } if target.starts_with("setup.developer.") { self.developer_options_activate(cx, target); return; @@ -3095,6 +3116,31 @@ impl App { self.update_bar(cx); } + /// Once a second: approval rules that ran out, `confirm: app` calls + /// that waited too long, sheets nobody answered; then what the person + /// must be told (every automatic approval among it). + fn approvals_tick(&mut self, cx: &mut Cx) { + approvals::tick(); + self.approvals_changed(cx); + } + + fn approvals_changed(&mut self, cx: &mut Cx) { + // The AI bus's held calls the router has answered go on (or are + // refused to the pane). + for (id, decision, reason) in approvals::take_bus_decisions() { + let route = self.ai_bus.release(&id.0, decision.approved(), &reason); + self.on_bus_route(cx, route); + } + for n in approvals::take_notices() { + self.notify(cx, &n.title, &n.body); + } + let generation = approvals::generation(); + if generation != self.approvals_generation { + self.approvals_generation = generation; + self.redraw_all(cx); + } + } + /// The in-process notification API — `WmRequest::Notify{title, body}` /// lands here. pub fn notify(&mut self, cx: &mut Cx, title: &str, body: &str) { @@ -4198,6 +4244,14 @@ impl App { i += 2; continue; } + // approval-sheet, approval-batch, approval-consent, + // approvals-settings (approvals/mod.rs). + if approvals::test_action(name) { + log!("wm: --test-action {}", name); + self.redraw_all(cx); + i += 2; + continue; + } match test_action(name) { Some(action) => { log!("wm: --test-action {} -> {:?}", name, action); @@ -4535,6 +4589,9 @@ impl MatchEvent for App { // starts, so grants and approvals see it from the first call. dev_mode::init(&octosense::paths::home()); self.dev_generation = dev_mode::generation(); + // Approvals (ADR 0004 §8, §4): this home's standing rules, consent + // and audit, before any app can ask for an approval. + approvals::init(&octosense::paths::home()); // CLI: --import-theme pulls an omarchy theme and converts // it to splash before the desktop appears. let mut args = std::env::args(); @@ -4996,6 +5053,7 @@ impl App { shell::script_mod(vm); glance_card::script_mod(vm); glance_panel::script_mod(vm); + approvals::script_mod(vm); desktop::script_mod(vm); snap::script_mod(vm); mobile_surface::script_mod(vm); @@ -5088,6 +5146,12 @@ impl App { } } } + // An approval sheet, the first-use sheet and the Approvals page + // are modal, over everything (approvals/mod.rs `pointer`). + if self.state.is_some() && approvals::pointer(&self.ui, cx, event) { + self.approvals_changed(cx); + return; + } // The shell menu (and, for move/down/up, an open bar flyout) is // modal: while it is up, the pointer event is exclusively its own // — see `shell_menu_pointer` / `shell_panel_pointer`. Taken before @@ -5252,6 +5316,7 @@ impl App { self.phone_tick(cx); dev_mode::tick(); self.dev_mode_changed(cx); + self.approvals_tick(cx); // The pool fills itself here: at startup, after an // adoption, and after any death it healed from. One spawn // per second, so a cold desktop never forks four cargo diff --git a/crates/shell/src/module_host.rs b/crates/shell/src/module_host.rs index 3f8523a3..b4ebbca4 100644 --- a/crates/shell/src/module_host.rs +++ b/crates/shell/src/module_host.rs @@ -362,6 +362,16 @@ impl ModuleHost { // In developer mode a covered module gets every service it declares // (dev_mode.rs); the grant lives and dies with this instance. let offer = crate::ai_host::offer_with(module, &scope, crate::dev_mode::grants_all(module.id())); + // Consent at first use (ADR 0004 §4, approvals/consent.rs): a module + // the person has not allowed an agent is not offered one. The first + // time, the shell's first-use sheet asks; this instance goes without + // and the next one gets it once allowed. Developer mode asks nothing. + let offer = if offer.is_offered() && !crate::approvals::consent_for_module(module.id(), module.label(), module.capabilities()) { + let _ = offer.finish(); + None + } else { + Some(offer) + }; // Its storage (jail, account folders, secrets; ADR 0004 §11) the // same way, when it declares `storage` and the host has storage. let scope_key = scope.to_string(); @@ -386,7 +396,7 @@ impl ModuleHost { }); // What the module did not take is withdrawn; what it took stays // with the instance until teardown. - let assistant = offer.finish(); + let assistant = offer.and_then(|offer| offer.finish()); crate::app_storage::withdraw(module.id(), &scope_key); let Some((parts, manifest)) = created else { // Already reported; the shell has no client to show it on, so diff --git a/crates/shell/src/shell/menu.rs b/crates/shell/src/shell/menu.rs index 7a885fee..81a1aa71 100644 --- a/crates/shell/src/shell/menu.rs +++ b/crates/shell/src/shell/menu.rs @@ -526,6 +526,11 @@ impl MenuModel { let at = items.iter().position(|item| item.id == "style").map_or(items.len(), |i| i + 1); items.splice(at..at, developer); } + // Settings → Assistant → Approvals (approvals/settings_page.rs), in + // every build: rules, app agents' consent, recent auto-approvals. + let assistant = assistant_items(&items); + let at = items.iter().rposition(|item| item.id == "setup" || item.id.starts_with("setup.")).map_or(items.len(), |i| i + 1); + items.splice(at..at, assistant); if path.starts_with("style.theme") { items.extend(theme_items()); } @@ -777,6 +782,25 @@ impl MenuModel { /// Settings → Developer options' Turn on row. const DEVELOPER_ON: &str = "setup.developer.on"; +/// The Approvals page's row (lib.rs opens `approvals::open_settings`). +pub const APPROVALS_ROW: &str = "setup.assistant.approvals"; + +/// Settings (Setup) → Assistant → Approvals. +fn assistant_items(existing: &[MenuItem]) -> Vec { + let mut items = Vec::new(); + if !existing.iter().any(|item| item.id == "setup") { + items.push(MenuItem::new("setup", "Setup", MenuKind::Menu).icon(Ico::Keyboard).aliases(&["settings"])); + } + items.push(MenuItem::new("setup.assistant", "Assistant", MenuKind::Menu).icon(Ico::Cpu).aliases(&["assistant", "agents"])); + items.push( + MenuItem::new(APPROVALS_ROW, "Approvals", MenuKind::Action) + .icon(Ico::Check) + .aliases(&["approvals", "rules", "consent"]) + .describe("Standing rules, app agents and recent automatic approvals"), + ); + items +} + /// Settings (the menu's Setup, alias "settings") → Developer options, in /// builds where Settings may turn developer mode on (dev_mode.rs). Turning /// it on needs the confirmation phrase typed into the menu's filter: the @@ -1692,6 +1716,19 @@ mod tests { assert_eq!(model.activate().as_deref(), Some("setup.developer.on:turn on developer mode")); } + /// Setup → Assistant → Approvals opens the Approvals page. + #[test] + fn setup_assistant_offers_the_approvals_page() { + let mut model = MenuModel::default(); + model.open_at("", MenuSkin::Menu); + model.sel = model.rows.iter().position(|r| r.target == "setup").expect("Setup is listed"); + assert_eq!(model.activate(), None); + model.sel = model.rows.iter().position(|r| r.target == "setup.assistant").expect("Assistant"); + assert_eq!(model.activate(), None); + model.sel = model.rows.iter().position(|r| r.target == APPROVALS_ROW).expect("Approvals"); + assert_eq!(model.activate().as_deref(), Some(APPROVALS_ROW)); + } + /// Start > Settings is Appearance alone until the build ships the AI /// providers system app; then it is a submenu that also opens it. #[test]