返回 DeepSeek-Reasonix
seatbelt_darwin.go
根目录 / internal / sandbox / seatbelt_darwin.go
1 package sandbox
2
3 import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 )
10
11 // Command returns the argv to run `command` through sh, wrapped in sandbox-exec
12 // when the spec enforces and the tool is available. The second return is whether
13 // wrapping happened; false means the argv is unwrapped (sandbox off, or
14 // sandbox-exec missing). Callers decide whether an unwrapped command is allowed.
15 func Command(spec Spec, sh Shell, command string) ([]string, bool) {
16 if !spec.Enforce() || !Available() {
17 return sh.argv(command), false
18 }
19 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, sh.argv(command)...), true
20 }
21
22 // CommandArgs is like Command but accepts the command as raw argv instead of a
23 // shell command string. The args are appended directly after the sandbox prefix
24 // without shell interpretation — suitable for direct binary invocations like
25 // ripgrep that don't need a shell wrapper.
26 func CommandArgs(spec Spec, args []string) ([]string, bool) {
27 if !spec.Enforce() || !Available() {
28 return args, false
29 }
30 return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, args...), true
31 }
32
33 // Available reports whether sandbox-exec is on PATH (it ships with macOS).
34 func Available() bool {
35 _, err := exec.LookPath("sandbox-exec")
36 return err == nil
37 }
38
39 // seatbeltProfile builds an SBPL profile that allows everything, then denies
40 // all file writes and re-allows them only under the write-roots (workspace +
41 // temp + caches). Network is denied unless allowed. Forbid-read roots get
42 // individual deny-read rules. Reads elsewhere are left open so the
43 // toolchain (compilers reading GOROOT, git reading ~/.gitconfig, …) keeps
44 // working — the boundary this draws is "can't write outside the configured
45 // writable roots, and optionally can't talk to the network", which is the Phase
46 // 0 blast-radius made to also cover arbitrary shell commands.
47 func seatbeltProfile(spec Spec) string {
48 var b strings.Builder
49 b.WriteString("(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n")
50 for _, p := range writeAllowDirsForSpec(spec) {
51 fmt.Fprintf(&b, " (subpath %s)\n", sbplString(p))
52 }
53 b.WriteString(")\n")
54 // Deny reads under forbid-read roots so even a permitted shell command
55 // cannot peek at them through the OS sandbox. Each path gets its own deny
56 // rule; (allow default) above keeps reads working everywhere else.
57 for _, p := range forbidReadDirs(spec.ForbidReadRoots) {
58 fmt.Fprintf(&b, "(deny file-read* (subpath %s))\n", sbplString(p))
59 }
60 if !spec.Network {
61 b.WriteString("(deny network*)\n")
62 }
63 return b.String()
64 }
65
66 // writeAllowDirs is the deduplicated, symlink-resolved set of directories the
67 // sandbox permits writes to: the caller's roots plus temp dirs, /dev, and the
68 // common toolchain caches under $HOME. Symlinks are resolved because macOS's
69 // /tmp and $TMPDIR live under /private, which is the path Seatbelt matches.
70 func writeAllowDirs(roots []string) []string {
71 return writeAllowDirsForSpec(Spec{WriteRoots: roots})
72 }
73
74 func writeAllowDirsForSpec(spec Spec) []string {
75 roots := spec.WriteRoots
76 dirs := append([]string{}, roots...)
77 dirs = append(dirs, "/dev")
78 if dir := strings.TrimSpace(spec.SessionTemp); dir != "" {
79 // Session-private temporary directory must be writable under Seatbelt
80 // even when MinimalWrites omits the broad host temp allowances.
81 dirs = append(dirs, dir)
82 }
83 if !spec.MinimalWrites {
84 dirs = append(dirs, "/tmp", "/private/tmp", "/private/var/folders", os.TempDir())
85 }
86 if !spec.MinimalWrites {
87 if home, err := os.UserHomeDir(); err == nil {
88 // go build/test → Library/Caches + go; pip/etc → .cache; npm/cargo too.
89 for _, sub := range []string{"Library/Caches", ".cache", ".npm", ".cargo", "go"} {
90 dirs = append(dirs, filepath.Join(home, sub))
91 }
92 }
93 }
94 seen := map[string]bool{}
95 out := make([]string, 0, len(dirs))
96 for _, d := range dirs {
97 if d == "" {
98 continue
99 }
100 abs, err := filepath.Abs(d)
101 if err != nil {
102 continue
103 }
104 if real, err := filepath.EvalSymlinks(abs); err == nil {
105 abs = real
106 }
107 if !seen[abs] {
108 seen[abs] = true
109 out = append(out, abs)
110 }
111 }
112 return out
113 }
114
115 // sbplString quotes a path as an SBPL string literal, escaping backslash and
116 // double-quote so a path can't break out of the profile syntax.
117 func sbplString(s string) string {
118 s = strings.ReplaceAll(s, `\`, `\\`)
119 s = strings.ReplaceAll(s, `"`, `\"`)
120 return `"` + s + `"`
121 }
122
123 // forbidReadDirs resolves forbid-read roots to absolute, symlink-free paths so
124 // Seatbelt matches the canonical on-disk location (e.g. /private/tmp for /tmp).
125 func forbidReadDirs(roots []string) []string {
126 seen := map[string]bool{}
127 out := make([]string, 0, len(roots))
128 for _, d := range roots {
129 if d == "" {
130 continue
131 }
132 abs, err := filepath.Abs(d)
133 if err != nil {
134 continue
135 }
136 if real, err := filepath.EvalSymlinks(abs); err == nil {
137 abs = real
138 }
139 if !seen[abs] {
140 seen[abs] = true
141 out = append(out, abs)
142 }
143 }
144 return out
145 }
146
146 lines GO