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