#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
| mode | mutating tool behaviour |
|---|---|
yolo (default) | run, no prompt (current behaviour) |
auto-edit | file-edit tools auto-run; other mutations prompt |
normal | every mutating tool prompts |
plan | refuse 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:
- mint
approval_id(uuid); createoneshot::channel(); insert sender into the map. - emit SSE event
{"type":"approval_request","approval_id","tool","summary","is_mutating":true}. 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.- always remove the
approval_idfrom 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 boundedselect!{ 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.approvalsmap +ApprovalDecisionenum +POST /v1/chat/approvehandler + 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_loopAND the two stream inline loops (chat.rs:~1150/1630, where Plan mode's gate already lives), replace the plan-only branch with: classify byapproval_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 theapproval_requestSSE event in the stream parser. - B2.4:
AllowAlwaysstanding-rule store +/approvalsCLI noun + budget kill-switch (force-suspend when a per-turn token/time ceiling trips).
Gating + rollout
- One env flag
OPENALICE_APPROVAL_MODE(defaultyolo) gates the whole thing; the TUI sendsapproval_modeper request (extends theplan_modefield 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; fullopenalice-alicebuild + 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.