This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/sandbox/sandbox.go
2026-03-24 11:46:13 +00:00

233 lines
6.4 KiB
Go

// 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}
}
// Exec executes a Starlark script within the sandbox.
//
// 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
// (e.g., {"secrets": secretsModule, "notifications": notifModule})
//
// The script cannot use load() — all dependencies must be provided
// via the modules parameter.
func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map[string]starlark.Value) (*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
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: 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)
}
// 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:
}
}()
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
}