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