Skip to content

Commit d740ed7

Browse files
committed
test: 19 E2E security tests for native tool danger integration
Covers all 5 tools (read_file, write_file, search_files, patch, browser) with per-path classification, cross-tool config consistency, and dangerous.classes override verification. All pass with race detector.
1 parent b893814 commit d740ed7

1 file changed

Lines changed: 384 additions & 0 deletions

File tree

cmd/kode/security_e2e_test.go

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strconv"
7+
"strings"
8+
"testing"
9+
10+
"github.com/BackendStack21/kode/internal/danger"
11+
)
12+
13+
// ── E2E Security: Danger Config Integration ───────────────────────────
14+
//
15+
// These tests verify that all native tools respect the same danger
16+
// classification rules as the shell tool. They use explicit configs
17+
// (non_interactive=deny) since there's no /dev/tty in tests.
18+
19+
func denyNonInteractive() danger.DangerousConfig {
20+
deny := "deny"
21+
return danger.DangerousConfig{
22+
NonInteractive: &deny,
23+
}
24+
}
25+
26+
// ── read_file ─────────────────────────────────────────────────────────
27+
28+
func TestSecurity_ReadFile_SystemPath(t *testing.T) {
29+
tool := &readFileTool{dangerousConfig: denyNonInteractive()}
30+
result := callJSON(t, tool, `{"path":"/etc/shadow"}`)
31+
var r struct{ Error string `json:"error"` }
32+
mustUnmarshal(t, result, &r)
33+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
34+
t.Errorf("expected denial for /etc/shadow, got: %s", r.Error)
35+
}
36+
}
37+
38+
func TestSecurity_ReadFile_LocalPath(t *testing.T) {
39+
dir := t.TempDir()
40+
path := filepath.Join(dir, "test.txt")
41+
os.WriteFile(path, []byte("hello"), 0644)
42+
43+
tool := &readFileTool{dangerousConfig: denyNonInteractive()}
44+
result := callJSON(t, tool, `{"path":"`+path+`"}`)
45+
var r struct{ Error string `json:"error"` }
46+
mustUnmarshal(t, result, &r)
47+
if r.Error != "" {
48+
t.Fatalf("expected allow for local path, got: %s", r.Error)
49+
}
50+
}
51+
52+
func TestSecurity_ReadFile_DestructivePath(t *testing.T) {
53+
tool := &readFileTool{dangerousConfig: denyNonInteractive()}
54+
result := callJSON(t, tool, `{"path":"/boot/vmlinuz"}`)
55+
var r struct{ Error string `json:"error"` }
56+
mustUnmarshal(t, result, &r)
57+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
58+
t.Errorf("expected denial for /boot path, got: %s", r.Error)
59+
}
60+
}
61+
62+
func TestSecurity_ReadFile_SSHKeyPath(t *testing.T) {
63+
home, _ := os.UserHomeDir()
64+
if home == "" {
65+
t.Skip("no home dir")
66+
}
67+
68+
tool := &readFileTool{dangerousConfig: denyNonInteractive()}
69+
result := callJSON(t, tool, `{"path":"`+filepath.Join(home, ".ssh", "id_rsa")+`"}`)
70+
var r struct{ Error string `json:"error"` }
71+
mustUnmarshal(t, result, &r)
72+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
73+
t.Errorf("expected denial for %s/.ssh, got: %s", home, r.Error)
74+
}
75+
}
76+
77+
// ── write_file ────────────────────────────────────────────────────────
78+
79+
func TestSecurity_WriteFile_SystemPath(t *testing.T) {
80+
tool := &writeFileTool{dangerousConfig: denyNonInteractive()}
81+
result := callJSON(t, tool, `{"path":"/etc/test-kode.txt","content":"test"}`)
82+
var r struct{ Error string `json:"error"` }
83+
mustUnmarshal(t, result, &r)
84+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
85+
t.Errorf("expected denial for /etc path, got: %s", r.Error)
86+
}
87+
}
88+
89+
func TestSecurity_WriteFile_TmpPath(t *testing.T) {
90+
tool := &writeFileTool{dangerousConfig: denyNonInteractive()}
91+
path := filepath.Join(os.TempDir(), "kode-test-"+strconv.Itoa(os.Getpid())+".txt")
92+
result := callJSON(t, tool, `{"path":"`+path+`","content":"safe"}`)
93+
var r struct{ Error string `json:"error"` }
94+
mustUnmarshal(t, result, &r)
95+
if r.Error != "" {
96+
t.Fatalf("expected allow for /tmp path, got: %s", r.Error)
97+
}
98+
os.Remove(path)
99+
}
100+
101+
func TestSecurity_WriteFile_DestructivePath(t *testing.T) {
102+
tool := &writeFileTool{dangerousConfig: denyNonInteractive()}
103+
result := callJSON(t, tool, `{"path":"/dev/null","content":"test"}`)
104+
var r struct{ Error string `json:"error"` }
105+
mustUnmarshal(t, result, &r)
106+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
107+
t.Errorf("expected denial for /dev path, got: %s", r.Error)
108+
}
109+
}
110+
111+
func TestSecurity_WriteFile_CustomAllowlist(t *testing.T) {
112+
// /etc is system_write → deny by default in deny-non-interactive mode.
113+
// But an allowlist for this specific path should override.
114+
allow := "allow"
115+
dc := danger.DangerousConfig{
116+
NonInteractive: &allow,
117+
}
118+
tool := &writeFileTool{dangerousConfig: dc}
119+
result := callJSON(t, tool, `{"path":"/etc/passwd","content":"hack"}`)
120+
var r struct{ Error string `json:"error"` }
121+
mustUnmarshal(t, result, &r)
122+
// With non_interactive=allow, all operations are allowed
123+
if r.Error != "" {
124+
t.Errorf("expected allow with non_interactive=allow, got: %s", r.Error)
125+
}
126+
}
127+
128+
// ── search_files ──────────────────────────────────────────────────────
129+
130+
func TestSecurity_SearchFiles_SystemPath(t *testing.T) {
131+
tool := &searchFilesTool{dangerousConfig: denyNonInteractive()}
132+
result := callJSON(t, tool, `{"pattern":"test","path":"/etc"}`)
133+
var r struct{ Error string `json:"error"` }
134+
mustUnmarshal(t, result, &r)
135+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
136+
t.Errorf("expected denial for /etc path, got: %s", r.Error)
137+
}
138+
}
139+
140+
func TestSecurity_SearchFiles_LocalPath(t *testing.T) {
141+
dir := t.TempDir()
142+
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0644)
143+
144+
tool := &searchFilesTool{dangerousConfig: denyNonInteractive()}
145+
result := callJSON(t, tool, `{"pattern":"hello","path":"`+dir+`"}`)
146+
var r struct{ Error string `json:"error"` }
147+
mustUnmarshal(t, result, &r)
148+
if r.Error != "" {
149+
t.Fatalf("expected allow for temp dir, got: %s", r.Error)
150+
}
151+
}
152+
153+
// ── patch ─────────────────────────────────────────────────────────────
154+
155+
func TestSecurity_Patch_SystemPath(t *testing.T) {
156+
// patch temp file → then patch system file → denied
157+
dir := t.TempDir()
158+
path := filepath.Join(dir, "test.txt")
159+
os.WriteFile(path, []byte("hello"), 0644)
160+
161+
// First, successful patch on local path
162+
tool := &patchTool{dangerousConfig: denyNonInteractive()}
163+
result := callJSON(t, tool, `{"path":"`+path+`","old_string":"hello","new_string":"world"}`)
164+
var r struct{ Success bool `json:"success"` }
165+
mustUnmarshal(t, result, &r)
166+
if !r.Success {
167+
t.Fatal("expected success for local patch")
168+
}
169+
170+
// Now try system path
171+
result = callJSON(t, tool, `{"path":"/etc/motd","old_string":"x","new_string":"y"}`)
172+
var r2 struct{ Error string `json:"error"` }
173+
mustUnmarshal(t, result, &r2)
174+
if r2.Error == "" || !strings.Contains(r2.Error, "denied") {
175+
t.Errorf("expected denial for /etc path, got: %s", r2.Error)
176+
}
177+
}
178+
179+
// ── browser ───────────────────────────────────────────────────────────
180+
181+
func TestSecurity_Browser_ExternalURL(t *testing.T) {
182+
// With non_interactive=allow, external URLs should work
183+
allow := "allow"
184+
dc := danger.DangerousConfig{
185+
NonInteractive: &allow,
186+
}
187+
188+
tool := &browserTool{dangerousConfig: dc}
189+
// Don't actually fetch — just test the classification check passes
190+
result := callJSON(t, tool, `{"action":"navigate","url":"http://nonexistent-test-12345.com"}`)
191+
var r struct{ Error string `json:"error"` }
192+
mustUnmarshal(t, result, &r)
193+
// Error should be connection-related, not security-denial
194+
if r.Error != "" && strings.Contains(r.Error, "denied") {
195+
t.Errorf("external URL should not be denied: %s", r.Error)
196+
}
197+
}
198+
199+
func TestSecurity_Browser_Localhost(t *testing.T) {
200+
tool := &browserTool{dangerousConfig: denyNonInteractive()}
201+
result := callJSON(t, tool, `{"action":"navigate","url":"http://localhost:9200"}`)
202+
var r struct{ Error string `json:"error"` }
203+
mustUnmarshal(t, result, &r)
204+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
205+
t.Errorf("expected denial for localhost, got: %s", r.Error)
206+
}
207+
}
208+
209+
func TestSecurity_Browser_InternalIP(t *testing.T) {
210+
tool := &browserTool{dangerousConfig: denyNonInteractive()}
211+
result := callJSON(t, tool, `{"action":"navigate","url":"http://192.168.1.1"}`)
212+
var r struct{ Error string `json:"error"` }
213+
mustUnmarshal(t, result, &r)
214+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
215+
t.Errorf("expected denial for internal IP, got: %s", r.Error)
216+
}
217+
}
218+
219+
// ── Consistent config across tools ────────────────────────────────────
220+
//
221+
// Verify that all write tools classify the same path the same way.
222+
223+
func TestSecurity_ConfigConsistency_SystemPath(t *testing.T) {
224+
dc := denyNonInteractive()
225+
path := "/etc/passwd"
226+
227+
// All four tools should deny /etc in non-interactive mode
228+
tools := []struct {
229+
name string
230+
tool interface{ Call(string) (string, error) }
231+
}{
232+
{"read_file", &readFileTool{dangerousConfig: dc}},
233+
{"write_file", &writeFileTool{dangerousConfig: dc}},
234+
{"search_files", &searchFilesTool{dangerousConfig: dc}},
235+
{"patch", &patchTool{dangerousConfig: dc}},
236+
}
237+
238+
for _, tt := range tools {
239+
var args string
240+
switch tt.name {
241+
case "read_file":
242+
args = `{"path":"` + path + `"}`
243+
case "write_file":
244+
args = `{"path":"` + path + `","content":"x"}`
245+
case "search_files":
246+
args = `{"pattern":"x","path":"` + path + `"}`
247+
case "patch":
248+
args = `{"path":"` + path + `","old_string":"x","new_string":"y"}`
249+
}
250+
251+
result, err := tt.tool.Call(args)
252+
if err != nil {
253+
t.Fatalf("%s: unexpected error: %v", tt.name, err)
254+
}
255+
var r struct{ Error string `json:"error"` }
256+
mustUnmarshal(t, result, &r)
257+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
258+
t.Errorf("%s(%s) = %q, want denial", tt.name, path, r.Error)
259+
}
260+
}
261+
}
262+
263+
func TestSecurity_ConfigConsistency_TmpPath(t *testing.T) {
264+
dir := t.TempDir()
265+
path := filepath.Join(dir, "test.txt")
266+
dc := denyNonInteractive()
267+
268+
// All four tools should allow tmp paths
269+
tools := []struct {
270+
name string
271+
tool interface{ Call(string) (string, error) }
272+
prep func()
273+
}{
274+
{"read_file", &readFileTool{dangerousConfig: dc}, func() {
275+
os.WriteFile(path, []byte("hello"), 0644)
276+
}},
277+
{"write_file", &writeFileTool{dangerousConfig: dc}, func() {}},
278+
{"search_files", &searchFilesTool{dangerousConfig: dc}, func() {
279+
os.WriteFile(path, []byte("hello"), 0644)
280+
}},
281+
{"patch", &patchTool{dangerousConfig: dc}, func() {
282+
os.WriteFile(path, []byte("hello"), 0644)
283+
}},
284+
}
285+
286+
for _, tt := range tools {
287+
tt.prep()
288+
289+
var args string
290+
switch tt.name {
291+
case "read_file":
292+
args = `{"path":"` + path + `"}`
293+
case "write_file":
294+
args = `{"path":"` + path + `","content":"x"}`
295+
case "search_files":
296+
args = `{"pattern":"hello","path":"` + dir + `"}`
297+
case "patch":
298+
args = `{"path":"` + path + `","old_string":"hello","new_string":"world"}`
299+
}
300+
301+
// Re-create tools to reset trusted state
302+
result, err := tt.tool.Call(args)
303+
if err != nil {
304+
t.Fatalf("%s: unexpected error: %v", tt.name, err)
305+
}
306+
var r struct{ Error string `json:"error"` }
307+
mustUnmarshal(t, result, &r)
308+
if r.Error != "" {
309+
t.Errorf("%s(%s) = %q, want allow", tt.name, path, r.Error)
310+
}
311+
}
312+
}
313+
314+
// ── Per-class config override ─────────────────────────────────────────
315+
//
316+
// Verify that changing a risk class in config changes tool behavior.
317+
318+
func TestSecurity_ClassOverride_LocalWriteDenied(t *testing.T) {
319+
// Override local_write to deny (default is allow)
320+
deny := "deny"
321+
dc := danger.DangerousConfig{
322+
Classes: map[danger.RiskClass]danger.Action{
323+
danger.LocalWrite: danger.Deny,
324+
},
325+
NonInteractive: &deny,
326+
}
327+
328+
dir := t.TempDir()
329+
path := filepath.Join(dir, "test.txt")
330+
331+
// write_file to temp dir → local_write → should be denied now
332+
tool := &writeFileTool{dangerousConfig: dc}
333+
result := callJSON(t, tool, `{"path":"`+path+`","content":"x"}`)
334+
var r struct{ Error string `json:"error"` }
335+
mustUnmarshal(t, result, &r)
336+
if r.Error == "" || !strings.Contains(r.Error, "denied") {
337+
t.Errorf("expected denial after local_write override, got: %s", r.Error)
338+
}
339+
}
340+
341+
func TestSecurity_ClassOverride_SystemWriteAllowed(t *testing.T) {
342+
// Override system_write to allow (default is prompt)
343+
allow := "allow"
344+
dc := danger.DangerousConfig{
345+
Classes: map[danger.RiskClass]danger.Action{
346+
danger.SystemWrite: danger.Allow,
347+
},
348+
NonInteractive: &allow,
349+
}
350+
351+
// write_file to /etc → system_write → should be allowed now
352+
tool := &writeFileTool{dangerousConfig: dc}
353+
result := callJSON(t, tool, `{"path":"/etc/kode-test-override","content":"x"}`)
354+
var r struct{ Error string `json:"error"` }
355+
mustUnmarshal(t, result, &r)
356+
// Write will fail because /etc is not writable, but it should NOT be a security denial
357+
if r.Error != "" && strings.Contains(r.Error, "denied") {
358+
t.Errorf("expected no security denial after system_write=allow, got: %s", r.Error)
359+
}
360+
// Error should be "cannot create directory" or similar FS error, not a config denial
361+
if r.Error != "" && !strings.Contains(r.Error, "denied") {
362+
t.Logf("expected FS error (not denial): %s", r.Error) // expected
363+
}
364+
}
365+
366+
func TestSecurity_ClassOverride_NetworkEgressAllowed(t *testing.T) {
367+
// Override network_egress to allow (default is prompt)
368+
allow := "allow"
369+
dc := danger.DangerousConfig{
370+
Classes: map[danger.RiskClass]danger.Action{
371+
danger.NetworkEgress: danger.Allow,
372+
},
373+
NonInteractive: &allow,
374+
}
375+
376+
tool := &browserTool{dangerousConfig: dc}
377+
result := callJSON(t, tool, `{"action":"navigate","url":"http://example.com"}`)
378+
var r struct{ Error string `json:"error"` }
379+
mustUnmarshal(t, result, &r)
380+
// Should NOT be a security denial
381+
if r.Error != "" && strings.Contains(r.Error, "denied") {
382+
t.Errorf("expected no security denial after network_egress=allow, got: %s", r.Error)
383+
}
384+
}

0 commit comments

Comments
 (0)