From 9384308677ddb8fb5dcd061211a1c4a8c894ade6 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 25 Aug 2026 21:17:08 -0700 Subject: [PATCH] feat(execution)!: add fixed compute stdlib Expose numeric sum, selected JSON helpers, and the pinned math module in every worker. Reserve the fixed language roots across catalog, execution, and worker validation, and keep the model-facing reference synchronized with the runtime. BREAKING CHANGE: Capability names whose top-level segment is a standard Starlark universe name, sum, json, or math now fail Build with ErrInvalidRegistration. --- builder.go | 3 + builder_test.go | 60 ++++++++ capability.go | 1 + docs/docs/explanation/security-model.md | 5 +- docs/docs/reference/mcp-tools.md | 26 +++- docs/docs/reference/public-api.md | 4 +- internal/catalog/build.go | 7 + internal/catalog/catalog.go | 1 + internal/catalog/catalog_test.go | 72 ++++++++++ internal/execution/detail_test.go | 6 +- internal/execution/doc.go | 6 +- internal/execution/engine.go | 13 +- internal/execution/namespace.go | 9 +- internal/execution/stdlib_test.go | 179 ++++++++++++++++++++++++ internal/universe/doc.go | 9 ++ internal/universe/sum.go | 73 ++++++++++ internal/universe/universe.go | 135 ++++++++++++++++++ internal/universe/universe_test.go | 111 +++++++++++++++ internal/worker/child_test.go | 4 +- internal/worker/frame_test.go | 25 ++++ internal/worker/limits.go | 8 ++ mcpserver/e2e_test.go | 64 +++++++++ mcpserver/language_surface_test.go | 62 ++++++++ mcpserver/server.go | 2 +- mcpserver/server_test.go | 11 +- moon.yml | 2 +- 26 files changed, 867 insertions(+), 31 deletions(-) create mode 100644 internal/execution/stdlib_test.go create mode 100644 internal/universe/doc.go create mode 100644 internal/universe/sum.go create mode 100644 internal/universe/universe.go create mode 100644 internal/universe/universe_test.go create mode 100644 mcpserver/language_surface_test.go diff --git a/builder.go b/builder.go index cf13f81..ef17b4b 100644 --- a/builder.go +++ b/builder.go @@ -70,6 +70,9 @@ func New(options Options) *Builder { // binding contract first. // // Capability-specific failures are accumulated and returned together by Build. +// A name whose first dotted segment collides with a reserved Starlark universe +// root, including standard builtins, sum, json, and math, is recorded as an +// invalid registration; nested leaves such as stats.sum remain legal. // Register panics when builder is nil or already closed because no future Build // call can report those lifecycle violations. func Register[Input, Output any](builder *Builder, capability Capability[Input, Output]) { diff --git a/builder_test.go b/builder_test.go index c63bca3..3e6f31f 100644 --- a/builder_test.go +++ b/builder_test.go @@ -348,6 +348,66 @@ func TestBuildRejectsWholeCatalogFailures(t *testing.T) { }) } +// TestBuildRejectsReservedUniverseRoots proves colliding namespace roots fail at Register and Build. +func TestBuildRejectsReservedUniverseRoots(t *testing.T) { + tests := []struct { + // name identifies the reserved-root collision. + name string + + // id is the stable capability identity. + id codemode.CapabilityID + + // capName is the colliding dotted capability name. + capName codemode.CapabilityName + + // disabled lists static filters that must not bypass validation. + disabled []codemode.CapabilityID + + // root is the reserved first dotted segment named in the diagnostic. + root string + }{ + {name: "sum root", id: "cap.sum", capName: "sum.x", root: "sum"}, + {name: "json root", id: "cap.json", capName: "json.fetch", root: "json"}, + {name: "math root", id: "cap.math", capName: "math.add", root: "math"}, + {name: "len root", id: "cap.len", capName: "len.items", root: "len"}, + {name: "set root", id: "cap.set", capName: "set.members", root: "set"}, + { + name: "disabled json root still rejected", + id: "cap.json", + capName: "json.fetch", + disabled: []codemode.CapabilityID{"cap.json"}, + root: "json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := codemode.New(codemode.Options{ + Authorizer: authz.AllowAll(), + DisabledCapabilities: tt.disabled, + }) + codemode.Register(builder, validBuilderCapability(tt.id, tt.capName)) + + server, err := builder.Build() + + require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Nil(t, server) + require.ErrorContains(t, err, `capability "`+string(tt.capName)+`"`) + require.ErrorContains(t, err, `reserved root "`+tt.root+`"`) + }) + } + + t.Run("nested leaf remains legal", func(t *testing.T) { + builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()}) + codemode.Register(builder, validBuilderCapability("cap.stats", "stats.sum")) + + server, err := builder.Build() + + require.NoError(t, err) + assert.NotNil(t, server) + }) +} + // TestBuilderBuildsWorkerProbe proves Build completes the real same-binary handshake. func TestBuilderBuildsWorkerProbe(t *testing.T) { builder := codemode.New(codemode.Options{ diff --git a/capability.go b/capability.go index aa2028d..83aefd5 100644 --- a/capability.go +++ b/capability.go @@ -23,6 +23,7 @@ type Capability[Input, Output any] struct { ID CapabilityID // Name is the dotted Starlark name exposed to programs and discovery. + // The first segment must not collide with a reserved Starlark universe root. Name CapabilityName // Summary is a compact description used by capability search. diff --git a/docs/docs/explanation/security-model.md b/docs/docs/explanation/security-model.md index dd70433..0eae0e9 100644 --- a/docs/docs/explanation/security-model.md +++ b/docs/docs/explanation/security-model.md @@ -121,8 +121,9 @@ Every `Server.Execute` call starts a fresh worker process by re-executing the host binary. The worker process is a child of the host process. The worker receives only the immutable enabled-capability manifest, positive execution limits, and one submitted program through CodeMode's private protocol. It -constructs a fresh Starlark interpreter. Module loading is disabled, and the -only predeclared application functions are the enabled capability namespace. +constructs a fresh Starlark interpreter. Module loading is disabled. The +predeclared environment is the fixed language surface (`sum`, `json`, `math`, +and the standard Starlark builtins) plus the enabled capability namespace. Native calls are rejected during top-level source loading and are accepted only while the required zero-argument `main()` function runs. diff --git a/docs/docs/reference/mcp-tools.md b/docs/docs/reference/mcp-tools.md index 585af59..d40f7e3 100644 --- a/docs/docs/reference/mcp-tools.md +++ b/docs/docs/reference/mcp-tools.md @@ -325,7 +325,19 @@ There is no compatibility suffix, alias, or alternate signature field. ## `execute` -Execute one Starlark program that defines `def main():` with zero arguments, calls only names confirmed through `search_api` and `describe_api` inside `main`, and returns `main`'s final result. Starlark is not Python: `sum`, `import`, `while`, and f-strings are unavailable; `load` is disabled; `print` is discarded. +Execute one Starlark program that defines `def main():` with zero arguments, calls only names confirmed through `search_api` and `describe_api` inside `main`, and returns `main`'s final result. Standard Starlark builtins plus `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import. `import`, `while`, f-strings, `filter`, and `map` are unavailable; `load` is disabled; `print` is discarded. + +### Language surface + +The following block is the exact fixed language surface. These names are +directly available without `import` or `load`. `set` is reserved as a +capability root but is not available. There is no built-in `time` module. + +```language-surface +top-level: False, None, True, abs, all, any, bool, bytes, chr, dict, dir, enumerate, fail, float, getattr, hasattr, hash, int, json, len, list, math, max, min, ord, print, range, repr, reversed, sorted, str, sum, tuple, type, zip +json: decode, encode, indent +math: acos, acosh, asin, asinh, atan, atan2, atanh, ceil, copysign, cos, cosh, degrees, e, exp, fabs, floor, gamma, hypot, log, mod, pi, pow, radians, remainder, round, sin, sinh, sqrt, tan, tanh +``` ### Input @@ -342,9 +354,9 @@ Execute one Starlark program that defines `def main():` with zero arguments, cal } ``` -The source must define `def main():` as a function with zero arguments. Starlark is not Python: `sum`, `import`, `while`, and f-strings are unavailable. Top-level source loading cannot make native calls; calls are accepted only while `main` runs, and only for names confirmed through `search_api` and `describe_api`. Module loading is disabled. `print` output is discarded. +The source must define `def main():` as a function with zero arguments. Standard Starlark builtins plus `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import. `import`, `while`, f-strings, `filter`, and `map` are unavailable; use comprehensions instead of `filter` and `map`. Top-level source loading cannot make native calls; calls are accepted only while `main` runs, and only for names confirmed through `search_api` and `describe_api`. Module loading is disabled. `print` output is discarded. -Capabilities are available by dotted name. The sample native call is `records.lookup(key="alpha", limit=2)`. Native calls accept keyword arguments only. Duplicate keyword syntax is rejected by the Starlark parser as `invalid program` before authorization or handler dispatch. Positional, unknown, missing, incorrectly typed, and out-of-range arguments reach binding and map to `invalid capability arguments`. For the sample, `key` is required and `limit` can be omitted, `None`, or an integer in the signed 64-bit range. +Capabilities are available by dotted name. The sample native call is `records.lookup(key="alpha", limit=2)`. Native calls accept keyword arguments only. Duplicate keyword syntax is rejected by the Starlark parser as `invalid program` before authorization or handler dispatch. Positional, unknown, missing, incorrectly typed, and out-of-range arguments reach binding and map to `invalid capability arguments`. For the sample, `key` is required and `limit` can be omitted, `None`, or an integer in the signed 64-bit range. The first dotted segment of a capability name must not be a reserved root (any standard Starlark universe name, plus `sum`, `json`, and `math`); nested leaves such as `stats.sum` remain legal. There is no built-in `time` module; a host-defined `time.*` capability remains legal. For example, a capability described with the signature `records.search(*, count: int, active: bool, score: float, label: str | None)` @@ -366,9 +378,11 @@ def main(): return {"count": count, "score": total, "ids": ids} ``` -Each `execute` call gets a fresh interpreter and fresh source, step, +Each `execute` call gets a fresh interpreter and fresh source, bytecode-step, elapsed-time, native-call, conversion-depth, per-value size, and aggregate -intermediate-value budgets. There is no interpreter or aggregate accounting +intermediate-value budgets. `MaxExecutionSteps` counts Starlark bytecode steps +and does not claim that Go builtin internals consume steps. `MaxExecutionTime` +bounds elapsed worker execution. There is no interpreter or aggregate accounting shared between calls. ### Successful structured output @@ -418,7 +432,7 @@ The listed descriptions above are the model-facing contract. Recovery uses the n - After `capability not found`, search again and pass `describe_api` an exact returned `name`, without whitespace or case changes. - After `invalid capability arguments`, use any suffix after the stable prefix to identify the rejected argument, then compare the call with the published `signature` and `input` field shapes. - After `invalid program`, use any suffix after the stable prefix. A parse or resolve suffix includes a `:line:col:` position in the submitted source. Check the program against these requirements: - - Write Starlark, not Python: `sum`, `import`, `while`, and f-strings are unavailable. + - Write Starlark, not Python: `import`, `while`, f-strings, `filter`, and `map` are unavailable; `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import. - Define `main` with zero arguments. - Call only names confirmed through `search_api` and `describe_api`, and call them only inside `main`. - Return the final value from `main`. diff --git a/docs/docs/reference/public-api.md b/docs/docs/reference/public-api.md index edabf00..2e418bb 100644 --- a/docs/docs/reference/public-api.md +++ b/docs/docs/reference/public-api.md @@ -49,7 +49,7 @@ to perform that setup; `IsWorker` does not serve worker mode. ### Capability registration -`CapabilityID` is the stable deployment and policy identity of a capability. `CapabilityName` is the dotted name visible in discovery and Starlark. A name has at least two valid Starlark identifier segments, such as `records.lookup`. +`CapabilityID` is the stable deployment and policy identity of a capability. `CapabilityName` is the dotted name visible in discovery and Starlark. A name has at least two valid Starlark identifier segments, such as `records.lookup`. The first dotted segment must not be a reserved root: any standard Starlark universe name, plus `sum`, `json`, and `math`. Nested segments and leaf functions are unaffected, so `stats.sum` remains legal. Roots that were previously legal capability namespaces, including `list`, `str`, `type`, `print`, `range`, `min`, and `max`, are now rejected. A colliding registration is recorded by `Register` and returned by `Build` as `ErrInvalidRegistration` with a host-side diagnostic. There is no built-in `time` module; a host-defined `time.*` capability remains legal. `Capability[Input, Output]` contains: @@ -444,7 +444,7 @@ Execute(ctx context.Context, subject authz.Subject, program Program) (any, error `Program` is Starlark source. The context must be non-nil and the subject ID must be non-empty. A nil context is a caller-contract violation and is currently classified as `ErrInternal`. -Every call creates a fresh interpreter and fresh budgets. Module loading is disabled. The enabled capability names form the predeclared namespace. Top-level source loading must define a function named `main` that accepts no positional parameters, keyword-only parameters, variadic positional parameters, or variadic keyword parameters. Native calls are accepted only while `main` is running. +Every call creates a fresh interpreter and fresh budgets. Module loading is disabled. The predeclared environment is the fixed language surface (`sum`, `json`, `math`, and the standard Starlark builtins) plus the enabled capability namespace. Top-level source loading must define a function named `main` that accepts no positional parameters, keyword-only parameters, variadic positional parameters, or variadic keyword parameters. Native calls are accepted only while `main` is running. There is no built-in `time` module; a host-defined `time.*` capability remains legal. For each native call, CodeMode performs these operations in order: diff --git a/internal/catalog/build.go b/internal/catalog/build.go index 509bd24..76ebc9c 100644 --- a/internal/catalog/build.go +++ b/internal/catalog/build.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/meigma/codemode/internal/binding" + "github.com/meigma/codemode/internal/universe" ) const minimumDottedSegments = 2 @@ -160,6 +161,8 @@ func indexCatalog(catalog *Catalog) { } // ValidateRegistration reports whether one copied registration has valid metadata, a compiled plan, and a handler. +// +// The first dotted name segment must not collide with a reserved Starlark universe root. func ValidateRegistration(registration Registration) error { if registration.ID == "" || registration.ID != strings.TrimSpace(registration.ID) { return errors.New("ID must be non-empty without surrounding whitespace") @@ -167,6 +170,10 @@ func ValidateRegistration(registration Registration) error { if !isDottedName(registration.Name) { return fmt.Errorf("name %q must contain valid dotted Starlark identifiers", registration.Name) } + root, _, _ := strings.Cut(registration.Name, ".") + if universe.IsReservedRoot(root) { + return fmt.Errorf("name %q uses reserved root %q", registration.Name, root) + } if registration.Summary == "" || registration.Summary != strings.TrimSpace(registration.Summary) { return errors.New("summary must be non-empty without surrounding whitespace") } diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 17c2cba..5fc4931 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -17,6 +17,7 @@ type Registration struct { ID string // Name is the dotted Starlark and model-facing capability name. + // The first segment must not collide with a reserved Starlark universe root. Name string // Summary is the compact description used by capability search. diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 59693f2..56836da 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -175,6 +175,78 @@ func TestBuildRejectsInvalidMetadataDuplicatesAndNamespaceCollisions(t *testing. } } +// TestBuildRejectsReservedUniverseRoots proves colliding namespace roots fail closed. +func TestBuildRejectsReservedUniverseRoots(t *testing.T) { + valid := validRegistration("cap.one", "records.one", "First record") + tests := []struct { + // name identifies the reserved-root collision. + name string + + // registrations contains the candidate capabilities. + registrations []Registration + + // options configures filtering and search. + options Options + + // root is the reserved first dotted segment named in the diagnostic. + root string + }{ + { + name: "sum root", + registrations: []Registration{withName(valid, "sum.x")}, + options: testOptions(), + root: "sum", + }, + { + name: "json root", + registrations: []Registration{withName(valid, "json.fetch")}, + options: testOptions(), + root: "json", + }, + { + name: "math root", + registrations: []Registration{withName(valid, "math.add")}, + options: testOptions(), + root: "math", + }, + { + name: "len root", + registrations: []Registration{withName(valid, "len.items")}, + options: testOptions(), + root: "len", + }, + { + name: "set root", + registrations: []Registration{withName(valid, "set.members")}, + options: testOptions(), + root: "set", + }, + { + name: "disabled json root still rejected", + registrations: []Registration{withID(withName(valid, "json.fetch"), "cap.json")}, + options: testOptions("cap.json"), + root: "json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Build(tt.registrations, tt.options) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidRegistration) + require.ErrorContains(t, err, `name "`+tt.registrations[0].Name+`" uses reserved root "`+tt.root+`"`) + }) + } + + catalog, err := Build([]Registration{ + validRegistration("cap.stats", "stats.sum", "Nested sum"), + }, testOptions()) + require.NoError(t, err) + _, found := catalog.Lookup("stats.sum") + assert.True(t, found) +} + // TestBuildFiltersOnceAndDerivesEverySurface proves disabled capabilities have no live representation. func TestBuildFiltersOnceAndDerivesEverySurface(t *testing.T) { catalog, err := Build([]Registration{ diff --git a/internal/execution/detail_test.go b/internal/execution/detail_test.go index a37c84d..7b3ec4f 100644 --- a/internal/execution/detail_test.go +++ b/internal/execution/detail_test.go @@ -62,9 +62,9 @@ func TestExecuteAttachesApprovedProgramDiagnostics(t *testing.T) { contains: ":", }, { - name: "undefined sum", - source: "def main():\n return sum([1, 2])\n", - contains: "undefined: sum", + name: "undefined filter", + source: "def main():\n return filter([1, 2])\n", + contains: "undefined: filter", }, } diff --git a/internal/execution/doc.go b/internal/execution/doc.go index 36adea9..c64df9c 100644 --- a/internal/execution/doc.go +++ b/internal/execution/doc.go @@ -1,6 +1,6 @@ // Package execution runs one restricted, bounded Starlark program at a time. // -// Engine is compiled from process-neutral capability bindings. Each Execute -// call receives a request-specific NativeCall and converts only main's final -// value. +// Engine is compiled from the fixed language surface merged with +// process-neutral capability bindings. Each Execute call receives a +// request-specific NativeCall and converts only main's final value. package execution diff --git a/internal/execution/engine.go b/internal/execution/engine.go index f78947c..dfbdc48 100644 --- a/internal/execution/engine.go +++ b/internal/execution/engine.go @@ -7,6 +7,7 @@ import ( "go.starlark.net/starlark" "github.com/meigma/codemode/internal/binding" + "github.com/meigma/codemode/internal/universe" ) const minimumDottedSegments = 2 @@ -28,13 +29,13 @@ type CapabilityBinding struct { // It returns one normalized process-neutral value or a classified error. type NativeCall func(id string, arguments map[string]any) (any, error) -// Engine owns the frozen capability namespace shared by fresh execution threads. +// Engine owns the frozen language surface and capability namespace shared by fresh execution threads. type Engine struct { - // predeclared contains immutable namespaced capability builtins. + // predeclared contains the frozen fixed language surface merged with capability namespaces. predeclared starlark.StringDict } -// New compiles one immutable execution engine from process-neutral capability bindings. +// New compiles one immutable execution engine from the fixed language surface and capability bindings. func New(bindings []CapabilityBinding) (*Engine, error) { copied := copyBindings(bindings) if err := validateBindings(copied); err != nil { @@ -62,7 +63,7 @@ func copyBindings(bindings []CapabilityBinding) []CapabilityBinding { return copied } -// validateBindings rejects empty or colliding identities and invalid input shapes. +// validateBindings rejects empty or colliding identities, reserved roots, and invalid input shapes. func validateBindings(bindings []CapabilityBinding) error { ids := make(map[string]struct{}, len(bindings)) names := make(map[string]struct{}, len(bindings)) @@ -76,6 +77,10 @@ func validateBindings(bindings []CapabilityBinding) error { if !isDottedName(capability.Name) { return fmt.Errorf("%w: capability name %q is not dotted", ErrInternal, capability.Name) } + root, _, _ := strings.Cut(capability.Name, ".") + if universe.IsReservedRoot(root) { + return fmt.Errorf("%w: capability root %q is reserved", ErrInternal, root) + } if _, duplicate := names[capability.Name]; duplicate { return fmt.Errorf("%w: duplicate namespace function", ErrInternal) } diff --git a/internal/execution/namespace.go b/internal/execution/namespace.go index 2a6bfe7..49130c7 100644 --- a/internal/execution/namespace.go +++ b/internal/execution/namespace.go @@ -6,6 +6,8 @@ import ( "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" + + "github.com/meigma/codemode/internal/universe" ) // namespaceNode is one temporary registration-time path used to assemble frozen modules. @@ -17,7 +19,7 @@ type namespaceNode struct { functions starlark.StringDict } -// buildPredeclared creates the frozen capability namespace for one immutable Engine. +// buildPredeclared merges the fixed language surface with frozen capability namespaces. func buildPredeclared(bindings []CapabilityBinding) (starlark.StringDict, error) { root := newNamespaceNode() for _, capability := range bindings { @@ -47,8 +49,11 @@ func buildPredeclared(bindings []CapabilityBinding) (starlark.StringDict, error) }) } - predeclared := make(starlark.StringDict, len(root.children)) + predeclared := universe.Predeclared() for name, node := range root.children { + if _, exists := predeclared[name]; exists { + return nil, fmt.Errorf("%w: capability root %q collides with the fixed language surface", ErrInternal, name) + } predeclared[name] = freezeModule(name, node) } predeclared.Freeze() diff --git a/internal/execution/stdlib_test.go b/internal/execution/stdlib_test.go new file mode 100644 index 0000000..ecc65f1 --- /dev/null +++ b/internal/execution/stdlib_test.go @@ -0,0 +1,179 @@ +package execution_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/internal/binding" + "github.com/meigma/codemode/internal/execution" + "github.com/meigma/codemode/internal/universe" +) + +// TestNewRejectsReservedCapabilityRoots proves execution defense-in-depth uses the canonical predicate. +func TestNewRejectsReservedCapabilityRoots(t *testing.T) { + valid := lookupBinding() + tests := []struct { + // name identifies the reserved root. + name string + + // capability is the colliding dotted name. + capability string + }{ + {name: "sum", capability: "sum.x"}, + {name: "json", capability: "json.fetch"}, + {name: "math", capability: "math.add"}, + {name: "len", capability: "len.x"}, + {name: "set", capability: "set.x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.True(t, universe.IsReservedRoot(tt.name)) + _, err := execution.New([]execution.CapabilityBinding{withName(valid, tt.capability)}) + + require.ErrorIs(t, err, execution.ErrInternal) + assert.Contains(t, err.Error(), "reserved") + }) + } +} + +// TestNewAcceptsNonreservedNestedSum proves stats.sum remains a valid dynamic capability path. +func TestNewAcceptsNonreservedNestedSum(t *testing.T) { + engine, err := execution.New([]execution.CapabilityBinding{statsSumBinding()}) + require.NoError(t, err) + + result, err := engine.Execute( + `def main(): return stats.sum(value="nested")`, + echoNativeCall(), + defaultExecutionLimits(), + ) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"value": "nested"}, result) +} + +// TestExecuteExposesFixedStdlibWithoutHostConfiguration proves sum, json, and math are always on. +func TestExecuteExposesFixedStdlibWithoutHostConfiguration(t *testing.T) { + engine, err := execution.New(nil) + require.NoError(t, err) + + tests := []struct { + // name identifies the language-surface behavior. + name string + + // source is the submitted Starlark program. + source string + + // want is the converted final value. + want any + }{ + {name: "sum integers", source: `def main(): return sum([1, 2, 3])`, want: int64(6)}, + {name: "sum floats", source: `def main(): return sum([1.5, 2.5])`, want: 4.0}, + {name: "sum mixed", source: `def main(): return sum([1, 2.5])`, want: 3.5}, + {name: "sum empty", source: `def main(): return sum([])`, want: int64(0)}, + { + name: "json round-trip", + source: `def main(): return json.decode(json.encode({"a": 1}))["a"]`, + want: int64(1), + }, + {name: "math sqrt", source: `def main(): return math.sqrt(4.0)`, want: 2.0}, + {name: "json members", source: `def main(): return dir(json)`, want: []any{"decode", "encode", "indent"}}, + {name: "json encode_indent absent", source: `def main(): return hasattr(json, "encode_indent")`, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, execErr := engine.Execute(tt.source, unusedNativeCall(), defaultExecutionLimits()) + + require.NoError(t, execErr) + assert.Equal(t, tt.want, result) + }) + } +} + +// TestExecuteSumRejectsNonNumericAndArityErrors proves sum stays numeric-only with one argument. +func TestExecuteSumRejectsNonNumericAndArityErrors(t *testing.T) { + engine, err := execution.New(nil) + require.NoError(t, err) + + tests := []struct { + // name identifies the invalid sum use. + name string + + // source is the submitted Starlark program. + source string + }{ + {name: "string element", source: `def main(): return sum(["a"])`}, + {name: "bool element", source: `def main(): return sum([True])`}, + {name: "missing argument", source: `def main(): return sum()`}, + {name: "extra argument", source: `def main(): return sum([1], 0)`}, + {name: "non iterable", source: `def main(): return sum(1)`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, execErr := engine.Execute(tt.source, unusedNativeCall(), defaultExecutionLimits()) + + require.ErrorIs(t, execErr, execution.ErrInvalidProgram) + }) + } +} + +// TestExecuteRejectsBuiltInTimeAndDialectSet proves excluded names stay undefined. +func TestExecuteRejectsBuiltInTimeAndDialectSet(t *testing.T) { + engine, err := execution.New(nil) + require.NoError(t, err) + + tests := []struct { + // name identifies the unavailable name. + name string + + // source is the submitted Starlark program. + source string + }{ + {name: "time", source: `def main(): return time`}, + {name: "set", source: `def main(): return set`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, execErr := engine.Execute(tt.source, unusedNativeCall(), defaultExecutionLimits()) + + require.ErrorIs(t, execErr, execution.ErrInvalidProgram) + }) + } +} + +// TestExecuteMergesDynamicNamespaceWithFixedStdlib proves capabilities coexist with sum. +func TestExecuteMergesDynamicNamespaceWithFixedStdlib(t *testing.T) { + engine, err := execution.New([]execution.CapabilityBinding{statsSumBinding()}) + require.NoError(t, err) + + result, err := engine.Execute(` +def main(): + return [sum([1, 2, 3]), stats.sum(value="nested")] +`, echoNativeCall(), defaultExecutionLimits()) + + require.NoError(t, err) + assert.Equal(t, []any{int64(6), map[string]any{"value": "nested"}}, result) +} + +// statsSumBinding returns a nonreserved nested sum capability. +func statsSumBinding() execution.CapabilityBinding { + return execution.CapabilityBinding{ + ID: "cap.stats.sum", + Name: "stats.sum", + Input: []binding.FieldShape{ + {Name: "value", Type: "str", Required: true}, + }, + } +} + +// unusedNativeCall fails if a program reaches the native port. +func unusedNativeCall() execution.NativeCall { + return func(string, map[string]any) (any, error) { + panic("native call is not part of the fixed language surface") + } +} diff --git a/internal/universe/doc.go b/internal/universe/doc.go new file mode 100644 index 0000000..fbe0c5b --- /dev/null +++ b/internal/universe/doc.go @@ -0,0 +1,9 @@ +// Package universe owns the fixed Starlark language surface. +// +// Every execution receives a fresh predeclared dictionary containing numeric +// sum, a filtered json module with decode, encode, and indent, and the pinned +// math module. Query helpers return copied sorted name lists for documentation +// and reserved-root checks. Capability namespaces merge only after +// [IsReservedRoot] rejects colliding roots. Nested leaves such as stats.sum +// remain legal. There is no built-in time module. +package universe diff --git a/internal/universe/sum.go b/internal/universe/sum.go new file mode 100644 index 0000000..877ffa1 --- /dev/null +++ b/internal/universe/sum.go @@ -0,0 +1,73 @@ +package universe + +import ( + "errors" + "fmt" + "math" + + "go.starlark.net/starlark" +) + +// sum returns the numeric total of one iterable of ints and floats. +// +// Empty input returns integer zero. The result stays an int until a float +// appears, then remaining ints are converted to float. Non-numeric values and +// extra arguments are errors. +func sum( + _ *starlark.Thread, + builtin *starlark.Builtin, + args starlark.Tuple, + kwargs []starlark.Tuple, +) (starlark.Value, error) { + var iterable starlark.Iterable + if err := starlark.UnpackPositionalArgs(builtin.Name(), args, kwargs, 1, &iterable); err != nil { + return nil, err + } + + iterator := iterable.Iterate() + defer iterator.Done() + + intTotal := starlark.MakeInt(0) + var floatTotal starlark.Float + useFloat := false + var element starlark.Value + for index := 0; iterator.Next(&element); index++ { + switch value := element.(type) { + case starlark.Int: + if !useFloat { + intTotal = intTotal.Add(value) + continue + } + converted, err := asFiniteFloat(value) + if err != nil { + return nil, err + } + floatTotal += converted + case starlark.Float: + if !useFloat { + converted, err := asFiniteFloat(intTotal) + if err != nil { + return nil, err + } + floatTotal = converted + useFloat = true + } + floatTotal += value + default: + return nil, fmt.Errorf("sum: at index %d, got %s, want int or float", index, element.Type()) + } + } + if useFloat { + return floatTotal, nil + } + return intTotal, nil +} + +// asFiniteFloat converts an int to float or reports that the magnitude overflowed. +func asFiniteFloat(value starlark.Int) (starlark.Float, error) { + converted := value.Float() + if math.IsInf(float64(converted), 0) { + return 0, errors.New("sum: int too large to convert to float") + } + return converted, nil +} diff --git a/internal/universe/universe.go b/internal/universe/universe.go new file mode 100644 index 0000000..8bc7a0b --- /dev/null +++ b/internal/universe/universe.go @@ -0,0 +1,135 @@ +package universe + +import ( + "slices" + "sort" + + "go.starlark.net/lib/json" + "go.starlark.net/lib/math" + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +const ( + // jsonName is the reserved json module root. + jsonName = "json" + + // mathName is the reserved math module root. + mathName = "math" + + // setName is the dialect-gated universe builtin excluded from the documented surface. + setName = "set" + + // sumName is the reserved numeric-only sum builtin. + sumName = "sum" + + // jsonMemberCount is the selected json surface: decode, encode, and indent. + jsonMemberCount = 3 + + // fixedAdditionCount is the always-on extras: sum, json, and math. + fixedAdditionCount = 3 +) + +//nolint:gochecknoglobals // Shared frozen language-surface values are process-wide and immutable. +var ( + // jsonModule is the filtered json module shared across Predeclared dictionaries. + jsonModule = selectedJSONModule() + + // sumBuiltin is the numeric-only sum function shared across Predeclared dictionaries. + sumBuiltin = starlark.NewBuiltin(sumName, sum) + + // reservedRoots contains every forbidden top-level capability namespace. + reservedRoots = collectReservedRoots() + + // topLevelNames is the sorted documented available surface, excluding set. + topLevelNames = collectTopLevelNames() + + // jsonMemberNames is the sorted selected json module surface. + jsonMemberNames = slices.Clone(jsonModule.Members.Keys()) + + // mathMemberNames is the sorted pinned math module surface. + mathMemberNames = slices.Clone(math.Module.Members.Keys()) +) + +// Predeclared returns a fresh dictionary of the fixed language surface. +// +// The dictionary itself is unfrozen so callers can merge capability +// namespaces. Member values are shared across calls: sum is a shared builtin, +// json is a shared filtered module, and math is the pinned math.Module. +// Freezing the returned dictionary therefore freezes those shared values. +func Predeclared() starlark.StringDict { + return starlark.StringDict{ + sumName: sumBuiltin, + jsonName: jsonModule, + mathName: math.Module, + } +} + +// TopLevelNames returns a sorted copy of the documented available top-level names. +// +// The list includes standard Starlark universe names except set, which is +// reserved but unavailable under the execution dialect, plus sum, json, and math. +func TopLevelNames() []string { + return slices.Clone(topLevelNames) +} + +// JSONMemberNames returns a sorted copy of the selected json module members. +func JSONMemberNames() []string { + return slices.Clone(jsonMemberNames) +} + +// MathMemberNames returns a sorted copy of the pinned math module members. +func MathMemberNames() []string { + return slices.Clone(mathMemberNames) +} + +// IsReservedRoot reports whether name is a forbidden top-level capability namespace. +// +// Name is the first dotted segment only. Nested leaves such as stats.sum remain +// legal. Membership includes every standard Starlark universe name, including +// dialect-gated set, plus sum, json, and math. +func IsReservedRoot(name string) bool { + _, ok := reservedRoots[name] + return ok +} + +// selectedJSONModule copies decode, encode, and indent from the pinned json module. +func selectedJSONModule() *starlarkstruct.Module { + members := make(starlark.StringDict, jsonMemberCount) + for _, name := range []string{"decode", "encode", "indent"} { + value, ok := json.Module.Members[name] + if !ok { + panic("pinned json module missing " + name) + } + members[name] = value + } + module := &starlarkstruct.Module{Name: jsonName, Members: members} + module.Freeze() + return module +} + +// collectReservedRoots records every universe name plus the fixed stdlib roots. +func collectReservedRoots() map[string]struct{} { + roots := make(map[string]struct{}, len(starlark.Universe)+fixedAdditionCount) + for name := range starlark.Universe { + roots[name] = struct{}{} + } + roots[jsonName] = struct{}{} + roots[mathName] = struct{}{} + roots[sumName] = struct{}{} + return roots +} + +// collectTopLevelNames returns sorted documented names, excluding dialect-gated set. +func collectTopLevelNames() []string { + names := make([]string, 0, len(starlark.Universe)+fixedAdditionCount) + for name := range starlark.Universe { + if name == setName { + continue + } + names = append(names, name) + } + names = append(names, jsonName, mathName, sumName) + sort.Strings(names) + return names +} diff --git a/internal/universe/universe_test.go b/internal/universe/universe_test.go new file mode 100644 index 0000000..42e80be --- /dev/null +++ b/internal/universe/universe_test.go @@ -0,0 +1,111 @@ +package universe_test + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.starlark.net/lib/math" + "go.starlark.net/starlark" + + "github.com/meigma/codemode/internal/universe" +) + +// TestTopLevelNamesExcludesSetAndIncludesStdlib proves the documented surface. +func TestTopLevelNamesExcludesSetAndIncludesStdlib(t *testing.T) { + names := universe.TopLevelNames() + + assert.True(t, slices.IsSorted(names), "expected documented names to be sorted") + assert.NotContains(t, names, "set", "set is dialect-gated and must not be documented") + assert.NotContains(t, names, "time", "time is not a built-in module") + assert.Contains(t, names, "abs") + assert.Contains(t, names, "len") + assert.Contains(t, names, "print") + assert.Contains(t, names, "sum") + assert.Contains(t, names, "json") + assert.Contains(t, names, "math") + + names[0] = "mutated" + assert.NotEqual(t, names[0], universe.TopLevelNames()[0], "expected TopLevelNames to copy") +} + +// TestJSONMemberNamesAreExactlyTheSelectedSurface proves encode_indent is omitted. +func TestJSONMemberNamesAreExactlyTheSelectedSurface(t *testing.T) { + assert.Equal(t, []string{"decode", "encode", "indent"}, universe.JSONMemberNames()) + + names := universe.JSONMemberNames() + names[0] = "mutated" + assert.Equal(t, []string{"decode", "encode", "indent"}, universe.JSONMemberNames()) +} + +// TestMathMemberNamesMatchThePinnedModule proves math members stay complete and sorted. +func TestMathMemberNamesMatchThePinnedModule(t *testing.T) { + names := universe.MathMemberNames() + + assert.True(t, slices.IsSorted(names), "expected math members to be sorted") + assert.Equal(t, math.Module.Members.Keys(), names) + assert.Contains(t, names, "sqrt") + assert.Contains(t, names, "round") + assert.Contains(t, names, "e") + assert.Contains(t, names, "pi") + + names[0] = "mutated" + assert.NotEqual(t, names[0], universe.MathMemberNames()[0], "expected MathMemberNames to copy") +} + +// TestIsReservedRootCoversUniverseAndStdlibRoots proves nested leaves stay legal. +func TestIsReservedRootCoversUniverseAndStdlibRoots(t *testing.T) { + tests := []struct { + // name identifies the membership case. + name string + + // root is the first dotted segment under test. + root string + + // reserved is the expected membership. + reserved bool + }{ + {name: "sum", root: "sum", reserved: true}, + {name: "json", root: "json", reserved: true}, + {name: "math", root: "math", reserved: true}, + {name: "len", root: "len", reserved: true}, + {name: "set", root: "set", reserved: true}, + {name: "print", root: "print", reserved: true}, + {name: "None", root: "None", reserved: true}, + {name: "stats", root: "stats", reserved: false}, + {name: "time", root: "time", reserved: false}, + {name: "empty", root: "", reserved: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.reserved, universe.IsReservedRoot(tt.root)) + }) + } + + for name := range starlark.Universe { + assert.Truef(t, universe.IsReservedRoot(name), "expected universe name %q to be reserved", name) + } +} + +// TestPredeclaredExposesOnlyTheFixedAdditions proves a fresh dictionary has no time or encode_indent. +func TestPredeclaredExposesOnlyTheFixedAdditions(t *testing.T) { + predeclared := universe.Predeclared() + + require.ElementsMatch(t, []string{"json", "math", "sum"}, predeclared.Keys()) + _, hasTime := predeclared["time"] + assert.False(t, hasTime, "expected no built-in time module") + + jsonModule, ok := predeclared["json"].(starlark.HasAttrs) + require.True(t, ok) + assert.Equal(t, []string{"decode", "encode", "indent"}, jsonModule.AttrNames()) + value, err := jsonModule.Attr("encode_indent") + require.NoError(t, err) + assert.Nil(t, value, "expected json.encode_indent to be absent") + + second := universe.Predeclared() + second["extra"] = starlark.None + _, exists := universe.Predeclared()["extra"] + assert.False(t, exists, "expected Predeclared to return a fresh dictionary") +} diff --git a/internal/worker/child_test.go b/internal/worker/child_test.go index 0723c73..55a0757 100644 --- a/internal/worker/child_test.go +++ b/internal/worker/child_test.go @@ -223,9 +223,9 @@ func TestFinalErrorFromExtractsApprovedSafeDetail(t *testing.T) { }{ { name: "invalid program keeps suffix", - err: execution.WithSafeDetail(execution.ErrInvalidProgram, ":1:1: undefined: sum"), + err: execution.WithSafeDetail(execution.ErrInvalidProgram, ":1:1: undefined: filter"), code: finalErrorInvalidProgram, - detail: ":1:1: undefined: sum", + detail: ":1:1: undefined: filter", }, { name: "invalid arguments keeps suffix", diff --git a/internal/worker/frame_test.go b/internal/worker/frame_test.go index 5f86b66..f806054 100644 --- a/internal/worker/frame_test.go +++ b/internal/worker/frame_test.go @@ -743,6 +743,26 @@ func TestFrameLimitsValidateManifest(t *testing.T) { name: "reserved name segment", manifest: []manifestEntry{{ID: "cap.lookup", Name: "records.import", Input: valid[0].Input}}, }, + { + name: "reserved root sum", + manifest: []manifestEntry{{ID: "cap.sum", Name: "sum.x", Input: valid[0].Input}}, + }, + { + name: "reserved root json", + manifest: []manifestEntry{{ID: "cap.json", Name: "json.fetch", Input: valid[0].Input}}, + }, + { + name: "reserved root math", + manifest: []manifestEntry{{ID: "cap.math", Name: "math.add", Input: valid[0].Input}}, + }, + { + name: "reserved root len", + manifest: []manifestEntry{{ID: "cap.len", Name: "len.items", Input: valid[0].Input}}, + }, + { + name: "reserved root set", + manifest: []manifestEntry{{ID: "cap.set", Name: "set.members", Input: valid[0].Input}}, + }, {name: "duplicate name", manifest: []manifestEntry{ {ID: "cap.one", Name: "records.lookup", Input: valid[0].Input}, {ID: "cap.two", Name: "records.lookup", Input: valid[0].Input}, @@ -764,6 +784,11 @@ func TestFrameLimitsValidateManifest(t *testing.T) { require.NoError(t, validateManifest(nil)) require.NoError(t, validateManifest(valid)) + require.NoError(t, validateManifest([]manifestEntry{{ + ID: "cap.stats", + Name: "stats.sum", + Input: valid[0].Input, + }})) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateManifest(tt.manifest) diff --git a/internal/worker/limits.go b/internal/worker/limits.go index c22fdd5..243ee0e 100644 --- a/internal/worker/limits.go +++ b/internal/worker/limits.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/meigma/codemode/internal/binding" + "github.com/meigma/codemode/internal/universe" ) const ( @@ -38,6 +39,7 @@ type manifestEntry struct { ID string `json:"id"` // Name is the dotted Starlark path. + // The first segment must not collide with a reserved Starlark universe root. Name string `json:"name"` // Input is the exact compiled input shape. @@ -146,6 +148,8 @@ func validateChildLimits(limits childLimits) error { } // validateManifest reports whether capability identities and shapes are legal. +// +// The first dotted name segment must not collide with a reserved Starlark universe root. func validateManifest(entries []manifestEntry) error { ids := make(map[string]struct{}, len(entries)) names := make(map[string]struct{}, len(entries)) @@ -159,6 +163,10 @@ func validateManifest(entries []manifestEntry) error { if !isDottedName(entry.Name) { return errInvalidManifest } + root, _, _ := strings.Cut(entry.Name, ".") + if universe.IsReservedRoot(root) { + return errInvalidManifest + } if _, duplicate := names[entry.Name]; duplicate { return errInvalidManifest } diff --git a/mcpserver/e2e_test.go b/mcpserver/e2e_test.go index 3988f68..ee2142b 100644 --- a/mcpserver/e2e_test.go +++ b/mcpserver/e2e_test.go @@ -137,6 +137,21 @@ type compositeExecuteEnvelope struct { Result compositeDigest `json:"result"` } +// pureComputeResult is the only value the pure-compute program may return. +type pureComputeResult struct { + // Total is sum([1, 2, 3]) after a JSON round-trip. + Total int64 `json:"total"` + + // Root is math.sqrt(4.0). + Root float64 `json:"root"` +} + +// pureComputeExecuteEnvelope is the exact structured execute payload for pure compute. +type pureComputeExecuteEnvelope struct { + // Result is main's final converted pure-compute object. + Result pureComputeResult `json:"result"` +} + // executeEnvelope is the exact structured execute payload. type executeEnvelope struct { // Result is main's final converted value. @@ -643,6 +658,55 @@ def main(): assert.False(t, hasLabel, "omitted optional string must not appear in the canonical map") } +// TestActualMCPPureComputeProgram proves one same-binary MCP execute call can +// combine json encode/decode, sum, and math.sqrt without host configuration. +func TestActualMCPPureComputeProgram(t *testing.T) { + builder := codemode.New(codemode.Options{ + Authorizer: authz.AllowAll(), + Limits: codemode.DefaultLimits(), + }) + root, err := builder.Build() + require.NoError(t, err) + + mcpServer, err := mcpserver.New(root, contextResolver{}) + require.NoError(t, err) + + trustedCtx := withInvocationIdentity(t.Context(), invocationIdentity{ + Subject: authz.Subject{ID: trustedSubjectID}, + Canary: credentialCanary, + }) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := mcpServer.Connect(trustedCtx, serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "codemode-e2e", Version: "test"}, nil) + session, err := client.Connect(t.Context(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + executed, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{ + "source": ` +def main(): + encoded = json.encode({"values": [1, 2, 3]}) + decoded = json.decode(encoded) + return {"total": sum(decoded["values"]), "root": math.sqrt(4.0)} +`, + }, + }) + require.NoError(t, err) + assertSuccessfulTool(t, executed) + want := pureComputeExecuteEnvelope{Result: pureComputeResult{ + Total: 6, + Root: 2.0, + }} + assert.Equal(t, want, decodeStructured[pureComputeExecuteEnvelope](t, executed)) + assertExactExecuteEnvelope(t, executed.StructuredContent) + requireJSONTextMirror(t, executed, want) +} + // TestActualMCPModelDerivedDiagnostics proves MCP execute surfaces only approved // parser, resolver, and binding detail and keeps handler text hidden. func TestActualMCPModelDerivedDiagnostics(t *testing.T) { diff --git a/mcpserver/language_surface_test.go b/mcpserver/language_surface_test.go new file mode 100644 index 0000000..7b8aae8 --- /dev/null +++ b/mcpserver/language_surface_test.go @@ -0,0 +1,62 @@ +package mcpserver_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/internal/universe" +) + +const ( + // languageSurfaceFence opens the exact documented name block. + languageSurfaceFence = "```language-surface" + + // languageSurfaceClose ends the exact documented name block. + languageSurfaceClose = "```" +) + +// TestLanguageSurfaceReferenceMatchesUniverse proves the MCP reference +// language-surface block matches the canonical universe name queries exactly. +func TestLanguageSurfaceReferenceMatchesUniverse(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "expected the test file path") + docPath := filepath.Join(filepath.Dir(thisFile), "..", "docs", "docs", "reference", "mcp-tools.md") + raw, err := os.ReadFile(docPath) + require.NoError(t, err, "expected to read the MCP tool reference") + + block, found := extractLanguageSurfaceBlock(string(raw)) + require.True(t, found, "expected a language-surface fence in mcp-tools.md") + assert.Equal(t, expectedLanguageSurfaceBlock(), block, "documented language surface must match internal/universe") +} + +// expectedLanguageSurfaceBlock formats the exact documented name lists. +func expectedLanguageSurfaceBlock() string { + return strings.Join([]string{ + "top-level: " + strings.Join(universe.TopLevelNames(), ", "), + "json: " + strings.Join(universe.JSONMemberNames(), ", "), + "math: " + strings.Join(universe.MathMemberNames(), ", "), + }, "\n") +} + +// extractLanguageSurfaceBlock returns the fenced language-surface body. +func extractLanguageSurfaceBlock(markdown string) (string, bool) { + start := strings.Index(markdown, languageSurfaceFence) + if start < 0 { + return "", false + } + bodyStart := start + len(languageSurfaceFence) + if bodyStart < len(markdown) && markdown[bodyStart] == '\n' { + bodyStart++ + } + end := strings.Index(markdown[bodyStart:], languageSurfaceClose) + if end < 0 { + return "", false + } + return strings.TrimSuffix(markdown[bodyStart:bodyStart+end], "\n"), true +} diff --git a/mcpserver/server.go b/mcpserver/server.go index d2ba7a7..c6a6275 100644 --- a/mcpserver/server.go +++ b/mcpserver/server.go @@ -127,7 +127,7 @@ func New(service Service, resolver InvocationResolver) (*mcp.Server, error) { }, bound.describe) mcp.AddTool(server, &mcp.Tool{ Name: "execute", - Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result. Starlark is not Python: sum, import, while, and f-strings are unavailable; load is disabled; print is discarded.", + Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result. Standard Starlark builtins plus sum(iterable), json.decode/encode/indent, and math.* are directly available without import. import, while, f-strings, filter, and map are unavailable; load is disabled; print is discarded.", OutputSchema: executeOutputSchema, }, bound.execute) return server, nil diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go index 5eaf20d..ee1e226 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -109,11 +109,12 @@ func TestNewRegistersExactlyThreeTools(t *testing.T) { "inside main", "confirmed through search_api and describe_api", "final result", - "Starlark is not Python", - "sum", - "import", - "while", - "f-strings", + "Standard Starlark builtins", + "sum(iterable)", + "json.decode/encode/indent", + "math.*", + "directly available without import", + "import, while, f-strings, filter, and map are unavailable", "load is disabled", "print is discarded", }, diff --git a/moon.yml b/moon.yml index 2143940..25335d7 100644 --- a/moon.yml +++ b/moon.yml @@ -72,7 +72,7 @@ tasks: # trailing single-dash `-flag=value` into two arguments; `script` runs the line # through the shell verbatim. mcp-smoke: - script: "go test ./mcpserver -run '^(TestActualMCPSecureLoop|TestActualMCPCompositeProgram)$' -count=1" + script: "go test ./mcpserver -run '^(TestActualMCPSecureLoop|TestActualMCPCompositeProgram|TestActualMCPPureComputeProgram)$' -count=1" inputs: - '@group(goSources)' options: