Refuse launch dirs containing the API socket and stop the service with the launcher
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint 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:29:08 +11:00
parent af55d83de6
commit 818ec3f59c
4 changed files with 49 additions and 9 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:
+9 -4
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)
@@ -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)
}
+25
View File
@@ -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)
+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
}
}