Why: Home and sibling isolation tests could pass after a Bubblewrap startup failure, hiding an untested security boundary. Evidence: Isolated fake-Bubblewrap replay changed both target tests from PASS to FAIL; SELF receipt /tmp/dependency-task1-fake-green-20261014-01a1345f.json. Native positive replay remains NOT_RUN. Impact: Test assertions only; production execution behavior is unchanged. Refs: Backend PR #196; Documents design 1790cf8
405 lines
13 KiB
Go
405 lines
13 KiB
Go
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"
|
||
"syscall"
|
||
"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) {
|
||
out, state, _ := sandboxRunWithPath(t, script, timeout)
|
||
return out, state
|
||
}
|
||
|
||
func sandboxRunWithPath(t *testing.T, script string, timeout time.Duration) (string, 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, p
|
||
}
|
||
|
||
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; echo SANDBOX_SCRIPT_RAN; exit 7", 30*time.Second)
|
||
if state != "exit=7" || !strings.Contains(out, "SANDBOX_SCRIPT_RAN") {
|
||
t.Fatalf("sandbox script did not run: state=%s out=%q", state, out)
|
||
}
|
||
if strings.Contains(out, "topsecret") {
|
||
t.Fatalf("sandbox read real home secret (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 + "' 2>&1; echo SANDBOX_SCRIPT_RAN; exit 7"}, 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()
|
||
exit, ok := werr.(*exec.ExitError)
|
||
if !ok || exit.ExitCode() != 7 || !strings.Contains(buf.String(), "SANDBOX_SCRIPT_RAN") {
|
||
t.Fatalf("sandbox script did not run: wait=%v out=%q", werr, buf.String())
|
||
}
|
||
if strings.Contains(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, scriptPath := sandboxRunWithPath(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) // 给孤儿进程留存活窗口
|
||
count, err := sandboxScriptProcessCount(scriptPath)
|
||
if err != nil {
|
||
t.Skipf("host ps probe failed: %v", err)
|
||
}
|
||
if count != 0 {
|
||
t.Fatalf("sandbox script still has %d host processes: %s", count, scriptPath)
|
||
}
|
||
}
|
||
|
||
func sandboxScriptProcessCount(scriptPath string) (int, error) {
|
||
out, err := exec.Command("ps", "-eo", "args").Output()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
count := 0
|
||
for _, args := range strings.Split(string(out), "\n") {
|
||
if strings.Contains(args, scriptPath) {
|
||
count++
|
||
}
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func TestSandboxOrphanProbeScopesScript(t *testing.T) {
|
||
dir := t.TempDir()
|
||
actual := filepath.Join(dir, "actual", "s.sh")
|
||
unrelated := filepath.Join(dir, "unrelated", "s.sh")
|
||
for _, path := range []string{actual, unrelated} {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, []byte("sleep 10 & wait\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
start := func(path string) {
|
||
proc := exec.Command("bash", path)
|
||
proc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||
if err := proc.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = syscall.Kill(-proc.Process.Pid, syscall.SIGKILL); _ = proc.Wait() })
|
||
}
|
||
start(unrelated)
|
||
for deadline := time.Now().Add(time.Second); ; {
|
||
count, err := sandboxScriptProcessCount(unrelated)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if count > 0 {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatal("unrelated control script did not start")
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
if count, err := sandboxScriptProcessCount(actual); err != nil || count != 0 {
|
||
t.Fatalf("unrelated process counted as this sandbox script: count=%d err=%v", count, err)
|
||
}
|
||
start(actual)
|
||
for deadline := time.Now().Add(time.Second); ; {
|
||
count, err := sandboxScriptProcessCount(actual)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if count > 0 {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatal("own sandbox script was not detected")
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// TestSandboxArgvDepsLayer F3:依赖层在 --tmpfs /home 之后只读挂回原路径
|
||
// (顺序敏感——先遮蔽再挂回),且挂载点存在性由 bwrap 创建。
|
||
func TestSandboxArgvDepsLayer(t *testing.T) {
|
||
argv := sandboxArgv(ExecSpec{
|
||
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
|
||
Dir: "/data/s", DepsDir: "/home/u/.local/lib/python3.14/site-packages",
|
||
})
|
||
joined := strings.Join(argv, "\x00")
|
||
deps := "--ro-bind\x00/home/u/.local/lib/python3.14/site-packages\x00/home/u/.local/lib/python3.14/site-packages"
|
||
if !strings.Contains(joined, deps) {
|
||
t.Fatalf("sandbox argv missing deps ro-bind: %v", argv)
|
||
}
|
||
tmpHome := strings.Index(joined, "--tmpfs\x00/home")
|
||
depsAt := strings.Index(joined, deps)
|
||
if tmpHome < 0 || depsAt < tmpHome {
|
||
t.Fatalf("deps bind must come after --tmpfs /home: %v", argv)
|
||
}
|
||
}
|
||
|
||
// TestExecEnvJoinsPythonPath F3:依赖层与技能根合成一条 PYTHONPATH,
|
||
// 不允许重复键互相覆盖(否则只挂载了依赖层却 import 不到)。
|
||
func TestExecEnvJoinsPythonPath(t *testing.T) {
|
||
oldSkills, oldDeps := skillsRoot, execDepsDir
|
||
defer func() { skillsRoot, execDepsDir = oldSkills, oldDeps }()
|
||
skillsRoot = "/data/skills"
|
||
execDepsDir = "/opt/pydeps"
|
||
env := execEnv()
|
||
count, value := 0, ""
|
||
for _, kv := range env {
|
||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||
count++
|
||
value = strings.TrimPrefix(kv, "PYTHONPATH=")
|
||
}
|
||
}
|
||
if count != 1 {
|
||
t.Fatalf("PYTHONPATH entries = %d(必须合成一条): %v", count, env)
|
||
}
|
||
if !strings.Contains(value, "/opt/pydeps") || !strings.Contains(value, "/data/skills/house-style") {
|
||
t.Fatalf("PYTHONPATH = %q(应同时含依赖层与 house-style)", value)
|
||
}
|
||
}
|