feat(acp): implement harness-level tool permission policy (#4938) - #5106
feat(acp): implement harness-level tool permission policy (#4938)#5106wpfleger96 wants to merge 15 commits into
Conversation
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>
…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>
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>
…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>
|
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 The failure progression demonstrates why the full permission policy in this PR is needed rather than only the narrow #5263 workaround:
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 Suggested upgrade/regression coverage:
The final point matters because our same reproduction also encountered #5054: a stale per-agent |
wolfyy970
left a comment
There was a problem hiding this comment.
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 notBUZZ_ACP_PERMISSION_MODE. Definition env is written after the policy, soacceptEditscan 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_messagecan 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.
|
@wpfleger96 I prepared the first review fix as signed commit It reserves You can cherry-pick it as-is. I kept it dependent on #5106 rather than opening another competing PR. |
|
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. |
|
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
left a comment
There was a problem hiding this comment.
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:
-
Persist and display the remotely applied policy, not only current desired policy.
build_deploy_payloadresolves a policy intolaunch.policy_env, butManagedAgentSummarylater recomputes from the mutable record/global config. If the fleet default changes after deployment, Desktop can displayrejectwhile the remote worker still runsallow. 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. -
Do not render unknown or persistent options as ordinary green Allow buttons.
PermissionDecisionButtonscurrently classifies everything not starting withrejectas Allow. That makes unknown kinds andallow_alwayslook equivalent toallow_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 forallow_once,reject_once,allow_always, and unknown kinds. -
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-acpplus 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 offeredallow_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.
|
I pushed 73e94f229 for the remaining option-kind boundary. Ask now exposes only 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 |
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
bypassPermissionsauto-approve default so thatsession/request_permissionfails 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:allowis explicit opt-in only, ambiguous outcomes resolve to deny, and the newaskdefault surfaces the request to the user instead of silently answering it.What this PR does
ACP Harness (
crates/buzz-acp/):PermissionPolicyenum (ask/allow/reject) read fromBUZZ_ACP_PERMISSION_POLICYenv varResolvedPermissionConfigresolves policy + permission mode at startup; rejects contradictions (dontAsk+ask,dontAsk+allow,reject+auto)ask+autois compatible-with-warning:autois a model classifier, not bypass mode — residual escalations still surface cards; internally-approved calls bypass the ask flow silentlyPermissionEntrywithPending→Writing→Resolvedlifecycleselect! min(earliest pending deadline, hard deadline); idle deadline suspended while any entry is pendingwrite_ndjson_no_observewrite + single authorizedacp_writeobserver emit per decision; no legacy single-slot duplicationObserverEventsize (raw +OBSERVER_EVENT_ENVELOPE_MAX = 512) againstOBSERVER_MAX_PLAINTEXT_LENpermission_denial_response: malformedreject_once(missing/emptyoptionId) falls back tocancelledsession/new) permission requests: forced reject (no decision arm available)PermissionPoisonedreturned; process is respawnedDesktop (
desktop/):PermissionPolicyRust enum +PermissionPolicySource+resolve_effective_permission_policyinpermission_policy.rs; precedence: per-agent > global > built-inaskManagedAgentRecord.permission_policy+GlobalAgentConfig.permission_policy(Rust and TypeScript)AgentDefaultsEditor+EMPTY_GLOBAL_CONFIGBUZZ_ACP_PERMISSION_POLICYat local spawn and remote deploy (shared resolver)UpdateManagedAgentRequestdouble-Option; server rejects remote-deployed editsauthorizationenvelope onacp_readframes parsed by transcript reducerpermission:ch:nonce:N) for concurrent request isolation; legacy turn-keyed fallback for non-ask pathsPermissionDecisionButtonscomponent withchannelIdthreaded end-to-endcontrol_resultdelivery failure: setsdeliveryFailedon card item;useEffectre-enables buttons for retrytimed_out,uncertain(pinned verbatim copy) indescribePermissionOutcomebuild_launch_blockacceptseffective_permission_policyfrom callerNIP-AO (
docs/nips/NIP-AO.md):switch_model: accurate behavior description (busy=cancel+requeue, idle=immediate); correctcontrol_resultstatuses (sent|turn_ending|switched|unsupported_model|no_active_turn)acp_writeexample:actionable: false(terminal, applied); correct payload shape (result.outcome.outcome=selected)Known CI failure — file-size ratchet
The ratchet checks growth against base
6eb65919ffor 9 files. All growth is unavoidable for the new fields and tests. Table:src-tauri/src/commands/agent_models.rssrc-tauri/src/commands/agents.rssrc-tauri/src/managed_agents/discovery/tests.rssrc-tauri/src/managed_agents/readiness.rssrc-tauri/src/managed_agents/types.rssrc/features/agents/ui/AgentInstanceEditDialog.tsxsrc/features/agents/ui/agentSessionTranscript.tssrc/shared/api/tauri.tssrc/shared/api/types.tsRatchet 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)