Changeset 0.29.0 (#195)
This commit is contained in:
136
server/sandbox/modules.go
Normal file
136
server/sandbox/modules.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Package sandbox — modules.go
|
||||
//
|
||||
// v0.29.0 CS3: Module factories for Starlark sandbox.
|
||||
// Each factory returns a starlarkstruct.Module that the runner
|
||||
// injects into the script namespace based on granted permissions.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ─── Secrets Module ──────────────────────────
|
||||
//
|
||||
// Requires permission: secrets.read
|
||||
// Starlark API:
|
||||
// val = secrets.get("api_key") → string or None
|
||||
// all = secrets.list() → list of key names
|
||||
//
|
||||
// Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}"
|
||||
// as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."}
|
||||
// Admin sets them via PUT /admin/extensions/:id/secrets.
|
||||
|
||||
// BuildSecretsModule creates the "secrets" module scoped to a package.
|
||||
func BuildSecretsModule(ctx context.Context, stores store.Stores, packageID string) *starlarkstruct.Module {
|
||||
// Pre-load secrets for this package (single query, not per-call)
|
||||
secretMap := loadExtensionSecrets(ctx, stores, packageID)
|
||||
|
||||
return MakeModule("secrets", starlark.StringDict{
|
||||
"get": starlark.NewBuiltin("secrets.get", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var key string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if val, ok := secretMap[key]; ok {
|
||||
return starlark.String(val), nil
|
||||
}
|
||||
return starlark.None, nil
|
||||
}),
|
||||
"list": starlark.NewBuiltin("secrets.list", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
keys := make([]starlark.Value, 0, len(secretMap))
|
||||
for k := range secretMap {
|
||||
keys = append(keys, starlark.String(k))
|
||||
}
|
||||
return starlark.NewList(keys), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func loadExtensionSecrets(ctx context.Context, stores store.Stores, packageID string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
configKey := "ext_secrets:" + packageID
|
||||
configVal, err := stores.GlobalConfig.Get(ctx, configKey)
|
||||
if err != nil || configVal == nil {
|
||||
return result
|
||||
}
|
||||
for k, v := range configVal {
|
||||
if s, ok := v.(string); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Notifications Module ────────────────────
|
||||
//
|
||||
// Requires permission: notifications.send
|
||||
// Starlark API:
|
||||
// notifications.send(user_id, title, body="", type="extension.notify")
|
||||
//
|
||||
// Creates an in-app notification via the notification store.
|
||||
// The extension cannot send email or bypass user preferences —
|
||||
// that's handled by the notification service layer.
|
||||
|
||||
// NotificationSender is the interface the notifications module needs.
|
||||
// Matches notifications.Service.Notify without importing the package.
|
||||
type NotificationSender interface {
|
||||
Notify(ctx context.Context, n *models.Notification) error
|
||||
}
|
||||
|
||||
// BuildNotificationsModule creates the "notifications" module scoped to a package.
|
||||
func BuildNotificationsModule(ctx context.Context, sender NotificationSender, packageID string) *starlarkstruct.Module {
|
||||
return MakeModule("notifications", starlark.StringDict{
|
||||
"send": starlark.NewBuiltin("notifications.send", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var userID, title string
|
||||
var body string
|
||||
var notifType string = "extension.notify"
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"user_id", &userID,
|
||||
"title", &title,
|
||||
"body?", &body,
|
||||
"type?", ¬ifType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("notifications.send: user_id is required")
|
||||
}
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("notifications.send: title is required")
|
||||
}
|
||||
|
||||
n := &models.Notification{
|
||||
UserID: userID,
|
||||
Type: notifType,
|
||||
Title: title,
|
||||
Body: body,
|
||||
ResourceType: "extension",
|
||||
ResourceID: packageID,
|
||||
}
|
||||
|
||||
if err := sender.Notify(ctx, n); err != nil {
|
||||
return nil, fmt.Errorf("notifications.send failed: %w", err)
|
||||
}
|
||||
|
||||
return starlark.True, nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
171
server/sandbox/modules_test.go
Normal file
171
server/sandbox/modules_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
func TestBuildSecretsModule_Get(t *testing.T) {
|
||||
mod := MakeModule("secrets", starlark.StringDict{
|
||||
"get": starlark.NewBuiltin("secrets.get", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var key string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "api_key" {
|
||||
return starlark.String("sk-test123"), nil
|
||||
}
|
||||
return starlark.None, nil
|
||||
}),
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
res, err := sb.Exec(context.Background(), "test.star", `
|
||||
val = secrets.get("api_key")
|
||||
missing = secrets.get("nope")
|
||||
`, map[string]starlark.Value{"secrets": mod})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
val := res.Globals["val"]
|
||||
if val.(starlark.String).GoString() != "sk-test123" {
|
||||
t.Fatalf("expected 'sk-test123', got %s", val.String())
|
||||
}
|
||||
missing := res.Globals["missing"]
|
||||
if missing != starlark.None {
|
||||
t.Fatalf("expected None, got %s", missing.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNotificationsModule_Send(t *testing.T) {
|
||||
var sentTitle string
|
||||
var sentUserID string
|
||||
|
||||
sendFn := starlark.NewBuiltin("notifications.send", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var userID, title string
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"user_id", &userID, "title", &title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sentUserID = userID
|
||||
sentTitle = title
|
||||
return starlark.True, nil
|
||||
})
|
||||
|
||||
mod := MakeModule("notifications", starlark.StringDict{
|
||||
"send": sendFn,
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
result = notifications.send(user_id="u-1", title="Hello World")
|
||||
`, map[string]starlark.Value{"notifications": mod})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sentUserID != "u-1" {
|
||||
t.Fatalf("expected user_id 'u-1', got %q", sentUserID)
|
||||
}
|
||||
if sentTitle != "Hello World" {
|
||||
t.Fatalf("expected title 'Hello World', got %q", sentTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleIsolation(t *testing.T) {
|
||||
// Script should not be able to access modules it wasn't given
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
val = secrets.get("key")
|
||||
`, nil) // no modules injected
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when accessing undefined module")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeModuleName(t *testing.T) {
|
||||
mod := MakeModule("test_mod", starlark.StringDict{
|
||||
"version": starlark.String("1.0"),
|
||||
})
|
||||
if mod.Name != "test_mod" {
|
||||
t.Fatalf("expected 'test_mod', got %q", mod.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryPointCall(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
// Simulate a package script with on_pre_completion entry point
|
||||
res, err := sb.Exec(context.Background(), "ext.star", `
|
||||
def on_pre_completion(ctx):
|
||||
channel = ctx["channel_id"]
|
||||
return [{"role": "system", "content": "context for " + channel}]
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("exec error: %v", err)
|
||||
}
|
||||
|
||||
fn := res.Globals["on_pre_completion"].(starlark.Callable)
|
||||
|
||||
ctxDict := starlark.NewDict(1)
|
||||
ctxDict.SetKey(starlark.String("channel_id"), starlark.String("ch-abc"))
|
||||
|
||||
val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("call error: %v", err)
|
||||
}
|
||||
|
||||
list, ok := val.(*starlark.List)
|
||||
if !ok {
|
||||
t.Fatalf("expected list, got %s", val.Type())
|
||||
}
|
||||
if list.Len() != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", list.Len())
|
||||
}
|
||||
|
||||
item := list.Index(0)
|
||||
dict, ok := item.(*starlark.Dict)
|
||||
if !ok {
|
||||
t.Fatalf("expected dict, got %s", item.Type())
|
||||
}
|
||||
|
||||
contentVal, _, _ := dict.Get(starlark.String("content"))
|
||||
content, _ := starlark.AsString(contentVal)
|
||||
if content != "context for ch-abc" {
|
||||
t.Fatalf("expected 'context for ch-abc', got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryPointNoneReturn(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
res, err := sb.Exec(context.Background(), "ext.star", `
|
||||
def on_pre_completion(ctx):
|
||||
return None
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("exec error: %v", err)
|
||||
}
|
||||
|
||||
fn := res.Globals["on_pre_completion"].(starlark.Callable)
|
||||
ctxDict := starlark.NewDict(0)
|
||||
|
||||
val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("call error: %v", err)
|
||||
}
|
||||
|
||||
if val != starlark.None {
|
||||
t.Fatalf("expected None, got %s", val.Type())
|
||||
}
|
||||
}
|
||||
131
server/sandbox/runner.go
Normal file
131
server/sandbox/runner.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package sandbox — runner.go
|
||||
//
|
||||
// v0.29.0 CS3: Runner loads a package's Starlark script, assembles
|
||||
// the module set based on granted permissions, and executes it.
|
||||
//
|
||||
// The runner is the bridge between the package/permission system
|
||||
// and the sandboxed Starlark interpreter.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// Runner executes Starlark package scripts with permission-gated modules.
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
func NewRunner(sb *Sandbox, stores store.Stores) *Runner {
|
||||
return &Runner{
|
||||
sandbox: sb,
|
||||
stores: stores,
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier attaches the notification sender for the notifications module.
|
||||
func (r *Runner) SetNotifier(n NotificationSender) {
|
||||
r.notifier = n
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from its manifest and executes it
|
||||
// with modules gated by the package's granted permissions.
|
||||
//
|
||||
// Returns the script's globals (which may contain callable entry points
|
||||
// like on_pre_completion, on_run, etc.) and any captured print output.
|
||||
func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration) (*Result, error) {
|
||||
// Only active starlark packages can execute
|
||||
if pkg.Status != models.PackageStatusActive {
|
||||
return nil, fmt.Errorf("package %q is %s, not active", pkg.ID, pkg.Status)
|
||||
}
|
||||
if pkg.Tier != models.ExtTierStarlark {
|
||||
return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier)
|
||||
}
|
||||
|
||||
// Extract script from manifest
|
||||
script, ok := pkg.Manifest["_starlark_script"].(string)
|
||||
if !ok || script == "" {
|
||||
return nil, fmt.Errorf("package %q has no _starlark_script in manifest", pkg.ID)
|
||||
}
|
||||
|
||||
// Build modules based on granted permissions
|
||||
modules, err := r.buildModules(ctx, pkg.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
|
||||
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
|
||||
|
||||
return r.sandbox.Exec(ctx, pkg.ID+".star", script, modules)
|
||||
}
|
||||
|
||||
// CallEntryPoint executes a package script and calls a named function.
|
||||
// This is the standard pattern for event-driven extensions:
|
||||
// 1. Exec the script (defines functions)
|
||||
// 2. Find the named entry point in globals
|
||||
// 3. Call it with the provided arguments
|
||||
//
|
||||
// Returns the function's return value and captured output.
|
||||
func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistration, entryPoint string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, string, error) {
|
||||
result, err := r.ExecPackage(ctx, pkg)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
fn, ok := result.Globals[entryPoint]
|
||||
if !ok {
|
||||
return nil, result.Output, fmt.Errorf("package %q has no %s function", pkg.ID, entryPoint)
|
||||
}
|
||||
|
||||
callable, ok := fn.(starlark.Callable)
|
||||
if !ok {
|
||||
return nil, result.Output, fmt.Errorf("package %q: %s is not callable", pkg.ID, entryPoint)
|
||||
}
|
||||
|
||||
val, callOutput, err := r.sandbox.Call(ctx, callable, args, kwargs)
|
||||
return val, result.Output + callOutput, err
|
||||
}
|
||||
|
||||
// buildModules assembles the module map based on granted permissions.
|
||||
func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string]starlark.Value, error) {
|
||||
if r.stores.ExtPermissions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
granted, err := r.stores.ExtPermissions.GrantedForPackage(ctx, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modules := make(map[string]starlark.Value)
|
||||
|
||||
for _, perm := range granted {
|
||||
switch perm {
|
||||
case models.ExtPermSecretsRead:
|
||||
modules["secrets"] = BuildSecretsModule(ctx, r.stores, packageID)
|
||||
|
||||
case models.ExtPermNotificationsSend:
|
||||
if r.notifier != nil {
|
||||
modules["notifications"] = BuildNotificationsModule(ctx, r.notifier, packageID)
|
||||
}
|
||||
|
||||
// Future permissions (CS4+):
|
||||
// case models.ExtPermDBRead, models.ExtPermDBWrite:
|
||||
// modules["db"] = BuildDBModule(...)
|
||||
// case models.ExtPermAPIHTTP:
|
||||
// modules["http"] = BuildHTTPModule(...)
|
||||
}
|
||||
}
|
||||
|
||||
return modules, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
306
server/sandbox/sandbox_test.go
Normal file
306
server/sandbox/sandbox_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
func TestExecBasic(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
res, err := sb.Exec(context.Background(), "test.star", `
|
||||
x = 1 + 2
|
||||
name = "hello"
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := res.Globals["x"]; !ok {
|
||||
t.Fatal("missing global 'x'")
|
||||
} else if v.String() != "3" {
|
||||
t.Fatalf("expected x=3, got %s", v.String())
|
||||
}
|
||||
if v, ok := res.Globals["name"]; !ok {
|
||||
t.Fatal("missing global 'name'")
|
||||
} else if v.(starlark.String).GoString() != "hello" {
|
||||
t.Fatalf("expected name='hello', got %s", v.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecPrintCapture(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
res, err := sb.Exec(context.Background(), "test.star", `
|
||||
print("line one")
|
||||
print("line two")
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := "line one\nline two\n"
|
||||
if res.Output != expected {
|
||||
t.Fatalf("expected output %q, got %q", expected, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecStepsTracked(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
res, err := sb.Exec(context.Background(), "test.star", `
|
||||
def work():
|
||||
x = 0
|
||||
for i in range(100):
|
||||
x += i
|
||||
return x
|
||||
result = work()
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Steps == 0 {
|
||||
t.Fatal("expected steps > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecStepLimitExceeded(t *testing.T) {
|
||||
sb := New(Config{MaxSteps: 100, Name: "limited"})
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
def work():
|
||||
x = 0
|
||||
for i in range(10000):
|
||||
x += i
|
||||
return x
|
||||
result = work()
|
||||
`, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected step limit error")
|
||||
}
|
||||
// Error message varies: "Starlark computation cancelled: exceeded ..."
|
||||
// or "context canceled" — just verify it failed.
|
||||
}
|
||||
|
||||
func TestExecContextTimeout(t *testing.T) {
|
||||
// Use step limit of 0 (unlimited) so only context timeout triggers
|
||||
sb := New(Config{MaxSteps: 0, Name: "timeout"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := sb.Exec(ctx, "test.star", `
|
||||
def work():
|
||||
x = 0
|
||||
for i in range(100000000):
|
||||
x += i
|
||||
return x
|
||||
result = work()
|
||||
`, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecLoadDisabled(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
load("other.star", "foo")
|
||||
`, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected load error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "disabled") {
|
||||
t.Fatalf("expected 'disabled' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecModuleInjection(t *testing.T) {
|
||||
getFn := starlark.NewBuiltin("test.get", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
return starlark.String("secret_value"), nil
|
||||
})
|
||||
|
||||
mod := MakeModule("test", starlark.StringDict{
|
||||
"get": getFn,
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
res, err := sb.Exec(context.Background(), "test.star", `
|
||||
result = test.get()
|
||||
`, map[string]starlark.Value{"test": mod})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
v, ok := res.Globals["result"]
|
||||
if !ok {
|
||||
t.Fatal("missing global 'result'")
|
||||
}
|
||||
if v.(starlark.String).GoString() != "secret_value" {
|
||||
t.Fatalf("expected 'secret_value', got %s", v.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecSyntaxError(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "bad.star", `
|
||||
def broken(
|
||||
`, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error")
|
||||
}
|
||||
se, ok := err.(*SandboxError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SandboxError, got %T", err)
|
||||
}
|
||||
_ = se // syntax errors may not have backtrace
|
||||
}
|
||||
|
||||
func TestExecRuntimeError(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "runtime.star", `
|
||||
x = 1 / 0
|
||||
`, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime error")
|
||||
}
|
||||
se, ok := err.(*SandboxError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SandboxError, got %T", err)
|
||||
}
|
||||
if se.Backtrace == "" {
|
||||
t.Fatal("expected backtrace for runtime error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
res, err := sb.Exec(context.Background(), "funcs.star", `
|
||||
def greet(name):
|
||||
return "hello, " + name
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("exec error: %v", err)
|
||||
}
|
||||
|
||||
fn := res.Globals["greet"].(starlark.Callable)
|
||||
val, _, err := sb.Call(context.Background(), fn,
|
||||
starlark.Tuple{starlark.String("world")}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("call error: %v", err)
|
||||
}
|
||||
if val.(starlark.String).GoString() != "hello, world" {
|
||||
t.Fatalf("expected 'hello, world', got %s", val.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWithPrint(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
res, err := sb.Exec(context.Background(), "funcs.star", `
|
||||
def run():
|
||||
print("running")
|
||||
return 42
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("exec error: %v", err)
|
||||
}
|
||||
|
||||
fn := res.Globals["run"].(starlark.Callable)
|
||||
val, output, err := sb.Call(context.Background(), fn, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("call error: %v", err)
|
||||
}
|
||||
if val.String() != "42" {
|
||||
t.Fatalf("expected 42, got %s", val.String())
|
||||
}
|
||||
if !strings.Contains(output, "running") {
|
||||
t.Fatalf("expected 'running' in output, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallRuntimeError(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
res, err := sb.Exec(context.Background(), "funcs.star", `
|
||||
def boom():
|
||||
return 1 / 0
|
||||
`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("exec error: %v", err)
|
||||
}
|
||||
|
||||
fn := res.Globals["boom"].(starlark.Callable)
|
||||
_, _, err = sb.Call(context.Background(), fn, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime error from Call")
|
||||
}
|
||||
se, ok := err.(*SandboxError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SandboxError, got %T", err)
|
||||
}
|
||||
if se.Backtrace == "" {
|
||||
t.Fatal("expected backtrace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaults(t *testing.T) {
|
||||
sb := New(Config{})
|
||||
if sb.config.MaxSteps != 0 {
|
||||
t.Fatalf("expected MaxSteps=0 (unlimited), got %d", sb.config.MaxSteps)
|
||||
}
|
||||
if sb.config.Name != "extension" {
|
||||
t.Fatalf("expected default Name='extension', got %q", sb.config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithDefaultConfig(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
if sb.config.MaxSteps != 1_000_000 {
|
||||
t.Fatalf("expected MaxSteps=1000000, got %d", sb.config.MaxSteps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeModule(t *testing.T) {
|
||||
mod := MakeModule("mymod", starlark.StringDict{
|
||||
"version": starlark.String("1.0"),
|
||||
})
|
||||
if mod.Name != "mymod" {
|
||||
t.Fatalf("expected name 'mymod', got %q", mod.Name)
|
||||
}
|
||||
v, ok := mod.Members["version"]
|
||||
if !ok {
|
||||
t.Fatal("missing 'version' member")
|
||||
}
|
||||
if v.(starlark.String).GoString() != "1.0" {
|
||||
t.Fatalf("expected '1.0', got %s", v.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxErrorUnwrap(t *testing.T) {
|
||||
inner := starlark.String("test").Truth() // just need any value
|
||||
_ = inner
|
||||
se := &SandboxError{
|
||||
Err: context.DeadlineExceeded,
|
||||
Backtrace: "",
|
||||
}
|
||||
if se.Unwrap() != context.DeadlineExceeded {
|
||||
t.Fatal("Unwrap should return inner error")
|
||||
}
|
||||
if se.Error() != "context deadline exceeded" {
|
||||
t.Fatalf("unexpected error string: %q", se.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxErrorWithBacktrace(t *testing.T) {
|
||||
se := &SandboxError{
|
||||
Err: context.DeadlineExceeded,
|
||||
Backtrace: " funcs.star:3:5: in boom",
|
||||
}
|
||||
if !strings.Contains(se.Error(), "context deadline exceeded") {
|
||||
t.Fatal("should contain original error")
|
||||
}
|
||||
if !strings.Contains(se.Error(), "funcs.star:3:5") {
|
||||
t.Fatal("should contain backtrace")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user