返回 DeepSeek-Reasonix
seatbelt_darwin.go
根目录 / internal / sandbox / seatbelt_darwin.go
1 package sandbox
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12 )
13
14 // Command returns the argv to run `command` through sh, wrapped in sandbox-exec
15 // when the spec enforces and the tool is available. The second return is whether
16 // wrapping happened; false means the argv is unwrapped (sandbox off, or
17 // sandbox-exec missing). Callers decide whether an unwrapped command is allowed.
18 func Command(spec Spec, sh Shell, command string) ([]string, bool) {
19 if !spec.Enforce() || !Available() {
20 return sh.argv(command), false
21 }
22 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, sh.argv(command)...), true
23 }
24
25 // CommandArgs is like Command but accepts the command as raw argv instead of a
26 // shell command string. The args are appended directly after the sandbox prefix
27 // without shell interpretation — suitable for direct binary invocations like
28 // ripgrep that don't need a shell wrapper.
29 func CommandArgs(spec Spec, args []string) ([]string, bool) {
30 if !spec.Enforce() || !Available() {
31 return args, false
32 }
33 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, args...), true
34 }
35
36 // sandboxExecUsability caches the probe result per resolved binary path, so
37 // repeated Available() calls stay O(1) after the first check.
38 var sandboxExecUsability sync.Map // resolved executable path -> bool
39
40 const (
41 sandboxExecProbeTimeout = 10 * time.Second
42 sandboxExecProbeCommand = "/usr/bin/true"
43 )
44
45 // usableSandboxExec distinguishes an installed sandbox-exec from a usable
46 // Seatbelt backend. On restricted macOS hosts, sandbox-exec can be on PATH
47 // while sandbox_apply fails with exit 71. Probe that operation directly with a
48 // minimal profile, mirroring usableBwrap on Linux.
49 func usableSandboxExec() bool {
50 path, err := exec.LookPath("sandbox-exec")
51 if err != nil {
52 return false
53 }
54 return usableSandboxExecPath(path)
55 }
56
57 func usableSandboxExecPath(path string) bool {
58 if path == "" {
59 return false
60 }
61 if cached, ok := sandboxExecUsability.Load(path); ok {
62 return cached.(bool)
63 }
64 ctx, cancel := context.WithTimeout(context.Background(), sandboxExecProbeTimeout)
65 defer cancel()
66 err := exec.CommandContext(ctx, path, "-p", "(version 1)(allow default)", sandboxExecProbeCommand).Run()
67 // A slow host should not permanently poison the process-local cache with a
68 // transient timeout. Definitive probe failures (including exit 71) remain
69 // cached so every command does not pay the failed probe cost.
70 if ctx.Err() != nil {
71 return false
72 }
73 usable := err == nil
74 actual, _ := sandboxExecUsability.LoadOrStore(path, usable)
75 return actual.(bool)
76 }
77
78 // Available reports whether the OS sandbox backend can actually confine
79 // processes. macOS probes sandbox-exec; Linux verifies bubblewrap can enter its
80 // namespace (see seatbelt_other.go).
81 func Available() bool {
82 return usableSandboxExec()
83 }
84
85 // seatbeltProfile builds an SBPL profile that allows everything, then denies
86 // all file writes and re-allows them only under the write-roots (workspace +
87 // temp + caches). Network is denied unless allowed. Forbid-read roots get
88 // individual deny-read rules. Reads elsewhere are left open so the
89 // toolchain (compilers reading GOROOT, git reading ~/.gitconfig, …) keeps
90 // working — the boundary this draws is "can't write outside the configured
91 // writable roots, and optionally can't talk to the network", which is the Phase
92 // 0 blast-radius made to also cover arbitrary shell commands.
93 func seatbeltProfile(spec Spec) string {
94 var b strings.Builder
95 b.WriteString("(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n")
96 for _, p := range writeAllowDirsForSpec(spec) {
97 fmt.Fprintf(&b, " (subpath %s)\n", sbplString(p))
98 }
99 b.WriteString(")\n")
100 // Deny reads under forbid-read roots so even a permitted shell command
101 // cannot peek at them through the OS sandbox. Each path gets its own deny
102 // rule; (allow default) above keeps reads working everywhere else.
103 for _, p := range forbidReadDirs(spec.ForbidReadRoots) {
104 fmt.Fprintf(&b, "(deny file-read* (subpath %s))\n", sbplString(p))
105 }
106 if !spec.Network {
107 b.WriteString("(deny network*)\n")
108 }
109 for _, p := range forbidWriteDirs(spec.ProtectedWriteRoots) {
110 fmt.Fprintf(&b, "(deny file-write* (subpath %s))\n", sbplString(p))
111 }
112 for _, p := range explicitProtectedAllowDirs(spec) {
113 fmt.Fprintf(&b, "(allow file-write* (subpath %s))\n", sbplString(p))
114 }
115 return b.String()
116 }
117
118 func forbidWriteDirs(roots []string) []string {
119 return forbidReadDirs(roots)
120 }
121
122 func explicitProtectedAllowDirs(spec Spec) []string {
123 protected := forbidWriteDirs(spec.ProtectedWriteRoots)
124 if len(protected) == 0 {
125 return nil
126 }
127 stateRoot := singleProtectedStateRoot(protected)
128 var out []string
129 for _, root := range writeAllowDirsForSpec(spec) {
130 if stateRoot != "" && IsProtectedWritePath(root, stateRoot) {
131 continue
132 }
133 for _, prot := range protected {
134 if root != prot && PathWithin(prot, root) {
135 out = append(out, root)
136 break
137 }
138 }
139 }
140 return out
141 }
142
143 // writeAllowDirs is the deduplicated, symlink-resolved set of directories the
144 // sandbox permits writes to: the caller's roots plus temp dirs, /dev, and the
145 // common toolchain caches under $HOME. Symlinks are resolved because macOS's
146 // /tmp and $TMPDIR live under /private, which is the path Seatbelt matches.
147 func writeAllowDirs(roots []string) []string {
148 return writeAllowDirsForSpec(Spec{WriteRoots: roots})
149 }
150
151 func writeAllowDirsForSpec(spec Spec) []string {
152 if spec.ReadOnly {
153 // Preserve device compatibility without granting host file writes.
154 return []string{"/dev/null"}
155 }
156 roots := spec.WriteRoots
157 dirs := append([]string{}, roots...)
158 dirs = append(dirs, "/dev")
159 if dir := strings.TrimSpace(spec.SessionTemp); dir != "" {
160 // Session-private temporary directory must be writable under Seatbelt
161 // even when MinimalWrites omits the broad host temp allowances.
162 dirs = append(dirs, dir)
163 }
164 if !spec.MinimalWrites {
165 dirs = append(dirs, "/tmp", "/private/tmp", "/private/var/folders", os.TempDir())
166 }
167 if !spec.MinimalWrites {
168 if home, err := os.UserHomeDir(); err == nil {
169 // go build/test → Library/Caches + go; pip/etc → .cache; npm/cargo too.
170 for _, sub := range []string{"Library/Caches", ".cache", ".npm", ".cargo", "go"} {
171 dirs = append(dirs, filepath.Join(home, sub))
172 }
173 }
174 }
175 seen := map[string]bool{}
176 out := make([]string, 0, len(dirs))
177 for _, d := range dirs {
178 if d == "" {
179 continue
180 }
181 abs, err := filepath.Abs(d)
182 if err != nil {
183 continue
184 }
185 if real, err := filepath.EvalSymlinks(abs); err == nil {
186 abs = real
187 }
188 if !seen[abs] {
189 seen[abs] = true
190 out = append(out, abs)
191 }
192 }
193 return out
194 }
195
196 // sbplString quotes a path as an SBPL string literal, escaping backslash and
197 // double-quote so a path can't break out of the profile syntax.
198 func sbplString(s string) string {
199 s = strings.ReplaceAll(s, `\`, `\\`)
200 s = strings.ReplaceAll(s, `"`, `\"`)
201 return `"` + s + `"`
202 }
203
204 // forbidReadDirs resolves forbid-read roots to absolute, symlink-free paths so
205 // Seatbelt matches the canonical on-disk location (e.g. /private/tmp for /tmp).
206 func forbidReadDirs(roots []string) []string {
207 seen := map[string]bool{}
208 out := make([]string, 0, len(roots))
209 for _, d := range roots {
210 if d == "" {
211 continue
212 }
213 abs, err := filepath.Abs(d)
214 if err != nil {
215 continue
216 }
217 if real, err := filepath.EvalSymlinks(abs); err == nil {
218 abs = real
219 }
220 if !seen[abs] {
221 seen[abs] = true
222 out = append(out, abs)
223 }
224 }
225 return out
226 }
227
227 lines GO