返回 DeepSeek-Reasonix
confine_test.go
根目录 / internal / tool / builtin / confine_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "reasonix/internal/config"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/secrets"
16 "reasonix/internal/testenv"
17 "reasonix/internal/tool"
18 )
19
20 func TestPowerShellToolUsesPwshIdentityAndConfinedLegacyAlias(t *testing.T) {
21 spec := sandbox.Spec{
22 Mode: "enforce",
23 Shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"},
24 }
25 primary := ConfineBash(spec, SessionDataGuard{})
26 if primary.Name() != "pwsh" {
27 t.Fatalf("primary name = %q", primary.Name())
28 }
29 schema := string(primary.Schema())
30 for _, want := range []string{`"description"`, `"timeout_ms"`, `"run_in_background"`} {
31 if !strings.Contains(schema, want) {
32 t.Fatalf("pwsh schema missing %s: %s", want, schema)
33 }
34 }
35 if strings.Contains(schema, "preserve_background_processes") {
36 t.Fatalf("pwsh schema should require formal jobs: %s", schema)
37 }
38 legacy, ok := AliasBash(primary, "bash")
39 if !ok || legacy.Name() != "bash" {
40 t.Fatalf("legacy alias = %T/%v/%q", legacy, ok, legacy.Name())
41 }
42 if got := legacy.(bash).sb.Mode; got != "enforce" {
43 t.Fatalf("legacy alias lost confinement: %q", got)
44 }
45 }
46
47 func TestWindowsEnabledToolShellAliasesSelectOnlyPwsh(t *testing.T) {
48 workspace := Workspace{Bash: sandbox.Spec{
49 Mode: "enforce",
50 Shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"},
51 }}
52 for _, configured := range []string{"bash", "Bash", "PowerShell", "powershell", "pwsh"} {
53 tools := workspace.Tools(configured)
54 if len(tools) != 1 || tools[0].Name() != "pwsh" {
55 t.Fatalf("enabled %q produced %#v; want only pwsh", configured, tools)
56 }
57 }
58 }
59
60 func TestGitBashToolBindingPreservesBashDialectAndStableSchema(t *testing.T) {
61 workspace := Workspace{Bash: sandbox.Spec{
62 Mode: "off",
63 Shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`},
64 }}
65 baseline := ConfineBash(workspace.Bash, SessionDataGuard{})
66 for _, configured := range []string{"bash", "Bash", "PowerShell", "powershell", "pwsh"} {
67 tools := workspace.Tools(configured)
68 if len(tools) != 1 || tools[0].Name() != "bash" {
69 t.Fatalf("enabled %q produced %#v; want the bound Bash tool", configured, tools)
70 }
71 if tools[0].Description() != baseline.Description() || string(tools[0].Schema()) != string(baseline.Schema()) {
72 t.Fatalf("shell alias %q changed provider-visible Bash schema", configured)
73 }
74 bound := tools[0].(bash)
75 if bound.resolved() != workspace.Bash.Shell || strings.Contains(bound.Description(), "PowerShell command") {
76 t.Fatalf("Git Bash binding lost its dialect: %+v", bound)
77 }
78 }
79 }
80
81 func isolateBuiltinTestUserState(t *testing.T) string {
82 t.Helper()
83 cleanup, err := testenv.IsolateUserState()
84 if err != nil {
85 t.Fatal(err)
86 }
87 t.Cleanup(cleanup)
88 return os.Getenv("HOME")
89 }
90
91 func TestWithin(t *testing.T) {
92 root := filepath.FromSlash("/work/proj")
93 cases := []struct {
94 path string
95 want bool
96 }{
97 {filepath.FromSlash("/work/proj"), true}, // the root itself
98 {filepath.FromSlash("/work/proj/a/b.go"), true}, // nested
99 {filepath.FromSlash("/work/proj/../proj/x"), true}, // normalises back inside
100 {filepath.FromSlash("/work/other"), false}, // sibling
101 {filepath.FromSlash("/work/proj-2"), false}, // prefix collision, not within
102 {filepath.FromSlash("/etc/passwd"), false}, // elsewhere
103 {filepath.FromSlash("/work"), false}, // parent
104 }
105 for _, c := range cases {
106 if got := within(root, filepath.Clean(c.path)); got != c.want {
107 t.Errorf("within(%q, %q) = %v, want %v", root, c.path, got, c.want)
108 }
109 }
110 }
111
112 func TestConfineUnconfinedWhenNoRoots(t *testing.T) {
113 if err := confine(nil, "/anywhere/at/all"); err != nil {
114 t.Errorf("empty roots should be unconfined, got %v", err)
115 }
116 }
117
118 func TestRebindBashWriteRootsUsesMinimalWriteSurface(t *testing.T) {
119 root := t.TempDir()
120 claim := filepath.Join(root, "claimed")
121 tool, ok := RebindBashWriteRoots(ConfineBash(sandbox.Spec{
122 Mode: "enforce",
123 WriteRoots: []string{root},
124 }, SessionDataGuard{}), []string{claim})
125 if !ok {
126 t.Fatal("expected confined bash to be rebound")
127 }
128 rebound, ok := tool.(bash)
129 if !ok {
130 t.Fatalf("rebound tool type = %T, want bash", tool)
131 }
132 if !rebound.sb.MinimalWrites {
133 t.Fatal("rebound bash must disable write allowances outside claim roots")
134 }
135 want := realRoots([]string{claim})
136 if len(rebound.sb.WriteRoots) != 1 || rebound.sb.WriteRoots[0] != want[0] {
137 t.Fatalf("write roots = %v, want %v", rebound.sb.WriteRoots, want)
138 }
139 }
140
141 func TestReboundBashCannotWriteOutsideClaim(t *testing.T) {
142 if !sandbox.Available() {
143 t.Skip("OS sandbox unavailable")
144 }
145 root := t.TempDir()
146 claim := filepath.Join(root, "claimed")
147 if err := os.MkdirAll(claim, 0o755); err != nil {
148 t.Fatal(err)
149 }
150 rebound, ok := RebindBashWriteRoots(ConfineBash(sandbox.Spec{
151 Mode: "enforce",
152 WriteRoots: []string{root},
153 }, SessionDataGuard{}), []string{claim})
154 if !ok {
155 t.Fatal("expected confined bash to be rebound")
156 }
157
158 inside := filepath.Join(claim, "inside.txt")
159 args, _ := json.Marshal(map[string]string{"command": fmt.Sprintf("printf inside > %q", inside)})
160 if _, err := rebound.Execute(context.Background(), args); err != nil {
161 t.Fatalf("write inside claim failed: %v", err)
162 }
163 if _, err := os.Stat(inside); err != nil {
164 t.Fatalf("write inside claim did not land: %v", err)
165 }
166
167 outside := filepath.Join(t.TempDir(), "escaped.txt")
168 args, _ = json.Marshal(map[string]string{"command": fmt.Sprintf("printf escaped > %q", outside)})
169 _, _ = rebound.Execute(context.Background(), args)
170 if _, err := os.Stat(outside); !os.IsNotExist(err) {
171 t.Fatalf("rebound bash wrote outside claim, stat err=%v", err)
172 }
173 }
174
175 func TestConfineInsideAndOutside(t *testing.T) {
176 root := t.TempDir()
177 roots := realRoots([]string{root})
178
179 if err := confine(roots, filepath.Join(root, "src", "main.go")); err != nil {
180 t.Errorf("path inside root rejected: %v", err)
181 }
182 // A sibling of the root and a parent escape must both be refused.
183 if err := confine(roots, filepath.Join(root, "..", "escape.txt")); err == nil {
184 t.Error("parent-escape path accepted, want error")
185 }
186 if err := confine(roots, filepath.Join(filepath.Dir(root), "neighbour", "x")); err == nil {
187 t.Error("sibling path accepted, want error")
188 }
189 }
190
191 func TestConfineRejectsSymlinkEscape(t *testing.T) {
192 root := t.TempDir()
193 outside := t.TempDir()
194 // A symlinked directory inside the root pointing outside must not become a
195 // tunnel: a write "within" the link still resolves outside the root.
196 link := filepath.Join(root, "out")
197 if err := os.Symlink(outside, link); err != nil {
198 t.Skipf("symlinks unavailable: %v", err)
199 }
200 roots := realRoots([]string{root})
201 if err := confine(roots, filepath.Join(link, "evil.txt")); err == nil {
202 t.Error("write through symlinked dir escaped the root, want error")
203 }
204 // A normal file under the real root still passes.
205 if err := confine(roots, filepath.Join(root, "ok.txt")); err != nil {
206 t.Errorf("legit path rejected: %v", err)
207 }
208 }
209
210 func TestWriteFileConfinement(t *testing.T) {
211 root := t.TempDir()
212 w := writeFile{roots: realRoots([]string{root})}
213
214 // Inside: written.
215 in := filepath.Join(root, "a", "in.txt")
216 args, _ := json.Marshal(map[string]string{"path": in, "content": "hi"})
217 if _, err := w.Execute(context.Background(), args); err != nil {
218 t.Fatalf("write inside root failed: %v", err)
219 }
220 if _, err := os.Stat(in); err != nil {
221 t.Errorf("file not created inside root: %v", err)
222 }
223
224 // Outside: refused, and the file must not be created.
225 out := filepath.Join(t.TempDir(), "out.txt")
226 args, _ = json.Marshal(map[string]string{"path": out, "content": "nope"})
227 if _, err := w.Execute(context.Background(), args); err == nil {
228 t.Error("write outside root should error")
229 }
230 if _, err := os.Stat(out); !os.IsNotExist(err) {
231 t.Error("file outside root must not be created")
232 }
233 }
234
235 func TestWriteFileDefaultRootsDenyUserConfigUnlessAllowed(t *testing.T) {
236 home := isolateBuiltinTestUserState(t)
237
238 project := filepath.Join(home, "project")
239 if err := os.MkdirAll(project, 0o755); err != nil {
240 t.Fatal(err)
241 }
242 cfg := config.Default()
243 w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project))}
244
245 userConfig := config.UserConfigPath()
246 args, _ := json.Marshal(map[string]string{
247 "path": userConfig,
248 "content": "default_model = \"deepseek\"\n",
249 })
250 if _, err := w.Execute(context.Background(), args); err == nil {
251 t.Fatalf("write user config should be denied by default")
252 }
253 if _, err := os.Stat(userConfig); !os.IsNotExist(err) {
254 t.Fatalf("user config must not be created by default, stat err=%v", err)
255 }
256
257 cfg.Sandbox.AllowWrite = []string{filepath.Dir(userConfig)}
258 w = writeFile{roots: realRoots(cfg.WriteRootsForRoot(project))}
259 if _, err := w.Execute(context.Background(), args); err != nil {
260 t.Fatalf("write user config should be allowed with allow_write: %v", err)
261 }
262 if _, err := os.Stat(userConfig); err != nil {
263 t.Fatalf("user config was not created with allow_write: %v", err)
264 }
265 }
266
267 // stubConfigWriteApprover is a scripted tool.ConfigWriteApprover recording the
268 // paths it was asked about.
269 type stubConfigWriteApprover struct {
270 allow bool
271 reason string
272 asked []string
273 }
274
275 func (s *stubConfigWriteApprover) ApproveManagedConfigWrite(_ context.Context, req tool.ConfigWriteRequest) (bool, string, error) {
276 s.asked = append(s.asked, req.Path)
277 return s.allow, s.reason, nil
278 }
279
280 func TestManagedConfigWriteFailsClosedWithoutApprover(t *testing.T) {
281 home := isolateBuiltinTestUserState(t)
282
283 project := filepath.Join(home, "project")
284 if err := os.MkdirAll(project, 0o755); err != nil {
285 t.Fatal(err)
286 }
287 cfg := config.Default()
288 managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths())
289 w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project)), managed: managed}
290
291 // Headless runs and sub-agents with no interactive parent carry no approver
292 // on ctx: the managed-config escape hatch must fail closed.
293 userConfig := config.UserConfigPath()
294 args, _ := json.Marshal(map[string]string{"path": userConfig, "content": "{}\n"})
295 _, err := w.Execute(context.Background(), args)
296 if err == nil {
297 t.Fatalf("managed config write without an approver should be denied")
298 }
299 if !strings.Contains(err.Error(), "interactive user approval") {
300 t.Fatalf("fail-closed error should name the missing approval, got: %v", err)
301 }
302 if _, err := os.Stat(userConfig); !os.IsNotExist(err) {
303 t.Fatalf("user config must not be created without approval, stat err=%v", err)
304 }
305 }
306
307 func TestManagedConfigWriteGatedOnApprover(t *testing.T) {
308 home := isolateBuiltinTestUserState(t)
309
310 project := filepath.Join(home, "project")
311 if err := os.MkdirAll(project, 0o755); err != nil {
312 t.Fatal(err)
313 }
314 cfg := config.Default()
315 managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths())
316 w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project)), managed: managed}
317
318 // Approved: current config.toml and the legacy v0.x config.json become
319 // writable, and the approver sees each target.
320 approve := &stubConfigWriteApprover{allow: true}
321 ctx := tool.WithConfigWriteApprover(context.Background(), approve)
322 for _, target := range []string{
323 config.UserConfigPath(),
324 filepath.Join(home, ".reasonix", "config.json"),
325 } {
326 args, _ := json.Marshal(map[string]string{"path": target, "content": "{}\n"})
327 if _, err := w.Execute(ctx, args); err != nil {
328 t.Fatalf("approved managed config write %s: %v", target, err)
329 }
330 if _, err := os.Stat(target); err != nil {
331 t.Fatalf("managed config was not created %s: %v", target, err)
332 }
333 }
334 if len(approve.asked) != 2 {
335 t.Fatalf("approver should be asked once per write, asked=%v", approve.asked)
336 }
337
338 // Declined: the approver's reason surfaces to the model and nothing lands.
339 decline := &stubConfigWriteApprover{allow: false, reason: "the user declined this Reasonix config write"}
340 dctx := tool.WithConfigWriteApprover(context.Background(), decline)
341 declinedTarget := config.UserConfigPath()
342 if err := os.Remove(declinedTarget); err != nil && !os.IsNotExist(err) {
343 t.Fatalf("remove approved config before declined write: %v", err)
344 }
345 args, _ := json.Marshal(map[string]string{"path": declinedTarget, "content": "{}\n"})
346 if _, err := w.Execute(dctx, args); err == nil || !strings.Contains(err.Error(), "declined") {
347 t.Fatalf("declined managed config write should surface the reason, got: %v", err)
348 }
349 if _, err := os.Stat(declinedTarget); !os.IsNotExist(err) {
350 t.Fatalf("declined config must not be created, stat err=%v", err)
351 }
352
353 // Even with an always-allowing approver, non-config files in the Reasonix
354 // home and the rest of the OS home stay denied — the escape hatch is
355 // file-level, not directory-level.
356 for _, target := range []string{
357 filepath.Join(home, "notes.txt"),
358 filepath.Join(home, ".reasonix", ".env"),
359 filepath.Join(home, ".reasonix", "settings.json"),
360 filepath.Join(home, ".reasonix", "skills", "evil", "SKILL.md"),
361 } {
362 asked := len(approve.asked)
363 args, _ := json.Marshal(map[string]string{"path": target, "content": "nope\n"})
364 if _, err := w.Execute(ctx, args); err == nil {
365 t.Fatalf("write outside managed config files should be denied: %s", target)
366 }
367 if len(approve.asked) != asked {
368 t.Fatalf("non-managed target %s must not reach the approver", target)
369 }
370 if _, err := os.Stat(target); !os.IsNotExist(err) {
371 t.Fatalf("file must not be created %s, stat err=%v", target, err)
372 }
373 }
374 }
375
376 // TestManagedConfigWriteAsksEvenInsideRoots pins that a Reasonix-managed config
377 // file stays gated behind the fresh per-write approval even when the write
378 // roots already cover it: YOLO and a widened allow_write must never let an
379 // agent rewrite config.toml silently.
380 func TestManagedConfigWriteAsksEvenInsideRoots(t *testing.T) {
381 home := isolateBuiltinTestUserState(t)
382 if err := os.MkdirAll(filepath.Join(home, ".reasonix"), 0o755); err != nil {
383 t.Fatal(err)
384 }
385 cfg := config.Default()
386 userConfig := config.UserConfigPath()
387 // The whole isolated home is a write root, config.toml included.
388 cfg.Sandbox.AllowWrite = []string{home}
389 managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths())
390 w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(home)), managed: managed}
391 args, _ := json.Marshal(map[string]string{"path": userConfig, "content": "{}\n"})
392
393 // No approver: fails closed even though the path is inside the roots.
394 if _, err := w.Execute(context.Background(), args); err == nil || !strings.Contains(err.Error(), "interactive user approval") {
395 t.Fatalf("in-root managed config write without an approver should be denied, got: %v", err)
396 }
397 if _, err := os.Stat(userConfig); !os.IsNotExist(err) {
398 t.Fatalf("user config must not be created without approval, stat err=%v", err)
399 }
400
401 // Approved: the approver is consulted and the write lands.
402 approve := &stubConfigWriteApprover{allow: true}
403 ctx := tool.WithConfigWriteApprover(context.Background(), approve)
404 if _, err := w.Execute(ctx, args); err != nil {
405 t.Fatalf("approved in-root managed config write: %v", err)
406 }
407 if len(approve.asked) != 1 || approve.asked[0] != userConfig {
408 t.Fatalf("approver should be asked for the in-root config path, asked=%v", approve.asked)
409 }
410
411 // Declined: reason surfaces, nothing lands.
412 decline := &stubConfigWriteApprover{allow: false, reason: "the user declined this Reasonix config write"}
413 dctx := tool.WithConfigWriteApprover(context.Background(), decline)
414 if err := os.Remove(userConfig); err != nil {
415 t.Fatalf("remove approved config before declined write: %v", err)
416 }
417 if _, err := w.Execute(dctx, args); err == nil || !strings.Contains(err.Error(), "declined") {
418 t.Fatalf("declined in-root managed config write should surface the reason, got: %v", err)
419 }
420 if _, err := os.Stat(userConfig); !os.IsNotExist(err) {
421 t.Fatalf("declined config must not be created, stat err=%v", err)
422 }
423
424 // A plain file inside the roots still writes silently: the managed gate is
425 // file-level, not directory-level.
426 plain := filepath.Join(home, "notes.txt")
427 pargs, _ := json.Marshal(map[string]string{"path": plain, "content": "hi\n"})
428 if _, err := w.Execute(context.Background(), pargs); err != nil {
429 t.Fatalf("plain in-root write must not consult the approver: %v", err)
430 }
431 if len(approve.asked) != 1 {
432 t.Fatalf("plain in-root write must not reach the approver, asked=%v", approve.asked)
433 }
434 }
435
436 func TestBashSandboxConfinement(t *testing.T) {
437 if !sandbox.Available() {
438 t.Skip("OS sandbox not available")
439 }
440 home, err := os.UserHomeDir()
441 if err != nil {
442 t.Skipf("no home dir: %v", err)
443 }
444 work, err := os.MkdirTemp(home, ".reasonix-bashsb-*")
445 if err != nil {
446 t.Skipf("cannot create work dir under home: %v", err)
447 }
448 t.Cleanup(func() { os.RemoveAll(work) })
449 t.Chdir(work)
450 spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, Network: true}
451 b := ConfineBash(spec, SessionDataGuard{})
452
453 // Writing inside the root works; writing to a sibling under $HOME is denied
454 // by the sandbox the bash tool wrapped the command in.
455 inCommand := "echo hi > " + filepath.Join(work, "in.txt")
456 inArgs, _ := json.Marshal(map[string]string{"command": inCommand})
457 if _, err := b.Execute(context.Background(), inArgs); err != nil {
458 t.Fatalf("bash write inside root failed: %v", err)
459 }
460 outPath := filepath.Join(home, ".reasonix-bashsb-escape.txt")
461 t.Cleanup(func() { os.Remove(outPath) })
462 outCommand := "echo nope > " + outPath
463 outArgs, _ := json.Marshal(map[string]string{"command": outCommand})
464 if _, err := b.Execute(context.Background(), outArgs); err == nil {
465 t.Error("bash write outside the workspace should be denied by the sandbox")
466 }
467 if _, err := os.Stat(outPath); !os.IsNotExist(err) {
468 t.Error("escaping write must not create the file")
469 }
470 }
471
472 func TestBashEnforceRejectsWhenSandboxUnavailable(t *testing.T) {
473 requirePOSIXShellTest(t)
474 t.Setenv("PATH", t.TempDir())
475
476 exe, err := os.Executable()
477 if err != nil {
478 t.Fatal(err)
479 }
480 b := bash{
481 sb: sandbox.Spec{
482 Mode: "enforce",
483 WriteRoots: []string{t.TempDir()},
484 },
485 shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: exe},
486 }
487
488 args, _ := json.Marshal(map[string]string{"command": "ignored"})
489 out, err := b.Execute(context.Background(), args)
490 if err == nil {
491 t.Fatal("bash should reject enforce mode when the OS sandbox is unavailable")
492 }
493 if !strings.Contains(err.Error(), "shell sandbox requested but unavailable") {
494 t.Fatalf("error = %q, want sandbox unavailable", err)
495 }
496 if out != "" {
497 t.Fatalf("output = %q, want no command execution", out)
498 }
499 }
500
501 func TestUnconfinedWriterWritesAnywhere(t *testing.T) {
502 // A zero-value writer (roots nil, as registered at init) is unconfined.
503 out := filepath.Join(t.TempDir(), "free.txt")
504 args, _ := json.Marshal(map[string]string{"path": out, "content": "ok"})
505 if _, err := (writeFile{}).Execute(context.Background(), args); err != nil {
506 t.Fatalf("unconfined write failed: %v", err)
507 }
508 if _, err := os.Stat(out); err != nil {
509 t.Errorf("unconfined writer did not write: %v", err)
510 }
511 }
512
513 // confineRead & ConfineReaders
514
515 func TestConfineReadEmpty(t *testing.T) {
516 if confineRead(nil, "/anywhere") {
517 t.Error("empty forbidRoots should be unconfined")
518 }
519 }
520
521 func TestConfineReadInsideAndOutside(t *testing.T) {
522 root := t.TempDir()
523 forbidRoots := realRoots([]string{root})
524
525 if !confineRead(forbidRoots, filepath.Join(root, "secret", "key.pem")) {
526 t.Error("path inside forbid root should be forbidden")
527 }
528 // A path outside must pass.
529 if confineRead(forbidRoots, filepath.Join(t.TempDir(), "ok.txt")) {
530 t.Error("path outside forbid root should not be forbidden")
531 }
532 }
533
534 func TestConfineReadExactFileRoot(t *testing.T) {
535 dir := t.TempDir()
536 secret := filepath.Join(dir, "credentials.env")
537 visible := filepath.Join(dir, "project.env")
538 for _, path := range []string{secret, visible} {
539 if err := os.WriteFile(path, []byte("value"), 0o600); err != nil {
540 t.Fatal(err)
541 }
542 }
543 forbidRoots := realRoots([]string{secret})
544 if !confineRead(forbidRoots, secret) {
545 t.Fatal("exact forbidden file should be unreadable")
546 }
547 if confineRead(forbidRoots, visible) {
548 t.Fatal("sibling file should remain readable")
549 }
550 }
551
552 func TestConfineReadBlocksReadFile(t *testing.T) {
553 forbidDir := t.TempDir()
554 secretPath := filepath.Join(forbidDir, "secret.txt")
555 if err := os.WriteFile(secretPath, []byte("classified"), 0o644); err != nil {
556 t.Fatal(err)
557 }
558 forbidRoots := realRoots([]string{forbidDir})
559 rf := readFile{forbidRoots: forbidRoots}
560 args, _ := json.Marshal(map[string]string{"path": secretPath})
561 _, err := rf.Execute(context.Background(), args)
562 if err == nil {
563 t.Error("read_file should refuse a forbid-read path")
564 }
565 var pathErr *os.PathError
566 if !errors.As(err, &pathErr) {
567 t.Errorf("read_file forbid-read error should be *os.PathError, got %T: %v", err, err)
568 }
569 // Unconfined (nil forbidRoots) should work.
570 rfUnconfined := readFile{}
571 if _, err := rfUnconfined.Execute(context.Background(), args); err != nil {
572 t.Errorf("unconfined read_file should work: %v", err)
573 }
574 }
575
576 // withProtectSensitiveFiles flips the [secrets] protect_sensitive_files
577 // toggle for one test and restores the default-off state afterwards.
578 func withProtectSensitiveFiles(t *testing.T, enabled bool) {
579 t.Helper()
580 secrets.SetProtectSensitiveFiles(enabled)
581 t.Cleanup(func() { secrets.SetProtectSensitiveFiles(false) })
582 }
583
584 func TestSensitiveReadPathsAreBlockedWhenProtected(t *testing.T) {
585 withProtectSensitiveFiles(t, true)
586 dir := t.TempDir()
587 envPath := filepath.Join(dir, ".env")
588 if err := os.WriteFile(envPath, []byte("DEEPSEEK_API_KEY=sk-real-secret-value-123456\n"), 0o600); err != nil {
589 t.Fatal(err)
590 }
591 pemPath := filepath.Join(dir, "client.pem")
592 if err := os.WriteFile(pemPath, []byte("PRIVATE KEY"), 0o600); err != nil {
593 t.Fatal(err)
594 }
595
596 for _, path := range []string{envPath, pemPath} {
597 if !confineRead(nil, path) {
598 t.Fatalf("sensitive path %s should be blocked when protection is on", path)
599 }
600 rf := readFile{}
601 _, err := rf.Execute(context.Background(), argsJSON(t, map[string]any{"path": path}))
602 if err == nil {
603 t.Fatalf("read_file should refuse sensitive path %s", path)
604 }
605 }
606
607 visible := filepath.Join(dir, "notes.txt")
608 if err := os.WriteFile(visible, []byte("ok"), 0o644); err != nil {
609 t.Fatal(err)
610 }
611 if confineRead(nil, visible) {
612 t.Fatalf("ordinary path %s should not be blocked", visible)
613 }
614 }
615
616 func TestSensitiveReadPathsAllowedByDefault(t *testing.T) {
617 dir := t.TempDir()
618 envPath := filepath.Join(dir, ".env")
619 if err := os.WriteFile(envPath, []byte("PORT=8080\n"), 0o600); err != nil {
620 t.Fatal(err)
621 }
622 if confineRead(nil, envPath) {
623 t.Fatalf(".env should stay readable while protect_sensitive_files is off (default)")
624 }
625 rf := readFile{}
626 out, err := rf.Execute(context.Background(), argsJSON(t, map[string]any{"path": envPath}))
627 if err != nil {
628 t.Fatalf("read_file .env with protection off: %v", err)
629 }
630 if !strings.Contains(out, "PORT=8080") {
631 t.Fatalf("read_file dropped .env content:\n%s", out)
632 }
633 }
634
635 func TestGlobFiltersSensitiveMatchesWhenProtected(t *testing.T) {
636 withProtectSensitiveFiles(t, true)
637 dir := t.TempDir()
638 if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("SECRET_TOKEN=abc\n"), 0o600); err != nil {
639 t.Fatal(err)
640 }
641 if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ok"), 0o644); err != nil {
642 t.Fatal(err)
643 }
644
645 g := globTool{}
646 out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": filepath.Join(dir, "*")}))
647 if err != nil {
648 t.Fatalf("glob: %v", err)
649 }
650 if strings.Contains(out, ".env") {
651 t.Fatalf("glob leaked sensitive match:\n%s", out)
652 }
653 if !strings.Contains(out, "notes.txt") {
654 t.Fatalf("glob dropped ordinary match:\n%s", out)
655 }
656 }
657
658 // grep forbid-read
659
660 func TestConfineReadBlocksGrepFile(t *testing.T) {
661 forbidDir := t.TempDir()
662 secretPath := filepath.Join(forbidDir, "secret.txt")
663 if err := os.WriteFile(secretPath, []byte("needle in a haystack"), 0o644); err != nil {
664 t.Fatal(err)
665 }
666 forbidRoots := realRoots([]string{forbidDir})
667 g := grepTool{forbidRoots: forbidRoots}
668 args, _ := json.Marshal(map[string]string{"pattern": "needle", "path": secretPath})
669 _, err := g.Execute(context.Background(), args)
670 if err == nil {
671 t.Error("grep on a forbid-read file should error, not return (no matches)")
672 }
673 var pathErr *os.PathError
674 if !errors.As(err, &pathErr) {
675 t.Errorf("grep forbid-read error should be *os.PathError, got %T: %v", err, err)
676 }
677 // Unconfined (nil forbidRoots) should work.
678 gUnconfined := grepTool{}
679 if out, err := gUnconfined.Execute(context.Background(), args); err != nil {
680 t.Errorf("unconfined grep should work: %v", err)
681 } else if out == "(no matches)" {
682 t.Error("unconfined grep should find the needle")
683 }
684 }
685
686 func TestConfineReadBlocksNativeGrepDirectoryRoot(t *testing.T) {
687 root := t.TempDir()
688 forbidDir := filepath.Join(root, "secret")
689 secretPath := filepath.Join(forbidDir, "secret.txt")
690 if err := os.MkdirAll(forbidDir, 0o755); err != nil {
691 t.Fatal(err)
692 }
693 if err := os.WriteFile(secretPath, []byte("needle in a haystack"), 0o644); err != nil {
694 t.Fatal(err)
695 }
696
697 g := grepTool{workDir: root, forbidRoots: realRoots([]string{forbidDir})}
698 out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": "needle", "path": "secret"}))
699 if err != nil {
700 t.Fatalf("grep forbidden directory should look empty, got error: %v", err)
701 }
702 if out != "(no matches)" {
703 t.Fatalf("grep forbidden directory = %q, want (no matches)", out)
704 }
705 }
706
707 func TestConfineReadFiltersPlainGlobMatches(t *testing.T) {
708 root := t.TempDir()
709 forbidDir := filepath.Join(root, "secret")
710 if err := os.MkdirAll(forbidDir, 0o755); err != nil {
711 t.Fatal(err)
712 }
713 if err := os.WriteFile(filepath.Join(forbidDir, "secret.go"), []byte("package secret\n"), 0o644); err != nil {
714 t.Fatal(err)
715 }
716
717 g := globTool{workDir: root, forbidRoots: realRoots([]string{forbidDir})}
718 out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": "secret/*.go"}))
719 if err != nil {
720 t.Fatalf("glob forbidden directory: %v", err)
721 }
722 if out != "(no matches)" {
723 t.Fatalf("glob leaked forbidden paths:\n%s", out)
724 }
725 }
726
726 lines GO