// Package sandbox — sandboxed Starlark interpreter. // // v0.29.0 CS1: Provides execution with timeout, step limits, // captured output, and injectable predeclared modules. // // The sandbox is intentionally restrictive: // - No file I/O // - No network access // - No os/env access // - No load() statements (modules injected via predeclared) // - Step limit prevents infinite loops // - Context-based timeout prevents runaway wall-clock // // Extensions interact with the platform through injected Go builtins // (e.g., secrets.get, notifications.send) — never through Starlark's // own I/O. This is the eval loop; modules are added in CS3. package sandbox import ( "context" "fmt" "strings" "sync" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" starlarkjson "go.starlark.net/lib/json" ) // ─── Configuration ─────────────────────────── // Config controls sandbox resource limits. type Config struct { // MaxSteps limits total bytecode operations. 0 = unlimited. // Default: 1_000_000 (~1M ops, covers most scripts in <100ms). MaxSteps uint64 // Name identifies this sandbox in logs and error messages. Name string } // DefaultConfig returns production defaults. func DefaultConfig() Config { return Config{ MaxSteps: 1_000_000, Name: "extension", } } // ─── Result ────────────────────────────────── // Result holds the output of a sandbox execution. type Result struct { // Globals contains the top-level names defined by the script. Globals map[string]starlark.Value // Output captures all print() calls made during execution. Output string // Steps is the number of bytecode operations executed. Steps uint64 } // ─── Sandbox ───────────────────────────────── // Sandbox executes Starlark scripts with resource limits and // injected modules. Safe for concurrent use — each Exec call // gets its own thread. type Sandbox struct { config Config } // New creates a sandbox with the given configuration. // Set MaxSteps to 0 for unlimited (not recommended in production). func New(cfg Config) *Sandbox { if cfg.Name == "" { cfg.Name = "extension" } return &Sandbox{config: cfg} } // LoadFunc resolves load("path") calls to Starlark source. // Returns the module's globals. The sandbox caches results per // thread (Starlark handles this via thread.Load dedup). type LoadFunc func(thread *starlark.Thread, module string) (starlark.StringDict, error) // Exec executes a Starlark script within the sandbox. // load() is disabled — all dependencies must be provided via modules. // For load() support, use ExecWithLoader. func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map[string]starlark.Value) (*Result, error) { return s.ExecWithLoader(ctx, filename, source, modules, nil) } // ExecWithLoader executes a Starlark script within the sandbox with // an optional load callback. If loader is nil, load() returns an error. // // Parameters: // - ctx: context for timeout/cancellation // - filename: used in error messages and stack traces // - source: Starlark source code // - modules: predeclared modules injected into the script's namespace // - loader: resolves load() calls; nil = load() disabled func (s *Sandbox) ExecWithLoader(ctx context.Context, filename, source string, modules map[string]starlark.Value, loader LoadFunc) (*Result, error) { var output strings.Builder var outputMu sync.Mutex predeclared := make(starlark.StringDict, len(modules)+1) for k, v := range modules { predeclared[k] = v } // json.encode / json.decode — universal, no permissions required (pure computation) predeclared["json"] = starlarkjson.Module // Default: load() disabled. If loader provided, delegate to it. loadFn := func(_ *starlark.Thread, module string) (starlark.StringDict, error) { return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module) } if loader != nil { loadFn = loader } thread := &starlark.Thread{ Name: s.config.Name, Print: func(_ *starlark.Thread, msg string) { outputMu.Lock() defer outputMu.Unlock() output.WriteString(msg) output.WriteByte('\n') }, Load: loadFn, } if s.config.MaxSteps > 0 { thread.SetMaxExecutionSteps(s.config.MaxSteps) } // Wire context cancellation into the thread. done := make(chan struct{}) defer close(done) go func() { select { case <-ctx.Done(): thread.Cancel(ctx.Err().Error()) case <-done: } }() // Unicode security gate — scan source for invisible/deceptive characters // before execution (defense in depth; install gate is primary). if findings := ScanSource(source, filename); len(findings) > 0 { if blocked, reason := Verdict(findings); blocked { return nil, fmt.Errorf("unicode security gate: %s (file: %s)", reason, filename) } } globals, err := starlark.ExecFile(thread, filename, source, predeclared) if err != nil { return nil, wrapError(err) } result := &Result{ Globals: make(map[string]starlark.Value, len(globals)), Output: output.String(), Steps: thread.ExecutionSteps(), } for k, v := range globals { result.Globals[k] = v } return result, nil } // Call invokes a Starlark function within the sandbox. // Used by the task executor and filter runner to call specific // entry points (e.g., "on_pre_completion", "on_run"). func (s *Sandbox) Call(ctx context.Context, fn starlark.Callable, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, string, error) { var output strings.Builder var outputMu sync.Mutex thread := &starlark.Thread{ Name: s.config.Name + "/call", Print: func(_ *starlark.Thread, msg string) { outputMu.Lock() defer outputMu.Unlock() output.WriteString(msg) output.WriteByte('\n') }, Load: func(_ *starlark.Thread, module string) (starlark.StringDict, error) { return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module) }, } if s.config.MaxSteps > 0 { thread.SetMaxExecutionSteps(s.config.MaxSteps) } done := make(chan struct{}) defer close(done) go func() { select { case <-ctx.Done(): thread.Cancel(ctx.Err().Error()) case <-done: } }() val, err := starlark.Call(thread, fn, args, kwargs) if err != nil { return nil, output.String(), wrapError(err) } return val, output.String(), nil } // ─── Module Helper ─────────────────────────── // MakeModule creates a starlarkstruct.Module with the given name and // members. Standard way to expose Go functions to Starlark. // // Usage: // // mod := sandbox.MakeModule("secrets", starlark.StringDict{ // "get": starlark.NewBuiltin("secrets.get", secretsGet), // }) func MakeModule(name string, members starlark.StringDict) *starlarkstruct.Module { return &starlarkstruct.Module{ Name: name, Members: members, } } // ─── Error Wrapping ────────────────────────── // SandboxError wraps a Starlark execution error with context. type SandboxError struct { Err error Backtrace string } func (e *SandboxError) Error() string { if e.Backtrace != "" { return fmt.Sprintf("%s\n%s", e.Err.Error(), e.Backtrace) } return e.Err.Error() } func (e *SandboxError) Unwrap() error { return e.Err } func wrapError(err error) error { if err == nil { return nil } se := &SandboxError{Err: err} if evalErr, ok := err.(*starlark.EvalError); ok { se.Backtrace = evalErr.Backtrace() } return se }