Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
127298f
feat(acp): implement permission policy (#4938)
Aug 6, 2026
d8be338
feat(desktop): permission policy config + actionable Allow/Deny card …
Aug 6, 2026
90e3292
fix(desktop): control_result is delivery confirmation, not terminal o…
Aug 6, 2026
4f72c3c
Merge remote-tracking branch 'origin/hayt/permission-policy' into dun…
Aug 6, 2026
6dbbc67
feat(acp): add PermissionMode::Auto + contradiction matrix rows (#4938)
Aug 6, 2026
b3b1b3b
fix(acp): address Thufir pass-1 CRITICAL and IMPORTANT findings (#4938)
Aug 6, 2026
96538e9
fix(acp): address Thufir pass-2 review findings (#4938)
Aug 6, 2026
6607eaf
fix(acp): structural round — one nonce-keyed permission record, finis…
Aug 7, 2026
7d277da
fix(acp): address Thufir pass-4 review findings
Aug 7, 2026
73f2ffe
fix(buzz-acp): thread single nonce through sync denial paths; upgrade…
Aug 7, 2026
fcd33ff
test(buzz-acp): tighten equality-deadline test to pinned future + exa…
Aug 7, 2026
bcd9bac
refactor(desktop): extract permission/transcript types to fix file-si…
Aug 7, 2026
fe901d8
Merge remote-tracking branch 'origin/main' into duncan/permission-policy
Aug 7, 2026
2dd0706
refactor(desktop): extract RawManagedAgent/fromRawManagedAgent to man…
Aug 7, 2026
e87f265
fix(desktop): use struct literal init to satisfy clippy::field_reassi…
Aug 7, 2026
cef78d7
feat(desktop): render permission-request sentinel card in thread time…
Aug 8, 2026
d808c4b
feat(acp): publish kind-9/40003 permission sentinel cards into channe…
Aug 8, 2026
74c9200
Merge remote-tracking branch 'origin/main' into duncan/permission-policy
Aug 8, 2026
1e0c5ba
fix(acp): restore correct merge resolution for buzz-acp post-main-revert
Aug 8, 2026
a16b059
fix(desktop): parse bare-JSON sentinel, unify expiry clock, tighten g…
Aug 8, 2026
fe7efd8
fix(acp): ACK-gated sentinel lifecycle, D7 admission, NIP-AO docs
Aug 8, 2026
3129900
test(desktop): add harness integration fixture test for kind-9 parser
Aug 8, 2026
ef67355
fix(acp): background task owns ACK-waiter expiry, add 3 missing named…
Aug 8, 2026
aeca230
refactor(acp): delete publish_event_acked, only register_publish_ack …
Aug 8, 2026
f858cab
fix(desktop): sentinel-only edit gate in formatTimelineMessages; rewo…
Aug 8, 2026
60c247e
test(e2e): align permission outcome assertion with label-based rendering
Aug 8, 2026
ff5b21f
chore: merge origin/main into duncan/permission-policy
Aug 10, 2026
3ad999a
fix(desktop): fail-closed PermissionDecisionButtons for allow_always …
Aug 10, 2026
bac3264
fix(acp): add permission_decision_tx: None to main's new test TaskMet…
Aug 10, 2026
00456e4
fix(desktop): persist applied permission policy for remote deploys
Aug 10, 2026
ca9acfa
fix(desktop): exact-allowlist permission kinds, disclose persistent deny
Aug 10, 2026
0bc5683
fix(desktop): enforce deploy-receipt invariant for applied permission…
Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,933 changes: 4,796 additions & 137 deletions crates/buzz-acp/src/acp.rs

Large diffs are not rendered by default.

293 changes: 255 additions & 38 deletions crates/buzz-acp/src/config.rs

Large diffs are not rendered by default.

231 changes: 228 additions & 3 deletions crates/buzz-acp/src/lib.rs

Large diffs are not rendered by default.

53 changes: 52 additions & 1 deletion crates/buzz-acp/src/observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ pub struct ObserverContext {
pub started_at: Option<String>,
}

/// Authorization envelope attached to permission-related observer events.
///
/// Present on the single `acp_read` emitted after a permission request passes
/// the admission preflight, and on the corresponding `acp_write` after the
/// response is confirmed written.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorizationEnvelope {
/// Single-use nonce bound to this request — delivered to the desktop and
/// consumed exactly once when the owner makes a decision.
pub request_nonce: String,
/// `true` when the owner can take action (policy=ask, preflight passed,
/// owner/observer available). `false` for auto-deny / fail-closed paths.
pub actionable: bool,
/// Human-readable reason when `actionable` is `false`.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}

/// Handle used by the harness to publish local observer events.
#[derive(Clone)]
pub struct ObserverHandle {
Expand All @@ -54,7 +73,7 @@ fn new_observer_handle() -> ObserverHandle {
}

/// Event delivered through the in-process observer bus.
#[derive(Clone, Serialize)]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ObserverEvent {
/// Monotonic process-local sequence number.
Expand All @@ -74,6 +93,12 @@ pub struct ObserverEvent {
/// RFC3339 timestamp at which the current turn began, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
/// Authorization envelope — present only on permission `acp_read` /
/// `acp_write` frames, and on the observer-only `permission_terminal` frame
/// (which carries `reason = "uncertain"` and is never sent on the ACP wire).
/// `None` on all other event kinds.
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization: Option<AuthorizationEnvelope>,
/// Raw or semantic event payload.
pub payload: serde_json::Value,
}
Expand Down Expand Up @@ -107,6 +132,31 @@ impl ObserverHandle {
agent_index: Option<usize>,
context: &ObserverContext,
payload: serde_json::Value,
) {
self.emit_inner(kind, agent_index, context, None, payload);
}

/// Emit a local observer event with an authorization envelope.
///
/// Used for permission `acp_read` and `acp_write` frames.
pub fn emit_authorized(
&self,
kind: impl Into<String>,
agent_index: Option<usize>,
context: &ObserverContext,
authorization: AuthorizationEnvelope,
payload: serde_json::Value,
) {
self.emit_inner(kind, agent_index, context, Some(authorization), payload);
}

fn emit_inner(
&self,
kind: impl Into<String>,
agent_index: Option<usize>,
context: &ObserverContext,
authorization: Option<AuthorizationEnvelope>,
payload: serde_json::Value,
) {
let event = ObserverEvent {
seq: self.inner.seq.fetch_add(1, Ordering::Relaxed),
Expand All @@ -117,6 +167,7 @@ impl ObserverHandle {
session_id: context.session_id.clone(),
turn_id: context.turn_id.clone(),
started_at: context.started_at.clone(),
authorization,
payload,
};

Expand Down
97 changes: 79 additions & 18 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use crate::acp::{
resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod,
StopReason, SystemPromptTransport,
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::config::{compose_session_title, DedupMode, PermissionMode, ResolvedPermissionConfig};
use crate::observer;
use crate::queue::{
CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo,
Expand Down Expand Up @@ -73,6 +73,13 @@ pub struct TaskMeta {
/// tasks only — all prompt tasks install a steer channel regardless
/// of the agent's name.
pub steer_tx: Option<tokio::sync::mpsc::Sender<SteerRequest>>,
/// Permission decision channel — delivers `permission_decision` control
/// frames from the observer dispatch loop into the read loop's decision
/// arm. `None` until the first `ask`-policy permission request arrives
/// (installed per-session by the pool dispatch path). Cloned from the
/// sender end of the channel installed on `AcpClient` via
/// `install_permission_decision_rx`.
pub permission_decision_tx: Option<tokio::sync::mpsc::Sender<crate::acp::PermissionDecision>>,
/// Successful non-cancelling steers acknowledged while this task owned the
/// live session. The session ID prevents a late ack from contaminating a
/// replacement session after task return.
Expand Down Expand Up @@ -584,8 +591,8 @@ pub struct PromptContext {
pub context_message_limit: u32,
/// Max turns per session before proactive rotation. 0 = disabled.
pub max_turns_per_session: u32,
/// Permission mode to apply after session creation. `Default` = skip.
pub permission_mode: PermissionMode,
/// Resolved permission configuration — policy, effective ACP mode, and how to transmit.
pub permission_config: ResolvedPermissionConfig,
/// Agent identity — used to derive the NIP-AE conversation key at
/// session creation for core injection.
pub agent_keys: nostr::Keys,
Expand All @@ -605,6 +612,11 @@ pub struct PromptContext {
/// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`,
/// mirroring the `managed_agent_runtime_lifecycle` frames.
pub relay_url: String,
/// Publisher for kind-9 sentinel cards and kind-40003 edits.
/// When set, `run_prompt_task` wires it into `AcpClient` so permission
/// cards appear in the channel thread. `None` disables sentinel publishing
/// (observer feed path remains).
pub relay_event_publisher: Option<crate::relay::RelayEventPublisher>,
}

impl AgentPool {
Expand Down Expand Up @@ -1089,14 +1101,20 @@ async fn create_session_and_apply_model(
}),
);

// Apply permission mode if not the agent's built-in default AND the agent
// advertises the requested mode in session/new. Agents that don't support
// the mode (e.g., goose crashes on unrecognized set_config_option values)
// are safely skipped — the harness auto-approves via handle_permission_request.
if !ctx.permission_mode.is_default()
&& agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str())
// Apply permission mode whenever the agent advertises it (including `default`).
// The `transmit_mode` flag handles any future cases where transmission should be skipped.
if ctx.permission_config.transmit_mode
&& agent_supports_mode(
&resp.raw,
ctx.permission_config.effective_mode.as_wire_str(),
)
{
apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?;
apply_permission_mode(
&mut agent.acp,
&resp.session_id,
&ctx.permission_config.effective_mode,
)
.await?;
}

Ok(resp.session_id)
Expand Down Expand Up @@ -1205,11 +1223,7 @@ async fn apply_model_switch(
Ok(())
}

/// Set the session permission mode via `session/set_config_option`.
///
/// Non-fatal for most errors: logs and proceeds. The agent falls back
/// to its default permission mode (`"default"`), which still works via
/// Check if the agent's `session/new` response advertises a given mode ID
/// Check whether the agent's `session/new` response advertises a given mode ID
/// in `result.modes.availableModes[].id`. Returns `false` if the modes
/// field is absent or the mode isn't listed.
fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool {
Expand All @@ -1225,7 +1239,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str)
.unwrap_or(false)
}

/// per-tool auto-approval in `handle_permission_request`.
/// Set the session permission mode via `session/set_config_option`.
///
/// Non-fatal for most errors: logs and proceeds. The agent falls back to its
/// default mode, and any interactive permission request is rejected by
/// `handle_permission_request`.
///
/// **Fatal exception:** if the agent process exits (e.g., goose crashes on
/// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn.
Expand Down Expand Up @@ -1265,7 +1283,7 @@ async fn apply_permission_mode(
Ok(Err(e)) => {
tracing::warn!(
target: "pool::permission",
"failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval"
"failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection"
);
}
Err(_) => {
Expand Down Expand Up @@ -1475,6 +1493,44 @@ pub async fn run_prompt_task(
turn_id.clone(),
turn_started_at.clone(),
));

// Wire permission configuration and owner-knowledge into the ACP client so
// `handle_permission_request` can evaluate the ask availability gate. These
// values come from `PromptContext` (resolved once at startup from CLI args and
// desktop-injected env vars) and are idempotent to re-apply across turns.
agent
.acp
.set_permission_config(ctx.permission_config.clone());
agent
.acp
.set_owner_pubkey_known(ctx.agent_owner_pubkey.is_some());

// Wire sentinel card publisher, agent signing keys, owner pubkey, and
// per-turn context for D7-final admission and kind-9/40003 publishing.
if let Some(publisher) = ctx.relay_event_publisher.clone() {
agent
.acp
.set_relay_publisher(publisher, ctx.agent_keys.clone());
}
agent
.acp
.set_agent_owner_pubkey_hex(ctx.agent_owner_pubkey.as_ref().map(|pk| pk.to_hex()));
// D7-final: record the turn initiator from the first event in the batch.
let turn_initiator = batch
.as_ref()
.and_then(|b| b.events.first())
.map(|be| be.event.pubkey);
agent.acp.set_turn_initiator_pubkey(turn_initiator);
// Sentinel routing: channel UUID and reply anchor from batch.
let batch_channel_id = batch.as_ref().map(|b| b.channel_id);
let thread_reply_event_id = batch
.as_ref()
.and_then(|b| b.events.first())
.map(|be| be.event.id.to_hex());
agent
.acp
.set_turn_channel_context(batch_channel_id, thread_reply_event_id);

let triggering_event_ids: Vec<String> = batch
.as_ref()
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
Expand Down Expand Up @@ -7456,12 +7512,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
),
context_message_limit: 0,
max_turns_per_session: 0,
permission_mode: PermissionMode::Default,
permission_config: ResolvedPermissionConfig::resolve(
crate::config::PermissionPolicy::Reject,
None,
)
.expect("test config"),
agent_keys: agent_keys.clone(),
agent_owner_pubkey: owner_pubkey,
memory_enabled: false,
harness_name: "goose".to_string(),
relay_url: "ws://127.0.0.1:3000".to_string(),
relay_event_publisher: None,
}
}

Expand Down
Loading