mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/hayt/permission-policy' into duncan/permission-policy
* origin/hayt/permission-policy: fix(desktop): control_result is delivery confirmation, not terminal outcome feat(desktop): permission policy config + actionable Allow/Deny card (#4938) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Vendored
+1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -215,6 +215,7 @@ fn local_agent() -> ManagedAgentRecord {
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
|
||||
definition_respond_to_allowlist: vec![],
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -73,6 +73,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
|
||||
definition_respond_to_allowlist: vec![],
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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![],
|
||||
};
|
||||
|
||||
@@ -216,6 +216,7 @@ mod tests {
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -283,6 +283,7 @@ fn record_with(
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ mod tests {
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -89,5 +89,6 @@ pub(super) fn fixture(
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
pub max_turn_duration_seconds: Option<u64>,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Mutation> {
|
||||
s.max_turn_duration_seconds = None
|
||||
}),
|
||||
("parallelism", |s| s.parallelism = 8),
|
||||
("permission_policy", |s| {
|
||||
s.permission_policy = "allow".into()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ fn record() -> ManagedAgentRecord {
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@ mod tests {
|
||||
definition_respond_to_allowlist: vec![],
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
permission_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_policy: Option<super::permission_policy::PermissionPolicy>,
|
||||
/// 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<String>,
|
||||
pub permission_policy: super::permission_policy::PermissionPolicy,
|
||||
pub permission_policy_source: super::permission_policy::PermissionPolicySource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -253,6 +253,12 @@ pub struct UpdateManagedAgentRequest {
|
||||
/// normalized server-side).
|
||||
#[serde(default)]
|
||||
pub respond_to_allowlist: Option<Vec<String>>,
|
||||
/// 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<Option<crate::managed_agents::permission_policy::PermissionPolicy>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string[]>(
|
||||
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<PermissionPolicy | null>(
|
||||
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<string, string> = {
|
||||
agent: "agent override",
|
||||
global_default: "global default",
|
||||
built_in: "built-in",
|
||||
};
|
||||
const sourceLabel =
|
||||
sourceLabelMap[agent.permissionPolicySource] ??
|
||||
agent.permissionPolicySource;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-agent-permission-policy"
|
||||
>
|
||||
Permission policy
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({agent.permissionPolicy} · from {sourceLabel})
|
||||
</span>
|
||||
</div>
|
||||
{isRemoteDeployed ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Read-only while deployed. To change, shut down and
|
||||
redeploy the agent.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
disabled={updateMutation.isPending}
|
||||
id="edit-agent-permission-policy"
|
||||
value={permissionPolicy ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setPermissionPolicy(
|
||||
val === "" ? null : (val as PermissionPolicy),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
Inherit ({agent.permissionPolicy} · from {sourceLabel})
|
||||
</option>
|
||||
<option value="ask">Ask — show Allow/Deny card</option>
|
||||
<option value="allow">
|
||||
Allow — auto-approve (explicit opt-in)
|
||||
</option>
|
||||
<option value="reject">Reject — auto-deny</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<RunOnSummarySection backend={agent.backend} />
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{options.map(({ optionId, kind, label }) => {
|
||||
const isDeny = kind.startsWith("reject");
|
||||
const displayLabel = label ?? (isDeny ? "Deny" : "Allow");
|
||||
return (
|
||||
<button
|
||||
key={optionId}
|
||||
type="button"
|
||||
className={
|
||||
isDeny
|
||||
? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
: "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50"
|
||||
}
|
||||
data-testid={`permission-decision-${optionId}`}
|
||||
disabled={pending !== null}
|
||||
onClick={() => {
|
||||
setPending(optionId);
|
||||
void sendPermissionDecision(
|
||||
agentPubkey,
|
||||
requestNonce,
|
||||
optionId,
|
||||
).catch(() => {
|
||||
// Fire-and-forget: harness will time out if the frame is lost.
|
||||
// Reset pending so the user can retry.
|
||||
setPending(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{pending === optionId ? "…" : displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LifecycleActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
@@ -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 (
|
||||
<div
|
||||
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
|
||||
@@ -69,11 +132,23 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
|
||||
<span className="opacity-80"> · {requestLines}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Row 2: options (muted sub-line) */}
|
||||
{optionsLine ? (
|
||||
{/* Row 2: authorization reason (from envelope), if present */}
|
||||
{authorizationReason ? (
|
||||
<div className="mt-0.5 pl-5 opacity-70">{authorizationReason}</div>
|
||||
) : null}
|
||||
{/* Row 3: options sub-line (legacy fallback) */}
|
||||
{optionsLine && !authorizationReason ? (
|
||||
<div className="mt-0.5 pl-5 opacity-60">{optionsLine}</div>
|
||||
) : null}
|
||||
{/* Row 3: decision — only when outcome is resolved */}
|
||||
{/* Row 4: Allow/Deny buttons (actionable card awaiting decision) */}
|
||||
{actionable && requestNonce && !outcome ? (
|
||||
<PermissionDecisionButtons
|
||||
agentPubkey={props.agentPubkey}
|
||||
options={options}
|
||||
requestNonce={requestNonce}
|
||||
/>
|
||||
) : null}
|
||||
{/* Row 5: decision — only when outcome is resolved */}
|
||||
{outcome && tone ? (
|
||||
<>
|
||||
<div className="my-1 border-t border-amber-500/20" />
|
||||
|
||||
@@ -47,6 +47,13 @@ export type TranscriptState = {
|
||||
string,
|
||||
{ itemId: string; optionNames: Map<string, string> }
|
||||
>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
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<string, string> }
|
||||
>;
|
||||
pendingPermissionsByNonce: Map<string, string>;
|
||||
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<string, unknown>) {
|
||||
"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<string, string>();
|
||||
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,26 @@ export function processTranscriptEvent(
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.kind === "control_result") {
|
||||
// `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 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
|
||||
}
|
||||
}
|
||||
|
||||
if (!d.changed && d.latestSessionId === state.latestSessionId) {
|
||||
@@ -1151,6 +1209,7 @@ export function processTranscriptEvent(
|
||||
sealedKeys: d.sealedKeys,
|
||||
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
|
||||
pendingPermissions: d.pendingPermissions,
|
||||
pendingPermissionsByNonce: d.pendingPermissionsByNonce,
|
||||
continuationSeq: d.continuationSeq,
|
||||
latestSessionId: d.latestSessionId,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
await sendAgentObserverControl(pubkey, {
|
||||
type: "permission_decision",
|
||||
requestNonce: nonce,
|
||||
optionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user