All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
256 lines
7.5 KiB
Go
256 lines
7.5 KiB
Go
// Package sandbox — sandboxed Starlark interpreter.
|
|
//
|
|
// 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
|
|
}
|