Files
agentic-coding-workflow/internal/acp/sandbox/sandbox_container_linux.go
T
2026-06-22 08:55:57 +08:00

119 lines
4.4 KiB
Go

//go:build linux
package sandbox
import (
"fmt"
"os/exec"
"strconv"
"time"
"github.com/google/uuid"
)
// containerSandbox runs the agent binary inside a rootless container with an
// egress-restricted network and the two writable paths bind-mounted in. This is
// the hard isolation boundary (UID + filesystem mask + network policy + cgroup
// limits) — unlike uid mode, a non-cooperating binary cannot bypass the egress
// allowlist because the container network only routes through the proxy/network
// policy.
//
// It rewrites cmd to `docker run --rm --name <n> --user uid:gid --read-only
// --network <egress-net> --pids-limit --memory --cpus --mount <home> --mount
// <worktree> -w <worktree> <image> <binary> <args>` where the image is the same
// runtime image (so the agent CLI is present). The cleanup closure runs
// `docker stop <n>` so proc.Group's tree-kill (which signals the `docker run`
// client's pgid) is reinforced by stopping the actual container — otherwise
// killing the client could orphan the container.
//
// Tree-kill invariant: Apply does NOT set Setpgid (proc.Group owns it). docker
// run becomes the group leader; killing -pgid signals it, and cleanup() issues
// docker stop for the container it spawned. The mapping client->container is via
// the deterministic --name we assign.
//
// The container runtime + egress network are provisioned by the deployment
// (Dockerfile installs the runtime; docker-compose defines the egress-net + the
// forward-proxy sidecar). Image/network/runtime are read from the spec's
// DataRoot convention or sensible defaults; here we keep them parameterized via
// package-level defaults so app.go can override without a code change later.
type containerSandbox struct{}
// container runtime defaults; overridable by deployment via env in a later pass.
const (
defaultRuntime = "docker"
defaultImage = "agent-coding-workflow-sandbox:latest"
defaultEgressNet = "egress-net"
containerStopWait = 5 * time.Second
)
func (s *containerSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) {
if spec.WorktreeDir == "" {
return func() {}, fmt.Errorf("sandbox container: worktree dir required")
}
runtime, err := exec.LookPath(defaultRuntime)
if err != nil {
// Runtime missing: fall back to no-op cleanup but surface the error so
// the supervisor can decide (it currently logs and proceeds unsandboxed
// only if configured to; otherwise spawn fails).
return func() {}, fmt.Errorf("sandbox container: %s not found: %w", defaultRuntime, err)
}
name := "acw-sbx-" + uuid.NewString()
orig := append([]string{cmd.Path}, cmd.Args[1:]...)
runArgs := []string{
"run", "--rm", "--name", name,
"--read-only",
"--network", defaultEgressNet,
"-w", spec.WorktreeDir,
"--mount", "type=bind,source=" + spec.WorktreeDir + ",target=" + spec.WorktreeDir,
}
if spec.HomeDir != "" {
runArgs = append(runArgs,
"--mount", "type=bind,source="+spec.HomeDir+",target="+spec.HomeDir,
"-e", "HOME="+spec.HomeDir)
}
if spec.UID > 0 {
gid := spec.GID
if gid <= 0 {
gid = spec.UID
}
runArgs = append(runArgs, "--user", strconv.Itoa(spec.UID)+":"+strconv.Itoa(gid))
}
if spec.Rlimits.AddressSpaceBytes > 0 {
runArgs = append(runArgs, "--memory", strconv.FormatUint(spec.Rlimits.AddressSpaceBytes, 10))
}
if spec.Rlimits.PIDs > 0 {
runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.PIDs, 10))
} else if spec.Rlimits.NProc > 0 {
runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.NProc, 10))
}
if spec.Rlimits.CPUSeconds > 0 {
// docker has no direct CPU-seconds cap; approximate with --cpus=1 and
// rely on the wall-clock/budget reaper for hard CPU-time bounds.
runArgs = append(runArgs, "--cpus", "1")
}
// Egress proxy env (the proxy itself enforces the allowlist on egress-net).
if spec.ProxyURL != "" {
noProxy := spec.NoProxy
if noProxy == "" {
noProxy = "localhost,127.0.0.1,::1"
}
runArgs = append(runArgs,
"-e", "HTTP_PROXY="+spec.ProxyURL,
"-e", "HTTPS_PROXY="+spec.ProxyURL,
"-e", "NO_PROXY="+noProxy)
}
runArgs = append(runArgs, defaultImage)
runArgs = append(runArgs, orig...)
cmd.Path = runtime
cmd.Args = append([]string{runtime}, runArgs...)
cleanup := func() {
stop := exec.Command(runtime, "stop", "-t", strconv.Itoa(int(containerStopWait.Seconds())), name)
_ = stop.Run() // best-effort; --rm removes it after stop
}
return cleanup, nil
}