返回 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 "slices"
10 "strings"
11 "sync"
12 "time"
13 )
14
15 // Command returns the argv to run `command` through sh, wrapped in sandbox-exec
16 // when the spec enforces and the tool is available. The second return is whether
17 // wrapping happened; false means the argv is unwrapped (sandbox off, or
18 // sandbox-exec missing). Callers decide whether an unwrapped command is allowed.
19 func Command(spec Spec, sh Shell, command string) ([]string, bool) {
20 if !spec.Enforce() || !Available() {
21 return sh.argv(command), false
22 }
23 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, sh.argv(command)...), true
24 }
25
26 // CommandArgs is like Command but accepts the command as raw argv instead of a
27 // shell command string. The args are appended directly after the sandbox prefix
28 // without shell interpretation — suitable for direct binary invocations like
29 // ripgrep that don't need a shell wrapper.
30 func CommandArgs(spec Spec, args []string) ([]string, bool) {
31 if !spec.Enforce() || !Available() {
32 return args, false
33 }
34 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, args...), true
35 }
36
37 // sandboxExecUsability caches the probe result per resolved binary path, so
38 // repeated Available() calls stay O(1) after the first check.
39 var sandboxExecUsability sync.Map // resolved executable path -> bool
40
41 const (
42 sandboxExecProbeTimeout = 10 * time.Second
43 sandboxExecProbeCommand = "/usr/bin/true"
44 )
45
46 // usableSandboxExec distinguishes an installed sandbox-exec from a usable
47 // Seatbelt backend. On restricted macOS hosts, sandbox-exec can be on PATH
48 // while sandbox_apply fails with exit 71. Probe that operation directly with a
49 // minimal profile, mirroring usableBwrap on Linux.
50 func usableSandboxExec() bool {
51 path, err := exec.LookPath("sandbox-exec")
52 if err != nil {
53 return false
54 }
55 return usableSandboxExecPath(path)
56 }
57
58 func usableSandboxExecPath(path string) bool {
59 if path == "" {
60 return false
61 }
62 if cached, ok := sandboxExecUsability.Load(path); ok {
63 return cached.(bool)
64 }
65 ctx, cancel := context.WithTimeout(context.Background(), sandboxExecProbeTimeout)
66 defer cancel()
67 err := exec.CommandContext(ctx, path, "-p", "(version 1)(allow default)", sandboxExecProbeCommand).Run()
68 // A slow host should not permanently poison the process-local cache with a
69 // transient timeout. Definitive probe failures (including exit 71) remain
70 // cached so every command does not pay the failed probe cost.
71 if ctx.Err() != nil {
72 return false
73 }
74 usable := err == nil
75 actual, _ := sandboxExecUsability.LoadOrStore(path, usable)
76 return actual.(bool)
77 }
78
79 // Available reports whether the OS sandbox backend can actually confine
80 // processes. macOS probes sandbox-exec; Linux verifies bubblewrap can enter its
81 // namespace (see seatbelt_other.go).
82 func Available() bool {
83 return usableSandboxExec()
84 }
85
86 // seatbeltProfile builds an SBPL profile that allows everything, then denies
87 // all file writes and re-allows them only under the write-roots (workspace +
88 // temp + caches). Network is denied unless allowed. Forbid-read roots get
89 // individual deny-read rules. Reads elsewhere are left open so the
90 // toolchain (compilers reading GOROOT, git reading ~/.gitconfig, …) keeps
91 // working — the boundary this draws is "can't write outside the configured
92 // writable roots, and optionally can't talk to the network", which is the Phase
93 // 0 blast-radius made to also cover arbitrary shell commands.
94 func seatbeltProfile(spec Spec) string {
95 var b strings.Builder
96 b.WriteString("(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n")
97 for _, p := range writeAllowDirsForSpec(spec) {
98 fmt.Fprintf(&b, " (subpath %s)\n", sbplString(p))
99 }
100 b.WriteString(")\n")
101 // Deny reads under forbid-read roots so even a permitted shell command
102 // cannot peek at them through the OS sandbox. Each path gets its own deny
103 // rule; (allow default) above keeps reads working everywhere else.
104 for _, p := range forbidReadDirs(spec.ForbidReadRoots) {
105 fmt.Fprintf(&b, "(deny file-read* (subpath %s))\n", sbplString(p))
106 }
107 if !spec.Network {
108 b.WriteString("(deny network*)\n")
109 }
110 for _, p := range forbidWriteDirs(spec.ProtectedWriteRoots) {
111 fmt.Fprintf(&b, "(deny file-write* (subpath %s))\n", sbplString(p))
112 }
113 for _, p := range explicitProtectedAllowDirs(spec) {
114 fmt.Fprintf(&b, "(allow file-write* (subpath %s))\n", sbplString(p))
115 }
116 // Last write rules: SBPL takes the final match, so no allowance re-opens them.
117 writeGitMetadataRules(&b, gitMetadataForSpec(spec))
118 return b.String()
119 }
120
121 // writeGitMetadataRules denies writes to protected Git metadata. A pin denies
122 // only removing or renaming the entry and planting a symlink there, so the
123 // entry's own mode and times stay writable. Worktree and submodule gitdirs
124 // that exist get exact rules up to a limit; patterns cover the rest and any
125 // created later, so the profile stays bounded however many a repository holds.
126 func writeGitMetadataRules(b *strings.Builder, meta gitMetadata) {
127 paths := meta.Paths
128 var overWorktrees []string
129 for _, common := range meta.Commons {
130 g := gitGroupsOf(common, gitGroupMaxEntries)
131 paths = append(paths, g.Paths...)
132 if g.WorktreesOver {
133 overWorktrees = append(overWorktrees, common)
134 }
135 }
136 for _, p := range paths {
137 switch {
138 case p.Pin:
139 fmt.Fprintf(b, "(deny file-write-unlink (literal %s))\n", sbplString(p.Path))
140 fmt.Fprintf(b, "(deny file-write-create (require-all (literal %s) (vnode-type SYMLINK)))\n", sbplString(p.Path))
141 case p.Tree:
142 fmt.Fprintf(b, "(deny file-write* (subpath %s))\n", sbplString(p.Path))
143 default:
144 fmt.Fprintf(b, "(deny file-write* (literal %s))\n", sbplString(p.Path))
145 }
146 }
147 for _, common := range meta.Commons {
148 c := sbplRegexQuote(common)
149 modules := "^" + c + "/modules/(" + gitGroupSegment + "/)*"
150 patterns := []string{
151 "^" + c + "/worktrees/[^/]+/(config|config[.]worktree)$",
152 modules + "(config|config[.]worktree|commondir)$",
153 modules + "hooks(/.*)?$",
154 }
155 if slices.Contains(overWorktrees, common) {
156 patterns = append(patterns, "^"+c+"/worktrees/[^/]+/commondir$")
157 }
158 for _, re := range patterns {
159 fmt.Fprintf(b, "(deny file-write* (regex %s))\n", sbplString(re))
160 }
161 fmt.Fprintf(b, "(deny file-write-create (require-all (regex %s) (vnode-type SYMLINK)))\n", sbplString("^"+c+"/(modules|worktrees)/"))
162 }
163 }
164
165 // gitGroupSegment matches one path segment other than refs and logs, so the
166 // submodule patterns do not catch a branch, tag or reflog named config or hooks.
167 const gitGroupSegment = `([^/rl][^/]*|r|re|ref|r[^/e][^/]*|re[^/f][^/]*|ref[^/s][^/]*|refs[^/]+|l|lo|log|l[^/o][^/]*|lo[^/g][^/]*|log[^/s][^/]*|logs[^/]+)`
168
169 // sbplRegexQuote escapes a path for a Seatbelt regex, whose metacharacters a
170 // directory name may legally contain.
171 func sbplRegexQuote(path string) string {
172 var b strings.Builder
173 for _, r := range path {
174 if strings.ContainsRune(`\.+*?()|[]{}^$`, r) {
175 b.WriteByte('\\')
176 }
177 b.WriteRune(r)
178 }
179 return b.String()
180 }
181
182 func writableDirsForSpec(spec Spec) []string { return writeAllowDirsForSpec(spec) }
183
184 func forbidWriteDirs(roots []string) []string {
185 return forbidReadDirs(roots)
186 }
187
188 func explicitProtectedAllowDirs(spec Spec) []string {
189 protected := forbidWriteDirs(spec.ProtectedWriteRoots)
190 if len(protected) == 0 {
191 return nil
192 }
193 stateRoot := singleProtectedStateRoot(protected)
194 var out []string
195 for _, root := range writeAllowDirsForSpec(spec) {
196 if stateRoot != "" && IsProtectedWritePath(root, stateRoot) {
197 continue
198 }
199 for _, prot := range protected {
200 if root != prot && PathWithin(prot, root) {
201 out = append(out, root)
202 break
203 }
204 }
205 }
206 return out
207 }
208
209 // writeAllowDirs is the deduplicated, symlink-resolved set of directories the
210 // sandbox permits writes to: the caller's roots plus temp dirs, /dev, and the
211 // common toolchain caches under $HOME. Symlinks are resolved because macOS's
212 // /tmp and $TMPDIR live under /private, which is the path Seatbelt matches.
213 func writeAllowDirs(roots []string) []string {
214 return writeAllowDirsForSpec(Spec{WriteRoots: roots})
215 }
216
217 func writeAllowDirsForSpec(spec Spec) []string {
218 if spec.ReadOnly {
219 // Preserve device compatibility without granting host file writes.
220 return []string{"/dev/null"}
221 }
222 return darwinWritePlan(spec).dirs
223 }
224
225 func gitMetadataRoots(spec Spec) []string {
226 if spec.ReadOnly {
227 return nil
228 }
229 return darwinWritePlan(spec).callers
230 }
231
232 // darwinWritePlan resolves the caller's roots and the directories Seatbelt
233 // allows beside them; the session temp stays writable under MinimalWrites.
234 func darwinWritePlan(spec Spec) writeRootPlan {
235 extras := []string{"/dev", spec.SessionTemp}
236 if !spec.MinimalWrites {
237 extras = append(extras, hostWriteDirs()...)
238 }
239 return planWriteRoots(spec.WriteRoots, extras, spec.SessionTemp)
240 }
241
242 // hostWriteDirs are the temp and toolchain cache directories a non-minimal
243 // launch may write: go build/test use Library/Caches and go, pip and others
244 // .cache, and npm and cargo their own.
245 func hostWriteDirs() []string {
246 dirs := []string{"/tmp", "/private/tmp", "/private/var/folders", os.TempDir()}
247 if home, err := os.UserHomeDir(); err == nil {
248 for _, sub := range []string{"Library/Caches", ".cache", ".npm", ".cargo", "go"} {
249 dirs = append(dirs, filepath.Join(home, sub))
250 }
251 }
252 return dirs
253 }
254
255 // sbplString quotes a path as an SBPL string literal, escaping backslash and
256 // double-quote so a path can't break out of the profile syntax.
257 func sbplString(s string) string {
258 s = strings.ReplaceAll(s, `\`, `\\`)
259 s = strings.ReplaceAll(s, `"`, `\"`)
260 return `"` + s + `"`
261 }
262
263 // forbidReadDirs resolves forbid-read roots to absolute, symlink-free paths so
264 // Seatbelt matches the canonical on-disk location (e.g. /private/tmp for /tmp).
265 func forbidReadDirs(roots []string) []string {
266 seen := map[string]bool{}
267 out := make([]string, 0, len(roots))
268 for _, d := range roots {
269 if d == "" {
270 continue
271 }
272 abs, err := filepath.Abs(d)
273 if err != nil {
274 continue
275 }
276 if real, err := filepath.EvalSymlinks(abs); err == nil {
277 abs = real
278 }
279 if !seen[abs] {
280 seen[abs] = true
281 out = append(out, abs)
282 }
283 }
284 return out
285 }
286
287 // HostWritableDirs lists the host directories any jailed command may write
288 // besides its write roots: temporary directories and toolchain caches.
289 func HostWritableDirs() []string { return writeAllowDirsForSpec(Spec{}) }
290
290 lines GO