This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/macos.go
Mathieu Virbel 58626c64e5 feat: add --http-proxy flag for configurable HTTP CONNECT proxy
Add network.httpProxyUrl config field and --http-proxy CLI flag
(default: http://localhost:42051) for apps that only understand
HTTP proxies (opencode, Node.js tools, etc.).

macOS daemon mode now sets:
- ALL_PROXY=socks5h:// for SOCKS5-aware apps (curl, git)
- HTTP_PROXY/HTTPS_PROXY=http:// for HTTP-proxy-aware apps

Credentials from the SOCKS5 proxy URL are automatically injected
into the HTTP proxy URL when not explicitly configured.
2026-03-04 12:47:57 -06:00

805 lines
27 KiB
Go

package sandbox
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"gitea.app.monadical.io/monadical/greywall/internal/config"
)
// sessionSuffix is a unique identifier for this process session.
var sessionSuffix = generateSessionSuffix()
func generateSessionSuffix() string {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
panic("failed to generate session suffix: " + err.Error())
}
return "_" + hex.EncodeToString(bytes)[:9] + "_SBX"
}
// MacOSSandboxParams contains parameters for macOS sandbox wrapping.
type MacOSSandboxParams struct {
Command string
NeedsNetworkRestriction bool
ProxyURL string // External proxy URL (for env vars)
ProxyHost string // Proxy host (for sandbox profile network rules)
ProxyPort string // Proxy port (for sandbox profile network rules)
AllowUnixSockets []string
AllowAllUnixSockets bool
AllowLocalBinding bool
AllowLocalOutbound bool
DefaultDenyRead bool
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
ReadAllowPaths []string
ReadDenyPaths []string
WriteAllowPaths []string
WriteDenyPaths []string
AllowPty bool
AllowGitConfig bool
Shell string
DaemonMode bool // When true, pf handles network routing; Seatbelt allows network-outbound
DaemonSocketPath string // Daemon socket to deny access to from sandboxed process
}
// GlobToRegex converts a glob pattern to a regex for macOS sandbox profiles.
func GlobToRegex(glob string) string {
result := "^"
// Escape regex special characters (except glob chars)
escaped := regexp.QuoteMeta(glob)
// Restore glob patterns and convert them
// Order matters: ** before *
escaped = strings.ReplaceAll(escaped, `\*\*/`, "(.*/)?")
escaped = strings.ReplaceAll(escaped, `\*\*`, ".*")
escaped = strings.ReplaceAll(escaped, `\*`, "[^/]*")
escaped = strings.ReplaceAll(escaped, `\?`, "[^/]")
result += escaped + "$"
return result
}
// escapePath escapes a path for sandbox profile using JSON encoding.
func escapePath(path string) string {
// Use Go's string quoting which handles escaping
return fmt.Sprintf("%q", path)
}
// getAncestorDirectories returns all ancestor directories of a path.
func getAncestorDirectories(pathStr string) []string {
var ancestors []string
current := filepath.Dir(pathStr)
for current != "/" && current != "." {
ancestors = append(ancestors, current)
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return ancestors
}
// expandMacOSTmpPaths mirrors /tmp paths to /private/tmp equivalents and vice versa.
// On macOS, /tmp is a symlink to /private/tmp, and symlink resolution can fail if paths
// don't exist yet. Adding both variants ensures sandbox rules match kernel-resolved paths.
func expandMacOSTmpPaths(paths []string) []string {
seen := make(map[string]bool)
for _, p := range paths {
seen[p] = true
}
var additions []string
for _, p := range paths {
var mirror string
switch {
case p == "/tmp":
mirror = "/private/tmp"
case p == "/private/tmp":
mirror = "/tmp"
case strings.HasPrefix(p, "/tmp/"):
mirror = "/private" + p
case strings.HasPrefix(p, "/private/tmp/"):
mirror = strings.TrimPrefix(p, "/private")
}
if mirror != "" && !seen[mirror] {
seen[mirror] = true
additions = append(additions, mirror)
}
}
return append(paths, additions...)
}
// getTmpdirParent gets the TMPDIR parent if it matches macOS pattern.
func getTmpdirParent() []string {
tmpdir := os.Getenv("TMPDIR")
if tmpdir == "" {
return nil
}
// Match /var/folders/XX/YYY/T/
pattern := regexp.MustCompile(`^/(private/)?var/folders/[^/]{2}/[^/]+/T/?$`)
if !pattern.MatchString(tmpdir) {
return nil
}
parent := strings.TrimSuffix(tmpdir, "/")
parent = strings.TrimSuffix(parent, "/T")
// Return both /var/ and /private/var/ versions
if strings.HasPrefix(parent, "/private/var/") {
return []string{parent, strings.Replace(parent, "/private", "", 1)}
} else if strings.HasPrefix(parent, "/var/") {
return []string{parent, "/private" + parent}
}
return []string{parent}
}
// generateReadRules generates filesystem read rules for the sandbox profile.
func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, logTag string) []string {
var rules []string
if defaultDenyRead {
// When defaultDenyRead is enabled:
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
// 2. Allow file-read-data only for system paths + CWD + user-specified allowRead paths
// This lets programs see what files exist but not read their contents.
// Allow metadata operations globally (stat, readdir, etc.) and root dir (for path resolution)
rules = append(rules, "(allow file-read-metadata)")
rules = append(rules, `(allow file-read-data (literal "/"))`)
// Allow reading data from essential system paths
for _, systemPath := range GetDefaultReadablePaths() {
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(systemPath)),
)
}
// Allow reading CWD (full recursive read access)
if cwd != "" {
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(cwd)),
)
// Allow ancestor directory traversal (literal only, so programs can resolve CWD path)
for _, ancestor := range getAncestorDirectories(cwd) {
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(ancestor)),
)
}
}
// Allow home shell configs and tool caches (read-only)
home, _ := os.UserHomeDir()
if home != "" {
// Shell config files (literal access)
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
for _, f := range shellConfigs {
p := filepath.Join(home, f)
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)),
)
}
// Home tool caches (subpath access for package managers/configs)
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config", ".nvm", ".pyenv", ".rbenv", ".asdf"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(p)),
)
}
}
// Allow reading data from user-specified paths
for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (regex %s))", escapePath(regex)),
)
} else {
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(normalized)),
)
}
}
// Deny sensitive files within CWD (Seatbelt evaluates deny before allow)
if cwd != "" {
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (literal %s)", escapePath(p)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Also deny .env.* pattern via regex
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")),
fmt.Sprintf(" (with message %q))", logTag),
)
}
} else {
// Allow all reads by default
rules = append(rules, "(allow file-read*)")
}
// In both modes, deny specific paths (denyRead takes precedence).
// Note: We use file-read* (not file-read-data) so denied paths are fully hidden.
// In defaultDenyRead mode, this overrides the global file-read-metadata allow,
// meaning denied paths can't even be listed or stat'd - more restrictive than
// default mode where denied paths are still visible but unreadable.
for _, pathPattern := range denyPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Block file movement to prevent bypass
rules = append(rules, generateMoveBlockingRules(denyPaths, logTag)...)
return rules
}
// generateWriteRules generates filesystem write rules for the sandbox profile.
// When cwd is non-empty, it is automatically included in the write allow paths.
func generateWriteRules(cwd string, allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
var rules []string
// Auto-allow CWD for writes (project directory should be writable)
if cwd != "" {
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(cwd)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Allow TMPDIR parent on macOS
for _, tmpdirParent := range getTmpdirParent() {
normalized := NormalizePath(tmpdirParent)
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Generate allow rules
for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Combine user-specified and mandatory deny patterns
mandatoryCwd := cwd
if mandatoryCwd == "" {
mandatoryCwd, _ = os.Getwd()
}
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
allDenyPaths = append(allDenyPaths, denyPaths...)
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
for _, pathPattern := range allDenyPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-write*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(deny file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Block file movement
rules = append(rules, generateMoveBlockingRules(allDenyPaths, logTag)...)
return rules
}
// generateMoveBlockingRules generates rules to prevent file movement bypasses.
func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
var rules []string
for _, pathPattern := range pathPatterns {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
// For globs, extract static prefix and block ancestor moves
staticPrefix := strings.Split(normalized, "*")[0]
if staticPrefix != "" && staticPrefix != "/" {
baseDir := staticPrefix
if strings.HasSuffix(baseDir, "/") {
baseDir = baseDir[:len(baseDir)-1]
} else {
baseDir = filepath.Dir(staticPrefix)
}
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(baseDir)),
fmt.Sprintf(" (with message %q))", logTag),
)
for _, ancestor := range getAncestorDirectories(baseDir) {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
} else {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
for _, ancestor := range getAncestorDirectories(normalized) {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
}
return rules
}
// GenerateSandboxProfile generates a complete macOS sandbox profile.
func GenerateSandboxProfile(params MacOSSandboxParams) string {
logTag := "CMD64_" + EncodeSandboxedCommand(params.Command) + "_END" + sessionSuffix
var profile strings.Builder
// Header
profile.WriteString("(version 1)\n")
fmt.Fprintf(&profile, "(deny default (with message %q))\n\n", logTag)
fmt.Fprintf(&profile, "; LogTag: %s\n\n", logTag)
// Essential permissions - based on Chrome sandbox policy
profile.WriteString(`; Essential permissions - based on Chrome sandbox policy
; Process permissions
(allow process-exec)
(allow process-fork)
(allow process-info* (target same-sandbox))
(allow signal (target same-sandbox))
(allow mach-priv-task-port (target same-sandbox))
; User preferences
(allow user-preference-read)
; Mach IPC - specific services only
(allow mach-lookup
(global-name "com.apple.audio.systemsoundserver")
(global-name "com.apple.distributed_notifications@Uv3")
(global-name "com.apple.FontObjectsServer")
(global-name "com.apple.fonts")
(global-name "com.apple.logd")
(global-name "com.apple.lsd.mapdb")
(global-name "com.apple.PowerManagement.control")
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent")
`)
// macOS DNS resolution goes through mDNSResponder via Mach IPC — blocking
// opendirectoryd.libinfo or configd does NOT cause a fallback to direct UDP
// DNS. getaddrinfo() simply fails with EAI_NONAME. So we must allow these
// services in all modes. In daemon mode, DNS for proxy-aware apps (curl, git)
// is handled via ALL_PROXY=socks5h:// env var instead.
profile.WriteString(` (global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
(global-name "com.apple.coreservices.launchservicesd")
(global-name "com.apple.FSEvents")
(global-name "com.apple.fseventsd")
(global-name "com.apple.SystemConfiguration.configd")
)
; POSIX IPC
(allow ipc-posix-shm)
(allow ipc-posix-sem)
; IOKit
(allow iokit-open
(iokit-registry-entry-class "IOSurfaceRootUserClient")
(iokit-registry-entry-class "RootDomainUserClient")
(iokit-user-client-class "IOSurfaceSendRight")
)
(allow iokit-get-properties)
; System socket for network info
(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))
; sysctl reads
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.argmax")
(sysctl-name "kern.bootargs")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfiles")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.ngroups")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.tcsm_available")
(sysctl-name "kern.tcsm_enable")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "kern.willshutdown")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "machdep.ptrauth_enabled")
(sysctl-name "security.mac.lockdown_mode_state")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.optional.arm")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.all")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "machdep.cpu.")
(sysctl-name-prefix "net.routetable.")
)
; V8 thread calculations
(allow sysctl-write
(sysctl-name "kern.tcsm_enable")
)
; Distributed notifications
(allow distributed-notification-post)
; Security server
(allow mach-lookup (global-name "com.apple.SecurityServer"))
; Device I/O
(allow file-ioctl (literal "/dev/null"))
(allow file-ioctl (literal "/dev/zero"))
(allow file-ioctl (literal "/dev/random"))
(allow file-ioctl (literal "/dev/urandom"))
(allow file-ioctl (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/tty"))
(allow file-ioctl (regex #"^/dev/ttys"))
(allow file-ioctl file-read-data file-write-data
(require-all
(literal "/dev/null")
(vnode-type CHARACTER-DEVICE)
)
)
; Inherited terminal access (TUI apps need read/write on the actual PTY device)
(allow file-read-data file-write-data (regex #"^/dev/ttys"))
`)
// Network rules
profile.WriteString("; Network\n")
switch {
case params.DaemonMode:
// In daemon mode, pf handles network routing: all traffic from the
// _greywall user is routed through utun → tun2socks → proxy.
// Seatbelt must allow network-outbound so packets reach pf.
// The proxy allowlist is enforced by the external SOCKS5 proxy.
profile.WriteString("(allow network-outbound)\n")
// Allow local binding for servers if configured.
if params.AllowLocalBinding {
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
(allow network-inbound (local ip "localhost:*"))
`)
}
// Explicitly deny access to the daemon socket to prevent the
// sandboxed process from manipulating daemon sessions.
if params.DaemonSocketPath != "" {
fmt.Fprintf(&profile, "(deny network-outbound (remote unix-socket (path-literal %s)))\n", escapePath(params.DaemonSocketPath))
}
case !params.NeedsNetworkRestriction:
profile.WriteString("(allow network*)\n")
default:
if params.AllowLocalBinding {
// Allow binding and inbound connections on localhost (for servers)
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
(allow network-inbound (local ip "localhost:*"))
`)
// Process can make outbound connections to localhost
if params.AllowLocalOutbound {
profile.WriteString(`(allow network-outbound (local ip "localhost:*"))
`)
}
}
if params.AllowAllUnixSockets {
profile.WriteString("(allow network* (subpath \"/\"))\n")
} else if len(params.AllowUnixSockets) > 0 {
for _, socketPath := range params.AllowUnixSockets {
normalized := NormalizePath(socketPath)
fmt.Fprintf(&profile, "(allow network* (subpath %s))\n", escapePath(normalized))
}
}
// Allow outbound to the external proxy host:port
if params.ProxyHost != "" && params.ProxyPort != "" {
fmt.Fprintf(&profile, "(allow network-outbound (remote ip \"%s:%s\"))\n", params.ProxyHost, params.ProxyPort)
}
}
profile.WriteString("\n")
// Read rules
profile.WriteString("; File read\n")
for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
profile.WriteString(rule + "\n")
}
profile.WriteString("\n")
// Write rules
profile.WriteString("; File write\n")
for _, rule := range generateWriteRules(params.Cwd, params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
profile.WriteString(rule + "\n")
}
// PTY allocation support (creating new pseudo-terminals)
if params.AllowPty {
profile.WriteString(`
; Pseudo-terminal allocation (pty) support
(allow pseudo-tty)
(allow file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* (literal "/dev/ptmx"))
`)
}
return profile.String()
}
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
// When daemonSession is non-nil, the command runs as the _greywall user
// with network-outbound allowed (pf routes traffic through utun → proxy).
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, daemonSession *DaemonSession, debug bool) (string, error) {
cwd, _ := os.Getwd()
// Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
// Expand /tmp <-> /private/tmp for macOS symlink compatibility
allowPaths = expandMacOSTmpPaths(allowPaths)
// Enable local binding if ports are exposed or if explicitly configured
allowLocalBinding := cfg.Network.AllowLocalBinding || len(exposedPorts) > 0
allowLocalOutbound := allowLocalBinding
if cfg.Network.AllowLocalOutbound != nil {
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
}
// Parse proxy URL for network rules
var proxyHost, proxyPort string
if cfg.Network.ProxyURL != "" {
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
proxyHost = u.Hostname()
proxyPort = u.Port()
}
}
// Determine if we're using daemon-mode (transparent proxying via pf + utun)
daemonMode := daemonSession != nil
// Restrict network unless proxy is configured to an external host
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
// In daemon mode, network restriction is handled by pf, not Seatbelt.
needsNetworkRestriction := !daemonMode
params := MacOSSandboxParams{
Command: command,
NeedsNetworkRestriction: needsNetworkRestriction,
ProxyURL: cfg.Network.ProxyURL,
ProxyHost: proxyHost,
ProxyPort: proxyPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: cwd,
ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,
WriteDenyPaths: cfg.Filesystem.DenyWrite,
AllowPty: cfg.AllowPty,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
DaemonMode: daemonMode,
DaemonSocketPath: "/var/run/greywall.sock",
}
if debug && len(exposedPorts) > 0 {
fmt.Fprintf(os.Stderr, "[greywall:macos] Enabling local binding for exposed ports: %v\n", exposedPorts)
}
if debug && allowLocalBinding && !allowLocalOutbound {
fmt.Fprintf(os.Stderr, "[greywall:macos] Blocking localhost outbound (AllowLocalOutbound=false)\n")
}
if debug && daemonMode {
fmt.Fprintf(os.Stderr, "[greywall:macos] Daemon mode: transparent proxying via pf + utun (group=%s, device=%s)\n",
daemonSession.SandboxGroup, daemonSession.TunDevice)
}
profile := GenerateSandboxProfile(params)
// Find shell
shell := params.Shell
if shell == "" {
shell = "bash"
}
shellPath, err := exec.LookPath(shell)
if err != nil {
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
var parts []string
if daemonMode {
// In daemon mode: run as the real user but with EGID=_greywall via sudo.
// pf routes all TCP from group _greywall through utun → tun2socks → proxy.
// Using -u #<uid> preserves the user's identity (home dir, SSH keys, etc.)
// while -g _greywall sets the effective GID for pf matching.
//
// DNS on macOS goes through mDNSResponder (Mach IPC), which runs outside
// the _greywall group, so pf can't intercept DNS. Instead, we set
// ALL_PROXY=socks5h:// so proxy-aware apps (curl, git, etc.) resolve DNS
// through the SOCKS5 proxy. The "h" suffix means "resolve hostname at proxy".
//
// Set ALL_PROXY and HTTP_PROXY/HTTPS_PROXY with socks5h:// so both
// SOCKS5-aware apps (curl, git) and HTTP-proxy-aware apps (opencode,
// Node.js tools) resolve DNS through the proxy. The "h" suffix means
// "resolve hostname at proxy side". Note: apps that read HTTP_PROXY
// but don't support SOCKS5 protocol (e.g., Bun) may fail to connect.
//
// sudo resets the environment, so we use `env` after sudo to re-inject
// terminal vars (TERM, COLORTERM, etc.) needed for TUI apps.
uid := fmt.Sprintf("#%d", os.Getuid())
sandboxEnvs := GenerateProxyEnvVars("")
// Convert socks5:// → socks5h:// for hostname resolution through proxy.
socks5hURL := strings.Replace(cfg.Network.ProxyURL, "socks5://", "socks5h://", 1)
if socks5hURL != "" {
// ALL_PROXY uses socks5h:// (DNS resolved at proxy side) for
// SOCKS5-aware apps (curl, git).
// HTTP_PROXY/HTTPS_PROXY use the configured HTTP CONNECT proxy
// for apps that only understand HTTP proxies (opencode, Node.js
// tools, etc.). The CONNECT proxy resolves DNS server-side.
httpProxyURL := cfg.Network.HTTPProxyURL
// Inject credentials from the SOCKS5 proxy URL into the HTTP proxy
// URL if the HTTP proxy URL doesn't already have credentials.
if httpProxyURL != "" {
if hu, err := url.Parse(httpProxyURL); err == nil && hu.User == nil {
if su, err := url.Parse(socks5hURL); err == nil && su.User != nil {
hu.User = su.User
httpProxyURL = hu.String()
}
}
}
sandboxEnvs = append(sandboxEnvs,
"ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL,
)
if httpProxyURL != "" {
sandboxEnvs = append(sandboxEnvs,
"HTTP_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL,
"HTTPS_PROXY="+httpProxyURL, "https_proxy="+httpProxyURL,
)
}
}
termEnvs := getTerminalEnvVars()
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env")
parts = append(parts, sandboxEnvs...)
parts = append(parts, termEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
} else {
// Non-daemon mode: use proxy env vars for best-effort proxying.
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
parts = append(parts, "env")
parts = append(parts, proxyEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
}
return ShellQuote(parts), nil
}