返回 DeepSeek-Reasonix
sandbox_test.go
根目录 / internal / sandbox / sandbox_test.go
1 package sandbox
2
3 import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 )
11
12 // Spec.Enforce
13
14 func TestEnforce(t *testing.T) {
15 cases := []struct {
16 mode string
17 want bool
18 }{
19 {"", false},
20 {"off", false},
21 {"enforce", true},
22 {"Enforce", false}, // case-sensitive
23 {"something", false},
24 }
25 for _, c := range cases {
26 s := Spec{Mode: c.mode}
27 if got := s.Enforce(); got != c.want {
28 t.Errorf("Spec{%q}.Enforce() = %v, want %v", c.mode, got, c.want)
29 }
30 }
31 }
32
33 // Spec zero value
34
35 func TestSpecZeroValue(t *testing.T) {
36 var s Spec
37 if s.Enforce() {
38 t.Error("zero-value Spec should not enforce")
39 }
40 if s.Network {
41 t.Error("zero-value Spec should not allow network")
42 }
43 if len(s.WriteRoots) != 0 {
44 t.Error("zero-value Spec should have no write roots")
45 }
46 }
47
48 func TestUnavailableMessageIsActionable(t *testing.T) {
49 msg := UnavailableMessage()
50 want := []string{
51 "refusing to run unconfined",
52 "Full access",
53 }
54 if runtime.GOOS == "windows" {
55 // Windows ships no OS-level shell sandbox and the effective mode is
56 // fixed to off, so the remediation states that fact instead of
57 // pointing at a config edit the platform would ignore.
58 want = []string{"refusing to run unconfined", "no OS-level shell sandbox", "Full access"}
59 }
60 for _, w := range want {
61 if !strings.Contains(msg, w) {
62 t.Fatalf("UnavailableMessage() = %q, want %q", msg, w)
63 }
64 }
65 }
66
67 func TestOSSandboxSupportedPerPlatform(t *testing.T) {
68 for goos, want := range map[string]bool{"darwin": true, "linux": true, "windows": false, "freebsd": true} {
69 if got := osSandboxSupportedForGOOS(goos); got != want {
70 t.Fatalf("osSandboxSupportedForGOOS(%q) = %v, want %v", goos, got, want)
71 }
72 }
73 }
74
75 // Command
76
77 func TestCommandNonEnforce(t *testing.T) {
78 spec := Spec{Mode: "off"}
79 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, "ls")
80 if wrapped {
81 t.Error("non-enforce should not wrap")
82 }
83 if cmd[0] != "bash" {
84 t.Errorf("cmd[0] = %q, want bash", cmd[0])
85 }
86 }
87
88 func TestCommandEmptyMode(t *testing.T) {
89 spec := Spec{}
90 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
91 if wrapped {
92 t.Error("empty mode should not wrap")
93 }
94 if len(cmd) != 3 {
95 t.Errorf("cmd length = %d, want 3", len(cmd))
96 }
97 }
98
99 func TestCommandPowerShell(t *testing.T) {
100 cmd, wrapped := Command(Spec{Mode: "off"}, Shell{Kind: ShellPowerShell, Path: "powershell"}, "Get-ChildItem")
101 if wrapped {
102 t.Error("non-enforce should not wrap")
103 }
104 want := []string{"powershell", "-NoProfile", "-NonInteractive", "-Command", psUTF8Prologue + "Get-ChildItem"}
105 if len(cmd) != len(want) {
106 t.Fatalf("argv = %v, want %v", cmd, want)
107 }
108 for i := range want {
109 if cmd[i] != want[i] {
110 t.Fatalf("argv[%d] = %q, want %q", i, cmd[i], want[i])
111 }
112 }
113 }
114
115 func TestResolveShellDecisionTable(t *testing.T) {
116 onPath := func(names ...string) func(string) (string, error) {
117 set := map[string]bool{}
118 for _, n := range names {
119 set[n] = true
120 }
121 return func(name string) (string, error) {
122 if set[name] {
123 return `C:\fake\` + name + ".exe", nil
124 }
125 return "", exec.ErrNotFound
126 }
127 }
128 gitBash := []string{`C:\fake\Git\bin\bash.exe`}
129 always := func(string) bool { return true }
130 never := func(string) bool { return false }
131 // onPath("bash") returns C:\fake\bash.exe; treat exactly that as the WSL
132 // launcher so the exclusion is exercised without matching the Git candidate.
133 wslIsPathBash := func(p string) bool { return p == `C:\fake\bash.exe` }
134 cases := []struct {
135 name string
136 goos string
137 lookPath func(string) (string, error)
138 candidates []string
139 exists func(string) bool
140 probe func(string) bool
141 isWSL func(string) bool
142 wantKind ShellKind
143 wantPath string
144 }{
145 {"native PowerShell precedes Bash", "windows", onPath("bash", "powershell"), gitBash, never, always, never, ShellPowerShell, `C:\fake\powershell.exe`},
146 {"bash on PATH but probe fails", "windows", onPath("bash", "powershell"), gitBash, never, never, never, ShellPowerShell, ""},
147 {"no Bash fallback when native shell absent", "windows", onPath(), gitBash, always, always, never, ShellPowerShell, "pwsh"},
148 {"git-bash on disk but probe fails", "windows", onPath("powershell"), gitBash, always, never, never, ShellPowerShell, ""},
149 {"no bash anywhere, pwsh", "windows", onPath("pwsh", "powershell"), gitBash, never, never, never, ShellPowerShell, ""},
150 {"no bash, only powershell", "windows", onPath("powershell"), gitBash, never, never, never, ShellPowerShell, ""},
151 {"windows, nothing found", "windows", onPath(), nil, never, never, never, ShellPowerShell, "pwsh"},
152 {"linux, no bash → no PS fallback", "linux", onPath("powershell"), gitBash, always, always, never, ShellBash, ""},
153 {"macOS, no bash → zsh", "darwin", onPath("zsh", "sh"), nil, never, always, never, ShellZsh, `C:\fake\zsh.exe`},
154 {"macOS, no bash or zsh → sh", "darwin", onPath("sh"), nil, never, always, never, ShellSh, `C:\fake\sh.exe`},
155 {"auto never falls back to WSL or Git Bash", "windows", onPath("bash"), gitBash, always, always, wslIsPathBash, ShellPowerShell, "pwsh"},
156 {"wsl bash on PATH, no git → powershell not wsl", "windows", onPath("bash", "powershell"), gitBash, never, always, wslIsPathBash, ShellPowerShell, ""},
157 }
158 for _, c := range cases {
159 got := resolveShell("", "", nil, c.goos, c.lookPath, c.exists, c.candidates, nil, c.probe, c.isWSL)
160 if got.Kind != c.wantKind {
161 t.Errorf("%s: kind = %s, want %s (path=%s)", c.name, got.Kind, c.wantKind, got.Path)
162 }
163 if c.wantPath != "" && got.Path != c.wantPath {
164 t.Errorf("%s: path = %q, want %q", c.name, got.Path, c.wantPath)
165 }
166 }
167 }
168
169 func TestResolveShellPrefer(t *testing.T) {
170 onPath := func(names ...string) func(string) (string, error) {
171 set := map[string]bool{}
172 for _, n := range names {
173 set[n] = true
174 }
175 return func(name string) (string, error) {
176 if set[name] {
177 return `C:\fake\` + name + ".exe", nil
178 }
179 return "", exec.ErrNotFound
180 }
181 }
182 gitBash := []string{`C:\fake\Git\bin\bash.exe`}
183 always := func(string) bool { return true }
184 never := func(string) bool { return false }
185 noWSL := func(string) bool { return false }
186
187 // prefer=powershell forces PowerShell even when bash is present and probes ok.
188 got := resolveShell("powershell", "", nil, "windows", onPath("bash", "powershell", "pwsh"), never, gitBash, nil, always, noWSL)
189 if got.Kind != ShellPowerShell {
190 t.Errorf(`prefer="powershell": kind = %s, want powershell`, got.Kind)
191 }
192
193 // Legacy Bash preferences retain their stored value but resolve natively.
194 got = resolveShell("bash", "", nil, "windows", onPath("bash", "powershell"), never, gitBash, nil, always, noWSL)
195 if got.Kind != ShellPowerShell {
196 t.Errorf(`prefer="bash": kind = %s, want powershell`, got.Kind)
197 }
198
199 // An explicit path is honoured for the forced kind.
200 got = resolveShell("pwsh", `C:\custom\pwsh.exe`, nil, "windows", onPath(), always, gitBash, nil, never, noWSL)
201 if got.Kind != ShellPowerShell || got.Path != `C:\custom\pwsh.exe` {
202 t.Errorf(`prefer="pwsh" path: got {%s %q}, want {powershell "C:\custom\pwsh.exe"}`, got.Kind, got.Path)
203 }
204
205 // prefer=pwsh finds PowerShell 7 in its standard install path even when that
206 // directory has not been added to PATH.
207 got = resolveShell("pwsh", "", nil, "windows", onPath("powershell"), func(p string) bool {
208 return p == `C:/Program Files/PowerShell/7/pwsh.exe`
209 }, gitBash, []string{`C:/Program Files/PowerShell/7/pwsh.exe`}, never, noWSL)
210 if got.Kind != ShellPowerShell || got.Path != `C:/Program Files/PowerShell/7/pwsh.exe` {
211 t.Errorf(`prefer="pwsh" standard path: got {%s %q}, want {powershell "C:/Program Files/PowerShell/7/pwsh.exe"}`, got.Kind, got.Path)
212 }
213
214 // A forced shell that isn't installed warns and falls back to auto-detection.
215 var warn strings.Builder
216 got = resolveShell("powershell", "", &warn, "linux", onPath("bash"), never, gitBash, nil, always, noWSL)
217 if got.Kind != ShellBash {
218 t.Errorf("missing forced powershell should fall back to bash, got %s", got.Kind)
219 }
220 if !strings.Contains(warn.String(), "powershell") {
221 t.Errorf("fallback should warn about the missing shell, got %q", warn.String())
222 }
223
224 // An unrecognised value is treated as auto, not an error.
225 got = resolveShell("fish", "", nil, "windows", onPath("bash"), never, gitBash, nil, always, noWSL)
226 if got.Kind != ShellPowerShell {
227 t.Errorf("unknown prefer should use native Windows auto-selection, got %s", got.Kind)
228 }
229
230 // A saved Git Bash path must not override the Windows Agent dialect.
231 existsWithBash := func(p string) bool {
232 return strings.EqualFold(p, `C:\Git\bin\bash.exe`)
233 }
234 got = resolveShell("bash", `C:\Git\git-bash.exe`, nil, "windows", onPath(), existsWithBash, nil, nil, always, noWSL)
235 if got.Kind != ShellPowerShell || got.Path != "pwsh" {
236 t.Errorf("git-bash.exe should resolve to native PowerShell, got %+v", got)
237 }
238 }
239
240 func TestSanitizeWindowsBashPath(t *testing.T) {
241 exists := func(p string) bool {
242 return strings.EqualFold(p, filepath.Join("C:", "Git", "bin", "bash.exe"))
243 }
244 raw := filepath.Join("C:", "Git", "git-bash.exe")
245 got := sanitizeWindowsBashPath(raw, exists)
246 want := filepath.Join("C:", "Git", "bin", "bash.exe")
247 if got != want {
248 t.Fatalf("sanitizeWindowsBashPath(%q) = %q, want %q", raw, got, want)
249 }
250 }
251
252 func TestIsWindowsWSLBash(t *testing.T) {
253 if runtime.GOOS != "windows" {
254 t.Skip("windows-only path detection")
255 }
256 t.Setenv("SystemRoot", `C:\Windows`)
257 if !isWindowsWSLBash(`C:\Windows\System32\bash.exe`) {
258 t.Error("System32 bash launcher should be detected as WSL")
259 }
260 if !isWindowsWSLBash(`c:\windows\system32\BASH.EXE`) {
261 t.Error("detection should be case-insensitive")
262 }
263 if isWindowsWSLBash(`C:\Program Files\Git\bin\bash.exe`) {
264 t.Error("Git-for-Windows bash must not be flagged as WSL")
265 }
266 if isWindowsWSLBash("") {
267 t.Error("empty path is not WSL")
268 }
269 }
270
271 func TestSupportsChaining(t *testing.T) {
272 cases := []struct {
273 sh Shell
274 want bool
275 }{
276 {Shell{Kind: ShellBash, Path: "bash"}, true},
277 {Shell{Kind: ShellPowerShell, Path: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`}, false},
278 {Shell{Kind: ShellPowerShell, Path: "powershell"}, false},
279 {Shell{Kind: ShellPowerShell, Path: `C:\Program Files\PowerShell\7\pwsh.exe`}, true},
280 {Shell{Kind: ShellPowerShell, Path: "pwsh"}, true},
281 }
282 for _, c := range cases {
283 if got := c.sh.SupportsChaining(); got != c.want {
284 t.Errorf("SupportsChaining(%+v) = %v, want %v", c.sh, got, c.want)
285 }
286 }
287 }
288
289 func TestShellArgvDefaultsPath(t *testing.T) {
290 if got := (Shell{Kind: ShellBash}).argv("ls"); got[0] != "bash" {
291 t.Errorf("empty bash path argv[0] = %q, want bash", got[0])
292 }
293 if got := (Shell{Kind: ShellPowerShell}).argv("ls"); got[0] != "powershell" {
294 t.Errorf("empty powershell path argv[0] = %q, want powershell", got[0])
295 }
296 }
297
298 // platform-specific Command tests
299
300 func TestCommandNonDarwin(t *testing.T) {
301 if runtime.GOOS == "darwin" {
302 t.Skip("testing non-darwin path")
303 }
304 spec := Spec{Mode: "enforce", WriteRoots: []string{"/tmp"}}
305 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
306 // Windows has no backend, so it always takes the unwrapped branch below.
307 if Available() {
308 if !wrapped || cmd[0] == "sh" {
309 t.Fatalf("non-darwin enforce with available sandbox should wrap: %v wrapped=%v", cmd, wrapped)
310 }
311 return
312 }
313 if wrapped {
314 t.Error("non-darwin without sandbox should not wrap")
315 }
316 if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" || cmd[2] != "echo hi" {
317 t.Errorf("unexpected cmd: %v", cmd)
318 }
319 }
320
321 func TestCommandDarwinEnforce(t *testing.T) {
322 if runtime.GOOS != "darwin" {
323 t.Skip("darwin-only test")
324 }
325 if !Available() {
326 t.Skip("sandbox-exec not available")
327 }
328 spec := Spec{Mode: "enforce", WriteRoots: []string{"/workspace"}}
329 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
330 if !wrapped {
331 t.Error("darwin enforce with sandbox-exec should wrap")
332 }
333 if cmd[0] != "sandbox-exec" {
334 t.Errorf("cmd[0] = %q, want sandbox-exec", cmd[0])
335 }
336 if len(cmd) != 6 {
337 t.Errorf("cmd length = %d, want 6", len(cmd))
338 }
339 }
340
341 func TestCommandDarwinNonEnforce(t *testing.T) {
342 if runtime.GOOS != "darwin" {
343 t.Skip("darwin-only test")
344 }
345 spec := Spec{Mode: "off", WriteRoots: []string{"/workspace"}}
346 _, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
347 if wrapped {
348 t.Error("non-enforce should not wrap even on darwin")
349 }
350 }
351
352 // Available
353
354 func TestAvailableNonDarwin(t *testing.T) {
355 if runtime.GOOS == "darwin" {
356 t.Skip("testing non-darwin path")
357 }
358 if runtime.GOOS == "windows" {
359 t.Skip("windows has its own helper-backed sandbox availability")
360 }
361 if Available() {
362 if _, err := exec.LookPath("bwrap"); err != nil {
363 t.Errorf("Available() = true, but bwrap lookup failed: %v", err)
364 }
365 }
366 }
367
368 func TestInstalledButUnusableBwrapIsUnavailable(t *testing.T) {
369 if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
370 t.Skip("bubblewrap-only test")
371 }
372 dir := t.TempDir()
373 bwrap := filepath.Join(dir, "bwrap")
374 if err := os.WriteFile(bwrap, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
375 t.Fatal(err)
376 }
377 t.Setenv("PATH", dir)
378 if Available() {
379 t.Fatal("non-functional bwrap binary was reported available")
380 }
381 argv, wrapped := Command(Spec{Mode: "enforce"}, Shell{Kind: ShellBash, Path: "sh"}, "true")
382 if wrapped || len(argv) == 0 || argv[0] != "sh" {
383 t.Fatalf("Command with unusable bwrap = %v, wrapped=%v; want unwrapped shell for caller fail-closed", argv, wrapped)
384 }
385 }
386
386 lines GO