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: