diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 67f96e5e1..d17852e17 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -122,7 +122,10 @@ const overrides = new Map([ // runtime log path. Load-bearing crash-recovery surface; queued to split. // internal-owner-only: persistence choke point normalizes local agent access. // internal-local-relay-lockdown: load/save rejects legacy disallowed pins. - ["src-tauri/src/managed_agents/storage.rs", 1390], + // internal-local-relay-lockdown round 2: remediation-safe changed-pin checks and tests. + ["src-tauri/src/managed_agents/storage.rs", 1464], + // internal-local-relay-lockdown round 2: guard initial and incremental huddle enrollment. + ["src-tauri/src/huddle/mod.rs", 1006], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index f4fdf5853..3e9135e43 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -791,14 +791,7 @@ pub async fn add_channel_members( let uuid = parse_channel_uuid(&channel_id)?; let relay_url = crate::relay::relay_ws_url_with_override(&state); let local_agents = crate::managed_agents::load_managed_agents(&app)?; - for pubkey in &pubkeys { - if let Some(record) = local_agents.iter().find(|record| { - record.pubkey.eq_ignore_ascii_case(pubkey) - && record.backend == crate::managed_agents::BackendKind::Local - }) { - crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?; - } - } + crate::managed_agents::validate_local_agent_members(&local_agents, &pubkeys, &relay_url)?; let role_str = match role.as_deref() { Some("admin") => Some("admin"), Some("bot") => Some("bot"), diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 8a8f1ec9f..9f5113b85 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -67,7 +67,7 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; // ── Imports ─────────────────────────────────────────────────────────────────── use std::sync::{atomic::Ordering, Arc}; -use tauri::State; +use tauri::{AppHandle, State}; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; @@ -94,6 +94,16 @@ fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> S name.chars().take(80).collect() } +fn validate_huddle_agent_enrollment( + app: &AppHandle, + state: &AppState, + pubkeys: &[String], +) -> Result<(), String> { + let records = crate::managed_agents::load_managed_agents(app)?; + let relay_url = crate::relay::relay_ws_url_with_override(state); + crate::managed_agents::validate_local_agent_members(&records, pubkeys, &relay_url) +} + // ── Tauri commands ──────────────────────────────────────────────────────────── /// Set the voice input mode (push-to-talk or voice-activity detection). @@ -162,6 +172,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -184,6 +195,7 @@ pub async fn start_huddle( } deduped }; + validate_huddle_agent_enrollment(&app, &state, &member_pubkeys)?; // Transition to Creating. { @@ -925,9 +937,11 @@ pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Re #[tauri::command] pub async fn add_agent_to_huddle( agent_pubkey: String, + app: AppHandle, state: State<'_, AppState>, ) -> Result { validate_pubkey_hex(&agent_pubkey)?; + validate_huddle_agent_enrollment(&app, &state, std::slice::from_ref(&agent_pubkey))?; let (eph_id, parent_id) = { let hs = state.huddle()?; diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 7e34fd713..1ae6739e0 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,7 +11,9 @@ pub(crate) use access_policy::{ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; -pub(crate) use relay_policy::{validate_local_agent_relay, validate_managed_agent_relay_pin}; +pub(crate) use relay_policy::{ + validate_local_agent_members, validate_local_agent_relay, validate_managed_agent_relay_pin, +}; mod backend; pub(crate) mod config_bridge; mod discovery; diff --git a/desktop/src-tauri/src/managed_agents/relay_policy.rs b/desktop/src-tauri/src/managed_agents/relay_policy.rs index ee7e78797..bbc8a2c45 100644 --- a/desktop/src-tauri/src/managed_agents/relay_policy.rs +++ b/desktop/src-tauri/src/managed_agents/relay_policy.rs @@ -89,6 +89,36 @@ pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> R validate_local_agent_relay(&record.backend, &record.relay_url) } +/// Reject attachment of locally managed agents to a disallowed effective relay. +/// Unknown pubkeys and provider-backed records are outside this policy. +pub(crate) fn validate_local_agent_members( + records: &[ManagedAgentRecord], + pubkeys: &[String], + relay_url: &str, +) -> Result<(), String> { + validate_local_agent_members_with(records, pubkeys, |backend| { + validate_local_agent_relay(backend, relay_url) + }) +} + +fn validate_local_agent_members_with( + records: &[ManagedAgentRecord], + pubkeys: &[String], + validate: F, +) -> Result<(), String> +where + F: Fn(&BackendKind) -> Result<(), String>, +{ + for pubkey in pubkeys { + if let Some(record) = records.iter().find(|record| { + record.pubkey.eq_ignore_ascii_case(pubkey) && record.backend == BackendKind::Local + }) { + validate(&record.backend)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -97,6 +127,24 @@ mod tests { parse_allowlist(" WSS://Buzz.Block.Builderlab.XYZ:443/\n") } + fn record(pubkey: &str, backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": "test-agent", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("sample record"); + record.backend = backend; + record + } + #[test] fn internal_local_policy_matches_exact_normalized_origin() { assert!(validate_local_agent_relay_with_policy( @@ -138,6 +186,38 @@ mod tests { .is_ok()); } + #[test] + fn member_enrollment_validates_only_matching_local_agents() { + let provider = BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + let records = vec![ + record("local", BackendKind::Local), + record("provider", provider), + ]; + let calls = std::cell::Cell::new(0); + + assert!( + validate_local_agent_members_with(&records, &["LOCAL".into()], |_| { + calls.set(calls.get() + 1); + Err("blocked".to_string()) + }) + .is_err() + ); + assert_eq!(calls.get(), 1); + assert!(validate_local_agent_members_with( + &records, + &["provider".into(), "unknown".into()], + |_| { + calls.set(calls.get() + 1); + Err("blocked".to_string()) + }, + ) + .is_ok()); + assert_eq!(calls.get(), 1); + } + #[test] fn internal_policy_fails_closed_on_missing_empty_or_malformed_allowlist() { for allowlist in [ diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 59b0e8ffa..6e0462ed7 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -193,15 +193,18 @@ fn load_agent_store(app: &AppHandle) -> Result, String> }) } +fn retain_managed_agent_instances(records: &mut Vec) { + records.retain(|record| !record.pubkey.is_empty()); +} + /// Load the keyed agent *instances*. Key-less definitions (former personas, /// folded into the same store) are filtered out so every pre-fold call site -/// keeps seeing exactly the records it always did. +/// keeps seeing exactly the records it always did. Relay-policy violations are +/// enforced at save/attach/start boundaries, not here, so a bad legacy pin can +/// still be listed, fixed, or deleted. pub fn load_managed_agents(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; - records.retain(|record| !record.pubkey.is_empty()); - records - .iter() - .try_for_each(super::validate_managed_agent_relay_pin)?; + retain_managed_agent_instances(&mut records); hydrate_keys(&mut records); Ok(records) } @@ -295,17 +298,49 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) } } +fn validate_changed_relay_pins_with( + records: &[ManagedAgentRecord], + previous: &[ManagedAgentRecord], + validate: F, +) -> Result<(), String> +where + F: Fn(&ManagedAgentRecord) -> Result<(), String>, +{ + for record in records { + let unchanged = previous.iter().any(|old| { + old.pubkey == record.pubkey + && old.backend == record.backend + && old.relay_url == record.relay_url + }); + if !unchanged { + validate(record)?; + } + } + Ok(()) +} + /// Save the keyed agent *instances*, preserving the key-less definitions that /// share the unified store: callers pass exactly the records they loaded via /// [`load_managed_agents`], and this re-reads the definition half from disk /// before the wholesale rewrite so a definition is never dropped by an /// instance-side save (and vice versa via [`save_agent_definitions`]). pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); + let stored = load_agent_store(app).unwrap_or_default(); + let definitions = stored + .iter() + .filter(|record| record.pubkey.is_empty()) + .cloned() + .collect(); + let previous_instances: Vec<_> = stored + .into_iter() + .filter(|record| !record.pubkey.is_empty()) + .collect(); + validate_changed_relay_pins_with(records, &previous_instances, |record| { + super::validate_managed_agent_relay_pin(record) + })?; let mut sorted = records.to_vec(); for record in &mut sorted { super::normalize_managed_agent_access(record); - super::validate_managed_agent_relay_pin(record)?; } // A caller-supplied key-less record would collide with the definition // half re-read below; instances always carry a pubkey. @@ -802,7 +837,8 @@ mod tests { use super::{ agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, - KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, + retain_managed_agent_instances, validate_changed_relay_pins_with, KeyMigration, KeyStore, + KeyringProbe, ManagedAgentRecord, }; /// In-memory [`KeyStore`] for testing the migrate decision without the OS @@ -932,6 +968,44 @@ mod tests { .expect("sample record") } + #[test] + fn instance_filter_keeps_legacy_pins_available_for_remediation() { + let mut pinned = record_with_key("nsec1realkey"); + pinned.relay_url = "wss://public.example".into(); + let mut definition = pinned.clone(); + definition.pubkey.clear(); + let mut records = vec![pinned.clone(), definition]; + + retain_managed_agent_instances(&mut records); + + assert_eq!(records, vec![pinned]); + } + + #[test] + fn legacy_bad_pin_allows_unrelated_save_but_changed_pin_is_validated() { + let mut pinned = record_with_key("nsec1realkey"); + pinned.relay_url = "wss://public.example".into(); + let previous = vec![pinned.clone()]; + let calls = std::cell::Cell::new(0); + + assert!( + validate_changed_relay_pins_with(&[pinned.clone()], &previous, |_| { + calls.set(calls.get() + 1); + Err("blocked".into()) + }) + .is_ok() + ); + assert_eq!(calls.get(), 0); + + pinned.relay_url = "wss://other.example".into(); + assert!(validate_changed_relay_pins_with(&[pinned], &previous, |_| { + calls.set(calls.get() + 1); + Err("blocked".into()) + }) + .is_err()); + assert_eq!(calls.get(), 1); + } + #[test] fn migrate_persists_and_signals_stripping_when_keyring_reachable() { // Item 2: an inline key (residue from a prior keyring-unreachable save)