//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 --user uid:gid --read-only // --network --pids-limit --memory --cpus --mount --mount // -w ` where the image is the same // runtime image (so the agent CLI is present). The cleanup closure runs // `docker stop ` 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 }