返回 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.Network {
117 // Re-allow network by removing the network namespace.
118 args = args[1:] // drop --unshare-net
119 }
120 for _, root := range spec.WriteRoots {
121 args = append(args, "--bind", root, root)
122 }
123 if !spec.MinimalWrites {
124 for _, root := range linuxWriteDirs() {
125 args = append(args, "--bind", root, root)
126 }
127 }
128 return append(args, bwrapForbidReadArgs(spec.ForbidReadRoots)...)
129 }
130
131 func bwrapTmpMountArgs(spec Spec) []string {
132 if dir := strings.TrimSpace(spec.SessionTemp); dir != "" {
133 return []string{"--bind", dir, "/tmp"}
134 }
135 return []string{"--tmpfs", "/tmp"}
136 }
137
138 // bwrapForbidReadArgs returns mounts suitable for both configured directory
139 // roots and Reasonix-owned credential files. bubblewrap cannot mount tmpfs on a
140 // file, so an existing file is replaced by a read-only /dev/null bind instead.
141 // Missing paths are ignored: there are no bytes to protect and passing a
142 // missing mount destination would make an otherwise valid sandbox fail closed.
143 func bwrapForbidReadArgs(roots []string) []string {
144 type forbiddenPath struct {
145 path string
146 isDir bool
147 }
148 paths := make([]forbiddenPath, 0, len(roots))
149 for _, root := range roots {
150 root, err := filepath.Abs(root)
151 if err != nil {
152 continue
153 }
154 if real, err := filepath.EvalSymlinks(root); err == nil {
155 root = real
156 }
157 info, err := os.Stat(root)
158 if err != nil {
159 continue
160 }
161 paths = append(paths, forbiddenPath{path: root, isDir: info.IsDir()})
162 }
163
164 var out []string
165 seen := map[string]bool{}
166 for _, entry := range paths {
167 if seen[entry.path] {
168 continue
169 }
170 covered := false
171 for _, parent := range paths {
172 if parent.isDir && parent.path != entry.path && pathWithin(entry.path, parent.path) {
173 covered = true
174 break
175 }
176 }
177 if covered {
178 continue
179 }
180 seen[entry.path] = true
181 if entry.isDir {
182 out = append(out, "--tmpfs", entry.path)
183 continue
184 }
185 out = append(out, "--ro-bind", "/dev/null", entry.path)
186 }
187 return out
188 }
189
190 func bwrapExecutableMountArgs(args []string) []string {
191 if len(args) == 0 {
192 return nil
193 }
194 destination := filepath.Clean(args[0])
195 if !filepath.IsAbs(destination) || !pathWithin(destination, "/tmp") {
196 return nil
197 }
198 source := destination
199 if resolved, err := filepath.EvalSymlinks(destination); err == nil {
200 source = resolved
201 }
202
203 parent := filepath.Dir(destination)
204 rel, err := filepath.Rel("/tmp", parent)
205 if err != nil {
206 return nil
207 }
208 out := make([]string, 0, 2*strings.Count(rel, string(filepath.Separator))+4)
209 current := "/tmp"
210 for _, part := range strings.Split(rel, string(filepath.Separator)) {
211 if part == "" || part == "." {
212 continue
213 }
214 current = filepath.Join(current, part)
215 out = append(out, "--dir", current)
216 }
217 return append(out, "--ro-bind", source, destination)
218 }
219
220 func pathWithin(path, root string) bool {
221 rel, err := filepath.Rel(root, path)
222 return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
223 }
224
225 func linuxWriteDirs() []string {
226 dirs := []string{}
227 if td := os.TempDir(); td != "" && td != "/tmp" {
228 dirs = append(dirs, td)
229 }
230 if home, err := os.UserHomeDir(); err == nil {
231 for _, sub := range []string{".cache", ".cargo", ".npm", "go"} {
232 dirs = append(dirs, filepath.Join(home, sub))
233 }
234 }
235 seen := map[string]bool{}
236 out := make([]string, 0, len(dirs))
237 for _, d := range dirs {
238 abs, err := filepath.Abs(d)
239 if err != nil {
240 continue
241 }
242 if real, err := filepath.EvalSymlinks(abs); err == nil {
243 abs = real
244 }
245 if abs == "/tmp" || seen[abs] || !dirExists(abs) {
246 continue
247 }
248 seen[abs] = true
249 out = append(out, abs)
250 }
251 return out
252 }
253
254 func dirExists(path string) bool {
255 info, err := os.Stat(path)
256 return err == nil && info.IsDir()
257 }
258
258 lines GO