Download
420 lines 15.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
package sandbox

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"
	"syscall"

	"blinker/internal/policy"
)

// macOS backend: a Seatbelt profile run with sandbox-exec. Seatbelt
// evaluates allow and deny rules over the real filesystem, the last
// matching rule winning, so the plan's mounts become one rule each, in
// order. It can also deny network connections, so a mode may connect only
// to its proxy on localhost. The floor's literal paths are rendered last
// again, so nothing can override them. As on Linux, globs are expanded
// against the filesystem when the command starts.

// darwinPlatform runs commands under sandbox-exec.
type darwinPlatform struct {
	sandboxExec string
	userTemp    string // the user's own temp dir, under /private/var/folders
}

func newDarwinPlatform() (platform, error) {
	const sandboxExec = "/usr/bin/sandbox-exec"
	if _, err := os.Stat(sandboxExec); err != nil {
		return nil, errors.New("sandbox-exec is missing")
	}
	// Asked of the system, not taken from $TMPDIR, which could name
	// anything.
	out, err := exec.Command("/usr/bin/getconf", "DARWIN_USER_TEMP_DIR").Output()
	if err != nil {
		return nil, fmt.Errorf("finding the user's temp dir: %w", err)
	}
	userTemp, err := filepath.EvalSymlinks(strings.TrimSpace(string(out)))
	if err != nil {
		return nil, fmt.Errorf("finding the user's temp dir: %w", err)
	}
	if !policy.Under(userTemp, "/private/var/folders") || userTemp == "/private/var/folders" {
		return nil, fmt.Errorf("unexpected user temp dir %q", userTemp)
	}
	return &darwinPlatform{sandboxExec: sandboxExec, userTemp: userTemp}, nil
}

func (d *darwinPlatform) Name() string { return "macOS Seatbelt" }

// ScratchRoot is the user's own temp dir, which, unlike /private/tmp, is
// not shared with other users.
func (d *darwinPlatform) ScratchRoot() (string, error) {
	if d.userTemp == "" {
		return "", errors.New("no user temp dir")
	}
	return d.userTemp, nil
}

// SystemReadPaths lists devices one by one: /dev also holds the user's
// other terminals and disks.
func (d *darwinPlatform) SystemReadPaths() []string {
	return []string{"/usr", "/bin", "/sbin", "/opt/homebrew", "/opt/local", "/System",
		"/Library", "/Applications", "/private/etc", "/private/var/db",
		"/private/var/folders",
		"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom",
		"/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/dtracehelper"}
}

// BaselineWritePaths: the user's caches are under /private/var/folders,
// beside their temp dir, which SystemTempDirs takes back out.
func (d *darwinPlatform) BaselineWritePaths() []string {
	return []string{"/private/var/folders"}
}

func (d *darwinPlatform) SystemSecrets() []string {
	return []string{
		"/private/var/db/dslocal", // password hashes
		"/private/var/db/SystemKey", "/private/var/db/sudo",
		"/Library/Keychains/System.keychain",
		"/private/etc/ssh/ssh_host_rsa_key", "/private/etc/ssh/ssh_host_ecdsa_key",
		"/private/etc/ssh/ssh_host_ed25519_key", "/private/etc/ssh/ssh_host_dsa_key",
	}
}

// UserHomes: macOS keeps its users in Directory Services, not
// /etc/passwd, which lists root's (/var/root) and the system's.
func (d *darwinPlatform) UserHomes() ([]string, error) { return userHomes(passwdFile, "/Users") }

func (d *darwinPlatform) SystemTempDirs() []string {
	dirs := []string{"/private/tmp", "/private/var/tmp"}
	if d.userTemp != "" {
		dirs = append(dirs, d.userTemp)
	}
	return dirs
}

// TempDir: Seatbelt cannot move paths, so commands see the scratch dir
// where it is, through $TMPDIR.
func (d *darwinPlatform) TempDir(scratch, project string) string { return scratch }

