kb://architecture/TUI_PERMISSIONS_B2_APPROVAL_PROTOCOL_2026-06-16active2026-06-17

#489 Phase B2 — Approval-request suspend/resume protocol (implementation design)

openalicedesignalice-core

#489 Phase B2 — Approval-request suspend/resume protocol (implementation design)

Author: Norbert · Date: 2026-06-16 · Status: design — ready to implement on a branch Builds on: Plan mode (B-plan, merged to mvp @ 6adc2d82c) — the is_mutating_tool gate + ConvContext.plan_mode are the foundation B2 generalises.

Goal

Generalise Plan mode's refuse-mutating-tools into pause-and-ask: in normal and auto-edit modes, when the agentic loop is about to run a mutating tool it suspends the turn, asks the client to approve, and resumes (run) or skips (deny) based on the answer. plan=refuse-all (done), yolo=approve-all (current default). The hard part is suspend/resume on the streaming loop — the code where the steer/coalesce/cancel races lived (Phase 8OOO). This design keeps that surgery minimal, additive, and env-gated OFF so prod is byte-identical until opted in.

Mode semantics

modemutating tool behaviour
yolo (default)run, no prompt (current behaviour)
auto-editfile-edit tools auto-run; other mutations prompt
normalevery mutating tool prompts
planrefuse all mutations (B-plan, shipped)

Plus a server-side denylist safety floor (~/.ssh, .env*, deploy scripts, secrets keys) that prompts even under yolo.

The suspend/resume mechanism (the crux)

A per-turn approval registry in AppState:

// approval_id -> oneshot sender the awaiting loop is parked on.
pub approvals: Arc<DashMap<String, tokio::sync::oneshot::Sender<ApprovalDecision>>>,

Flow when the loop hits a gated mutating tool:

  1. mint approval_id (uuid); create oneshot::channel(); insert sender into the map.
  2. emit SSE event {"type":"approval_request","approval_id","tool","summary","is_mutating":true}.
  3. tokio::select! await the receiver with a timeout (e.g. 120s): - Ok(Allow|AllowAlways) → execute the tool (AllowAlways records a standing rule). - Ok(Deny) or timeout or channel drop → skip with a synthetic Tool result ("[denied] tool X not run"), exactly like the Plan-mode skip path.
  4. always remove the approval_id from the map (RAII guard, so cancel/disconnect cleans up).

Decision endpoint: POST /v1/chat/approve {approval_id, decision} → look up the sender, .send(decision), return 200/410(gone). No body streaming; tiny handler.

Why this is safe vs the race history

  • The loop already .awaits (LLM calls, tool exec); adding one more bounded select!{ recv, sleep(timeout) } doesn't change the cancel path — the existing cancel token still aborts the whole task; on abort the RAII guard drops the map entry, the client's pending approval 410s. No new shared mutable turn state.
  • Default OFF: when approval_mode ∈ {yolo} (the default), the gate is a no-op branch taken before the channel machinery — zero behaviour change.

File touchpoints (each its own gated slice)

  • B2.1 (infra, low-risk, no loop change): AppState.approvals map + ApprovalDecision enum + POST /v1/chat/approve handler + the SSE event type. Env-gated; nothing emits yet. Unit-test the endpoint↔map round-trip.
  • B2.2 (server gate, the delicate slice): in run_agentic_loop AND the two stream inline loops (chat.rs:~1150/1630, where Plan mode's gate already lives), replace the plan-only branch with: classify by approval_mode → run / refuse / suspend-via-registry. Reuse the existing skip-result emit. Integration test with a ScriptedProvider + a background task that approves/denies/times-out.
  • B2.3 (client): Overlay::Approval{approval_id,tool,summary} (mirror the theme/config overlays already shipped) + key routing (a allow / d deny / A allow-always / Esc deny) + the decision POST in client.rs. Parse the approval_request SSE event in the stream parser.
  • B2.4: AllowAlways standing-rule store + /approvals CLI noun + budget kill-switch (force-suspend when a per-turn token/time ceiling trips).

Gating + rollout

  • One env flag OPENALICE_APPROVAL_MODE (default yolo) gates the whole thing; the TUI sends approval_mode per request (extends the plan_mode field already added). Until a client sends a non-yolo mode, every path is byte-identical.
  • Branch feat/permissions-b2; each slice B2.1→B2.4 builds + tests green; full openalice-alice build + a lab smoke (suspend → approve → resume; suspend → timeout → deny) before mvp merge — same gate Plan mode just passed.

Test plan

  • Unit: endpoint↔registry round-trip; timeout auto-denies; drop cleans the map.
  • Integration (ScriptedProvider): normal-mode mutating call → suspends → a spawned approver sends Allow → tool runs; sends Deny → skipped; no decision → timeout-deny.
  • Live lab smoke on the rebuilt alice before merge.

Honest risk note

B2.2 is the one slice touching the streaming suspend path. It must be written fresh + unhurried with the integration test first (TDD), not at a marathon tail — that discipline is exactly what kept Plan mode regression-free. B2.1/B2.3/B2.4 are additive + low-risk and can land first to de-risk B2.2.