返回 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 )
16
17 // approvalManager owns the approval/ask prompt bookkeeping and the runtime
18 // approval posture, behind its own locks and off the controller's c.mu. It is a
19 // strict leaf: its methods only touch its own state and never call back into the
20 // Controller. The Controller keeps the I/O orchestration (emitting events,
21 // firing hooks, rebuilding the executor gate) that needs its other collaborators
22 // — approval, unlike the goal FSM, blocks on user input and has side effects, so
23 // only the bookkeeping is extracted, not the orchestration.
24 type approvalManager struct {
25 // policy is the immutable base permission policy, captured at construction.
26 // Used to decide whether a tool call would auto-approve under the writer
27 // fallback (autoApprovalWouldAllowLocked); the Controller keeps its own copy
28 // for building the executor gate.
29 policy permission.Policy
30
31 // mu guards the prompt maps and posture fields; every critical section under
32 // it is short and non-blocking.
33 mu sync.Mutex
34 approvals map[string]pendingApproval
35 asks map[string]pendingAsk
36 granted map[string]bool
37 planModeReadOnlyCommands map[string]bool
38 nextID int
39 // toolApprovalMode is the runtime approval posture: "ask" prompts, "auto"
40 // lets the policy auto-approve the writer fallback while preserving ask/deny
41 // rules, and "yolo" skips ordinary tool prompts while deny rules and fresh
42 // decisions remain enforced.
43 toolApprovalMode string
44 // approvalTimeout bounds how long requestApproval/Ask block on a user
45 // decision. Zero means wait indefinitely (correct for an interactive
46 // terminal); bot/headless frontends set it so a walked-away user can't wedge
47 // the session forever (#4626, #4402). Write-once at construction.
48 approvalTimeout time.Duration
49 // planAutoApprove auto-allows the ordinary writer fallback while a
50 // just-approved plan executes. Explicit ask/deny rules and fresh decisions
51 // remain authoritative, matching Auto rather than YOLO semantics.
52 planAutoApprove bool
53
54 // promptMu serializes outstanding prompts so at most one user decision is in
55 // flight. Held across the blocking wait, so it must never be taken by the
56 // resolve paths (Approve/AnswerQuestion). sink.Emit also runs under it (Ask,
57 // requestApproval): Sink implementations must not block and must not call
58 // back into Ask or the tool-approval chain, or they deadlock the prompt.
59 promptMu sync.Mutex
60 // promptEmitMu serializes prompt registration and emission with an SSE
61 // attach handoff. It is separate from promptMu because promptMu remains
62 // held while waiting for the user's answer.
63 promptEmitMu sync.Mutex
64 }
65
66 func newApprovalManager(policy permission.Policy, mode string, timeout time.Duration) approvalManager {
67 return approvalManager{
68 policy: policy,
69 approvals: map[string]pendingApproval{},
70 asks: map[string]pendingAsk{},
71 granted: map[string]bool{},
72 planModeReadOnlyCommands: map[string]bool{},
73 toolApprovalMode: mode,
74 approvalTimeout: timeout,
75 }
76 }
77
78 // NewHeadlessPermissionGate builds the legacy bootstrap gate used before a
79 // frontend declares its approval posture. Interactive frontends replace it
80 // before running; callers that are actually headless must pass a non-empty mode
81 // through BuildHeadlessApprovalGate.
82 func NewHeadlessPermissionGate(policy permission.Policy) *freshHumanHeadlessGate {
83 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, nil)}
84 }
85
86 // BuildHeadlessApprovalGate constructs the non-interactive gate for a given
87 // approval mode, matching the contract ApplyHeadlessApprovalMode installs on a
88 // running controller's parent executor. boot uses this as the single
89 // construction point for every headless-only gate — the top-level executor,
90 // the `task`/`read_only_task` sub-agent, writer-capable skill sub-agents
91 // (run_skill/install_skill), and the planner runner — so all of them share the
92 // CLI-selected headless approval mode instead of only the parent executor
93 // getting it while the rest silently keep the mode-unaware default, which let
94 // a task sub-agent run a write an explicit ask
95 // rule was supposed to deny under auto.
96 func BuildHeadlessApprovalGate(policy permission.Policy, mode string) *freshHumanHeadlessGate {
97 // An empty mode is the boot-time placeholder used by interactive frontends
98 // before they install their real gate. Keep that compatibility path distinct
99 // from an explicit headless Ask posture, which has nobody to approve it.
100 if strings.TrimSpace(mode) == "" {
101 return NewHeadlessPermissionGate(policy)
102 }
103 switch normalizeToolApprovalMode(mode) {
104 case ToolApprovalYolo:
105 policy.Mode = permission.Allow
106 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, nil), dynamicBashBypass: true}
107 case ToolApprovalAuto:
108 policy.Mode = permission.Allow
109 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
110 case ToolApprovalDontAsk:
111 policy.Mode = permission.Deny
112 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
113 default:
114 policy.Mode = permission.Ask
115 return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
116 }
117 }
118
119 // SharedHeadlessGate is a mutable, concurrency-safe holder for the
120 // non-interactive gate that every headless-only sub-agent surface shares —
121 // `task`/`read_only_task`, writer-capable skill sub-agents, and the planner
122 // runner. Those surfaces capture their gate once at construction with no
123 // rebuild hook of their own, unlike the parent executor's gate (rebuilt in
124 // place via Agent.SetGate on every SetToolApprovalMode/
125 // ApplyHeadlessApprovalMode call). Every consumer holds this same pointer and
126 // reads through Check, so a runtime approval-mode switch (interactive
127 // Shift+Tab, or a headless --permission-mode passed at boot) only needs to
128 // call Update here to keep sub-agents on the same contract as the parent
129 // instead of silently pinning them to whatever mode was active when they were
130 // first constructed.
131 type SharedHeadlessGate struct {
132 mu sync.RWMutex
133 policy permission.Policy
134 gate *freshHumanHeadlessGate
135 }
136
137 // NewSharedHeadlessGate builds a shared gate holder from the base policy and
138 // the initial approval mode (see BuildHeadlessApprovalGate for the mode
139 // contract).
140 func NewSharedHeadlessGate(policy permission.Policy, mode string) *SharedHeadlessGate {
141 g := &SharedHeadlessGate{policy: policy}
142 g.Update(mode)
143 return g
144 }
145
146 // Update rebuilds the held gate for a new approval mode. Safe to call
147 // concurrently with Check (a turn may be mid-flight on another goroutine when
148 // the user switches modes).
149 func (g *SharedHeadlessGate) Update(mode string) {
150 next := BuildHeadlessApprovalGate(g.policy, mode)
151 g.mu.Lock()
152 g.gate = next
153 g.mu.Unlock()
154 }
155
156 func (g *SharedHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
157 g.mu.RLock()
158 gate := g.gate
159 g.mu.RUnlock()
160 return gate.Check(ctx, toolName, args, readOnly)
161 }
162
163 func (g *SharedHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
164 g.mu.RLock()
165 gate := g.gate
166 g.mu.RUnlock()
167 return gate.ExplicitlyDenies(toolName, args)
168 }
169
170 type freshHumanHeadlessGate struct {
171 gate *permission.Gate
172 dynamicBashBypass bool
173 allowLowRiskFreshAction func(toolName string, args json.RawMessage) bool
174 }
175
176 func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
177 if RequiresFreshHumanApprovalTool(toolName) {
178 if !g.gate.ExplicitlyDenies(toolName, args) &&
179 g.allowLowRiskFreshAction != nil &&
180 g.allowLowRiskFreshAction(toolName, args) {
181 return true, "", nil
182 }
183 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
184 }
185 if strings.EqualFold(toolName, "bash") && permission.BashSubjectRequiresExplicitApproval(permission.Subject(args)) {
186 if g.gate.Policy.Decide(toolName, readOnly, args) != permission.Allow && !g.dynamicBashBypass {
187 return false, "this dynamic shell command requires human approval and cannot run in a non-interactive session. Use an interactive session or YOLO mode.", nil
188 }
189 }
190 return g.gate.Check(ctx, toolName, args, readOnly)
191 }
192
193 func (g *freshHumanHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
194 return g.gate.Policy.ExplicitlyDenies(toolName, args)
195 }
196
197 // preApproved reports whether a tool call can skip the prompt — either the
198 // posture bypasses it (YOLO / plan-execution window) or a session grant already
199 // covers the scope.
200 func (a *approvalManager) preApproved(tool, subject string, args json.RawMessage) bool {
201 a.mu.Lock()
202 defer a.mu.Unlock()
203 return a.bypassAllowsLocked(tool, subject, args) || a.sessionGrantAllowsLocked(tool, subject)
204 }
205
206 // preApprovedForDecision reports whether a prompt can be skipped for a decision
207 // class. Fresh user decisions may reuse an explicit session grant, but they are
208 // never answered by YOLO/full-access or the approved-plan execution window.
209 func (a *approvalManager) preApprovedForDecision(tool, subject string, args json.RawMessage, fresh bool) bool {
210 return a.preApprovedForDecisionOptions(tool, subject, args, fresh, false)
211 }
212
213 func (a *approvalManager) preApprovedForDecisionOptions(tool, subject string, args json.RawMessage, fresh, requireHuman bool) bool {
214 a.mu.Lock()
215 defer a.mu.Unlock()
216 if fresh {
217 return a.sessionGrantAllowsLocked(tool, subject)
218 }
219 if requireHuman {
220 return a.toolApprovalMode == ToolApprovalYolo || a.sessionGrantAllowsLocked(tool, subject)
221 }
222 return a.bypassAllowsLocked(tool, subject, args) || a.sessionGrantAllowsLocked(tool, subject)
223 }
224
225 func (a *approvalManager) preApprovedForRequiredHuman(tool, subject string) bool {
226 a.mu.Lock()
227 defer a.mu.Unlock()
228 return a.toolApprovalMode == ToolApprovalYolo || a.sessionGrantAllowsLocked(tool, subject)
229 }
230
231 // register allocates an approval ID, records the pending prompt, and returns the
232 // reply channel the resolve path will signal.
233 func (a *approvalManager) register(tool, subject, reason string) (string, chan approvalReply) {
234 return a.registerWithInput(tool, subject, reason, nil)
235 }
236
237 func (a *approvalManager) registerWithInput(tool, subject, reason string, rawInput json.RawMessage) (string, chan approvalReply) {
238 return a.registerDecisionWithInput(tool, subject, reason, rawInput, false, false)
239 }
240
241 // registerDecision allocates an approval ID for either an ordinary tool
242 // permission or a fresh user decision. Fresh decisions are not auto-drained when
243 // the user switches to auto/yolo tool approval while the prompt is visible.
244 func (a *approvalManager) registerDecision(tool, subject, reason string, fresh, requireHuman bool) (string, chan approvalReply) {
245 return a.registerDecisionWithInput(tool, subject, reason, nil, fresh, requireHuman)
246 }
247
248 func (a *approvalManager) registerDecisionWithInput(tool, subject, reason string, rawInput json.RawMessage, fresh, requireHuman bool) (string, chan approvalReply) {
249 return a.registerDecisionKindWithInput(tool, subject, reason, rawInput, fresh, requireHuman, "", nil)
250 }
251
252 // registerDecisionKind is registerDecision with optional Kind/Recovery payload
253 // so Auto Guard cards survive ReplayPendingPrompts.
254 func (a *approvalManager) registerDecisionKind(tool, subject, reason string, fresh, requireHuman bool, kind string, rec *event.RecoveryApproval) (string, chan approvalReply) {
255 return a.registerDecisionKindWithInput(tool, subject, reason, nil, fresh, requireHuman, kind, rec)
256 }
257
258 func (a *approvalManager) registerDecisionKindWithInput(tool, subject, reason string, rawInput json.RawMessage, fresh, requireHuman bool, kind string, rec *event.RecoveryApproval) (string, chan approvalReply) {
259 a.mu.Lock()
260 defer a.mu.Unlock()
261 a.nextID++
262 id := strconv.Itoa(a.nextID)
263 reply := make(chan approvalReply, 1)
264 autoDrain := false
265 if !fresh && !requireHuman {
266 autoDrain = a.autoApprovalWouldAllowLocked(tool, subject)
267 }
268 a.approvals[id] = pendingApproval{
269 id: id,
270 tool: tool, subject: subject, reason: reason, rawInput: append(json.RawMessage(nil), rawInput...), fresh: fresh, requireHuman: requireHuman,
271 autoDrain: autoDrain, kind: kind, recovery: rec, reply: reply,
272 }
273 return id, reply
274 }
275
276 // grantSession records a session-scoped grant so future calls in the same scope
277 // short-circuit.
278 func (a *approvalManager) grantSession(tool, subject string) {
279 a.mu.Lock()
280 defer a.mu.Unlock()
281 a.granted[permission.SessionGrantRuleForScope(tool, subject)] = true
282 }
283
284 func (a *approvalManager) planModeReadOnlyCommandTrusted(prefix string) bool {
285 prefix = normalizePlanModeReadOnlyCommandPrefix(prefix)
286 if prefix == "" {
287 return false
288 }
289 a.mu.Lock()
290 defer a.mu.Unlock()
291 return a.planModeReadOnlyCommands[prefix]
292 }
293
294 func (a *approvalManager) grantPlanModeReadOnlyCommand(prefix string) {
295 prefix = normalizePlanModeReadOnlyCommandPrefix(prefix)
296 if prefix == "" {
297 return
298 }
299 a.mu.Lock()
300 defer a.mu.Unlock()
301 a.planModeReadOnlyCommands[prefix] = true
302 }
303
304 // SessionAuthorizations is the same-session tool-grant and Plan-mode
305 // read-only command trust state a controller rebuild must carry forward; see
306 // Controller.SessionAuthorizations / RestoreSessionAuthorizations.
307 type SessionAuthorizations struct {
308 Grants []string
309 PlanModeReadOnlyCommands []string
310 }
311
312 func (a *approvalManager) snapshotSessionAuthorizations() SessionAuthorizations {
313 a.mu.Lock()
314 defer a.mu.Unlock()
315 auth := SessionAuthorizations{
316 Grants: make([]string, 0, len(a.granted)),
317 PlanModeReadOnlyCommands: make([]string, 0, len(a.planModeReadOnlyCommands)),
318 }
319 for rule := range a.granted {
320 auth.Grants = append(auth.Grants, rule)
321 }
322 for prefix := range a.planModeReadOnlyCommands {
323 auth.PlanModeReadOnlyCommands = append(auth.PlanModeReadOnlyCommands, prefix)
324 }
325 return auth
326 }
327
328 func (a *approvalManager) restoreSessionAuthorizations(auth SessionAuthorizations) {
329 a.mu.Lock()
330 defer a.mu.Unlock()
331 for _, rule := range auth.Grants {
332 a.granted[rule] = true
333 }
334 for _, prefix := range auth.PlanModeReadOnlyCommands {
335 a.planModeReadOnlyCommands[prefix] = true
336 }
337 }
338
339 // cancel drops a pending approval (timeout/abort path).
340 func (a *approvalManager) cancel(id string) {
341 a.mu.Lock()
342 delete(a.approvals, id)
343 a.mu.Unlock()
344 }
345
346 // resolve removes and returns the pending approval for id (Approve path).
347 func (a *approvalManager) resolve(id string) pendingApproval {
348 a.mu.Lock()
349 defer a.mu.Unlock()
350 p := a.approvals[id]
351 delete(a.approvals, id)
352 return p
353 }
354
355 // resolveTool removes id only when it belongs to the expected specialized
356 // decision surface. A mismatched bridge call must not consume another approval
357 // type that happens to share the same short numeric id.
358 func (a *approvalManager) resolveTool(id, tool string) (pendingApproval, bool) {
359 a.mu.Lock()
360 defer a.mu.Unlock()
361 p, ok := a.approvals[id]
362 if !ok || p.tool != tool {
363 return pendingApproval{}, false
364 }
365 delete(a.approvals, id)
366 return p, true
367 }
368
369 // registerAsk allocates an ask ID, records the pending question batch, and
370 // returns the reply channel.
371 func (a *approvalManager) registerAsk(questions []event.AskQuestion) (string, chan []event.AskAnswer) {
372 a.mu.Lock()
373 defer a.mu.Unlock()
374 a.nextID++
375 id := strconv.Itoa(a.nextID)
376 reply := make(chan []event.AskAnswer, 1)
377 a.asks[id] = pendingAsk{questions: questions, reply: reply}
378 return id, reply
379 }
380
381 // cancelAsk drops a pending ask (timeout/abort path).
382 func (a *approvalManager) cancelAsk(id string) {
383 a.mu.Lock()
384 delete(a.asks, id)
385 a.mu.Unlock()
386 }
387
388 // resolveAsk removes and returns the pending ask for id (AnswerQuestion path).
389 func (a *approvalManager) resolveAsk(id string) (pendingAsk, bool) {
390 a.mu.Lock()
391 defer a.mu.Unlock()
392 p, ok := a.asks[id]
393 delete(a.asks, id)
394 return p, ok
395 }
396
397 // clearAll drops every in-flight prompt without signaling — the cancel path,
398 // where blocked waiters unblock via their cancelled context instead.
399 func (a *approvalManager) clearAll() {
400 a.mu.Lock()
401 defer a.mu.Unlock()
402 clear(a.approvals)
403 clear(a.asks)
404 }
405
406 // clearKind drops pending approvals of one specialized kind. Session recovery
407 // state uses this during rotations so a card from the previous session cannot
408 // be answered against the newly active one.
409 func (a *approvalManager) clearKind(kind string) {
410 a.mu.Lock()
411 defer a.mu.Unlock()
412 for id, pending := range a.approvals {
413 if pending.kind == kind {
414 delete(a.approvals, id)
415 }
416 }
417 }
418
419 // hasPending reports whether any prompt is awaiting a user decision.
420 func (a *approvalManager) hasPending() bool {
421 a.mu.Lock()
422 defer a.mu.Unlock()
423 return len(a.approvals) > 0 || len(a.asks) > 0
424 }
425
426 // mode returns the normalized runtime approval posture.
427 func (a *approvalManager) mode() string {
428 a.mu.Lock()
429 defer a.mu.Unlock()
430 return normalizeToolApprovalMode(a.toolApprovalMode)
431 }
432
433 // setMode applies a (pre-normalized) posture and drains any pending approvals
434 // the new posture should auto-allow, returning them for the caller to signal
435 // {allow:true} after unlocking.
436 func (a *approvalManager) setMode(mode string) []drainedApproval {
437 a.mu.Lock()
438 defer a.mu.Unlock()
439 a.toolApprovalMode = mode
440 switch mode {
441 case ToolApprovalAuto:
442 return a.drainLocked(false)
443 case ToolApprovalYolo:
444 return a.drainLocked(true)
445 }
446 return nil
447 }
448
449 // setPlanAutoApprove toggles the just-approved-plan execution window.
450 func (a *approvalManager) setPlanAutoApprove(on bool) {
451 a.mu.Lock()
452 a.planAutoApprove = on
453 a.mu.Unlock()
454 }
455
456 // waitContext bounds the blocking wait by approvalTimeout when set.
457 func (a *approvalManager) waitContext(ctx context.Context) (context.Context, context.CancelFunc) {
458 if a.approvalTimeout <= 0 {
459 return ctx, func() {}
460 }
461 return context.WithTimeout(ctx, a.approvalTimeout)
462 }
463
464 // snapshotPrompts copies the in-flight prompts for re-emission to a reconnected
465 // frontend (ReplayPendingPrompts).
466 func (a *approvalManager) snapshotPrompts() ([]event.Approval, []event.Ask) {
467 a.mu.Lock()
468 defer a.mu.Unlock()
469 approvals := make([]event.Approval, 0, len(a.approvals))
470 for id, p := range a.approvals {
471 approvals = append(approvals, event.Approval{
472 ID: id, Tool: p.tool, Subject: p.subject, Reason: p.reason, RawInput: append(json.RawMessage(nil), p.rawInput...), Fresh: p.fresh,
473 Kind: p.kind, Recovery: p.recovery,
474 })
475 }
476 asks := make([]event.Ask, 0, len(a.asks))
477 for id, p := range a.asks {
478 asks = append(asks, event.Ask{ID: id, Questions: p.questions})
479 }
480 return approvals, asks
481 }
482
483 func normalizePlanModeReadOnlyCommandPrefix(prefix string) string {
484 return strings.Join(strings.Fields(strings.TrimSpace(prefix)), " ")
485 }
486
487 // --- decision helpers (caller holds a.mu) ---
488
489 func (a *approvalManager) bypassAllowsLocked(tool, subject string, args json.RawMessage) bool {
490 if requiresFreshApprovalTool(tool) {
491 return false
492 }
493 if a.toolApprovalMode == ToolApprovalYolo {
494 return true
495 }
496 if !a.planAutoApprove {
497 return false
498 }
499 policy := a.policy
500 policy.Mode = permission.Allow
501 if len(args) > 0 {
502 return policy.Decide(tool, false, args) == permission.Allow
503 }
504 return policy.DecideSubject(tool, false, subject) == permission.Allow
505 }
506
507 func (a *approvalManager) autoApprovalWouldAllowLocked(tool, subject string) bool {
508 if requiresFreshApprovalTool(tool) {
509 return false
510 }
511 policy := a.policy
512 policy.Mode = permission.Allow
513 return policy.DecideSubject(tool, false, subject) == permission.Allow
514 }
515
516 func (a *approvalManager) sessionGrantAllowsLocked(tool, subject string) bool {
517 if requiresFreshApprovalTool(tool) && !allowsFreshSessionGrantTool(tool) {
518 return false
519 }
520 for rule := range a.granted {
521 if permission.RuleMatchesString(rule, tool, subject) {
522 return true
523 }
524 }
525 return false
526 }
527
528 // drainedApproval is a pending approval removed by a posture switch, keeping
529 // its prompt id so frontends can dismiss exactly the prompts the new posture
530 // resolved (fresh/plan/memory prompts stay pending and must stay visible).
531 type drainedApproval struct {
532 id string
533 reply chan approvalReply
534 }
535
536 // drainLocked removes every pending approval the new posture should auto-allow
537 // and returns them; caller holds a.mu and sends {allow:true} after unlocking.
538 func (a *approvalManager) drainLocked(includeExplicitAsk bool) []drainedApproval {
539 pending := make([]drainedApproval, 0, len(a.approvals))
540 for id, approval := range a.approvals {
541 if approval.fresh || requiresFreshApprovalTool(approval.tool) {
542 continue
543 }
544 if approval.requireHuman && !includeExplicitAsk {
545 continue
546 }
547 if !includeExplicitAsk && !approval.autoDrain {
548 continue
549 }
550 delete(a.approvals, id)
551 pending = append(pending, drainedApproval{id: id, reply: approval.reply})
552 }
553 return pending
554 }
555
556 // --- pure approval helpers ---
557
558 func normalizeToolApprovalMode(mode string) string {
559 switch strings.ToLower(strings.TrimSpace(mode)) {
560 case ToolApprovalAuto, "approve", "allow":
561 return ToolApprovalAuto
562 case "dontask", "dont-ask", "deny":
563 return ToolApprovalDontAsk
564 case ToolApprovalYolo, "full", "full-access", "bypass":
565 return ToolApprovalYolo
566 default:
567 return ToolApprovalAsk
568 }
569 }
570
571 // RequiresFreshHumanApprovalTool reports whether a tool's unsafe variants must
572 // be answered by a human decision, not by YOLO/auto approval, Guardian, or a
573 // non-interactive nil approver. A controller that owns the scoped memory store
574 // may still classify a bounded new project memory as create-only and allow that
575 // narrow operation in interactive or headless mode.
576 func RequiresFreshHumanApprovalTool(tool string) bool {
577 switch tool {
578 case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool:
579 return true
580 default:
581 return false
582 }
583 }
584
585 func requiresFreshApprovalTool(tool string) bool {
586 return RequiresFreshHumanApprovalTool(tool)
587 }
588
589 func allowsFreshSessionGrantTool(tool string) bool {
590 switch tool {
591 case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool:
592 return true
593 default:
594 return false
595 }
596 }
597
598 func approvalNotificationText(tool, subject string) string {
599 if requiresFreshApprovalTool(tool) {
600 return fmt.Sprintf(i18n.M.ApprovalNeededFmt, tool)
601 }
602 if subject == "" {
603 return fmt.Sprintf(i18n.M.ApprovalNeededFmt, tool)
604 }
605 return fmt.Sprintf(i18n.M.ApprovalNeededWithSubjectFmt, tool, subject)
606 }
607
608 func permissionRequestHookPayload(tool, subject string, args json.RawMessage) (string, json.RawMessage, bool) {
609 switch tool {
610 case planApprovalTool:
611 return "", nil, false
612 case memoryRememberTool, memoryForgetTool:
613 return "", nil, true
614 default:
615 return subject, args, true
616 }
617 }
618
618 lines GO