Skip to content

Commit f7f101b

Browse files
committed
fix: batch approval gate only shows for tools needing approval; lists only risky tools
1 parent c98b614 commit f7f101b

4 files changed

Lines changed: 185 additions & 54 deletions

File tree

cmd/odek/telegram.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1265,7 +1265,8 @@ func handleChatMessage(
12651265
&telegram.SendOpts{ReplyMarkup: replyMarkup, ParseMode: "Markdown", ReplyToMessageID: messageID})
12661266
}
12671267
},
1268-
Approver: approver,
1268+
Approver: approver,
1269+
DangerousConfig: &resolved.Dangerous,
12691270
}
12701271

12711272
agent, err := odek.New(agentCfg)

internal/loop/loop.go

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package loop
33

44
import (
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).
182185
func (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+
}

internal/loop/loop_test.go

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,30 @@ func parallelToolServer(t *testing.T, toolCount int, finalAnswer string) *httpte
12351235
}))
12361236
}
12371237

1238+
// batchApprovalServer returns a mock LLM that responds with shell tool calls
1239+
// that can be classified by classifyToolCall for batch approval testing.
1240+
func batchApprovalServer(t *testing.T, toolCount int, finalAnswer string) *httptest.Server {
1241+
callNum := 0
1242+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1243+
callNum++
1244+
if callNum == 1 {
1245+
var b strings.Builder
1246+
b.WriteString(`{"choices":[{"message":{"content":"","tool_calls":[`)
1247+
for j := 0; j < toolCount; j++ {
1248+
if j > 0 {
1249+
b.WriteString(",")
1250+
}
1251+
// Use shell tool with a destructive command targeting /etc
1252+
fmt.Fprintf(&b, `{"id":"call_%d","function":{"name":"shell","arguments":"{\"command\":\"rm -rf /etc/test%d\"}"}}`, j, j)
1253+
}
1254+
b.WriteString(`]}}]}`)
1255+
fmt.Fprint(w, b.String())
1256+
} else {
1257+
fmt.Fprintf(w, `{"choices":[{"message":{"content":%q}}]}`, finalAnswer)
1258+
}
1259+
}))
1260+
}
1261+
12381262
// TestParallelToolExecution verifies that multiple tool calls from one LLM
12391263
// response execute in parallel (total time ~= single tool delay, not sum).
12401264
func TestParallelToolExecution(t *testing.T) {
@@ -1478,6 +1502,17 @@ func TestParallelSingleTool(t *testing.T) {
14781502
// Batch Approval Gate Tests (Phase 1.5)
14791503
// ═════════════════════════════════════════════════════════════════════
14801504

1505+
// mockTool is a simple tool stub for testing.
1506+
type mockTool struct {
1507+
name string
1508+
result string
1509+
}
1510+
1511+
func (t *mockTool) Name() string { return t.name }
1512+
func (t *mockTool) Description() string { return "mock tool for testing" }
1513+
func (t *mockTool) Schema() any { return map[string]any{"type": "object", "properties": map[string]any{}} }
1514+
func (t *mockTool) Call(args string) (string, error) { return t.result, nil }
1515+
14811516
// mockApprover implements danger.Approver plus SetTrustAll for testing.
14821517
type mockApprover struct {
14831518
mu sync.Mutex
@@ -1510,19 +1545,24 @@ func (a *mockApprover) SetTrustAll(enabled bool) {
15101545
// all tool results show "batch approval denied" and no tools execute.
15111546
func TestBatchApprovalDenied(t *testing.T) {
15121547
approver := &mockApprover{approved: false}
1513-
tools := make([]tool.Tool, 3)
1514-
for j := 0; j < 3; j++ {
1515-
tools[j] = &timedTool{name: fmt.Sprintf("tool_%d", j), description: "timed", delayMs: 50}
1516-
}
1517-
registry := tool.NewRegistry(tools)
15181548

1519-
server := parallelToolServer(t, 3, "done")
1549+
// Create a shell tool that returns a canned response.
1550+
shellTool := &mockTool{name: "shell", result: "done"}
1551+
registry := tool.NewRegistry([]tool.Tool{shellTool})
1552+
1553+
server := batchApprovalServer(t, 3, "done")
15201554
defer server.Close()
15211555

15221556
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
15231557
engine := New(client, registry, 10, "", nil, 0)
15241558
engine.SetApprover(approver)
15251559
engine.SetMaxToolParallel(3)
1560+
// Set DangerousConfig so destructive tools are flagged for approval.
1561+
allow := "allow"
1562+
engine.SetDangerousConfig(&danger.DangerousConfig{
1563+
DefaultAction: &allow,
1564+
Classes: map[danger.RiskClass]danger.Action{danger.Destructive: danger.Prompt},
1565+
})
15261566

15271567
result, err := engine.Run(context.Background(), "run 3 tools with batch denied")
15281568
if err != nil {
@@ -1548,21 +1588,23 @@ func TestBatchApprovalDenied(t *testing.T) {
15481588
func TestBatchApprovalApproved(t *testing.T) {
15491589
approver := &mockApprover{approved: true}
15501590

1551-
// Use a tool that checks whether trustAll is active during execution.
1552-
// The timedTool doesn't check approval, so we just verify timing + call count.
1553-
tools := make([]tool.Tool, 3)
1554-
for j := 0; j < 3; j++ {
1555-
tools[j] = &timedTool{name: fmt.Sprintf("tool_%d", j), description: "timed", delayMs: 20}
1556-
}
1557-
registry := tool.NewRegistry(tools)
1591+
// Use a single shell mock tool — the batch gate will classify the
1592+
// calls as destructive and prompt once for the batch.
1593+
shellTool := &mockTool{name: "shell", result: "done"}
1594+
registry := tool.NewRegistry([]tool.Tool{shellTool})
15581595

1559-
server := parallelToolServer(t, 3, "batch approved done")
1596+
server := batchApprovalServer(t, 3, "done")
15601597
defer server.Close()
15611598

15621599
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
15631600
engine := New(client, registry, 10, "", nil, 0)
15641601
engine.SetApprover(approver)
15651602
engine.SetMaxToolParallel(3)
1603+
allow := "allow"
1604+
engine.SetDangerousConfig(&danger.DangerousConfig{
1605+
DefaultAction: &allow,
1606+
Classes: map[danger.RiskClass]danger.Action{danger.Destructive: danger.Prompt},
1607+
})
15661608

15671609
start := time.Now()
15681610
result, err := engine.Run(context.Background(), "run 3 tools with batch approved")
@@ -1571,14 +1613,12 @@ func TestBatchApprovalApproved(t *testing.T) {
15711613
if err != nil {
15721614
t.Fatalf("Run() error: %v", err)
15731615
}
1574-
if result != "batch approved done" {
1575-
t.Errorf("result = %q, want %q", result, "batch approved done")
1616+
if result != "done" {
1617+
t.Errorf("result = %q, want %q", result, "done")
15761618
}
15771619

1578-
// With 3 tools × 20ms parallel, should be ~20ms, not ~60ms
1579-
if elapsed > 100*time.Millisecond {
1580-
t.Errorf("parallel execution took %v (expected ~20ms)", elapsed)
1581-
}
1620+
// Tools execute sequentially through the shared mock, so no timing check.
1621+
_ = elapsed
15821622

15831623
approver.mu.Lock()
15841624
cc := approver.callCount

odek.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ type Config struct {
168168
// for that iteration. If approved, individual tool-level PromptCommand
169169
// calls are bypassed via SetTrustAll.
170170
Approver danger.Approver
171+
172+
// DangerousConfig holds the user's risk class configuration (Allow/Deny/
173+
// Prompt per risk class). Used by the batch gate to decide whether a
174+
// tool call needs approval before showing the prompt. When nil, the
175+
// batch gate plays safe and shows the prompt for any classified tool.
176+
DangerousConfig *danger.DangerousConfig
171177
}
172178

173179
// Agent is the agent loop runtime.
@@ -494,6 +500,9 @@ func New(cfg Config) (*Agent, error) {
494500
if cfg.Approver != nil {
495501
engine.SetApprover(cfg.Approver)
496502
}
503+
if cfg.DangerousConfig != nil {
504+
engine.SetDangerousConfig(cfg.DangerousConfig)
505+
}
497506

498507
// Set skill verbosity: condensed by default, full banners when verbose.
499508
if cfg.Skills != nil {

0 commit comments

Comments
 (0)