返回 DeepSeek-Reasonix
seatbelt_darwin_test.go
根目录 / internal / sandbox / seatbelt_darwin_test.go
1 package sandbox
2
3 import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9 )
10
11 // --- sbplString ---
12
13 func TestSbplString(t *testing.T) {
14 cases := []struct {
15 input string
16 want string
17 }{
18 {"/tmp", `"/tmp"`},
19 {`/path/with"quote`, `"/path/with\"quote"`},
20 {`/path/with\backslash`, `"/path/with\\backslash"`},
21 {`/both"and\`, `"/both\"and\\"`},
22 {"", `""`},
23 }
24 for _, c := range cases {
25 got := sbplString(c.input)
26 if got != c.want {
27 t.Errorf("sbplString(%q) = %q, want %q", c.input, got, c.want)
28 }
29 }
30 }
31
32 // --- writeAllowDirs ---
33
34 func TestWriteAllowDirsDeduplication(t *testing.T) {
35 dirs := writeAllowDirs([]string{"/tmp", "/tmp", "/tmp"})
36 seen := map[string]bool{}
37 for _, d := range dirs {
38 if seen[d] {
39 t.Errorf("duplicate dir: %s", d)
40 }
41 seen[d] = true
42 }
43 }
44
45 func TestWriteAllowDirsIncludesRoots(t *testing.T) {
46 root := t.TempDir()
47 dirs := writeAllowDirs([]string{root})
48 found := false
49 for _, d := range dirs {
50 real, _ := filepath.EvalSymlinks(root)
51 if d == real {
52 found = true
53 break
54 }
55 }
56 if !found {
57 t.Errorf("writeAllowDirs should include root %s, got %v", root, dirs)
58 }
59 }
60
61 func TestWriteAllowDirsIncludesTemp(t *testing.T) {
62 dirs := writeAllowDirs(nil)
63 tmpDir := os.TempDir()
64 realTmp, _ := filepath.EvalSymlinks(tmpDir)
65 found := false
66 for _, d := range dirs {
67 if d == realTmp {
68 found = true
69 break
70 }
71 }
72 if !found {
73 t.Errorf("writeAllowDirs should include temp dir %s, got %v", tmpDir, dirs)
74 }
75 }
76
77 func TestWriteAllowDirsIncludesSessionTemp(t *testing.T) {
78 private := t.TempDir()
79 dirs := writeAllowDirsForSpec(Spec{SessionTemp: private, MinimalWrites: true})
80 real, _ := filepath.EvalSymlinks(private)
81 found := false
82 for _, d := range dirs {
83 if d == real {
84 found = true
85 break
86 }
87 }
88 if !found {
89 t.Fatalf("SessionTemp must be allowed under Seatbelt even with MinimalWrites: %v", dirs)
90 }
91 }
92
93 func TestWriteAllowDirsSkipsEmpty(t *testing.T) {
94 dirs := writeAllowDirs([]string{"", "", ""})
95 for _, d := range dirs {
96 if d == "" {
97 t.Error("writeAllowDirs should skip empty strings")
98 }
99 }
100 }
101
102 func TestWriteAllowDirsNoDuplicates(t *testing.T) {
103 roots := []string{"/tmp", "/private/tmp", os.TempDir()}
104 dirs := writeAllowDirs(roots)
105 seen := map[string]bool{}
106 for _, d := range dirs {
107 if seen[d] {
108 t.Errorf("duplicate: %s", d)
109 }
110 seen[d] = true
111 }
112 }
113
114 // --- seatbeltProfile ---
115
116 func TestSeatbeltProfileDeniesNetwork(t *testing.T) {
117 spec := Spec{Mode: "enforce", Network: false, WriteRoots: []string{"/workspace"}}
118 profile := seatbeltProfile(spec)
119 if !strings.Contains(profile, "(deny network*)") {
120 t.Error("profile should deny network when Network=false")
121 }
122 }
123
124 func TestSeatbeltProfileAllowsNetwork(t *testing.T) {
125 spec := Spec{Mode: "enforce", Network: true, WriteRoots: []string{"/workspace"}}
126 profile := seatbeltProfile(spec)
127 if strings.Contains(profile, "(deny network*)") {
128 t.Error("profile should not deny network when Network=true")
129 }
130 }
131
132 func TestSeatbeltProfileContainsVersion(t *testing.T) {
133 spec := Spec{Mode: "enforce", WriteRoots: []string{"/workspace"}}
134 profile := seatbeltProfile(spec)
135 if !strings.Contains(profile, "(version 1)") {
136 t.Error("profile should contain version 1")
137 }
138 if !strings.Contains(profile, "(allow default)") {
139 t.Error("profile should allow default")
140 }
141 if !strings.Contains(profile, "(deny file-write*)") {
142 t.Error("profile should deny file-write")
143 }
144 }
145
146 func TestSeatbeltProfileContainsRoots(t *testing.T) {
147 root := t.TempDir()
148 spec := Spec{Mode: "enforce", WriteRoots: []string{root}}
149 profile := seatbeltProfile(spec)
150 if !strings.Contains(profile, "(allow file-write*") {
151 t.Error("profile should have allow file-write section")
152 }
153 if !strings.Contains(profile, "(subpath ") {
154 t.Error("profile should contain subpath entries")
155 }
156 }
157
158 func TestMinimalWriteProfileOnlyAddsExplicitRootsAndDev(t *testing.T) {
159 root := t.TempDir()
160 dirs := writeAllowDirsForSpec(Spec{Mode: "enforce", WriteRoots: []string{root}, MinimalWrites: true})
161 if !containsDarwinPath(dirs, root) || !containsDarwinPath(dirs, "/dev") {
162 t.Fatalf("minimal write dirs = %v", dirs)
163 }
164 for _, forbidden := range []string{"/tmp", "/private/tmp", filepath.Join(os.Getenv("HOME"), ".npm"), filepath.Join(os.Getenv("HOME"), ".cache")} {
165 if forbidden != "" && containsDarwinPath(dirs, forbidden) {
166 t.Fatalf("minimal MCP profile unexpectedly allowed broad write root %q: %v", forbidden, dirs)
167 }
168 }
169 }
170
171 func containsDarwinPath(paths []string, want string) bool {
172 abs, err := filepath.Abs(want)
173 if err != nil {
174 return false
175 }
176 if real, err := filepath.EvalSymlinks(abs); err == nil {
177 abs = real
178 }
179 for _, path := range paths {
180 if path == abs {
181 return true
182 }
183 }
184 return false
185 }
186
187 func TestCommandUnwrappedWhenOff(t *testing.T) {
188 argv, wrapped := Command(Spec{Mode: "off"}, Shell{Kind: ShellBash, Path: "bash"}, "echo hi")
189 if wrapped {
190 t.Error("Mode=off should not wrap")
191 }
192 if len(argv) != 3 || argv[0] != "bash" || argv[1] != "-c" || argv[2] != "echo hi" {
193 t.Errorf("argv = %v, want [bash -c echo hi]", argv)
194 }
195 }
196
197 func TestProfileNetworkAndRoots(t *testing.T) {
198 with := seatbeltProfile(Spec{Mode: "enforce", WriteRoots: []string{"/work/proj"}, ForbidReadRoots: []string{"/etc/ssh", "/home/user/.ssh"}, Network: true})
199 if strings.Contains(with, "(deny network*)") {
200 t.Error("network=true should not deny network")
201 }
202 if !strings.Contains(with, "(allow default)") || !strings.Contains(with, "(deny file-write*)") || !strings.Contains(with, "(deny file-read* (subpath") {
203 t.Error("profile missing base allow/deny structure")
204 }
205 if !strings.Contains(with, `(subpath "/work/proj")`) {
206 t.Errorf("profile missing the write-root subpath:\n%s", with)
207 }
208 if !strings.Contains(with, `(subpath "/home/user/.ssh")`) {
209 t.Errorf("profile missing the forbid-read subpath:\n%s", with)
210 }
211 without := seatbeltProfile(Spec{Mode: "enforce", Network: false})
212 if !strings.Contains(without, "(deny network*)") {
213 t.Error("network=false should deny network")
214 }
215 if strings.Contains(without, "deny file-read") {
216 t.Error("profile should not contain file-read rules when forbid-read is empty")
217 }
218 }
219
220 // TestSandboxEnforcesWrites runs real commands through sandbox-exec and checks
221 // the boundary: a write under a write-root succeeds, a write elsewhere under
222 // $HOME (not a root, not a cache dir) is refused, and reads are unrestricted.
223 // Dirs are created under $HOME (not /tmp, which the profile always allows) so
224 // the test exercises the root mechanism itself.
225 func TestSandboxEnforcesWrites(t *testing.T) {
226 if !Available() {
227 t.Skip("sandbox-exec not available")
228 }
229 home, err := os.UserHomeDir()
230 if err != nil {
231 t.Skipf("no home dir: %v", err)
232 }
233 workRoot, err := os.MkdirTemp(home, ".reasonix-sbtest-work-*")
234 if err != nil {
235 t.Skipf("cannot create work dir under home: %v", err)
236 }
237 t.Cleanup(func() { os.RemoveAll(workRoot) })
238 outside, err := os.MkdirTemp(home, ".reasonix-sbtest-out-*")
239 if err != nil {
240 t.Skipf("cannot create outside dir under home: %v", err)
241 }
242 t.Cleanup(func() { os.RemoveAll(outside) })
243
244 spec := Spec{Mode: "enforce", WriteRoots: []string{workRoot}, Network: true}
245 run := func(command string) error {
246 argv, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, command)
247 if !wrapped {
248 t.Fatalf("expected wrapping for command %q", command)
249 }
250 return exec.Command(argv[0], argv[1:]...).Run()
251 }
252
253 // Write inside the root: allowed.
254 inFile := filepath.Join(workRoot, "in.txt")
255 if err := run("echo hi > " + inFile); err != nil {
256 t.Fatalf("write inside root failed: %v", err)
257 }
258 if _, err := os.Stat(inFile); err != nil {
259 t.Errorf("file not created inside root: %v", err)
260 }
261
262 // Write outside every root: refused (the command exits non-zero).
263 outFile := filepath.Join(outside, "out.txt")
264 if err := run("echo nope > " + outFile); err == nil {
265 t.Error("write outside root should be denied by the sandbox")
266 }
267 if _, err := os.Stat(outFile); !os.IsNotExist(err) {
268 t.Error("file outside root must not be created")
269 }
270
271 // Reading outside the root is allowed (read-all).
272 if err := run("cat /etc/hosts > " + filepath.Join(workRoot, "hosts.txt")); err != nil {
273 t.Errorf("read of /etc/hosts inside sandbox failed: %v", err)
274 }
275 }
276
277 // TestGoBuildUnderSandbox guards the default-on profile against the main risk:
278 // breaking the toolchain. `go build` writes to GOCACHE (under ~/Library/Caches)
279 // and a temp work dir, both of which the profile must allow, while output lands
280 // in the workspace. If this fails, the default profile is too tight.
281 func TestGoBuildUnderSandbox(t *testing.T) {
282 if !Available() {
283 t.Skip("sandbox-exec not available")
284 }
285 if _, err := exec.LookPath("go"); err != nil {
286 t.Skip("go not on PATH")
287 }
288 home, err := os.UserHomeDir()
289 if err != nil {
290 t.Skipf("no home dir: %v", err)
291 }
292 work, err := os.MkdirTemp(home, ".reasonix-sbtest-go-*")
293 if err != nil {
294 t.Skipf("cannot create work dir under home: %v", err)
295 }
296 t.Cleanup(func() { os.RemoveAll(work) })
297 write := func(name, body string) {
298 if err := os.WriteFile(filepath.Join(work, name), []byte(body), 0o644); err != nil {
299 t.Fatal(err)
300 }
301 }
302 write("go.mod", "module sbtest\n\ngo 1.25\n")
303 write("main.go", "package main\nfunc main() { println(\"ok\") }\n")
304
305 spec := Spec{Mode: "enforce", WriteRoots: []string{work}, Network: true}
306 argv, _ := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, "cd "+work+" && go build -o sbtest .")
307 if out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput(); err != nil {
308 t.Fatalf("go build under sandbox failed (profile too tight?): %v\n%s", err, out)
309 }
310 if _, err := os.Stat(filepath.Join(work, "sbtest")); err != nil {
311 t.Errorf("build output missing: %v", err)
312 }
313 }
314
314 lines GO