//go:build darwin // sandbox_darwin.go v1.6 package sandbox import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "apigo.cc/gojs" "github.com/ssgo/log" "github.com/ssgo/u" ) var defaultVolumes = []Volume{ {Source: "/bin", ReadOnly: true}, {Source: "/usr/bin", ReadOnly: true}, {Source: "/usr/lib", ReadOnly: true}, {Source: "/usr/share", ReadOnly: true}, {Source: "/usr/local/bin", ReadOnly: true}, {Source: "/usr/local/lib", ReadOnly: true}, {Source: "/usr/local/share", ReadOnly: true}, {Source: "/usr/local/include", ReadOnly: true}, {Source: "/usr/local/Cellar", ReadOnly: true}, {Source: "/private/var/folders"}, // 必须:存储临时模块缓存、Python .pyc 的地方 {Source: "/System/Library", ReadOnly: true}, // 必须:系统级 Frameworks (OpenSSL, Foundation) {Source: "/dev/fd"}, // 很多 shell 操作和 Python 的 subprocess 重定向强依赖它。 {Source: "/dev/null"}, {Source: "/dev/zero"}, {Source: "/dev/stdout"}, {Source: "/dev/stderr"}, {Source: "/dev/random", ReadOnly: true}, {Source: "/dev/urandom", ReadOnly: true}, } func Start(cfg *Config) (*Sandbox, error) { s := &Sandbox{ config: cfg, mountedList: []string{}, status: "created", extra: map[string]any{}, } s.lock.Lock() defer s.lock.Unlock() if s.status != "created" { return s, gojs.Err("sandbox is already used") } s.id = getId() s.status = "starting" s.startTime = time.Now().Unix() // 1. 物理环境准备 s.root = filepath.Join(pluginConfig.Root, s.id) os.MkdirAll(s.root, 0755) sandboxTmp := filepath.Join(os.TempDir(), s.id, ".tmp") os.MkdirAll(sandboxTmp, 0777) // 2. 优先级逻辑:WorkDir 处理 if s.config.ProjectDir != "" { s.workDir = u.GetAbsFilename(s.config.ProjectDir) if s.config.WorkDir != "" { log.DefaultLogger.Warning("[Sandbox] both HostWorkDir and WorkDir provided. Using HostWorkDir.") } } else if s.config.WorkDir != "" { s.workDir = u.GetAbsFilename(s.config.WorkDir) } else { s.workDir = filepath.Join(s.root, ".workdir") os.MkdirAll(s.workDir, 0755) // log.DefaultLogger.Warning("[Sandbox] no WorkDir specified. Using isolated temporary directory as WorkDir.") } if !u.FileExists(s.workDir) { os.MkdirAll(s.workDir, 0755) } vs := NewVolumes() vs.Add(defaultVolumes...) vs.Add(s.config.Volumes...) vs.Add(Volume{Source: s.workDir}) // 添加工作目录 // 3. 不支持功能警告 if s.config.Limits.Cpu > 0 || s.config.Limits.Mem > 0 { log.DefaultLogger.Warning("[Sandbox] resource 'Limits' are currently not enforced on Darwin.") } // 4. 环境变量 initEnv := map[string]string{ "PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": os.Getenv("LANG"), "HOME": s.workDir, "TMPDIR": sandboxTmp, "PYTHONUNBUFFERED": "1", } addEnv := func(k, v string) { initEnv[k] = os.Expand(v, func(varName string) string { return initEnv[varName] }) } for k, v := range s.config.Envs { addEnv(k, v) } // 处理 runtime runtimeListLock.RLock() rt := runtimeList[s.config.Runtime.Language] runtimeListLock.RUnlock() if rt != nil { rtCfg := pluginConfig.Runtime[s.config.Runtime.Language] if rtCfg == nil { rtCfg = &RuntimeConfig{} } if rtCfg.Root == "" { rtCfg.Root = filepath.Join(pluginConfig.Root, s.config.Runtime.Language) } runtimePath := filepath.Join(rtCfg.Root, "runtime", s.config.Runtime.Version) venvPath := filepath.Join(rtCfg.Root, "venv", fmt.Sprintf("%s_%s", s.config.Runtime.Version, s.config.Runtime.Venv)) if rtsbCfg, err := rt.Check(runtimePath, venvPath, s.config.ProjectDir, s.uid, s.gid, rtCfg); err == nil { if s.config.StartCmd == "" && rtsbCfg.StartCmd != "" { s.config.StartCmd = rtsbCfg.StartCmd } for k, v := range rtsbCfg.Envs { addEnv(k, v) } // 挂载运行时环境 if u.FileExists(runtimePath) { vs.Add(Volume{Source: runtimePath, ReadOnly: true}) } if u.FileExists(venvPath) { vs.Add(Volume{Source: venvPath, ReadOnly: false}) } } else { s._cleanup() return s, err } } else { s._cleanup() return s, gojs.Err("runtime not found for language " + s.config.Runtime.Language) } if s.config.StartCmd == "" { s._cleanup() return s, gojs.Err("start cmd is empty") } // 5. 生成 Seatbelt 策略 sbProfile := s.generateSeatbelt(vs) sbPath := filepath.Join(s.root, "sandbox.sb") os.WriteFile(sbPath, []byte(sbProfile), 0644) // 6. 启动命令 s.cmd = exec.Command("sandbox-exec", append([]string{"-f", sbPath, s.config.StartCmd}, s.config.StartArgs...)...) s.cmd.Dir = s.workDir s.cmd.Env = make([]string, 0, len(initEnv)) for k, v := range initEnv { s.cmd.Env = append(s.cmd.Env, fmt.Sprintf("%s=%s", k, v)) } if fdOut, err := os.OpenFile(filepath.Join(s.workDir, "stdout.log"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_SYNC, 0644); err == nil { s.lastLogFd = fdOut s.cmd.Stdout = fdOut } else { s.cmd.Stdout = os.Stdout } if fdErr, err := os.OpenFile(filepath.Join(s.workDir, "stderr.log"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_SYNC, 0644); err == nil { s.errorLogFd = fdErr s.cmd.Stderr = fdErr } else { s.cmd.Stderr = os.Stderr } if err := s.cmd.Start(); err != nil { log.DefaultLogger.Error("[Sandbox] failed to start process", "err", err.Error(), "cmd", s.config.StartCmd, "args", s.config.StartArgs) s.errorLogFd.WriteString(fmt.Sprintf("Failed to start process: %s\n", err.Error())) s._cleanup() return s, err } s.status = "running" s.pid = s.cmd.Process.Pid u.Save(filepath.Join(s.root, ".state.json"), State{ Id: s.id, Pid: s.pid, StartTime: s.startTime, WorkDir: s.workDir, }) RegisterSandbox(s) return s, nil } func (s *Sandbox) generateSeatbelt(vs *Volumes) string { var sb strings.Builder // 默认拒绝所有,仅允许根路径读取(无其他路径访问权限) sb.WriteString("(version 1)\n(deny default)\n") sb.WriteString("(allow file-read* (literal \"/\"))\n") sb.WriteString("(allow file-read-metadata (literal \"/\"))\n") // 没有它 Python 无法完成路径溯源 // 挂载卷授权 for _, v := range vs.Get() { mode := "file-read*" if !v.ReadOnly { mode += " file-write*" } typ := "subpath" if fi := u.GetFileInfo(v.Source); fi != nil && !fi.IsDir { typ = "literal" // 非文件夹 } sb.WriteString(fmt.Sprintf("(allow %s (%s \"%s\"))\n", mode, typ, v.Source)) s.assertLog("Mount "+v.Source, nil, "mode", mode, "id", s.id, "pid", s.pid, "name", s.config.Name) } // 系统能力 sb.WriteString("(allow file-read-metadata)\n") sb.WriteString("(allow sysctl-read)\n") // 能看到 CPU 型号、内核版本。Python 的 platform, multiprocessing.cpu_count() 需要它 sb.WriteString("(allow process*)\n") sb.WriteString("(allow signal (target self))\n") // 允许向自己发送信号,无法像其他进程发送信号。 sb.WriteString("(allow ipc-posix-shm*)\n") if len(s.config.Gpu.Devices) > 0 { sb.WriteString("(allow iokit-open)\n") // 开启GPU权限 sb.WriteString("(allow iokit-open (iokit-user-client-class \"AppleGraphicsControlClient\"))\n") sb.WriteString("(allow iokit-open (iokit-user-client-class \"AGXDeviceUserClient\"))\n") // 针对 Apple Silicon } // 入站:按需开启监听 for _, port := range s.config.Network.AllowListen { sb.WriteString(fmt.Sprintf("(allow network-bind (local ip \"*:%d\"))\n", port)) sb.WriteString(fmt.Sprintf("(allow network-inbound (local ip \"*:%d\"))\n", port)) } // 出站:按需开窗 if s.config.Network.AllowInternet || s.config.Network.AllowLocalNetwork || len(s.config.Network.AllowList) > 0 { // 允许 DNS 和系统网络配置查询 sb.WriteString("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n") sb.WriteString("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.configd\"))\n") if s.config.Network.AllowInternet { sb.WriteString("(allow network-outbound (remote ip \"*:*\"))\n") } else if s.config.Network.AllowLocalNetwork { // 因为无法精细控制,本地网络指 localhost 而非局域网 sb.WriteString("(allow network* (remote ip \"localhost:*\"))\n") } } // 允许白名单访问 for _, ipport := range s.config.Network.AllowList { // 受 Seatbelt 限制,不支持指定IP,仅支持限制端口 if a := strings.Split(ipport, ":"); len(a) >= 2 { sb.WriteString(fmt.Sprintf("(allow network-outbound (remote ip \"*:%s\"))\n", a[len(a)-1])) } } // 拒绝白名单访问 for _, ipport := range s.config.Network.BlockList { // 受 Seatbelt 限制,不支持指定IP,仅限制端口 if a := strings.Split(ipport, ":"); len(a) >= 2 { sb.WriteString(fmt.Sprintf("(deny network-outbound (remote ip \"*:%s\"))\n", a[len(a)-1])) } } // 以下为“按需放行”的可选能力,默认注释以保安全 // 例如传入 "(allow mach-per-user-lookup)" // 仅当涉及复杂系统 UI 服务时开启,能访问剪切板、用户偏好设置等系统服务因此风险较高,默认关闭 for _, o := range s.config.ExtraOptions { if (strings.HasPrefix(o, "(allow ") || strings.HasPrefix(o, "(deny ")) && strings.HasSuffix(o, ")") { sb.WriteString(o + "\n") } } // fmt.Println(" ====Seatbelt:", sb.String()) return sb.String() } func (s *Sandbox) Kill() error { s.lock.Lock() defer s.lock.Unlock() if s.status != "running" && s.status != "starting" { return gojs.Err("sandbox not running or starting") } s.status = "stopping" return s._kill(syscall.SIGTERM) } func (s *Sandbox) Wait(timeout int64) (any, error) { s.lock.Lock() defer s.lock.Unlock() if s.status != "running" && s.status != "stopping" { return nil, gojs.Err("sandbox not running or stopping") } if s._alive() { ch := make(chan error, 1) go func() { state, err := s._waitProcess() if err == nil && state != nil { s.status = "exited" } ch <- err }() select { case _ = <-ch: break case <-time.After(time.Duration(timeout) * time.Millisecond): s._kill(syscall.SIGKILL) break } } return s._cleanup() } func (s *Sandbox) Cleanup() (any, error) { s.lock.Lock() defer s.lock.Unlock() return s._cleanup() } func (s *Sandbox) _cleanup() (any, error) { s.log("cleaning up sandbox", "id", s.id, "name", s.config.Name) if s.lastLogFd != nil { s.lastLogFd.Close() s.lastLogFd = nil } if s.errorLogFd != nil { s.errorLogFd.Close() s.errorLogFd = nil } // 存储日志(按起始天合并) _, err := copyLog(s.workDir, s.startTime) s.assertLog("Copy log to logs directory", err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) outLog := u.ReadFileN(filepath.Join(s.workDir, "stdout.log")) errLog := u.ReadFileN(filepath.Join(s.workDir, "stderr.log")) err = os.RemoveAll(s.root) s.assertLog(fmt.Sprintf("Remove Sandbox %s", s.root), err, "id", s.id, "name", s.config.Name) releaseId(s.id) ReleaseSandbox(s.id) var outData any = outLog if strings.HasPrefix(outLog, "{") && strings.HasSuffix(outLog, "}") || strings.HasPrefix(outLog, "[") && strings.HasSuffix(outLog, "]") { var data map[string]any if err := json.Unmarshal([]byte(outLog), &data); err == nil { outData = data } } if errLog != "" { return outData, errors.New(errLog) } return outData, nil } func (s *Sandbox) Status() Status { s.lock.Lock() defer s.lock.Unlock() st := Status{Id: s.id, Pid: s.pid, Status: s.status, StartTime: s.startTime, Uptime: time.Now().Unix() - s.startTime} st.Alive = s._alive() return st }