Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"BUZZ_ACP_DISPLAY_NAME": "worker",
"BUZZ_ACP_LAZY_POOL": "true",
"BUZZ_ACP_MODEL": "gpt-5",
"BUZZ_ACP_PERMISSION_POLICY": "ask",
"BUZZ_ACP_RELAY_OBSERVER": "true",
"BUZZ_ACP_SESSION_TITLE": "worker",
"GOOSE_MODE": "auto"
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ fn agent_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
Expand Down
12 changes: 12 additions & 0 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,18 @@ pub async fn update_managed_agent(
record.respond_to_allowlist = prospective_allowlist;
}

// Per-agent permission policy. `None` = clear override; remote agents are read-only.
if let Some(policy_opt) = input.permission_policy {
if matches!(
record.backend,
crate::managed_agents::BackendKind::Provider { .. }
) && record.backend_agent_id.is_some()
{
return Err("permission_policy is read-only while the agent is deployed remotely; shut down and redeploy to change it".to_string());
}
record.permission_policy = policy_opt;
}

record.updated_at = now_iso();

save_managed_agents(&app, &records)?;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ pub async fn create_managed_agent(
} else {
relay_mesh.clone()
},
permission_policy: None, // inherits global default or built-in `ask`
};

records.push(record);
Expand Down
21 changes: 21 additions & 0 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ pub(super) fn build_launch_block(
policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value);
}

// Permission policy: injected into policy_env so the remote process uses the
// same resolved value as a local spawn. Because deployed remote agents are
// read-only for this field (changing it requires shutdown + redeploy), the
// value here is always the record's own field falling back to the built-in
// (`ask`). The global config is intentionally not consulted for remote deploy —
// the global config is a desktop-local setting, not a per-record contract.
{
// For remote deploys we resolve directly from the record + built-in.
// The global config is not available here (it's a desktop-local fallback);
// an empty GlobalAgentConfig has no permission_policy so only the record
// and built-in are consulted — correct for a remote agent whose lifetime
// outlasts the spawning desktop session.
let remote_policy = record
.permission_policy
.unwrap_or_else(crate::managed_agents::permission_policy::PermissionPolicy::desktop_default);
policy_env.insert(
"BUZZ_ACP_PERMISSION_POLICY".into(),
remote_policy.as_str().to_string(),
);
}

