diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718..7f63973ca6 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -15,9 +15,16 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_INTERNAL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // Explicit distribution identity. Internal packaging sets this presence-only + // marker; OSS/custom builds remain public regardless of baked defaults. + if std::env::var("BUZZ_BUILD_INTERNAL").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_INTERNAL=1"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..f16ddb759d 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -773,6 +773,12 @@ async fn discover_databricks_models( })) } +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::internal_build() +} + /// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch /// to `record`, enforcing the linked-instance write guard: a definition-linked /// record's model/provider/prompt are definition-authoritative (see @@ -906,28 +912,11 @@ pub async fn update_managed_agent( record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); } - // Inbound author gate: merge patch onto current values, then validate - // the merged state. This lets a single update switch to Allowlist AND - // supply pubkeys atomically. - let prospective_mode = input.respond_to.unwrap_or(record.respond_to); - let prospective_allowlist = match input.respond_to_allowlist.as_ref() { - Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, - None => record.respond_to_allowlist.clone(), - }; - if prospective_mode == crate::managed_agents::RespondTo::Allowlist - && prospective_allowlist.is_empty() - { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" - .to_string(), - ); - } - record.respond_to = prospective_mode; - // Preserve the persisted allowlist across mode toggles — only replace - // when the caller explicitly supplied a new list. - if input.respond_to_allowlist.is_some() { - record.respond_to_allowlist = prospective_allowlist; - } + crate::managed_agents::apply_update_access( + record, + input.respond_to, + input.respond_to_allowlist.as_deref(), + )?; record.updated_at = now_iso(); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..85ded0b3a7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -584,22 +584,11 @@ pub async fn create_managed_agent( } crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - // Validate & normalize the respond-to allowlist BEFORE any side effects. - // The harness has its own validator (buzz-acp/src/config.rs) but we want - // to catch malformed input at the boundary so the agent never tries to - // start with a list that will crash it on launch. The mode/allowlist - // pairing (and the definition-default fallback) is resolved later at the - // mint site via `resolve_mint_behavioral_defaults`, where the linked - // definition is in hand. - let respond_to_allowlist = - crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?; - if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist) - && respond_to_allowlist.is_empty() - { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } + let (requested_respond_to, respond_to_allowlist) = + crate::managed_agents::resolve_create_access( + input.respond_to, + &input.respond_to_allowlist, + )?; // Snapshot the workspace owner pubkey for the legacy-record auth_tag // fallback. Computed outside the records lock to keep lock ordering simple. @@ -823,7 +812,7 @@ pub async fn create_managed_agent( // point for definition behavioral strings — fails loudly on a bad // mode/range instead of minting an agent the author didn't describe. let minted = crate::managed_agents::resolve_mint_behavioral_defaults( - input.respond_to, + requested_respond_to, respond_to_allowlist.clone(), input.parallelism, linked_persona.as_ref(), diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index af785711d5..76036b406e 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -99,6 +99,7 @@ pub(super) fn build_deploy_payload( effective_provider, effective_prompt, merged_env, + crate::managed_agents::internal_build(), )) } @@ -112,7 +113,10 @@ pub(super) fn deploy_payload_json( effective_provider: Option, effective_prompt: Option, merged_env: std::collections::BTreeMap, + internal: bool, ) -> serde_json::Value { + let (respond_to, respond_to_allowlist) = + crate::managed_agents::projected_access_with_policy(record, internal); serde_json::json!({ "name": &record.name, "relay_url": relay_url, @@ -127,8 +131,8 @@ pub(super) fn deploy_payload_json( "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, "env_vars": merged_env, }) } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..2beb52330f 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -398,6 +398,18 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── +fn deploy_payload_for_policy(record: &ManagedAgentRecord, internal: bool) -> serde_json::Value { + deploy_payload_json( + record, + "wss://relay.example".to_string(), + Some("gpt-x".to_string()), + Some("openai".to_string()), + None, + std::collections::BTreeMap::new(), + internal, + ) +} + /// Regression (PR #1667 review, Thufir): the provider deploy payload must /// carry every behavioral field the local spawn path applies — a field /// missing here silently strips it from provider-backed agents. @@ -429,14 +441,7 @@ fn deploy_payload_carries_the_full_behavioral_quad() { )) .expect("sample record"); - let payload = deploy_payload_json( - &record, - "wss://relay.example".to_string(), - Some("gpt-x".to_string()), - Some("openai".to_string()), - None, - std::collections::BTreeMap::new(), - ); + let payload = deploy_payload_for_policy(&record, false); assert_eq!(payload["parallelism"], 4); assert_eq!(payload["respond_to"], "allowlist"); @@ -445,3 +450,28 @@ fn deploy_payload_carries_the_full_behavioral_quad() { assert_eq!(payload["provider"], "openai"); assert_eq!(payload["relay_url"], "wss://relay.example"); } + +#[test] +fn internal_deploy_payload_clamps_stale_access() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_for_policy(&record, true); + + assert_eq!( + payload["respond_to"], "owner-only", + "internal deploy payload widened stale access" + ); + assert_eq!( + payload["respond_to_allowlist"], + serde_json::json!([]), + "internal deploy payload retained a stale allowlist" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index c00de1c6da..13a26d1f40 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -74,6 +74,7 @@ pub async fn create_persona( updated_at: now, }; apply_persona_behavior(&mut persona, input.behavior)?; + crate::managed_agents::normalize_definition_access(&mut persona); personas.push(persona.clone()); save_personas(&app, &personas)?; retain_persona_pending(&app, &state, &persona); diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..9354fe3e36 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -340,6 +340,14 @@ fn event_d_tag(event: &nostr::Event) -> Result { /// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as /// the id and a re-received event stays idempotent (no duplicate row). fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { + apply_inbound_persona_with_policy(personas, inbound, crate::managed_agents::internal_build()); +} + +fn apply_inbound_persona_with_policy( + personas: &mut Vec, + inbound: AgentDefinition, + internal: bool, +) { let d_tag = persona_d_tag(&inbound); match personas .iter_mut() @@ -357,9 +365,19 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi local.respond_to_allowlist = inbound.respond_to_allowlist; local.parallelism = inbound.parallelism; local.shared = inbound.shared; + crate::managed_agents::access_policy::normalize_definition_access_with_policy( + local, internal, + ); local.updated_at = inbound.updated_at; } - None => personas.push(inbound), + None => { + let mut inbound = inbound; + crate::managed_agents::access_policy::normalize_definition_access_with_policy( + &mut inbound, + internal, + ); + personas.push(inbound); + } } } @@ -401,6 +419,7 @@ fn apply_inbound_managed_agent( local.parallelism = inbound.parallelism; local.respond_to = inbound.respond_to; local.respond_to_allowlist = inbound.respond_to_allowlist; + crate::managed_agents::normalize_managed_agent_access(local); } } diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..18c05997b4 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -4,7 +4,7 @@ use super::*; use std::collections::BTreeMap; -const UUID: &str = "11111111-2222-3333-4444-555555555555"; +const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq.pii.cc.visa /// A local in-app persona: `source_team_persona_slug` is None, so its d-tag /// IS its UUID id. Carries env_vars + source_team that must survive a patch. @@ -151,6 +151,27 @@ fn no_local_match_inserts_inbound_reusing_d_tag_as_id() { assert_eq!(personas.len(), 2, "re-receive of inserted record no-ops"); } +#[test] +fn internal_policy_clamps_inbound_persona_insert() { + let d_tag = "99999999-8888-7777-6666-555555555555"; + let mut inbound = inbound_for(d_tag, "New"); + inbound.respond_to = Some("anyone".to_string()); + inbound.respond_to_allowlist = vec!["a".repeat(64)]; + let mut personas = Vec::new(); + + apply_inbound_persona_with_policy(&mut personas, inbound, true); + + let inserted = personas.first().expect("inbound persona inserted"); + assert_eq!( + inserted.respond_to, None, + "internal inbound persona insert retained stale access" + ); + assert!( + inserted.respond_to_allowlist.is_empty(), + "internal inbound persona insert retained a stale allowlist" + ); +} + // ── Managed-agent (30177) inbound ──────────────────────────────────────── const AGENT_PUBKEY: &str = "agentpubkeyhex0000000000000000000000000000000000000000000000000000"; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eccf8ee601..3ac1a384aa 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -444,7 +444,7 @@ pub async fn confirm_agent_snapshot_import( let persona_id = uuid::Uuid::new_v4().to_string(); // Build persona from snapshot definition. - let persona = AgentDefinition { + let mut persona = AgentDefinition { id: persona_id.clone(), display_name: display_name.clone(), avatar_url: effective_avatar.clone(), @@ -470,6 +470,7 @@ pub async fn confirm_agent_snapshot_import( created_at: now.clone(), updated_at: now.clone(), }; + crate::managed_agents::normalize_definition_access(&mut persona); personas.push(persona.clone()); save_personas(&app, &personas)?; @@ -479,7 +480,7 @@ pub async fn confirm_agent_snapshot_import( // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. - let record = ManagedAgentRecord { + let mut record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, @@ -540,6 +541,7 @@ pub async fn confirm_agent_snapshot_import( runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), }; + crate::managed_agents::normalize_managed_agent_access(&mut record); records.push(record.clone()); save_managed_agents(&app, &records)?; diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54e..4ec10e2c15 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -129,6 +129,7 @@ pub(super) async fn update_persona_with( persona.env_vars = env_vars; } apply_persona_behavior(persona, input.behavior)?; + crate::managed_agents::normalize_definition_access(persona); persona.updated_at = now_iso(); let result = persona.clone(); diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..28b6f58faf 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -118,7 +118,7 @@ fn definition_from_snapshot( let respond_to = (behavior.respond_to != crate::managed_agents::RespondTo::default()) .then(|| behavior.respond_to.as_str().to_string()); - Ok(AgentDefinition { + let mut definition = AgentDefinition { id: Uuid::new_v4().to_string(), display_name: member.profile.display_name.trim().to_string(), avatar_url: effective_avatar(member), @@ -139,7 +139,9 @@ fn definition_from_snapshot( parallelism: behavior.parallelism, created_at: now.to_string(), updated_at: now.to_string(), - }) + }; + crate::managed_agents::normalize_definition_access(&mut definition); + Ok(definition) } pub(crate) fn build_import_definitions( @@ -549,7 +551,7 @@ pub async fn confirm_team_snapshot_import( }; // Build the ManagedAgentRecord for this member. - let record = ManagedAgentRecord { + let mut record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, @@ -612,6 +614,7 @@ pub async fn confirm_team_snapshot_import( runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; + crate::managed_agents::normalize_managed_agent_access(&mut record); minted.push(MintedMember { definition, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..a51c12813f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -817,6 +817,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + agent_access_owner_only, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs new file mode 100644 index 0000000000..402ca4849a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,257 @@ +//! Distribution policy for managed-agent inbound author access. + +use super::{validate_respond_to_allowlist, AgentDefinition, ManagedAgentRecord, RespondTo}; + +pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Internal packaging sets `BUZZ_BUILD_INTERNAL`; OSS/custom builds do not. +pub(crate) fn internal_build() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_INTERNAL").is_some() +} + +pub(crate) fn owner_only() -> bool { + owner_only_with_policy(internal_build()) +} + +pub(crate) fn owner_only_with_policy(internal: bool) -> bool { + internal +} + +/// Project effective access at a behavioral boundary. This is independent of +/// persistence normalization so stale or hand-edited records cannot widen an +/// internal agent's access when they are deployed or published. +pub(crate) fn projected_access_with_policy( + record: &ManagedAgentRecord, + internal: bool, +) -> (RespondTo, Vec) { + if owner_only_with_policy(internal) { + (RespondTo::OwnerOnly, Vec::new()) + } else { + (record.respond_to, record.respond_to_allowlist.clone()) + } +} + +/// Build the inbound-author access environment for a launched agent. The +/// explicit policy input keeps internal-build enforcement testable without +/// weakening the production caller's compile-time decision. +pub(crate) fn build_respond_to_env_with_policy( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, + enforced_owner_only: bool, +) -> Result { + let respond_to = if enforced_owner_only { + RespondTo::OwnerOnly + } else { + record.respond_to + }; + let normalized = if enforced_owner_only { + Vec::new() + } else { + validate_respond_to_allowlist(&record.respond_to_allowlist)? + }; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())]; + let mut remove = Vec::new(); + if respond_to == RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +/// Normalize a persisted/projected instance for the current distribution. +pub(crate) fn normalize_managed_agent_access(record: &mut ManagedAgentRecord) -> bool { + normalize_managed_agent_access_with_policy(record, internal_build()) +} + +pub(crate) fn normalize_managed_agent_access_with_policy( + record: &mut ManagedAgentRecord, + internal: bool, +) -> bool { + if !owner_only_with_policy(internal) { + return false; + } + let changed = + record.respond_to != RespondTo::OwnerOnly || !record.respond_to_allowlist.is_empty(); + record.respond_to = RespondTo::OwnerOnly; + record.respond_to_allowlist.clear(); + changed +} + +/// Definitions are backend-neutral defaults. Internal builds store owner-only +/// defaults so every later mint starts safe. +pub(crate) fn normalize_definition_access(record: &mut AgentDefinition) -> bool { + normalize_definition_access_with_policy(record, internal_build()) +} + +pub(crate) fn normalize_definition_access_with_policy( + record: &mut AgentDefinition, + internal: bool, +) -> bool { + if !internal { + return false; + } + let changed = record.respond_to.is_some() || !record.respond_to_allowlist.is_empty(); + record.respond_to = None; + record.respond_to_allowlist.clear(); + changed +} + +pub(crate) fn resolve_create_access( + requested_mode: Option, + requested_allowlist: &[String], +) -> Result<(Option, Vec), String> { + resolve_create_access_with_policy(requested_mode, requested_allowlist, internal_build()) +} + +pub(crate) fn resolve_create_access_with_policy( + requested_mode: Option, + requested_allowlist: &[String], + internal: bool, +) -> Result<(Option, Vec), String> { + if owner_only_with_policy(internal) { + return Ok((Some(RespondTo::OwnerOnly), Vec::new())); + } + let allowlist = validate_respond_to_allowlist(requested_allowlist)?; + if requested_mode == Some(RespondTo::Allowlist) && allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".into(), + ); + } + Ok((requested_mode, allowlist)) +} + +pub(crate) fn apply_update_access( + record: &mut ManagedAgentRecord, + requested_mode: Option, + requested_allowlist: Option<&[String]>, +) -> Result<(), String> { + apply_update_access_with_policy( + record, + requested_mode, + requested_allowlist, + internal_build(), + ) +} + +pub(crate) fn apply_update_access_with_policy( + record: &mut ManagedAgentRecord, + requested_mode: Option, + requested_allowlist: Option<&[String]>, + internal: bool, +) -> Result<(), String> { + if owner_only_with_policy(internal) { + record.respond_to = RespondTo::OwnerOnly; + record.respond_to_allowlist.clear(); + return Ok(()); + } + + let mode = requested_mode.unwrap_or(record.respond_to); + let allowlist = match requested_allowlist { + Some(list) => validate_respond_to_allowlist(list)?, + None => record.respond_to_allowlist.clone(), + }; + if mode == RespondTo::Allowlist && allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".into(), + ); + } + record.respond_to = mode; + if requested_allowlist.is_some() { + record.respond_to_allowlist = allowlist; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::BackendKind; + + fn record(backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["stale".into()]; + record + } + + #[test] + fn internal_create_clamps_every_agent() { + let result = + resolve_create_access_with_policy(Some(RespondTo::Anyone), &["bad".into()], true) + .unwrap(); + assert_eq!(result, (Some(RespondTo::OwnerOnly), Vec::new())); + } + + #[test] + fn internal_update_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let mut record = record(backend); + apply_update_access_with_policy(&mut record, None, None, true).unwrap(); + assert_eq!( + record.respond_to, + RespondTo::OwnerOnly, + "internal update did not clamp {label} agent", + ); + assert!( + record.respond_to_allowlist.is_empty(), + "internal update did not clear {label} agent allowlist", + ); + } + } + + #[test] + fn internal_normalization_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let mut record = record(backend); + assert!( + normalize_managed_agent_access_with_policy(&mut record, true), + "internal normalization did not change {label} agent", + ); + assert_eq!(record.respond_to, RespondTo::OwnerOnly); + assert!(record.respond_to_allowlist.is_empty()); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..d39ba9d226 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -68,6 +68,13 @@ pub struct ManagedAgentEventContent { /// operational start/stop produces an identical projection and never /// republishes. pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventContent { + agent_event_content_with_policy(record, super::internal_build()) +} + +pub(crate) fn agent_event_content_with_policy( + record: &ManagedAgentRecord, + internal: bool, +) -> ManagedAgentEventContent { // Slimmed projection (NIP-AP "Slimming: kind:30177"): definition-linked // instances resolve prompt/model/provider/source_version through their // kind:30175 definition, so those fields are omitted from the wire. @@ -77,6 +84,7 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont // restore path. This branch retires once every record is // definition-backed (B5 backfill). let definition_linked = record.persona_id.is_some(); + let (respond_to, respond_to_allowlist) = super::projected_access_with_policy(record, internal); ManagedAgentEventContent { name: record.name.clone(), persona_id: record.persona_id.clone(), @@ -101,8 +109,8 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont record.persona_source_version.clone() }, parallelism: record.parallelism, - respond_to: record.respond_to, - respond_to_allowlist: record.respond_to_allowlist.clone(), + respond_to, + respond_to_allowlist, } } @@ -302,6 +310,29 @@ mod tests { ); } + #[test] + fn public_projection_preserves_configured_access() { + let projection = agent_event_content_with_policy(&sample_agent(), false); + + assert_eq!(projection.respond_to, RespondTo::Allowlist); + assert_eq!(projection.respond_to_allowlist, vec!["79be667e"]); + } + + #[test] + fn internal_projection_clamps_stale_access() { + let projection = agent_event_content_with_policy(&sample_agent(), true); + + assert_eq!( + projection.respond_to, + RespondTo::OwnerOnly, + "internal kind:30177 projection widened stale access" + ); + assert!( + projection.respond_to_allowlist.is_empty(), + "internal kind:30177 projection retained a stale allowlist" + ); + } + /// Slimming (NIP-AP): definition-linked records omit the definition quad; /// definition-less records keep emitting it (they ARE their own /// definition — old readers would otherwise wipe fields with no restore diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..fbbfa062d8 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,7 +1,13 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{ + apply_update_access, internal_build, normalize_definition_access, + normalize_managed_agent_access, owner_only, projected_access_with_policy, + resolve_create_access, +}; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..720eb5ce7c 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -365,6 +365,9 @@ pub(crate) fn load_personas_from_path( pub fn save_personas(app: &AppHandle, records: &[AgentDefinition]) -> Result<(), String> { let mut sorted = records.to_vec(); + for record in &mut sorted { + crate::managed_agents::normalize_definition_access(record); + } sort_personas(&mut sorted); // Post-fold: persona saves write key-less definition records into the diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..8c9f7ecc06 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,9 +16,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::compose_path_entries; -pub(crate) use path::should_skip_claude_executable; -pub(crate) use path::should_use_inherited; +pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; + +pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; mod metadata; pub(crate) use metadata::{ @@ -33,8 +33,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); - mod process; #[cfg(test)] use process::{ @@ -130,6 +128,13 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } +fn summary_access_with_policy( + record: &ManagedAgentRecord, + internal: bool, +) -> (super::types::RespondTo, Vec) { + super::projected_access_with_policy(record, internal) +} + pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, @@ -297,6 +302,8 @@ pub fn build_managed_agent_summary( .unwrap_or("") .to_string(); + let (respond_to, respond_to_allowlist) = + summary_access_with_policy(record, super::internal_build()); Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), name: record.name.clone(), @@ -336,8 +343,8 @@ pub fn build_managed_agent_summary( start_on_app_launch: record.start_on_app_launch, auto_restart_on_config_change: record.auto_restart_on_config_change, log_path, - respond_to: record.respond_to, - respond_to_allowlist: record.respond_to_allowlist.clone(), + respond_to, + respond_to_allowlist, }) } @@ -381,44 +388,7 @@ pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, owner_hex: Option<&str>, ) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) + build_respond_to_env_with_policy(record, owner_hex, super::owner_only()) } pub(crate) fn configure_runtime_cli( @@ -1023,5 +993,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod test_fixtures; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs new file mode 100644 index 0000000000..1352bbbc18 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -0,0 +1,64 @@ +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +/// Construct a minimal record fixture for runtime tests. +pub(super) fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to, + respond_to_allowlist: allowlist, + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..5415439a6b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -117,73 +117,10 @@ fn unknown_command_returns_none() { // ── build_respond_to_env tests ─────────────────────────────────────── -use super::build_respond_to_env; +use super::test_fixtures::fixture; +use super::{build_respond_to_env, build_respond_to_env_with_policy}; use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; -/// Construct a minimal record fixture for env-building tests. Only the -/// fields read by `build_respond_to_env` matter here. -fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, -) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to, - respond_to_allowlist: allowlist, - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); @@ -237,6 +174,24 @@ fn build_env_anyone_omits_allowlist_var() { assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); } +#[test] +fn internal_policy_overrides_stale_anyone_record_at_runtime() { + let rec = fixture( + RespondTo::Anyone, + vec!["malformed stale allowlist".into()], + Some("tag".into()), + ); + let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); +} + #[test] fn build_env_legacy_record_without_auth_tag_emits_agent_owner() { let rec = fixture(RespondTo::OwnerOnly, vec![], None); @@ -275,6 +230,23 @@ fn build_env_rejects_empty_allowlist_in_allowlist_mode() { assert!(err.contains("at least one pubkey")); } +#[test] +fn internal_summary_access_clamps_stale_record() { + let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], Some("tag".into())); + + let (respond_to, allowlist) = super::summary_access_with_policy(&rec, true); + + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "internal summary widened stale access" + ); + assert!( + allowlist.is_empty(), + "internal summary retained a stale allowlist" + ); +} + // ── persona fixture helpers ───────────────────────────────────────── fn persona_with_provider( diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..787a9fa9f0 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -363,6 +363,9 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { let definitions = load_agent_definitions(app).unwrap_or_default(); let mut sorted = records.to_vec(); + for record in &mut sorted { + super::normalize_managed_agent_access(record); + } // A caller-supplied key-less record would collide with the definition // half re-read below; instances always carry a pubkey. sorted.retain(|record| !record.pubkey.is_empty()); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9222c7032..a44bc38f17 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -146,7 +146,10 @@ with a TypeScript lookup table or an id comparison in a component. shown; when it *is* remote they picked that host from the selector themselves. Never synthesize a run location a surface doesn't have. Don't expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI - copy. + copy. **Internal-build owner-only access is backend-independent.** When + `getAgentAccessOwnerOnly()` is true, every managed agent's access control is + locked to owner-only, including provider-backed agents. A provider backend + does not prove remote execution and must never create a policy carve-out. ## The tests that enforce this diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..b51da62ea8 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -27,6 +27,7 @@ import { discoverGitBashPrerequisite, discoverManagedAgentPrereqs, getAgentConfigSurface, + getAgentAccessOwnerOnly, getBakedBuildEnv, getBakedBuildEnvKeys, getChannelMembers, @@ -939,6 +940,20 @@ export function useRuntimeFileConfigQuery( export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const; export const bakedBuildEnvQueryKey = ["baked-build-env"] as const; +export const agentAccessOwnerOnlyQueryKey = [ + "agent-access-owner-only", +] as const; + +export function useAgentAccessOwnerOnlyQuery(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: agentAccessOwnerOnlyQueryKey, + queryFn: () => getAgentAccessOwnerOnly(), + enabled: options?.enabled ?? true, + staleTime: Infinity, + refetchInterval: false, + retry: false, + }); +} /** * Query safely displayable baked build env entries. The backend masks secrets, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 79d1e9a790..c00981102b 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { useAcpRuntimesQuery, + useAgentAccessOwnerOnlyQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, usePersonasQuery, @@ -62,9 +63,9 @@ import { type RuntimeModelProviderSelection, } from "./runtimeModelProviderSelection"; import { AgentCreationPreview } from "./AgentCreationPreview"; +import { InternalAgentAccessField } from "./InternalAgentAccessField"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { useRequiredCredentialState } from "./useRequiredCredentialState"; -import { CreateAgentRespondToField } from "./RespondToField"; import { PersonaDropdownField } from "./PersonaDropdownField"; import { MODEL_DISCOVERY_LOADING_VALUE, @@ -394,6 +395,9 @@ export function AgentInstanceEditDialog({ }); const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({ + enabled: open, + }); // Merge global env as the base layer so credential keys satisfied via global // config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use @@ -907,7 +911,6 @@ export function AgentInstanceEditDialog({ )}
- {/* Agent name */}
- - {/* Who can send instructions */} - - {/* Provider (runtime) */}