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