返回 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 "strings"
11 "sync"
12 "time"
13 )
14
15 var bwrapUsability sync.Map // resolved executable path -> bool
16
17 // usableBwrap distinguishes an installed binary from a usable sandbox backend.
18 // Hardened Linux hosts (including some CI runners) may expose bwrap on PATH but
19 // deny the user namespace it needs; treating that as available makes enforce
20 // fail later with a misleading launch error and overstates MCP isolation.
21 func usableBwrap() (string, bool) {
22 bwrap, err := exec.LookPath("bwrap")
23 if err != nil {
24 return "", false
25 }
26 if cached, ok := bwrapUsability.Load(bwrap); ok {
27 return bwrap, cached.(bool)
28 }
29 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
30 defer cancel()
31 err = exec.CommandContext(ctx, bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--", "true").Run()
32 usable := err == nil
33 actual, _ := bwrapUsability.LoadOrStore(bwrap, usable)
34 return bwrap, actual.(bool)
35 }
36
37 // When spec.Mode is "enforce" and bubblewrap (bwrap) is available on PATH,
38 // the command is wrapped in a bubblewrap sandbox with a profile analogous to
39 // macOS Seatbelt: writes confined to WriteRoots, network denied unless
40 // spec.Network is true. When bwrap is unavailable, the argv is returned
41 // unwrapped with wrapped=false so callers can decide whether to fail closed.
42 func Command(spec Spec, sh Shell, command string) ([]string, bool) {
43 if !spec.Enforce() {
44 return sh.argv(command), false
45 }
46 if bwrap, ok := usableBwrap(); ok {
47 argv := append([]string{bwrap}, bwrapArgs(spec, sh, command)...)
48 return argv, true
49 }
50 // enforce requested but bwrap unavailable — return the unwrapped argv and let
51 // callers decide whether a non-sandboxed command is acceptable.
52 return sh.argv(command), false
53 }
54
55 // CommandArgs is like Command but accepts the command as raw argv instead of a
56 // shell command string. The args are appended directly after the bwrap sandbox
57 // prefix without shell interpretation — suitable for direct binary invocations
58 // like ripgrep that don't need a shell wrapper.
59 func CommandArgs(spec Spec, args []string) ([]string, bool) {
60 if !spec.Enforce() {
61 return args, false
62 }
63 if bwrap, ok := usableBwrap(); ok {
64 argv := append([]string{bwrap}, bwrapArgsForArgs(spec, args)...)
65 return argv, true
66 }
67 return args, false
68 }
69
70 // Available reports whether an OS sandbox is available on this platform.
71 // On Linux, this verifies that bubblewrap can actually enter its namespace;
72 // binary presence alone is insufficient on hardened hosts.
73 func Available() bool {
74 _, ok := usableBwrap()
75 return ok
76 }
77
78 // bwrapArgs builds the bubblewrap command-line arguments that confine the
79 // shell command to the write roots, deny network unless allowed, and overlay
80 // forbid-read paths so directories appear empty and files read as empty. The
81 // rest of the filesystem is mounted read-only (matching macOS Seatbelt).
82 func bwrapArgs(spec Spec, sh Shell, command string) []string {
83 args := bwrapBaseArgs(spec)
84 return append(args, sh.argv(command)...)
85 }
86
87 // bwrapArgsForArgs is like bwrapArgs but accepts raw argv instead of a shell
88 // command string. It builds the same sandbox prefix and appends the caller's
89 // argv directly — no shell interpreter wrapping.
90 func bwrapArgsForArgs(spec Spec, args []string) []string {
91 out := bwrapBaseArgs(spec)
92 // /tmp is replaced above (tmpfs or session-private bind) so MCP servers
93 // cannot inspect unrelated host temporary files. A configured executable
94 // may itself live below /tmp, though (for example a downloaded one-shot
95 // launcher or a Go test helper). Re-expose only that exact file, read-only,
96 // after every masking mount so the process can start without revealing its
97 // siblings. Session-private binds already contain the generation's files,
98 // so only host-/tmp executables need this re-mount.
99 out = append(out, bwrapExecutableMountArgs(args)...)
100 return append(out, args...)
101 }
102
103 // bwrapBaseArgs is the shared bubblewrap prefix for shell and raw-argv launches.
104 // With Spec.SessionTemp set, the private directory is bind-mounted at /tmp so
105 // consecutive Bash calls in the same logical session share temporary files.
106 // Without it (MCP and other independent sandboxes), /tmp is a fresh empty
107 // tmpfs as before.
108 func bwrapBaseArgs(spec Spec) []string {
109 args := []string{
110 "--unshare-net", // deny network by default
111 "--ro-bind", "/", "/",
112 "--dev", "/dev",
113 "--proc", "/proc",
114 }
115 args = append(args, bwrapTmpMountArgs(spec)...)
116 if spec.ReadOnly {
117 return append(args, bwrapForbidReadArgs(spec.ForbidReadRoots)...)
118 }
119 if spec.Network {
120 // Re-allow network by removing the network namespace.
121 args = args[1:] // drop --unshare-net
122 }
123 for _, root := range spec.WriteRoots {
124 args = append(args, bwrapWriteRootMountArgs(root)...)
125 }
126 if !spec.MinimalWrites {
127 for _, root := range linuxWriteDirs() {
128 args = append(args, "--bind", root, root)
129 }
130 }
131 args = append(args, bwrapProtectedWriteArgs(spec, spec.WriteRoots)...)
132 return append(args, bwrapForbidReadArgs(spec.ForbidReadRoots)...)
133 }
134
135 func bwrapProtectedWriteArgs(spec Spec, writeRoots []string) []string {
136 protected := resolveProtectedWriteRoots(spec.ProtectedWriteRoots)
137 protected = overlappingProtectedWriteRoots(protected, writeRoots)
138 if len(protected) == 0 {
139 return nil
140 }
141 var out []string
142 seen := map[string]bool{}
143 for _, root := range protected {
144 if seen[root] {
145 continue
146 }
147 seen[root] = true
148 out = append(out, "--ro-bind", root, root)
149 }
150 stateRoot := singleProtectedStateRoot(protected)
151 for _, abs := range writeRoots {
152 if stateRoot != "" && IsProtectedWritePath(abs, stateRoot) {
153 continue
154 }
155 for _, prot := range protected {
156 if abs != prot && PathWithin(prot, abs) {
157 out = append(out, "--bind", abs, abs)
158 break
159 }
160 }
161 }
162 return out
163 }
164
165 func overlappingProtectedWriteRoots(protected, writeRoots []string) []string {
166 var out []string
167 for _, prot := range protected {
168 for _, root := range writeRoots {
169 root = filepath.Clean(strings.TrimSpace(root))
170 if root != "" && root != "." && (PathWithin(root, prot) || PathWithin(prot, root)) {
171 out = append(out, prot)
172 break
173 }
174 }
175 }
176 return out
177 }
178
179 func resolveProtectedWriteRoots(roots []string) []string {
180 seen := map[string]bool{}
181 out := make([]string, 0, len(roots))
182 for _, root := range roots {
183 root = strings.TrimSpace(root)
184 if root == "" {
185 continue
186 }
187 abs, err := ResolveAbsPath(root)
188 if err == nil && !seen[abs] {
189 seen[abs] = true
190 out = append(out, abs)
191 }
192 }
193 return out
194 }
195
196 func bwrapTmpMountArgs(spec Spec) []string {
197 if spec.ReadOnly {
198 return nil
199 }
200 if dir := strings.TrimSpace(spec.SessionTemp); dir != "" {
201 return []string{"--bind", dir, "/tmp"}
202 }
203 return []string{"--tmpfs", "/tmp"}
204 }
205
206 func bwrapWriteRootMountArgs(root string) []string {
207 root = filepath.Clean(strings.TrimSpace(root))
208 if root == "" || root == "." {
209 return nil
210 }
211 if !filepath.IsAbs(root) || !pathWithin(root, "/tmp") {
212 return []string{"--bind", root, root}
213 }
214 out := bwrapTmpParentDirArgs(root)
215 return append(out, "--bind", root, root)
216 }
217
218 // bwrapForbidReadArgs returns mounts suitable for both configured directory
219 // roots and Reasonix-owned credential files. bubblewrap cannot mount tmpfs on a
220 // file, so an existing file is replaced by a read-only /dev/null bind instead.
221 // Missing paths are ignored: there are no bytes to protect and passing a
222 // missing mount destination would make an otherwise valid sandbox fail closed.
223 func bwrapForbidReadArgs(roots []string) []string {
224 type forbiddenPath struct {
225 path string
226 isDir bool
227 }
228 paths := make([]forbiddenPath, 0, len(roots))
229 for _, root := range roots {
230 root, err := filepath.Abs(root)
231 if err != nil {
232 continue
233 }
234 if real, err := filepath.EvalSymlinks(root); err == nil {
235 root = real
236 }
237 info, err := os.Stat(root)
238 if err != nil {
239 continue
240 }
241 paths = append(paths, forbiddenPath{path: root, isDir: info.IsDir()})
242 }
243
244 var out []string
245 seen := map[string]bool{}
246 for _, entry := range paths {
247 if seen[entry.path] {
248 continue
249 }
250 covered := false
251 for _, parent := range paths {
252 if parent.isDir && parent.path != entry.path && pathWithin(entry.path, parent.path) {
253 covered = true
254 break
255 }
256 }
257 if covered {
258 continue
259 }
260 seen[entry.path] = true
261 if entry.isDir {
262 out = append(out, "--tmpfs", entry.path)
263 continue
264 }
265 out = append(out, "--ro-bind", "/dev/null", entry.path)
266 }
267 return out
268 }
269
270 func bwrapExecutableMountArgs(args []string) []string {
271 if len(args) == 0 {
272 return nil
273 }
274 destination := filepath.Clean(args[0])
275 if !filepath.IsAbs(destination) || !pathWithin(destination, "/tmp") {
276 return nil
277 }
278 source := destination
279 if resolved, err := filepath.EvalSymlinks(destination); err == nil {
280 source = resolved
281 }
282
283 out := bwrapTmpParentDirArgs(destination)
284 return append(out, "--ro-bind", source, destination)
285 }
286
287 func bwrapTmpParentDirArgs(destination string) []string {
288 parent := filepath.Dir(destination)
289 rel, err := filepath.Rel("/tmp", parent)
290 if err != nil {
291 return nil
292 }
293 out := make([]string, 0, 2*strings.Count(rel, string(filepath.Separator))+4)
294 current := "/tmp"
295 for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
296 if part == "" || part == "." {
297 continue
298 }
299 current = filepath.Join(current, part)
300 out = append(out, "--dir", current)
301 }
302 return out
303 }
304
305 func pathWithin(path, root string) bool {
306 rel, err := filepath.Rel(root, path)
307 return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
308 }
309
310 func linuxWriteDirs() []string {
311 dirs := []string{}
312 if td := os.TempDir(); td != "" && td != "/tmp" {
313 dirs = append(dirs, td)
314 }
315 if home, err := os.UserHomeDir(); err == nil {
316 for _, sub := range []string{".cache", ".cargo", ".npm", "go"} {
317 dirs = append(dirs, filepath.Join(home, sub))
318 }
319 }
320 seen := map[string]bool{}
321 out := make([]string, 0, len(dirs))
322 for _, d := range dirs {
323 abs, err := filepath.Abs(d)
324 if err != nil {
325 continue
326 }
327 if real, err := filepath.EvalSymlinks(abs); err == nil {
328 abs = real
329 }
330 if abs == "/tmp" || seen[abs] || !dirExists(abs) {
331 continue
332 }
333 seen[abs] = true
334 out = append(out, abs)
335 }
336 return out
337 }
338
339 func dirExists(path string) bool {
340 info, err := os.Stat(path)
341 return err == nil && info.IsDir()
342 }
343
343 lines GO