Files
Agent-System-Backend/internal/tools/exec_backend.go
T
weichenandCursor b85a1e1ca3 Add immutable layered Python environments for the sandbox.
Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox.
Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run.
Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:48 +00:00

242 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// LayerDirs 是本次执行要只读挂载的 site-packages(基础层与扩展层,或单独的隔离层)。
// 路径若位于 /home 或 /tmp,必须在对应 tmpfs 之后挂载。
LayerDirs []string
// PythonPrefix / ShimDir 同样可能落在被 tmpfs 遮住的路径上,需要挂回。
PythonPrefix string
ShimDir string
// S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、
// 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。
WorkspaceFrozen string
WorkspaceUpper string
WorkspaceWork string
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
CheckExternalEffect func() error // 启动与 overlay 写回前重验账号;nil 为旧内部调用
}
// 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 := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil {
return nil, err
}
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 := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil {
return nil, err
}
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)
}
for _, dir := range spec.LayerDirs {
if dir == "" || dir == spec.DepsDir {
continue
}
argv = append(argv, "--ro-bind", dir, dir)
}
if spec.PythonPrefix != "" {
argv = append(argv, "--ro-bind", spec.PythonPrefix, spec.PythonPrefix)
}
if spec.ShimDir != "" && spec.ShimDir != spec.PythonPrefix {
argv = append(argv, "--ro-bind", spec.ShimDir, spec.ShimDir)
}
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, " ")
}