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:
Duncan
2026-08-06 16:43:01 -04:00
46 changed files with 541 additions and 26 deletions
@@ -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)?;
+1
View File
@@ -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;
+22
View File
@@ -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,
});
}
+7
View File
@@ -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",
};
}
+39 -1
View File
@@ -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;