551 lines
19 KiB
Go
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)
|
|
}
|
|
}
|