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