1package core
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "testing"
9)
10
11type recordingConfirmer struct {
12 mu sync.Mutex
13 calls []string
14 replies []ConfirmDecision
15 idx int
16}
17
18func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
19 r.mu.Lock()
20 defer r.mu.Unlock()
21 r.calls = append(r.calls, toolName+"/"+preview)
22 if r.idx >= len(r.replies) {
23 return ConfirmDecision{Allow: false, Reason: "no reply queued"}
24 }
25 d := r.replies[r.idx]
26 r.idx++
27 return d
28}
29
30type detailedConfirmer struct {
31 recordingConfirmer
32 call ToolCallConfirmation
33}
34
35func (d *detailedConfirmer) ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision {
36 d.call = call
37 return ConfirmDecision{Allow: true}
38}
39
40func TestConfirmGatePassesRichToolCallPreview(t *testing.T) {
41 confirmer := &detailedConfirmer{}
42 gate := NewConfirmGate(confirmer)
43 call := ToolCallConfirmation{
44 ID: "call-1",
45 Name: "edit",
46 Summary: "sample.go",
47 Content: "-old\n+new\n",
48 }
49 if allow, _, _ := gate.CheckToolCall(call); !allow {
50 t.Fatal("rich confirmation should allow")
51 }
52 if confirmer.call != call {
53 t.Fatalf("confirmation = %+v, want %+v", confirmer.call, call)
54 }
55}
56
57func TestConfirmGateNilAllowsEverything(t *testing.T) {
58 var g *ConfirmGate
59 allow, reason, args := g.Check("bash", "rm -rf /")
60 if !allow || reason != "" || args != nil {
61 t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args)
62 }
63}
64
65func TestConfirmGateNilInnerRefuses(t *testing.T) {
66 g := NewConfirmGate(nil)
67 allow, reason, _ := g.Check("bash", "ls")
68 if allow {
69 t.Fatal("gate with nil inner should refuse")
70 }
71 if reason == "" {
72 t.Fatal("refusal must carry a reason for the model to learn from")
73 }
74}
75
76func TestConfirmGateAllowOnce(t *testing.T) {
77 rc := &recordingConfirmer{replies: []ConfirmDecision{
78 {Allow: true},
79 {Allow: true},
80 }}
81 g := NewConfirmGate(rc)
82 if allow, _, _ := g.Check("bash", "ls"); !allow {
83 t.Fatal("call 1 should allow")
84 }
85 if allow, _, _ := g.Check("bash", "ls"); !allow {
86 t.Fatal("call 2 should allow")
87 }
88 // Two calls, two confirmer invocations (no remember).
89 if len(rc.calls) != 2 {
90 t.Errorf("want 2 confirmer calls, got %d", len(rc.calls))
91 }
92}
93
94func TestConfirmGateRememberTool(t *testing.T) {
95 rc := &recordingConfirmer{replies: []ConfirmDecision{
96 {Allow: true, RememberTool: true},
97 }}
98 g := NewConfirmGate(rc)
99
100 // First call prompts and remembers.
101 if allow, _, _ := g.Check("bash", "ls"); !allow {
102 t.Fatal("call 1 should allow")
103 }
104 // Second call short-circuits; confirmer must NOT be invoked.
105 if allow, _, _ := g.Check("bash", "pwd"); !allow {
106 t.Fatal("call 2 should allow from memory")
107 }
108 // Different tool still prompts.
109 rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"})
110 if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" {
111 t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason)
112 }
113 if len(rc.calls) != 2 {
114 t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls)
115 }
116}
117
118func TestConfirmGateRememberAll(t *testing.T) {
119 rc := &recordingConfirmer{replies: []ConfirmDecision{
120 {Allow: true, RememberAll: true},
121 }}
122 g := NewConfirmGate(rc)
123 if allow, _, _ := g.Check("bash", "ls"); !allow {
124 t.Fatal("call 1 should allow")
125 }
126 // From now on everything short-circuits.
127 if allow, _, _ := g.Check("read", "foo.txt"); !allow {
128 t.Fatal("call 2 should allow")
129 }
130 if allow, _, _ := g.Check("write", "bar.txt"); !allow {
131 t.Fatal("call 3 should allow")
132 }
133 if len(rc.calls) != 1 {
134 t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls))
135 }
136}
137
138func TestConfirmGateRefuseSurfacesReason(t *testing.T) {
139 rc := &recordingConfirmer{replies: []ConfirmDecision{
140 {Allow: false, Reason: "do not run sudo"},
141 }}
142 g := NewConfirmGate(rc)
143 allow, reason, _ := g.Check("bash", "sudo rm -rf /")
144 if allow || reason != "do not run sudo" {
145 t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason)
146 }
147}
148
149func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) {
150 rc := &recordingConfirmer{replies: []ConfirmDecision{
151 {Allow: false},
152 }}
153 g := NewConfirmGate(rc)
154 _, reason, _ := g.Check("bash", "x")
155 if reason == "" {
156 t.Fatal("empty reason must be replaced with a sensible default")
157 }
158}
159
160func TestConfirmGateAllowAllRuntime(t *testing.T) {
161 rc := &recordingConfirmer{replies: []ConfirmDecision{
162 {Allow: false, Reason: "no"},
163 }}
164 g := NewConfirmGate(rc)
165 // Refuse first call
166 if allow, _, _ := g.Check("bash", "ls"); allow {
167 t.Fatal("call 1 should refuse")
168 }
169 // User types /yolo
170 g.AllowAll()
171 // All subsequent calls allowed without prompting.
172 if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow {
173 t.Fatal("after AllowAll, should allow")
174 }
175 if allow, _, _ := g.Check("read", "x"); !allow {
176 t.Fatal("after AllowAll, should allow any tool")
177 }
178 if len(rc.calls) != 1 {
179 t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls))
180 }
181}
182
183func TestBuildPreview(t *testing.T) {
184 cases := []struct {
185 name string
186 args string
187 want string
188 }{
189 {"bash command", `{"command":"ls -la"}`, "ls -la"},
190 {"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"},
191 {"file_path", `{"file_path":"a.go"}`, "a.go"},
192 {"url", `{"url":"https://example.com"}`, "https://example.com"},
193 {"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""},
194 {"unparseable", `{not json`, `{not json`},
195 {"empty", ``, ``},
196 }
197 for _, c := range cases {
198 t.Run(c.name, func(t *testing.T) {
199 got := BuildPreview(json.RawMessage(c.args), 50)
200 if c.name == "truncation" && !hasEllipsis(got) {
201 t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got)
202 return
203 }
204 if c.want != "" && got != c.want {
205 t.Errorf("%s: want %q, got %q", c.name, c.want, got)
206 }
207 })
208 }
209}
210
211func hasEllipsis(s string) bool {
212 return strings.HasSuffix(s, "...")
213}
214
215// keyedFakeTool implements Tool and ConfirmationKeyer. An empty key exercises
216// the fallback path back to the registered tool name.
217type keyedFakeTool struct{ key string }
218
219func (t keyedFakeTool) Name() string { return "subagent" }
220func (t keyedFakeTool) Description() string { return "" }
221func (t keyedFakeTool) Schema() json.RawMessage {
222 return nil
223}
224func (t keyedFakeTool) ConfirmationKey(json.RawMessage) string { return t.key }
225func (t keyedFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
226 return ToolResult{}, nil
227}
228
229// plainFakeTool implements Tool but not ConfirmationKeyer.
230type plainFakeTool struct{}
231
232func (plainFakeTool) Name() string { return "read" }
233func (plainFakeTool) Description() string { return "" }
234func (plainFakeTool) Schema() json.RawMessage {
235 return nil
236}
237func (plainFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
238 return ToolResult{}, nil
239}
240
241func TestConfirmationKeyFallsBackToToolName(t *testing.T) {
242 args := json.RawMessage(`{"action":"status"}`)
243 if got := ConfirmationKey(keyedFakeTool{key: "subagent:status"}, "subagent", args); got != "subagent:status" {
244 t.Fatalf("keyed tool key = %q, want subagent:status", got)
245 }
246 if got := ConfirmationKey(keyedFakeTool{}, "subagent", args); got != "subagent" {
247 t.Fatalf("empty key should fall back to the tool name, got %q", got)
248 }
249 if got := ConfirmationKey(plainFakeTool{}, "read", args); got != "read" {
250 t.Fatalf("non-keyer should use the tool name, got %q", got)
251 }
252}
253
254func TestConfirmGateRememberKeyScopesGrant(t *testing.T) {
255 rc := &recordingConfirmer{replies: []ConfirmDecision{
256 {Allow: true, RememberTool: true},
257 {Allow: false, Reason: "no"},
258 }}
259 g := NewConfirmGate(rc)
260 if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status"}); !allow {
261 t.Fatal("status call should allow")
262 }
263 if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status again"}); !allow {
264 t.Fatal("remembered key should short-circuit")
265 }
266 if allow, reason, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:spawn", Summary: "spawn"}); allow || reason != "no" {
267 t.Fatalf("a sibling action must re-prompt; allow=%v reason=%q", allow, reason)
268 }
269 if len(rc.calls) != 2 {
270 t.Fatalf("confirmer calls = %d, want 2 (status then spawn)", len(rc.calls))
271 }
272}