// EXT-16 执行后端:把「启动白名单解释器进程」抽象为 ExecBackend 接口。 // // direct = 现状(exec.CommandContext 直启,逐字节等价);sandbox = bwrap 隔离档 // (模型生成代码按不可信对待):只读系统根、tmpfs 遮蔽 /home 与 /tmp、仅会话 // 工作目录与技能目录挂载可写、--unshare-net 断网、ulimit -u 限进程数防 fork // 炸弹、超时杀 bwrap 宿主侧进程组(pid 命名空间随之销毁,不可逃逸存活)。 // 启动级 DisableExecTools 是最终兜底,优先级高于后端选择(execGate 拒绝在前)。 package tools import ( "context" "errors" "fmt" "io" "os" "os/exec" "strings" "sync/atomic" "syscall" "time" ) // execSandboxMaxProcs sandbox 档内 ulimit -u(fork 炸弹防线;bash 自身进程也计入)。 const execSandboxMaxProcs = 256 // ExecSpec 一次解释器执行的启动规格(后端无关)。 type ExecSpec struct { Bin string // 白名单解释器绝对路径 Args []string // 落盘脚本路径为唯一参数 Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载) Env []string // 最小环境(execEnv 口径) SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂) // DepsDir 依赖层(sandbox 档只读挂载;空 = 不挂):python-docx 等装在 // ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH, // 交付物生成链路(F3)在隔离档下才可用。 DepsDir string // S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、 // 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。 WorkspaceFrozen string WorkspaceUpper string WorkspaceWork string Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用) } // ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体 // (direct = 进程组 SIGKILL;sandbox = bwrap 宿主侧进程组,pid 命名空间销毁)。 type ExecProcess interface { Wait() error Kill() error } // ExecBackend 执行后端接口(EXT-16):启动 spec 描述的解释器, // stdout+stderr 合并写入 out。 type ExecBackend interface { Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) } // ---- 后端选择(组合根装配期设置)---- var execBackend = atomic.Value{} // string:"" / "direct" = 现状;"sandbox" = 隔离档 // SetExecBackend 按名选择执行后端;空串与 "direct" 等价(现状直启)。 // 未知名字报错(fail-closed,不静默回落)。硬关闭 DisableExecTools 优先于此。 func SetExecBackend(name string) error { switch name { case "", "direct", "sandbox": execBackend.Store(name) return nil default: return fmt.Errorf("unknown exec backend %q (direct|sandbox)", name) } } // ExecBackendName 报告当前后端名(空 = direct)。 func ExecBackendName() string { v, _ := execBackend.Load().(string) return v } func currentExecBackend() ExecBackend { if ExecBackendName() == "sandbox" { return sandboxBackend{} } return directBackend{} } // ---- direct 后端:现状行为 ---- type directBackend struct{} func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) { cmd := exec.CommandContext(ctx, spec.Bin, spec.Args...) cmd.Dir = spec.Dir cmd.Env = spec.Env cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.WaitDelay = 2 * time.Second cmd.Cancel = func() error { if cmd.Process == nil { return os.ErrProcessDone } err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) if errors.Is(err, syscall.ESRCH) { return nil } return err } cmd.Stdout = out cmd.Stderr = out if err := cmd.Start(); err != nil { return nil, err } return cmdProcess{cmd: cmd}, nil } type cmdProcess struct { cmd *exec.Cmd } func (p cmdProcess) Wait() error { return p.cmd.Wait() } func (p cmdProcess) Kill() error { if p.cmd.Process == nil { return os.ErrProcessDone } err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) if errors.Is(err, syscall.ESRCH) { return nil } return err } // ---- sandbox 后端:bwrap 隔离档 ---- type sandboxBackend struct{} func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) { bin, err := exec.LookPath("bwrap") if err != nil { return nil, fmt.Errorf("sandbox exec backend requires bwrap: %w", err) } if workspaceOverlay { return startOverlaySandbox(ctx, spec, bin, out) } argv := sandboxArgv(spec) cmd := exec.CommandContext(ctx, bin, argv...) cmd.Env = []string{"PATH=" + os.Getenv("PATH")} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.WaitDelay = 2 * time.Second cmd.Cancel = func() error { if cmd.Process == nil { return os.ErrProcessDone } err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) if errors.Is(err, syscall.ESRCH) { return nil } return err } cmd.Stdout = out cmd.Stderr = out if err := cmd.Start(); err != nil { return nil, err } return cmdProcess{cmd: cmd}, nil } // sandboxArgv 组装 bwrap 参数(单测锚点;挂载顺序敏感——/home 与 /tmp 的 // tmpfs 遮蔽必须后于 --ro-bind /,会话目录 bind 必须后于其遮蔽)。 func sandboxArgv(spec ExecSpec) []string { argv := []string{ "--unshare-all", // pid/net/ipc/uts/user 命名空间全隔离(断网在此) "--die-with-parent", "--ro-bind", "/", "/", // 只读系统根(解释器与依赖库可用) "--tmpfs", "/home", // 遮蔽真实家目录(~/.secrets 等不可见不可读) "--tmpfs", "/tmp", // 可写临时区(pip/tempfile),不含宿主内容 "--proc", "/proc", "--dev", "/dev", } if spec.WorkspaceUpper != "" { // S0:会话目录不再直写——冻结基底 overlay,写入只落私有 upper // (正常退出后 CAS 回收;超时/中止整体丢弃,半写状态不落地)。 argv = append(argv, "--overlay-src", spec.WorkspaceFrozen, "--overlay", spec.WorkspaceUpper, spec.WorkspaceWork, spec.Dir) } else { argv = append(argv, "--bind", spec.Dir, spec.Dir) // 会话沙箱:唯一可写挂载 } if spec.SkillsDir != "" { argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir) } if spec.DepsDir != "" { // 必须后于 --tmpfs /home:先遮蔽再只读挂回依赖层(挂载顺序敏感)。 argv = append(argv, "--ro-bind", spec.DepsDir, spec.DepsDir) } argv = append(argv, "--chdir", spec.Dir, "--clearenv") for _, kv := range spec.Env { if i := strings.IndexByte(kv, '='); i > 0 { argv = append(argv, "--setenv", kv[:i], kv[i+1:]) } } // ulimit 包一层 bash(dash 不支持 -u):进程数限额防 fork 炸弹 // (命名空间内 RLIMIT_NPROC 生效)。 wrapped := append([]string{spec.Bin}, spec.Args...) argv = append(argv, "/bin/bash", "-c", fmt.Sprintf("ulimit -u %d; exec %s", execSandboxMaxProcs, shellQuote(wrapped))) return argv } // shellQuote 逐元素单引号包裹(sh 安全拼接,无引号内插值)。 func shellQuote(parts []string) string { out := make([]string, len(parts)) for i, p := range parts { out[i] = "'" + strings.ReplaceAll(p, "'", `'\''`) + "'" } return strings.Join(out, " ") }