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 7a8c906..2089ebe 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) @@ -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) } diff --git a/main_test.go b/main_test.go index 8df3e72..b75125d 100644 --- a/main_test.go +++ b/main_test.go @@ -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) 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 } }