返回 DeepSeek-Reasonix
seatbelt_other.go
根目录 / internal / sandbox / seatbelt_other.go
1 //go:build !darwin && !windows
2
3 package sandbox
4
5 import (
6 "context"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "slices"
11 "strings"
12 "sync"
13 "time"
14 )
15
16 var bwrapUsability sync.Map // resolved executable path -> bool
17
18 // usableBwrap distinguishes an installed binary from a usable sandbox backend.
19 // Hardened Linux hosts (including some CI runners) may expose bwrap on PATH but
20 // deny the user namespace it needs; treating that as available makes enforce
21 // fail later with a misleading launch error and overstates MCP isolation.
22 func usableBwrap() (string, bool) {
23 bwrap, err := exec.LookPath("bwrap")
24 if err != nil {
25 return "", false
26 }
27 if cached, ok := bwrapUsability.Load(bwrap); ok {
28 return bwrap, cached.(bool)
29 }
30 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
31 defer cancel()
32 err = exec.CommandContext(ctx, bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--", "true").Run()
33 usable := err == nil
34 actual, _ := bwrapUsability.LoadOrStore(bwrap, usable)
35 return bwrap, actual.(bool)
36 }
37
38 // When spec.Mode is "enforce" and bubblewrap (bwrap) is available on PATH,
39 // the command is wrapped in a bubblewrap sandbox with a profile analogous to
40 // macOS Seatbelt: writes confined to WriteRoots, network denied unless
41 // spec.Network is true. When bwrap is unavailable, the argv is returned
42 // unwrapped with wrapped=false so callers can decide whether to fail closed.
43 func Command(spec Spec, sh Shell, command string) ([]string, bool) {
44 if !spec.Enforce() {
45 return sh.argv(command), false
46 }
47 if bwrap, ok := usableBwrap(); ok {
48 argv := append([]string{bwrap}, bwrapArgs(spec, sh, command)...)
49 return argv, true
50 }
51 // enforce requested but bwrap unavailable — return the unwrapped argv and let
52 // callers decide whether a non-sandboxed command is acceptable.
53 return sh.argv(command), false
54 }
55
56 // CommandArgs is like Command but accepts the command as raw argv instead of a
57 // shell command string. The args are appended directly after the bwrap sandbox
58 // prefix without shell interpretation — suitable for direct binary invocations
59 // like ripgrep that don't need a shell wrapper.
60 func CommandArgs(spec Spec, args []string) ([]string, bool) {
61 if !spec.Enforce() {
62 return args, false
63 }
64 if bwrap, ok := usableBwrap(); ok {
65 argv := append([]string{bwrap}, bwrapArgsForArgs(spec, args)...)
66 return argv, true
67 }
68 return args, false
69 }
70
71 // Available reports whether an OS sandbox is available on this platform.
72 // On Linux, this verifies that bubblewrap can actually enter its namespace;
73 // binary presence alone is insufficient on hardened hosts.
74 func Available() bool {
75 _, ok := usableBwrap()
76 return ok
77 }
78
79 // bwrapArgs builds the bubblewrap command-line arguments that confine the
80 // shell command to the write roots, deny network unless allowed, and overlay
81 // forbid-read paths so directories appear empty and files read as empty. The
82 // rest of the filesystem is mounted read-only (matching macOS Seatbelt).
83 func bwrapArgs(spec Spec, sh Shell, command string) []string {
84 args := bwrapBaseArgs(spec)
85 return append(args, sh.argv(command)...)
86 }
87
88 // bwrapArgsForArgs is like bwrapArgs but accepts raw argv instead of a shell
89 // command string. It builds the same sandbox prefix and appends the caller's
90 // argv directly — no shell interpreter wrapping.
91 func bwrapArgsForArgs(spec Spec, args []string) []string {
92 out := bwrapBaseArgs(spec)
93 // /tmp is replaced above (tmpfs or session-private bind) so MCP servers
94 // cannot inspect unrelated host temporary files. A configured executable
95 // may itself live below /tmp, though (for example a downloaded one-shot
96 // launcher or a Go test helper). Re-expose only that exact file, read-only,
97 // after every masking mount so the process can start without revealing its
98 // siblings. Session-private binds already contain the generation's files,
99 // so only host-/tmp executables need this re-mount.
100 out = append(out, bwrapExecutableMountArgs(args)...)
101 return append(out, args...)
102 }
103
104 // bwrapBaseArgs is the shared bubblewrap prefix for shell and raw-argv launches.
105 // With Spec.SessionTemp set, the private directory is bind-mounted at /tmp so
106 // consecutive Bash calls in the same logical session share temporary files.
107 // Without it (MCP and other independent sandboxes), /tmp is a fresh empty
108 // tmpfs as before.
109 func bwrapBaseArgs(spec Spec) []string {
110 args := []string{
111 "--unshare-net", // deny network by default
112 "--ro-bind", "/", "/",
113 "--dev", "/dev",
114 "--proc", "/proc",
115 }
116 args = append(args, bwrapTmpMountArgs(spec)...)
117 if spec.ReadOnly {
118 return append(args, bwrapForbidReadArgs(spec.ForbidReadRoots)...)
119 }
120 if spec.Network {
121 // Re-allow network by removing the network namespace.
122 args = args[1:] // drop --unshare-net
123 }
124 plan := linuxWritePlan(spec)
125 for _, root := range bwrapWriteBinds(plan) {
126 args = append(args, bwrapWriteRootMountArgs(root)...)
127 }
128 args = append(args, bwrapProtectedWriteArgs(spec, plan.callers)...)
129 args = append(args, bwrapGitMetadataArgs(spec)...)
130 return append(args, bwrapForbidReadArgs(spec.ForbidReadRoots)...)
131 }
132
133 // bwrapGitMetadataArgs pins existing protected directories as mount points, which
134 // rename and rmdir refuse, and binds protected files and trees read-only; paths
135 // beneath a tree are left to it, so an over-budget group costs one mount. A dir
136 // holding a writable root is skipped (rebinding hides the mounts beneath), and
137 // only existing non-symlinks are mount targets: binding a link exposes its target.
138 func bwrapGitMetadataArgs(spec Spec) []string {
139 meta := gitMetadataForSpec(spec)
140 paths := meta.Paths
141 for _, common := range meta.Commons {
142 paths = append(paths, gitGroupPaths(common, gitGroupMaxMounts)...)
143 }
144 var trees []string
145 for _, p := range paths {
146 if p.Tree {
147 trees = append(trees, p.Path)
148 }
149 }
150 writable := writableDirsForSpec(spec)
151 var out []string
152 for _, p := range paths {
153 if slices.ContainsFunc(trees, func(t string) bool { return strings.HasPrefix(p.Path, t+string(filepath.Separator)) }) {
154 continue
155 }
156 info, err := os.Lstat(p.Path)
157 if err != nil {
158 continue
159 }
160 switch {
161 case (p.Tree && info.IsDir()) || (!p.Tree && !p.Pin && info.Mode().IsRegular()):
162 out = append(out, "--ro-bind", p.Path, p.Path)
163 case p.Pin && info.IsDir() && !slices.ContainsFunc(writable, func(w string) bool { return PathWithin(p.Path, w) }):
164 out = append(out, "--bind", p.Path, p.Path)
165 }
166 }
167 return out
168 }
169
170 func writableDirsForSpec(spec Spec) []string {
171 return linuxWritePlan(spec).dirs
172 }
173
174 func gitMetadataRoots(spec Spec) []string { return linuxWritePlan(spec).callers }
175
176 // linuxWritePlan resolves the caller's roots and, unless MinimalWrites, the
177 // host caches bwrap binds beside them.
178 func linuxWritePlan(spec Spec) writeRootPlan {
179 var extras []string
180 if !spec.MinimalWrites {
181 extras = hostWriteDirCandidates()
182 }
183 return planWriteRoots(spec.WriteRoots, extras, spec.SessionTemp)
184 }
185
186 // bwrapWriteBinds are the outermost existing resolved directories: a nested
187 // bind adds nothing, and its path runs through a directory a confined command
188 // can re-point between the check and the mount. Host /tmp is replaced by the
189 // tmp mount rather than re-exposed.
190 func bwrapWriteBinds(plan writeRootPlan) []string {
191 var dirs []string
192 for _, d := range existingDirs(plan.dirs) {
193 if d != "/tmp" {
194 dirs = append(dirs, d)
195 }
196 }
197 out := make([]string, 0, len(dirs))
198 for i, d := range dirs {
199 nested := false
200 for j, other := range dirs {
201 if i != j && other != d && PathWithin(other, d) {
202 nested = true
203 break
204 }
205 }
206 if !nested {
207 out = append(out, d)
208 }
209 }
210 return out
211 }
212
213 func bwrapProtectedWriteArgs(spec Spec, writeRoots []string) []string {
214 protected := resolveProtectedWriteRoots(spec.ProtectedWriteRoots)
215 protected = overlappingProtectedWriteRoots(protected, writeRoots)
216 if len(protected) == 0 {
217 return nil
218 }
219 var out []string
220 seen := map[string]bool{}
221 for _, root := range protected {
222 if seen[root] {
223 continue
224 }
225 seen[root] = true
226 out = append(out, "--ro-bind", root, root)
227 }
228 stateRoot := singleProtectedStateRoot(protected)
229 for _, abs := range writeRoots {
230 if stateRoot != "" && IsProtectedWritePath(abs, stateRoot) {
231 continue
232 }
233 for _, prot := range protected {
234 if abs != prot && PathWithin(prot, abs) {
235 out = append(out, "--bind", abs, abs)
236 break
237 }
238 }
239 }
240 return out
241 }
242
243 func overlappingProtectedWriteRoots(protected, writeRoots []string) []string {
244 var out []string
245 for _, prot := range protected {
246 for _, root := range writeRoots {
247 root = filepath.Clean(strings.TrimSpace(root))
248 if root != "" && root != "." && (PathWithin(root, prot) || PathWithin(prot, root)) {
249 out = append(out, prot)
250 break
251 }
252 }
253 }
254 return out
255 }
256
257 func resolveProtectedWriteRoots(roots []string) []string {
258 seen := map[string]bool{}
259 out := make([]string, 0, len(roots))
260 for _, root := range roots {
261 root = strings.TrimSpace(root)
262 if root == "" {
263 continue
264 }
265 abs, err := ResolveAbsPath(root)
266 if err == nil && !seen[abs] {
267 seen[abs] = true
268 out = append(out, abs)
269 }
270 }
271 return out
272 }
273
274 func bwrapTmpMountArgs(spec Spec) []string {
275 if spec.ReadOnly {
276 return nil
277 }
278 if dir := strings.TrimSpace(spec.SessionTemp); dir != "" {
279 return []string{"--bind", dir, "/tmp"}
280 }
281 return []string{"--tmpfs", "/tmp"}
282 }
283
284 func bwrapWriteRootMountArgs(root string) []string {
285 root = filepath.Clean(strings.TrimSpace(root))
286 if root == "" || root == "." {
287 return nil
288 }
289 if !filepath.IsAbs(root) || !pathWithin(root, "/tmp") {
290 return []string{"--bind", root, root}
291 }
292 out := bwrapTmpParentDirArgs(root)
293 return append(out, "--bind", root, root)
294 }
295
296 // bwrapForbidReadArgs returns mounts suitable for both configured directory
297 // roots and Reasonix-owned credential files. bubblewrap cannot mount tmpfs on a
298 // file, so an existing file is replaced by a read-only /dev/null bind instead.
299 // Missing paths are ignored: there are no bytes to protect and passing a
300 // missing mount destination would make an otherwise valid sandbox fail closed.
301 func bwrapForbidReadArgs(roots []string) []string {
302 type forbiddenPath struct {
303 path string
304 isDir bool
305 }
306 paths := make([]forbiddenPath, 0, len(roots))
307 for _, root := range roots {
308 root, err := filepath.Abs(root)
309 if err != nil {
310 continue
311 }
312 if real, err := filepath.EvalSymlinks(root); err == nil {
313 root = real
314 }
315 info, err := os.Stat(root)
316 if err != nil {
317 continue
318 }
319 paths = append(paths, forbiddenPath{path: root, isDir: info.IsDir()})
320 }
321
322 var out []string
323 seen := map[string]bool{}
324 for _, entry := range paths {
325 if seen[entry.path] {
326 continue
327 }
328 covered := false
329 for _, parent := range paths {
330 if parent.isDir && parent.path != entry.path && pathWithin(entry.path, parent.path) {
331 covered = true
332 break
333 }
334 }
335 if covered {
336 continue
337 }
338 seen[entry.path] = true
339 if entry.isDir {
340 out = append(out, "--tmpfs", entry.path)
341 continue
342 }
343 out = append(out, "--ro-bind", "/dev/null", entry.path)
344 }
345 return out
346 }
347
348 func bwrapExecutableMountArgs(args []string) []string {
349 if len(args) == 0 {
350 return nil
351 }
352 destination := filepath.Clean(args[0])
353 if !filepath.IsAbs(destination) || !pathWithin(destination, "/tmp") {
354 return nil
355 }
356 source := destination
357 if resolved, err := filepath.EvalSymlinks(destination); err == nil {
358 source = resolved
359 }
360
361 out := bwrapTmpParentDirArgs(destination)
362 return append(out, "--ro-bind", source, destination)
363 }
364
365 func bwrapTmpParentDirArgs(destination string) []string {
366 parent := filepath.Dir(destination)
367 rel, err := filepath.Rel("/tmp", parent)
368 if err != nil {
369 return nil
370 }
371 out := make([]string, 0, 2*strings.Count(rel, string(filepath.Separator))+4)
372 current := "/tmp"
373 for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
374 if part == "" || part == "." {
375 continue
376 }
377 current = filepath.Join(current, part)
378 out = append(out, "--dir", current)
379 }
380 return out
381 }
382
383 func pathWithin(path, root string) bool {
384 rel, err := filepath.Rel(root, path)
385 return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
386 }
387
388 func hostWriteDirCandidates() []string {
389 dirs := []string{}
390 if td := os.TempDir(); td != "" && td != "/tmp" {
391 dirs = append(dirs, td)
392 }
393 if home, err := os.UserHomeDir(); err == nil {
394 for _, sub := range []string{".cache", ".cargo", ".npm", "go"} {
395 dirs = append(dirs, filepath.Join(home, sub))
396 }
397 }
398 return dirs
399 }
400
401 func linuxWriteDirs() []string {
402 var out []string
403 for _, d := range existingDirs(planWriteRoots(nil, hostWriteDirCandidates(), "").dirs) {
404 if d != "/tmp" {
405 out = append(out, d)
406 }
407 }
408 return out
409 }
410
411 func hostWriteDirs() []string { return hostWriteDirCandidates() }
412
413 func existingDirs(dirs []string) []string {
414 var out []string
415 for _, d := range dirs {
416 if dirExists(d) {
417 out = append(out, d)
418 }
419 }
420 return out
421 }
422
423 func dirExists(path string) bool {
424 info, err := os.Stat(path)
425 return err == nil && info.IsDir()
426 }
427
428 // HostWritableDirs lists the host directories any jailed command may write
429 // besides its write roots: toolchain caches, and TMPDIR when it is not /tmp.
430 // A cache not created yet is listed too, since the next command binds it.
431 func HostWritableDirs() []string { return hostWriteDirCandidates() }
432
432 lines GO