Merge benvin/go-launcher
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

This commit is contained in:
2026-10-24 17:33:03 +11:00
4 changed files with 51 additions and 13 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ when:
steps:
- name: lint
image: "golangci/golangci-lint:latest"
image: "golangci/golangci-lint:v2.15.0"
commands:
- golangci-lint run ./...
backend_options:
+10 -8
View File
@@ -80,6 +80,11 @@ func allowlistPath() string {
return envOr("AGENT_SANDBOX_ALLOWLIST", filepath.Join(configDir(), "allowed-hosts"))
}
// runtimeDir holds the session dirs, including the private API socket's.
func runtimeDir() string {
return envOr("XDG_RUNTIME_DIR", "/tmp")
}
func warn(format string, a ...any) {
fmt.Fprintf(os.Stderr, "agent-sandbox: "+format+"\n", a...)
}
@@ -115,7 +120,7 @@ func launch(ctx context.Context, args []string) int {
}
session := fmt.Sprintf("agent-sandbox-%d-%d", time.Now().Unix(), os.Getpid())
runtimeDir := envOr("XDG_RUNTIME_DIR", "/tmp")
runtimeDir := runtimeDir()
tmp, err := os.MkdirTemp(runtimeDir, session+".")
if err != nil {
warn("%v", err)
@@ -341,10 +346,7 @@ func allowedPathsFile() string {
// sensitiveDirs are host dirs an allowed-paths root may neither contain nor sit under.
func sensitiveDirs(home string) []string {
dirs := []string{home + "/.ssh", home + "/.gnupg", home + "/.config", filepath.Dir(configDir()),
home + "/.local/share/containers"}
if v := os.Getenv("XDG_RUNTIME_DIR"); v != "" {
dirs = append(dirs, v)
}
home + "/.local/share/containers", runtimeDir()}
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
dirs = append(dirs, v+"/containers")
}
@@ -396,8 +398,8 @@ func allowedRoots(home string) (roots []string, def bool, err error) {
}
// checkLaunchDir allows only launch dirs strictly under an allowed root, never
// $HOME itself or anything holding the sandbox's config. Under the default
// $HOME root, dot-dirs of $HOME are refused too.
// $HOME itself or anything holding the sandbox's config or the API socket.
// Under the default $HOME root, dot-dirs of $HOME are refused too.
func checkLaunchDir(pwd, allowlist string) error {
pwd = realpath(pwd)
home := realpath(os.Getenv("HOME"))
@@ -413,7 +415,7 @@ func checkLaunchDir(pwd, allowlist string) error {
}
paths := allowedPathsFile()
for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)),
filepath.Dir(paths), filepath.Dir(realpath(paths))} {
filepath.Dir(paths), filepath.Dir(realpath(paths)), runtimeDir()} {
if dir = realpath(dir); inside(dir, pwd) {
return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir)
}
+26
View File
@@ -268,6 +268,20 @@ func TestLaunchRefusals(t *testing.T) {
{"allowlist-dir", "home/proj", func(t *testing.T, e *env) {
t.Setenv("AGENT_SANDBOX_ALLOWLIST", e.work+"/home/proj/hosts")
}},
{"runtime-dir", "other/run", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run")
}},
{"runtime-parent", "other", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run")
}},
{"runtime-symlink", "other", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
_ = os.Symlink(e.work+"/other/run", e.work+"/home/runlink")
t.Setenv("XDG_RUNTIME_DIR", e.work+"/home/runlink")
}},
{"runtime-fallback", "/tmp", func(t *testing.T, e *env) { t.Setenv("XDG_RUNTIME_DIR", "") }},
{"colon", "home/a:b", func(t *testing.T, e *env) { _ = os.Mkdir(e.work+"/home/a:b", 0o755) }},
{"symlinked-allowlist-target", "home/dotfiles", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/home/dotfiles", 0o755)
@@ -321,6 +335,18 @@ func TestLaunchRefusals(t *testing.T) {
}
}
func TestCheckLaunchDirRuntimeFallback(t *testing.T) {
t.Setenv("HOME", "/nonexistent/home")
t.Setenv("XDG_CONFIG_HOME", "/nonexistent/cfg")
t.Setenv("XDG_RUNTIME_DIR", "")
err := checkLaunchDir("/tmp", "/nonexistent/hosts")
if err == nil || !strings.Contains(err.Error(), "contains "+realpath("/tmp")) {
t.Fatalf("got %v", err)
}
t.Setenv("HOME", "/tmp/home")
check(t, "subdir", nil, checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts"))
}
func leftovers(t *testing.T, dir string) {
t.Helper()
entries, _ := os.ReadDir(dir)
+14 -4
View File
@@ -53,21 +53,31 @@ var startService = func(dir string) (context.Context, engine, func(), error) {
return nil, nil, nil, err
}
defer func() { _ = logf.Close() }()
cmd := exec.Command("podman", "system", "service", "--time=0", "unix://"+sock)
// The service stops when we close lifeline, or the kernel does on SIGKILL.
// Pdeathsig can't do this: podman's userns join resets it.
lifeline, lifelineW, err := os.Pipe()
if err != nil {
return nil, nil, nil, err
}
cmd := exec.Command("sh", "-c", `podman system service --time=60 "$1" 3<&- & p=$!; { read -r _ <&3; kill $p; } & wait $p`, "sh", "unix://"+sock)
cmd.Stdout, cmd.Stderr = logf, logf
cmd.ExtraFiles = []*os.File{lifeline}
// Own process group: terminal signals must not kill it before cleanup.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
err = cmd.Start()
_ = lifeline.Close()
if err != nil {
_ = lifelineW.Close()
return nil, nil, nil, err
}
exited := make(chan struct{})
go func() { _ = cmd.Wait(); close(exited) }()
stop := func() {
_ = cmd.Process.Signal(syscall.SIGTERM)
_ = lifelineW.Close()
select {
case <-exited:
case <-time.After(5 * time.Second):
_ = cmd.Process.Kill()
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-exited
}
}