Changeset 0.29.1 (#196)

This commit is contained in:
2026-03-17 19:32:20 +00:00
parent 5d637d3a90
commit d4de84f3f1
24 changed files with 3117 additions and 28 deletions

View File

@@ -0,0 +1,448 @@
// Package sandbox — http_module.go
//
// v0.29.1 CS0: HTTP outbound module for Starlark extensions.
// Requires permission: api.http
//
// Starlark API:
//
// resp = http.get(url, headers={})
// resp = http.post(url, body="", headers={})
// resp = http.put(url, body="", headers={})
// resp = http.delete(url, headers={})
// resp = http.request(method, url, body="", headers={})
//
// # resp is a dict: {"status": 200, "headers": {...}, "body": "..."}
//
// Network access rules from manifest:
//
// {
// "network_access": {
// "allow": ["api.example.com"], // allowlist mode: ONLY these domains
// "block": ["evil.com"] // blocklist: additive to built-in blocks
// }
// }
//
// Security:
// - Private/loopback IPs blocked (SSRF protection)
// - DNS resolution checked before connect
// - Response body capped at 1 MB
// - 10 s default timeout (inherits context)
// - Max 10 redirects, each checked for SSRF
package sandbox
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"time"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)
// ─── Configuration ──────────────────────────
const (
httpDefaultTimeout = 10 * time.Second
httpMaxResponseBody = 1 << 20 // 1 MB
httpMaxRedirects = 10
)
// HTTPModuleConfig controls the HTTP module's network access policy.
type HTTPModuleConfig struct {
// AllowDomains: if non-empty, only these exact domains are reachable.
// When set, BlockDomains is ignored (allowlist takes precedence).
AllowDomains []string
// BlockDomains: domains to block in addition to built-in SSRF rules.
// Ignored when AllowDomains is non-empty.
BlockDomains []string
// Timeout overrides the default 10 s request timeout.
// Zero means use default.
Timeout time.Duration
// MaxBodyBytes overrides the default 1 MB response body limit.
// Zero means use default.
MaxBodyBytes int64
}
func (c HTTPModuleConfig) timeout() time.Duration {
if c.Timeout > 0 {
return c.Timeout
}
return httpDefaultTimeout
}
func (c HTTPModuleConfig) maxBody() int64 {
if c.MaxBodyBytes > 0 {
return c.MaxBodyBytes
}
return httpMaxResponseBody
}
// ParseNetworkAccess extracts HTTPModuleConfig from a package manifest.
// Missing or malformed fields are silently ignored (no access = default policy).
func ParseNetworkAccess(manifest map[string]any) HTTPModuleConfig {
cfg := HTTPModuleConfig{}
na, ok := manifest["network_access"]
if !ok {
return cfg
}
naMap, ok := na.(map[string]any)
if !ok {
return cfg
}
if allow, ok := naMap["allow"]; ok {
cfg.AllowDomains = toStringSlice(allow)
}
if block, ok := naMap["block"]; ok {
cfg.BlockDomains = toStringSlice(block)
}
return cfg
}
func toStringSlice(v any) []string {
switch arr := v.(type) {
case []any:
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok && s != "" {
out = append(out, strings.ToLower(s))
}
}
return out
case []string:
out := make([]string, 0, len(arr))
for _, s := range arr {
if s != "" {
out = append(out, strings.ToLower(s))
}
}
return out
default:
return nil
}
}
// ─── Module Builder ─────────────────────────
// BuildHTTPModule creates the "http" Starlark module with the given
// network access policy. Each call to a builtin creates a fresh
// request bound to the parent context.
func BuildHTTPModule(ctx context.Context, cfg HTTPModuleConfig) *starlarkstruct.Module {
ac := &accessControl{cfg: cfg}
doRequest := func(method, rawURL, body string, headers map[string]string) (starlark.Value, error) {
return executeHTTPRequest(ctx, ac, method, rawURL, body, headers, cfg.timeout(), cfg.maxBody())
}
return MakeModule("http", starlark.StringDict{
"request": starlark.NewBuiltin("http.request", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var method, rawURL string
var body string
var hdrs *starlark.Dict
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"method", &method,
"url", &rawURL,
"body?", &body,
"headers?", &hdrs,
); err != nil {
return nil, err
}
return doRequest(strings.ToUpper(method), rawURL, body, dictToStringMap(hdrs))
}),
"get": starlark.NewBuiltin("http.get", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var rawURL string
var hdrs *starlark.Dict
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"url", &rawURL,
"headers?", &hdrs,
); err != nil {
return nil, err
}
return doRequest("GET", rawURL, "", dictToStringMap(hdrs))
}),
"post": starlark.NewBuiltin("http.post", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var rawURL, body string
var hdrs *starlark.Dict
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"url", &rawURL,
"body?", &body,
"headers?", &hdrs,
); err != nil {
return nil, err
}
return doRequest("POST", rawURL, body, dictToStringMap(hdrs))
}),
"put": starlark.NewBuiltin("http.put", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var rawURL, body string
var hdrs *starlark.Dict
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"url", &rawURL,
"body?", &body,
"headers?", &hdrs,
); err != nil {
return nil, err
}
return doRequest("PUT", rawURL, body, dictToStringMap(hdrs))
}),
"delete": starlark.NewBuiltin("http.delete", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var rawURL string
var hdrs *starlark.Dict
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"url", &rawURL,
"headers?", &hdrs,
); err != nil {
return nil, err
}
return doRequest("DELETE", rawURL, "", dictToStringMap(hdrs))
}),
})
}
// ─── Request Execution ──────────────────────
func executeHTTPRequest(
ctx context.Context,
ac *accessControl,
method, rawURL, body string,
headers map[string]string,
timeout time.Duration,
maxBody int64,
) (starlark.Value, error) {
// Validate URL
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("http.%s: invalid URL: %w", strings.ToLower(method), err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("http.%s: only http/https schemes allowed, got %q", strings.ToLower(method), parsed.Scheme)
}
// Check domain against access control (pre-DNS)
host := parsed.Hostname()
if err := ac.checkDomain(host); err != nil {
return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
}
// Build request
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(reqCtx, method, rawURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
}
// Set headers (user-provided override defaults)
req.Header.Set("User-Agent", "ChatSwitchboard-Extension/1.0")
for k, v := range headers {
req.Header.Set(k, v)
}
// SSRF-safe client with custom dialer
client := ac.client()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http.%s: request failed: %w", strings.ToLower(method), err)
}
defer resp.Body.Close()
// Read body with size limit
limited := io.LimitReader(resp.Body, maxBody+1)
respBody, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("http.%s: reading response: %w", strings.ToLower(method), err)
}
if int64(len(respBody)) > maxBody {
respBody = respBody[:maxBody]
// Truncated — still return what we have, don't error
}
return buildResponseDict(resp.StatusCode, resp.Header, string(respBody))
}
// buildResponseDict converts an HTTP response into a Starlark dict:
//
// {"status": int, "headers": dict, "body": string}
func buildResponseDict(status int, h http.Header, body string) (starlark.Value, error) {
// Build headers dict (lowercase keys, first value only for simplicity)
hdrDict := starlark.NewDict(len(h))
for k, vals := range h {
if len(vals) > 0 {
_ = hdrDict.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
}
}
resp := starlark.NewDict(3)
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(status))
_ = resp.SetKey(starlark.String("headers"), hdrDict)
_ = resp.SetKey(starlark.String("body"), starlark.String(body))
return resp, nil
}
// ─── Access Control ─────────────────────────
type accessControl struct {
cfg HTTPModuleConfig
}
// checkDomain validates a hostname against the access policy.
func (ac *accessControl) checkDomain(host string) error {
host = strings.ToLower(host)
// Allowlist mode: only listed domains are permitted
if len(ac.cfg.AllowDomains) > 0 {
for _, d := range ac.cfg.AllowDomains {
if host == d {
return nil
}
}
return fmt.Errorf("domain %q not in network_access allow list", host)
}
// Blocklist mode: check against explicit blocks
for _, d := range ac.cfg.BlockDomains {
if host == d {
return fmt.Errorf("domain %q is blocked by network_access policy", host)
}
}
return nil
}
// client builds an SSRF-safe http.Client with IP validation on connect.
func (ac *accessControl) client() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
Control: ssrfControl,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
MaxIdleConns: 1,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: true, // extension requests are one-shot
}
return &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= httpMaxRedirects {
return fmt.Errorf("too many redirects (max %d)", httpMaxRedirects)
}
// Re-check domain on redirect to prevent SSRF via redirect
host := req.URL.Hostname()
if err := ac.checkDomain(host); err != nil {
return err
}
return nil
},
}
}
// ssrfControl is a net.Dialer.Control function that rejects connections
// to private, loopback, and link-local addresses. Called after DNS
// resolution, before the TCP handshake — prevents DNS rebinding attacks.
func ssrfControl(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("ssrf: invalid address %q: %w", address, err)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("ssrf: could not parse IP %q", host)
}
if !isPublicIP(ip) {
return fmt.Errorf("ssrf: connection to private/reserved address %s blocked", ip)
}
return nil
}
// isPublicIP returns true if the IP is a routable public address.
// Blocks all RFC1918, loopback, link-local, multicast, and unspecified.
func isPublicIP(ip net.IP) bool {
// Unspecified (0.0.0.0, ::)
if ip.IsUnspecified() {
return false
}
// Loopback (127.0.0.0/8, ::1)
if ip.IsLoopback() {
return false
}
// Link-local unicast (169.254.0.0/16, fe80::/10)
if ip.IsLinkLocalUnicast() {
return false
}
// Link-local multicast
if ip.IsLinkLocalMulticast() {
return false
}
// Multicast
if ip.IsMulticast() {
return false
}
// Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
if ip.IsPrivate() {
return false
}
return true
}
// ─── Starlark Dict Helpers ──────────────────
// dictToStringMap converts a Starlark dict to a Go map[string]string.
// Non-string keys or values are silently skipped.
func dictToStringMap(d *starlark.Dict) map[string]string {
if d == nil {
return nil
}
m := make(map[string]string, d.Len())
for _, item := range d.Items() {
k, ok1 := starlark.AsString(item[0])
v, ok2 := starlark.AsString(item[1])
if ok1 && ok2 {
m[k] = v
}
}
return m
}

