You've already forked agentic-coding-workflow
119 lines
4.4 KiB
Go
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
|
|
}
|