package builtin import ( "context" "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" "reasonix/internal/config" "reasonix/internal/sandbox" "reasonix/internal/secrets" "reasonix/internal/tool" ) func TestWithin(t *testing.T) { root := filepath.FromSlash("/work/proj") cases := []struct { path string want bool }{ {filepath.FromSlash("/work/proj"), true}, // the root itself {filepath.FromSlash("/work/proj/a/b.go"), true}, // nested {filepath.FromSlash("/work/proj/../proj/x"), true}, // normalises back inside {filepath.FromSlash("/work/other"), false}, // sibling {filepath.FromSlash("/work/proj-2"), false}, // prefix collision, not within {filepath.FromSlash("/etc/passwd"), false}, // elsewhere {filepath.FromSlash("/work"), false}, // parent } for _, c := range cases { if got := within(root, filepath.Clean(c.path)); got != c.want { t.Errorf("within(%q, %q) = %v, want %v", root, c.path, got, c.want) } } } func TestConfineUnconfinedWhenNoRoots(t *testing.T) { if err := confine(nil, "/anywhere/at/all"); err != nil { t.Errorf("empty roots should be unconfined, got %v", err) } } func TestConfineInsideAndOutside(t *testing.T) { root := t.TempDir() roots := realRoots([]string{root}) if err := confine(roots, filepath.Join(root, "src", "main.go")); err != nil { t.Errorf("path inside root rejected: %v", err) } // A sibling of the root and a parent escape must both be refused. if err := confine(roots, filepath.Join(root, "..", "escape.txt")); err == nil { t.Error("parent-escape path accepted, want error") } if err := confine(roots, filepath.Join(filepath.Dir(root), "neighbour", "x")); err == nil { t.Error("sibling path accepted, want error") } } func TestConfineRejectsSymlinkEscape(t *testing.T) { root := t.TempDir() outside := t.TempDir() // A symlinked directory inside the root pointing outside must not become a // tunnel: a write "within" the link still resolves outside the root. link := filepath.Join(root, "out") if err := os.Symlink(outside, link); err != nil { t.Skipf("symlinks unavailable: %v", err) } roots := realRoots([]string{root}) if err := confine(roots, filepath.Join(link, "evil.txt")); err == nil { t.Error("write through symlinked dir escaped the root, want error") } // A normal file under the real root still passes. if err := confine(roots, filepath.Join(root, "ok.txt")); err != nil { t.Errorf("legit path rejected: %v", err) } } func TestWriteFileConfinement(t *testing.T) { root := t.TempDir() w := writeFile{roots: realRoots([]string{root})} // Inside: written. in := filepath.Join(root, "a", "in.txt") args, _ := json.Marshal(map[string]string{"path": in, "content": "hi"}) if _, err := w.Execute(context.Background(), args); err != nil { t.Fatalf("write inside root failed: %v", err) } if _, err := os.Stat(in); err != nil { t.Errorf("file not created inside root: %v", err) } // Outside: refused, and the file must not be created. out := filepath.Join(t.TempDir(), "out.txt") args, _ = json.Marshal(map[string]string{"path": out, "content": "nope"}) if _, err := w.Execute(context.Background(), args); err == nil { t.Error("write outside root should error") } if _, err := os.Stat(out); !os.IsNotExist(err) { t.Error("file outside root must not be created") } } func TestWriteFileDefaultRootsDenyUserConfigUnlessAllowed(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming")) project := filepath.Join(home, "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) } cfg := config.Default() w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project))} userConfig := config.UserConfigPath() args, _ := json.Marshal(map[string]string{ "path": userConfig, "content": "default_model = \"deepseek\"\n", }) if _, err := w.Execute(context.Background(), args); err == nil { t.Fatalf("write user config should be denied by default") } if _, err := os.Stat(userConfig); !os.IsNotExist(err) { t.Fatalf("user config must not be created by default, stat err=%v", err) } cfg.Sandbox.AllowWrite = []string{filepath.Dir(userConfig)} w = writeFile{roots: realRoots(cfg.WriteRootsForRoot(project))} if _, err := w.Execute(context.Background(), args); err != nil { t.Fatalf("write user config should be allowed with allow_write: %v", err) } if _, err := os.Stat(userConfig); err != nil { t.Fatalf("user config was not created with allow_write: %v", err) } } // stubConfigWriteApprover is a scripted tool.ConfigWriteApprover recording the // paths it was asked about. type stubConfigWriteApprover struct { allow bool reason string asked []string } func (s *stubConfigWriteApprover) ApproveManagedConfigWrite(_ context.Context, req tool.ConfigWriteRequest) (bool, string, error) { s.asked = append(s.asked, req.Path) return s.allow, s.reason, nil } func TestManagedConfigWriteFailsClosedWithoutApprover(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming")) project := filepath.Join(home, "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) } cfg := config.Default() managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths()) w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project)), managed: managed} // Headless runs and sub-agents with no interactive parent carry no approver // on ctx: the managed-config escape hatch must fail closed. userConfig := config.UserConfigPath() args, _ := json.Marshal(map[string]string{"path": userConfig, "content": "{}\n"}) _, err := w.Execute(context.Background(), args) if err == nil { t.Fatalf("managed config write without an approver should be denied") } if !strings.Contains(err.Error(), "interactive user approval") { t.Fatalf("fail-closed error should name the missing approval, got: %v", err) } if _, err := os.Stat(userConfig); !os.IsNotExist(err) { t.Fatalf("user config must not be created without approval, stat err=%v", err) } } func TestManagedConfigWriteGatedOnApprover(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming")) project := filepath.Join(home, "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) } cfg := config.Default() managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths()) w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(project)), managed: managed} // Approved: current config.toml and the legacy v0.x config.json become // writable, and the approver sees each target. approve := &stubConfigWriteApprover{allow: true} ctx := tool.WithConfigWriteApprover(context.Background(), approve) for _, target := range []string{ config.UserConfigPath(), filepath.Join(home, ".reasonix", "config.json"), } { args, _ := json.Marshal(map[string]string{"path": target, "content": "{}\n"}) if _, err := w.Execute(ctx, args); err != nil { t.Fatalf("approved managed config write %s: %v", target, err) } if _, err := os.Stat(target); err != nil { t.Fatalf("managed config was not created %s: %v", target, err) } } if len(approve.asked) != 2 { t.Fatalf("approver should be asked once per write, asked=%v", approve.asked) } // Declined: the approver's reason surfaces to the model and nothing lands. decline := &stubConfigWriteApprover{allow: false, reason: "the user declined this Reasonix config write"} dctx := tool.WithConfigWriteApprover(context.Background(), decline) declinedTarget := config.UserConfigPath() if err := os.Remove(declinedTarget); err != nil && !os.IsNotExist(err) { t.Fatalf("remove approved config before declined write: %v", err) } args, _ := json.Marshal(map[string]string{"path": declinedTarget, "content": "{}\n"}) if _, err := w.Execute(dctx, args); err == nil || !strings.Contains(err.Error(), "declined") { t.Fatalf("declined managed config write should surface the reason, got: %v", err) } if _, err := os.Stat(declinedTarget); !os.IsNotExist(err) { t.Fatalf("declined config must not be created, stat err=%v", err) } // Even with an always-allowing approver, non-config files in the Reasonix // home and the rest of the OS home stay denied — the escape hatch is // file-level, not directory-level. for _, target := range []string{ filepath.Join(home, "notes.txt"), filepath.Join(home, ".reasonix", ".env"), filepath.Join(home, ".reasonix", "settings.json"), filepath.Join(home, ".reasonix", "skills", "evil", "SKILL.md"), } { asked := len(approve.asked) args, _ := json.Marshal(map[string]string{"path": target, "content": "nope\n"}) if _, err := w.Execute(ctx, args); err == nil { t.Fatalf("write outside managed config files should be denied: %s", target) } if len(approve.asked) != asked { t.Fatalf("non-managed target %s must not reach the approver", target) } if _, err := os.Stat(target); !os.IsNotExist(err) { t.Fatalf("file must not be created %s, stat err=%v", target, err) } } } func TestBashSandboxConfinement(t *testing.T) { if !sandbox.Available() { t.Skip("OS sandbox not available") } home, err := os.UserHomeDir() if err != nil { t.Skipf("no home dir: %v", err) } work, err := os.MkdirTemp(home, ".reasonix-bashsb-*") if err != nil { t.Skipf("cannot create work dir under home: %v", err) } t.Cleanup(func() { os.RemoveAll(work) }) t.Chdir(work) var timeout []time.Duration if runtime.GOOS == "windows" { wait := 20 * time.Second t.Setenv("WINDOWS_SANDBOX_WAIT_MS", fmt.Sprint(wait.Milliseconds())) timeout = []time.Duration{wait} } spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, Network: true} if runtime.GOOS == "windows" { spec.Shell = sandbox.ResolveShell("powershell", "", nil) } b := ConfineBash(spec, SessionDataGuard{}, timeout...) // Writing inside the root works; writing to a sibling under $HOME is denied // by the sandbox the bash tool wrapped the command in. inCommand := "echo hi > " + filepath.Join(work, "in.txt") if runtime.GOOS == "windows" { inCommand = "Set-Content -LiteralPath " + psQuoteForBuiltinTest(filepath.Join(work, "in.txt")) + " -Value hi" } inArgs, _ := json.Marshal(map[string]string{"command": inCommand}) if _, err := b.Execute(context.Background(), inArgs); err != nil { t.Fatalf("bash write inside root failed: %v", err) } outPath := filepath.Join(home, ".reasonix-bashsb-escape.txt") t.Cleanup(func() { os.Remove(outPath) }) outCommand := "echo nope > " + outPath if runtime.GOOS == "windows" { outCommand = "Set-Content -LiteralPath " + psQuoteForBuiltinTest(outPath) + " -Value nope" } outArgs, _ := json.Marshal(map[string]string{"command": outCommand}) if _, err := b.Execute(context.Background(), outArgs); err == nil { t.Error("bash write outside the workspace should be denied by the sandbox") } if _, err := os.Stat(outPath); !os.IsNotExist(err) { t.Error("escaping write must not create the file") } } func psQuoteForBuiltinTest(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } func TestBashEnforceRejectsWhenSandboxUnavailable(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("native Windows sandbox availability is helper-backed and independent of PATH") } t.Setenv("PATH", t.TempDir()) exe, err := os.Executable() if err != nil { t.Fatal(err) } b := bash{ sb: sandbox.Spec{ Mode: "enforce", WriteRoots: []string{t.TempDir()}, }, shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: exe}, } args, _ := json.Marshal(map[string]string{"command": "ignored"}) out, err := b.Execute(context.Background(), args) if err == nil { t.Fatal("bash should reject enforce mode when the OS sandbox is unavailable") } if !strings.Contains(err.Error(), "bash sandbox requested but unavailable") { t.Fatalf("error = %q, want sandbox unavailable", err) } if out != "" { t.Fatalf("output = %q, want no command execution", out) } } func TestUnconfinedWriterWritesAnywhere(t *testing.T) { // A zero-value writer (roots nil, as registered at init) is unconfined. out := filepath.Join(t.TempDir(), "free.txt") args, _ := json.Marshal(map[string]string{"path": out, "content": "ok"}) if _, err := (writeFile{}).Execute(context.Background(), args); err != nil { t.Fatalf("unconfined write failed: %v", err) } if _, err := os.Stat(out); err != nil { t.Errorf("unconfined writer did not write: %v", err) } } // --- confineRead & ConfineReaders --- func TestConfineReadEmpty(t *testing.T) { if confineRead(nil, "/anywhere") { t.Error("empty forbidRoots should be unconfined") } } func TestConfineReadInsideAndOutside(t *testing.T) { root := t.TempDir() forbidRoots := realRoots([]string{root}) if !confineRead(forbidRoots, filepath.Join(root, "secret", "key.pem")) { t.Error("path inside forbid root should be forbidden") } // A path outside must pass. if confineRead(forbidRoots, filepath.Join(t.TempDir(), "ok.txt")) { t.Error("path outside forbid root should not be forbidden") } } func TestConfineReadBlocksReadFile(t *testing.T) { forbidDir := t.TempDir() secretPath := filepath.Join(forbidDir, "secret.txt") if err := os.WriteFile(secretPath, []byte("classified"), 0o644); err != nil { t.Fatal(err) } forbidRoots := realRoots([]string{forbidDir}) rf := readFile{forbidRoots: forbidRoots} args, _ := json.Marshal(map[string]string{"path": secretPath}) _, err := rf.Execute(context.Background(), args) if err == nil { t.Error("read_file should refuse a forbid-read path") } if _, ok := err.(*os.PathError); !ok { t.Errorf("read_file forbid-read error should be *os.PathError, got %T: %v", err, err) } // Unconfined (nil forbidRoots) should work. rfUnconfined := readFile{} if _, err := rfUnconfined.Execute(context.Background(), args); err != nil { t.Errorf("unconfined read_file should work: %v", err) } } // withProtectSensitiveFiles flips the [secrets] protect_sensitive_files // toggle for one test and restores the default-off state afterwards. func withProtectSensitiveFiles(t *testing.T, enabled bool) { t.Helper() secrets.SetProtectSensitiveFiles(enabled) t.Cleanup(func() { secrets.SetProtectSensitiveFiles(false) }) } func TestSensitiveReadPathsAreBlockedWhenProtected(t *testing.T) { withProtectSensitiveFiles(t, true) dir := t.TempDir() envPath := filepath.Join(dir, ".env") if err := os.WriteFile(envPath, []byte("DEEPSEEK_API_KEY=sk-real-secret-value-123456\n"), 0o600); err != nil { t.Fatal(err) } pemPath := filepath.Join(dir, "client.pem") if err := os.WriteFile(pemPath, []byte("PRIVATE KEY"), 0o600); err != nil { t.Fatal(err) } for _, path := range []string{envPath, pemPath} { if !confineRead(nil, path) { t.Fatalf("sensitive path %s should be blocked when protection is on", path) } rf := readFile{} _, err := rf.Execute(context.Background(), argsJSON(t, map[string]any{"path": path})) if err == nil { t.Fatalf("read_file should refuse sensitive path %s", path) } } visible := filepath.Join(dir, "notes.txt") if err := os.WriteFile(visible, []byte("ok"), 0o644); err != nil { t.Fatal(err) } if confineRead(nil, visible) { t.Fatalf("ordinary path %s should not be blocked", visible) } } func TestSensitiveReadPathsAllowedByDefault(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") if err := os.WriteFile(envPath, []byte("PORT=8080\n"), 0o600); err != nil { t.Fatal(err) } if confineRead(nil, envPath) { t.Fatalf(".env should stay readable while protect_sensitive_files is off (default)") } rf := readFile{} out, err := rf.Execute(context.Background(), argsJSON(t, map[string]any{"path": envPath})) if err != nil { t.Fatalf("read_file .env with protection off: %v", err) } if !strings.Contains(out, "PORT=8080") { t.Fatalf("read_file dropped .env content:\n%s", out) } } func TestGlobFiltersSensitiveMatchesWhenProtected(t *testing.T) { withProtectSensitiveFiles(t, true) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("SECRET_TOKEN=abc\n"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ok"), 0o644); err != nil { t.Fatal(err) } g := globTool{} out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": filepath.Join(dir, "*")})) if err != nil { t.Fatalf("glob: %v", err) } if strings.Contains(out, ".env") { t.Fatalf("glob leaked sensitive match:\n%s", out) } if !strings.Contains(out, "notes.txt") { t.Fatalf("glob dropped ordinary match:\n%s", out) } } // --- grep forbid-read --- func TestConfineReadBlocksGrepFile(t *testing.T) { forbidDir := t.TempDir() secretPath := filepath.Join(forbidDir, "secret.txt") if err := os.WriteFile(secretPath, []byte("needle in a haystack"), 0o644); err != nil { t.Fatal(err) } forbidRoots := realRoots([]string{forbidDir}) g := grepTool{forbidRoots: forbidRoots} args, _ := json.Marshal(map[string]string{"pattern": "needle", "path": secretPath}) _, err := g.Execute(context.Background(), args) if err == nil { t.Error("grep on a forbid-read file should error, not return (no matches)") } if _, ok := err.(*os.PathError); !ok { t.Errorf("grep forbid-read error should be *os.PathError, got %T: %v", err, err) } // Unconfined (nil forbidRoots) should work. gUnconfined := grepTool{} if out, err := gUnconfined.Execute(context.Background(), args); err != nil { t.Errorf("unconfined grep should work: %v", err) } else if out == "(no matches)" { t.Error("unconfined grep should find the needle") } } func TestConfineReadBlocksNativeGrepDirectoryRoot(t *testing.T) { root := t.TempDir() forbidDir := filepath.Join(root, "secret") secretPath := filepath.Join(forbidDir, "secret.txt") if err := os.MkdirAll(forbidDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(secretPath, []byte("needle in a haystack"), 0o644); err != nil { t.Fatal(err) } g := grepTool{workDir: root, forbidRoots: realRoots([]string{forbidDir})} out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": "needle", "path": "secret"})) if err != nil { t.Fatalf("grep forbidden directory should look empty, got error: %v", err) } if out != "(no matches)" { t.Fatalf("grep forbidden directory = %q, want (no matches)", out) } } func TestConfineReadFiltersPlainGlobMatches(t *testing.T) { root := t.TempDir() forbidDir := filepath.Join(root, "secret") if err := os.MkdirAll(forbidDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(forbidDir, "secret.go"), []byte("package secret\n"), 0o644); err != nil { t.Fatal(err) } g := globTool{workDir: root, forbidRoots: realRoots([]string{forbidDir})} out, err := g.Execute(context.Background(), argsJSON(t, map[string]any{"pattern": "secret/*.go"})) if err != nil { t.Fatalf("glob forbidden directory: %v", err) } if out != "(no matches)" { t.Fatalf("glob leaked forbidden paths:\n%s", out) } }