View File

@@ -0,0 +1,510 @@
package sandbox
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go.starlark.net/starlark"
)
// ─── isPublicIP ─────────────────────────────
func TestIsPublicIP(t *testing.T) {
tests := []struct {
ip string
public bool
}{
// Public
{"8.8.8.8", true},
{"1.1.1.1", true},
{"93.184.216.34", true},
{"2607:f8b0:4004:800::200e", true}, // google IPv6
// Loopback
{"127.0.0.1", false},
{"127.0.0.2", false},
{"::1", false},
// Private RFC1918
{"10.0.0.1", false},
{"10.255.255.255", false},
{"172.16.0.1", false},
{"172.31.255.255", false},
{"192.168.0.1", false},
{"192.168.1.100", false},
// Link-local
{"169.254.0.1", false},
{"169.254.169.254", false}, // AWS metadata
{"fe80::1", false},
// Unspecified
{"0.0.0.0", false},
{"::", false},
// Multicast
{"224.0.0.1", false},
{"ff02::1", false},
// Private IPv6
{"fc00::1", false},
{"fd00::1", false},
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse test IP %q", tt.ip)
}
got := isPublicIP(ip)
if got != tt.public {
t.Errorf("isPublicIP(%s) = %v, want %v", tt.ip, got, tt.public)
}
}
}
// ─── ParseNetworkAccess ─────────────────────
func TestParseNetworkAccess(t *testing.T) {
t.Run("empty manifest", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{})
if len(cfg.AllowDomains) != 0 || len(cfg.BlockDomains) != 0 {
t.Errorf("expected empty config, got allow=%v block=%v", cfg.AllowDomains, cfg.BlockDomains)
}
})
t.Run("allow list", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{
"network_access": map[string]any{
"allow": []any{"API.Example.COM", "hooks.slack.com"},
},
})
if len(cfg.AllowDomains) != 2 {
t.Fatalf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
}
if cfg.AllowDomains[0] != "api.example.com" {
t.Errorf("allow[0] = %q, want %q", cfg.AllowDomains[0], "api.example.com")
}
})
t.Run("block list", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{
"network_access": map[string]any{
"block": []any{"evil.com"},
},
})
if len(cfg.BlockDomains) != 1 || cfg.BlockDomains[0] != "evil.com" {
t.Errorf("expected block=[evil.com], got %v", cfg.BlockDomains)
}
})
t.Run("malformed network_access ignored", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{
"network_access": "not a map",
})
if len(cfg.AllowDomains) != 0 {
t.Errorf("expected empty allow, got %v", cfg.AllowDomains)
}
})
t.Run("string slice variant", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{
"network_access": map[string]any{
"allow": []string{"a.com", "b.com"},
},
})
if len(cfg.AllowDomains) != 2 {
t.Errorf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
}
})
t.Run("empty strings filtered", func(t *testing.T) {
cfg := ParseNetworkAccess(map[string]any{
"network_access": map[string]any{
"allow": []any{"", "ok.com", ""},
},
})
if len(cfg.AllowDomains) != 1 || cfg.AllowDomains[0] != "ok.com" {
t.Errorf("expected [ok.com], got %v", cfg.AllowDomains)
}
})
}
// ─── Access Control ─────────────────────────
func TestAccessControl_Allowlist(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{
AllowDomains: []string{"api.example.com", "hooks.slack.com"},
}}
if err := ac.checkDomain("api.example.com"); err != nil {
t.Errorf("allowed domain rejected: %v", err)
}
if err := ac.checkDomain("hooks.slack.com"); err != nil {
t.Errorf("allowed domain rejected: %v", err)
}
if err := ac.checkDomain("evil.com"); err == nil {
t.Error("unlisted domain should be rejected in allowlist mode")
}
if err := ac.checkDomain("API.EXAMPLE.COM"); err != nil {
t.Errorf("case-insensitive match should pass: %v", err)
}
}
func TestAccessControl_Blocklist(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{
BlockDomains: []string{"evil.com", "malware.org"},
}}
if err := ac.checkDomain("api.example.com"); err != nil {
t.Errorf("unblocked domain rejected: %v", err)
}
if err := ac.checkDomain("evil.com"); err == nil {
t.Error("blocked domain should be rejected")
}
if err := ac.checkDomain("malware.org"); err == nil {
t.Error("blocked domain should be rejected")
}
}
func TestAccessControl_NoPolicy(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{}}
if err := ac.checkDomain("anything.com"); err != nil {
t.Errorf("no-policy mode should allow all public domains: %v", err)
}
}
// ─── dictToStringMap ────────────────────────
func TestDictToStringMap(t *testing.T) {
t.Run("nil dict", func(t *testing.T) {
m := dictToStringMap(nil)
if m != nil {
t.Errorf("nil input should return nil, got %v", m)
}
})
t.Run("valid dict", func(t *testing.T) {
d := starlark.NewDict(2)
_ = d.SetKey(starlark.String("Content-Type"), starlark.String("application/json"))
_ = d.SetKey(starlark.String("X-Token"), starlark.String("abc"))
m := dictToStringMap(d)
if m["Content-Type"] != "application/json" {
t.Errorf("Content-Type = %q", m["Content-Type"])
}
if m["X-Token"] != "abc" {
t.Errorf("X-Token = %q", m["X-Token"])
}
})
t.Run("non-string values skipped", func(t *testing.T) {
d := starlark.NewDict(2)
_ = d.SetKey(starlark.String("ok"), starlark.String("yes"))
_ = d.SetKey(starlark.String("bad"), starlark.MakeInt(42))
m := dictToStringMap(d)
if len(m) != 1 {
t.Errorf("expected 1 entry (non-string skipped), got %d", len(m))
}
})
}
// ─── buildResponseDict ──────────────────────
func TestBuildResponseDict(t *testing.T) {
h := http.Header{}
h.Set("Content-Type", "application/json")
h.Set("X-Request-Id", "abc-123")
val, err := buildResponseDict(200, h, `{"ok":true}`)
if err != nil {
t.Fatalf("buildResponseDict: %v", err)
}
d, ok := val.(*starlark.Dict)
if !ok {
t.Fatalf("expected *starlark.Dict, got %T", val)
}
// Status
statusVal, found, _ := d.Get(starlark.String("status"))
if !found {
t.Fatal("missing 'status' key")
}
statusInt, _ := starlark.AsInt32(statusVal)
if statusInt != 200 {
t.Errorf("status = %d, want 200", statusInt)
}
// Body
bodyVal, found, _ := d.Get(starlark.String("body"))
if !found {
t.Fatal("missing 'body' key")
}
bodyStr, _ := starlark.AsString(bodyVal)
if bodyStr != `{"ok":true}` {
t.Errorf("body = %q", bodyStr)
}
// Headers
hdrsVal, found, _ := d.Get(starlark.String("headers"))
if !found {
t.Fatal("missing 'headers' key")
}
hdrs, ok := hdrsVal.(*starlark.Dict)
if !ok {
t.Fatalf("headers is %T, want *Dict", hdrsVal)
}
ctVal, found, _ := hdrs.Get(starlark.String("content-type"))
if !found {
t.Fatal("missing content-type header")
}
ct, _ := starlark.AsString(ctVal)
if ct != "application/json" {
t.Errorf("content-type = %q", ct)
}
}
// ─── Integration: HTTP GET against httptest ──
func TestHTTPModule_GET(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
if r.Header.Get("X-Custom") != "hello" {
t.Errorf("missing custom header")
}
w.Header().Set("X-Test", "passed")
w.WriteHeader(200)
fmt.Fprint(w, `{"result":"ok"}`)
}))
defer srv.Close()
// Extract host for allowlist
host := strings.TrimPrefix(srv.URL, "http://")
hostOnly := strings.Split(host, ":")[0]
_ = hostOnly // httptest uses 127.0.0.1 which is blocked by SSRF
// For testing, we need to use the module without SSRF checks
// since httptest binds to 127.0.0.1. Test the module builder
// API by calling executeHTTPRequest with a permissive client.
// SSRF checks are tested separately via isPublicIP tests.
resp, err := executeHTTPRequest(
context.Background(),
&accessControl{cfg: HTTPModuleConfig{}},
"GET", srv.URL, "",
map[string]string{"X-Custom": "hello"},
5*time.Second,
httpMaxResponseBody,
)
// This will fail with SSRF block because httptest is 127.0.0.1.
// That's the correct behavior — verify we get the SSRF error.
if err == nil {
// If it somehow succeeded (e.g., test env allows loopback), check response
d := resp.(*starlark.Dict)
statusVal, _, _ := d.Get(starlark.String("status"))
statusInt, _ := starlark.AsInt32(statusVal)
if statusInt != 200 {
t.Errorf("status = %d, want 200", statusInt)
}
} else if !strings.Contains(err.Error(), "ssrf") && !strings.Contains(err.Error(), "private") {
t.Errorf("expected SSRF error for loopback, got: %v", err)
}
}
// TestHTTPModule_SSRFBlocked verifies that private IPs are blocked
// even when disguised as valid URLs.
func TestHTTPModule_SSRFBlocked(t *testing.T) {
targets := []string{
"http://127.0.0.1/",
"http://10.0.0.1/",
"http://192.168.1.1/",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://[::1]/",
}
ac := &accessControl{cfg: HTTPModuleConfig{}}
for _, target := range targets {
_, err := executeHTTPRequest(
context.Background(), ac,
"GET", target, "", nil,
2*time.Second, httpMaxResponseBody,
)
if err == nil {
t.Errorf("expected SSRF block for %s", target)
}
}
}
// TestHTTPModule_SchemeValidation verifies only http/https are allowed.
func TestHTTPModule_SchemeValidation(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{}}
schemes := []string{"ftp://example.com", "file:///etc/passwd", "gopher://evil.com"}
for _, u := range schemes {
_, err := executeHTTPRequest(
context.Background(), ac,
"GET", u, "", nil,
2*time.Second, httpMaxResponseBody,
)
if err == nil {
t.Errorf("expected scheme rejection for %s", u)
}
if !strings.Contains(err.Error(), "only http/https") {
t.Errorf("wrong error for %s: %v", u, err)
}
}
}
// TestHTTPModule_DomainBlock verifies blocklist enforcement at request time.
func TestHTTPModule_DomainBlock(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{
BlockDomains: []string{"evil.com"},
}}
_, err := executeHTTPRequest(
context.Background(), ac,
"GET", "https://evil.com/api", "", nil,
2*time.Second, httpMaxResponseBody,
)
if err == nil {
t.Error("expected block for evil.com")
}
if !strings.Contains(err.Error(), "blocked") {
t.Errorf("wrong error: %v", err)
}
}
// TestHTTPModule_AllowlistEnforcement verifies allowlist-only mode.
func TestHTTPModule_AllowlistEnforcement(t *testing.T) {
ac := &accessControl{cfg: HTTPModuleConfig{
AllowDomains: []string{"api.good.com"},
}}
_, err := executeHTTPRequest(
context.Background(), ac,
"GET", "https://api.bad.com/data", "", nil,
2*time.Second, httpMaxResponseBody,
)
if err == nil {
t.Error("expected rejection for unlisted domain")
}
if !strings.Contains(err.Error(), "not in network_access allow list") {
t.Errorf("wrong error: %v", err)
}
}
// TestHTTPModule_Timeout verifies context-based timeout.
func TestHTTPModule_Timeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second)
w.WriteHeader(200)
}))
defer srv.Close()
ac := &accessControl{cfg: HTTPModuleConfig{}}
_, err := executeHTTPRequest(
context.Background(), ac,
"GET", srv.URL, "", nil,
100*time.Millisecond, httpMaxResponseBody,
)
// Will either fail with SSRF (127.0.0.1) or timeout — both are correct.
if err == nil {
t.Error("expected error (SSRF or timeout)")
}
}
// TestHTTPModule_Starlark_Integration runs the http module through the
// Starlark interpreter to verify the builtins are properly wired.
// Starlark has no try/except — errors from builtins propagate to Go.
func TestHTTPModule_Starlark_Integration(t *testing.T) {
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
AllowDomains: []string{"only-this.example.com"},
})
sb := New(DefaultConfig())
modules := map[string]starlark.Value{"http": mod}
// Call http.get against a domain NOT in the allowlist.
// The builtin should return an error that propagates to Go.
script := `
def test():
return http.get(url="https://blocked.example.com/api")
result = test()
`
_, err := sb.Exec(context.Background(), "test.star", script, modules)
if err == nil {
t.Fatal("expected error for blocked domain, got nil")
}
if !strings.Contains(err.Error(), "not in network_access allow list") {
t.Errorf("expected allowlist error, got: %v", err)
}
}
// TestHTTPModule_Starlark_POST verifies post binding accepts body/headers kwargs.
// Starlark has no try/except — we verify binding by checking the error is
// a network/DNS error (correct kwargs accepted) not a Starlark type error.
func TestHTTPModule_Starlark_POST(t *testing.T) {
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
AllowDomains: []string{"only-this.example.com"},
})
sb := New(DefaultConfig())
modules := map[string]starlark.Value{"http": mod}
// Call http.post with body and headers kwargs against an allowed domain.
// Will fail at DNS/connection (domain doesn't exist), but the binding
// itself must parse kwargs without error.
script := `
def test():
return http.post(url="https://only-this.example.com/api", body='{"k":"v"}', headers={"Content-Type": "application/json"})
result = test()
`
_, err := sb.Exec(context.Background(), "test.star", script, modules)
if err == nil {
t.Fatal("expected network error (domain doesn't resolve), got nil")
}
// Should be a network error (DNS lookup failure), NOT a Starlark
// argument-parsing error like "unexpected keyword argument".
errStr := err.Error()
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
t.Errorf("binding signature broken: %v", err)
}
}
// TestHTTPModule_Starlark_Request verifies the generic request binding.
func TestHTTPModule_Starlark_Request(t *testing.T) {
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
AllowDomains: []string{"nope.invalid"},
})
sb := New(DefaultConfig())
modules := map[string]starlark.Value{"http": mod}
// Call http.request with method, url, body kwargs.
// Will fail at DNS, but binding must accept kwargs correctly.
script := `
def test():
return http.request(method="PATCH", url="https://nope.invalid/x", body="data")
result = test()
`
_, err := sb.Exec(context.Background(), "test.star", script, modules)
if err == nil {
t.Fatal("expected network error, got nil")
}
errStr := err.Error()
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
t.Errorf("binding signature broken: %v", err)
}
}

