@@ -3,6 +3,7 @@ package loop
33
44import (
55 "context"
6+ "encoding/json"
67 "fmt"
78 "strings"
89 "time"
@@ -104,11 +105,13 @@ type Engine struct {
104105
105106 // approver gates dangerous operations. When set and the LLM returns
106107 // multiple tool calls in one iteration, a single batch approval prompt
107- // is shown before any tool executes. If the batch is denied, no tools
108- // run for that iteration. If approved, SetTrustAll(true) is called on
109- // the approver (if supported) so individual tool-level PromptCommand
110- // calls auto-approve.
111- approver danger.Approver
108+ // is shown before any tool executes, but ONLY for tools whose risk
109+ // class requires approval according to dangerousCfg. If the batch is
110+ // denied, no tools run for that iteration. If approved, SetTrustAll(true)
111+ // is called on the approver (if supported) so individual tool-level
112+ // PromptCommand calls auto-approve.
113+ approver danger.Approver
114+ dangerousCfg * danger.DangerousConfig // used by batch gate to pre-check risk
112115
113116 // Token accounting — accumulated across all iterations of the most recent run.
114117 // Reset on each Run/RunWithMessages call and read by callers (e.g. WebUI).
@@ -181,6 +184,11 @@ func (e *Engine) SetMaxToolParallel(n int) { e.MaxToolParallel = n }
181184// SetTrustAll).
182185func (e * Engine ) SetApprover (a danger.Approver ) { e .approver = a }
183186
187+ // SetDangerousConfig provides the DangerousConfig for batch gate
188+ // pre-classification. Without it, the batch gate cannot know which
189+ // risk classes require approval and would skip pre-checking.
190+ func (e * Engine ) SetDangerousConfig (cfg * danger.DangerousConfig ) { e .dangerousCfg = cfg }
191+
184192// ── Token Estimation ─────────────────────────────────────────────────
185193//
186194// Zero-dependency heuristic: 1 token ≈ 4 chars for English text.
@@ -623,39 +631,75 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
623631 }
624632 }
625633
626- // Phase 1.5: batch approval gate
627- // When an approver is set and the LLM returned multiple tool calls,
628- // present a single approval prompt for the entire batch instead of
629- // N individual prompts. If denied, all tool calls are rejected
630- // without executing anything. If approved, the approver's trustAll
631- // flag is set so individual tool-level PromptCommand calls auto-pass.
632- batchDenied := false
633- if e .approver != nil && len (result .ToolCalls ) > 1 {
634- var sb strings.Builder
635- sb .WriteString (fmt .Sprintf ("Execute %d tool calls in parallel?\n \n " , len (result .ToolCalls )))
636- for i , tc := range result .ToolCalls {
637- args := tc .Function .Arguments
638- if len (args ) > 120 {
639- args = args [:120 ] + "…"
640- }
641- sb .WriteString (fmt .Sprintf (" %d. %s(%s)\n " , i + 1 , tc .Function .Name , args ))
642- }
643- description := sb .String ()
644634
645- // Single approval prompt for the entire batch.
646- if err := e .approver .PromptCommand ("tool_batch" , description , "" ); err != nil {
647- batchDenied = true
635+ // Phase 1.5: batch approval gate
636+ // When an approver is set and the LLM returned multiple tool calls,
637+ // present a single approval prompt for the entire batch instead of
638+ // N individual prompts, but ONLY for tools that actually require
639+ // approval. If denied, all tool calls are rejected without executing
640+ // anything. If approved, the approver's trustAll flag is set so
641+ // individual tool-level PromptCommand calls auto-pass.
642+ batchDenied := false
643+ if e .approver != nil && len (result .ToolCalls ) > 1 {
644+ // Classify each tool call and filter to only those needing approval.
645+ type riskyCall struct {
646+ idx int
647+ name string
648+ args string
649+ risk danger.RiskClass
650+ resource string
651+ }
652+ var risky []riskyCall
653+ for i , tc := range result .ToolCalls {
654+ risk , resource := classifyToolCall (tc .Function .Name , tc .Function .Arguments )
655+ if risk == "" {
656+ continue // tool not classifiable — skip in batch, handled individually
657+ }
658+ // Check the user's configured action for this risk class.
659+ // If the DangerousConfig says Allow, skip it — no approval needed.
660+ if e .dangerousCfg != nil && e .dangerousCfg .ActionFor (risk ) == danger .Allow {
661+ continue // auto-allowed by config, no batch approval needed
662+ }
663+ // Without DangerousConfig, fall back to blocking: include the tool
664+ // so the batch gate plays safe and prompts.
665+ risky = append (risky , riskyCall {
666+ idx : i , name : tc .Function .Name ,
667+ args : tc .Function .Arguments ,
668+ risk : risk ,
669+ resource : resource ,
670+ })
671+ }
672+
673+ if len (risky ) > 0 {
674+ var sb strings.Builder
675+ if len (risky ) == 1 {
676+ sb .WriteString ("⚠️ The following tool action requires approval:\n \n " )
677+ } else {
678+ sb .WriteString (fmt .Sprintf ("⚠️ %d tool actions require approval:\n \n " , len (risky )))
679+ }
680+ for i , rc := range risky {
681+ resource := rc .resource
682+ if len (resource ) > 120 {
683+ resource = resource [:120 ] + "…"
648684 }
685+ sb .WriteString (fmt .Sprintf (" %d. `%s` — `%s`\n " , i + 1 , rc .name , resource ))
686+ }
687+ description := sb .String ()
649688
650- // Approved: set trustAll on the approver if supported, so
651- // individual tool-level PromptCommand calls auto-pass.
652- if ! batchDenied {
653- if ta , ok := e .approver .(interface { SetTrustAll (bool ) }); ok {
654- ta .SetTrustAll (true )
655- defer ta .SetTrustAll (false )
656- }
689+ if err := e .approver .PromptCommand ("tool_batch" , description , "" ); err != nil {
690+ batchDenied = true
691+ }
692+
693+ // Approved: set trustAll on the approver if supported, so
694+ // individual tool-level PromptCommand calls auto-pass.
695+ if ! batchDenied {
696+ if ta , ok := e .approver .(interface { SetTrustAll (bool ) }); ok {
697+ ta .SetTrustAll (true )
698+ defer ta .SetTrustAll (false )
657699 }
658700 }
701+ }
702+ }
659703
660704 // Phase 2: execute tools in parallel (bounded by semaphore)
661705 type execResult struct {
@@ -801,3 +845,40 @@ func (e *Engine) buildToolDefs() []llm.ToolDef {
801845 }
802846 return defs
803847}
848+
849+ // classifyToolCall attempts to determine the risk class of a tool call
850+ // based on its name and arguments. Returns the risk class and a
851+ // human-readable resource identifier, or ("", "") if the tool is
852+ // classified as safe and does not need approval for this call.
853+ // This mirrors the classification that the actual tool's Call() method
854+ // performs, so the batch gate only prompts for tools that would
855+ // actually require user approval.
856+ func classifyToolCall (name , args string ) (danger.RiskClass , string ) {
857+ switch name {
858+ case "shell" , "terminal" :
859+ // Extract the command from JSON args.
860+ var cmd struct {
861+ Command string `json:"command"`
862+ }
863+ if err := json .Unmarshal ([]byte (args ), & cmd ); err != nil || cmd .Command == "" {
864+ return "" , ""
865+ }
866+ return danger .Classify (cmd .Command ), cmd .Command
867+ case "read_file" , "write_file" , "patch" , "search_files" :
868+ // Extract the path from JSON args.
869+ var p struct {
870+ Path string `json:"path"`
871+ }
872+ if err := json .Unmarshal ([]byte (args ), & p ); err != nil || p .Path == "" {
873+ return "" , ""
874+ }
875+ return danger .ClassifyPath (p .Path ), p .Path
876+ case "browser_navigate" , "browser_click" , "browser_type" :
877+ return danger .NetworkEgress , args
878+ default :
879+ // For unrecognized tools, return empty — they are handled by
880+ // the tool's own Call() method individually. The batch gate
881+ // will skip them (no pre-classification available).
882+ return "" , ""
883+ }
884+ }
0 commit comments