//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 -- `. 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 }