You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user