Files
2026-07-13 13:00:08 +08:00

551 lines
19 KiB
Go

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