feat(acp): implement permission policy (#4938) - #5106
Open
wpfleger96 wants to merge 7 commits into
Open
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the harness-level permission policy for issue #4938 (full stack: ACP harness + Desktop + NIP-AO doc update).
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)