From 2733ab746df28f94aac9f632213ede0447d4960a Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:44:46 +0800 Subject: [PATCH] fix: require trusted scripts for SQLite callbacks --- README.md | 8 ++++ SECURITY.md | 6 +++ cli/args.go | 2 +- cli/capability_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++ cli/mods.go | 7 +++- e2e/e2e_test.go | 36 +++++++++++++++- go.mod | 2 +- go.sum | 4 +- 8 files changed, 153 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 00e6eb5..115e8df 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,14 @@ by a tier, not even `full`. For a one-flag "trust everything" run there is command execution of any command in a single switch. Use it only with fully trusted scripts. +SQLite's `register_function` is disabled unless the operator passes +`--dangerously-allow-all`. The `open`/`full` tiers and `--allow-cmd` do not enable +custom SQL callbacks. Ordinary SQL and built-in functions remain available. +Restricted connections also exclude callbacks registered earlier by a trusted +box in the same process. This intentionally tightens the previous default. +Custom callbacks run outside the Machine's step budget; use the trusted-script +flag only when that limitation is acceptable. + Set a stricter default for a whole deployment with the env var: ```bash diff --git a/SECURITY.md b/SECURITY.md index 24f721b..77d3416 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,12 @@ do not provide process or memory isolation. The default `open` tier permits network and filesystem access. `--allow-cmd` authorizes arbitrary host commands and process environment access, including through `gum` and `runtime`. +SQLite custom functions require `--dangerously-allow-all`. Ordinary module and +command grants do not enable them. Normal connections exclude process-wide +callbacks, including ones registered earlier by a trusted box. Fully trusted +callbacks retain the existing separate-thread behavior and are not bounded by +`--max-steps`; this is not an isolated-worker or complete resource budget. + Use the narrowest capability grants and import directories that your script needs. HTTP execution has request and cooperative execution limits, but these do not preempt arbitrary Go builtins or impose a complete output-byte budget. diff --git a/cli/args.go b/cli/args.go index 5e57b08..7ff67b3 100644 --- a/cli/args.go +++ b/cli/args.go @@ -66,7 +66,7 @@ func ParseArgs() *Args { flag.BoolVar(&args.AllowNet, "allow-net", false, "widen a restrictive tier with network modules (http, net, email, llm)") flag.BoolVar(&args.AllowFS, "allow-fs", false, "widen a restrictive tier with filesystem modules (file, path)") flag.BoolVar(&args.AllowCmd, "allow-cmd", false, "allow ANY host command, gum subprocesses, and runtime environment mutation (trusted scripts only)") - flag.BoolVar(&args.Dangerous, "dangerously-allow-all", false, "DANGER: open everything — network + filesystem + host command execution of ANY command. Use only with fully trusted scripts.") + flag.BoolVar(&args.Dangerous, "dangerously-allow-all", false, "DANGER: open network, filesystem, arbitrary host commands, and SQLite custom functions. Fully trusted scripts only.") flag.BoolVar(&args.Check, "check", false, "syntax/resolve check the script (-c or file) without running it") flag.StringVar(&args.LogFile, "log-file", "", "append the script's log module output to this file") flag.StringVar(&args.LogFormat, "log-format", "console", "log file format: console (human) or json (machine)") diff --git a/cli/capability_test.go b/cli/capability_test.go index 86b51a8..df93747 100644 --- a/cli/capability_test.go +++ b/cli/capability_test.go @@ -12,6 +12,7 @@ package cli // - wired pure domain modules are classified pure and pass the safe gate // - cmd execution gating: off by default, on with --allow-cmd / // --dangerously-allow-all (the execCmd lever + end-to-end run()) +// - SQLite custom functions require the fully trusted-script grant import ( "fmt" @@ -19,6 +20,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "github.com/1set/starlet" @@ -441,3 +443,95 @@ func TestProcessCapabilityBoundary(t *testing.T) { } } } + +// SQLite callbacks use their own Starlark threads and process-wide registry. +// Ordinary module/network/filesystem/command grants must not authorize them. +var sqlitePolicyFunctionID uint64 + +func TestSQLiteCustomFunctionPolicy(t *testing.T) { + cases := []struct { + name string + opts BoxOpts + allowed bool + }{ + {"default open", BoxOpts{}, false}, + {"explicit open", BoxOpts{caps: "open"}, false}, + {"full tier", BoxOpts{caps: "full"}, false}, + {"network and filesystem", BoxOpts{caps: "safe", allowNet: true, allowFS: true}, false}, + {"command grant", BoxOpts{allowCmd: true}, false}, + {"fully trusted", BoxOpts{caps: "safe", dangerous: true}, true}, + } + for _, tc := range cases { + for _, eager := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/eager=%t", tc.name, eager), func(t *testing.T) { + opts := tc.opts + checkSQLiteFunctionPolicy(t, &opts, eager, tc.allowed) + }) + } + } +} + +func checkSQLiteFunctionPolicy(t *testing.T, opts *BoxOpts, eager, allowed bool) { + t.Helper() + opts.scenario, opts.printerName = scenarioDirect, "none" + opts.moduleToLoad = []string{"sqlite"} + box, err := BuildBox(opts) + if err != nil { + t.Fatal(err) + } + fn := fmt.Sprintf("starcli_policy_fn_%d", atomic.AddUint64(&sqlitePolicyFunctionID, 1)) + load, prefix := `load("sqlite", "connect", "register_function")`, "" + if eager { + load, prefix = "", "sqlite." + } + code := fmt.Sprintf(` +%s +%sregister_function(%q, lambda: 73, num_args=0) +db = %sconnect(":memory:") +def verify(): + if db.query_one("SELECT %s() AS result")["result"] != 73: + fail("custom function returned an incorrect value") +verify() +db.close() +`, load, prefix, fn, prefix, fn) + _, err = box.Run(code) + if allowed { + if err != nil { + t.Fatal(err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "custom functions are disabled by the host") { + t.Fatalf("registration must be rejected by host policy, got %v", err) + } +} + +func TestSQLiteRestrictedConnectionIgnoresTrustedFunctions(t *testing.T) { + trusted, err := BuildBox(&BoxOpts{scenario: scenarioDirect, printerName: "none", moduleToLoad: []string{"sqlite"}, dangerous: true}) + if err != nil { + t.Fatal(err) + } + fn := fmt.Sprintf("starcli_private_fn_%d", atomic.AddUint64(&sqlitePolicyFunctionID, 1)) + if _, err = trusted.Run(fmt.Sprintf(`load("sqlite", "register_function"); register_function(%q, lambda: 41, num_args=0)`, fn)); err != nil { + t.Fatal(err) + } + restricted, err := BuildBox(&BoxOpts{scenario: scenarioDirect, printerName: "none", moduleToLoad: []string{"sqlite"}, caps: "full"}) + if err != nil { + t.Fatal(err) + } + _, err = restricted.Run(fmt.Sprintf(`load("sqlite", "connect"); db = connect(":memory:"); row = db.query_one("SELECT %s() AS result")`, fn)) + if err == nil || !strings.Contains(err.Error(), "no such function") { + t.Fatalf("restricted connection inherited a process-wide callback: %v", err) + } + if _, err = restricted.Run(` +load("sqlite", "connect") +db = connect(":memory:") +def verify(): + if db.query_one("SELECT abs(-73) AS result")["result"] != 73: + fail("built-in SQLite functions must remain usable") +verify() +db.close() +`); err != nil { + t.Fatalf("ordinary SQL on a restricted connection: %v", err) + } +} diff --git a/cli/mods.go b/cli/mods.go index 8cb638c..2f39a7b 100644 --- a/cli/mods.go +++ b/cli/mods.go @@ -94,7 +94,12 @@ func loadCLIModuleByName(opts *BoxOpts, name string) (starlet.ModuleLoader, erro } return cmd.NewModule().LoadModule(), nil case sqlite.ModuleName: - return sqlite.NewModule().LoadModule(), nil + // SQLite callbacks use separate Starlark threads and a process-wide + // registry. Only the fully trusted-script grant may enable them; ordinary + // SQL and built-in functions remain available under the normal grant. + return sqlite.NewModuleWithHostPolicy(sqlite.HostPolicy{ + DisableCustomFunctions: !opts.dangerous, + }).LoadModule(), nil case web.ModuleName: return web.NewModule().LoadModule(), nil case cache.ModuleName: diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index f9bb767..48767c9 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -5,7 +5,7 @@ // *work* through the CLI, not merely that they load. // // Sections: local imports, golden scripts, domain modules, stdin, recordings, -// containers. +// containers, SQLite custom-function authorization. package e2e import ( @@ -378,3 +378,37 @@ func TestContainer(t *testing.T) { } }) } + +// TestSQLiteCustomFunctionAuthorization exercises real argument parsing and the +// production module registry, including SQL calls after the explicit opt-in. +func TestSQLiteCustomFunctionAuthorization(t *testing.T) { + const code = ` +load("sqlite", "connect", "register_function") +register_function("starcli_e2e_answer", lambda: 73, num_args=0) +db = connect(":memory:") +print(db.query_one("SELECT starcli_e2e_answer() AS result")["result"]) +db.close() +` + for _, tc := range []struct { + name string + flags []string + allowed bool + }{ + {"default", nil, false}, + {"full tier", []string{"--caps", "full"}, false}, + {"command grant", []string{"--allow-cmd"}, false}, + {"fully trusted", []string{"--dangerously-allow-all"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append(append([]string{}, tc.flags...), "-o", "stdout", "-c", code) + stdout, stderr, exit := runCLI(t, "", args...) + if tc.allowed { + if exit != 0 || !strings.Contains(stdout, "73") { + t.Fatalf("trusted custom function: exit=%d stdout=%q stderr=%q", exit, stdout, stderr) + } + } else if exit == 0 || !strings.Contains(stderr, "custom functions are disabled by the host") { + t.Fatalf("custom function requires the trusted-script grant: exit=%d stdout=%q stderr=%q", exit, stdout, stderr) + } + }) + } +} diff --git a/go.mod b/go.mod index 6f86fe3..17f403c 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/starpkg/llm v0.1.0 github.com/starpkg/markdown v0.1.0 github.com/starpkg/qrcode v0.1.0 - github.com/starpkg/sqlite v0.2.1 + github.com/starpkg/sqlite v0.2.2-0.20260923164825-56739df72ba2 github.com/starpkg/toml v0.1.0 github.com/starpkg/totp v0.1.0 github.com/starpkg/web v0.1.0 diff --git a/go.sum b/go.sum index c9d6a52..13d008f 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/starpkg/markdown v0.1.0 h1:buLZwTcZ9SQWrVOUVeTcHqw/DCkxKPE8rdvHpbhjrE github.com/starpkg/markdown v0.1.0/go.mod h1:JsRNXRFjvLPRrkjGrf6jXDTl3Qg9+d2/PByzW7WxYvg= github.com/starpkg/qrcode v0.1.0 h1:VULD2XBVacMq0c2WxJgdG0d3k0Z4EOSaFdYdkOB7bME= github.com/starpkg/qrcode v0.1.0/go.mod h1:itKiBubTuioyMM78bA+RTfwm/hH3EYG+W6NX0cscgh0= -github.com/starpkg/sqlite v0.2.1 h1:oTVkPCTQYhud3qDXztJsb/1cBY4SgXLF2PKonRhJPFI= -github.com/starpkg/sqlite v0.2.1/go.mod h1:Hfs3JZnFbDPefCL9ybSmRJIQ+IBVMYtj5FyZ+7XZpmc= +github.com/starpkg/sqlite v0.2.2-0.20260923164825-56739df72ba2 h1:YS+in+4QZRDTboQWFip0HFcduKdFV3xDIEMKTq/9IDc= +github.com/starpkg/sqlite v0.2.2-0.20260923164825-56739df72ba2/go.mod h1:16YgyyCzRaKMDqWZO6uucWIKyG3wWuIJx/O9MsDKHDs= github.com/starpkg/toml v0.1.0 h1:geXbLw/SW7XcU9Tz/eqjxSNywsMbxqfndXYOlXXkSKE= github.com/starpkg/toml v0.1.0/go.mod h1:8QtTjIjFNuw0WGYzDHFbynhQ21QM6BvdACzapKE1cr8= github.com/starpkg/totp v0.1.0 h1:BQ1J5bbhR8iICQ+iRJ4LZgJNX3RhhOZtY2aoaf38WqY=