//! D-02: "one assistant, many front doors." A shared assistant service — //! one curated tool registry, one backend selector, one place the model key //! lives — used today by AIUI chat (`CallerScope::LocalOperator`) and, by //! design, extensible to mesh/LoRa callers (`CallerScope::Mesh`) and later //! Pine voice without recreating a second, divergent security model. //! //! This is the tracer slice for Phase 13 (D-01, D-02, D-06): a typed //! question reaches exactly one curated, read-only tool //! (`tools::system_disk_status_tool`) via the Claude backend, dispatched //! through the SAME `handle_system_disk_status` RPC handler every other //! authenticated caller uses. See `13-01-PLAN.md` for the full spine. pub mod backends; pub mod confirm; pub mod grants; pub mod loop_; pub mod tools; use std::collections::{BTreeSet, HashMap}; use std::path::Path; use std::sync::{Arc, Mutex}; use anyhow::Result; use serde::{Deserialize, Serialize}; use crate::api::rpc::RpcHandler; /// D-16's ten permission categories. All default-closed on a fresh node — /// nothing is shared with the model until deliberately granted. Serialized /// with `rename_all = "kebab-case"` so the wire form (`"ai-local"`, etc.) /// matches `neode-ui/src/stores/aiPermissions.ts`'s category ids /// one-for-one — 13-05's acceptance criterion diffs the two lists by hand, /// but the serde rename is what keeps them from drifting silently again. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PermissionCategory { Apps, System, Network, Wallet, Files, Media, Search, AiLocal, Notes, Bitcoin, } impl PermissionCategory { /// All ten categories, for grants-get/list-tools enumeration and for /// tests that need to assert over the whole set. pub const ALL: [PermissionCategory; 10] = [ PermissionCategory::Apps, PermissionCategory::System, PermissionCategory::Network, PermissionCategory::Wallet, PermissionCategory::Files, PermissionCategory::Media, PermissionCategory::Search, PermissionCategory::AiLocal, PermissionCategory::Notes, PermissionCategory::Bitcoin, ]; } /// D-02's promoted primary noun: a caller identity carrying the permission /// scope its tool calls resolve authority through. "A mesh peer" and "the /// local operator in AIUI" are two variants of it; Pine voice will be a /// third (not built in this phase — no `Voice` variant exists yet, by /// design, until that phase actually needs one). #[derive(Debug, Clone)] pub enum CallerScope { /// A mesh/LoRa peer. Not exercised by this plan (mesh's existing /// `!ai` path is Q&A-only, per `mesh/listener/assist.rs`'s own doc /// comment) — the variant exists so the shape is right when a future /// plan wires mesh callers into the shared loop. `authorized` is where /// that future plan threads the existing per-caller /// `trusted_only`/`allowed_contacts`/`denied_askers` resolution /// (`api/rpc/mesh/assistant.rs`) through: a mesh peer's authority is /// never wider than the operator's own persisted grants, only ever a /// subset of them (all-or-nothing today; a future plan may narrow this /// to a per-peer category subset without changing this variant's /// shape). Mesh { peer_id: String, authorized: bool }, /// The authenticated operator using AIUI, identified by their neode-ui /// session. This is the only variant this tracer's `assistant.chat` /// RPC constructs. LocalOperator { session_id: String }, } impl CallerScope { /// The sole source of tool authority `execute_tool` reads. No /// `execute_tool` branch may read a caller-specific field directly /// instead of going through this — that would reintroduce the /// mesh-only assumption D-02 exists to retire. /// /// Both variants resolve through the SAME persisted `Grants` store /// (D-16's default-closed categories, per `data_dir`) — never a /// hardcoded default, and never two divergent sources of authority. pub async fn granted_categories(&self, data_dir: &Path) -> BTreeSet { let persisted = grants::Grants::load(data_dir).await; match self { CallerScope::LocalOperator { .. } => persisted.categories().clone(), // A mesh peer can never exceed what the operator opened: its // authority is the persisted grants intersected with whether // this peer is itself authorized to use the assistant at all // (today a single boolean; a future plan may narrow this to a // per-peer category subset without changing this call site). CallerScope::Mesh { authorized, .. } => { if *authorized { persisted.categories().clone() } else { BTreeSet::new() } } } } } /// Bundles what `execute_tool` needs regardless of which backend produced /// the tool call: the curated registry, the caller's resolved authority, /// and a handle back to the SAME `RpcHandler` every other authenticated /// caller dispatches through — never an AI-only backdoor. /// /// Also carries the AI-SPEC §4b.1 "≤ 2 consecutive validation failures per /// tool name" counter. Construct via [`ToolExecCtx::new`] — the counter /// field is private so every call site shares the same reset/note logic /// rather than reimplementing it. pub struct ToolExecCtx { pub registry: tools::ToolRegistry, pub caller: CallerScope, pub handler: Arc, /// D-07/D-11: the confirm gate every destructive tool suspends on. /// Defaults to the process-wide gate (`confirm::global()`) so the loop /// and the `assistant.confirm-tool`/`assistant.pending` RPC handlers /// share one pending queue; tests inject a fresh gate per test via /// [`ToolExecCtx::with_confirm_gate`] for isolation. pub confirm: Arc, validation_failures: Mutex>, } impl ToolExecCtx { pub fn new(registry: tools::ToolRegistry, caller: CallerScope, handler: Arc) -> Self { Self::with_confirm_gate(registry, caller, handler, confirm::global()) } pub fn with_confirm_gate( registry: tools::ToolRegistry, caller: CallerScope, handler: Arc, confirm: Arc, ) -> Self { Self { registry, caller, handler, confirm, validation_failures: Mutex::new(HashMap::new()), } } /// A tool name's argument validation just succeeded — its consecutive /// failure streak resets. pub(crate) fn reset_validation_failures(&self, tool_name: &str) { self.validation_failures .lock() .expect("validation_failures mutex poisoned") .remove(tool_name); } /// A tool name's argument validation just failed. Returns `true` if /// this failure is the third (or later) consecutive one for this tool /// name — the signal `run_loop` uses to abort the turn with an apology /// rather than spinning (D-05, AI-SPEC §4b.1). pub(crate) fn note_validation_failure(&self, tool_name: &str) -> bool { let mut map = self .validation_failures .lock() .expect("validation_failures mutex poisoned"); let count = map.entry(tool_name.to_string()).or_insert(0); *count += 1; *count > 2 } /// Whether any tool name has crossed the consecutive-failure threshold /// this turn. Checked by `run_loop` after each batch of tool calls. pub(crate) fn should_abort(&self) -> bool { self.validation_failures .lock() .expect("validation_failures mutex poisoned") .values() .any(|&c| c > 2) } } /// D-16/AI-SPEC §4b.3: one static, phase-authored persona and confirm-gate /// statement, never assembled from prior model output and never editable /// by AIUI. Appends **only** the currently-granted-category tools' names /// and descriptions — an ungranted tool's name never appears in this /// string, which is the prompt-side half of the two-layer defense (the /// `execute_tool` grant re-check in `loop_.rs` is the other, and the gate /// that actually matters — see `ungranted_tool_absent_from_system_prompt` /// and `settings_tool_respects_category_grant`). const SYSTEM_PROMPT_PREAMBLE: &str = "You are the Archipelago node's operator-control assistant. \ Only use the tools explicitly listed below for this turn — never invent a tool name or call one \ that is not listed here, even if it sounds like something this node could plausibly do. Every \ write requires a human confirmation you cannot bypass, skip, or pre-approve on the user's \ behalf. If the user asks for something outside the tools listed below (including anything \ touching keys, seeds, wallet spends, federation trust, or a factory reset), refuse plainly and, \ if there is a real path in neode-ui's Settings screen for it, name that path instead of \ fabricating a tool call."; pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String { let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE); if visible_tools.is_empty() { prompt.push_str( "\n\nNo permission categories are granted on this node right now, so no tools are \ available this turn. Say so plainly if asked to do something — do not guess, and do \ not pretend a category is open.", ); } else { prompt.push_str("\n\nTools available this turn:\n"); for tool in visible_tools { prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description)); } } prompt } /// Entry point: run one chat turn for `caller` through the shared loop. /// Builds the visible-tool set from the caller's granted categories only /// (D-16 — the model should never even see a tool it can't use), selects a /// backend (Claude only, in this tracer), and runs it to a final answer. pub async fn chat(handler: Arc, caller: CallerScope, user_text: String) -> Result { let registry = tools::registry(); let grants = caller.granted_categories(handler.data_dir()).await; let visible_tools = registry.visible_to(&grants); let backend = backends::select_backend(handler.data_dir()); let system_prompt = build_system_prompt(&visible_tools); let history = vec![tools::ChatMessage { role: tools::Role::User, text: Some(user_text), tool_calls: vec![], tool_results: vec![], }]; let ctx = ToolExecCtx::new(registry, caller, handler); loop_::run_loop(backend.as_ref(), &system_prompt, &visible_tools, history, &ctx).await } #[cfg(test)] mod tests { use super::*; use crate::api::rpc::RpcHandler; /// A minimal but real `RpcHandler` for tests, matching /// `loop_::tests::test_rpc_handler` — a fresh temp `data_dir`, no /// orchestrator. async fn test_rpc_handler() -> (Arc, tempfile::TempDir) { let tmp = tempfile::tempdir().expect("tempdir"); let mut config = crate::config::Config::default(); config.data_dir = tmp.path().to_path_buf(); let state_manager = Arc::new(crate::state::StateManager::new()); let metrics_store = Arc::new(crate::monitoring::MetricsStore::new()); let session_store = crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")); let handler = RpcHandler::new( config, state_manager, metrics_store, session_store, None, None, ) .await .expect("RpcHandler::new"); (Arc::new(handler), tmp) } /// A minimal installed-and-running app entry, seeded into the scanner /// snapshot so the app-lifecycle tools' business-rule id resolution /// (`container-list`) finds it — the same cached-state path the real /// node serves, no orchestrator or podman needed. fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry { use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles}; PackageDataEntry { state: PackageState::Running, health: None, exit_code: None, static_files: StaticFiles { license: String::new(), instructions: String::new(), icon: String::new(), }, manifest: Manifest { id: app_id.to_string(), title: app_id.to_string(), version: String::new(), description: Description { short: String::new(), long: String::new(), }, release_notes: String::new(), license: String::new(), wrapper_repo: String::new(), upstream_repo: String::new(), support_site: String::new(), marketing_site: String::new(), donation_url: None, author: None, website: None, interfaces: None, tier: None, }, installed: None, install_progress: None, uninstall_stage: None, available_update: None, } } /// Like `test_rpc_handler`, but with one installed, running app seeded /// into the state snapshot so destructive app-lifecycle calls pass /// business-rule validation and actually reach the confirm gate. async fn test_rpc_handler_with_app(app_id: &str) -> (Arc, tempfile::TempDir) { let tmp = tempfile::tempdir().expect("tempdir"); let mut config = crate::config::Config::default(); config.data_dir = tmp.path().to_path_buf(); let state_manager = Arc::new(crate::state::StateManager::new()); let mut data = crate::data_model::DataModel::new(); data.server_info.status_info.containers_scanned = true; data.package_data .insert(app_id.to_string(), installed_entry(app_id)); state_manager.update_data(data).await; let metrics_store = Arc::new(crate::monitoring::MetricsStore::new()); let session_store = crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")); let handler = RpcHandler::new( config, state_manager, metrics_store, session_store, None, None, ) .await .expect("RpcHandler::new"); (Arc::new(handler), tmp) } async fn grant(handler: &Arc, category: PermissionCategory) { let mut g = grants::Grants::load(handler.data_dir()).await; g.set(category, true); g.save(handler.data_dir()).await.expect("save grants"); } async fn wait_pending(gate: &confirm::ConfirmGate) -> confirm::PendingSnapshot { for _ in 0..500 { if let Some(snap) = gate.peek() { return snap; } tokio::time::sleep(std::time::Duration::from_millis(5)).await; } panic!("no pending confirmation appeared — the destructive branch did not suspend on the gate"); } /// S-01 / D-07: a destructive tool call suspends before any execution /// and produces a pending confirmation whose node-authored description /// names the resource; nothing runs until a human resolves it, and a /// decline returns an error result without executing. #[tokio::test] async fn destructive_tool_requires_confirm() { let (handler, _tmp) = test_rpc_handler_with_app("demo-app").await; grant(&handler, PermissionCategory::Apps).await; // Part 1 — the choke point directly: execute_tool suspends on the // gate, and a decline returns a declined error result (dispatch is // never reached — nothing was changed). let gate = Arc::new(confirm::ConfirmGate::new()); let ctx = Arc::new(ToolExecCtx::with_confirm_gate( tools::registry(), CallerScope::LocalOperator { session_id: "s".to_string(), }, handler.clone(), gate.clone(), )); let call = tools::ToolCall { id: "call-1".to_string(), name: "app_restart".to_string(), arguments: serde_json::json!({ "app_id": "demo-app" }), }; let (ctx_task, call_task) = (ctx.clone(), call.clone()); let suspended = tokio::spawn(async move { loop_::execute_tool(&call_task, &ctx_task).await }); let snap = wait_pending(&gate).await; assert!( snap.description.contains("demo-app"), "the node-authored description must name the resource: {}", snap.description ); assert!( !suspended.is_finished(), "execute_tool must stay suspended until the human decides" ); gate.resolve(&snap.req_id, &snap.nonce, false) .expect("decline resolves"); let result = suspended.await.expect("join"); assert!(result.is_error, "a declined action must not execute"); assert!( result.content.to_lowercase().contains("declined"), "the tool result must say the user declined: {}", result.content ); // Part 2 — through the real loop: a scripted backend proposes the // destructive call, the loop suspends on the gate, and only after // the human answers does the turn finish. let gate2 = Arc::new(confirm::ConfirmGate::new()); let ctx2 = ToolExecCtx::with_confirm_gate( tools::registry(), CallerScope::LocalOperator { session_id: "s".to_string(), }, handler.clone(), gate2.clone(), ); let backend = crate::assistant::backends::scripted::ScriptedBackend::new(vec![ crate::assistant::backends::BackendTurn::ToolCalls(vec![call.clone()]), crate::assistant::backends::BackendTurn::Text( "Understood — I did not restart it.".to_string(), ), ]); let tools_list = vec![tools::app_restart_tool()]; let loop_task = tokio::spawn(async move { loop_::run_loop(&backend, "sys", &tools_list, vec![], &ctx2).await }); let snap2 = wait_pending(&gate2).await; assert!( !loop_task.is_finished(), "run_loop must stay suspended while the confirmation is pending" ); gate2 .resolve(&snap2.req_id, &snap2.nonce, false) .expect("decline resolves"); let answer = loop_task.await.expect("join").expect("run_loop"); assert_eq!(answer, "Understood — I did not restart it."); } /// S-06 / D-16: a fresh node's `LocalOperator` resolves to no granted /// categories at all. #[tokio::test] async fn fresh_node_grants_are_empty() { let (handler, _tmp) = test_rpc_handler().await; let caller = CallerScope::LocalOperator { session_id: "s".to_string(), }; let granted = caller.granted_categories(handler.data_dir()).await; assert!(granted.is_empty(), "fresh node must grant nothing: {granted:?}"); } /// The system prompt built for a caller must never mention a tool /// whose category is not currently granted — the prompt filter is /// defense in depth, never the gate (S-05's gate is /// `settings_tool_respects_category_grant` in tools.rs), but it must /// still hold. #[test] fn ungranted_tool_absent_from_system_prompt() { let reg = tools::registry(); let mut grants = BTreeSet::new(); grants.insert(PermissionCategory::System); let visible = reg.visible_to(&grants); let prompt = build_system_prompt(&visible); for tool in reg.all() { if tool.category == PermissionCategory::System { continue; } assert!( !prompt.contains(tool.name), "ungranted tool {} leaked into the system prompt", tool.name ); } // Sanity: at least one granted tool IS present, so this isn't // trivially passing because the prompt is empty. assert!( reg.visible_to(&grants).iter().any(|t| prompt.contains(t.name)), "expected at least one granted-category tool name in the prompt" ); } /// D-16: revoking a category takes effect on the very next /// `granted_categories` resolution — not only on the next session / /// process restart. #[tokio::test] async fn grant_revocation_takes_effect_next_turn() { let (handler, _tmp) = test_rpc_handler().await; let caller = CallerScope::LocalOperator { session_id: "s".to_string(), }; let mut g = grants::Grants::load(handler.data_dir()).await; g.set(PermissionCategory::System, true); g.save(handler.data_dir()).await.expect("save"); let granted = caller.granted_categories(handler.data_dir()).await; assert!(granted.contains(&PermissionCategory::System)); let mut g = grants::Grants::load(handler.data_dir()).await; g.set(PermissionCategory::System, false); g.save(handler.data_dir()).await.expect("save"); // Same caller, same process, no restart — the very next resolution // must reflect the revocation. let granted = caller.granted_categories(handler.data_dir()).await; assert!( !granted.contains(&PermissionCategory::System), "revocation must take effect on the next resolution, not only on restart" ); } /// D-02's promoted-primary contract: both `CallerScope` variants /// resolve authority through the SAME method, and a mesh peer's /// authority is never wider than the operator's own persisted grants. #[tokio::test] async fn every_caller_variant_resolves_authority_through_caller_scope() { let (handler, _tmp) = test_rpc_handler().await; let mut g = grants::Grants::load(handler.data_dir()).await; g.set(PermissionCategory::Bitcoin, true); g.save(handler.data_dir()).await.expect("save"); let operator = CallerScope::LocalOperator { session_id: "s".to_string(), }; let operator_grants = operator.granted_categories(handler.data_dir()).await; assert!(operator_grants.contains(&PermissionCategory::Bitcoin)); let authorized_peer = CallerScope::Mesh { peer_id: "peer-1".to_string(), authorized: true, }; let peer_grants = authorized_peer.granted_categories(handler.data_dir()).await; assert_eq!( peer_grants, operator_grants, "an authorized mesh peer's ceiling is exactly the operator's persisted grants" ); let unauthorized_peer = CallerScope::Mesh { peer_id: "peer-2".to_string(), authorized: false, }; let peer_grants = unauthorized_peer.granted_categories(handler.data_dir()).await; assert!( peer_grants.is_empty(), "an unauthorized mesh peer must resolve to no authority regardless of what the operator granted" ); } }