返回 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 "slices"
8 "strings"
9 "testing"
10 )
11
12 // sbplString
13
14 func TestSbplString(t *testing.T) {
15 cases := []struct {
16 input string
17 want string
18 }{
19 {"/tmp", `"/tmp"`},
20 {`/path/with"quote`, `"/path/with\"quote"`},
21 {`/path/with\backslash`, `"/path/with\\backslash"`},
22 {`/both"and\`, `"/both\"and\\"`},
23 {"", `""`},
24 }
25 for _, c := range cases {
26 got := sbplString(c.input)
27 if got != c.want {
28 t.Errorf("sbplString(%q) = %q, want %q", c.input, got, c.want)
29 }
30 }
31 }
32
33 // writeAllowDirs
34
35 func TestWriteAllowDirsDeduplication(t *testing.T) {
36 dirs := writeAllowDirs([]string{"/tmp", "/tmp", "/tmp"})
37 seen := map[string]bool{}
38 for _, d := range dirs {
39 if seen[d] {
40 t.Errorf("duplicate dir: %s", d)
41 }
42 seen[d] = true
43 }
44 }
45
46 func TestWriteAllowDirsIncludesRoots(t *testing.T) {
47 root := t.TempDir()
48 dirs := writeAllowDirs([]string{root})
49 found := false
50 for _, d := range dirs {
51 real, _ := filepath.EvalSymlinks(root)
52 if d == real {
53 found = true
54 break
55 }
56 }
57 if !found {
58 t.Errorf("writeAllowDirs should include root %s, got %v", root, dirs)
59 }
60 }
61
62 func TestWriteAllowDirsIncludesTemp(t *testing.T) {
63 dirs := writeAllowDirs(nil)
64 tmpDir := os.TempDir()
65 realTmp, _ := filepath.EvalSymlinks(tmpDir)
66 found := slices.Contains(dirs, realTmp)
67 if !found {
68 t.Errorf("writeAllowDirs should include temp dir %s, got %v", tmpDir, dirs)
69 }
70 }
71
72 func TestWriteAllowDirsIncludesSessionTemp(t *testing.T) {
73 private := t.TempDir()
74 dirs := writeAllowDirsForSpec(Spec{SessionTemp: private, MinimalWrites: true})
75 real, _ := filepath.EvalSymlinks(private)
76 found := slices.Contains(dirs, real)
77 if !found {
78 t.Fatalf("SessionTemp must be allowed under Seatbelt even with MinimalWrites: %v", dirs)
79 }
80 }
81
82 func TestWriteAllowDirsSkipsEmpty(t *testing.T) {
83 dirs := writeAllowDirs([]string{"", "", ""})
84 for _, d := range dirs {
85 if d == "" {
86 t.Error("writeAllowDirs should skip empty strings")
87 }
88 }
89 }
90
91 func TestWriteAllowDirsNoDuplicates(t *testing.T) {
92 roots := []string{"/tmp", "/private/tmp", os.TempDir()}
93 dirs := writeAllowDirs(roots)
94 seen := map[string]bool{}
95 for _, d := range dirs {
96 if seen[d] {
97 t.Errorf("duplicate: %s", d)
98 }
99 seen[d] = true
100 }
101 }
102
103 // seatbeltProfile
104
105 func TestSeatbeltProfileDeniesNetwork(t *testing.T) {
106 spec := Spec{Mode: "enforce", Network: false, WriteRoots: []string{"/workspace"}}
107 profile := seatbeltProfile(spec)
108 if !strings.Contains(profile, "(deny network*)") {
109 t.Error("profile should deny network when Network=false")
110 }
111 }
112
113 func TestSeatbeltProfileAllowsNetwork(t *testing.T) {
114 spec := Spec{Mode: "enforce", Network: true, WriteRoots: []string{"/workspace"}}
115 profile := seatbeltProfile(spec)
116 if strings.Contains(profile, "(deny network*)") {
117 t.Error("profile should not deny network when Network=true")
118 }
119 }
120
121 func TestSeatbeltProfileContainsVersion(t *testing.T) {
122 spec := Spec{Mode: "enforce", WriteRoots: []string{"/workspace"}}
123 profile := seatbeltProfile(spec)
124 if !strings.Contains(profile, "(version 1)") {
125 t.Error("profile should contain version 1")
126 }
127 if !strings.Contains(profile, "(allow default)") {
128 t.Error("profile should allow default")
129 }
130 if !strings.Contains(profile, "(deny file-write*)") {
131 t.Error("profile should deny file-write")
132 }
133 }
134
135 func TestSeatbeltProfileContainsRoots(t *testing.T) {
136 root := t.TempDir()
137 spec := Spec{Mode: "enforce", WriteRoots: []string{root}}
138 profile := seatbeltProfile(spec)
139 if !strings.Contains(profile, "(allow file-write*") {
140 t.Error("profile should have allow file-write section")
141 }
142 if !strings.Contains(profile, "(subpath ") {
143 t.Error("profile should contain subpath entries")
144 }
145 }
146
147 func TestMinimalWriteProfileOnlyAddsExplicitRootsAndDev(t *testing.T) {
148 root := t.TempDir()
149 dirs := writeAllowDirsForSpec(Spec{Mode: "enforce", WriteRoots: []string{root}, MinimalWrites: true})
150 if !containsDarwinPath(dirs, root) || !containsDarwinPath(dirs, "/dev") {
151 t.Fatalf("minimal write dirs = %v", dirs)
152 }
153 for _, forbidden := range []string{"/tmp", "/private/tmp", filepath.Join(os.Getenv("HOME"), ".npm"), filepath.Join(os.Getenv("HOME"), ".cache")} {
154 if forbidden != "" && containsDarwinPath(dirs, forbidden) {
155 t.Fatalf("minimal MCP profile unexpectedly allowed broad write root %q: %v", forbidden, dirs)
156 }
157 }
158 }
159
160 func containsDarwinPath(paths []string, want string) bool {
161 abs, err := filepath.Abs(want)
162 if err != nil {
163 return false
164 }
165 if real, err := filepath.EvalSymlinks(abs); err == nil {
166 abs = real
167 }
168 return slices.Contains(paths, abs)
169 }
170
171 func TestCommandUnwrappedWhenOff(t *testing.T) {
172 argv, wrapped := Command(Spec{Mode: "off"}, Shell{Kind: ShellBash, Path: "bash"}, "echo hi")
173 if wrapped {
174 t.Error("Mode=off should not wrap")
175 }
176 if len(argv) != 3 || argv[0] != "bash" || argv[1] != "-c" || argv[2] != "echo hi" {
177 t.Errorf("argv = %v, want [bash -c echo hi]", argv)
178 }
179 }
180
181 func TestProfileDeniesProtectedWriteRoots(t *testing.T) {
182 home := t.TempDir()
183 state := canonicalDir(filepath.Join(home, ".reasonix"))
184 sessions := filepath.Join(state, "sessions")
185 if err := os.MkdirAll(sessions, 0o755); err != nil {
186 t.Fatal(err)
187 }
188 profile := seatbeltProfile(Spec{
189 Mode: "enforce",
190 WriteRoots: []string{home},
191 ProtectedWriteRoots: ProtectedWriteRoots(state),
192 })
193 if !strings.Contains(profile, `(deny file-write* (subpath "`+state+`")`) &&
194 !strings.Contains(profile, "deny file-write*") {
195 t.Fatalf("protected write deny missing:\n%s", profile)
196 }
197 if !strings.Contains(profile, "deny file-write*") || !strings.Contains(profile, state) {
198 t.Fatalf("expected deny of state boundary in profile:\n%s", profile)
199 }
200 }
201
202 func TestProfileReallowsOnlySafeStateChild(t *testing.T) {
203 state := canonicalDir(t.TempDir())
204 skills := filepath.Join(state, "skills")
205 projects := filepath.Join(state, "projects", "slug")
206 if err := os.MkdirAll(skills, 0o755); err != nil {
207 t.Fatal(err)
208 }
209 if err := os.MkdirAll(projects, 0o755); err != nil {
210 t.Fatal(err)
211 }
212 profile := seatbeltProfile(Spec{
213 Mode: "enforce",
214 WriteRoots: []string{skills, projects},
215 ProtectedWriteRoots: ProtectedWriteRoots(state),
216 MinimalWrites: true,
217 })
218 if !strings.Contains(profile, `(allow file-write* (subpath "`+skills+`"))`) {
219 t.Fatalf("safe state child should be explicitly reopened:\n%s", profile)
220 }
221 if strings.Contains(profile, `(allow file-write* (subpath "`+projects+`"))`) {
222 t.Fatalf("project runtime state must remain denied:\n%s", profile)
223 }
224 }
225
226 func TestProfileNetworkAndRoots(t *testing.T) {
227 with := seatbeltProfile(Spec{Mode: "enforce", WriteRoots: []string{"/work/proj"}, ForbidReadRoots: []string{"/etc/ssh", "/home/user/.ssh"}, Network: true})
228 if strings.Contains(with, "(deny network*)") {
229 t.Error("network=true should not deny network")
230 }
231 if !strings.Contains(with, "(allow default)") || !strings.Contains(with, "(deny file-write*)") || !strings.Contains(with, "(deny file-read* (subpath") {
232 t.Error("profile missing base allow/deny structure")
233 }
234 if !strings.Contains(with, `(subpath "/work/proj")`) {
235 t.Errorf("profile missing the write-root subpath:\n%s", with)
236 }
237 if !strings.Contains(with, `(subpath "/home/user/.ssh")`) {
238 t.Errorf("profile missing the forbid-read subpath:\n%s", with)
239 }
240 without := seatbeltProfile(Spec{Mode: "enforce", Network: false})
241 if !strings.Contains(without, "(deny network*)") {
242 t.Error("network=false should deny network")
243 }
244 if strings.Contains(without, "deny file-read") {
245 t.Error("profile should not contain file-read rules when forbid-read is empty")
246 }
247 }
248
249 // TestSandboxEnforcesWrites runs real commands through sandbox-exec and checks
250 // the boundary: a write under a write-root succeeds, a write elsewhere under
251 // $HOME (not a root, not a cache dir) is refused, and reads are unrestricted.
252 // Dirs are created under $HOME (not /tmp, which the profile always allows) so
253 // the test exercises the root mechanism itself.
254 func TestSandboxEnforcesWrites(t *testing.T) {
255 if !Available() {
256 t.Skip("sandbox-exec not available")
257 }
258 home, err := os.UserHomeDir()
259 if err != nil {
260 t.Skipf("no home dir: %v", err)
261 }
262 workRoot, err := os.MkdirTemp(home, ".reasonix-sbtest-work-*")
263 if err != nil {
264 t.Skipf("cannot create work dir under home: %v", err)
265 }
266 t.Cleanup(func() { os.RemoveAll(workRoot) })
267 outside, err := os.MkdirTemp(home, ".reasonix-sbtest-out-*")
268 if err != nil {
269 t.Skipf("cannot create outside dir under home: %v", err)
270 }
271 t.Cleanup(func() { os.RemoveAll(outside) })
272
273 spec := Spec{Mode: "enforce", WriteRoots: []string{workRoot}, Network: true}
274 run := func(command string) error {
275 argv, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, command)
276 if !wrapped {
277 t.Fatalf("expected wrapping for command %q", command)
278 }
279 return exec.Command(argv[0], argv[1:]...).Run()
280 }
281
282 // Write inside the root: allowed.
283 inFile := filepath.Join(workRoot, "in.txt")
284 if err := run("echo hi > " + inFile); err != nil {
285 t.Fatalf("write inside root failed: %v", err)
286 }
287 if _, err := os.Stat(inFile); err != nil {
288 t.Errorf("file not created inside root: %v", err)
289 }
290
291 // Write outside every root: refused (the command exits non-zero).
292 outFile := filepath.Join(outside, "out.txt")
293 if err := run("echo nope > " + outFile); err == nil {
294 t.Error("write outside root should be denied by the sandbox")
295 }
296 if _, err := os.Stat(outFile); !os.IsNotExist(err) {
297 t.Error("file outside root must not be created")
298 }
299
300 // Reading outside the root is allowed (read-all).
301 if err := run("cat /etc/hosts > " + filepath.Join(workRoot, "hosts.txt")); err != nil {
302 t.Errorf("read of /etc/hosts inside sandbox failed: %v", err)
303 }
304 }
305
306 // TestGoBuildUnderSandbox guards the default-on profile against the main risk:
307 // breaking the toolchain. `go build` writes to GOCACHE (under ~/Library/Caches)
308 // and a temp work dir, both of which the profile must allow, while output lands
309 // in the workspace. If this fails, the default profile is too tight.
310 func TestGoBuildUnderSandbox(t *testing.T) {
311 if !Available() {
312 t.Skip("sandbox-exec not available")
313 }
314 if _, err := exec.LookPath("go"); err != nil {
315 t.Skip("go not on PATH")
316 }
317 home, err := os.UserHomeDir()
318 if err != nil {
319 t.Skipf("no home dir: %v", err)
320 }
321 work, err := os.MkdirTemp(home, ".reasonix-sbtest-go-*")
322 if err != nil {
323 t.Skipf("cannot create work dir under home: %v", err)
324 }
325 t.Cleanup(func() { os.RemoveAll(work) })
326 write := func(name, body string) {
327 if err := os.WriteFile(filepath.Join(work, name), []byte(body), 0o644); err != nil {
328 t.Fatal(err)
329 }
330 }
331 write("go.mod", "module sbtest\n\ngo 1.25\n")
332 write("main.go", "package main\nfunc main() { println(\"ok\") }\n")
333
334 spec := Spec{Mode: "enforce", WriteRoots: []string{work}, Network: true}
335 argv, _ := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, "cd "+work+" && go build -o sbtest .")
336 if out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput(); err != nil {
337 t.Fatalf("go build under sandbox failed (profile too tight?): %v\n%s", err, out)
338 }
339 if _, err := os.Stat(filepath.Join(work, "sbtest")); err != nil {
340 t.Errorf("build output missing: %v", err)
341 }
342 }
343
344 // fakeSandboxExec writes an executable named sandbox-exec into a fresh temp
345 // dir and returns its path. Tests probe the returned path directly so they do
346 // not depend on process-global PATH state while the package runs in parallel
347 // with the rest of the repository.
348 func fakeSandboxExec(t *testing.T, exitCode string) string {
349 t.Helper()
350 dir := t.TempDir()
351 path := filepath.Join(dir, "sandbox-exec")
352 if err := os.WriteFile(path, []byte("#!/bin/sh\nexit "+exitCode+"\n"), 0o755); err != nil {
353 t.Fatal(err)
354 }
355 return path
356 }
357
358 // TestAvailableFalseWhenSandboxExecUnusable covers the macOS 10.14+ case where
359 // sandbox-exec is installed but sandbox_apply is refused (exit 71): the probe
360 // must report unavailable so enforce mode fails loudly at boot instead of
361 // silently on every command.
362 func TestAvailableFalseWhenSandboxExecUnusable(t *testing.T) {
363 path := fakeSandboxExec(t, "71")
364 sandboxExecUsability.Delete(path) // the fake is fresh per test; re-probe it
365 if usableSandboxExecPath(path) {
366 t.Fatal("sandbox-exec probe = true, want false: executable is unusable (exit 71)")
367 }
368 }
369
370 func TestAvailableTrueWhenSandboxExecUsable(t *testing.T) {
371 path := fakeSandboxExec(t, "0")
372 sandboxExecUsability.Delete(path)
373 if !usableSandboxExecPath(path) {
374 t.Fatal("sandbox-exec probe = false, want true: working sandbox-exec")
375 }
376 }
377
378 func TestAvailableFalseWhenSandboxExecMissing(t *testing.T) {
379 t.Setenv("PATH", t.TempDir()) // no sandbox-exec anywhere on PATH
380 if Available() {
381 t.Fatal("Available() = true, want false: sandbox-exec not on PATH")
382 }
383 }
384
384 lines GO