mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
* feat(sandbox): unify command execution and enforcement * test(sandbox): make platform contracts portable * fix(sandbox): close execution lifecycle races Bound retained interactive output, preserve interruption state, and start workspace observation before process launch. Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback. * fix(sandbox): restore strict macOS isolation Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces. Track process reap completion so retained sessions cannot signal a stale numeric PID.
222 lines
5.8 KiB
Go
222 lines
5.8 KiB
Go
package execution
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"sync"
|
|
)
|
|
|
|
const maxCapturedStreamBytes = 1 << 20
|
|
|
|
// Preparer is the platform-adapter seam. It turns a platform-neutral request
|
|
// into one enforceable OS command while keeping native sandbox mechanics out of
|
|
// every caller.
|
|
type Preparer interface {
|
|
PrepareExecution(context.Context, Request) (PreparedCommand, error)
|
|
}
|
|
|
|
type PreparedCommand struct {
|
|
Command *exec.Cmd
|
|
Enforcement Enforcement
|
|
Report func() (AdapterReport, error)
|
|
Cleanup func()
|
|
}
|
|
|
|
type CapturedRequest struct {
|
|
Request Request
|
|
Stdin []byte
|
|
}
|
|
|
|
type CapturedResult struct {
|
|
Stdout string
|
|
Stderr string
|
|
Truncated bool
|
|
Outcome Outcome
|
|
Err error
|
|
}
|
|
|
|
// Runner is the deep execution module used by Zero-owned subprocess launchers.
|
|
// The preparer may be installed after tool registration, but execution fails
|
|
// closed until an adapter is present.
|
|
type Runner struct {
|
|
mu sync.RWMutex
|
|
preparer Preparer
|
|
}
|
|
|
|
func NewRunner(preparer Preparer) *Runner {
|
|
return &Runner{preparer: preparer}
|
|
}
|
|
|
|
func (runner *Runner) SetPreparer(preparer Preparer) {
|
|
if runner == nil {
|
|
return
|
|
}
|
|
runner.mu.Lock()
|
|
runner.preparer = preparer
|
|
runner.mu.Unlock()
|
|
}
|
|
|
|
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := input.Request.Validate(); err != nil {
|
|
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
|
|
}
|
|
if input.Request.Mode != ModeCaptured {
|
|
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
|
|
return capturedSetupFailure(err.Error(), err, Enforcement{})
|
|
}
|
|
prepared, err := runner.Prepare(ctx, input.Request)
|
|
if err != nil {
|
|
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
|
|
}
|
|
if prepared.Cleanup != nil {
|
|
defer prepared.Cleanup()
|
|
}
|
|
if prepared.Command == nil {
|
|
err := errors.New("execution adapter returned no command")
|
|
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
|
|
}
|
|
if len(input.Stdin) > 0 {
|
|
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
|
|
}
|
|
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
|
|
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
|
|
prepared.Command.Stdout = stdout
|
|
prepared.Command.Stderr = stderr
|
|
runErr := prepared.Command.Run()
|
|
report, reportErr := AdapterReport{}, error(nil)
|
|
if prepared.Report != nil {
|
|
report, reportErr = prepared.Report()
|
|
}
|
|
result := CapturedResult{
|
|
Stdout: stdout.String(),
|
|
Stderr: stderr.String(),
|
|
Truncated: stdout.Truncated() || stderr.Truncated(),
|
|
Err: runErr,
|
|
Outcome: Outcome{
|
|
Enforcement: prepared.Enforcement,
|
|
},
|
|
}
|
|
exitCode := commandExitCode(runErr)
|
|
result.Outcome.Exit = &Exit{Code: exitCode}
|
|
switch {
|
|
case reportErr != nil:
|
|
result.Outcome.State = StateFailed
|
|
result.Outcome.Kind = OutcomeSandboxSetupFailure
|
|
result.Err = reportErr
|
|
case report.Denial != nil:
|
|
denial := *report.Denial
|
|
result.Outcome.State = StateDenied
|
|
result.Outcome.Kind = OutcomeEnforcementDenied
|
|
result.Outcome.Denial = &denial
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
result.Outcome.State = StateFailed
|
|
result.Outcome.Kind = OutcomeTimedOut
|
|
case errors.Is(ctx.Err(), context.Canceled):
|
|
result.Outcome.State = StateCancelled
|
|
result.Outcome.Kind = OutcomeCancelled
|
|
case runErr == nil:
|
|
result.Outcome.State = StateCompleted
|
|
result.Outcome.Kind = OutcomeSuccess
|
|
case executableNotFound(runErr):
|
|
result.Outcome.State = StateFailed
|
|
result.Outcome.Kind = OutcomeExecutableNotFound
|
|
default:
|
|
result.Outcome.State = StateFailed
|
|
result.Outcome.Kind = OutcomeApplicationFailure
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Prepare evaluates and prepares a typed request for callers that require
|
|
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
|
|
// The returned cleanup remains mandatory; ordinary captured commands should use
|
|
// ExecuteCaptured so the Runner owns it automatically.
|
|
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
|
|
if runner == nil {
|
|
return PreparedCommand{}, errors.New("execution runner is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := request.Validate(); err != nil {
|
|
return PreparedCommand{}, err
|
|
}
|
|
runner.mu.RLock()
|
|
preparer := runner.preparer
|
|
runner.mu.RUnlock()
|
|
if preparer == nil {
|
|
return PreparedCommand{}, errors.New("execution adapter is not configured")
|
|
}
|
|
return preparer.PrepareExecution(ctx, request)
|
|
}
|
|
|
|
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
|
|
return CapturedResult{
|
|
Stderr: message,
|
|
Err: err,
|
|
Outcome: Outcome{
|
|
State: StateFailed,
|
|
Kind: OutcomeSandboxSetupFailure,
|
|
Exit: &Exit{Code: -1},
|
|
Enforcement: enforcement,
|
|
},
|
|
}
|
|
}
|
|
|
|
func commandExitCode(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.ExitCode()
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func executableNotFound(err error) bool {
|
|
var execErr *exec.Error
|
|
return errors.As(err, &execErr)
|
|
}
|
|
|
|
type capturedBuffer struct {
|
|
mu sync.Mutex
|
|
buffer bytes.Buffer
|
|
limit int
|
|
truncated bool
|
|
}
|
|
|
|
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
|
|
buffer.mu.Lock()
|
|
defer buffer.mu.Unlock()
|
|
remaining := buffer.limit - buffer.buffer.Len()
|
|
if remaining > 0 {
|
|
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
|
|
}
|
|
if len(data) > remaining {
|
|
buffer.truncated = true
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
func (buffer *capturedBuffer) String() string {
|
|
buffer.mu.Lock()
|
|
defer buffer.mu.Unlock()
|
|
return buffer.buffer.String()
|
|
}
|
|
|
|
func (buffer *capturedBuffer) Truncated() bool {
|
|
buffer.mu.Lock()
|
|
defer buffer.mu.Unlock()
|
|
return buffer.truncated
|
|
}
|
|
|
|
var _ io.Writer = (*capturedBuffer)(nil)
|