// 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. // // 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" ) // 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 } // 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 } // 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) 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) } } // Future permissions: // case models.ExtPermDBRead, models.ExtPermDBWrite: // modules["db"] = BuildDBModule(...) } } return modules, nil }