View File

@@ -0,0 +1,241 @@
// Package sandbox — provider_module.go
//
// v0.29.1 CS2: Provider module for Starlark extensions.
// Requires permission: provider.complete
//
// Starlark API:
//
// resp = provider.complete(
// messages=[{"role": "user", "content": "Hello"}],
// model="claude-3-haiku", # optional — uses default from BYOK chain
// max_tokens=1024, # optional — default 4096
// temperature=0.7, # optional — provider default
// )
//
// # resp = {
// # "content": "Hi there!",
// # "model": "claude-3-haiku-20240307",
// # "finish_reason": "stop",
// # "input_tokens": 10,
// # "output_tokens": 15,
// # }
//
// Provider resolution uses the existing BYOK chain via the
// ProviderResolver interface (implemented by handlers package).
// This avoids a circular dependency (sandbox → handlers → sandbox).
package sandbox
import (
"context"
"fmt"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// ─── Provider Resolution Interface ──────────
// ProviderResolution holds the resolved provider and config.
// Mirrors handlers.ProviderResolution without importing it.
type ProviderResolution struct {
Provider providers.Provider
Config providers.ProviderConfig
ProviderID string
Model string
ConfigID string
}
// ProviderResolver resolves a provider configuration from the BYOK chain.
// Implemented by handlers via a thin adapter (set on Runner at startup).
type ProviderResolver interface {
Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*ProviderResolution, error)
}
// ─── Configuration ──────────────────────────
const providerDefaultMaxTokens = 4096
// ProviderModuleConfig holds per-package provider settings parsed from
// the manifest's requires_provider field.
type ProviderModuleConfig struct {
// ProviderConfigID pins the extension to a specific provider config.
// Empty string means use the BYOK resolution chain.
ProviderConfigID string
// DefaultModel is the model to use when the script doesn't specify one.
DefaultModel string
}
// ParseRequiresProvider extracts ProviderModuleConfig from a manifest.
//
// Accepted formats:
//
// true → empty config (BYOK default)
// {"model": "claude-3-haiku"} → default model
// {"provider_config_id": "uuid", ...} → pinned provider
func ParseRequiresProvider(manifest map[string]any) (ProviderModuleConfig, bool) {
raw, ok := manifest["requires_provider"]
if !ok {
return ProviderModuleConfig{}, false
}
// Boolean shorthand
if b, ok := raw.(bool); ok {
return ProviderModuleConfig{}, b
}
// Object form
m, ok := raw.(map[string]any)
if !ok {
return ProviderModuleConfig{}, false
}
cfg := ProviderModuleConfig{}
if v, ok := m["provider_config_id"].(string); ok {
cfg.ProviderConfigID = v
}
if v, ok := m["model"].(string); ok {
cfg.DefaultModel = v
}
return cfg, true
}
// ─── Module Builder ─────────────────────────
// BuildProviderModule creates the "provider" Starlark module. Each call
// to provider.complete() resolves a provider via the BYOK chain and
// makes a synchronous (non-streaming) LLM completion call.
func BuildProviderModule(
ctx context.Context,
resolver ProviderResolver,
userID string,
manifestCfg ProviderModuleConfig,
) *starlarkstruct.Module {
return MakeModule("provider", starlark.StringDict{
"complete": starlark.NewBuiltin("provider.complete", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var messagesList *starlark.List
var model string
var maxTokens int = providerDefaultMaxTokens
var temperature starlark.Value = starlark.None
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"messages", &messagesList,
"model?", &model,
"max_tokens?", &maxTokens,
"temperature?", &temperature,
); err != nil {
return nil, err
}
// Apply default model from manifest
if model == "" {
model = manifestCfg.DefaultModel
}
// Convert Starlark messages to provider messages
msgs, err := starlarkToMessages(messagesList)
if err != nil {
return nil, fmt.Errorf("provider.complete: %w", err)
}
if len(msgs) == 0 {
return nil, fmt.Errorf("provider.complete: messages list is empty")
}
// Resolve provider via BYOK chain
res, err := resolver.Resolve(ctx, userID, "",
manifestCfg.ProviderConfigID, model)
if err != nil {
return nil, fmt.Errorf("provider.complete: %w", err)
}
// Build request
req := providers.CompletionRequest{
Model: res.Model,
Messages: msgs,
MaxTokens: maxTokens,
Stream: false,
}
// Set temperature if provided
if temperature != starlark.None {
if f, ok := starlark.AsFloat(temperature); ok {
req.Temperature = &f
}
}
// Make synchronous completion call
resp, err := res.Provider.ChatCompletion(ctx, res.Config, req)
if err != nil {
return nil, fmt.Errorf("provider.complete: LLM call failed: %w", err)
}
return completionToStarlark(resp)
}),
})
}
// ─── Message Conversion ─────────────────────
// starlarkToMessages converts a Starlark list of dicts to provider Messages.
// Each dict must have "role" (string) and "content" (string).
func starlarkToMessages(list *starlark.List) ([]providers.Message, error) {
if list == nil {
return nil, nil
}
msgs := make([]providers.Message, 0, list.Len())
iter := list.Iterate()
defer iter.Done()
var item starlark.Value
for iter.Next(&item) {
dict, ok := item.(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("expected dict in messages list, got %s", item.Type())
}
roleVal, found, _ := dict.Get(starlark.String("role"))
if !found {
return nil, fmt.Errorf("message dict missing 'role' key")
}
role, ok := starlark.AsString(roleVal)
if !ok {
return nil, fmt.Errorf("message 'role' must be a string")
}
contentVal, found, _ := dict.Get(starlark.String("content"))
if !found {
return nil, fmt.Errorf("message dict missing 'content' key")
}
content, ok := starlark.AsString(contentVal)
if !ok {
return nil, fmt.Errorf("message 'content' must be a string")
}
msgs = append(msgs, providers.Message{
Role: role,
Content: content,
})
}
return msgs, nil
}
// ─── Response Conversion ────────────────────
// completionToStarlark converts a provider CompletionResponse to a Starlark dict.
func completionToStarlark(resp *providers.CompletionResponse) (starlark.Value, error) {
d := starlark.NewDict(5)
_ = d.SetKey(starlark.String("content"), starlark.String(resp.Content))
_ = d.SetKey(starlark.String("model"), starlark.String(resp.Model))
_ = d.SetKey(starlark.String("finish_reason"), starlark.String(resp.FinishReason))
_ = d.SetKey(starlark.String("input_tokens"), starlark.MakeInt(resp.InputTokens))
_ = d.SetKey(starlark.String("output_tokens"), starlark.MakeInt(resp.OutputTokens))
return d, nil
}

