Changeset 0.38.0 (#233)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -22,6 +22,9 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
@@ -43,12 +46,13 @@ type RunContext struct {
|
||||
|
||||
// Runner executes Starlark package scripts with permission-gated modules.
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -77,8 +81,17 @@ func (r *Runner) SetDB(db *sql.DB, isPostgres bool) {
|
||||
r.dbPostgres = isPostgres
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from its manifest and executes it
|
||||
// with modules gated by the package's granted permissions.
|
||||
// SetPackagesDir sets the disk path where package archives are extracted.
|
||||
// Required for load() support — scripts are read from disk at runtime.
|
||||
// v0.38.0.
|
||||
func (r *Runner) SetPackagesDir(dir string) {
|
||||
r.packagesDir = dir
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from disk (primary) or manifest
|
||||
// (legacy) and executes it with modules gated by granted permissions.
|
||||
//
|
||||
// v0.38.0: Disk-based loading + package-scoped load() support.
|
||||
//
|
||||
// The RunContext carries per-invocation state (user_id for provider
|
||||
// resolution). Pass nil if no per-invocation context is needed.
|
||||
@@ -94,10 +107,10 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
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)
|
||||
// Load script from disk (primary) or manifest (legacy)
|
||||
script, err := r.loadScript(pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build modules based on granted permissions
|
||||
@@ -106,9 +119,105 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
|
||||
// Build package-scoped load callback
|
||||
loader := r.packageLoader(pkg.ID, modules)
|
||||
|
||||
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
|
||||
|
||||
return r.sandbox.Exec(ctx, pkg.ID+".star", script, modules)
|
||||
return r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
|
||||
}
|
||||
|
||||
// loadScript reads the entry point script from disk (primary path)
|
||||
// or falls back to the legacy _starlark_script manifest field.
|
||||
func (r *Runner) loadScript(pkg *store.PackageRegistration) (string, error) {
|
||||
// Primary: read from disk
|
||||
if r.packagesDir != "" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := pkg.Manifest["entry_point"].(string); ok && ep != "" {
|
||||
entryPoint = ep
|
||||
}
|
||||
path := filepath.Join(r.packagesDir, pkg.ID, entryPoint)
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
// Fall through to legacy
|
||||
}
|
||||
|
||||
// Legacy: inline in manifest
|
||||
script, ok := pkg.Manifest["_starlark_script"].(string)
|
||||
if ok && script != "" {
|
||||
return script, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("package %q: no script.star on disk and no _starlark_script in manifest", pkg.ID)
|
||||
}
|
||||
|
||||
// loadEntry tracks cache and cycle detection for the package loader.
|
||||
type loadEntry struct {
|
||||
loading bool
|
||||
globals starlark.StringDict
|
||||
}
|
||||
|
||||
// packageLoader builds a LoadFunc scoped to a package's directory.
|
||||
// Returns nil if no packages directory is configured.
|
||||
func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value) LoadFunc {
|
||||
if r.packagesDir == "" {
|
||||
return nil // no disk = no load support
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(r.packagesDir, pkgID)
|
||||
cache := make(map[string]*loadEntry) // dedup + cycle detection
|
||||
|
||||
return func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
// Security: reject path traversal
|
||||
if strings.Contains(module, "..") || filepath.IsAbs(module) {
|
||||
return nil, fmt.Errorf("load: path traversal not allowed: %q", module)
|
||||
}
|
||||
|
||||
// Resolve to package directory
|
||||
resolved := filepath.Join(pkgDir, module)
|
||||
if !strings.HasPrefix(filepath.Clean(resolved), filepath.Clean(pkgDir)) {
|
||||
return nil, fmt.Errorf("load: path escapes package directory: %q", module)
|
||||
}
|
||||
|
||||
// Must be a .star file
|
||||
if !strings.HasSuffix(resolved, ".star") {
|
||||
return nil, fmt.Errorf("load: only .star files can be loaded: %q", module)
|
||||
}
|
||||
|
||||
// Cache / cycle detection
|
||||
if entry, ok := cache[module]; ok {
|
||||
if entry.loading {
|
||||
return nil, fmt.Errorf("load: circular dependency: %q", module)
|
||||
}
|
||||
return entry.globals, nil
|
||||
}
|
||||
|
||||
entry := &loadEntry{loading: true}
|
||||
cache[module] = entry
|
||||
|
||||
// Read and execute
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: %q not found in package %q", module, pkgID)
|
||||
}
|
||||
|
||||
// Build predeclared with same modules as entry point
|
||||
predeclared := make(starlark.StringDict, len(modules)+1)
|
||||
for k, v := range modules {
|
||||
predeclared[k] = v
|
||||
}
|
||||
|
||||
globals, err := starlark.ExecFile(thread, module, string(data), predeclared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: error in %q: %w", module, err)
|
||||
}
|
||||
|
||||
entry.globals = globals
|
||||
entry.loading = false
|
||||
return globals, nil
|
||||
}
|
||||
}
|
||||
|
||||
// CallEntryPoint executes a package script and calls a named function.
|
||||
|
||||
@@ -79,18 +79,28 @@ func New(cfg Config) *Sandbox {
|
||||
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
|
||||
// (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) {
|
||||
// - 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
|
||||
|
||||
@@ -101,6 +111,14 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map
|
||||
// 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) {
|
||||
@@ -109,9 +127,7 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map
|
||||
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)
|
||||
},
|
||||
Load: loadFn,
|
||||
}
|
||||
|
||||
if s.config.MaxSteps > 0 {
|
||||
|
||||
Reference in New Issue
Block a user