From d8be3386ec606bf0a710ee21a9b990926a3ad6f6 Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Thu, 6 Aug 2026 16:34:20 -0400 Subject: [PATCH 1/2] feat(desktop): permission policy config + actionable Allow/Deny card (#4938) Add per-agent and fleet-wide permission policy configuration with an actionable Allow/Deny card for the ask policy. **Rust (desktop/src-tauri)** - Add `permission_policy` module: `PermissionPolicy` enum (ask | allow | reject, lowercase serde), `PermissionPolicySource` (agent | global_default | built_in), and `resolve_effective_permission_policy` (precedence: per-agent > global > built-in ask) - Add `permission_policy: Option` to `ManagedAgentRecord` (per-agent override) and `GlobalAgentConfig` (fleet default) - Inject resolved policy as `BUZZ_ACP_PERMISSION_POLICY` env var at spawn; add to `RESERVED_ENV_KEYS` so users cannot override via env-vars UI - Include `permission_policy` in `SpawnSnapshot` / restart-diff so edits surface in the existing `needsRestart` flow - Expose `permission_policy` + `permission_policy_source` on `ManagedAgentSummary` (resolved values) - Extend `UpdateManagedAgentRequest` with double-Option `permission_policy` (None = unchanged, Some(None) = clear, Some(Some(v)) = set); reject edits to remotely deployed agents with a clear error message - Add remote-deployed agent path in `agents_deploy.rs`: read per-record policy, fall back to desktop default, inject into `policy_env` **TypeScript (desktop/src)** - `PermissionPolicy = "ask" | "allow" | "reject"` and `PermissionPolicySource = "agent" | "global_default" | "built_in"` in `types.ts`; add to `ManagedAgent`, `CreateManagedAgentInput`, and `UpdateManagedAgentInput` (null = clear per-agent override) - `tauri.ts`: add `permission_policy` / `permission_policy_source` to `RawManagedAgent` with safe defaults; map in `fromRawManagedAgent` - `agentSessionTypes.ts`: add `authorization?: { requestNonce, actionable, reason? }` to `ObserverEvent`; extend `lifecycle` `TranscriptItem` with `requestNonce`, `actionable`, `authorizationReason`, `options` - `agentSessionTranscript.ts`: add `pendingPermissionsByNonce` map; parse `authorization` envelope from `session/request_permission` events; handle `control_result/permission_decision` to retire cards on terminal outcomes, including the pinned uncertain message - `agentControl.ts`: add `sendPermissionDecision(pubkey, nonce, optionId)` fire-and-forget control API - `LifecycleActivity.tsx`: `PermissionDecisionButtons` component renders per-option buttons styled by kind (reject_* = destructive); local pending state with retry on error; rendered when `actionable && !outcome` - `AgentInstanceEditDialog.tsx`: permission policy select (Inherit / Ask / Allow / Reject) for local agents; read-only for remote-deployed agents with a shutdown+redeploy hint; shows effective value and source Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../deploy-full-launch.request.json | 1 + .../src/commands/agent_config_tests.rs | 1 + .../src-tauri/src/commands/agent_models.rs | 12 ++ desktop/src-tauri/src/commands/agents.rs | 1 + .../src-tauri/src/commands/agents_deploy.rs | 21 ++++ .../src-tauri/src/commands/agents_tests.rs | 1 + .../commands/personas/delete_cascade_tests.rs | 1 + .../personas/inbound/inbound_tests.rs | 1 + .../personas/snapshot/fidelity_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + .../src/managed_agents/agent_events.rs | 1 + .../managed_agents/agent_snapshot_envelope.rs | 1 + .../managed_agents/agent_snapshot_tests.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 1 + .../managed_agents/effective_config/tests.rs | 1 + .../src/managed_agents/global_config/mod.rs | 8 ++ .../src/managed_agents/global_config/tests.rs | 3 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/nest/tests.rs | 1 + .../src/managed_agents/parallelism.rs | 1 + .../src/managed_agents/permission_policy.rs | 82 +++++++++++++ .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 1 + .../src/managed_agents/reserved_env_keys.rs | 5 + .../src-tauri/src/managed_agents/runtime.rs | 16 +++ .../managed_agents/runtime/test_fixtures.rs | 1 + .../src/managed_agents/spawn_snapshot.rs | 12 ++ .../spawn_snapshot/diff/tests.rs | 4 + .../managed_agents/spawn_snapshot/tests.rs | 1 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 6 +- .../src/managed_agents/types/requests.rs | 6 + .../src/managed_agents/types/tests.rs | 2 + .../agents/ui/AgentInstanceEditDialog.tsx | 78 +++++++++++++ .../LifecycleActivity.tsx | 83 ++++++++++++- .../agents/ui/agentSessionTranscript.ts | 110 ++++++++++++++---- .../features/agents/ui/agentSessionTypes.ts | 34 ++++++ desktop/src/shared/api/agentControl.ts | 22 ++++ desktop/src/shared/api/tauri.ts | 7 ++ desktop/src/shared/api/types.ts | 40 ++++++- 46 files changed, 552 insertions(+), 26 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/permission_policy.rs diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index beffc29440..8c243a0c34 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -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" diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5519153578..119eac8c43 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -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, diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..94a464e30a 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -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)?; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398..23cf7b0b75 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -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); diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d4..898653da37 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -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, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2bab..8863a62d1a 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -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![], diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..b33c7feaa9 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -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![], diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..327aef8a5b 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -215,6 +215,7 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..49d828b1e6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..f4a4201029 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -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()); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..8e000e926c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..9a066156e3 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -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, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..c8990738fd 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -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(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..3ec5ec16a8 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -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![], }; diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..13f75c2eff 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -216,6 +216,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..e553916c88 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -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, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..fc9759657c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -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, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..08ce59cf5a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -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, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..1fd0c95ef1 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -283,6 +283,7 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..230b945644 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -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![], diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 162f447981..50e3082394 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -70,6 +70,14 @@ pub struct GlobalAgentConfig { /// Preferred ACP runtime for definitions without an explicit runtime. #[serde(default)] pub preferred_runtime: Option, + /// 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, } /// Validate a `GlobalAgentConfig` before persisting it. diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..d9eb6f4c1d 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -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"); @@ -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![], @@ -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 = diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430f..a0f9c6f9f5 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -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::{ diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..1288c5ceb3 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -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, } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..0253d48158 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -117,6 +117,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/permission_policy.rs b/desktop/src-tauri/src/managed_agents/permission_policy.rs new file mode 100644 index 0000000000..bcacccb125 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/permission_policy.rs @@ -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, + ) +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 0580b12ce2..91110836b4 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -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, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..041ad029a4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -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"); diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 8698d3a51d..3a972ed849 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -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 { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c4..f1e47c0911 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -10,6 +10,7 @@ use crate::{ missing_command_message, normalize_agent_args, open_log_file, resolve_command, spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, + permission_policy::resolve_effective_permission_policy, }, util::now_iso, }; @@ -296,6 +297,9 @@ pub fn build_managed_agent_summary( .unwrap_or("") .to_string(); + let (effective_permission_policy_summary, effective_permission_policy_source) = + resolve_effective_permission_policy(record, global_config); + Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), name: record.name.clone(), @@ -338,6 +342,8 @@ pub fn build_managed_agent_summary( log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), + permission_policy: effective_permission_policy_summary, + permission_policy_source: effective_permission_policy_source, }) } @@ -761,6 +767,15 @@ pub fn spawn_agent_child( command.env_remove(key); } + // Inject BUZZ_ACP_PERMISSION_POLICY — resolved here so the running process + // and the UI-visible setting are always in sync. + let (effective_permission_policy, _) = + resolve_effective_permission_policy(record, &global); + command.env( + "BUZZ_ACP_PERMISSION_POLICY", + effective_permission_policy.as_str(), + ); + command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); // ── Git credential helper for Buzz relay ────────────────────────── @@ -844,6 +859,7 @@ pub fn spawn_agent_child( system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), + permission_policy: effective_permission_policy, }, ); diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983ed..70ed9db6ab 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -89,5 +89,6 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c984..238b263f6f 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -72,6 +72,8 @@ pub(crate) struct SpawnConfigInputs<'a> { pub system_prompt: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, + /// Resolved effective permission policy (per-agent > global > built-in). + pub permission_policy: super::permission_policy::PermissionPolicy, } /// The effective spawn configuration of one managed-agent process. @@ -123,6 +125,10 @@ pub(crate) struct SpawnConfigSnapshot { pub idle_timeout_seconds: Option, pub max_turn_duration_seconds: Option, pub parallelism: u32, + /// Effective permission policy at spawn time. Reaches the harness via + /// `BUZZ_ACP_PERMISSION_POLICY`. Tracked in the snapshot so an edit shows + /// in the `needsRestart` diff. + pub permission_policy: String, } impl SpawnConfigSnapshot { @@ -136,6 +142,7 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + permission_policy, } = inputs; Self { acp_command: record.acp_command.clone(), @@ -174,6 +181,7 @@ impl SpawnConfigSnapshot { // pool and must badge. The diff surface consequently displays the // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), + permission_policy: permission_policy.as_str().to_string(), } } @@ -262,6 +270,10 @@ pub(crate) fn prospective_spawn_config_snapshot( system_prompt: prompt.as_deref(), model: model.as_deref(), provider: provider.as_deref(), + permission_policy: super::permission_policy::resolve_effective_permission_policy( + record, global, + ) + .0, }) } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index a7a8cab93e..ca9999f17e 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -28,6 +28,7 @@ fn base() -> SpawnConfigSnapshot { idle_timeout_seconds: Some(600), max_turn_duration_seconds: Some(7200), parallelism: 1, + permission_policy: "ask".into(), } } @@ -70,6 +71,9 @@ fn mutations() -> Vec { s.max_turn_duration_seconds = None }), ("parallelism", |s| s.parallelism = 8), + ("permission_policy", |s| { + s.permission_policy = "allow".into() + }), ] } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f..00acfe7bde 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -70,6 +70,7 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..9db79e4c03 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,7 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..4d4297ee27 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -213,6 +213,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..6cd7366383 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, path::PathBuf, process::Child}; - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BackendKind { @@ -153,6 +152,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + permission_policy: None, } } } @@ -352,6 +352,8 @@ pub struct ManagedAgentRecord { /// Preserved across mode toggles so users don't lose state. #[serde(default)] pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option, /// Optional display name distinct from the unique `name` handle. Absorbed /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -566,6 +568,8 @@ pub struct ManagedAgentSummary { pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec, + pub permission_policy: super::permission_policy::PermissionPolicy, + pub permission_policy_source: super::permission_policy::PermissionPolicySource, } #[derive(Debug, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..ae90375297 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -253,6 +253,12 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// Absent = don't touch. `null` = clear per-agent override (revert to + /// global/built-in). Present string = set per-agent override. + /// Remote deployed agents: rejected server-side (displayed read-only in UI). + #[serde(default, deserialize_with = "crate::util::double_option")] + pub permission_policy: + Option>, } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..c3db5ae90b 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -744,6 +744,8 @@ fn summary_fixture( log_path: String::new(), respond_to: RespondTo::OwnerOnly, respond_to_allowlist: Vec::new(), + permission_policy: crate::managed_agents::permission_policy::PermissionPolicy::Ask, + permission_policy_source: crate::managed_agents::permission_policy::PermissionPolicySource::BuiltIn, } } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833..05f7a136d0 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -15,6 +15,7 @@ import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOw import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { ManagedAgent, + PermissionPolicy, RespondToMode, UpdateManagedAgentInput, } from "@/shared/api/types"; @@ -160,6 +161,12 @@ export function AgentInstanceEditDialog({ const [respondToAllowlist, setRespondToAllowlist] = React.useState( agent.respondToAllowlist, ); + // `null` means "inherit from global/built-in". Local state mirrors the record's + // per-agent override field. Remote deployed agents: this is read-only. + const [permissionPolicy, setPermissionPolicy] = + React.useState( + agent.permissionPolicySource === "agent" ? agent.permissionPolicy : null, + ); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -196,6 +203,11 @@ export function AgentInstanceEditDialog({ setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); + setPermissionPolicy( + agent.permissionPolicySource === "agent" + ? agent.permissionPolicy + : null, + ); setAvatarUrl(agent.avatarUrl ?? ""); setShowAdvancedFields(false); setIsAvatarUploadPending(false); @@ -725,6 +737,15 @@ export function AgentInstanceEditDialog({ respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",") ? respondToAllowlist : undefined, + // `null` = clear the per-agent override (revert to global/built-in). + // `undefined` = don't touch. Only include when the value actually changed. + permissionPolicy: (() => { + const saved = + agent.permissionPolicySource === "agent" + ? agent.permissionPolicy + : null; + return permissionPolicy !== saved ? permissionPolicy : undefined; + })(), }; const result = await updateMutation.mutateAsync(input); @@ -944,6 +965,63 @@ export function AgentInstanceEditDialog({ onAllowlistChange={setRespondToAllowlist} onModeChange={setRespondTo} /> + {/* Permission policy */} + {(() => { + const isRemoteDeployed = + agent.backend.type === "provider" && + agent.backendAgentId !== null; + const sourceLabelMap: Record = { + agent: "agent override", + global_default: "global default", + built_in: "built-in", + }; + const sourceLabel = + sourceLabelMap[agent.permissionPolicySource] ?? + agent.permissionPolicySource; + return ( +
+
+ + + ({agent.permissionPolicy} · from {sourceLabel}) + +
+ {isRemoteDeployed ? ( +

+ Read-only while deployed. To change, shut down and + redeploy the agent. +

+ ) : ( + + )} +
+ ); + })()} {/* Provider (runtime) */} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index 53a00e6463..9e8c342f25 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -1,5 +1,7 @@ import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react"; +import * as React from "react"; +import { sendPermissionDecision } from "@/shared/api/agentControl"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import { ActivityRow, ActivityRowLabel } from "./ActivityRow"; import { ToolActivity } from "./ToolActivity"; @@ -29,7 +31,7 @@ function splitPermissionText(text: string): { /** * Derive the visual tone and icon for a resolved permission outcome string. * Outcome strings come from describePermissionOutcome: - * "Approved (...)" | "Denied (...)" | "Cancelled" + * "Approved (...)" | "Denied (...)" | "Cancelled" | "uncertain" pinned copy */ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" { if (outcome.startsWith("Approved")) return "approve"; @@ -37,6 +39,63 @@ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" { return "cancel"; } +/** + * Allow/Deny buttons for an actionable permission card. + * Renders the agent's exact options as labeled buttons; a click sends the + * `permission_decision` control event (fire-and-forget). + */ +function PermissionDecisionButtons({ + agentPubkey, + options, + requestNonce, +}: { + agentPubkey: string; + options: Array<{ optionId: string; kind: string; label?: string }>; + requestNonce: string; +}) { + const [pending, setPending] = React.useState(null); + + if (options.length === 0) { + return null; + } + + return ( +
+ {options.map(({ optionId, kind, label }) => { + const isDeny = kind.startsWith("reject"); + const displayLabel = label ?? (isDeny ? "Deny" : "Allow"); + return ( + + ); + })} +
+ ); +} + export function LifecycleActivity(props: ActivityRenderClassItemProps) { if (props.item.type === "tool") { return ; @@ -55,6 +114,10 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { const { requestLines, optionsLine } = splitPermissionText(props.item.text); const outcome = props.item.outcome; const tone = outcome ? permissionOutcomeTone(outcome) : null; + const actionable = props.item.actionable ?? false; + const requestNonce = props.item.requestNonce; + const options = props.item.options ?? []; + const authorizationReason = props.item.authorizationReason; return (
· {requestLines} ) : null}
- {/* Row 2: options (muted sub-line) */} - {optionsLine ? ( + {/* Row 2: authorization reason (from envelope), if present */} + {authorizationReason ? ( +
{authorizationReason}
+ ) : null} + {/* Row 3: options sub-line (legacy fallback) */} + {optionsLine && !authorizationReason ? (
{optionsLine}
) : null} - {/* Row 3: decision — only when outcome is resolved */} + {/* Row 4: Allow/Deny buttons (actionable card awaiting decision) */} + {actionable && requestNonce && !outcome ? ( + + ) : null} + {/* Row 5: decision — only when outcome is resolved */} {outcome && tone ? ( <>
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc3..e3c7f4f9cf 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -47,6 +47,13 @@ export type TranscriptState = { string, { itemId: string; optionNames: Map } >; + /** + * Maps `requestNonce` → `itemId` for actionable permission cards. + * Populated alongside `pendingPermissions` when the `authorization` envelope + * is present on the `acp_read` frame. Used by the `permission_decision` + * `control_result` handler to retire the card on any terminal outcome. + */ + pendingPermissionsByNonce: Map; continuationSeq: number; latestSessionId: string | null; }; @@ -59,6 +66,7 @@ export function createEmptyTranscriptState(): TranscriptState { sealedKeys: new Set(), triggeringEventIdsByTurn: new Map(), pendingPermissions: new Map(), + pendingPermissionsByNonce: new Map(), continuationSeq: 0, latestSessionId: null, }; @@ -79,6 +87,7 @@ type TranscriptDraft = { string, { itemId: string; optionNames: Map } >; + pendingPermissionsByNonce: Map; continuationSeq: number; latestSessionId: string | null; changed: boolean; @@ -92,6 +101,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { sealedKeys: state.sealedKeys, triggeringEventIdsByTurn: state.triggeringEventIdsByTurn, pendingPermissions: state.pendingPermissions, + pendingPermissionsByNonce: state.pendingPermissionsByNonce, continuationSeq: state.continuationSeq, latestSessionId: state.latestSessionId, changed: false, @@ -180,40 +190,47 @@ function describePermissionRequest(payload: Record) { "Permission requested"; const toolCallId = asString(params.toolCallId) ?? asString(params.tool_call_id); - const options = Array.isArray(params.options) - ? params.options - .map((option) => { - const record = asRecord(option); - return ( - asString(record.name) ?? - asString(record.kind) ?? - asString(record.optionId) - ); - }) - .filter((option): option is string => Boolean(option)) - : []; - const detail: string[] = []; - if (title !== "Permission requested") detail.push(title); - if (toolCallId) detail.push(`Tool call: ${toolCallId}`); - if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); - // Build optionId → kind map for outcome labeling on the response. + // Build both the display-string list and the structured options list in + // a single pass over params.options. const optionNames = new Map(); + const structuredOptions: Array<{ + optionId: string; + kind: string; + label?: string; + }> = []; + const optionDisplayNames: string[] = []; if (Array.isArray(params.options)) { for (const option of params.options) { - const record = asRecord(option); - const optionId = asString(record.optionId); - const kind = asString(record.kind); + const rec = asRecord(option); + const optionId = asString(rec.optionId); + const kind = asString(rec.kind); + const label = asString(rec.label) ?? asString(rec.name); + const displayName = + asString(rec.name) ?? asString(rec.kind) ?? asString(rec.optionId); + if (displayName) optionDisplayNames.push(displayName); if (optionId && kind) { optionNames.set(optionId, kind); + structuredOptions.push({ + optionId, + kind, + ...(label ? { label } : {}), + }); } } } + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (optionDisplayNames.length > 0) + detail.push(`Options: ${optionDisplayNames.join(", ")}`); + return { title, text: detail.join("\n"), optionNames, + options: structuredOptions, descriptor: { renderClass: "permission" as const, label: "Permission requested", @@ -804,6 +821,27 @@ export function processTranscriptEvent( "permission_request", request.descriptor, ); + + // Attach authorization-envelope fields to the item. The `authorization` + // object is on the ObserverEvent itself (not the payload — payloads are + // raw ACP with no `_buzz` wrapper). + const auth = event.authorization; + if (auth) { + const existing = d.itemsById.get(itemId); + if (existing?.type === "lifecycle") { + replaceItem(d, itemId, { + ...existing, + requestNonce: auth.requestNonce, + actionable: auth.actionable, + authorizationReason: auth.reason, + options: request.options, + }); + } + // Also index by nonce so control_result frames can retire the card. + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.set(auth.requestNonce, itemId); + } + // Index by JSON-RPC id so the response (acp_write with result.outcome, // no method) can correlate by id rather than by turn/seq. const requestId = jsonRpcId(payload.id); @@ -1138,6 +1176,37 @@ export function processTranscriptEvent( ); } } + } else if (event.kind === "control_result") { + // Retire any pending actionable permission card on a `permission_decision` + // control result. All terminal statuses (applied, denied, timed_out, + // cancelled, uncertain) close the card. "uncertain" gets the pinned copy: + // the agent process stopped before the harness could continue, so the + // outcome is genuinely unknown — never "denied", never "failed closed". + const payload = asRecord(event.payload); + const frameType = asString(payload.type); + if (frameType === "permission_decision") { + const nonce = asString(payload.requestNonce); + const terminalStatus = asString(payload.status); + const itemId = nonce ? d.pendingPermissionsByNonce.get(nonce) : null; + if (itemId && terminalStatus) { + const existing = d.itemsById.get(itemId); + if (existing?.type === "lifecycle") { + const outcomeText = + terminalStatus === "uncertain" + ? "Approval outcome unknown; agent process stopped before it could continue." + : describePermissionOutcome(terminalStatus, null, new Map()); + replaceItem(d, itemId, { + ...existing, + outcome: outcomeText, + actionable: false, + }); + } + if (nonce) { + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.delete(nonce); + } + } + } } if (!d.changed && d.latestSessionId === state.latestSessionId) { @@ -1151,6 +1220,7 @@ export function processTranscriptEvent( sealedKeys: d.sealedKeys, triggeringEventIdsByTurn: d.triggeringEventIdsByTurn, pendingPermissions: d.pendingPermissions, + pendingPermissionsByNonce: d.pendingPermissionsByNonce, continuationSeq: d.continuationSeq, latestSessionId: d.latestSessionId, }; diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076c..1ae5ec87a3 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -10,6 +10,17 @@ export type ObserverEvent = { turnId: string | null; startedAt?: string | null; payload: unknown; + /** + * Present on `acp_read` permission frames (kind === "acp_read" + method === + * "session/request_permission"). Carries the harness-level permission gate + * metadata — `requestNonce`, `actionable`, and an optional human-readable + * `reason`. Payloads are raw ACP; there is no `_buzz` wrapper field. + */ + authorization?: { + requestNonce: string; + actionable: boolean; + reason?: string; + }; }; export type ConnectionState = @@ -112,6 +123,29 @@ export type TranscriptItem = timestamp: string; descriptor?: AgentActivityDescriptor; acpSource?: TranscriptAcpSource; + /** + * Nonce from the `authorization` envelope on an `acp_read` permission + * frame. Present only on `renderClass === "permission"` items; used to + * correlate the `permission_decision` control response and to match + * incoming `control_result` frames back to this card. + */ + requestNonce?: string; + /** + * When `true`, this card is waiting for a user Allow/Deny decision. + * `false` (or absent) means the card is read-only (auto-handled, or the + * policy is not `ask`). + */ + actionable?: boolean; + /** + * Human-readable reason string from the `authorization` envelope. + * Displayed as context below the request description. + */ + authorizationReason?: string; + /** + * Parsed options from the request params, passed back for Allow/Deny + * button rendering. + */ + options?: Array<{ optionId: string; kind: string; label?: string }>; } & TranscriptItemIdentity) | ({ id: string; diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad4..888fb537f9 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -29,3 +29,25 @@ export async function switchManagedAgentModel( modelId, }); } + +/** + * Send a permission decision to a running agent's ACP harness. The decision + * is fire-and-forget: the harness receives it via the observer control channel + * and updates the permission card asynchronously via a `control_result` frame. + * + * @param pubkey - Agent's public key (hex or npub). + * @param nonce - `requestNonce` from the `authorization` envelope on the + * corresponding `acp_read` permission frame. + * @param optionId - The chosen option's `optionId` (e.g. `"allow_once"`). + */ +export async function sendPermissionDecision( + pubkey: string, + nonce: string, + optionId: string, +): Promise { + await sendAgentObserverControl(pubkey, { + type: "permission_decision", + requestNonce: nonce, + optionId, + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 6e29f77c14..fd1df56c88 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -40,6 +40,8 @@ import type { InstallRuntimeResult, GitBashPrerequisite, RuntimeConfigSurface, + PermissionPolicy, + PermissionPolicySource, } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; @@ -162,6 +164,9 @@ export type RawManagedAgent = { // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. respond_to?: ManagedAgent["respondTo"]; respond_to_allowlist?: string[]; + // Pre-feature fixtures may omit these; defaults applied in fromRawManagedAgent. + permission_policy?: PermissionPolicy; + permission_policy_source?: PermissionPolicySource; }; type RawCreateManagedAgentResponse = { @@ -730,6 +735,8 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { backendAgentId: agent.backend_agent_id, respondTo: agent.respond_to ?? "owner-only", respondToAllowlist: agent.respond_to_allowlist ?? [], + permissionPolicy: agent.permission_policy ?? "ask", + permissionPolicySource: agent.permission_policy_source ?? "built_in", }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef625783..4dad0db014 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -384,11 +384,40 @@ export type ManagedAgent = { * `"allowlist"`. Preserved across mode toggles. */ respondToAllowlist: string[]; + /** + * Effective permission policy at the last spawn. Determines how the ACP + * harness answers `session/request_permission` calls. + */ + permissionPolicy: PermissionPolicy; + /** + * Where the effective `permissionPolicy` value came from: a per-agent + * override, the fleet-wide global default, or the built-in desktop default. + */ + permissionPolicySource: PermissionPolicySource; }; /** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; +/** + * Permission policy controlling how the ACP harness answers + * `session/request_permission` calls. + * + * - `ask`: Show an actionable Allow/Deny card in the transcript (desktop default). + * - `allow`: Auto-approve the unique `allow_once` option (explicit opt-in). + * - `reject`: Auto-deny all requests without surfacing a card. + */ +export type PermissionPolicy = "ask" | "allow" | "reject"; + +/** + * Where the effective permission policy value came from. + * + * - `agent`: Per-agent override set on this specific agent record. + * - `global_default`: Fleet-wide default from the global agent config. + * - `built_in`: Neither layer had a value; the desktop built-in default (`ask`) applies. + */ +export type PermissionPolicySource = "agent" | "global_default" | "built_in"; + export type BackendProviderCandidate = { id: string; binaryPath: string; @@ -443,6 +472,8 @@ export type CreateManagedAgentInput = { */ respondToAllowlist?: string[]; relayMesh?: RelayMeshConfig; + /** Per-agent permission policy override. Omitted = inherit from global or built-in default. */ + permissionPolicy?: PermissionPolicy; }; export type CreateManagedAgentResponse = { @@ -475,9 +506,11 @@ export type SwitchManagedAgentModelStatus = | "no_active_turn"; export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; + type: "cancel_turn" | "switch_model" | "permission_decision"; status: string; modelId?: string; + /** Present on `permission_decision` results — identifies the request card to retire. */ + requestNonce?: string; }; export type GitBashPrerequisite = { @@ -706,6 +739,11 @@ export type UpdateManagedAgentInput = { * (validated & normalized server-side). */ respondToAllowlist?: string[]; + /** + * Absent = don't touch. Present = override (or `null` to clear back to inherit). + * Remote deployed agents: read-only; edit the deploy config and redeploy. + */ + permissionPolicy?: PermissionPolicy | null; }; export type AgentPersona = { id: string; From 90e32923ef77d094e758a7140eb8f3e41d755098 Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Thu, 6 Aug 2026 16:38:47 -0400 Subject: [PATCH 2/2] fix(desktop): control_result is delivery confirmation, not terminal outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per interface note from Paul (2026-08-06): control_result statuses (sent | no_active_turn | channel_full | channel_closed | no_channel) confirm whether the permission_decision click was delivered to the harness, not whether the permission was applied/denied. Terminal outcomes arrive as enveloped acp_write frames correlated by requestNonce. The card retirement matrix will be wired once Thufir's review of Duncan's buzz-acp contract lands and NIP-AO is pinned. Updated the control_result handler to preserve card actionability on delivery — the PermissionDecisionButtons component already handles button-level pending-state reset via its own catch handler if the fire-and-forget send fails. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/agentSessionTranscript.ts | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e3c7f4f9cf..1a352ed4ea 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1177,35 +1177,24 @@ export function processTranscriptEvent( } } } else if (event.kind === "control_result") { - // Retire any pending actionable permission card on a `permission_decision` - // control result. All terminal statuses (applied, denied, timed_out, - // cancelled, uncertain) close the card. "uncertain" gets the pinned copy: - // the agent process stopped before the harness could continue, so the - // outcome is genuinely unknown — never "denied", never "failed closed". + // `control_result` for `permission_decision` is a **delivery confirmation**, + // not a terminal outcome. Status values are: sent | no_active_turn | + // channel_full | channel_closed | no_channel. + // + // A non-"sent" status means the click did not reach the harness — the card + // stays actionable so the user can retry. Terminal outcomes (applied, + // denied, timed_out, cancelled, uncertain) arrive as enveloped acp_write + // frames correlated by requestNonce (see the acp_write branch above). + // That path will be wired once Thufir's review of Duncan's contract lands. const payload = asRecord(event.payload); const frameType = asString(payload.type); if (frameType === "permission_decision") { - const nonce = asString(payload.requestNonce); - const terminalStatus = asString(payload.status); - const itemId = nonce ? d.pendingPermissionsByNonce.get(nonce) : null; - if (itemId && terminalStatus) { - const existing = d.itemsById.get(itemId); - if (existing?.type === "lifecycle") { - const outcomeText = - terminalStatus === "uncertain" - ? "Approval outcome unknown; agent process stopped before it could continue." - : describePermissionOutcome(terminalStatus, null, new Map()); - replaceItem(d, itemId, { - ...existing, - outcome: outcomeText, - actionable: false, - }); - } - if (nonce) { - d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); - d.pendingPermissionsByNonce.delete(nonce); - } - } + const deliveryStatus = asString(payload.status); + // If delivery failed, the PermissionDecisionButtons component handles + // button-level pending-state reset via its own catch handler. No card + // retirement here — the card stays actionable until a terminal acp_write + // frame confirms the outcome. + void deliveryStatus; // acknowledged; no card mutation on delivery results } }