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/runner.go
Jeffrey Smith 115004a3ab Changeset 0.29.2 (#197)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-17 22:31:34 +00:00

206 lines
6.7 KiB
Go

// 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.
//
// v0.29.1 CS0: Wires api.http permission to BuildHTTPModule with
// network_access config from package manifest.
//
// v0.29.1 CS2: Adds RunContext for per-invocation state (user_id),
// vault for provider key decryption, and provider.complete module.
//
// v0.29.2 CS1: Adds db *sql.DB for the db module. SetDB() wires it
// at startup. db.read grants query/view/list_tables; db.write adds
// insert/update/delete. If both are granted, db.write wins (superset).
//
// The runner is the bridge between the package/permission system
// and the sandboxed Starlark interpreter.
package sandbox
import (
"context"
"database/sql"
"fmt"
"log"
"go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// RunContext carries per-invocation state that modules need but which
// varies per caller (API route vs filter vs task). Nil is safe — modules
// that need RunContext fields gracefully degrade.
type RunContext struct {
// UserID is the acting user for provider resolution (BYOK chain).
UserID string
// ChannelID is the channel context, if any. Used for provider
// resolution when a channel has a pinned provider config.
ChannelID string
}
// 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 ?
}
// 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
}
// SetProviderResolver attaches the provider resolver for the provider.complete module.
func (r *Runner) SetProviderResolver(pr ProviderResolver) {
r.resolver = pr
}
// SetDB attaches the raw database connection for the db module.
// isPostgres controls whether $N or ? placeholders are used.
// Call this at startup after database.Connect().
func (r *Runner) SetDB(db *sql.DB, isPostgres bool) {
r.db = db
r.dbPostgres = isPostgres
}
// ExecPackage loads a package's script from its manifest and executes it
// with modules gated by the package's granted permissions.
//
// The RunContext carries per-invocation state (user_id for provider
// resolution). Pass nil if no per-invocation context is needed.
//
// 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, rc *RunContext) (*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, pkg.Manifest, rc)
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
//
// The RunContext carries per-invocation state. Pass nil if not needed.
//
// 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, rc *RunContext) (starlark.Value, string, error) {
result, err := r.ExecPackage(ctx, pkg, rc)
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.
// The manifest is passed through so modules can read their config
// (e.g., http module reads network_access, provider reads requires_provider).
// The RunContext carries per-invocation state for user-scoped modules.
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (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)
// Track db permission level: 0=none, 1=read, 2=write
dbLevel := 0
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)
}
case models.ExtPermAPIHTTP:
httpCfg := ParseNetworkAccess(manifest)
modules["http"] = BuildHTTPModule(ctx, httpCfg)
case models.ExtPermProviderComplete:
if r.resolver != nil && rc != nil && rc.UserID != "" {
provCfg, ok := ParseRequiresProvider(manifest)
if ok {
modules["provider"] = BuildProviderModule(ctx, r.resolver, rc.UserID, provCfg)
}
}
case models.ExtPermDBRead:
if dbLevel < 1 {
dbLevel = 1
}
case models.ExtPermDBWrite:
dbLevel = 2
}
}
// Wire db module at the highest granted level.
if dbLevel > 0 && r.db != nil {
modules["db"] = BuildDBModule(ctx, DBModuleConfig{
PackageID: packageID,
CanWrite: dbLevel == 2,
DB: r.db,
IsPostgres: r.dbPostgres,
})
}
return modules, nil
}