项目文件夹

文件
2026-07-13 13:00:08 +08:00

131 行
4.5 KiB
Go

package sandbox
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Command returns the argv to run `command` through sh, wrapped in sandbox-exec
// when the spec enforces and the tool is available. The second return is whether
// wrapping happened; false means the argv is unwrapped (sandbox off, or
// sandbox-exec missing). Callers decide whether an unwrapped command is allowed.
func Command(spec Spec, sh Shell, command string) ([]string, bool) {
if !spec.Enforce() || !Available() {
return sh.argv(command), false
}
return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, sh.argv(command)...), true
}
// CommandArgs is like Command but accepts the command as raw argv instead of a
// shell command string. The args are appended directly after the sandbox prefix
// without shell interpretation — suitable for direct binary invocations like
// ripgrep that don't need a shell wrapper.
func CommandArgs(spec Spec, args []string) ([]string, bool) {
if !spec.Enforce() || !Available() {
return args, false
}
return append([]string{"sandbox-exec", "-p", seatbeltProfile(spec)}, args...), true
}
// Available reports whether sandbox-exec is on PATH (it ships with macOS).
func Available() bool {
_, err := exec.LookPath("sandbox-exec")
return err == nil
}
// seatbeltProfile builds an SBPL profile that allows everything, then denies
// all file writes and re-allows them only under the write-roots (workspace +
// temp + caches). Network is denied unless allowed. Forbid-read roots get
// individual deny-read rules. Reads elsewhere are left open so the
// toolchain (compilers reading GOROOT, git reading ~/.gitconfig, …) keeps
// working — the boundary this draws is "can't write outside the configured
// writable roots, and optionally can't talk to the network", which is the Phase
// 0 blast-radius made to also cover arbitrary shell commands.
func seatbeltProfile(spec Spec) string {
var b strings.Builder
b.WriteString("(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n")
for _, p := range writeAllowDirs(spec.WriteRoots) {
fmt.Fprintf(&b, " (subpath %s)\n", sbplString(p))
}
b.WriteString(")\n")
// Deny reads under forbid-read roots so even a permitted shell command
// cannot peek at them through the OS sandbox. Each path gets its own deny
// rule; (allow default) above keeps reads working everywhere else.
for _, p := range forbidReadDirs(spec.ForbidReadRoots) {
fmt.Fprintf(&b, "(deny file-read* (subpath %s))\n", sbplString(p))
}
if !spec.Network {
b.WriteString("(deny network*)\n")
}
return b.String()
}
// writeAllowDirs is the deduplicated, symlink-resolved set of directories the
// sandbox permits writes to: the caller's roots plus temp dirs, /dev, and the
// common toolchain caches under $HOME. Symlinks are resolved because macOS's
// /tmp and $TMPDIR live under /private, which is the path Seatbelt matches.
func writeAllowDirs(roots []string) []string {
dirs := append([]string{}, roots...)
dirs = append(dirs, "/dev", "/tmp", "/private/tmp", "/private/var/folders", os.TempDir())
if home, err := os.UserHomeDir(); err == nil {
// go build/test → Library/Caches + go; pip/etc → .cache; npm/cargo too.
for _, sub := range []string{"Library/Caches", ".cache", ".npm", ".cargo", "go"} {
dirs = append(dirs, filepath.Join(home, sub))
}
}
seen := map[string]bool{}
out := make([]string, 0, len(dirs))
for _, d := range dirs {
if d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if !seen[abs] {
seen[abs] = true
out = append(out, abs)
}
}
return out
}
// sbplString quotes a path as an SBPL string literal, escaping backslash and
// double-quote so a path can't break out of the profile syntax.
func sbplString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
// forbidReadDirs resolves forbid-read roots to absolute, symlink-free paths so
// Seatbelt matches the canonical on-disk location (e.g. /private/tmp for /tmp).
func forbidReadDirs(roots []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(roots))
for _, d := range roots {
if d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if !seen[abs] {
seen[abs] = true
out = append(out, abs)
}
}
return out
}