View File

@@ -0,0 +1,241 @@
package sandbox
import (
"testing"
"go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// ─── ParseRequiresProvider ──────────────────
func TestParseRequiresProvider_Missing(t *testing.T) {
_, ok := ParseRequiresProvider(map[string]any{})
if ok {
t.Error("missing requires_provider should return false")
}
}
func TestParseRequiresProvider_BoolTrue(t *testing.T) {
cfg, ok := ParseRequiresProvider(map[string]any{"requires_provider": true})
if !ok {
t.Fatal("boolean true should return ok=true")
}
if cfg.ProviderConfigID != "" || cfg.DefaultModel != "" {
t.Errorf("boolean true should produce empty config, got %+v", cfg)
}
}
func TestParseRequiresProvider_BoolFalse(t *testing.T) {
_, ok := ParseRequiresProvider(map[string]any{"requires_provider": false})
if ok {
t.Error("boolean false should return ok=false")
}
}
func TestParseRequiresProvider_ObjectFull(t *testing.T) {
cfg, ok := ParseRequiresProvider(map[string]any{
"requires_provider": map[string]any{
"provider_config_id": "cfg-uuid-123",
"model": "claude-3-haiku",
},
})
if !ok {
t.Fatal("object form should return ok=true")
}
if cfg.ProviderConfigID != "cfg-uuid-123" {
t.Errorf("provider_config_id = %q", cfg.ProviderConfigID)
}
if cfg.DefaultModel != "claude-3-haiku" {
t.Errorf("model = %q", cfg.DefaultModel)
}
}
func TestParseRequiresProvider_ObjectPartial(t *testing.T) {
cfg, ok := ParseRequiresProvider(map[string]any{
"requires_provider": map[string]any{
"model": "gpt-4o-mini",
},
})
if !ok {
t.Fatal("should return ok=true")
}
if cfg.ProviderConfigID != "" {
t.Errorf("provider_config_id should be empty, got %q", cfg.ProviderConfigID)
}
if cfg.DefaultModel != "gpt-4o-mini" {
t.Errorf("model = %q", cfg.DefaultModel)
}
}
func TestParseRequiresProvider_InvalidType(t *testing.T) {
_, ok := ParseRequiresProvider(map[string]any{"requires_provider": "yes"})
if ok {
t.Error("string value should return false")
}
}
// ─── starlarkToMessages ─────────────────────
func TestStarlarkToMessages_Valid(t *testing.T) {
list := starlark.NewList(nil)
msg1 := starlark.NewDict(2)
_ = msg1.SetKey(starlark.String("role"), starlark.String("system"))
_ = msg1.SetKey(starlark.String("content"), starlark.String("You are helpful."))
list.Append(msg1)
msg2 := starlark.NewDict(2)
_ = msg2.SetKey(starlark.String("role"), starlark.String("user"))
_ = msg2.SetKey(starlark.String("content"), starlark.String("Hello"))
list.Append(msg2)
msgs, err := starlarkToMessages(list)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Role != "system" || msgs[0].Content != "You are helpful." {
t.Errorf("msg[0] = %+v", msgs[0])
}
if msgs[1].Role != "user" || msgs[1].Content != "Hello" {
t.Errorf("msg[1] = %+v", msgs[1])
}
}
func TestStarlarkToMessages_Empty(t *testing.T) {
list := starlark.NewList(nil)
msgs, err := starlarkToMessages(list)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestStarlarkToMessages_Nil(t *testing.T) {
msgs, err := starlarkToMessages(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msgs != nil {
t.Errorf("expected nil, got %v", msgs)
}
}
func TestStarlarkToMessages_MissingRole(t *testing.T) {
list := starlark.NewList(nil)
msg := starlark.NewDict(1)
_ = msg.SetKey(starlark.String("content"), starlark.String("hello"))
list.Append(msg)
_, err := starlarkToMessages(list)
if err == nil {
t.Error("expected error for missing role")
}
}
func TestStarlarkToMessages_MissingContent(t *testing.T) {
list := starlark.NewList(nil)
msg := starlark.NewDict(1)
_ = msg.SetKey(starlark.String("role"), starlark.String("user"))
list.Append(msg)
_, err := starlarkToMessages(list)
if err == nil {
t.Error("expected error for missing content")
}
}
func TestStarlarkToMessages_NonDictItem(t *testing.T) {
list := starlark.NewList([]starlark.Value{starlark.String("not a dict")})
_, err := starlarkToMessages(list)
if err == nil {
t.Error("expected error for non-dict item")
}
}
func TestStarlarkToMessages_NonStringRole(t *testing.T) {
list := starlark.NewList(nil)
msg := starlark.NewDict(2)
_ = msg.SetKey(starlark.String("role"), starlark.MakeInt(42))
_ = msg.SetKey(starlark.String("content"), starlark.String("hello"))
list.Append(msg)
_, err := starlarkToMessages(list)
if err == nil {
t.Error("expected error for non-string role")
}
}
// ─── completionToStarlark ───────────────────
func TestCompletionToStarlark(t *testing.T) {
resp := &providers.CompletionResponse{
Content: "Hello! How can I help?",
Model: "claude-3-haiku-20240307",
FinishReason: "stop",
InputTokens: 15,
OutputTokens: 8,
}
val, err := completionToStarlark(resp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
d, ok := val.(*starlark.Dict)
if !ok {
t.Fatalf("expected dict, got %T", val)
}
// content
cv, found, _ := d.Get(starlark.String("content"))
if !found {
t.Fatal("missing 'content'")
}
if s, _ := starlark.AsString(cv); s != "Hello! How can I help?" {
t.Errorf("content = %q", s)
}
// model
mv, found, _ := d.Get(starlark.String("model"))
if !found {
t.Fatal("missing 'model'")
}
if s, _ := starlark.AsString(mv); s != "claude-3-haiku-20240307" {
t.Errorf("model = %q", s)
}
// finish_reason
fv, found, _ := d.Get(starlark.String("finish_reason"))
if !found {
t.Fatal("missing 'finish_reason'")
}
if s, _ := starlark.AsString(fv); s != "stop" {
t.Errorf("finish_reason = %q", s)
}
// input_tokens
iv, found, _ := d.Get(starlark.String("input_tokens"))
if !found {
t.Fatal("missing 'input_tokens'")
}
if i, err := starlark.AsInt32(iv); err != nil || i != 15 {
t.Errorf("input_tokens = %v (err=%v)", i, err)
}
// output_tokens
ov, found, _ := d.Get(starlark.String("output_tokens"))
if !found {
t.Fatal("missing 'output_tokens'")
}
if i, err := starlark.AsInt32(ov); err != nil || i != 8 {
t.Errorf("output_tokens = %v (err=%v)", i, err)
}
}

View File

@@ -3,6 +3,12 @@
// 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
@@ -18,11 +24,24 @@ import (
"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
notifier NotificationSender // nil = notifications module unavailable
resolver ProviderResolver // nil = provider module unavailable
}
// NewRunner creates a runner with the given sandbox and dependencies.
@@ -38,12 +57,20 @@ 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) (*Result, error) {
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)
@@ -59,7 +86,7 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
}
// Build modules based on granted permissions
modules, err := r.buildModules(ctx, pkg.ID)
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)
}
@@ -75,9 +102,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
// 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) (starlark.Value, string, error) {
result, err := r.ExecPackage(ctx, pkg)
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
}
@@ -97,7 +126,10 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
}
// buildModules assembles the module map based on granted permissions.
func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string]starlark.Value, error) {
// 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
}
@@ -119,11 +151,21 @@ func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string
modules["notifications"] = BuildNotificationsModule(ctx, r.notifier, packageID)
}
// Future permissions (CS4+):
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(...)
// case models.ExtPermAPIHTTP:
// modules["http"] = BuildHTTPModule(...)
}
}