Merge pull request 'Add the EXT-16 exec backend interface: direct status quo and bwrap sandbox' (#119) from feature/ext-16-exec-backend into main
ci / vet-and-test (push) Successful in 41s
ci / vet-and-test (push) Successful in 41s
This commit was merged in pull request #119.
This commit is contained in:
@@ -45,6 +45,7 @@ func main() {
|
||||
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
@@ -52,6 +53,7 @@ func main() {
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -61,7 +63,7 @@ func main() {
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools,
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -105,11 +107,13 @@ func runConfig(args []string) int {
|
||||
insecure := fs.Bool("insecure-listen", false, "")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
fs.Parse(args[1:])
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
|
||||
@@ -76,6 +76,9 @@ type Options struct {
|
||||
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
|
||||
// 零值=false=现状。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -128,6 +131,13 @@ func Run(opt Options) error {
|
||||
if opt.DisableExecTools {
|
||||
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
|
||||
}
|
||||
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
|
||||
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
|
||||
return fmt.Errorf("exec backend: %w", err)
|
||||
}
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ type Config struct {
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||||
DisableExecTools bool `json:"disableExecTools"`
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -47,6 +50,7 @@ var envKey = map[string]string{
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
@@ -170,6 +174,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.AllowedHostsRaw
|
||||
case "disableExecTools":
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -199,6 +205,8 @@ func stringField(c Config, f string) string {
|
||||
return c.Instance
|
||||
case "allowedHosts":
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -247,6 +255,8 @@ func setString(c *Config, f, v string) {
|
||||
c.Instance = v
|
||||
case "allowedHosts":
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +278,7 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools"} {
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
|
||||
v := stringField(r.Config, f)
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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, " ")
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package tools
|
||||
|
||||
// EXT-16 执行后端测试:direct 档回归(现状语义)+ sandbox 档 bwrap 参数组装与
|
||||
// 逃逸负例(提案 2026-10-09-ext-g1-b1-b3 §4 验收:读 ~/.secrets、读其他会话目录、
|
||||
// 访问外网、fork 炸弹、超时不可杀——须全部被拦;会话目录内正常任务成功)。
|
||||
// bwrap 缺席的环境跳过 sandbox 集成项(CI 只兜 direct 回归;隔离证据在具备
|
||||
// bwrap 的评审环境重放)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetExecBackendValidation(t *testing.T) {
|
||||
defer func() { _ = SetExecBackend("") }()
|
||||
if err := SetExecBackend("jail"); err == nil {
|
||||
t.Fatal("unknown backend must be rejected (fail-closed)")
|
||||
}
|
||||
for _, ok := range []string{"", "direct", "sandbox"} {
|
||||
if err := SetExecBackend(ok); err != nil {
|
||||
t.Fatalf("%q: %v", ok, err)
|
||||
}
|
||||
}
|
||||
if err := SetExecBackend("sandbox"); err != nil || ExecBackendName() != "sandbox" {
|
||||
t.Fatalf("sandbox not selected: %v %q", err, ExecBackendName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxArgvShape(t *testing.T) {
|
||||
argv := sandboxArgv(ExecSpec{
|
||||
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
|
||||
Dir: "/data/s", SkillsDir: "/data/skills",
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"},
|
||||
})
|
||||
joined := strings.Join(argv, "\x00")
|
||||
musts := []string{
|
||||
"--unshare-all", "--die-with-parent",
|
||||
"--ro-bind\x00/\x00/", // 只读系统根
|
||||
"--tmpfs\x00/home", // 遮蔽真实家目录
|
||||
"--tmpfs\x00/tmp",
|
||||
"--bind\x00/data/s\x00/data/s", // 会话目录唯一可写挂载
|
||||
"--ro-bind\x00/data/skills\x00/data/skills",
|
||||
"--chdir\x00/data/s", "--clearenv",
|
||||
"--setenv\x00PATH", "--setenv\x00HOME",
|
||||
"/bin/bash", "-c",
|
||||
}
|
||||
for _, m := range musts {
|
||||
if !strings.Contains(joined, m) {
|
||||
t.Fatalf("sandbox argv missing %q: %v", m, argv)
|
||||
}
|
||||
}
|
||||
wrapper := argv[len(argv)-1]
|
||||
if !strings.Contains(wrapper, "ulimit -u "+itoa(execSandboxMaxProcs)) ||
|
||||
!strings.Contains(wrapper, "exec '/usr/bin/python3' '/data/s/x.py'") {
|
||||
t.Fatalf("wrapper = %q(须 ulimit -u 限进程数并 exec 白名单解释器)", wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// ---- sandbox 集成(需 bwrap;缺席即跳过)----
|
||||
|
||||
func requireBwrap(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("bwrap not available; sandbox integration replayed on capable hosts")
|
||||
}
|
||||
}
|
||||
|
||||
// sandboxRun 经 sandbox 后端执行一段 bash 脚本,返回合并输出与退出描述。
|
||||
func sandboxRun(t *testing.T, script string, timeout time.Duration) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "s.sh")
|
||||
if err := os.WriteFile(p, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{p}, Dir: dir,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
werr := proc.Wait()
|
||||
out := buf.String()
|
||||
state := "exit=0"
|
||||
if werr != nil {
|
||||
if ee, ok := werr.(*exec.ExitError); ok {
|
||||
state = "exit=" + itoa(ee.ExitCode())
|
||||
} else {
|
||||
state = "err:" + werr.Error()
|
||||
}
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
state = "exit=timeout"
|
||||
}
|
||||
return out, state
|
||||
}
|
||||
|
||||
func TestSandboxHomeHidden(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" || !strings.HasPrefix(home, "/home/") {
|
||||
t.Skipf("home %q not under /home; fixture would not be shadowed", home)
|
||||
}
|
||||
secret := filepath.Join(home, ".agent-ext16-marker")
|
||||
if err := os.WriteFile(secret, []byte("topsecret"), 0o600); err != nil {
|
||||
t.Skipf("cannot create home fixture: %v", err)
|
||||
}
|
||||
defer os.Remove(secret)
|
||||
out, state := sandboxRun(t, "cat \"$HOME/.agent-ext16-marker\" 2>err.txt; cat err.txt; exit 7", 30*time.Second)
|
||||
if strings.Contains(out, "topsecret") {
|
||||
t.Fatalf("sandbox read real home secret (state=%s out=%q)", state, out)
|
||||
}
|
||||
if state == "exit=0" || state == "exit=7" && strings.Contains(out, "topsecret") {
|
||||
t.Fatalf("unexpected success: state=%s out=%q", state, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxSiblingSessionUnreadable(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
base := t.TempDir()
|
||||
a, b := filepath.Join(base, "session-a"), filepath.Join(base, "session-b")
|
||||
if err := os.MkdirAll(a, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(b, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(b, "other-session.txt")
|
||||
if err := os.WriteFile(marker, []byte("others-data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{"-c", "cat '" + marker + "'"}, Dir: a,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
werr := proc.Wait()
|
||||
if werr == nil || buf.String() == "others-data" {
|
||||
t.Fatalf("sibling session readable inside sandbox: wait=%v out=%q", werr, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxNoNetwork(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
out, state := sandboxRun(t,
|
||||
"timeout 5 bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED",
|
||||
30*time.Second)
|
||||
if strings.Contains(out, "NET-OPEN") {
|
||||
t.Fatalf("network reachable in sandbox (state=%s)", state)
|
||||
}
|
||||
if !strings.Contains(out, "NET-BLOCKED") {
|
||||
t.Fatalf("probe did not run: state=%s out=%q", state, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxForkBombContained(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
// 确定性进程数压测(替代经典 :(){ :|:& };:——其在沙箱 user namespace 内
|
||||
// 递归行为依 bash 实现而变,不适合做门禁):400 个并发 sleep 撞 ulimit -u
|
||||
// 256 的墙,fork: retry 即限额生效的直接证据。
|
||||
start := time.Now()
|
||||
out, state := sandboxRun(t,
|
||||
"n=0; for i in $(seq 1 400); do sleep 5 & n=$((n+1)); done; echo spawned=$n; wait",
|
||||
20*time.Second)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 40*time.Second {
|
||||
t.Fatalf("spawn storm stalled the runner: %s", elapsed)
|
||||
}
|
||||
if !strings.Contains(out, "fork: retry") {
|
||||
t.Fatalf("process limit not enforced (state=%s out=%.200q)", state, out)
|
||||
}
|
||||
if state == "exit=0" && strings.Contains(out, "spawned=400") {
|
||||
t.Fatalf("400 processes spawned unbounded: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxOrphansDieWithNamespace(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
out, state := sandboxRun(t, ":(){ :|:& };:; echo gone", 30*time.Second)
|
||||
if state != "exit=0" || !strings.Contains(out, "gone") {
|
||||
t.Fatalf("state=%s out=%q", state, out)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond) // 给孤儿进程留存活窗口
|
||||
hb, err := exec.Command("bash", "-c", "ps -eo args | grep -c '[s]\\.sh' || true").Output()
|
||||
if err != nil {
|
||||
t.Skipf("host ps probe failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(hb)) != "0" {
|
||||
t.Fatalf("orphaned bomb survived outside the namespace: %s", hb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxTimeoutKills(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
start := time.Now()
|
||||
_, state := sandboxRun(t, "sleep 60", 3*time.Second)
|
||||
if d := time.Since(start); d > 20*time.Second {
|
||||
t.Fatalf("timeout did not kill the sandbox: %s", d)
|
||||
}
|
||||
if state != "exit=timeout" {
|
||||
t.Fatalf("state = %s, want exit=timeout", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxSessionWriteVisible(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
script := filepath.Join(dir, "w.sh")
|
||||
if err := os.WriteFile(script, []byte("printf 'docx-ish' > \"$(dirname \"$0\")/out.txt\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{script}, Dir: dir,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := proc.Wait(); err != nil {
|
||||
t.Fatalf("sandbox task failed: %v out=%q", err, buf.String())
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil || string(got) != "docx-ish" {
|
||||
t.Fatalf("product not visible on host: %v %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
// direct 档回归:后端抽象后 runInterpreter 语义不变(退出码/超时文本口径)。
|
||||
func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash missing")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "x.sh")
|
||||
if err := os.WriteFile(p, []byte("echo hi; exit 7\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := runInterpreter(context.Background(), "bash", p, dir, 30, nil)
|
||||
if err != nil || !strings.HasPrefix(got, "exit=7\n") || !strings.Contains(got, "hi") {
|
||||
t.Fatalf("direct regression: err=%v got=%q", err, got)
|
||||
}
|
||||
p2 := filepath.Join(dir, "t.sh")
|
||||
if err := os.WriteFile(p2, []byte("sleep 60\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = runInterpreter(context.Background(), "bash", p2, dir, 2, nil)
|
||||
if err != nil || !strings.HasPrefix(got, "exit=timeout(2s)") {
|
||||
t.Fatalf("direct timeout regression: err=%v got=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ func resolveInterpreter(name string) (string, error) {
|
||||
}
|
||||
|
||||
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
|
||||
// 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。
|
||||
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
|
||||
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) {
|
||||
if parent == nil {
|
||||
@@ -145,24 +146,11 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, scriptPath)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = execEnv()
|
||||
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
|
||||
spec := ExecSpec{
|
||||
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(),
|
||||
SkillsDir: skillsRoot, Timeout: time.Duration(timeoutS) * time.Second,
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
var buf bytes.Buffer
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -183,13 +171,13 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = cmd.Start()
|
||||
proc, err := currentExecBackend().Start(ctx, spec, pw)
|
||||
if err != nil {
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
return "", fmt.Errorf("%s: %w", interpreter, err)
|
||||
}
|
||||
err = cmd.Wait()
|
||||
err = proc.Wait()
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
text := truncateOutput(buf.Bytes())
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID
|
||||
// 重建受限宿主句柄,供下游恢复路径)。
|
||||
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
|
||||
const SurfaceVersion = "2.5.0-experimental"
|
||||
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
|
||||
const SurfaceVersion = "2.6.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
@@ -68,6 +69,11 @@ type Options struct {
|
||||
// register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。
|
||||
// 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档
|
||||
// (模型生成代码按不可信对待:断网、只读系统根、tmpfs 遮蔽 /home 与 /tmp、
|
||||
// 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。
|
||||
// DisableExecTools 优先级高于此选择。
|
||||
ExecBackend string
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -15,6 +15,7 @@ func init() {
|
||||
DisableUploadIndexing: opt.DisableUploadIndexing,
|
||||
DisableConvCardMirror: opt.DisableConvCardMirror,
|
||||
DisableExecTools: opt.DisableExecTools,
|
||||
ExecBackend: opt.ExecBackend,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user