From 818ec3f59ceae56f846841a2a2f33ce5f9436bbe Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:29:08 +1100 Subject: [PATCH] Refuse launch dirs containing the API socket and stop the service with the launcher --- .woodpecker/lint.yaml | 2 +- main.go | 13 +++++++++---- main_test.go | 25 +++++++++++++++++++++++++ podman.go | 18 ++++++++++++++---- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index 8a75739..3ed5c62 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -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: diff --git a/main.go b/main.go index 626f92b..4860c99 100644 --- a/main.go +++ b/main.go @@ -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) @@ -332,14 +337,14 @@ func ensureAllowlist(path string) error { return nil } -// checkLaunchDir refuses launch dirs that would mount $HOME or the allowlist -// read-write. +// checkLaunchDir refuses launch dirs that would mount $HOME, the allowlist or +// the podman API socket read-write. func checkLaunchDir(pwd, allowlist string) error { pwd = realpath(pwd) if inside(realpath(os.Getenv("HOME")), pwd) { return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd) } - for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist))} { + for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), runtimeDir()} { if dir = realpath(dir); inside(dir, pwd) { return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir) } diff --git a/main_test.go b/main_test.go index 0d90364..2b42d7f 100644 --- a/main_test.go +++ b/main_test.go @@ -251,6 +251,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) @@ -284,6 +298,17 @@ 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) + } + check(t, "subdir", nil, checkLaunchDir("/tmp/proj", "/nonexistent/hosts")) +} + func leftovers(t *testing.T, dir string) { t.Helper() entries, _ := os.ReadDir(dir) diff --git a/podman.go b/podman.go index 269821f..dd4cc4c 100644 --- a/podman.go +++ b/podman.go @@ -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 } }