返回 DeepSeek-Reasonix
bash_write_denial_test.go
根目录 / internal / tool / builtin / bash_write_denial_test.go
1 package builtin
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "testing"
13
14 "reasonix/internal/sandbox"
15 )
16
17 func TestSandboxWriteDenialClassifierRejectsOrdinaryFailures(t *testing.T) {
18 for _, tc := range []struct {
19 name string
20 out string
21 err error
22 }{
23 {name: "python key error", out: "Traceback\nKeyError: 'observation_time'", err: errors.New("exit status 1")},
24 {name: "http failure", out: "HTTP 403: permission denied", err: errors.New("exit status 22")},
25 {name: "timeout", out: "curl: (28) operation timed out", err: errors.New("exit status 28")},
26 {name: "sandbox startup", out: "", err: errors.New("sandbox helper failed to start")},
27 } {
28 t.Run(tc.name, func(t *testing.T) {
29 if looksLikeSandboxWriteDenial(tc.out, tc.err) {
30 t.Fatal("ordinary failure was classified as a sandbox write denial")
31 }
32 })
33 }
34 }
35
36 func TestSandboxWriteDenialClassifierAcceptsFilesystemFailures(t *testing.T) {
37 for _, out := range []string{
38 "touch: /outside/file: Permission denied",
39 "bash: /outside/file: Permission denied",
40 "mkdir: cannot create directory '/outside': Read-only file system",
41 "open /outside/file: operation not permitted",
42 "PermissionError: [Errno 13] Permission denied: '/outside/file'",
43 } {
44 if !looksLikeSandboxWriteDenial(out, errors.New("exit status 1")) {
45 t.Fatalf("filesystem failure was not recognized: %q", out)
46 }
47 }
48 }
49
50 func TestSandboxWriteHintNamesGitWorktreeMetadata(t *testing.T) {
51 if _, err := exec.LookPath("git"); err != nil {
52 t.Skip("git is unavailable")
53 }
54 root, err := filepath.EvalSymlinks(t.TempDir())
55 if err != nil {
56 t.Fatal(err)
57 }
58 main := filepath.Join(root, "main")
59 worktree := filepath.Join(root, "linked")
60 if err := os.Mkdir(main, 0o755); err != nil {
61 t.Fatal(err)
62 }
63 for _, args := range [][]string{{"init", main}, {"-C", main, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "initial"}, {"-C", main, "worktree", "add", "-b", "linked", worktree}} {
64 if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
65 t.Fatalf("git %v: %v: %s", args, err, out)
66 }
67 }
68 subdir := filepath.Join(worktree, "subdir")
69 if err := os.Mkdir(subdir, 0o755); err != nil {
70 t.Fatal(err)
71 }
72 gitDir := filepath.Join(main, ".git", "worktrees", "linked")
73 objects := filepath.Join(main, ".git", "objects")
74 denial := "fatal: Unable to create '" + filepath.Join(gitDir, "index.lock") + "': Operation not permitted"
75 hint := appendSandboxWriteHint(denial, errors.New("exit status 128"), bashParams{Command: "git add ."}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", subdir)
76 wantDirs, err := json.Marshal([]string{gitDir, objects})
77 if err != nil {
78 t.Fatal(err)
79 }
80 if !strings.Contains(hint, string(wantDirs)) || !strings.Contains(hint, "additional_write_dirs") {
81 t.Fatalf("missing actionable worktree metadata hint: %s", hint)
82 }
83 if got := gitWorktreeWriteDirs(subdir, "touch: /outside: Operation not permitted", []string{worktree}); len(got) != 0 {
84 t.Fatalf("unrelated denial must not suggest Git metadata: %v", got)
85 }
86 if got := gitWorktreeWriteDirs(subdir, "note: "+gitDir+"\ntouch: '/outside/file': Operation not permitted", []string{worktree}); len(got) != 0 {
87 t.Fatalf("metadata mention outside the denied path must not trigger a hint: %v", got)
88 }
89 if got := gitWorktreeWriteDirs(subdir, "note: '"+filepath.Join(gitDir, "index.lock")+"':\nOperation not permitted", []string{worktree}); len(got) != 0 {
90 t.Fatalf("denial must share the diagnostic line with the metadata path: %v", got)
91 }
92 alias := filepath.Join(root, "metadata-alias")
93 if err := os.Symlink(gitDir, alias); err == nil {
94 aliasDenial := "fatal: Unable to create '" + filepath.Join(alias, "index.lock") + "': Operation not permitted"
95 if got := gitWorktreeWriteDirs(subdir, aliasDenial, []string{worktree}); len(got) != 2 || got[0] != gitDir || got[1] != objects {
96 t.Fatalf("symlinked diagnostic path must resolve to Git metadata: %v", got)
97 }
98 }
99 if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree, filepath.Join(main, ".git")}); len(got) != 0 {
100 t.Fatalf("already writable metadata must not be suggested: %v", got)
101 }
102 commonDenial := "fatal: cannot lock ref 'refs/heads/linked': Unable to create '" + filepath.Join(main, ".git", "refs", "heads", "linked.lock") + "': Operation not permitted"
103 if got := gitWorktreeWriteDirs(subdir, commonDenial, []string{worktree, gitDir}); len(got) != 2 || got[0] != objects || got[1] != filepath.Join(main, ".git", "refs") {
104 t.Fatalf("ref denial must suggest only needed metadata subdirectories: %v", got)
105 }
106 commonPath := filepath.Join(gitDir, "commondir")
107 originalCommon, err := os.ReadFile(commonPath)
108 if err != nil {
109 t.Fatal(err)
110 }
111 private := filepath.Join(root, "private")
112 for _, path := range []string{private, filepath.Join(private, "objects"), filepath.Join(private, "refs")} {
113 if err := os.Mkdir(path, 0o755); err != nil {
114 t.Fatal(err)
115 }
116 }
117 if err := os.WriteFile(filepath.Join(private, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
118 t.Fatal(err)
119 }
120 if err := os.WriteFile(commonPath, []byte(private+"\n"), 0o644); err != nil {
121 t.Fatal(err)
122 }
123 forgedDenial := "fatal: Unable to create '" + filepath.Join(private, "refs", "heads", "main.lock") + "': Operation not permitted"
124 if got := gitWorktreeWriteDirs(subdir, forgedDenial, []string{worktree}); len(got) != 0 {
125 t.Fatalf("forged commondir must not be suggested: %v", got)
126 }
127 if err := os.WriteFile(commonPath, originalCommon, 0o644); err != nil {
128 t.Fatal(err)
129 }
130 backlinkPath := filepath.Join(gitDir, "gitdir")
131 originalBacklink, err := os.ReadFile(backlinkPath)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if err := os.WriteFile(backlinkPath, []byte(filepath.Join(private, ".git")+"\n"), 0o644); err != nil {
136 t.Fatal(err)
137 }
138 if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree}); len(got) != 0 {
139 t.Fatalf("invalid worktree backlink must not be suggested: %v", got)
140 }
141 if err := os.WriteFile(backlinkPath, originalBacklink, 0o644); err != nil {
142 t.Fatal(err)
143 }
144 if runtime.GOOS != "darwin" {
145 return
146 }
147 if _, err := exec.LookPath("sandbox-exec"); err != nil {
148 t.Skip("macOS Seatbelt is unavailable")
149 }
150 if err := os.WriteFile(filepath.Join(worktree, "note.txt"), []byte("change\n"), 0o644); err != nil {
151 t.Fatal(err)
152 }
153 profile := fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q))", worktree)
154 blocked, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput()
155 if err == nil || !strings.Contains(string(blocked), "index.lock") {
156 t.Fatalf("git add must fail at external worktree metadata: %v: %s", err, blocked)
157 }
158 if hint := appendSandboxWriteHint(string(blocked), err, bashParams{Command: "git add note.txt"}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", worktree); !strings.Contains(hint, gitDir) || !strings.Contains(hint, objects) {
159 t.Fatalf("actual Seatbelt denial did not name the needed directory: %s", hint)
160 }
161 profile = fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q) (subpath %q) (subpath %q))", worktree, gitDir, objects)
162 if out, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput(); err != nil {
163 t.Fatalf("approved metadata must permit git add: %v: %s", err, out)
164 }
165 }
166
166 lines GO