feat: manage task process lifetimes and preserve turn history

This commit is contained in:
Ed1s0nZ
2026-09-16 17:52:58 +08:00
parent fd1c13a43d
commit f7882be546
54 changed files with 3650 additions and 330 deletions
+120
View File
@@ -0,0 +1,120 @@
//go:build linux
package processguard
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func TestCgroupContainsSetsidAndAppliesLimits(t *testing.T) {
opts := testOptions()
if opts.CgroupRoot == "" {
t.Skip("set CSAI_TEST_CGROUP_ROOT to a delegated cgroup v2 root")
}
opts.Mode = "required"
opts.CPUQuotaMicros = 50000
id := testID()
g, err := NewWithOptions(id, opts)
if err != nil {
t.Fatal(err)
}
defer closeTestGroup(t, g)
file := filepath.Join(t.TempDir(), "escaped")
cmd, err := startTestCommand(g, fmt.Sprintf("setsid sh -c 'echo $$ > %s; exec sleep 300' </dev/null >/dev/null 2>&1 &", file))
if err != nil {
t.Fatal(err)
}
reaped := make(chan struct{})
go func() { _ = cmd.Wait(); close(reaped) }()
pid := readPID(t, file)
<-reaped // The launching shell is gone; the cgroup must still own setsid descendants.
root := filepath.Join(opts.CgroupRoot, "task-"+id)
for name, want := range map[string]string{"pids.max": "64", "memory.max": "268435456", "cpu.max": "50000 100000"} {
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil || strings.TrimSpace(string(data)) != want {
t.Fatalf("%s=%s err=%v", name, data, err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = g.Close(ctx); err != nil {
t.Fatal(err)
}
<-reaped
waitGone(t, pid)
if _, err = os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("cgroup retained after cleanup: %v", err)
}
}
func TestCgroupStartupDelegationAndRecovery(t *testing.T) {
opts := testOptions()
if opts.CgroupRoot == "" {
t.Skip("requires delegated cgroup fixture")
}
before, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
t.Fatal(err)
}
original := ""
for _, line := range strings.Split(string(before), "\n") {
if strings.HasPrefix(line, "0::") {
original = filepath.Join("/sys/fs/cgroup", strings.TrimPrefix(line, "0::"))
}
}
root := filepath.Join(opts.CgroupRoot, "startup-fixture")
if err = os.Mkdir(root, 0700); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(root, "cgroup.procs"), []byte(fmt.Sprint(os.Getpid())), 0600); err != nil {
t.Fatal(err)
}
defer func() {
_ = os.WriteFile(filepath.Join(original, "cgroup.procs"), []byte(fmt.Sprint(os.Getpid())), 0600)
if rootLock != nil {
_ = rootLock.Close()
rootLock = nil
}
_ = removeCgroupTree(root)
}()
stale := filepath.Join(root, "task-"+testID())
if err = os.Mkdir(stale, 0700); err != nil {
t.Fatal(err)
}
dir, err := os.Open(stale)
if err != nil {
t.Fatal(err)
}
defer dir.Close()
cmd := exec.Command("sh", "-c", "exec sleep 300")
cmd.SysProcAttr = &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: int(dir.Fd()), Setsid: true}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
reaped := make(chan struct{})
go func() { _ = cmd.Wait(); close(reaped) }()
opts.CgroupRoot = root
opts.Mode = "required"
if err = configurePlatform(&opts); err != nil {
_ = cmd.Process.Kill()
<-reaped
t.Fatal(err)
}
<-reaped
waitGone(t, cmd.Process.Pid)
if _, err = os.Stat(stale); !os.IsNotExist(err) {
t.Fatalf("stale task cgroup was not removed: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "cgroup.subtree_control"))
if err != nil || !strings.Contains(string(data), "memory") {
t.Fatalf("delegation not enabled: %s %v", data, err)
}
}
+52
View File
@@ -0,0 +1,52 @@
package processguard
import (
"context"
"crypto/rand"
"errors"
"fmt"
"os"
"os/exec"
)
// Check exercises the real creation path, including clone3/Job inheritance,
// watchdog readiness, admission and cleanup. It does not start the HTTP server.
func Check(ctx context.Context) (backend string, err error) {
var id [16]byte
if _, err = rand.Read(id[:]); err != nil {
return "", err
}
name := fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
g, err := New(name)
if err != nil {
return "", err
}
defer func() { err = errors.Join(err, g.Close(ctx)) }()
exe, err := os.Executable()
if err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, exe, "-h")
configureGuardian(cmd)
launch, err := g.Prepare(cmd)
if err != nil {
return "", err
}
defer launch.Dispose()
if err = cmd.Start(); err != nil {
return "", err
}
if err = launch.Commit(); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return "", err
}
err = cmd.Wait()
if err != nil {
return "", err
}
if err = g.Release(cmd.Process.Pid); err != nil {
return "", err
}
return g.Name(), nil
}
+180
View File
@@ -0,0 +1,180 @@
//go:build !windows
package processguard
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"sync"
"syscall"
"time"
)
type unixGroup struct {
mu sync.Mutex
watcher *watchdog
pids map[int]struct{}
closed bool
}
func newUnixGroup() (*unixGroup, error) {
g := &unixGroup{pids: make(map[int]struct{})}
w, err := startWatchdog(watchRequest{Name: "process_group"}, func() {
g.mu.Lock()
defer g.mu.Unlock()
g.closed = true
for pid := range g.pids {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
})
if err != nil {
return nil, err
}
g.watcher = w
return g, nil
}
func (g *unixGroup) Name() string { return "process_group_watchdog" }
func configureGuardian(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} }
type childSpec struct {
Path string
Args []string
}
func (g *unixGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return nil, fmt.Errorf("process group is closed")
}
// A dead guardian rejects subsequent launches before user code is executed.
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
return nil, err
}
read, write, err := os.Pipe()
if err != nil {
return nil, err
}
spec, _ := json.Marshal(childSpec{Path: cmd.Path, Args: cmd.Args})
exe, err := os.Executable()
if err != nil {
read.Close()
write.Close()
return nil, err
}
fd := 3 + len(cmd.ExtraFiles)
cmd.ExtraFiles = append(cmd.ExtraFiles, read)
cmd.Path = exe
cmd.Args = []string{exe, childArg, strconv.Itoa(fd), base64.RawStdEncoding.EncodeToString(spec)}
return &Launch{Dispose: func() { read.Close(); write.Close() }, Commit: func() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return fmt.Errorf("process group is closed")
}
pid := cmd.Process.Pid
if _, err := g.watcher.send(watchRequest{Op: "add", PID: pid}); err != nil {
return err
}
g.pids[pid] = struct{}{}
_, err := write.Write([]byte{1})
return err
}}, nil
}
func (g *unixGroup) Release(pid int) error {
g.mu.Lock()
defer g.mu.Unlock()
if _, ok := g.pids[pid]; !ok {
return nil
}
select {
case <-g.watcher.done:
delete(g.pids, pid)
return nil
default:
}
if _, err := g.watcher.send(watchRequest{Op: "release", PID: pid}); err != nil {
return err
}
delete(g.pids, pid)
return nil
}
func (g *unixGroup) Close(ctx context.Context) error {
g.mu.Lock()
defer g.mu.Unlock()
g.closed = true
for pid := range g.pids {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
for {
for pid := range g.pids {
if syscall.Kill(-pid, 0) == syscall.ESRCH {
delete(g.pids, pid)
}
}
if len(g.pids) == 0 {
return g.watcher.close()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
}
func gatedChildMain(args []string) error {
if len(args) != 2 {
return fmt.Errorf("invalid internal launch")
}
fd, err := strconv.Atoi(args[0])
if err != nil || fd < 3 {
return fmt.Errorf("invalid launch gate")
}
gate := os.NewFile(uintptr(fd), "launch-gate")
var token [1]byte
if _, err = io.ReadFull(gate, token[:]); err != nil {
return fmt.Errorf("owner exited before launch: %w", err)
}
gate.Close()
if token[0] != 1 {
return fmt.Errorf("invalid launch token")
}
b, err := base64.RawStdEncoding.DecodeString(args[1])
if err != nil {
return err
}
var spec childSpec
if err = json.Unmarshal(b, &spec); err != nil {
return err
}
return syscall.Exec(spec.Path, spec.Args, os.Environ())
}
func groupGuardian(dec *json.Decoder, enc *json.Encoder) error {
pids := make(map[int]struct{})
return serveGuardian(dec, enc, func(req watchRequest) error {
switch req.Op {
case "ping":
case "add":
if req.PID <= 1 {
return fmt.Errorf("invalid PID")
}
pids[req.PID] = struct{}{}
case "release":
delete(pids, req.PID)
default:
return fmt.Errorf("unknown guardian command")
}
return nil
}, func() error {
for pid := range pids {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
return nil
})
}
+89
View File
@@ -0,0 +1,89 @@
// Package processguard provides OS containment and out-of-process crash cleanup.
// It is intentionally independent of the Agent/MCP packages so it can be
// cross-compiled and exercised without starting the application.
package processguard
import (
"context"
"fmt"
"os/exec"
"sync"
)
type Options struct {
Mode string `yaml:"mode" json:"mode"` // auto, required, process_group
CgroupRoot string `yaml:"cgroup_root" json:"cgroup_root"`
MaxProcesses int `yaml:"max_processes" json:"max_processes"`
MemoryMaxBytes int64 `yaml:"memory_max_bytes" json:"memory_max_bytes"`
CPUQuotaMicros int64 `yaml:"cpu_quota_micros" json:"cpu_quota_micros"` // per 100000 us
}
// Prepared commands must call Commit after Start and always call Dispose.
// Commit releases the Unix fallback launch gate only after watchdog ownership
// is acknowledged. Strong backends assign containment atomically at creation.
type Launch struct {
Commit func() error
Dispose func()
}
type Group interface {
Name() string
Prepare(*exec.Cmd) (*Launch, error)
Release(int) error
Close(context.Context) error
}
var configured = struct {
sync.RWMutex
opts Options
}{opts: Options{Mode: "auto", MaxProcesses: 256, MemoryMaxBytes: 2 << 30}}
func normalize(o Options) (Options, error) {
if o.Mode == "" {
o.Mode = "auto"
}
if o.Mode != "auto" && o.Mode != "required" && o.Mode != "process_group" {
return o, fmt.Errorf("invalid process isolation mode %q", o.Mode)
}
if o.MaxProcesses == 0 {
o.MaxProcesses = 256
}
if o.MemoryMaxBytes == 0 {
o.MemoryMaxBytes = 2 << 30
}
if o.MaxProcesses < 1 || o.MaxProcesses > 65535 || o.MemoryMaxBytes < 0 || o.CPUQuotaMicros < 0 {
return o, fmt.Errorf("invalid process isolation resource limits")
}
return o, nil
}
// Configure validates deployment before accepting any tasks. An explicit root
// or required mode fails closed; it never silently falls back after an error.
func Configure(o Options) error {
var err error
o, err = normalize(o)
if err != nil {
return err
}
if err = configurePlatform(&o); err != nil {
return err
}
configured.Lock()
configured.opts = o
configured.Unlock()
return nil
}
func New(id string) (Group, error) {
configured.RLock()
o := configured.opts
configured.RUnlock()
return NewWithOptions(id, o)
}
func NewWithOptions(id string, o Options) (Group, error) {
var err error
o, err = normalize(o)
if err != nil {
return nil, err
}
return newPlatformGroup(id, o)
}
+160
View File
@@ -0,0 +1,160 @@
//go:build !windows
package processguard
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func testID() string {
return fmt.Sprintf("%08x-1111-4111-8111-%012x", os.Getpid(), uint64(time.Now().UnixNano())&0xffffffffffff)
}
func testOptions() Options {
return Options{CgroupRoot: os.Getenv("CSAI_TEST_CGROUP_ROOT"), MaxProcesses: 64, MemoryMaxBytes: 256 << 20}
}
func closeTestGroup(t *testing.T, g Group) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := g.Close(ctx); err != nil {
t.Error(err)
}
}
func startTestCommand(g Group, command string) (*exec.Cmd, error) {
cmd := exec.Command("sh", "-c", command)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
launch, err := g.Prepare(cmd)
if err != nil {
return nil, err
}
defer launch.Dispose()
if err = cmd.Start(); err != nil {
return nil, err
}
if err = launch.Commit(); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return nil, err
}
return cmd, nil
}
func readPID(t *testing.T, path string) int {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
b, err := os.ReadFile(path)
if err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err == nil && pid > 0 {
return pid
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("no PID written to %s", path)
return 0
}
func waitGone(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) == syscall.ESRCH {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("PID %d survived cleanup", pid)
}
func TestGuardianReapsAfterOwnerSIGKILL(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "pid")
owner := exec.Command(os.Args[0], "-test.run=^TestGuardianOwnerHelper$")
owner.Env = append(os.Environ(), "CSAI_GUARD_TEST_OWNER=1", "CSAI_GUARD_TEST_PID="+pidPath)
if err := owner.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }()
pid := readPID(t, pidPath)
if err := owner.Process.Kill(); err != nil {
t.Fatal(err)
}
_ = owner.Wait()
waitGone(t, pid)
}
func TestGuardianOwnerHelper(t *testing.T) {
if os.Getenv("CSAI_GUARD_TEST_OWNER") != "1" {
t.Skip("subprocess helper")
}
g, err := NewWithOptions(testID(), testOptions())
if err != nil {
t.Fatal(err)
}
command := fmt.Sprintf("echo $$ > %q; exec sleep 300", os.Getenv("CSAI_GUARD_TEST_PID"))
cmd, err := startTestCommand(g, command)
if err != nil {
t.Fatal(err)
}
go cmd.Wait()
select {}
}
func TestGroupCloseAndAdmission(t *testing.T) {
g, err := NewWithOptions(testID(), testOptions())
if err != nil {
t.Fatal(err)
}
defer closeTestGroup(t, g)
cmd, err := startTestCommand(g, "exec sleep 300")
if err != nil {
t.Fatal(err)
}
reaped := make(chan struct{})
go func() { _ = cmd.Wait(); close(reaped) }()
closeTestGroup(t, g)
<-reaped
waitGone(t, cmd.Process.Pid)
if _, err = g.Prepare(exec.Command("sh", "-c", "true")); err == nil {
t.Fatal("closed containment accepted a command")
}
}
func TestLaunchGateOwnerDisappearsBeforeCommit(t *testing.T) {
if runtime.GOOS == "linux" && testOptions().CgroupRoot != "" {
t.Skip("cgroup assignment is atomic without a gate")
}
g, err := NewWithOptions(testID(), Options{})
if err != nil {
t.Fatal(err)
}
defer closeTestGroup(t, g)
file := filepath.Join(t.TempDir(), "should-not-exist")
cmd := exec.Command("sh", "-c", fmt.Sprintf("echo escaped > %q", file))
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
launch, err := g.Prepare(cmd)
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
launch.Dispose() // simulate owner crashing before watchdog registration
_ = cmd.Wait()
if _, err = os.Stat(file); !os.IsNotExist(err) {
t.Fatal("unregistered child executed user code")
}
}
func TestRequiredIsolationFailsClosed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows has Job Objects")
}
if _, err := NewWithOptions(testID(), Options{Mode: "required"}); err == nil {
t.Fatal("required isolation silently downgraded")
}
}
+97
View File
@@ -0,0 +1,97 @@
//go:build windows
package processguard
import (
"context"
"fmt"
"golang.org/x/sys/windows"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestWindowsJobOwnerHelper(t *testing.T) {
if os.Getenv("CSAI_JOB_OWNER") != "1" {
t.Skip("subprocess helper")
}
g, err := NewWithOptions(fmt.Sprintf("test-%d-%d", os.Getpid(), time.Now().UnixNano()), Options{Mode: "required"})
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestWindowsJobPayload$")
cmd.Env = append(os.Environ(), "CSAI_JOB_PAYLOAD=1")
launch, err := g.Prepare(cmd)
if err != nil {
t.Fatal(err)
}
defer launch.Dispose()
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
if err = launch.Commit(); err != nil {
t.Fatal(err)
}
go cmd.Wait()
select {}
}
func TestWindowsJobPayload(t *testing.T) {
if os.Getenv("CSAI_JOB_PAYLOAD") != "1" {
t.Skip("subprocess helper")
}
if err := os.WriteFile(os.Getenv("CSAI_JOB_PIDFILE"), []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
t.Fatal(err)
}
time.Sleep(300 * time.Second)
}
func TestWindowsJobReapsAfterOwnerKilled(t *testing.T) {
file := filepath.Join(t.TempDir(), "pid")
owner := exec.Command(os.Args[0], "-test.run=^TestWindowsJobOwnerHelper$")
owner.Env = append(os.Environ(), "CSAI_JOB_OWNER=1", "CSAI_JOB_PIDFILE="+file)
if err := owner.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }()
var pid int
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(file)
pid, _ = strconv.Atoi(strings.TrimSpace(string(data)))
if pid > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if pid == 0 {
t.Fatal("job child did not start")
}
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
t.Fatal(err)
}
defer windows.CloseHandle(handle)
_ = owner.Process.Kill()
_ = owner.Wait()
event, err := windows.WaitForSingleObject(handle, 5000)
if err != nil || event != windows.WAIT_OBJECT_0 {
t.Fatalf("child survived owner death: %d %v", event, err)
}
}
func TestWindowsJobClose(t *testing.T) {
g, err := NewWithOptions(fmt.Sprintf("test-%d-%d", os.Getpid(), time.Now().UnixNano()), Options{Mode: "required", CPUQuotaMicros: 100000})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = g.Close(ctx); err != nil {
t.Fatal(err)
}
if _, err = g.Prepare(exec.Command("cmd.exe", "/c", "exit")); err == nil {
t.Fatal("closed job admitted a process")
}
}
+313
View File
@@ -0,0 +1,313 @@
//go:build linux
package processguard
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
var rootLock *os.File // retained until server exit; never inherited by commands
func configurePlatform(o *Options) error {
if o.CgroupRoot == "" {
if o.Mode == "required" {
return fmt.Errorf("required isolation needs security.process_isolation.cgroup_root")
}
return nil
}
if o.Mode == "process_group" {
return fmt.Errorf("cgroup_root cannot be combined with process_group mode")
}
if o.CgroupRoot == "auto" {
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return err
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "0::") {
o.CgroupRoot = filepath.Join("/sys/fs/cgroup", strings.TrimPrefix(line, "0::"))
break
}
}
}
root, err := validateRoot(o.CgroupRoot)
if err != nil {
return err
}
o.CgroupRoot = root
// An exclusive host-side lock prevents one server's recovery sweep from
// killing tasks owned by another server using the same delegated root.
hash := sha256.Sum256([]byte(root))
lockPath := filepath.Join(os.TempDir(), fmt.Sprintf("cyberstrike-cgroup-%d-%x.lock", os.Getuid(), hash[:12]))
fd, err := unix.Open(lockPath, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0600)
if err != nil {
return err
}
lock := os.NewFile(uintptr(fd), lockPath)
if err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
lock.Close()
return fmt.Errorf("cgroup root is already owned: %w", err)
}
success := false
defer func() {
if !success {
lock.Close()
}
}()
// cgroup v2 requires the delegated parent to have no processes before
// domain controllers can be enabled. Move only this server, never outsiders.
data, err := os.ReadFile(filepath.Join(root, "cgroup.procs"))
if err != nil {
return err
}
for _, pid := range strings.Fields(string(data)) {
if pid != strconv.Itoa(os.Getpid()) {
return fmt.Errorf("delegated root contains another process %s", pid)
}
}
if len(strings.Fields(string(data))) > 0 {
supervisor := filepath.Join(root, "supervisor")
if err = os.Mkdir(supervisor, 0700); err != nil && !os.IsExist(err) {
return err
}
if err = os.WriteFile(filepath.Join(supervisor, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
return err
}
}
if err = os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+cpu +memory +pids"), 0600); err != nil {
return fmt.Errorf("delegate cpu, memory and pids controllers: %w", err)
}
// Recover only our names under the exclusively owned root. No PID replay.
entries, err := os.ReadDir(root)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() && validTaskName(entry.Name()) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = killAndRemoveCgroup(ctx, filepath.Join(root, entry.Name()))
cancel()
if err != nil {
return fmt.Errorf("recover %s: %w", entry.Name(), err)
}
}
}
rootLock = lock
success = true
return nil
}
func validateRoot(root string) (string, error) {
if !filepath.IsAbs(root) {
return "", fmt.Errorf("cgroup root must be absolute")
}
root = filepath.Clean(root)
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return "", err
}
if root != resolved || root == "/sys/fs/cgroup" || root == "/" {
return "", fmt.Errorf("use a dedicated delegated cgroup, not the hierarchy root or a symlink")
}
var st unix.Statfs_t
if err = unix.Statfs(root, &st); err != nil {
return "", err
}
if st.Type != unix.CGROUP2_SUPER_MAGIC {
return "", fmt.Errorf("%s is not cgroup v2", root)
}
return root, nil
}
func validTaskName(name string) bool {
if !strings.HasPrefix(name, "task-") || len(name) != 41 {
return false
}
for _, c := range name[5:] {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c == '-') {
return false
}
}
return true
}
type cgroupGroup struct {
mu sync.Mutex
path string
dir *os.File
watcher *watchdog
closed bool
}
func newPlatformGroup(id string, o Options) (Group, error) {
if o.CgroupRoot == "" {
if o.Mode == "required" {
return nil, fmt.Errorf("required isolation has no delegated cgroup root")
}
return newUnixGroup()
}
root, err := validateRoot(o.CgroupRoot)
if err != nil {
return nil, err
}
name := "task-" + id
if !validTaskName(name) {
return nil, fmt.Errorf("invalid task run ID")
}
path := filepath.Join(root, name)
if err = os.Mkdir(path, 0700); err != nil {
return nil, err
}
success := false
defer func() {
if !success {
_ = os.Remove(path)
}
}()
limits := map[string]string{"pids.max": strconv.Itoa(o.MaxProcesses), "memory.max": strconv.FormatInt(o.MemoryMaxBytes, 10), "memory.oom.group": "1"}
if o.CPUQuotaMicros > 0 {
limits["cpu.max"] = fmt.Sprintf("%d 100000", o.CPUQuotaMicros)
}
for file, value := range limits {
if err = os.WriteFile(filepath.Join(path, file), []byte(value), 0600); err != nil {
return nil, fmt.Errorf("set %s: %w", file, err)
}
}
if _, err = os.Stat(filepath.Join(path, "cgroup.kill")); err != nil {
return nil, fmt.Errorf("cgroup.kill requires Linux 5.14+: %w", err)
}
dir, err := os.Open(path)
if err != nil {
return nil, err
}
g := &cgroupGroup{path: path, dir: dir}
w, err := startWatchdog(watchRequest{Name: "cgroup", Path: path}, func() {
// A guardian crash is also fail-closed while the owner is still alive.
_ = os.WriteFile(filepath.Join(path, "cgroup.kill"), []byte("1"), 0600)
})
if err != nil {
dir.Close()
return nil, err
}
g.watcher = w
success = true
return g, nil
}
func (g *cgroupGroup) Name() string { return "cgroup_v2" }
func (g *cgroupGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return nil, fmt.Errorf("cgroup is closed")
}
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
return nil, err
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
// clone3(CLONE_INTO_CGROUP), not a racy write of a newly started PID.
cmd.SysProcAttr.UseCgroupFD = true
cmd.SysProcAttr.CgroupFD = int(g.dir.Fd())
return &Launch{Commit: func() error { return nil }, Dispose: func() {}}, nil
}
func (g *cgroupGroup) Release(pid int) error { return nil }
func (g *cgroupGroup) Close(ctx context.Context) error {
g.mu.Lock()
defer g.mu.Unlock()
g.closed = true
if g.dir == nil {
return nil
}
if err := killAndRemoveCgroup(ctx, g.path); err != nil {
return err
}
watchErr := g.watcher.close()
err := errors.Join(watchErr, g.dir.Close())
g.dir = nil
return err
}
func killAndRemoveCgroup(ctx context.Context, path string) error {
if err := os.WriteFile(filepath.Join(path, "cgroup.kill"), []byte("1"), 0600); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for {
data, err := os.ReadFile(filepath.Join(path, "cgroup.events"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if strings.Contains(string(data), "populated 0") {
return removeCgroupTree(path)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
}
func removeCgroupTree(path string) error {
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
if err = removeCgroupTree(filepath.Join(path, e.Name())); err != nil {
return err
}
}
}
err = os.Remove(path)
if os.IsNotExist(err) {
return nil
}
return err
}
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
var req watchRequest
if err := dec.Decode(&req); err != nil {
return err
}
if req.Name == "process_group" {
return groupGuardian(dec, enc)
}
if req.Name != "cgroup" || !validTaskName(filepath.Base(req.Path)) {
return fmt.Errorf("invalid cgroup guardian")
}
if _, err := validateRoot(req.Path); err != nil {
return err
}
return serveGuardian(dec, enc, func(r watchRequest) error {
if r.Op != "ping" {
return fmt.Errorf("unknown command")
}
return nil
}, func() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return killAndRemoveCgroup(ctx, req.Path)
})
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !linux && !windows
package processguard
import (
"encoding/json"
"fmt"
)
func configurePlatform(o *Options) error {
if o.Mode == "required" || o.CgroupRoot != "" {
return fmt.Errorf("kernel task containment is unavailable on this OS; use a Linux cgroup deployment")
}
return nil
}
func newPlatformGroup(id string, o Options) (Group, error) {
if err := configurePlatform(&o); err != nil {
return nil, err
}
return newUnixGroup()
}
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
var req watchRequest
if err := dec.Decode(&req); err != nil {
return err
}
if req.Name != "process_group" {
return fmt.Errorf("unsupported guardian")
}
return groupGuardian(dec, enc)
}
+183
View File
@@ -0,0 +1,183 @@
//go:build windows
package processguard
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
func configurePlatform(o *Options) error {
if o.CgroupRoot != "" {
return fmt.Errorf("cgroups are Linux-only")
}
if o.Mode == "process_group" {
return fmt.Errorf("Windows tasks require Job Object containment")
}
return nil
}
func configureGuardian(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
}
func gatedChildMain(args []string) error {
return fmt.Errorf("Unix launch gates are unavailable on Windows")
}
type jobGroup struct {
mu sync.Mutex
job windows.Handle
parent windows.Handle
watcher *watchdog
closed bool
broken bool
}
func newPlatformGroup(id string, o Options) (Group, error) {
if err := configurePlatform(&o); err != nil {
return nil, err
}
name := "Local\\CyberStrikeAI-" + id
g := &jobGroup{}
w, err := startWatchdog(watchRequest{Name: name, Options: o}, func() {
g.mu.Lock()
defer g.mu.Unlock()
g.broken = true
if !g.closed && g.job != 0 {
_ = windows.TerminateJobObject(g.job, 1)
}
})
if err != nil {
return nil, err
}
fail := func(err error) (Group, error) { w.close(); return nil, err }
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return fail(err)
}
proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("OpenJobObjectW")
h, _, callErr := proc.Call(0x0004|0x0008, 0, uintptr(unsafe.Pointer(namePtr)))
if h == 0 {
return fail(callErr)
}
parent, err := windows.OpenProcess(windows.PROCESS_CREATE_PROCESS|windows.PROCESS_DUP_HANDLE, false, uint32(w.cmd.Process.Pid))
if err != nil {
windows.CloseHandle(windows.Handle(h))
return fail(err)
}
g.mu.Lock()
defer g.mu.Unlock()
if g.broken {
windows.CloseHandle(parent)
windows.CloseHandle(windows.Handle(h))
return fail(fmt.Errorf("job guardian exited during setup"))
}
g.job = windows.Handle(h)
g.parent = parent
g.watcher = w
return g, nil
}
func (g *jobGroup) Name() string { return "windows_job" }
func (g *jobGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed || g.broken {
return nil, fmt.Errorf("job is closed")
}
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
return nil, err
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
// Windows inherits the job at CreateProcess time from this parent. The
// guardian joined the job BEFORE acknowledging readiness, closing the
// Start-then-Assign race and its suspended-process crash window.
cmd.SysProcAttr.ParentProcess = syscall.Handle(g.parent)
return &Launch{Commit: func() error { return nil }, Dispose: func() {}}, nil
}
func (g *jobGroup) Release(pid int) error { return nil }
func (g *jobGroup) Close(ctx context.Context) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return nil
}
if err := windows.TerminateJobObject(g.job, 1); err != nil {
return err
}
type accounting struct {
TotalUser, TotalKernel, PeriodUser, PeriodKernel int64
PageFaults, TotalProcesses, ActiveProcesses, Terminated uint32
}
for {
var info accounting
if err := windows.QueryInformationJobObject(g.job, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil); err != nil {
return err
}
if info.ActiveProcesses == 0 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
g.closed = true
return errors.Join(g.watcher.close(), windows.CloseHandle(g.parent), windows.CloseHandle(g.job))
}
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
var req watchRequest
if err := dec.Decode(&req); err != nil {
return err
}
name, err := windows.UTF16PtrFromString(req.Name)
if err != nil {
return err
}
job, err := windows.CreateJobObject(nil, name)
if err != nil {
return err
}
defer windows.CloseHandle(job)
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | windows.JOB_OBJECT_LIMIT_JOB_MEMORY
limits.BasicLimitInformation.ActiveProcessLimit = uint32(req.Options.MaxProcesses + 1)
limits.JobMemoryLimit = uintptr(req.Options.MemoryMaxBytes)
if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
return err
}
if req.Options.CPUQuotaMicros > 0 {
rate := req.Options.CPUQuotaMicros / 10 / int64(runtime.NumCPU())
if rate < 1 {
rate = 1
}
if rate > 10000 {
rate = 10000
}
cpu := struct{ Flags, Rate uint32 }{Flags: 1 | 4, Rate: uint32(rate)}
if _, err = windows.SetInformationJobObject(job, windows.JobObjectCpuRateControlInformation, uintptr(unsafe.Pointer(&cpu)), uint32(unsafe.Sizeof(cpu))); err != nil {
return err
}
}
if err = windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil {
return err
}
return serveGuardian(dec, enc, func(req watchRequest) error {
if req.Op != "ping" {
return fmt.Errorf("unknown guardian command")
}
return nil
}, func() error { return windows.TerminateJobObject(job, uint32(os.Getpid())) })
}
+182
View File
@@ -0,0 +1,182 @@
package processguard
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sync"
"time"
)
const guardianArg = "--cyberstrike-internal-process-guardian"
const childArg = "--cyberstrike-internal-process-child"
type watchRequest struct {
Op string
PID int
Path string
Name string
Options Options
}
type watchReply struct {
PID int
Error string
}
type watchdog struct {
mu sync.Mutex
cmd *exec.Cmd
input *os.File
output *os.File
encoder *json.Encoder
decoder *json.Decoder
done chan struct{}
failed error
}
// The re-exec modes run before application configuration, listeners or MCP
// initialization. Stdin is a private pipe; no network control port is opened.
func init() {
if len(os.Args) < 2 {
return
}
switch os.Args[1] {
case guardianArg:
err := guardianMain(json.NewDecoder(os.Stdin), json.NewEncoder(os.Stdout))
if err != nil {
_ = json.NewEncoder(os.Stdout).Encode(watchReply{Error: err.Error()})
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(0)
case childArg:
if err := gatedChildMain(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(0)
}
}
func startWatchdog(req watchRequest, onExit func()) (*watchdog, error) {
exe, err := os.Executable()
if err != nil {
return nil, err
}
cmd := exec.Command(exe, guardianArg)
configureGuardian(cmd)
in, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
in.Close()
return nil, err
}
// No inherited stderr pipe that could keep a caller's output reader alive.
if err = cmd.Start(); err != nil {
in.Close()
out.Close()
return nil, err
}
w := &watchdog{cmd: cmd, input: in.(*os.File), output: out.(*os.File), done: make(chan struct{})}
w.encoder = json.NewEncoder(w.input)
w.decoder = json.NewDecoder(w.output)
// The guardian only exits after EOF or failure; exit invalidates all RPCs.
go func() {
_ = cmd.Wait()
close(w.done)
if onExit != nil {
onExit()
}
}()
req.Op = "init"
if _, err = w.send(req); err != nil {
w.close()
return nil, err
}
return w, nil
}
func (w *watchdog) send(req watchRequest) (watchReply, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.failed != nil {
return watchReply{}, w.failed
}
type response struct {
reply watchReply
err error
}
result := make(chan response, 1)
// Pipe deadlines are not supported by every Windows pipe implementation.
// On timeout kill the helper and close both ends to release this goroutine.
go func() {
if err := w.encoder.Encode(req); err != nil {
result <- response{err: err}
return
}
var reply watchReply
err := w.decoder.Decode(&reply)
if err == nil && reply.Error != "" {
err = fmt.Errorf("process guardian: %s", reply.Error)
}
result <- response{reply, err}
}()
select {
case r := <-result:
w.failed = r.err
return r.reply, r.err
case <-time.After(3 * time.Second):
_ = w.cmd.Process.Kill()
_ = w.input.Close()
_ = w.output.Close()
w.failed = fmt.Errorf("process guardian acknowledgement timed out")
return watchReply{}, w.failed
}
}
func (w *watchdog) close() error {
w.mu.Lock()
_ = w.input.Close()
w.mu.Unlock()
select {
case <-w.done:
case <-time.After(3 * time.Second):
_ = w.cmd.Process.Kill()
select {
case <-w.done:
case <-time.After(3 * time.Second):
return fmt.Errorf("process guardian did not exit")
}
}
_ = w.output.Close()
return nil
}
func serveGuardian(dec *json.Decoder, enc *json.Encoder, apply func(watchRequest) error, cleanup func() error) error {
defer cleanup()
if err := enc.Encode(watchReply{PID: os.Getpid()}); err != nil {
return err
}
for {
var req watchRequest
if err := dec.Decode(&req); err != nil {
if err == io.EOF {
return nil
}
return err
}
err := apply(req)
reply := watchReply{PID: os.Getpid()}
if err != nil {
reply.Error = err.Error()
}
if err := enc.Encode(reply); err != nil {
return err
}
}
}