|
| 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