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

93 lines
3.3 KiB
Go

//go:build linux
package sandbox
import (
"fmt"
"os/exec"
"strconv"
"syscall"
)
// uidSandbox confines the agent child to a low-privilege UID/GID and applies OS
// resource limits, while leaving proc.Group's Setpgid (tree-kill) intact.
//
// Tree-kill invariant: we ONLY add Credential to the existing SysProcAttr. We do
// NOT touch Setpgid (proc.Group sets it after Apply via group.Prepare). The
// negative-pgid SIGTERM/SIGKILL path keeps working because the new process group
// leader is the same child — it simply runs as the low-priv uid now.
//
// Resource limits are applied by wrapping the real binary in `prlimit(1)`:
// `prlimit --as=N --nproc=N --cpu=N --fsize=N -- <binary> <args...>`. prlimit
// sets the limits on the child it execs, so they apply to the agent and (since
// limits are inherited across fork) its whole subtree — exactly the tree the
// pgid covers. Wrapping is additive to Credential/Setpgid and does not change
// the process-group topology (prlimit execs the target in place, becoming the
// group leader). If prlimit is unavailable, UID drop still applies and we log no
// rlimits (best-effort; container mode is the hard boundary).
//
// Bind-mount masking (CLONE_NEWNS + remount /data with only HomeDir+WorktreeDir
// visible) is intentionally NOT auto-enabled here: it requires the server to
// hold CAP_SYS_ADMIN / a user namespace, which the rootless production container
// (USER app) does not have by default. The robust isolation path is
// ModeContainer. uidSandbox therefore provides UID drop + rlimits + egress proxy
// as the minimum viable confinement, and documents that filesystem masking is a
// container-mode guarantee.
type uidSandbox struct{}
func (s *uidSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) {
if spec.UID <= 0 {
return func() {}, fmt.Errorf("sandbox uid: invalid target uid %d", spec.UID)
}
// UID/GID drop — ADDITIVE to whatever proc.Group will set (Setpgid).
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
gid := spec.GID
if gid <= 0 {
gid = spec.UID
}
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: uint32(spec.UID),
Gid: uint32(gid),
}
// Resource limits via prlimit wrapper (if available).
if prlimit, err := exec.LookPath("prlimit"); err == nil {
args := prlimitArgs(spec.Rlimits)
if len(args) > 0 {
// Re-point the command through prlimit, preserving the original
// binary + args after the `--` separator.
orig := append([]string{cmd.Path}, cmd.Args[1:]...)
cmd.Path = prlimit
cmd.Args = append(append([]string{prlimit}, args...), append([]string{"--"}, orig...)...)
}
}
// Egress allowlist: inject HTTP(S)_PROXY/NO_PROXY into the stripped child env.
injectProxyEnv(cmd, spec)
// No ephemeral resources to tear down in uid mode (no bind mounts here);
// return a no-op cleanup.
return func() {}, nil
}
// prlimitArgs builds the prlimit flags for the non-zero limits in l.
func prlimitArgs(l Limits) []string {
var args []string
if l.AddressSpaceBytes > 0 {
args = append(args, "--as="+strconv.FormatUint(l.AddressSpaceBytes, 10))
}
if l.NProc > 0 {
args = append(args, "--nproc="+strconv.FormatUint(l.NProc, 10))
}
if l.CPUSeconds > 0 {
args = append(args, "--cpu="+strconv.FormatUint(l.CPUSeconds, 10))
}
if l.DiskBytes > 0 {
args = append(args, "--fsize="+strconv.FormatUint(l.DiskBytes, 10))
}
return args
}