From f6488a7ae306884ef862bd37ab831c3098653eeb Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Fri, 31 Jul 2026 19:09:20 -0700 Subject: [PATCH 01/14] fix(desktop): enforce owner-only internal agent access Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src-tauri/build.rs | 7 + .../src-tauri/src/commands/agent_access.rs | 18 +++ .../src-tauri/src/commands/agents_deploy.rs | 9 +- .../src-tauri/src/commands/agents_tests.rs | 43 ++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/access_policy.rs | 142 ++++++++++++++++++ .../src-tauri/src/managed_agents/env_vars.rs | 7 +- .../src/managed_agents/env_vars/tests.rs | 6 +- desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 50 +----- .../managed_agents/runtime/test_fixtures.rs | 64 ++++++++ .../src/managed_agents/runtime/tests.rs | 95 ++++-------- desktop/src/features/agents/AGENTS.md | 5 +- desktop/src/features/agents/hooks.ts | 15 ++ .../agents/ui/AgentInstanceEditDialog.tsx | 14 +- .../agents/ui/InternalAgentAccessField.tsx | 35 +++++ .../agents/ui/PersonaAdvancedFields.tsx | 22 ++- .../src/features/agents/ui/RespondToField.tsx | 14 ++ .../channels/ui/EditRespondToDialog.tsx | 27 +++- .../features/onboarding/welcomeGuide.test.mjs | 20 +++ .../src/features/onboarding/welcomeGuide.ts | 44 ++++-- .../onboarding/welcomeKickoff.test.mjs | 41 +++++ .../src/features/onboarding/welcomeKickoff.ts | 22 ++- desktop/src/shared/api/tauriAgentAccess.ts | 4 + desktop/src/testing/e2eBridge.ts | 4 + desktop/tests/e2e/edit-agent.spec.ts | 79 ++++++++++ desktop/tests/helpers/bridge.ts | 1 + 28 files changed, 646 insertions(+), 147 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_access.rs create mode 100644 desktop/src-tauri/src/managed_agents/access_policy.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs create mode 100644 desktop/src/features/agents/ui/InternalAgentAccessField.tsx create mode 100644 desktop/src/shared/api/tauriAgentAccess.ts 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_access.rs b/desktop/src-tauri/src/commands/agent_access.rs new file mode 100644 index 0000000000..b56a4ec3d5 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -0,0 +1,18 @@ +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::internal_build() +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"] + fn compiled_policy_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"); + assert_eq!(super::agent_access_owner_only(), expected); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..f21b612771 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -149,11 +149,13 @@ pub(super) fn build_deploy_payload( effective.system_prompt.value, merged_user_env, launch, + crate::managed_agents::internal_build(), )) } /// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields /// remain for display/bookkeeping; providers execute the resolved `launch` block. +#[allow(clippy::too_many_arguments)] pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, @@ -162,7 +164,10 @@ pub(super) fn deploy_payload_json( effective_prompt: Option, merged_env: BTreeMap, launch: serde_json::Value, + 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, @@ -177,8 +182,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, "launch": launch, }) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..a456a42a30 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -411,6 +411,21 @@ 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(), + // Access projection is the subject here; the launch block is exercised + // by the shared provider fixture test below. + serde_json::Value::Null, + internal, + ) +} + /// The shared provider fixture is the contract arbiter: it must be the exact /// richest deploy request produced by the real desktop serializers. #[test] @@ -470,6 +485,9 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { None, std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), launch, + // Fixture asserts the record's own access fields survive, so the + // owner-only projection must be off for this comparison. + false, ); assert_eq!( @@ -501,3 +519,28 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { ); } } + +#[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/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..e8afddad54 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_access; mod agent_auth; mod agent_config; mod agent_discovery; @@ -61,6 +62,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..775e1860fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -814,6 +814,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..ebdebae690 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,142 @@ +//! Distribution policy at managed-agent enforcement boundaries. + +use super::{validate_respond_to_allowlist, 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 without changing the +/// stored or relay-advertised access fields. +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, normalized) = projected_access_with_policy(record, enforced_owner_only); + let normalized = if enforced_owner_only { + normalized + } else { + validate_respond_to_allowlist(&normalized)? + }; + 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 enforced_owner_only { + set.push(( + "BUZZ_ACP_ALLOWED_RESPOND_TO", + RespondTo::OwnerOnly.as_str().to_string(), + )); + } else { + remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO"); + } + 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)) +} + +#[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!["malformed stale allowlist".into()]; + record + } + + #[test] + fn internal_enforcement_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let record = record(backend); + let (gate_set, _) = + build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap(); + let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect(); + assert_eq!( + gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "internal runtime env did not clamp {label} agent", + ); + assert_eq!( + gate_set + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "internal runtime env omitted the {label} agent guard", + ); + + let (respond_to, allowlist) = projected_access_with_policy(&record, true); + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "internal provider payload did not clamp {label} agent", + ); + assert!( + allowlist.is_empty(), + "internal provider payload retained {label} agent allowlist", + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7f..28ee366f8a 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,11 +71,12 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", - // Security gates: respond-to mode + allowlist + legacy owner-only - // fallback. Overriding would make the running agent's gate diverge - // from the saved/UI-visible settings. + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", "BUZZ_ACP_AGENT_OWNER", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 534c2e0835..34cdfede2c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. // Overriding via env_vars would let the running agent answer // anyone even when the UI/record says owner-only. - for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] { + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "anyone")]); let merged = merged_user_env(&BTreeMap::new(), &agent); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..a4cff219d2 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,8 +1,10 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{internal_build, owner_only, projected_access_with_policy}; 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/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3173126b90..98a04e1f2a 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::{ @@ -32,8 +32,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::{ @@ -380,44 +378,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( @@ -1022,5 +983,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..887aa91e3e 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())); @@ -195,6 +132,8 @@ fn build_env_owner_only_sets_mode_and_removes_others() { ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO")); + assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); // auth_tag is present → no AGENT_OWNER fallback fires. assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } @@ -237,6 +176,32 @@ 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"), + "internal runtime env widened stale access", + ); + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "internal runtime env omitted the owner-only guard", + ); + 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); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f2eb7f285c..998aa07523 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -152,7 +152,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..7be7e91dcc 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -39,6 +39,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -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 adfb8182a8..598e9a2c63 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, @@ -63,9 +64,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 { RunOnSummarySection } from "./RunOnSummarySection"; import { PersonaDropdownField } from "./PersonaDropdownField"; import { @@ -392,6 +393,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 @@ -905,7 +909,6 @@ export function AgentInstanceEditDialog({ )}
- {/* Agent name */}
- - {/* Who can send instructions */} - - {/* Provider (runtime) */} diff --git a/desktop/src/features/agents/ui/InternalAgentAccessField.tsx b/desktop/src/features/agents/ui/InternalAgentAccessField.tsx new file mode 100644 index 0000000000..c1f0f84716 --- /dev/null +++ b/desktop/src/features/agents/ui/InternalAgentAccessField.tsx @@ -0,0 +1,35 @@ +import type { RespondToMode } from "@/shared/api/types"; +import { + CreateAgentRespondToField, + INTERNAL_AGENT_ACCESS_DISABLED_REASON, +} from "./RespondToField"; + +export function InternalAgentAccessField({ + accessLocked, + allowlist, + disabled, + mode, + onAllowlistChange, + onModeChange, +}: { + accessLocked: boolean; + allowlist: string[]; + disabled: boolean; + mode: RespondToMode; + onAllowlistChange: (allowlist: string[]) => void; + onModeChange: (mode: RespondToMode) => void; +}) { + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 1ecd98b83f..49b6765ea8 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -1,8 +1,12 @@ import * as React from "react"; +import { useAgentAccessOwnerOnlyQuery } from "../hooks"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; -import { CreateAgentRespondToField } from "./RespondToField"; +import { + CreateAgentRespondToField, + INTERNAL_AGENT_ACCESS_DISABLED_REASON, +} from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; import { isBuzzAgentRuntime, @@ -82,6 +86,11 @@ export function PersonaAdvancedFields({ */ selectedRuntime?: AcpRuntimeCatalogEntry; }) { + const { data: agentAccessOwnerOnly = false } = useAgentAccessOwnerOnlyQuery(); + const respondToMode = agentAccessOwnerOnly + ? "owner-only" + : (behaviorDraft.respondTo ?? "owner-only"); + // Numeric tuning descriptors — gate on catalog status so that loading/error // never collapses to "no controls": keys stay visible as generic rows. const numericDescriptors = React.useMemo( @@ -105,9 +114,14 @@ export function PersonaAdvancedFields({ return (
onBehaviorDraftChange({ ...behaviorDraft, diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index b32b9e0983..1d3777b7ff 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -72,6 +72,9 @@ const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ { label: "Selected people", value: "allowlist" }, ]; +export const INTERNAL_AGENT_ACCESS_DISABLED_REASON = + "This build limits agents to messages from you, so the access level cannot be changed."; + export function CreateAgentRespondToField({ mode, allowlist, @@ -79,6 +82,7 @@ export function CreateAgentRespondToField({ onAllowlistChange, ownerPubkey, disabled, + disabledReason, variant, runLocation, }: { @@ -93,6 +97,8 @@ export function CreateAgentRespondToField({ */ ownerPubkey?: string | null; disabled?: boolean; + /** Explanation shown when this access control is unavailable. */ + disabledReason?: string; /** When "persona", uses PersonaDropdownField styling to match the persona dialog. */ variant?: "default" | "persona"; /** @@ -219,6 +225,14 @@ export function CreateAgentRespondToField({ ))} )} + {disabledReason ? ( +

+ {disabledReason} +

+ ) : null} {mode === "anyone" ? accessWarning : null} {mode === "owner-only" ? (

diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index d9f091e902..227d450aa3 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -1,8 +1,14 @@ import * as React from "react"; -import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { + useAgentAccessOwnerOnlyQuery, + useUpdateManagedAgentMutation, +} from "@/features/agents/hooks"; import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; -import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; +import { + CreateAgentRespondToField, + INTERNAL_AGENT_ACCESS_DISABLED_REASON, +} from "@/features/agents/ui/RespondToField"; import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { @@ -25,6 +31,10 @@ export function EditRespondToDialog({ open: boolean; }) { const updateMutation = useUpdateManagedAgentMutation(); + const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({ + enabled: open, + }); + const accessLocked = agentAccessOwnerOnly === true; const [respondTo, setRespondTo] = React.useState("owner-only"); const [respondToAllowlist, setRespondToAllowlist] = React.useState( [], @@ -61,9 +71,12 @@ export function EditRespondToDialog({

- diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 77e9cc75e8..8f0d02a471 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -72,7 +72,7 @@ const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ { label: "Selected people", value: "allowlist" }, ]; -export const INTERNAL_AGENT_ACCESS_DISABLED_REASON = +export const OWNER_ONLY_ACCESS_DISABLED_REASON = "This build disallows changing this setting."; export function CreateAgentRespondToField({ diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index 227d450aa3..f5bf390519 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -7,7 +7,7 @@ import { import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; import { CreateAgentRespondToField, - INTERNAL_AGENT_ACCESS_DISABLED_REASON, + OWNER_ONLY_ACCESS_DISABLED_REASON, } from "@/features/agents/ui/RespondToField"; import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -74,7 +74,7 @@ export function EditRespondToDialog({ allowlist={accessLocked ? [] : respondToAllowlist} disabled={updateMutation.isPending || accessLocked} disabledReason={ - accessLocked ? INTERNAL_AGENT_ACCESS_DISABLED_REASON : undefined + accessLocked ? OWNER_ONLY_ACCESS_DISABLED_REASON : undefined } mode={accessLocked ? "owner-only" : respondTo} onAllowlistChange={setRespondToAllowlist} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6f320f04f6..3864e41aec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -441,8 +441,8 @@ type E2eConfig = { model: string | null; preferred_runtime?: string | null; }; - /** Explicit internal-distribution marker; independent of baked defaults. */ - internalBuild?: boolean; + /** Explicit owner-only agent-access capability; independent of baked defaults. */ + ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record; /** Baked build env returned by the display and key-name Tauri commands. */ @@ -11774,7 +11774,7 @@ export function maybeInstallE2eTauriMocks() { case "get_baked_build_env_keys": return (config?.mock?.bakedBuildEnv ?? []).map((entry) => entry.key); case "agent_access_owner_only": - return config?.mock?.internalBuild ?? false; + return config?.mock?.ownerOnlyAccessBuild ?? false; case "update_managed_agent": return handleUpdateManagedAgent( payload as Parameters[0], diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 6262fde394..5e9ecb548b 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -83,7 +83,7 @@ test.describe("agent definition dialog", () => { page, }) => { await installMockBridge(page, { - internalBuild: true, + ownerOnlyAccessBuild: true, bakedBuildEnv: BAKED_DEFAULTS, }); await page.goto("/"); @@ -110,7 +110,7 @@ test.describe("edit agent dialog", () => { page, }) => { await installMockBridge(page, { - internalBuild: true, + ownerOnlyAccessBuild: true, bakedBuildEnv: BAKED_DEFAULTS, managedAgents: [ { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 4bb793ed17..43edef66de 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -433,7 +433,7 @@ type MockBridgeOptions = { model: string | null; preferred_runtime?: string | null; }; - internalBuild?: boolean; + ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record< string, From f176e2d9f942bdbd0d866bdc4a99c00b968048da Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 22:52:36 -0700 Subject: [PATCH 09/14] refactor(desktop): finish owner-only access naming Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src-tauri/src/commands/agents_deploy.rs | 4 ++-- desktop/src-tauri/src/commands/agents_tests.rs | 13 ++++++++----- .../src-tauri/src/managed_agents/runtime/tests.rs | 6 +++--- desktop/src/features/agents/AGENTS.md | 2 +- .../src/features/onboarding/welcomeGuide.test.mjs | 4 ++-- .../src/features/onboarding/welcomeKickoff.test.mjs | 4 ++-- desktop/tests/e2e/edit-agent.spec.ts | 4 ++-- 7 files changed, 20 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 817a6bbde5..9a7d0022c4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -185,10 +185,10 @@ pub(super) fn deploy_payload_json( effective_prompt: Option, merged_env: BTreeMap, launch: serde_json::Value, - internal: bool, + owner_only_access: bool, ) -> serde_json::Value { let (respond_to, respond_to_allowlist) = - crate::managed_agents::projected_access_with_policy(record, internal); + crate::managed_agents::projected_access_with_policy(record, owner_only_access); serde_json::json!({ "name": &record.name, "relay_url": relay_url, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index c83c67f9e6..7d18e04520 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -411,7 +411,10 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── -fn deploy_payload_for_policy(record: &ManagedAgentRecord, internal: bool) -> serde_json::Value { +fn deploy_payload_for_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> serde_json::Value { deploy_payload_json( record, "wss://relay.example".to_string(), @@ -422,7 +425,7 @@ fn deploy_payload_for_policy(record: &ManagedAgentRecord, internal: bool) -> ser // Access projection is the subject here; the launch block is exercised // by the shared provider fixture test below. serde_json::Value::Null, - internal, + owner_only_access, ) } @@ -583,7 +586,7 @@ fn current_build_deploy_payload_forwards_compiled_policy() { } #[test] -fn internal_deploy_payload_clamps_stale_access() { +fn owner_only_access_deploy_payload_clamps_stale_access() { use crate::managed_agents::{BackendKind, RespondTo}; let mut record = bare_agent_record(None, None, None); @@ -598,11 +601,11 @@ fn internal_deploy_payload_clamps_stale_access() { assert_eq!( payload["respond_to"], "owner-only", - "internal deploy payload widened stale access" + "owner-only-access deploy payload widened stale access" ); assert_eq!( payload["respond_to_allowlist"], serde_json::json!([]), - "internal deploy payload retained a stale allowlist" + "owner-only-access deploy payload retained a stale allowlist" ); } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 75684959f8..ebc34fd9ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -193,7 +193,7 @@ fn build_env_anyone_omits_allowlist_var() { } #[test] -fn internal_policy_overrides_stale_anyone_record_at_runtime() { +fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() { let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], 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(); @@ -201,14 +201,14 @@ fn internal_policy_overrides_stale_anyone_record_at_runtime() { assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), Some("owner-only"), - "internal runtime env widened stale access", + "owner-only-access runtime env widened stale access", ); assert_eq!( set_map .get("BUZZ_ACP_ALLOWED_RESPOND_TO") .map(String::as_str), Some("owner-only"), - "internal runtime env omitted the owner-only guard", + "owner-only-access runtime env omitted the owner-only guard", ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 998aa07523..ae45bb99f5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -152,7 +152,7 @@ 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. **Internal-build owner-only access is backend-independent.** When + copy. **The owner-only-access build capability 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. diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index 7b7f7da7e8..7933f368e1 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -381,7 +381,7 @@ test("starter matching prefers running, then deployed instances", () => { ); }); -test("internal local Welcome teammates accept enforced owner-only access", () => { +test("owner-only-access policy accepts local Welcome teammates", () => { const teammate = makeAgent({ respondTo: "owner-only", respondToAllowlist: [], @@ -390,7 +390,7 @@ test("internal local Welcome teammates accept enforced owner-only access", () => assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false); }); -test("internal provider Welcome teammates accept enforced owner-only access", () => { +test("owner-only-access policy accepts provider Welcome teammates", () => { const teammate = makeAgent({ backend: { type: "provider", id: "remote", config: {} }, respondTo: "owner-only", diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 9f8ec02f02..736721434a 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -199,7 +199,7 @@ test("running teammates restart when their allowlist does not include the lead", ); }); -test("internal running local and provider teammates do not restart for enforced owner-only access", () => { +test("owner-only-access policy does not restart running local and provider teammates", () => { for (const backend of [ { type: "local" }, { type: "provider", id: "remote", config: {} }, @@ -222,7 +222,7 @@ test("internal running local and provider teammates do not restart for enforced } }); -test("internal running teammates still restart for runtime changes", () => { +test("owner-only-access policy still restarts running teammates for runtime changes", () => { assert.equal( welcomeTeammateNeedsRestart( { diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 5e9ecb548b..4c4834b552 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -79,7 +79,7 @@ async function pickDropdownOption( } test.describe("agent definition dialog", () => { - test("internal build shows disabled agent access with an explanation", async ({ + test("owner-only-access build shows disabled agent access with an explanation", async ({ page, }) => { await installMockBridge(page, { @@ -106,7 +106,7 @@ test.describe("agent definition dialog", () => { }); test.describe("edit agent dialog", () => { - test("internal build shows a disabled owner-only access control with an explanation", async ({ + test("owner-only-access build shows a disabled owner-only access control with an explanation", async ({ page, }) => { await installMockBridge(page, { From 99685b52b7daa7e385eca3db84c8fb916c709de1 Mon Sep 17 00:00:00 2001 From: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 08:21:53 -0700 Subject: [PATCH 10/14] refactor(desktop): move owner-only access query to its own module The merge with main pushed desktop/src/features/agents/hooks.ts past the 1000-line file-size ratchet. Move the owner-only access query into a dedicated module, which keeps hooks.ts under the limit and matches the existing one-hook-per-file pattern next to it. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src/features/agents/hooks.ts | 16 ------------ .../agents/ui/AgentInstanceEditDialog.tsx | 2 +- .../agents/ui/PersonaAdvancedFields.tsx | 2 +- .../agents/useAgentAccessOwnerOnly.ts | 25 +++++++++++++++++++ .../channels/ui/EditRespondToDialog.tsx | 6 ++--- .../src/features/onboarding/welcomeKickoff.ts | 2 +- 6 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/agents/useAgentAccessOwnerOnly.ts diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ce54d1d4e8..b3d4c5315e 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -40,7 +40,6 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; -import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -947,21 +946,6 @@ 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, * so this is only used for inherited provider/model/effort labels. diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 6598828c4e..f7ee098833 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -5,13 +5,13 @@ import { toast } from "sonner"; import { useAcpRuntimesQuery, - useAgentAccessOwnerOnlyQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, usePersonasQuery, useStartManagedAgentMutation, useUpdateManagedAgentMutation, } from "@/features/agents/hooks"; +import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { ManagedAgent, diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index e438368bf5..57cd9952e4 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useAgentAccessOwnerOnlyQuery } from "../hooks"; +import { useAgentAccessOwnerOnlyQuery } from "../useAgentAccessOwnerOnly"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; diff --git a/desktop/src/features/agents/useAgentAccessOwnerOnly.ts b/desktop/src/features/agents/useAgentAccessOwnerOnly.ts new file mode 100644 index 0000000000..d5de995334 --- /dev/null +++ b/desktop/src/features/agents/useAgentAccessOwnerOnly.ts @@ -0,0 +1,25 @@ +/** + * React hook: report whether this build forces owner-only agent access. + * + * The value is baked at build time, so it cannot change while the app runs. + * The query key is stable and the result never goes stale, which keeps one + * fetch per QueryClient lifetime and gives every caller the same answer. + */ +import { useQuery } from "@tanstack/react-query"; + +import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess"; + +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, + }); +} diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index f5bf390519..d3c3df339b 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -1,9 +1,7 @@ import * as React from "react"; -import { - useAgentAccessOwnerOnlyQuery, - useUpdateManagedAgentMutation, -} from "@/features/agents/hooks"; +import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; import { CreateAgentRespondToField, diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 1a1963628a..f46aeb3b21 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -3,9 +3,9 @@ import * as React from "react"; import { managedAgentsQueryKey, useAcpRuntimesQuery, - useAgentAccessOwnerOnlyQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; import { useCommunities } from "@/features/communities/useCommunities"; From dfad534440e87578e59a7c44f24cf81f31883c43 Mon Sep 17 00:00:00 2001 From: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 09:51:58 -0700 Subject: [PATCH 11/14] fix(desktop): converge Welcome teammate access remediation `provisionWelcomeTeam` decided whether a teammate needed an access write with `welcomeTeammateHasExpectedAccess`, which requires `owner-only` and an empty allowlist under the owner-only-access build policy, but then wrote `allowlist:[lead]` unconditionally. An upgraded install with pre-existing allowlisted teammates therefore rewrote state the predicate rejects on every provisioning pass, and `welcomeTeammateNeedsRestart` kept restarting teammates that were already running. Extract `welcomeTeammateAccessUpdate`, which returns the write that satisfies the predicate for the current build (or null when the teammate is already correct), and use it for the decision and the write so the two cannot drift again. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .../features/onboarding/welcomeGuide.test.mjs | 53 +++++++++++++++++++ .../src/features/onboarding/welcomeGuide.ts | 42 ++++++++++++--- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index 7933f368e1..59af9c75d3 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -9,6 +9,7 @@ import { pickWelcomeGuideAgentForRelay, pickWelcomeTeamStarterAgentForRelay, welcomeStarterRuntimeUpdate, + welcomeTeammateAccessUpdate, welcomeTeammateHasExpectedAccess, WELCOME_GUIDE_AGENT_NAME, WELCOME_GUIDE_PERSONA_ID, @@ -390,6 +391,58 @@ test("owner-only-access policy accepts local Welcome teammates", () => { assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false); }); +test("access remediation converges for an upgraded owner-only install", () => { + // Pre-existing installs allowlisted the lead. An owner-only build must move + // them to owner-only, and the write it makes must satisfy the predicate, so + // the next provisioning pass makes no further write. + const allowlisted = makeAgent({ + respondTo: "allowlist", + respondToAllowlist: [PUB_B], + }); + const update = welcomeTeammateAccessUpdate(allowlisted, PUB_B, true); + assert.deepEqual(update, { + pubkey: PUB_A, + respondTo: "owner-only", + respondToAllowlist: [], + }); + const remediated = makeAgent({ + respondTo: update.respondTo, + respondToAllowlist: update.respondToAllowlist, + }); + assert.equal(welcomeTeammateHasExpectedAccess(remediated, PUB_B, true), true); + assert.equal(welcomeTeammateAccessUpdate(remediated, PUB_B, true), null); +}); + +test("access remediation allowlists the lead when the build is not owner-only", () => { + const ownerOnly = makeAgent({ + respondTo: "owner-only", + respondToAllowlist: [], + }); + const update = welcomeTeammateAccessUpdate(ownerOnly, PUB_B, false); + assert.deepEqual(update, { + pubkey: PUB_A, + respondTo: "allowlist", + respondToAllowlist: [PUB_B], + }); + const remediated = makeAgent({ + respondTo: update.respondTo, + respondToAllowlist: update.respondToAllowlist, + }); + assert.equal( + welcomeTeammateHasExpectedAccess(remediated, PUB_B, false), + true, + ); + assert.equal(welcomeTeammateAccessUpdate(remediated, PUB_B, false), null); +}); + +test("access remediation skips a teammate that already allows the lead", () => { + const allowlisted = makeAgent({ + respondTo: "allowlist", + respondToAllowlist: [PUB_B, PUB_C], + }); + assert.equal(welcomeTeammateAccessUpdate(allowlisted, PUB_B, false), null); +}); + test("owner-only-access policy accepts provider Welcome teammates", () => { const teammate = makeAgent({ backend: { type: "provider", id: "remote", config: {} }, diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 905b71a75f..82b6886d74 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -18,6 +18,7 @@ import type { AgentPersona, CreateManagedAgentInput, ManagedAgent, + UpdateManagedAgentInput, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -282,6 +283,37 @@ export function welcomeTeammateHasExpectedAccess( ); } +/** + * The access write that moves a Welcome teammate to the state this build + * expects, or null when it is already there. The remediation target must track + * {@link welcomeTeammateHasExpectedAccess}: writing `allowlist:[lead]` in an + * owner-only build would fail the predicate again on the next provisioning + * pass, so an upgraded install with pre-existing allowlisted teammates would + * rewrite the same rejected state forever and keep restarting them. + */ +export function welcomeTeammateAccessUpdate( + teammate: ManagedAgent, + leadPubkey: string, + agentAccessOwnerOnly: boolean, +): UpdateManagedAgentInput | null { + if ( + welcomeTeammateHasExpectedAccess(teammate, leadPubkey, agentAccessOwnerOnly) + ) { + return null; + } + return agentAccessOwnerOnly + ? { + pubkey: teammate.pubkey, + respondTo: "owner-only", + respondToAllowlist: [], + } + : { + pubkey: teammate.pubkey, + respondTo: "allowlist", + respondToAllowlist: [leadPubkey], + }; +} + /** * Ensure the complete built-in Welcome Team is ready for kickoff. * The team itself is Rust-seeded; this only activates personas, creates any @@ -346,17 +378,13 @@ async function provisionWelcomeTeam( const leadPubkey = lead.pubkey; for (const index of [1, 2] as const) { const teammate = welcomeAgents[index]; - const alreadyAllowsLead = welcomeTeammateHasExpectedAccess( + const accessUpdate = welcomeTeammateAccessUpdate( teammate, leadPubkey, agentAccessOwnerOnly, ); - if (!alreadyAllowsLead) { - const updated = await updateManagedAgent({ - pubkey: teammate.pubkey, - respondTo: "allowlist", - respondToAllowlist: [leadPubkey], - }); + if (accessUpdate) { + const updated = await updateManagedAgent(accessUpdate); welcomeAgents[index] = updated.agent; } } From 34f8ac823661d55a7003318e184e264afffdeb73 Mon Sep 17 00:00:00 2001 From: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 09:52:10 -0700 Subject: [PATCH 12/14] fix(desktop): refuse reserved keys in the baked build env The baked `BUZZ_BUILD_AGENT_ENV` pairs are written into a spawned agent's environment last, after Desktop sets the access gates and identity vars, and nothing filtered them. A build packaged with `BUZZ_ACP_RESPOND_TO` or `BUZZ_ACP_ALLOWED_RESPOND_TO` set to `anyone` therefore produced agents that answer anyone while the UI shows the access locked to "Only me", so the build capability could disable its own enforcement. Reject reserved keys where the value enters the system, in `build.rs`, so such a build fails instead of shipping, and keep a runtime filter in `baked_build_env()` for a binary built without that check. The key list is `include!`d from one source into both consumers, matching the existing `reconnect_hook_config.rs` pattern, because a build script cannot import from the crate and the two copies must not drift. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src-tauri/build.rs | 17 +++++ .../buzz-terminal/src/env_fence_tests.rs | 5 +- .../src-tauri/src/managed_agents/agent_env.rs | 75 +++++++++++++++++++ .../src-tauri/src/managed_agents/env_vars.rs | 66 +--------------- .../src/managed_agents/reserved_env_keys.rs | 74 ++++++++++++++++++ 5 files changed, 173 insertions(+), 64 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/reserved_env_keys.rs diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0d05b335ff..2cdd785c73 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,9 @@ // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); +// Same source of truth the runtime filters with, so a baked build env cannot +// carry a reserved key the runtime believes it already rejected. +include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; @@ -66,6 +69,20 @@ fn main() { line ); } + // The baked env is written into every spawned agent's environment + // LAST (see `managed_agents/runtime.rs`), after Buzz sets the + // access gates and identity vars. A baked reserved key would + // therefore silently override the gate the UI promises, so reject + // it at build time instead of shipping a binary that bypasses its + // own enforcement. + if is_reserved_env_key(key) { + panic!( + "BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \ + into a build (it would override Buzz's own identity/access env)", + line_no + 1, + key + ); + } } let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}"); diff --git a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs index 6d99337c4b..59e94a1f63 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs @@ -330,7 +330,10 @@ fn child_shell_is_the_resolved_shell_not_the_inherited_one() { /// grows a new secret, this points at the file to update. #[test] fn reserved_keys_are_covered() { - let source = include_str!("../../../src/managed_agents/env_vars.rs"); + // The list lives in its own file because `build.rs` `include!`s the same + // source (see `managed_agents/reserved_env_keys.rs`); read it there rather + // than through the module that includes it. + let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs"); let declared: Vec<&str> = source .lines() .skip_while(|line| !line.contains("RESERVED_ENV_KEYS")) diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index bf6bcb2298..05979e76cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -58,6 +58,19 @@ fn build_env_map( } } } + // Defense in depth. `build.rs` already refuses to bake a reserved key, so + // reaching this filter means the binary was produced by a build that + // skipped that check. Drop the key rather than let it override the access + // gate: the baked map is written into the spawned agent's environment last + // (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would + // otherwise win over the gate Desktop just set. + map.retain(|key, _| { + if super::env_vars::is_reserved_env_key(key) { + eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env"); + return false; + } + true + }); map } @@ -356,4 +369,66 @@ mod tests { "unrelated merged_env keys must pass through unchanged" ); } + + // ── baked reserved-key filtering ────────────────────────────────────── + // + // The baked map is written into a spawned agent's environment LAST (see + // `managed_agents/runtime.rs`), after Buzz sets the access gates. If a + // baked reserved key survived here, an internal build packaged with + // `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows + // "Only me". `build.rs` rejects such a key at build time; these tests pin + // the runtime backstop for a binary built without that check. + + #[test] + fn build_env_map_drops_baked_access_gate_keys() { + use base64::Engine as _; + let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!( + !map.contains_key(key), + "baked `{key}` must not reach the spawned agent env" + ); + } + assert_eq!( + map.get("DATABRICKS_MODEL").map(String::as_str), + Some("goose-claude-opus-4-8"), + "non-reserved baked keys must still pass through" + ); + } + + #[test] + fn build_env_map_drops_baked_reserved_keys_case_insensitively() { + use base64::Engine as _; + // `is_reserved_env_key` compares case-insensitively, and so must the + // baked filter: env lookup is case-sensitive on Unix, but a lowercase + // spelling would still be a reserved key smuggled past a case-sensitive + // check on Windows. + let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "reserved keys in any casing must be dropped from the baked env: {map:?}" + ); + } + + #[test] + fn build_env_map_drops_every_reserved_key() { + use base64::Engine as _; + for key in super::super::env_vars::RESERVED_ENV_KEYS { + let raw = format!("{key}=baked-value"); + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "baked reserved key `{key}` must be dropped, got {map:?}" + ); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 9b7ed804bc..8484801c3b 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -39,69 +39,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { .any(|k| k.eq_ignore_ascii_case(key)) } -/// Env var keys that Buzz sets itself and users must not override from -/// the persona/agent env_vars UI. Three categories: -/// -/// 1. **Identity / secrets** — overriding would swap the agent's nsec or -/// leak credentials. -/// 2. **Code-execution surface** — overriding the binary/args lets the -/// user run arbitrary code as the agent process. -/// 3. **Security gates** — overriding the respond-to mode/allowlist or -/// relay URL would silently break the saved security settings (the UI -/// shows owner-only while the running agent answers anyone, for -/// example), or redirect the agent to an attacker-controlled relay. -/// -/// This list is deliberately narrow — it only covers keys with security -/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely -/// overridable; those have dedicated UI fields but power users may want -/// to bypass them. -pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ - // Identity / secrets. - "BUZZ_PRIVATE_KEY", - "NOSTR_PRIVATE_KEY", - "BUZZ_AUTH_TAG", - "BUZZ_API_TOKEN", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - // Relay URL: overriding would let a malicious config redirect the - // agent to an attacker-controlled relay. - "BUZZ_RELAY_URL", - // Code-execution surface: overriding would let the user run arbitrary - // binaries/args as the agent process. - "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_ARGS", - "BUZZ_ACP_MCP_COMMAND", - // Security gates: respond-to mode + allowlist + deployment allowlist + - // legacy owner-only fallback. Overriding would make the running agent's - // gate diverge from the saved/UI-visible settings. - "BUZZ_ACP_RESPOND_TO", - "BUZZ_ACP_RESPOND_TO_ALLOWLIST", - "BUZZ_ACP_ALLOWED_RESPOND_TO", - "BUZZ_ACP_AGENT_OWNER", - // Stable agent identity used for git attribution and private-conversation - // provenance must come from the managed-agent record, not user overrides. - "BUZZ_ACP_DISPLAY_NAME", - // Remote lifetime/presence policy: user env must not disable the - // desktop/provider-owned bounds while the saved record still promises them. - "BUZZ_ACP_EXIT_AFTER_INACTIVITY", - "BUZZ_ACP_NO_PRESENCE", - // Readiness handoff: desktop is the ONLY readiness source. A saved or - // ambient env var must not be able to forge setup mode (NotReady) on a - // Ready agent or suppress it (empty/stale payload) on a NotReady one. - "BUZZ_ACP_SETUP_PAYLOAD", - // Desktop ownership markers: these brand every spawned harness with the - // launching Desktop instance. A user-supplied override would let a - // definition masquerade as a different instance or fake the nonce used - // for same-session sweep decisions. - "BUZZ_MANAGED_AGENT", - "BUZZ_MANAGED_AGENT_START_NONCE", -]; - -pub(crate) fn is_reserved_env_key(key: &str) -> bool { - RESERVED_ENV_KEYS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) -} +// Canonical reserved-key list + predicate, shared verbatim with `build.rs`. +// See `reserved_env_keys.rs` for why this is `include!`d rather than a module. +include!("reserved_env_keys.rs"); /// Returns true if `key` is a well-formed POSIX-shaped env var name: /// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs new file mode 100644 index 0000000000..82ec63a862 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -0,0 +1,74 @@ +// Canonical reserved-env-key list, `include!`d into BOTH `build.rs` +// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and +// `managed_agents/env_vars.rs` (save-time validation and spawn-time +// filtering). Build scripts cannot import from the crate, so sharing the +// source via `include!` is what guarantees the build-time check and the +// runtime filter use one identical list — zero drift surface. See +// `commands/reconnect_hook_config.rs` for the same pattern. +// +// Keep this file dependency-free: no crate-internal imports, no external +// crates. Both consumers compile it as-is. + +/// Env var keys that Buzz sets itself and users must not override from +/// the persona/agent env_vars UI. Three categories: +/// +/// 1. **Identity / secrets** — overriding would swap the agent's nsec or +/// leak credentials. +/// 2. **Code-execution surface** — overriding the binary/args lets the +/// user run arbitrary code as the agent process. +/// 3. **Security gates** — overriding the respond-to mode/allowlist or +/// relay URL would silently break the saved security settings (the UI +/// shows owner-only while the running agent answers anyone, for +/// example), or redirect the agent to an attacker-controlled relay. +/// +/// This list is deliberately narrow — it only covers keys with security +/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +/// overridable; those have dedicated UI fields but power users may want +/// to bypass them. +pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ + // Identity / secrets. + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + // Relay URL: overriding would let a malicious config redirect the + // agent to an attacker-controlled relay. + "BUZZ_RELAY_URL", + // Code-execution surface: overriding would let the user run arbitrary + // binaries/args as the agent process. + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", + // Desktop ownership markers: these brand every spawned harness with the + // launching Desktop instance. A user-supplied override would let a + // definition masquerade as a different instance or fake the nonce used + // for same-session sweep decisions. + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +pub(crate) fn is_reserved_env_key(key: &str) -> bool { + RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) +} From bf9f0c40da59573c7670f0baf70fe99dd074a1a8 Mon Sep 17 00:00:00 2001 From: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 09:52:20 -0700 Subject: [PATCH 13/14] docs(desktop): state the owner-only access boundary and its limits Record what the owner-only-access build capability enforces (local spawn and every deploy payload this build serializes) and what it does not: a provider deployment created by an unmarked build keeps its wider remote access until it is next deployed from a marked build, even though this build's UI shows that agent locked to "Only me". Also state that the harness gate admits the owner and every verified same-owner sibling agent, which is the intended boundary and what the built-in Welcome team depends on, so a later reader does not mistake it for a hole in the clamp. Co-authored-by: Tom Brow Signed-off-by: Tom Brow Co-authored-by: Amp Ai-assisted: true --- .../src/managed_agents/access_policy.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs index b75a5f7efb..f96ffdc874 100644 --- a/desktop/src-tauri/src/managed_agents/access_policy.rs +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -1,4 +1,50 @@ //! Distribution policy at managed-agent enforcement boundaries. +//! +//! ## What this build capability guarantees, and what it does not +//! +//! `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` marks a build whose managed agents may +//! answer only their owner. Enforcement is applied at the two boundaries where +//! Desktop hands access to something that runs the agent, and nowhere else. The +//! stored record and its relay-advertised access fields are left untouched, so +//! the same profile keeps its user-chosen access when it is opened in an OSS +//! build. +//! +//! Enforced: +//! +//! - **Local spawn.** [`build_respond_to_env_with_policy`] clamps +//! `BUZZ_ACP_RESPOND_TO` to `owner-only` and pins the independent +//! `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only` guard on every start, whatever +//! the record says. +//! - **Provider deploy payload.** [`projected_access_with_policy`] projects +//! owner-only into every deploy payload this build serializes, for both +//! backends. +//! +//! Not enforced, deliberately: +//! +//! - **A provider deployment that already exists is not reconciled at upgrade +//! time.** The projection above only reaches the provider when Desktop builds +//! a new payload: create-with-deploy, or an explicit Start/deploy of that +//! agent. A record with a `backend_agent_id` stays represented as deployed +//! across restarts without redeploying it, so an agent deployed from an +//! unmarked build with `anyone` or an allowlist keeps that wider access +//! remotely until it is next deployed from a marked build, while this build's +//! UI shows its access locked to "Only me". Rolling existing deployments +//! under the policy needs a rollout reconciliation pass (redeploy or +//! fail-closed on mismatch) that this capability does not attempt. +//! +//! ## "owner-only" is owner plus verified same-owner sibling agents +//! +//! The harness gate this projection targets admits the human owner *and* every +//! cryptographically NIP-OA-verified agent that shares that owner (see +//! `crates/buzz-acp/src/lib.rs`). That is the intended boundary, not an +//! oversight: an owner's own agents are inside their trust boundary, and Buzz's +//! built-in Welcome team relies on it, because the lead instructs its teammates +//! while every teammate is created owner-only (see +//! `welcomeTeammateHasExpectedAccess` in +//! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of +//! "owner-only" in this module as `owner ∪ verified same-owner agents`. Buzz's +//! own user-facing copy for the setting is narrower than that, which is a copy +//! question tracked outside this module, not a difference in enforcement. use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; From 579e9b10d389ba2202daac074b587fc225a5cc84 Mon Sep 17 00:00:00 2001 From: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 10:21:54 -0700 Subject: [PATCH 14/14] fix(desktop): say the owner-only line admits the owner's agents The line under Only me read "Only you can send instructions.", but the harness gate admits the owner and every cryptographically verified same-owner agent, and the built-in Welcome team depends on that: the lead instructs its teammates while each teammate is created owner-only. The line therefore promised a narrower boundary than the one enforced, and this PR makes that boundary invariant in internal builds, so the copy has to be accurate. Say "Only you and your agents can send instructions." instead. The dropdown label stays "Only me", which is the audience the user picks and what that option has meant since before agents could instruct each other. A contract test pins the new line, and the access_policy.rs module docs now cite the copy rather than calling it a separate open question. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src-tauri/src/managed_agents/access_policy.rs | 7 ++++--- desktop/src/features/agents/ui/RespondToField.tsx | 8 +++++++- .../features/agents/ui/respondToFieldContract.test.mjs | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs index f96ffdc874..c00ad2f1ca 100644 --- a/desktop/src-tauri/src/managed_agents/access_policy.rs +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -42,9 +42,10 @@ //! while every teammate is created owner-only (see //! `welcomeTeammateHasExpectedAccess` in //! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of -//! "owner-only" in this module as `owner ∪ verified same-owner agents`. Buzz's -//! own user-facing copy for the setting is narrower than that, which is a copy -//! question tracked outside this module, not a difference in enforcement. +//! "owner-only" in this module as `owner ∪ verified same-owner agents`. The +//! setting's own copy says so: the line under Only me reads "Only you and your +//! agents can send instructions." (`RespondToField.tsx`). The dropdown label +//! stays "Only me", which is the audience the user picks. use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 8f0d02a471..589d4c8c7a 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -39,6 +39,12 @@ import type { PersonaDropdownOption } from "./agentConfigOptions"; * than an explanation, and stays one sentence — Only me already owns the line * below the control. * + * The line below Only me says "Only you and your agents", because the harness + * gate admits the owner and every verified same-owner agent, not the owner + * alone (see `managed_agents/access_policy.rs`). The dropdown label stays + * "Only me": it is the audience the user picks, and it has meant this since + * before agents could instruct each other. + * * Which machine and stakes it names follow the optional `runLocation` prop, and * an unknown location falls back to the local wording rather than hedging with * "computer or server" — see `lib/agentAccessWarning.ts` for the copy and the @@ -236,7 +242,7 @@ export function CreateAgentRespondToField({ {mode === "anyone" ? accessWarning : null} {mode === "owner-only" ? (

- Only you can send instructions. + Only you and your agents can send instructions.

) : null} {mode === "allowlist" ? ( diff --git a/desktop/src/features/agents/ui/respondToFieldContract.test.mjs b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs index c3efd34650..91b4c6d1f6 100644 --- a/desktop/src/features/agents/ui/respondToFieldContract.test.mjs +++ b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs @@ -55,6 +55,16 @@ test("the warning copy comes from the shared helper, not inline text", () => { assert.match(collapsedSource, /

]*> \{warningText\}/); }); +test("the Only me line names the owner's agents, not the owner alone", () => { + // The harness gate admits the owner and every verified same-owner agent + // (`managed_agents/access_policy.rs`), and the built-in Welcome team depends + // on that, so a line promising the owner alone would overstate the boundary. + assert.match( + collapsedSource, + /mode === "owner-only" \? \( ]*> Only you and your agents can send instructions\./, + ); +}); + test("primary respond-to copy does not expose implementation jargon", () => { const primaryFieldSource = respondToFieldSource.slice( respondToFieldSource.indexOf('data-testid="agent-respond-to"'),