serde_json::json!({
"command": descriptor.command,
"args": descriptor.args,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn bare_agent_record(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
permission_policy: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn make_agent(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
permission_policy: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ fn local_agent() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ pub async fn confirm_agent_snapshot_import(
relay_mesh: None,
runtime: snapshot.definition.runtime.clone(),
name_pool: snapshot.definition.name_pool.clone(),
permission_policy: None,
};

records.push(record.clone());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ pub async fn confirm_team_snapshot_import(
definition_respond_to_allowlist: definition.respond_to_allowlist.clone(),
definition_parallelism: minted_parallelism,
relay_mesh: None,
permission_policy: None,
runtime: member.definition.runtime.clone(),
name_pool: member.definition.name_pool.clone(),
};
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
runtime: None,
name_pool: vec![],
};
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/agent_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ fn minimal_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ fn test_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
agent_command_override: None,
persona_source_version: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ fn record_with(
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ fn record(
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
permission_policy: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/managed_agents/global_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ pub struct GlobalAgentConfig {
/// Preferred ACP runtime for definitions without an explicit runtime.
#[serde(default)]
pub preferred_runtime: Option<String>,
/// Fleet-wide permission policy default. `None` = use the built-in
/// desktop default (`ask`). Per-agent `permission_policy` takes precedence.
///
/// Semantics match the per-agent field: `ask` shows the Allow/Deny card,
/// `allow` auto-approves the unique `allow_once` option (explicit opt-in
/// only), `reject` auto-denies.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_policy: Option<crate::managed_agents::permission_policy::PermissionPolicy>,
}

/// Validate a `GlobalAgentConfig` before persisting it.
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/managed_agents/global_config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ fn roundtrip_serialization() {
provider: Some("anthropic".to_string()),
model: Some("claude-opus-4".to_string()),
preferred_runtime: Some("claude".to_string()),
permission_policy: None,
};
let json = serde_json::to_string(&config).expect("serialize");
let back: GlobalAgentConfig = serde_json::from_str(&json).expect("deserialize");
Expand Down Expand Up @@ -348,6 +349,7 @@ fn bare_record() -> ManagedAgentRecord {
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
permission_policy: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
Expand Down Expand Up @@ -592,6 +594,7 @@ fn populated_global_config_round_trips() {
provider: Some("anthropic".to_string()),
model: Some("claude-opus-4-5".to_string()),
preferred_runtime: None,
permission_policy: None,
};
let json = serde_json::to_string(&original).expect("serialization must not fail");
let decoded: GlobalAgentConfig =
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod agent_env;
pub(crate) mod agent_events;
pub(crate) mod agent_snapshot;
pub(crate) mod agent_snapshot_envelope;
pub(crate) mod permission_policy;
pub(crate) mod team_snapshot;
pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy};
pub(crate) use agent_env::{
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/nest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/parallelism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
82 changes: 82 additions & 0 deletions desktop/src-tauri/src/managed_agents/permission_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Permission policy enum, source attribution, and the precedence resolver.
//!
//! `BUZZ_ACP_PERMISSION_POLICY` is in `RESERVED_ENV_KEYS` so users cannot
//! override it via the env-vars UI — a manual override would make the running
//! harness use a different policy than the saved/UI-visible setting.

use serde::{Deserialize, Serialize};

use super::types::ManagedAgentRecord;

/// How the agent answers `session/request_permission` requests.
///
/// - `Ask` — show an Allow/Deny card; auto-deny after 300 s (desktop default).
/// - `Allow` — auto-select the unique `allow_once` option; explicit opt-in.
/// - `Reject` — deny immediately; headless/CLI default.
///
/// Wire format is lowercase to match the harness CLI vocabulary and the
/// `BUZZ_ACP_PERMISSION_POLICY` env var the harness reads.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionPolicy {
Ask,
Allow,
Reject,
}

impl PermissionPolicy {
/// The env-var wire string consumed by the harness
/// (`BUZZ_ACP_PERMISSION_POLICY`).
pub fn as_str(self) -> &'static str {
match self {
Self::Ask => "ask",
Self::Allow => "allow",
Self::Reject => "reject",
}
}

/// The built-in desktop default: show the Allow/Deny card.
///
/// Headless / bare-CLI callers use `Reject` — they never have a UI to
/// answer a card. The desktop injects the resolved effective policy so
/// headless sessions spawned by the desktop still pick up the user's
/// choice.
pub fn desktop_default() -> Self {
Self::Ask
}
}

/// Where the effective [`PermissionPolicy`] came from. Serialized as a
/// `snake_case` string for TypeScript's exhaustive-switch pattern.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionPolicySource {
/// Set explicitly on this agent record.
Agent,
/// Inherited from the global agent config.
GlobalDefault,
/// Neither per-agent nor global is set; using the built-in desktop default.
BuiltIn,
}

/// Resolve the effective permission policy for an agent.
///
/// Precedence (highest first):
/// 1. `record.permission_policy` — per-agent override.
/// 2. `global.permission_policy` — fleet-wide default.
/// 3. [`PermissionPolicy::desktop_default`] — built-in.
pub fn resolve_effective_permission_policy(
record: &ManagedAgentRecord,
global: &super::global_config::GlobalAgentConfig,
) -> (PermissionPolicy, PermissionPolicySource) {
if let Some(policy) = record.permission_policy {
return (policy, PermissionPolicySource::Agent);
}
if let Some(policy) = global.permission_policy {
return (policy, PermissionPolicySource::GlobalDefault);
}
(
PermissionPolicy::desktop_default(),
PermissionPolicySource::BuiltIn,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ mod tests {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
permission_policy: None,
};

let runtime = known_acp_runtime_exact("buzz-agent");
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/managed_agents/reserved_env_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
// for same-session sweep decisions.
"BUZZ_MANAGED_AGENT",
"BUZZ_MANAGED_AGENT_START_NONCE",
// Permission policy gate: Desktop resolves the effective policy
// (per-agent > global > built-in) and injects it here. A user-supplied
// override would make the running harness use a different policy than the
// saved/UI-visible setting — exactly the truthfulness failure #4938 fixes.
"BUZZ_ACP_PERMISSION_POLICY",
];

pub(crate) fn is_reserved_env_key(key: &str) -> bool {
Expand Down
Loading