// PrepareScratch copies xcrun's cache from the user's own temp dir.
// Without it, the first developer tool a session runs through /usr/bin
// (git, make, cc) has xcrun run xcodebuild, which takes a second and
// complains on stderr about the services the profile denies. The cache
// holds only tools' paths. Never through a symlink, so nothing else can
// land in the scratch dir this way.
func (d *darwinPlatform) PrepareScratch(scratch string) error {
	if d.userTemp == "" {
		return nil
	}
	const maxSize = 1 << 20
	src, err := os.OpenFile(filepath.Join(d.userTemp, "xcrun_db"), os.O_RDONLY|syscall.O_NOFOLLOW, 0)
	if errors.Is(err, fs.ErrNotExist) {
		return nil // xcrun has not run yet; it will build its own
	}
	if err != nil {
		return err
	}
	defer src.Close()
	fi, err := src.Stat()
	if err != nil {
		return err
	}
	if !fi.Mode().IsRegular() || fi.Size() > maxSize {
		return fmt.Errorf("%s is not a small regular file", src.Name())
	}
	data, err := io.ReadAll(io.LimitReader(src, maxSize))
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(scratch, "xcrun_db"), data, 0o600)
}

// ListenProxy listens on a TCP port on localhost, the one place the
// profile lets the command connect to. dir is not needed.
func (d *darwinPlatform) ListenProxy(dir string) (net.Listener, error) {
	return net.Listen("tcp", "127.0.0.1:0")
}

func (d *darwinPlatform) Wrap(w wrap) (argv, env, made []string, err error) {
	if w.Preview != nil || w.Helper != "" {
		return nil, nil, nil, errors.New("previews need Linux's overlay filesystem")
	}
	port := 0
	if w.Proxy != nil {
		addr, ok := w.Proxy.(*net.TCPAddr)
		if !ok {
			return nil, nil, nil, fmt.Errorf("the proxy is not on a TCP port: %s", w.Proxy)
		}
		port = addr.Port
		env = proxyEnv(port)
	}
	profile, dirs, err := seatbeltProfile(w.Policy, w.Mode, port)
	if err != nil {
		return nil, nil, nil, err
	}
	// The missing directories the profile pins (see guard), made here, where
	// the mode could make them anyway, so a command can still write into
	// them (git init, under .git/hooks). Failing to is safe: the pin denies
	// creating the name too, by mkdir or by a rename that would bring a
	// protected path along.
	for _, p := range dirs {
		if err := os.Mkdir(p, 0o755); err == nil {
			made = append(made, p)
		}
	}
	if w.Create != "" {
		// Exactly the missing part of the path, so the command can create
		// it and nothing else.
		if err := safeSBPL(w.Create); err != nil {
			return nil, nil, made, err
		}
		profile += fmt.Sprintf("\n;; WriteFile creates %s\n(allow file-write* (subpath %s))\n", w.Create, sbplString(w.Create))
	}
	if w.Terminal != "" {
		// The command's own pty (see Cmd.Terminal), and /dev/tty, which is
		// whatever its controlling terminal is: that pty.
		if err := safeSBPL(w.Terminal); err != nil || !strings.HasPrefix(w.Terminal, "/dev/tty") {
			return nil, nil, made, fmt.Errorf("the terminal %q cannot be used in a sandbox profile", w.Terminal)
		}
		profile += fmt.Sprintf("\n;; The command's terminal\n(allow file-read* %s file-write* (literal %s) (literal \"/dev/tty\"))\n", readMetaOps, sbplString(w.Terminal))
	}
	return append([]string{d.sandboxExec, "-p", profile}, w.Argv...), env, made, nil
}

// readMetaOps are the operations that reveal a file's existence: stat and
// access(F_OK). The kernel also consults file-test-existence when name
// resolution fails, so denying both makes a missing file fail with EPERM
// like a present one, which hides what exists inside denied regions.
const readMetaOps = "file-read-metadata file-test-existence"

// sbplString quotes a path for a profile. Every path is checked by
// safeSBPL first: rule paths cannot hold a quote or backslash
// (policy.ValidatePath), but what globs match on disk, and where symlinks
// lead, can.
func sbplString(p string) string { return `"` + p + `"` }

func safeSBPL(p string) error {
	if strings.ContainsAny(p, "\"\\\n\r\x00") {
		return fmt.Errorf("path %q cannot be used in a sandbox profile", p)
	}
	return nil
}

