Skip to content

feat(acp): implement harness-level tool permission policy (#4938) - #5106

Open
wpfleger96 wants to merge 15 commits into
mainfrom
duncan/permission-policy
Open

feat(acp): implement harness-level tool permission policy (#4938)#5106
wpfleger96 wants to merge 15 commits into
mainfrom
duncan/permission-policy

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Implements the harness-level permission policy for issue #4938 (full stack: ACP harness + Desktop + NIP-AO doc update).

Background — relationship to #4609: #4609 removed the bypassPermissions auto-approve default so that session/request_permission fails closed. That left permission requests silently denied with no user-visible surface — the behavior reported in #4938. This PR keeps #4609's fail-closed direction: allow is explicit opt-in only, ambiguous outcomes resolve to deny, and the new ask default surfaces the request to the user instead of silently answering it.

What this PR does

ACP Harness (crates/buzz-acp/):

  • PermissionPolicy enum (ask / allow / reject) read from BUZZ_ACP_PERMISSION_POLICY env var
  • ResolvedPermissionConfig resolves policy + permission mode at startup; rejects contradictions (dontAsk+ask, dontAsk+allow, reject+auto)
  • ask+auto is compatible-with-warning: auto is a model classifier, not bypass mode — residual escalations still surface cards; internally-approved calls bypass the ask flow silently
  • Pending permission map (bounded at 8 entries): PermissionEntry with Pending→Writing→Resolved lifecycle
  • Per-request 300 s deadline: select! min(earliest pending deadline, hard deadline); idle deadline suspended while any entry is pending
  • Exact-once semantics: single write_ndjson_no_observe write + single authorized acp_write observer emit per decision; no legacy single-slot duplication
  • Admission preflight: validates options, checks map capacity, measures annotated ObserverEvent size (raw + OBSERVER_EVENT_ENVELOPE_MAX = 512) against OBSERVER_MAX_PLAINTEXT_LEN
  • permission_denial_response: malformed reject_once (missing/empty optionId) falls back to cancelled
  • Pre-turn (session/new) permission requests: forced reject (no decision arm available)
  • Turn exit and cancel completion both drain the pending map
  • Cancel-during-write: PermissionPoisoned returned; process is respawned

Desktop (desktop/):

  • PermissionPolicy Rust enum + PermissionPolicySource + resolve_effective_permission_policy in permission_policy.rs; precedence: per-agent > global > built-in ask
  • ManagedAgentRecord.permission_policy + GlobalAgentConfig.permission_policy (Rust and TypeScript)
  • Fleet-wide default in AgentDefaultsEditor + EMPTY_GLOBAL_CONFIG
  • Injected as BUZZ_ACP_PERMISSION_POLICY at local spawn and remote deploy (shared resolver)
  • UpdateManagedAgentRequest double-Option; server rejects remote-deployed edits
  • authorization envelope on acp_read frames parsed by transcript reducer
  • Cards keyed by nonce (permission:ch:nonce:N) for concurrent request isolation; legacy turn-keyed fallback for non-ask paths
  • PermissionDecisionButtons component with channelId threaded end-to-end
  • control_result delivery failure: sets deliveryFailed on card item; useEffect re-enables buttons for retry
  • Terminal outcomes: timed_out, uncertain (pinned verbatim copy) in describePermissionOutcome
  • Remote deploy: build_launch_block accepts effective_permission_policy from caller

NIP-AO (docs/nips/NIP-AO.md):

  • switch_model: accurate behavior description (busy=cancel+requeue, idle=immediate); correct control_result statuses (sent|turn_ending|switched|unsupported_model|no_active_turn)
  • acp_write example: actionable: false (terminal, applied); correct payload shape (result.outcome.outcome=selected)

Known CI failure — file-size ratchet

The ratchet checks growth against base 6eb65919f for 9 files. All growth is unavoidable for the new fields and tests. Table:

File Limit Actual +Lines
src-tauri/src/commands/agent_models.rs 1025 1037 +12
src-tauri/src/commands/agents.rs 1376 1377 +1
src-tauri/src/managed_agents/discovery/tests.rs 1840 1841 +1
src-tauri/src/managed_agents/readiness.rs 1742 1743 +1
src-tauri/src/managed_agents/types.rs 1000 1002 +4 (double-Option field)
src/features/agents/ui/AgentInstanceEditDialog.tsx 1228 1306 +78 (policy select)
src/features/agents/ui/agentSessionTranscript.ts 1174 1256 +82 (envelope handling, card lifecycle)
src/shared/api/tauri.ts 1175 1182 +7
src/shared/api/types.ts 1030 1074 +44 (GlobalAgentConfig.permission_policy, deliveryFailed)

Ratchet remediation (file split vs. limit bump) deferred until Thufir review clears.

Test counts

  • buzz-acp: 726 Rust (previously 724)
  • buzz-desktop (Rust): 2256 (previously 2249)
  • Desktop TS: 4401 (previously 4392)

Add a three-value BUZZ_ACP_PERMISSION_POLICY (allow | ask | reject) that
gates how session/request_permission calls are handled:

- reject (headless default): synchronous denial, byte-for-byte unchanged
  from today's dontAsk behavior; ResolvedPermissionConfig derives dontAsk
  mode so the adapter self-denies before Buzz sees the request.

- allow: synchronous auto-selection of the unique allow_once option from
  the exact options in the request; zero/multiple allow_once candidates
  or malformed options fail closed with a denial. Never allow_always,
  never hardcoded IDs.

- ask: interactive — emits an acp_read telemetry frame with an
  authorization envelope (requestNonce, actionable, reason) and registers
  a pending entry in a bounded map (cap=8) on AcpClient. The desktop
  delivers a permission_decision control frame carrying the nonce and
  chosen optionId; the read loop matches by nonce, validates the optionId
  against the captured option snapshot, and writes the ACP response.
  Per-request timeout min(300s, remaining hard deadline) fails closed.

Key implementation details:

- ResolvedPermissionConfig computed once at startup; transmits
  effective_mode via set_config_option for every agent that advertises
  the mode field (goose skipped).

- Admission preflight (synchronous, before map insertion): options
  nonempty, count ≤ 16, every optionId unique+nonempty, required
  kind/name fields, duplicate live requestId → immediate denial with
  original untouched, map at cap → deny, serialized payload ≤
  OBSERVER_MAX_PLAINTEXT_LEN.

- Cancel during writing → PermissionPoisoned error: surfaces through
  cancel_with_cleanup_grace so classify_control_cancel_failure triggers
  respawn (not pool return). PermissionPoisoned added to is_transport_error.
  Pending entries drained with cancelled responses before session/cancel.

- ask without observer or unresolved owner downgrades to reject with a
  loud warning.

- acp_read generic emit suppressed for ask permission requests; replaced
  with a single post-preflight enveloped emit (one frame per request).

- Decision receiver arm placed ahead of reader arm in the biased select!
  for inbound fairness.

- ObserverEvent gains optional authorization: Option<AuthorizationEnvelope>
  with skip_serializing_if. Payload bytes remain raw ACP, never mutated.

- NIP-AO.md reconciled: adds authorization envelope, permission_decision
  control type, control_result telemetry kind, switch_model control type,
  single-use nonce semantics, best-effort delivery with mandatory timeout,
  cancel-during-write poison behavior, and 5-minute desktop live lookback.

Tests: 720 passing (31 new pinned tests covering mode matrix, admission
preflight, allow selector, ask map lifecycle, cancel-during-writing poison,
policy × mode combinations, and decision arm behavior).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 6, 2026 20:29
Hayt and others added 4 commits August 6, 2026 16:34
…4938)

Add per-agent and fleet-wide permission policy configuration with
an actionable Allow/Deny card for the ask policy.

**Rust (desktop/src-tauri)**
- Add `permission_policy` module: `PermissionPolicy` enum (ask | allow |
  reject, lowercase serde), `PermissionPolicySource` (agent | global_default |
  built_in), and `resolve_effective_permission_policy` (precedence: per-agent
  > global > built-in ask)
- Add `permission_policy: Option<PermissionPolicy>` to `ManagedAgentRecord`
  (per-agent override) and `GlobalAgentConfig` (fleet default)
- Inject resolved policy as `BUZZ_ACP_PERMISSION_POLICY` env var at spawn;
  add to `RESERVED_ENV_KEYS` so users cannot override via env-vars UI
- Include `permission_policy` in `SpawnSnapshot` / restart-diff so edits
  surface in the existing `needsRestart` flow
- Expose `permission_policy` + `permission_policy_source` on
  `ManagedAgentSummary` (resolved values)
- Extend `UpdateManagedAgentRequest` with double-Option `permission_policy`
  (None = unchanged, Some(None) = clear, Some(Some(v)) = set); reject edits
  to remotely deployed agents with a clear error message
- Add remote-deployed agent path in `agents_deploy.rs`: read per-record
  policy, fall back to desktop default, inject into `policy_env`

**TypeScript (desktop/src)**
- `PermissionPolicy = "ask" | "allow" | "reject"` and
  `PermissionPolicySource = "agent" | "global_default" | "built_in"` in
  `types.ts`; add to `ManagedAgent`, `CreateManagedAgentInput`, and
  `UpdateManagedAgentInput` (null = clear per-agent override)
- `tauri.ts`: add `permission_policy` / `permission_policy_source` to
  `RawManagedAgent` with safe defaults; map in `fromRawManagedAgent`
- `agentSessionTypes.ts`: add `authorization?: { requestNonce, actionable,
  reason? }` to `ObserverEvent`; extend `lifecycle` `TranscriptItem` with
  `requestNonce`, `actionable`, `authorizationReason`, `options`
- `agentSessionTranscript.ts`: add `pendingPermissionsByNonce` map; parse
  `authorization` envelope from `session/request_permission` events;
  handle `control_result/permission_decision` to retire cards on terminal
  outcomes, including the pinned uncertain message
- `agentControl.ts`: add `sendPermissionDecision(pubkey, nonce, optionId)`
  fire-and-forget control API
- `LifecycleActivity.tsx`: `PermissionDecisionButtons` component renders
  per-option buttons styled by kind (reject_* = destructive); local pending
  state with retry on error; rendered when `actionable && !outcome`
- `AgentInstanceEditDialog.tsx`: permission policy select (Inherit / Ask /
  Allow / Reject) for local agents; read-only for remote-deployed agents with
  a shutdown+redeploy hint; shows effective value and source

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…utcome

Per interface note from Paul (2026-08-06): control_result statuses
(sent | no_active_turn | channel_full | channel_closed | no_channel)
confirm whether the permission_decision click was delivered to the
harness, not whether the permission was applied/denied.

Terminal outcomes arrive as enveloped acp_write frames correlated by
requestNonce. The card retirement matrix will be wired once Thufir's
review of Duncan's buzz-acp contract lands and NIP-AO is pinned.

Updated the control_result handler to preserve card actionability on
delivery — the PermissionDecisionButtons component already handles
button-level pending-state reset via its own catch handler if the
fire-and-forget send fails.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…can/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>
Add the Auto variant to PermissionMode (wire string 'auto'; #4557 adds the
same variant from the claude-config arc — this commit establishes the
contradiction logic ahead of that merge so the rebase is mechanical).

Auto mode = fully autonomous execution; model-gated (requires
supportsAutoMode); the adapter self-approves all tool calls internally
and never emits session/request_permission.

Mode matrix:
- allow + auto → compatible (transmit as-is; both want unattended approval)
- ask   + auto → startup error (card never fires — ask becomes a dead letter)
- reject + auto → startup error (inverted-security worst case: policy says
                  deny while adapter silently auto-approves everything)

Tests: 4 new pinned tests (allow+auto ok, ask+auto error, reject+auto error,
wire string correct). Total: 724 passing.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Duncan and others added 2 commits August 6, 2026 18:05
Harness (crates/buzz-acp/):
- Remove legacy single-slot (pending_permission_id/permission_responded) from
  ask path; map is sole source of truth; Writing state drops stored option id
- write_ndjson_no_observe: prevent duplicate generic+authorized telemetry on
  permission response paths
- Deadline logic: select min(earliest pending deadline, hard deadline) when any
  Pending entries exist; suspend idle while pending; drain map on turn exit
  and cancel completion to prevent capacity leak across reused sessions
- Pre-turn ask requests: force reject in non-turn reader (session/new path)
  so map entries can never be registered without a decision arm to resolve them
- Admission preflight: measure annotated ObserverEvent size (raw + envelope
  overhead constant) not just raw msg; add OBSERVER_EVENT_ENVELOPE_MAX = 512
- permission_denial_response: malformed reject_once (missing/empty optionId)
  falls back to cancelled instead of returning Protocol error
- ask+auto: change to compatible-with-warning; keep reject+auto hard error;
  auto is a model classifier not bypass mode (per adapter source review)
- Dead state: Writing(String) -> Writing; is_permission_poisoned() removed;
  PermissionMode::is_default #[cfg(test)]
- Tests: decision loop success, bad optionId idle-timeout, annotated-size
  preflight, malformed reject_once fallback, updated cancelled behavior tests

Desktop (desktop/):
- Thread channelId through PermissionDecisionButtons and sendPermissionDecision()
- Key permission cards by nonce; fallback to turn-based key for legacy paths
- control_result non-sent: set deliveryFailed on card; buttons re-enable via
  useEffect; add deliveryFailed field to TranscriptItem lifecycle type
- Fleet-wide permission_policy: add to TS GlobalAgentConfig, EMPTY_GLOBAL_CONFIG,
  and AgentDefaultsEditor fleet defaults select control
- Remote deploy: pass caller-resolved policy to build_launch_block; resolver
  tests in permission_policy.rs; deploy tests for all three policy sources
- Terminal outcomes: timed_out and uncertain (pinned copy) in describePermissionOutcome
- Tests: nonce-keyed card, concurrent cards, auth envelope, fallback key,
  channelId threading, delivery-failed/sent control_result (9 new)

NIP-AO (docs/nips/NIP-AO.md):
- switch_model: describe actual behavior; fix control_result statuses
- acp_write example: actionable=false; correct payload shape

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Add #[derive(Debug)] to PermissionEntry so test assertions can
  format entry_state:? in the paused-time test
- Update NIP-AO.md Authorization Envelope section: document the
  one-write/one-observe contract, enumerate terminal reason values
  (applied / timed_out / cancelled), and define the uncertain path
  (cancel-during-write = no acp_write, process respawned)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(acp): implement permission policy (#4938) feat(acp): implement harness-level tool permission policy (#4938) Aug 7, 2026
Duncan and others added 8 commits August 7, 2026 11:25
…h_permission() helper

Implements Thufir's minimal shape across three passes of residual defects:

Harness (acp.rs):
- Remove PermissionEntryState::Resolved — entries are removed from the map
  on every terminal transition (applied/timed_out/cancelled). The absence
  of a nonce is the replay guard; no tombstones means capacity counts only
  live (Pending|Writing) requests, fixing the 9th-request-in-one-turn bug.
- Add finish_permission() terminal helper owning Pending→Writing→Resolved
  for all terminals. Exactly one write+flush, exactly one nonce-correlated
  authorized acp_write with the terminal reason. Any write failure poisons
  the process and emits a permission_terminal observer-only event so Desktop
  can retire the card (the uncertain path).
- Cancel path: write failure also poisons and emits permission_terminal.
  cancel-during-write emits permission_terminal for the in-flight entry.
- Re-arm idle deadline when live pending count reaches zero so a slow human
  decision grants a fresh idle window instead of insta-cancelling the turn.
- Capacity check now counts only live (Pending|Writing) entries.
- Clippy: fix assert_eq!(x, true) → assert!(x), while-let-loop, doc
  overindented list items in acp.rs and config.rs.
- Fmt: cargo fmt applied.

Desktop (agentSessionTranscript.ts):
- acp_write authorized frames correlate exclusively by authorization.requestNonce
  (primary); JSON-RPC id correlation is a legacy fallback for non-ask paths.
- Terminal copy derives from authorization.reason (applied/timed_out/cancelled/
  uncertain) via describePermissionTerminalReason — timeout now renders 'Timed out'
  not 'Denied (reject_once)'.
- set actionable: false on all retirement paths.
- permission_terminal observer event handler retires the card via nonce.
- turn_completed and turn_error backstop: retireAllLivePermissionCards() retires
  any still-live cards so missing telemetry and archive replay cannot reconstruct
  live controls.
- Biome format applied.

lib.rs:
- fit_observer_event_to_budget: early return without mutation when
  event.authorization.is_some() — authorized frames are never leaf-trimmed
  or stubbed (NIP-AO §3 byte-for-byte requirement).
- Enqueue suppresses over-cap authorized frames entirely (defense in depth).
- Test: test_authorized_frame_payload_is_never_trimmed.

Tests:
- ask_production_path_emits_request_captures_nonce_and_delivers_decision:
  real script emits session/request_permission, harness captures nonce from
  in-process observer, routes decision through channel, asserts end_turn.
- cancel_writes_exactly_one_response_per_pending_id_no_replay: registers
  entries via production path, captures nonces before cancel, verifies each
  emitted cancel nonce matches a registered entry nonce, verifies no replay.
- ask_permission_idle_is_suspended_while_pending_entry_exists: paused-time,
  asserts entry present at 299s.
- ask_permission_deadline_fires_at_300_seconds: paused-time, asserts entry
  removed at exactly 300s.
- ask_permission_idle_rearmed_after_last_entry_resolves: paused-time,
  proves idle deadline re-armed after decision applied.
- ask_nine_sequential_requests_all_succeed_after_capacity_recovery: nine
  sequential requests each decided before the next is queued; asserts 9
  distinct authorized acp_write observer nonces.

NIP-AO.md:
- session_resolved = session establishment (not terminal).
- Added turn_completed and turn_error rows as terminal lifecycle events.
- uncertain path: permission_terminal observer event replaces wrong
  session_resolved reference.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Every ask terminal routes through finish_permission(); applied path
  stops on false (poison) immediately instead of looping back. Cancel
  path uses None idle sentinel instead of dummy instant.
- Deadline equality: process expired entries first at entry.deadline ==
  hard_deadline, then return HardTimeout — fail-closed response is
  always written before exit.
- Wire-truth tests: production-path, cancel, and nine-request tests now
  capture child stdin NDJSON and assert parsed exact lines/ids. Temporal
  tests rebuilt around one continuously running loop per scenario.
- Desktop nonce-present = nonce-only: unknown nonce drops the frame
  without falling back to the id map. Legacy fallback keyed by compound
  (channel:session:turn:id), never bare id. Both indexes cleaned on every
  terminal (acp_write, permission_terminal) and backstop (turn_completed,
  turn_error). New tests: FOREIGN-nonce drop + cleanup assertions on both
  indexes for all four terminal paths.
- NIP-AO: permission_terminal in frame-kind table; synchronous policy
  outcomes (rejected/allowed/allow_failed_closed) in reason table with
  explanatory note distinguishing ask vs. synchronous paths.
- Desktop: permission_terminal handler uses pinned uncertain copy; tests
  for live replay and lifecycle-only archive replay.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… two tests to pipe-level proof

Thread one nonce through each synchronous denial path so the acp_read
and acp_write telemetry frames always carry the same nonce. Before this
change, emit_permission_read_non_actionable generated its own nonce
internally while the caller passed a different nonce to
finish_permission_sync, producing one logical challenge/answer pair with
two different nonces. Desktop's nonce-only correlation rule left the read
card live because the write could never find it by nonce.

Fix: accept nonce as a parameter in emit_permission_read_non_actionable
(removing its internal new_permission_nonce() call) and drop the now-dead
caller_will_emit_read parameter from handle_permission_request and
emit_permission_read_with_nonce.

Upgrade two tests from telemetry-proxy assertions to direct pipe proofs:
- ask_permission_entry_deadline_equal_to_loop_hard_deadline_writes_denial_before_exit:
  replace observer telemetry assertion with a capture script that reads
  the denial line from child stdin NDJSON and parses the wire response.
- cancel_first_write_fails_stops_immediately_no_second_write: add a
  write-attempt counter (Arc<AtomicUsize> in write_ndjson_inner) to assert
  exactly ONE attempt was made and the loop stopped, not just that no
  successful writes occurred.

Add Rust tests proving the nonce is shared:
- sync_denial_malformed_options_read_and_write_carry_same_nonce
- sync_denial_preflight_failure_read_and_write_carry_same_nonce

Add TypeScript reducer tests:
- buildTranscript_sync_denial_write_with_matching_nonce_retires_card
- buildTranscript_sync_denial_write_with_mismatched_nonce_leaves_card_live

Update NIP-AO schema prose and observer.rs field comment to explicitly
document the permission_terminal exception to the authorization-only-on-
acp_read/acp_write rule.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ct payload

Replace the drop-and-restart select pattern with tokio::spawn (single
continuously running future), assert Err(AcpError::HardTimeout), and
prove the fail-closed payload via observer telemetry rather than file
capture (start_paused = true makes real-time file I/O unreliable for
virtual-time tests).

Four assertions now in place:
1. HardTimeout returned by the continuously running loop
2. Attempt counter == 1 (incremented before I/O in write_ndjson_inner)
3. Exactly one timed_out acp_write in observer
4. Payload id=1, outcome=selected, optionId=opt-reject

Zero production diff — all changes are within the test function.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ze ratchet

Extract permission-related types from agentSessionTranscript.ts to
agentSessionTranscriptPermissions.ts, from tauri.ts to tauriEditMessage.ts,
and from types.ts to permissionPolicy.ts. Refactor AgentPermissionPolicyField
to a self-managing forwardRef component and extract useRespondToField hook to
OwnerOnlyAccessField.tsx to bring AgentInstanceEditDialog.tsx under its cap.
Trim doc comments on new permission fields. Fix &mut borrow on
apply_permission_policy_update call in agent_models.rs.

All nine ratcheted files are now at or below their allowances. Zero semantic
change — all exports and behavior are preserved via re-exports from the
original modules.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (32 commits)
  Recover from max-token response truncation (#5223)
  chore(release): release Buzz Desktop version 0.5.6 (#5214)
  fix(mobile): keep latest messages above composer (#4981)
  fix(sdk): preserve self-mention p tags in message and forum event builders (#4975)
  bump @tauri-apps/cli to ~2.11.4 to fix linux app icon issue (#4858)
  feat(desktop): adding rich link previews to messages (#3818)
  fix(buzz-agent): Responses reasoning summary, Anthropic display:summarized, ACP v2 messageId (#5195)
  fix(desktop): retain distinct agent instances in autocomplete (#5202)
  fix(desktop): defer channel visibility change to Save (#5203)
  feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073)
  refactor(cli): replace probe/decider/detail split with single typed extractor (#5191)
  fix(desktop): drop unhandled rejection from throwing window.Notification (#5143)
  fix(desktop): fence localStorage SecurityError from killing the React tree (#5142)
  fix(desktop): make terminal output selectable (#4980)
  fix(desktop): use WEBKIT_DMABUF_RENDERER_FORCE_SHM for NVIDIA/AppImage (#3654) (#4505)
  Make public starter channels best effort (#5192)
  Mobile: add anchored reaction popover (#5025)
  feat(mobile): add bee pull-to-refresh (#5059)
  Remove agent creation success modal (#5063)
  fix(buzz-agent): escalate LLM timeouts per retry and log per-call latency (#5130)
  ...

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	desktop/src/shared/api/tauri.ts
…agedAgentMapping.ts

tauri.ts exceeded the file-size ratchet after main's #3818 moved editMessage out,
shrinking the allowance from 1175 to 1161. The six permission-policy additions to
tauri.ts that were within the old headroom now exceed the tighter baseline.

Extract RawManagedAgent type and fromRawManagedAgent function into a dedicated
shared/api/managedAgentMapping.ts module (mirrors main's editMessage.ts pattern).
tauri.ts re-exports both for zero caller changes. Removes now-unused imports
(ManagedAgentBackend, PermissionPolicy, PermissionPolicySource, RawRestartDiffEntry).

Result: tauri.ts 1073 gate vs 1161 allowed (88-line margin).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gn_with_default

Four test-setup sites created GlobalAgentConfig::default() then immediately
assigned permission_policy. Clippy 1.95 flags this as field-reassign-with-default.
Rewrite each site to use a struct literal with ..Default::default().

Files: agents_deploy.rs (1 site), permission_policy.rs (3 sites).
Zero semantic change — test-only correction.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@LucasMoskun

Copy link
Copy Markdown

Field validation from Buzz Desktop 0.5.7 on macOS, using a locally managed Claude agent with Claude Code 2.1.220 against a self-hosted relay:

The #4609 behavior created an abrupt upgrade regression for an existing managed agent. Before the desktop update, the agent could use the relay and perform implementation work. After updating, the same agent launched in dontAsk; every unapproved request was rejected with no desktop approval surface.

The failure progression demonstrates why the full permission policy in this PR is needed rather than only the narrow #5263 workaround:

  1. Preauthorizing Bash(buzz) and Bash(buzz:*) in the trusted local Claude settings restored relay reads and replies.
  2. The next workflow failed because even read-only git status was denied.
  3. Preauthorizing git restored repository commands, but native Edit/Write operations remained subject to the same problem.
  4. The only complete manual workaround was an owner-controlled blanket local allowlist including bare Bash, Edit, Write, and the other permission-requiring Claude tools.

That workaround restores autonomy, but it is all-or-nothing, hidden from the Buzz UI, and easy for users to misdiagnose as a relay, model, or harness-switching problem. The per-agent and fleet-wide ask | allow | reject policy plus actionable desktop cards in this PR is the correct product boundary: explicit owner intent rather than either silent approval or silent rejection.

Suggested upgrade/regression coverage:

  • Start with an existing managed agent created before fix(acp): reject unattended permission requests #4609, then upgrade into the new permission-policy build.
  • Verify the effective inherited/per-agent policy is visible in the UI and changing it triggers the required restart.
  • With allow, verify a fresh worker can run standalone buzz, git, and file-edit operations without an interactive prompt.
  • With ask, verify concurrent approval cards render and resolve correctly for a locally managed agent connected to a self-hosted relay.
  • With reject, verify requests remain fail-closed.
  • Verify the selected policy follows the actual effective harness when a user switches Claude and Codex, rather than only the global default.

The final point matters because our same reproduction also encountered #5054: a stale per-agent agent_command_override could leave the actual harness different from the UI/global runtime selection. Permission policy and harness identity should be displayed from the same effective spawn configuration.

@wolfyy970 wolfyy970 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right boundary for portable agents: the definition can request tools, while the execution target decides how permission requests are handled. I would not merge this head yet because four paths can make the saved policy differ from what actually runs.

  • Desktop reserves BUZZ_ACP_PERMISSION_POLICY, but not BUZZ_ACP_PERMISSION_MODE. Definition env is written after the policy, so acceptEdits can bypass Ask or Reject before ACP asks Buzz. Managed local and provider launches should derive the mode from the selected policy and strip user overrides. Bare CLI use can keep the explicit mode.
  • The permission receiver is taken by the first prompt and not restored. A new session's initial_message can consume it before the real prompt. Heartbeats have no receiver at all, but can still emit an actionable card. Only emit an actionable Ask request when a routable decision channel exists.
  • Permission responses use several awaited writes. Cancel can drop one after partial output, then send another cancellation response. Every permission response path needs the same persistent Writing or poisoned state so cancellation cannot produce a second answer.
  • Remote deployment records the policy in its launch payload, but the UI later recomputes the current desired value. Changing the global default can therefore display Reject while the remote process still runs Allow. Keep the applied policy in the deployment receipt and show Redeploy required when it drifts.

The UI should also say that this governs ACP permission requests, not tools that run without asking Buzz. Unknown option kinds and allow_always should not appear as an ordinary green Allow button.

The focused permission tests pass, but they do not cover these paths. The missing regressions are: definition mode override, initial-message then main-prompt approval, heartbeat Ask, cancel during a blocked permission write, and remote desired-versus-applied drift.

@wolfyy970

Copy link
Copy Markdown

@wpfleger96 I prepared the first review fix as signed commit aeb8f71eb directly on top of your head.

It reserves BUZZ_ACP_PERMISSION_MODE across Desktop configuration, removes any inherited process value before launch, and rejects Reject + acceptEdits before startup. The focused ACP and Desktop tests, formatting, and strict Clippy pass; the adversarial re-review is clean.

You can cherry-pick it as-is. I kept it dependent on #5106 rather than opening another competing PR.

@wolfyy970

Copy link
Copy Markdown

I pushed the second independent fix from my review as wolfyy970@a83200bf7.

It keeps the permission-decision route alive across the initial message and the main prompt. Ask now fails closed and non-actionable when no live route exists, including heartbeat tasks.

The focused Ask and permission tests pass, strict clippy passes, and the adversarial review is clean. This can be cherry-picked after aeb8f71.

@wolfyy970

Copy link
Copy Markdown

The third review fix is wolfyy970@eeaedd910.

If a control signal interrupts a permission response after stdin has accepted some bytes, cleanup now reports the result as uncertain, sends no second JSON-RPC response, and forces process replacement. The regression backpressures a real child pipe and proves there is exactly one write attempt.

All 740 buzz-acp library tests and strict Clippy pass. The adversarial review is clean. This can be cherry-picked after a83200b.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at exact head e87f265d258a1816995f743e4fee85d4a2ee6c02 (and separately inspected the proposed follow-up stack through eeaedd910f3df5cdd3e6b937f6e920ee0f6acfa9).

The proposed three-commit stack addresses the mode-override, decision-route lifetime, and interrupted-write findings, but I do not think this is release-safe yet:

  1. Persist and display the remotely applied policy, not only current desired policy. build_deploy_payload resolves a policy into launch.policy_env, but ManagedAgentSummary later recomputes from the mutable record/global config. If the fleet default changes after deployment, Desktop can display reject while the remote worker still runs allow. The deployment receipt/record needs the applied value, and the UI should show desired-vs-applied drift as redeploy-required. Add a regression covering deploy under Allow, mutate global default to Reject, and verify the UI continues to truthfully expose applied Allow plus drift.

  2. Do not render unknown or persistent options as ordinary green Allow buttons. PermissionDecisionButtons currently classifies everything not starting with reject as Allow. That makes unknown kinds and allow_always look equivalent to allow_once. Only recognized one-shot and reject choices should be actionable under this feature, or persistent grants must receive explicit differentiated semantics/copy. Unknown kinds should fail closed/non-actionable. Add reducer/render tests for allow_once, reject_once, allow_always, and unknown kinds.

  3. Release validation must use actual adapters, not only scripts. This PR changes the mode sent into runtime-specific ACP adapters and claims to restore permission-requiring tools. Before release, exercise exact built buzz-acp plus current managed Claude/Codex/Buzz Agent/Goose versions under Ask, Allow, and Reject. At minimum prove: Buzz read/reply; a representative workspace read and edit; Ask card round-trip; Allow selects only offered allow_once; Reject denies; unattended escalation remains blocked; network/filesystem boundaries remain intact. Codex's separate workspace-write network defect still requires its adapter fix.

This is the correct architectural boundary and the harness work is impressively defensive, but CI green does not establish cross-adapter behavior. Please land the existing three fixes, resolve the two remaining truth/UI issues, and attach exact-runtime evidence before merging as the full recovery.

@wolfyy970

Copy link
Copy Markdown

I pushed 73e94f229 for the remaining option-kind boundary.

Ask now exposes only allow_once and reject_once. Persistent, unknown, or malformed choices fail closed, and unfamiliar historical outcomes are no longer presented as an ordinary green Allow. For allow-only requests, Desktop offers Cancel only when the harness explicitly advertises that control, so mixed Desktop and harness versions do not get a dead button.

The regressions cross the real ACP wire and the rendered Desktop card. The full ACP suite passed with the known unrelated timer flake excluded, all 4,565 Desktop tests passed, and strict Clippy, typecheck, formatting, and the adversarial review are clean. This can be cherry-picked after eeaedd910.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants