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

116 lines
4.7 KiB
Go

// Package sandbox provides a pluggable per-session execution sandbox that sits
// between acp.session_service.Create and acp.Supervisor.Spawn. It confines an
// agent child process to a low-privilege UID, bind-mounts only that session's
// worktree + the per-user agent home, applies OS resource limits, and routes
// egress through an allowlist proxy — all WITHOUT touching proc.Group's
// Setpgid-based tree-kill (Apply only ADDS to cmd.SysProcAttr, never replaces).
//
// Three modes, config-gated via acp.sandbox.mode:
// - none : no-op (default; Windows dev + CI + any non-Linux box)
// - uid : drop to a low-priv UID + setrlimit (+ optional namespace/bind
// mounts) — Linux only
// - container : run the agent inside a rootless runc/bwrap or docker container
// on an egress-restricted network — Linux only
//
// The Linux-specific mechanics live in build-tagged files; this file holds the
// cross-platform contract and the factory so app.go and supervisor.go compile
// on every platform.
package sandbox
import "os/exec"
// Mode selects the sandbox strategy.
type Mode string
const (
// ModeNone disables sandboxing (no-op Apply). Default everywhere except
// production Linux.
ModeNone Mode = "none"
// ModeUID drops to a low-priv UID + setrlimit (+ optional bind mounts).
ModeUID Mode = "uid"
// ModeContainer runs the child inside a rootless container.
ModeContainer Mode = "container"
)
// Limits captures the OS resource limits applied to the child (and its tree).
// A zero field means "do not set this limit" (inherit the parent's).
type Limits struct {
AddressSpaceBytes uint64 // RLIMIT_AS — caps total virtual memory (anti big-alloc)
NProc uint64 // RLIMIT_NPROC — caps process/thread count (anti fork-bomb)
CPUSeconds uint64 // RLIMIT_CPU — caps CPU seconds
PIDs uint64 // cgroup pids.max (container mode); mirrors NProc for uid
DiskBytes uint64 // RLIMIT_FSIZE — caps max file size the child can write
}
// Spec is the per-spawn sandbox request, derived from the session + agent kind.
type Spec struct {
Mode Mode
UID int // target low-priv uid (uid mode); base+offset computed by caller
GID int // target low-priv gid
HomeDir string // per-user agent home (writable, bind-mounted in)
WorktreeDir string // this session's worktree (writable, bind-mounted in)
DataRoot string // /data root to mask everything else under
Rlimits Limits
ProxyURL string // HTTP(S)_PROXY value injected into the child env (egress allowlist)
NoProxy string // NO_PROXY value (e.g. localhost,127.0.0.1)
}
// Sandbox applies a Spec to an exec.Cmd before it is started.
type Sandbox interface {
// Apply mutates cmd.SysProcAttr (Credential, namespace/chroot flags) and
// cmd.Env (HTTP(S)_PROXY/NO_PROXY) BEFORE proc.Group.Prepare and cmd.Start.
// It returns a cleanup closure (unmount binds, remove ephemeral dirs, stop
// container) that the supervisor calls from monitorProcess AFTER
// group.Close(). cleanup is always non-nil (a no-op when there is nothing to
// clean) and must be safe to call exactly once. Apply MUST NOT set Setpgid —
// that is proc.Group's job; on Linux it ADDS to the existing SysProcAttr.
Apply(cmd *exec.Cmd, spec Spec) (cleanup func(), err error)
}
// New returns the Sandbox implementation for the given mode. On non-Linux
// platforms (or mode=none) it always returns the no-op sandbox. The actual
// uid/container constructors are provided by build-tagged files; newPlatform
// returns nil when the mode is unsupported on this OS, in which case we fall
// back to no-op so dev/CI never break.
func New(mode Mode) Sandbox {
switch mode {
case ModeUID, ModeContainer:
if sb := newPlatform(mode); sb != nil {
return sb
}
// Unsupported on this platform → safe no-op fallback.
return noopSandbox{}
default:
return noopSandbox{}
}
}
// noopSandbox makes no changes and returns a no-op cleanup. It is the default
// and the fallback for unsupported modes/platforms.
type noopSandbox struct{}
func (noopSandbox) Apply(_ *exec.Cmd, _ Spec) (func(), error) {
return func() {}, nil
}
// injectProxyEnv appends egress-proxy env vars to cmd.Env when a proxy URL is
// configured. Shared by uid + container modes (both honor HTTP(S)_PROXY). Kept
// here so the cross-platform contract is testable without build tags.
func injectProxyEnv(cmd *exec.Cmd, spec Spec) {
if spec.ProxyURL == "" {
return
}
noProxy := spec.NoProxy
if noProxy == "" {
noProxy = "localhost,127.0.0.1,::1"
}
cmd.Env = append(cmd.Env,
"HTTP_PROXY="+spec.ProxyURL,
"HTTPS_PROXY="+spec.ProxyURL,
"http_proxy="+spec.ProxyURL,
"https_proxy="+spec.ProxyURL,
"NO_PROXY="+noProxy,
"no_proxy="+noProxy,
)
}