Files
Agent-System-Backend/internal/tools/exec_backend.go
T
weichen 4badecbf57
ci / vet-and-test (pull_request) Successful in 41s
Add the EXT-16 exec backend interface: direct status quo and bwrap sandbox
Why: Q2 ruled exec tools stay disabled for now but code execution must
return later; the recovery path treats model-generated code as
untrusted. This abstracts the interpreter launch point in
exec_interpreter.go into an ExecBackend port (direct = byte-identical
status quo; sandbox = bwrap isolation) so downstream products can
re-enable execution behind isolation without further base surgery.

Evidence: go build/vet clean; go test ./... 32/32 packages green;
sandbox escape negatives all blocked locally (bwrap 0.11.1): home
secret unreadable (tmpfs /home), sibling session unreadable
(session-only bind), network blocked (--unshare-net), 400-process
spawn storm capped by ulimit -u 256 (fork: retry), 60s sleep killed at
3s timeout, orphans die with the namespace; session-dir write visible
on host; direct backend regression (exit=7 / exit=timeout texts)
unchanged.

Impact: run_shell/run_python route through the backend; workflow
ScriptCommand and the squad kernel ExecConfig stay direct (follow-up
scope); config key execBackend + env AGENT_SYSTEM_EXEC_BACKEND + CLI
--exec-backend; extension.Options.ExecBackend (SurfaceVersion
2.6.0-experimental); DisableExecTools remains the top-priority
fail-closed switch.

Refs: proposal 2026-10-09-ext-g1-b1-b3-exec-backend.md section 4
(EXT-16, G2-8 prototype); Backend #110 (DisableExecTools).
2026-10-09 17:01:32 +00:00

193 lines
6.1 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 档只读挂载;空 = 不挂)
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)
}
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",
"--bind", spec.Dir, spec.Dir, // 会话沙箱:唯一可写挂载
}
if spec.SkillsDir != "" {
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
}
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, " ")
}