Changeset 0.29.0 (#195)
This commit is contained in:
229
server/sandbox/sandbox.go
Normal file
229
server/sandbox/sandbox.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// ─── 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))
|
||||
for k, v := range modules {
|
||||
predeclared[k] = v
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user