mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-17 15:12:24 +02:00
221 lines
5.4 KiB
Go
221 lines
5.4 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
|
|
"cyberstrike-ai/internal/processguard"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var ErrProcessScopeClosed = errors.New("task is ending; new processes are not allowed")
|
|
var ErrBackgroundNeedsTask = errors.New("background commands require a managed task")
|
|
|
|
type processScopeKey struct{}
|
|
|
|
// ProcessScope owns local commands for one task run, including background work.
|
|
// Ownership is carried by context values, so MCP's WithoutCancel retains it.
|
|
// Start and Seal serialize under the same lock: no process can escape cleanup
|
|
// by starting between the final snapshot and task completion.
|
|
type ProcessScope struct {
|
|
ID string
|
|
mu sync.Mutex
|
|
closed bool
|
|
sessions map[*ShellSession]struct{}
|
|
guard processguard.Group
|
|
guardErr error
|
|
closeMu sync.Mutex
|
|
}
|
|
|
|
func NewProcessScope() *ProcessScope {
|
|
return &ProcessScope{ID: uuid.NewString(), sessions: make(map[*ShellSession]struct{})}
|
|
}
|
|
|
|
func WithProcessScope(ctx context.Context, scope *ProcessScope) context.Context {
|
|
return context.WithValue(ctx, processScopeKey{}, scope)
|
|
}
|
|
|
|
func ProcessScopeFromContext(ctx context.Context) *ProcessScope {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
scope, _ := ctx.Value(processScopeKey{}).(*ProcessScope)
|
|
return scope
|
|
}
|
|
|
|
func (s *ProcessScope) Seal() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.closed = true
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func startShellSessionContext(ctx context.Context, cmd *exec.Cmd, start func() error) (*ShellSession, error) {
|
|
scope := ProcessScopeFromContext(ctx)
|
|
if scope != nil {
|
|
scope.mu.Lock()
|
|
defer scope.mu.Unlock()
|
|
if scope.closed {
|
|
return nil, ErrProcessScopeClosed
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := prepareShellCmdSession(cmd); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var launch *processguard.Launch
|
|
if scope != nil {
|
|
if scope.guard == nil && scope.guardErr == nil {
|
|
scope.guard, scope.guardErr = processguard.New(scope.ID)
|
|
}
|
|
if scope.guardErr != nil {
|
|
return nil, scope.guardErr
|
|
}
|
|
var err error
|
|
launch, err = scope.guard.Prepare(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer launch.Dispose()
|
|
}
|
|
// Bound Go's output-copy goroutines when descendants inherit a pipe.
|
|
if cmd.WaitDelay == 0 {
|
|
cmd.WaitDelay = 2 * time.Second
|
|
}
|
|
if err := start(); err != nil {
|
|
return nil, err
|
|
}
|
|
if launch != nil {
|
|
if err := launch.Commit(); err != nil {
|
|
terminateProcessGroup(cmd.Process.Pid, cmd)
|
|
_ = cmd.Wait()
|
|
if scope != nil {
|
|
_ = scope.guard.Release(cmd.Process.Pid)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
session := &ShellSession{Cmd: cmd, rootPID: cmd.Process.Pid, scope: scope, done: make(chan struct{})}
|
|
if scope != nil {
|
|
scope.sessions[session] = struct{}{}
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// Close seals the scope, asks every process group to exit, then escalates to
|
|
// SIGKILL. It waits for command reaping, with one shared deadline, not N timeouts.
|
|
// Failed entries remain owned, permitting a later Close to retry cleanup.
|
|
func (s *ProcessScope) Close() error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.closeMu.Lock()
|
|
defer s.closeMu.Unlock()
|
|
s.mu.Lock()
|
|
s.closed = true
|
|
sessions := make([]*ShellSession, 0, len(s.sessions))
|
|
for session := range s.sessions {
|
|
sessions = append(sessions, session)
|
|
}
|
|
s.mu.Unlock()
|
|
if len(sessions) == 0 {
|
|
return s.closeGuard()
|
|
}
|
|
for _, session := range sessions {
|
|
session.signal(false)
|
|
}
|
|
if waitShellSessions(sessions, 3*time.Second) {
|
|
return s.closeGuard()
|
|
}
|
|
for _, session := range sessions {
|
|
session.Terminate()
|
|
}
|
|
guardErr := s.closeGuard()
|
|
if waitShellSessions(sessions, 3*time.Second) {
|
|
return guardErr
|
|
}
|
|
remaining := make([]int, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
if !session.tryComplete() {
|
|
remaining = append(remaining, session.rootPID)
|
|
}
|
|
}
|
|
return fmt.Errorf("task %s: process cleanup timed out (process groups %v)", s.ID, remaining)
|
|
}
|
|
|
|
func waitShellSessions(sessions []*ShellSession, timeout time.Duration) bool {
|
|
deadline := time.NewTimer(timeout)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
complete := true
|
|
for _, session := range sessions {
|
|
if !session.tryComplete() {
|
|
complete = false
|
|
}
|
|
}
|
|
if complete {
|
|
return true
|
|
}
|
|
select {
|
|
case <-deadline.C:
|
|
return false
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// StartManagedBackground returns promptly while retaining task ownership. The
|
|
// shell executes the job in the foreground internally, keeping a waitable root
|
|
// alive; tool completion must not cancel the job's lifetime.
|
|
func StartManagedBackground(ctx context.Context, shell, command, dir string) (*ShellSession, error) {
|
|
if ProcessScopeFromContext(ctx) == nil {
|
|
return nil, ErrBackgroundNeedsTask
|
|
}
|
|
cmd := exec.Command(shell, "-c", PrepareShellCommandForExecute(command))
|
|
cmd.Dir = dir
|
|
ConfigureShellCmdForAgentExecute(cmd)
|
|
// Nil output streams use /dev/null; background output cannot hold tool pipes.
|
|
session, err := StartShellSessionContext(ctx, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
go func() { _ = session.Wait() }()
|
|
return session, nil
|
|
}
|
|
|
|
func (s *ProcessScope) closeGuard() error {
|
|
if s.guard == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
return s.guard.Close(ctx)
|
|
}
|
|
|
|
func (s *ProcessScope) IsolationBackend() string {
|
|
if s == nil {
|
|
return "none"
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.guard != nil {
|
|
return s.guard.Name()
|
|
}
|
|
if s.guardErr != nil {
|
|
return "unavailable"
|
|
}
|
|
return "pending"
|
|
}
|