// metadataAncestors lists the ancestor directories of the readable mounts,
// as exact paths: tools that realpath() a path component by component
// (python, PATH lookups through /opt) need to stat each ancestor of a
// place they may enter. Exact paths, not subtrees, so files in between
// stay hidden.
func metadataAncestors(paths []string) []string {
	set := map[string]bool{}
	for _, p := range paths {
		for cur := filepath.Dir(p); cur != "/" && cur != "."; cur = filepath.Dir(cur) {
			set[cur] = true
		}
	}
	var out []string
	for p := range set {
		if safeSBPL(p) == nil {
			out = append(out, p)
		}
	}
	slices.Sort(out)
	return out
}

// seatbeltProfile renders the profile for a mode, and lists the missing
// directories it pins, for Wrap to make. proxyPort is the mode's proxy on
// localhost, or 0 for no network.
func seatbeltProfile(c *policy.Compiled, mode policy.Mode, proxyPort int) (profile string, dirs []string, err error) {
	env := c.Env
	for _, p := range []string{env.Home, env.Project, env.Scratch} {
		if err := safeSBPL(p); err != nil {
			return "", nil, err
		}
	}
	mounts, err := plan(c)
	if err != nil {
		return "", nil, err
	}
	// Rules judge a rename by the names it changes, not the paths under
	// them, so a writable directory above a protected path is pinned: it
	// cannot be moved aside, or made by moving another into its place.
	// A copy: guard marks the mounts it is given.
	var pins []string
	for _, m := range guard(c, slices.Clone(mounts)) {
		if !m.pin || m.state != rw || policy.Under(m.path, env.Scratch) {
			continue // a path the mode cannot write cannot be renamed either
		}
		pins = append(pins, m.path)
		if !m.exists && c.Access(filepath.Dir(m.path)).Write {
			dirs = append(dirs, m.path)
		}
	}
	// A file named to close the string would add rules of its own, so a
	// path that cannot be quoted stops the command: leaving its rule out
	// would fail open.
	blocklist, neverWrite := c.FloorMatchers()
	paths := slices.Concat(env.SystemRead, pins)
	for _, m := range mounts {
		paths = append(paths, m.path)
	}
	for _, m := range slices.Concat(blocklist, neverWrite) {
		if !m.IsGlob() {
			paths = append(paths, m.Path)
		}
	}
	for _, p := range paths {
		if err := safeSBPL(p); err != nil {
			return "", nil, fmt.Errorf("%w; rename it, or narrow the policy's globs so they do not match it", err)
		}
	}
	var b strings.Builder
	line := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }

	line("(version 1)")
	line(";; blinker %s mode", mode)
	line(";; Everything not allowed below is denied. System services run outside")
	line(";; the sandbox, so one a command may reach does what the command could")
	line(";; not: LaunchServices (open) and Apple Events start programs unsandboxed,")
	line(";; cfprefsd reads and writes every app's preferences, and the pasteboard")
	line(";; and keychain hold the user's secrets.")
	line("(deny default)")
	line("(allow process-exec process-fork)")
	line(";; Other processes' arguments and environment (ps eww, KERN_PROCARGS2):")
	line(";; blinker's include its API key. Signals stay within the sandbox too.")
	line("(allow signal process-info* (target same-sandbox))")
	line("(allow sysctl-read)")
	line(`(deny sysctl-read (sysctl-name "kern.procargs") (sysctl-name "kern.procargs2"))`)
	line(";; On files the rules below let the command open.")
	line("(allow file-ioctl)")
	line(";; Named semaphores, for Python's multiprocessing.")
	line("(allow ipc-posix-sem)")
	line(";; User and group lookups, notifications, logging, and certificate checks")
	line(";; (crypto/x509 on macOS asks trustd).")
	line("(allow mach-lookup")
	line(`  (global-name "com.apple.system.opendirectoryd.libinfo")`)
	line(`  (global-name "com.apple.system.notification_center")`)
	line(`  (global-name "com.apple.system.logger")`)
	line(`  (global-name "com.apple.logd")`)
	line(`  (global-name "com.apple.trustd")`)
	line(`  (global-name "com.apple.trustd.agent")`)
	line(")")

	line("\n;; Reads: denied by default, existence included.")
	line("(deny file-read*)")
	line("(deny %s)", readMetaOps)
	line(";; fstat() on inherited pipes.")
	line("(allow %s (vnode-type FIFO))", readMetaOps)
	line(";; The system config surface: existence checks see both the symlinked")
	line(";; paths and /private (mkstemp under $TMPDIR needs it). The floor takes")
	line(";; the system's temp dirs back out.")
	line(`(allow %s (subpath "/var") (subpath "/tmp") (subpath "/etc") (subpath "/private"))`, readMetaOps)

	readable := []string{env.Project, env.Scratch}
	readable = append(readable, env.SystemRead...)
	for _, m := range mounts {
		if m.state != none {
			readable = append(readable, m.path)
		}
	}
	if anc := metadataAncestors(readable); len(anc) > 0 {
		line("(allow %s", readMetaOps)
		for _, p := range anc {
			line("  (literal %s)", sbplString(p))
		}
		line(")")
	}
	line("(allow file-read* %s", readMetaOps)
	line(`  (literal "/") ; processes read "/" at startup`)
	line("  (subpath %s)", sbplString(env.Scratch))
	for _, p := range env.SystemRead {
		line("  (subpath %s)", sbplString(p))
	}
	line(")")

	line("\n;; Writes: the scratch dir, caches and terminal devices.")
	line("(deny file-write*)")
	line("(allow file-write*")
	line("  (subpath %s)", sbplString(env.Scratch))
	for _, p := range env.BaselineWrite {
		line("  (subpath %s)", sbplString(p))
	}
	// Not /dev/tty: commands have no controlling terminal unless they have a
	// pty of their own (see Run and Wrap).
	for _, p := range []string{"/dev/null", "/dev/zero", "/dev/stdout", "/dev/stderr", "/dev/dtracehelper"} {
		line("  (literal %s)", sbplString(p))
	}
	line(")")

	line("\n;; The policy, ancestors first; the last matching rule wins.")
	for _, m := range mounts {
		f := fmt.Sprintf("(subpath %s)", sbplString(m.path))
		switch m.state {
		case none:
			line("(deny file-read* %s file-write* %s)", readMetaOps, f)
		case ro:
			line("(allow file-read* %s %s)", readMetaOps, f)
			line("(deny file-write* %s)", f)
		case rw:
			line("(allow file-read* %s file-write* %s)", readMetaOps, f)
		}
	}
	if len(pins) > 0 {
		line(";; Directories above protected paths: written into, never renamed.")
		line("(deny file-write*")
		for _, p := range pins {
			line("  (literal %s)", sbplString(p))
		}
		line(")")
	}

	if links := shownLinks(c); len(links) > 0 {
		line(";; The links the rules followed, and their ancestors: metadata only,")
		line(";; as their targets' rules decide the rest.")
		line("(allow %s", readMetaOps)
		for _, p := range append(metadataAncestors(links), links...) {
			if safeSBPL(p) == nil {
				line("  (literal %s)", sbplString(p))
			}
		}
		line(")")
	}

	line("\n;; Network: only the mode's proxy. DNS stays denied: CONNECT needs no")
	line(";; local resolution, and open DNS would be a way to leak data.")
	line("(deny network*)")
	if proxyPort > 0 {
		line(`(allow network-outbound (remote ip "localhost:%d"))`, proxyPort)
	}

	line("\n;; The floor, last so no rule overrides it.")
	for _, m := range neverWrite {
		if !m.IsGlob() {
			line("(deny file-write* (subpath %s))", sbplString(m.Path))
		}
	}
	for _, m := range blocklist {
		if !m.IsGlob() {
			line("(deny file-read* %s file-write* (subpath %s))", readMetaOps, sbplString(m.Path))
		}
	}
	line("\n;; The session's temp dir, open whatever else says, even inside a")
	line(";; system temp dir.")
	line("(allow file-read* %s file-write* (subpath %s))", readMetaOps, sbplString(env.Scratch))
	line(";; Its ancestors' metadata only, as mkdir -p stats each: listing the")
	line(";; system temp dir needs file-read-data, which stays denied.")
	line("(allow %s", readMetaOps)
	for _, p := range metadataAncestors([]string{env.Scratch}) {
		line("  (literal %s)", sbplString(p))
	}
	line(")")
	return b.String(), dirs, nil
}