返回 DeepSeek-Reasonix
approval.go
根目录 / internal / control / approval.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strconv"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/event"
13 "reasonix/internal/i18n"
14 "reasonix/internal/permission"
15 "reasonix/internal/permissionpreset"
16 )
17
18 // Approve answers a pending ApprovalRequest by ID. It remains the compatibility
19 // bridge for clients that do not yet call the scope-aware resolver directly.
20 func (c *Controller) Approve(id string, allow, session, persist bool) {
21 _ = c.approveChecked(id, allow, session, persist)
22 }
23
24 func (c *Controller) approveChecked(id string, allow, session, persist bool) error {
25 if allow && persist {
26 return fmt.Errorf("permanent approval is no longer supported; allow once or for this session")
27 }
28 if pending := c.approval.peek(id); pending.reply != nil && pending.kind == writeAccessKind {
29 return c.ResolveApproval(id, allow, scopeFromApprove(allow, session, persist))
30 }
31 pending, ok, err := c.approval.resolveAfter(id, func(p pendingApproval) error {
32 state := PromptRejected
33 if allow {
34 state = PromptAnswered
35 }
36 return c.emitTurnEventChecked(event.Event{Kind: event.PromptAnswered, ItemID: id, InteractionState: string(state), Status: event.TurnInProgress})
37 })
38 if err != nil {
39 return err
40 }
41 if !ok || pending.reply == nil {
42 return nil
43 }
44 terminal := PromptRejected
45 if allow {
46 terminal = PromptAnswered
47 }
48 c.promptOwner.MarkIDTerminal(id, terminal)
49 outcome := "deny"
50 if pending.tool == planApprovalTool {
51 outcome = string(PlanDecisionRevisePlan)
52 if allow {
53 outcome = string(PlanDecisionStartExecution)
54 }
55 } else if allow {
56 switch {
57 case persist:
58 outcome = "allow_persistent"
59 case session:
60 outcome = "allow_session"
61 default:
62 outcome = "allow_once"
63 }
64 }
65 c.recordDecisionReceipt(pending, outcome)
66 pending.reply <- approvalReply{allow: allow, session: session, persist: persist}
67 return nil
68 }
69
70 // approvalManager owns the approval/ask prompt bookkeeping and the runtime
71 // approval posture, behind its own locks and off the controller's c.mu. It is a
72 // strict leaf: its methods only touch its own state and never call back into the
73 // Controller. The Controller keeps the I/O orchestration (emitting events,
74 // firing hooks, rebuilding the executor gate) that needs its other collaborators
75 // — approval, unlike the goal FSM, blocks on user input and has side effects, so
76 // only the bookkeeping is extracted, not the orchestration.
77 type approvalManager struct {
78 // policy is the immutable base permission policy, captured at construction.
79 // Used to decide whether a tool call would auto-approve under the writer
80 // fallback (autoApprovalWouldAllowLocked); the Controller keeps its own copy
81 // for building the executor gate.
82 policy permission.Policy
83
84 // mu guards the prompt maps and posture fields; every critical section under
85 // it is short and non-blocking.
86 mu sync.Mutex
87 approvals map[string]pendingApproval
88 asks map[string]pendingAsk
89 approvalResolutions map[string]*promptResolution
90 askResolutions map[string]*promptResolution
91 granted map[string]bool
92 planModeReadOnlyCommands map[string]bool
93 nextID int
94 // toolApprovalMode is the canonical runtime permission preset. Read-only
95 // asks for mutation authorization, workspace-write permits confined work,
96 // and danger-full-access skips ordinary prompts while explicit deny rules
97 // and fresh decisions remain enforced.
98 toolApprovalMode string
99 // approvalTimeout bounds how long requestApproval/Ask block on a user
100 // decision. Zero means wait indefinitely (correct for an interactive
101 // terminal); bot/headless frontends set it so a walked-away user can't wedge
102 // the session forever (#4626, #4402). Write-once at construction.
103 approvalTimeout time.Duration
104 // planAutoApprove auto-allows the ordinary writer fallback while a
105 // just-approved plan executes. Explicit ask/deny rules and fresh decisions
106 // remain authoritative, matching Auto rather than YOLO semantics.
107 planAutoApprove bool
108
109 // promptEmitMu serializes the short registration-and-publication handoff with
110 // an SSE attach. It is never held while waiting for a user's answer: each
111 // interaction owns an independent cancellable reply channel.
112 promptEmitMu sync.Mutex
113
114 // mcpInteractions holds pending MCP elicitations, guarded by mu and
115 // grouped so the struct-state ratchet grows by one field.
116 mcpInteractions mcpInteractionState
117 }
118
119 type promptResolution struct {
120 done chan struct{}
121 joined chan struct{}
122 joinOnce sync.Once
123 err error
124 }
125
126 func newPromptResolution() *promptResolution {
127 return &promptResolution{done: make(chan struct{}), joined: make(chan struct{})}
128 }
129
130 func (r *promptResolution) wait() error {
131 if r == nil {
132 return nil
133 }
134 r.joinOnce.Do(func() { close(r.joined) })
135 <-r.done
136 return r.err
137 }
138
139 func newApprovalManager(policy permission.Policy, mode string, timeout time.Duration) approvalManager {
140 return approvalManager{
141 policy: policy,
142 approvals: map[string]pendingApproval{},
143 asks: map[string]pendingAsk{},
144 approvalResolutions: map[string]*promptResolution{},
145 askResolutions: map[string]*promptResolution{},
146 granted: map[string]bool{},
147 planModeReadOnlyCommands: map[string]bool{},
148 toolApprovalMode: mode,
149 approvalTimeout: timeout,
150 }
151 }
152
153 // NewHeadlessPermissionGate builds the legacy bootstrap gate used before a
154 // frontend declares its approval posture. Interactive frontends replace it
155 // before running; callers that are actually headless must pass a non-empty mode
156 // through BuildHeadlessApprovalGate.
157 func NewHeadlessPermissionGate(policy permission.Policy) *freshHumanHeadlessGate {
158 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, nil)}
159 }
160
161 // BuildHeadlessApprovalGate constructs the non-interactive gate for a given
162 // approval mode, matching the contract ApplyHeadlessApprovalMode installs on a
163 // running controller's parent executor. boot uses this as the single
164 // construction point for every headless-only gate — the top-level executor,
165 // the `task`/`read_only_task` sub-agent, writer-capable skill sub-agents
166 // (run_skill/install_skill), and the planner runner — so all of them share the
167 // CLI-selected headless approval mode instead of only the parent executor
168 // getting it while the rest silently keep the mode-unaware default, which let
169 // a task sub-agent run a write an explicit ask
170 // rule was supposed to deny under auto.
171 func BuildHeadlessApprovalGate(policy permission.Policy, mode string) *freshHumanHeadlessGate {
172 // An empty mode is the boot-time placeholder used by interactive frontends
173 // before they install their real gate. Keep that compatibility path distinct
174 // from an explicit headless Ask posture, which has nobody to approve it.
175 if strings.TrimSpace(mode) == "" {
176 return NewHeadlessPermissionGate(policy)
177 }
178 switch normalizeToolApprovalMode(mode) {
179 case ToolApprovalDangerFullAccess:
180 policy.Mode = permission.Allow
181 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, nil)}
182 case ToolApprovalWorkspaceWrite:
183 policy.Mode = permission.Allow
184 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
185 case ToolApprovalDontAsk:
186 policy.Mode = permission.Deny
187 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
188 default:
189 policy.Mode = permission.Deny
190 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
191 }
192 }
193
194 // SharedHeadlessGate is a mutable, concurrency-safe holder for the
195 // non-interactive gate that every headless-only sub-agent surface shares —
196 // `task`/`read_only_task`, writer-capable skill sub-agents, and the planner
197 // runner. Those surfaces capture their gate once at construction with no
198 // rebuild hook of their own, unlike the parent executor's gate (rebuilt in
199 // place via Agent.SetGate on every SetToolApprovalMode/
200 // ApplyHeadlessApprovalMode call). Every consumer holds this same pointer and
201 // reads through Check, so a runtime approval-mode switch (interactive
202 // Shift+Tab, or a headless --permission-mode passed at boot) only needs to
203 // call Update here to keep sub-agents on the same contract as the parent
204 // instead of silently pinning them to whatever mode was active when they were
205 // first constructed.
206 type SharedHeadlessGate struct {
207 mu sync.RWMutex
208 policy permission.Policy
209 gate *freshHumanHeadlessGate
210 }
211
212 // NewSharedHeadlessGate builds a shared gate holder from the base policy and
213 // the initial approval mode (see BuildHeadlessApprovalGate for the mode
214 // contract).
215 func NewSharedHeadlessGate(policy permission.Policy, mode string) *SharedHeadlessGate {
216 g := &SharedHeadlessGate{policy: policy}
217 g.Update(mode)
218 return g
219 }
220
221 // Update rebuilds the held gate for a new approval mode. Safe to call
222 // concurrently with Check (a turn may be mid-flight on another goroutine when
223 // the user switches modes).
224 func (g *SharedHeadlessGate) Update(mode string) {
225 next := BuildHeadlessApprovalGate(g.policy, mode)
226 g.mu.Lock()
227 g.gate = next
228 g.mu.Unlock()
229 }
230
231 func (g *SharedHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
232 g.mu.RLock()
233 gate := g.gate
234 g.mu.RUnlock()
235 return gate.Check(ctx, toolName, args, readOnly)
236 }
237
238 func (g *SharedHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
239 g.mu.RLock()
240 gate := g.gate
241 g.mu.RUnlock()
242 return gate.ExplicitlyDenies(toolName, args)
243 }
244
245 type freshHumanHeadlessGate struct {
246 gate *permission.Gate
247 allowLowRiskFreshAction func(toolName string, args json.RawMessage) bool
248 }
249
250 func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
251 if RequiresFreshHumanApprovalTool(toolName) {
252 if !g.gate.ExplicitlyDenies(toolName, args) &&
253 g.allowLowRiskFreshAction != nil &&
254 g.allowLowRiskFreshAction(toolName, args) {
255 return true, "", nil
256 }
257 return false, "this tool requires fresh human approval and cannot run in a non-interactive session. Use an interactive session or a user-initiated memory command.", nil
258 }
259 return g.gate.Check(ctx, toolName, args, readOnly)
260 }
261
262 func (g *freshHumanHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
263 return g.gate.Policy.ExplicitlyDenies(toolName, args)
264 }
265
266 // preApproved reports whether a tool call can skip the prompt — either the
267 // posture bypasses it (YOLO / plan-execution window) or a session grant already
268 // covers the scope.
269 func (a *approvalManager) preApproved(tool, subject string, args json.RawMessage) bool {
270 a.mu.Lock()
271 defer a.mu.Unlock()
272 return a.bypassAllowsLocked(tool, subject, args) || a.sessionGrantAllowsLocked(tool, subject)
273 }
274
275 // preApprovedForDecision reports whether a prompt can be skipped for a decision
276 // class. Fresh user decisions may reuse an explicit session grant, but they are
277 // never answered by YOLO/full-access or the approved-plan execution window.
278 func (a *approvalManager) preApprovedForDecision(tool, subject string, args json.RawMessage, fresh bool) bool {
279 return a.preApprovedForDecisionOptions(tool, subject, args, fresh, false)
280 }
281
282 func (a *approvalManager) preApprovedForDecisionOptions(tool, subject string, args json.RawMessage, fresh, requireHuman bool) bool {
283 a.mu.Lock()
284 defer a.mu.Unlock()
285 if fresh {
286 return a.sessionGrantAllowsLocked(tool, subject)
287 }
288 if requireHuman {
289 return a.toolApprovalMode == ToolApprovalYolo || a.sessionGrantAllowsLocked(tool, subject)
290 }
291 return a.bypassAllowsLocked(tool, subject, args) || a.sessionGrantAllowsLocked(tool, subject)
292 }
293
294 // preApprovedForExactSession is used for a retry that crosses the active
295 // sandbox boundary. It deliberately avoids the normal Bash prefix expansion:
296 // authorizing one failed command must not authorize a different invocation.
297 func (a *approvalManager) preApprovedForExactSession(tool, subject string) bool {
298 a.mu.Lock()
299 defer a.mu.Unlock()
300 return a.granted[exactSessionGrantRule(tool, subject)]
301 }
302
303 // register allocates an approval ID, records the pending prompt, and returns the
304 // reply channel the resolve path will signal.
305 func (a *approvalManager) register(tool, subject, reason string) (string, chan approvalReply) {
306 return a.registerWithInput(tool, subject, reason, nil)
307 }
308
309 func (a *approvalManager) registerWithInput(tool, subject, reason string, rawInput json.RawMessage) (string, chan approvalReply) {
310 return a.registerDecisionWithInput(tool, subject, reason, rawInput, false, false)
311 }
312
313 // registerDecision allocates an approval ID for either an ordinary tool
314 // permission or a fresh user decision. Fresh decisions are not auto-drained when
315 // the user switches to auto/yolo tool approval while the prompt is visible.
316 func (a *approvalManager) registerDecision(tool, subject, reason string, fresh, requireHuman bool) (string, chan approvalReply) {
317 return a.registerDecisionWithInput(tool, subject, reason, nil, fresh, requireHuman)
318 }
319
320 func (a *approvalManager) registerDecisionWithInput(tool, subject, reason string, rawInput json.RawMessage, fresh, requireHuman bool) (string, chan approvalReply) {
321 return a.registerDecisionKindWithInput(tool, subject, reason, rawInput, fresh, requireHuman, "", nil)
322 }
323
324 // registerDecisionKind is registerDecision with optional Kind/Recovery payload
325 // so ordinary permission and plan prompts survive ReplayPendingPrompts.
326 func (a *approvalManager) registerDecisionKind(tool, subject, reason string, fresh, requireHuman bool, kind string, rec *event.RecoveryApproval) (string, chan approvalReply) {
327 return a.registerDecisionKindWithInput(tool, subject, reason, nil, fresh, requireHuman, kind, rec)
328 }
329
330 func (a *approvalManager) registerDecisionKindWithInput(tool, subject, reason string, rawInput json.RawMessage, fresh, requireHuman bool, kind string, rec *event.RecoveryApproval) (string, chan approvalReply) {
331 a.mu.Lock()
332 defer a.mu.Unlock()
333 a.nextID++
334 id := strconv.Itoa(a.nextID)
335 reply := make(chan approvalReply, 1)
336 autoDrain := false
337 if !fresh && !requireHuman {
338 autoDrain = a.autoApprovalWouldAllowLocked(tool, subject, rawInput)
339 }
340 a.approvals[id] = pendingApproval{
341 id: id,
342 tool: tool, subject: subject, reason: reason, rawInput: append(json.RawMessage(nil), rawInput...), fresh: fresh, requireHuman: requireHuman,
343 autoDrain: autoDrain, kind: kind, recovery: rec, reply: reply,
344 }
345 return id, reply
346 }
347
348 func (a *approvalManager) registerWriteAccess(tool, subject, reason string, rawInput json.RawMessage, payload *event.WriteAccessApproval) (string, chan approvalReply) {
349 a.mu.Lock()
350 defer a.mu.Unlock()
351 a.nextID++
352 id := strconv.Itoa(a.nextID)
353 reply := make(chan approvalReply, 1)
354 a.approvals[id] = pendingApproval{
355 id: id, tool: tool, subject: subject, reason: reason,
356 rawInput: append(json.RawMessage(nil), rawInput...),
357 fresh: true, requireHuman: true, kind: writeAccessKind,
358 writeAccess: event.NormalizeWriteAccessApproval(payload), reply: reply,
359 }
360 return id, reply
361 }
362
363 func (a *approvalManager) peek(id string) pendingApproval {
364 a.mu.Lock()
365 defer a.mu.Unlock()
366 return a.approvals[id]
367 }
368
369 // grantSession records a session-scoped grant so future calls in the same scope
370 // short-circuit.
371 func (a *approvalManager) grantSession(tool, subject string) {
372 a.mu.Lock()
373 defer a.mu.Unlock()
374 a.granted[permission.SessionGrantRuleForScope(tool, subject)] = true
375 }
376
377 func (a *approvalManager) grantExactSession(tool, subject string) {
378 a.mu.Lock()
379 defer a.mu.Unlock()
380 a.granted[exactSessionGrantRule(tool, subject)] = true
381 }
382
383 func exactSessionGrantRule(tool, subject string) string {
384 tool = strings.TrimSpace(tool)
385 subject = strings.TrimSpace(subject)
386 if strings.EqualFold(tool, "bash") && subject != "" {
387 return "Bash=" + subject
388 }
389 return permission.SessionGrantRuleForScope(tool, subject)
390 }
391
392 func (a *approvalManager) planModeReadOnlyCommandTrusted(prefix string) bool {
393 prefix = normalizePlanModeReadOnlyCommandPrefix(prefix)
394 if prefix == "" {
395 return false
396 }
397 a.mu.Lock()
398 defer a.mu.Unlock()
399 return a.planModeReadOnlyCommands[prefix]
400 }
401
402 func (a *approvalManager) grantPlanModeReadOnlyCommand(prefix string) {
403 prefix = normalizePlanModeReadOnlyCommandPrefix(prefix)
404 if prefix == "" {
405 return
406 }
407 a.mu.Lock()
408 defer a.mu.Unlock()
409 a.planModeReadOnlyCommands[prefix] = true
410 }
411
412 func (a *approvalManager) revokeSessionAuthorization(scope, target string) bool {
413 target = strings.TrimSpace(target)
414 if target == "" {
415 return false
416 }
417 a.mu.Lock()
418 defer a.mu.Unlock()
419 switch strings.TrimSpace(scope) {
420 case "tool":
421 if !a.granted[target] {
422 return false
423 }
424 delete(a.granted, target)
425 return true
426 case "command-prefix":
427 target = normalizePlanModeReadOnlyCommandPrefix(target)
428 if target == "" || !a.planModeReadOnlyCommands[target] {
429 return false
430 }
431 delete(a.planModeReadOnlyCommands, target)
432 return true
433 default:
434 return false
435 }
436 }
437
438 // SessionAuthorizations is the same-session tool-grant and Plan-mode
439 // read-only command trust state a controller rebuild must carry forward; see
440 // Controller.SessionAuthorizations / RestoreSessionAuthorizations.
441 type SessionAuthorizations struct {
442 Grants []string
443 PlanModeReadOnlyCommands []string
444 WriteRoots []string
445 }
446
447 func (a *approvalManager) snapshotSessionAuthorizations() SessionAuthorizations {
448 a.mu.Lock()
449 defer a.mu.Unlock()
450 auth := SessionAuthorizations{
451 Grants: make([]string, 0, len(a.granted)),
452 PlanModeReadOnlyCommands: make([]string, 0, len(a.planModeReadOnlyCommands)),
453 }
454 for rule := range a.granted {
455 auth.Grants = append(auth.Grants, rule)
456 }
457 for prefix := range a.planModeReadOnlyCommands {
458 auth.PlanModeReadOnlyCommands = append(auth.PlanModeReadOnlyCommands, prefix)
459 }
460 return auth
461 }
462
463 func (a *approvalManager) restoreSessionAuthorizations(auth SessionAuthorizations) {
464 a.mu.Lock()
465 defer a.mu.Unlock()
466 for _, rule := range auth.Grants {
467 a.granted[rule] = true
468 }
469 for _, prefix := range auth.PlanModeReadOnlyCommands {
470 a.planModeReadOnlyCommands[prefix] = true
471 }
472 }
473
474 // cancel drops a pending approval (timeout/abort path).
475 func (a *approvalManager) cancel(id string) {
476 a.mu.Lock()
477 delete(a.approvals, id)
478 a.cancelApprovalResolutionLocked(id)
479 a.mu.Unlock()
480 }
481
482 // resolve removes and returns the pending approval for id (Approve path).
483 func (a *approvalManager) resolve(id string) pendingApproval {
484 a.mu.Lock()
485 defer a.mu.Unlock()
486 p := a.approvals[id]
487 delete(a.approvals, id)
488 a.cancelApprovalResolutionLocked(id)
489 return p
490 }
491
492 func (a *approvalManager) resolveAfter(id string, persist func(pendingApproval) error) (pendingApproval, bool, error) {
493 a.mu.Lock()
494 p, ok := a.approvals[id]
495 if !ok {
496 a.mu.Unlock()
497 return pendingApproval{}, false, nil
498 }
499 if inFlight := a.approvalResolutions[id]; inFlight != nil {
500 a.mu.Unlock()
501 return pendingApproval{}, false, inFlight.wait()
502 }
503 attempt := newPromptResolution()
504 a.approvalResolutions[id] = attempt
505 a.mu.Unlock()
506 if persist != nil {
507 if err := persist(p); err != nil {
508 a.mu.Lock()
509 a.finishApprovalResolutionLocked(id, attempt, err)
510 a.mu.Unlock()
511 return pendingApproval{}, false, err
512 }
513 }
514 a.mu.Lock()
515 defer a.mu.Unlock()
516 current, ok := a.approvals[id]
517 if !ok || a.approvalResolutions[id] != attempt || current.reply != p.reply {
518 if a.approvalResolutions[id] == attempt {
519 a.finishApprovalResolutionLocked(id, attempt, context.Canceled)
520 }
521 return pendingApproval{}, false, attempt.err
522 }
523 delete(a.approvals, id)
524 a.finishApprovalResolutionLocked(id, attempt, nil)
525 return p, true, nil
526 }
527
528 func (a *approvalManager) finishApprovalResolutionLocked(id string, attempt *promptResolution, err error) {
529 if attempt == nil || a.approvalResolutions[id] != attempt {
530 return
531 }
532 delete(a.approvalResolutions, id)
533 attempt.err = err
534 close(attempt.done)
535 }
536
537 func (a *approvalManager) cancelApprovalResolutionLocked(id string) {
538 if attempt := a.approvalResolutions[id]; attempt != nil {
539 a.finishApprovalResolutionLocked(id, attempt, context.Canceled)
540 }
541 }
542
543 func (a *approvalManager) resolveToolAfter(id, tool string, persist func(pendingApproval) error) (pendingApproval, bool, error) {
544 p := a.peek(id)
545 if p.reply == nil || p.tool != tool {
546 return pendingApproval{}, false, nil
547 }
548 return a.resolveAfter(id, persist)
549 }
550
551 // registerAsk allocates an ask ID, records the pending question batch, and
552 // returns the reply channel. The ask starts queued: registering before the
553 // prompt lock is what makes a question waiting behind another prompt visible
554 // at all, instead of existing only inside a blocked goroutine.
555 func (a *approvalManager) registerAsk(questions []event.AskQuestion) (string, chan []event.AskAnswer) {
556 a.mu.Lock()
557 defer a.mu.Unlock()
558 a.nextID++
559 id := strconv.Itoa(a.nextID)
560 reply := make(chan []event.AskAnswer, 1)
561 a.asks[id] = pendingAsk{questions: questions, reply: reply, queued: true}
562 return id, reply
563 }
564
565 // markAskEmitted clears the queued flag once the ask has reached a frontend,
566 // which is what makes it eligible for replay.
567 func (a *approvalManager) markAskEmitted(id string) {
568 a.mu.Lock()
569 defer a.mu.Unlock()
570 if p, ok := a.asks[id]; ok {
571 p.queued = false
572 a.asks[id] = p
573 }
574 }
575
576 // cancelAsk drops a pending ask (timeout/abort path).
577 func (a *approvalManager) cancelAsk(id string) {
578 a.mu.Lock()
579 delete(a.asks, id)
580 a.cancelAskResolutionLocked(id)
581 a.mu.Unlock()
582 }
583
584 func (a *approvalManager) resolveAskAfter(id string, persist func(pendingAsk) error) (pendingAsk, bool, error) {
585 a.mu.Lock()
586 p, ok := a.asks[id]
587 if !ok {
588 a.mu.Unlock()
589 return pendingAsk{}, false, nil
590 }
591 if inFlight := a.askResolutions[id]; inFlight != nil {
592 a.mu.Unlock()
593 return pendingAsk{}, false, inFlight.wait()
594 }
595 attempt := newPromptResolution()
596 a.askResolutions[id] = attempt
597 a.mu.Unlock()
598 if persist != nil {
599 if err := persist(p); err != nil {
600 a.mu.Lock()
601 a.finishAskResolutionLocked(id, attempt, err)
602 a.mu.Unlock()
603 return pendingAsk{}, false, err
604 }
605 }
606 a.mu.Lock()
607 defer a.mu.Unlock()
608 current, ok := a.asks[id]
609 if !ok || a.askResolutions[id] != attempt || current.reply != p.reply {
610 if a.askResolutions[id] == attempt {
611 a.finishAskResolutionLocked(id, attempt, context.Canceled)
612 }
613 return pendingAsk{}, false, attempt.err
614 }
615 delete(a.asks, id)
616 a.finishAskResolutionLocked(id, attempt, nil)
617 return p, true, nil
618 }
619
620 func (a *approvalManager) finishAskResolutionLocked(id string, attempt *promptResolution, err error) {
621 if attempt == nil || a.askResolutions[id] != attempt {
622 return
623 }
624 delete(a.askResolutions, id)
625 attempt.err = err
626 close(attempt.done)
627 }
628
629 func (a *approvalManager) cancelAskResolutionLocked(id string) {
630 if attempt := a.askResolutions[id]; attempt != nil {
631 a.finishAskResolutionLocked(id, attempt, context.Canceled)
632 }
633 }
634
635 // clearAll drops every in-flight prompt without signaling — the cancel path,
636 // where blocked waiters unblock via their cancelled context instead.
637 func (a *approvalManager) clearAll() {
638 a.mu.Lock()
639 defer a.mu.Unlock()
640 clear(a.approvals)
641 clear(a.asks)
642 clear(a.mcpInteractions.pending)
643 for id := range a.approvalResolutions {
644 a.cancelApprovalResolutionLocked(id)
645 }
646 for id := range a.askResolutions {
647 a.cancelAskResolutionLocked(id)
648 }
649 for id := range a.mcpInteractions.resolutions {
650 a.finishMCPInteractionResolutionLocked(id, a.mcpInteractions.resolutions[id], context.Canceled)
651 }
652 }
653
654 // clearKind drops pending approvals of one specialized kind. Session recovery
655 // state uses this during rotations so a card from the previous session cannot
656 // be answered against the newly active one.
657 func (a *approvalManager) clearKind(kind string) {
658 a.mu.Lock()
659 defer a.mu.Unlock()
660 for id, pending := range a.approvals {
661 if pending.kind == kind {
662 delete(a.approvals, id)
663 a.cancelApprovalResolutionLocked(id)
664 }
665 }
666 }
667
668 // hasPending reports whether any prompt is awaiting a user decision.
669 func (a *approvalManager) hasPending() bool {
670 a.mu.Lock()
671 defer a.mu.Unlock()
672 return len(a.approvals) > 0 || len(a.asks) > 0 || len(a.mcpInteractions.pending) > 0
673 }
674
675 // mode returns the normalized runtime approval posture.
676 func (a *approvalManager) mode() string {
677 a.mu.Lock()
678 defer a.mu.Unlock()
679 return normalizeToolApprovalMode(a.toolApprovalMode)
680 }
681
682 // setMode applies a pre-normalized posture. Existing prompts remain tied to
683 // the revision that created them and are invalidated by the controller.
684 func (a *approvalManager) setMode(mode string) []drainedApproval {
685 a.mu.Lock()
686 defer a.mu.Unlock()
687 a.toolApprovalMode = mode
688 return nil
689 }
690
691 // setPlanAutoApprove toggles the just-approved-plan execution window.
692 func (a *approvalManager) setPlanAutoApprove(on bool) {
693 a.mu.Lock()
694 a.planAutoApprove = on
695 a.mu.Unlock()
696 }
697
698 // waitContext bounds the blocking wait by approvalTimeout when set.
699 func (a *approvalManager) waitContext(ctx context.Context) (context.Context, context.CancelFunc) {
700 if a.approvalTimeout <= 0 {
701 return ctx, func() {}
702 }
703 return context.WithTimeout(ctx, a.approvalTimeout)
704 }
705
706 // snapshotPrompts copies the in-flight prompts for re-emission to a reconnected
707 // frontend (ReplayPendingPrompts).
708 func (a *approvalManager) snapshotPrompts() ([]event.Approval, []event.Ask) {
709 a.mu.Lock()
710 defer a.mu.Unlock()
711 approvals := make([]event.Approval, 0, len(a.approvals))
712 for id, p := range a.approvals {
713 approvals = append(approvals, event.Approval{
714 ID: id, Tool: p.tool, Subject: p.subject, Reason: p.reason, RawInput: append(json.RawMessage(nil), p.rawInput...), Fresh: p.fresh,
715 Kind: p.kind, Recovery: p.recovery, WriteAccess: event.NormalizeWriteAccessApproval(p.writeAccess),
716 })
717 }
718 asks := make([]event.Ask, 0, len(a.asks))
719 for id, p := range a.asks {
720 // A queued ask has never been shown; replaying it would put a question
721 // on screen ahead of the prompt it is waiting behind.
722 if p.queued {
723 continue
724 }
725 asks = append(asks, event.Ask{ID: id, Questions: p.questions})
726 }
727 return approvals, asks
728 }
729
730 func normalizePlanModeReadOnlyCommandPrefix(prefix string) string {
731 return strings.Join(strings.Fields(strings.TrimSpace(prefix)), " ")
732 }
733
734 // decision helpers (caller holds a.mu)
735
736 func (a *approvalManager) bypassAllowsLocked(tool, subject string, args json.RawMessage) bool {
737 if isMemoryApprovalTool(tool) {
738 switch a.toolApprovalMode {
739 case ToolApprovalYolo:
740 return true
741 case ToolApprovalAuto:
742 return a.autoApprovalWouldAllowLocked(tool, subject, args)
743 }
744 }
745 if requiresFreshApprovalTool(tool) {
746 return false
747 }
748 if a.toolApprovalMode == ToolApprovalYolo {
749 return true
750 }
751 if !a.planAutoApprove {
752 return false
753 }
754 policy := a.policy
755 policy.Mode = permission.Allow
756 if len(args) > 0 {
757 return policy.Decide(tool, false, args) == permission.Allow
758 }
759 return policy.DecideSubject(tool, false, subject) == permission.Allow
760 }
761
762 func (a *approvalManager) autoApprovalWouldAllowLocked(tool, subject string, args json.RawMessage) bool {
763 if requiresFreshApprovalTool(tool) && !isMemoryApprovalTool(tool) {
764 return false
765 }
766 policy := a.policy
767 policy.Mode = permission.Allow
768 if len(args) > 0 {
769 return policy.Decide(tool, false, args) == permission.Allow
770 }
771 return policy.DecideSubject(tool, false, subject) == permission.Allow
772 }
773
774 func (a *approvalManager) sessionGrantAllowsLocked(tool, subject string) bool {
775 if requiresFreshApprovalTool(tool) && !allowsFreshSessionGrantTool(tool) {
776 return false
777 }
778 for rule := range a.granted {
779 if permission.RuleMatchesString(rule, tool, subject) {
780 return true
781 }
782 }
783 return false
784 }
785
786 // drainedApproval is a pending approval removed by a posture switch, keeping
787 // its prompt id so frontends can dismiss exactly the prompts the new posture
788 // resolved (plan/sandbox/config prompts stay pending and must stay visible).
789 type drainedApproval struct {
790 id string
791 reply chan approvalReply
792 }
793
794 // pure approval helpers
795
796 func normalizeToolApprovalMode(mode string) string {
797 switch strings.ToLower(strings.TrimSpace(mode)) {
798 case "dontask", "dont-ask", "deny":
799 return ToolApprovalDontAsk
800 default:
801 return string(permissionpreset.Normalize(mode))
802 }
803 }
804
805 // RequiresFreshHumanApprovalTool reports tools that session grants,
806 // Guardian/hooks, and headless nil approvers cannot authorize. Interactive Auto
807 // treats remember/forget as normal policy fallback, while interactive YOLO may
808 // also bypass explicit memory ask rules. A controller that owns the scoped
809 // memory store may still auto-allow a bounded create-only project memory.
810 func RequiresFreshHumanApprovalTool(tool string) bool {
811 switch tool {
812 case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool:
813 return true
814 default:
815 return false
816 }
817 }
818
819 func isMemoryApprovalTool(tool string) bool {
820 switch tool {
821 case memoryRememberTool, memoryForgetTool:
822 return true
823 default:
824 return false
825 }
826 }
827
828 func requiresFreshApprovalTool(tool string) bool {
829 return RequiresFreshHumanApprovalTool(tool)
830 }
831
832 func allowsFreshSessionGrantTool(tool string) bool {
833 switch tool {
834 case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool:
835 return true
836 default:
837 return false
838 }
839 }
840
841 func approvalNotificationText(tool, subject string) string {
842 if requiresFreshApprovalTool(tool) {
843 return fmt.Sprintf(i18n.M.ApprovalNeededFmt, tool)
844 }
845 if subject == "" {
846 return fmt.Sprintf(i18n.M.ApprovalNeededFmt, tool)
847 }
848 return fmt.Sprintf(i18n.M.ApprovalNeededWithSubjectFmt, tool, subject)
849 }
850
851 func permissionRequestHookPayload(tool, subject string, args json.RawMessage) (string, json.RawMessage, bool) {
852 switch tool {
853 case planApprovalTool:
854 return "", nil, false
855 case memoryRememberTool, memoryForgetTool:
856 return "", nil, true
857 default:
858 return subject, args, true
859 }
860 }
861
861 lines GO