Skip to content

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

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

feat(acp): implement permission policy (#4938)#5106
wpfleger96 wants to merge 7 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).

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>
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.

1 participant