From a07ae9867c7e3071849b181430d76e34b4c9872c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 23 Aug 2026 09:46:34 -0700 Subject: [PATCH] feat: add capability contracts and catalog Compile restricted Starlark binding plans once and derive typed inputs, canonical authorization arguments, signatures, and safe bounded results from them. Validate and statically filter registrations before building immutable sorted lookup, namespace, search, and description surfaces. --- authz/authz.go | 56 +++++ authz/authz_test.go | 50 +++++ authz/doc.go | 2 + capability.go | 34 +++ doc.go | 2 +- errors.go | 35 ++++ go.mod | 7 +- go.sum | 12 +- internal/binding/doc.go | 2 + internal/binding/input.go | 114 ++++++++++ internal/binding/input_test.go | 147 +++++++++++++ internal/binding/output.go | 234 +++++++++++++++++++++ internal/binding/output_test.go | 169 +++++++++++++++ internal/binding/plan.go | 265 +++++++++++++++++++++++ internal/binding/plan_test.go | 170 +++++++++++++++ internal/binding/signature.go | 98 +++++++++ internal/binding/signature_test.go | 26 +++ internal/catalog/build.go | 171 +++++++++++++++ internal/catalog/catalog.go | 151 ++++++++++++++ internal/catalog/catalog_test.go | 323 +++++++++++++++++++++++++++++ internal/catalog/doc.go | 2 + internal/catalog/search.go | 97 +++++++++ limits.go | 82 ++++++++ limits_test.go | 82 ++++++++ 24 files changed, 2328 insertions(+), 3 deletions(-) create mode 100644 authz/authz.go create mode 100644 authz/authz_test.go create mode 100644 authz/doc.go create mode 100644 capability.go create mode 100644 errors.go create mode 100644 internal/binding/doc.go create mode 100644 internal/binding/input.go create mode 100644 internal/binding/input_test.go create mode 100644 internal/binding/output.go create mode 100644 internal/binding/output_test.go create mode 100644 internal/binding/plan.go create mode 100644 internal/binding/plan_test.go create mode 100644 internal/binding/signature.go create mode 100644 internal/binding/signature_test.go create mode 100644 internal/catalog/build.go create mode 100644 internal/catalog/catalog.go create mode 100644 internal/catalog/catalog_test.go create mode 100644 internal/catalog/doc.go create mode 100644 internal/catalog/search.go create mode 100644 limits.go create mode 100644 limits_test.go diff --git a/authz/authz.go b/authz/authz.go new file mode 100644 index 0000000..9aeea02 --- /dev/null +++ b/authz/authz.go @@ -0,0 +1,56 @@ +package authz + +import ( + "context" + "errors" +) + +// SubjectID is a stable, non-secret authenticated identity. +type SubjectID string + +// Subject identifies the authenticated caller used for authorization decisions. +type Subject struct { + // ID is the caller's stable, non-secret identity. + ID SubjectID +} + +// AuthorizationInput contains the complete trusted input for one authorization decision. +type AuthorizationInput struct { + // Subject is the authenticated identity resolved from trusted host context. + Subject Subject + + // CapabilityID is the capability's stable policy identity. + CapabilityID string + + // CapabilityName is the model-facing dotted capability name. + CapabilityName string + + // Arguments is a fresh canonical JSON-shaped projection of validated arguments. + Arguments map[string]any +} + +// Authorizer decides whether a subject may perform one validated native invocation. +type Authorizer interface { + // Authorize returns nil to allow the invocation, an error wrapping ErrDenied for a recognized denial, + // or another error when policy evaluation fails. + Authorize(context.Context, AuthorizationInput) error +} + +// ErrDenied classifies a recognized authorization denial. +// +// An authorizer may wrap ErrDenied with trusted diagnostic context. Callers must not expose +// the wrapped error text to untrusted clients. +var ErrDenied = errors.New("authorization denied") + +// AllowAllAuthorizer explicitly permits every authorization input. +type AllowAllAuthorizer struct{} + +// AllowAll returns an explicit authorizer that permits every invocation. +func AllowAll() AllowAllAuthorizer { + return AllowAllAuthorizer{} +} + +// Authorize permits the invocation without inspecting its input. +func (AllowAllAuthorizer) Authorize(context.Context, AuthorizationInput) error { + return nil +} diff --git a/authz/authz_test.go b/authz/authz_test.go new file mode 100644 index 0000000..2d92c26 --- /dev/null +++ b/authz/authz_test.go @@ -0,0 +1,50 @@ +package authz_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/authz" +) + +// TestAllowAllRequiresExplicitConstruction proves the unrestricted policy is explicit and always permits. +func TestAllowAllRequiresExplicitConstruction(t *testing.T) { + var authorizer authz.Authorizer = authz.AllowAll() + input := authz.AuthorizationInput{ + Subject: authz.Subject{ID: "subject-1"}, + CapabilityID: "records.lookup", + CapabilityName: "records.lookup", + Arguments: map[string]any{"id": "alpha"}, + } + + require.NoError(t, authorizer.Authorize(t.Context(), input)) +} + +// TestDeniedClassificationKeepsTrustedDetailWrapped proves callers can classify denials without string matching. +func TestDeniedClassificationKeepsTrustedDetailWrapped(t *testing.T) { + trustedErr := fmt.Errorf("account suspended: %w", authz.ErrDenied) + + require.ErrorIs(t, trustedErr, authz.ErrDenied) + assert.NotEqual(t, authz.ErrDenied.Error(), trustedErr.Error()) +} + +// TestAuthorizerRemainsContextAware proves the port accepts request cancellation state. +func TestAuthorizerRemainsContextAware(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + authorizer := contextAuthorizer{} + + require.ErrorIs(t, authorizer.Authorize(ctx, authz.AuthorizationInput{}), context.Canceled) +} + +// contextAuthorizer returns the request context error for contract testing. +type contextAuthorizer struct{} + +// Authorize returns the current request context error. +func (contextAuthorizer) Authorize(ctx context.Context, _ authz.AuthorizationInput) error { + return ctx.Err() +} diff --git a/authz/doc.go b/authz/doc.go new file mode 100644 index 0000000..9648c99 --- /dev/null +++ b/authz/doc.go @@ -0,0 +1,2 @@ +// Package authz defines the trusted authorization boundary for native capability calls. +package authz diff --git a/capability.go b/capability.go new file mode 100644 index 0000000..870ac66 --- /dev/null +++ b/capability.go @@ -0,0 +1,34 @@ +package codemode + +import ( + "context" + + "github.com/meigma/codemode/authz" +) + +// CapabilityID is a stable deployment and authorization identity for a capability. +type CapabilityID string + +// CapabilityName is the dotted name exposed to Starlark programs and model-facing discovery. +type CapabilityName string + +// Handler executes one capability with trusted subject identity and typed input. +type Handler[Input, Output any] func(context.Context, authz.Subject, Input) (Output, error) + +// Capability describes one typed native operation available to CodeMode. +type Capability[Input, Output any] struct { + // ID is the stable identity used by deployment filtering and authorization policy. + ID CapabilityID + + // Name is the dotted Starlark name exposed to programs and discovery. + Name CapabilityName + + // Summary is a compact description used by capability search. + Summary string + + // Description explains the capability behavior for exact description requests. + Description string + + // Handler executes the capability after binding and authorization succeed. + Handler Handler[Input, Output] +} diff --git a/doc.go b/doc.go index 4aab7b6..2336313 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,2 @@ -// Package codemode is the root package of the CodeMode library. +// Package codemode defines capability and runtime configuration contracts for code-native MCP servers. package codemode diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..6d18225 --- /dev/null +++ b/errors.go @@ -0,0 +1,35 @@ +package codemode + +import "errors" + +var ( + // ErrInvalidRegistration classifies invalid capability registration, limits, or server construction. + ErrInvalidRegistration = errors.New("invalid registration") + + // ErrUnauthenticated classifies failure to resolve a trusted invocation subject. + ErrUnauthenticated = errors.New("unauthenticated") + + // ErrNotFound classifies an unavailable or disabled capability. + ErrNotFound = errors.New("capability not found") + + // ErrInvalidProgram classifies invalid Starlark source or entrypoint behavior. + ErrInvalidProgram = errors.New("invalid program") + + // ErrInvalidArguments classifies capability arguments rejected before authorization. + ErrInvalidArguments = errors.New("invalid capability arguments") + + // ErrPermissionDenied classifies a recognized authorization denial. + ErrPermissionDenied = errors.New("permission denied") + + // ErrPolicyFailure classifies an authorization evaluation failure. + ErrPolicyFailure = errors.New("authorization policy failure") + + // ErrResourceLimit classifies a configured execution or conversion limit. + ErrResourceLimit = errors.New("resource limit exceeded") + + // ErrCapabilityFailure classifies a native capability handler failure. + ErrCapabilityFailure = errors.New("capability failed") + + // ErrInternal classifies an unexpected framework failure. + ErrInternal = errors.New("internal failure") +) diff --git a/go.mod b/go.mod index bdcf6e8..cf94f1b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,11 @@ module github.com/meigma/codemode go 1.26.6 require ( - github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/stretchr/testify v1.12.1 go.starlark.net v0.0.0-20260708150628-5395d018f003 ) + +require ( + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index f43e535..7259203 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ -github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/binding/doc.go b/internal/binding/doc.go new file mode 100644 index 0000000..9206d0d --- /dev/null +++ b/internal/binding/doc.go @@ -0,0 +1,2 @@ +// Package binding compiles restricted Go input and output types into immutable Starlark conversion plans. +package binding diff --git a/internal/binding/input.go b/internal/binding/input.go new file mode 100644 index 0000000..3c2ce8d --- /dev/null +++ b/internal/binding/input.go @@ -0,0 +1,114 @@ +package binding + +import ( + "fmt" + "reflect" + + "go.starlark.net/starlark" +) + +const keywordTupleSize = 2 + +// BindAs binds keyword arguments to the plan's exact generic input type and canonical arguments. +func BindAs[Input any](plan *Plan, args starlark.Tuple, kwargs []starlark.Tuple) (Input, map[string]any, error) { + var zero Input + if plan == nil || plan.inputType != reflect.TypeFor[Input]() { + return zero, nil, fmt.Errorf("%w: requested input type does not match the compiled plan", ErrInvalidPlan) + } + bound, canonical, err := plan.Bind(args, kwargs) + if err != nil { + return zero, nil, err + } + typed, ok := bound.(Input) + if !ok { + return zero, nil, fmt.Errorf("%w: bound input does not match its compiled type", ErrInvalidPlan) + } + return typed, canonical, nil +} + +// Bind validates keyword-only Starlark arguments and constructs an exact Go input plus fresh canonical map. +func (plan *Plan) Bind(args starlark.Tuple, kwargs []starlark.Tuple) (any, map[string]any, error) { + if plan == nil { + return nil, nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) + } + if len(args) != 0 { + return nil, nil, fmt.Errorf("%w: positional arguments are not accepted", ErrInvalidArguments) + } + + input := reflect.New(plan.inputType).Elem() + canonical := make(map[string]any, len(kwargs)) + seen := make(map[string]struct{}, len(kwargs)) + for _, keyword := range kwargs { + name, err := plan.bindKeyword(input, canonical, keyword) + if err != nil { + return nil, nil, err + } + if _, duplicate := seen[name]; duplicate { + return nil, nil, fmt.Errorf("%w: duplicate argument %q", ErrInvalidArguments, name) + } + seen[name] = struct{}{} + } + if err := plan.requireArguments(seen); err != nil { + return nil, nil, err + } + return input.Interface(), canonical, nil +} + +// bindKeyword validates and converts one keyword into both typed and canonical representations. +func (plan *Plan) bindKeyword(input reflect.Value, canonical map[string]any, keyword starlark.Tuple) (string, error) { + if len(keyword) != keywordTupleSize { + return "", fmt.Errorf("%w: malformed keyword argument", ErrInvalidArguments) + } + name, ok := starlark.AsString(keyword[0]) + if !ok { + return "", fmt.Errorf("%w: keyword name must be a string", ErrInvalidArguments) + } + fieldIndex, known := plan.inputByName[name] + if !known { + return "", fmt.Errorf("%w: unknown argument %q", ErrInvalidArguments, name) + } + + field := plan.inputFields[fieldIndex] + value := keyword[1] + switch field.kind { + case fieldString: + converted, ok := starlark.AsString(value) + if !ok { + return "", fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, name) + } + input.Field(field.index).SetString(converted) + canonical[name] = converted + case fieldOptionalInt64: + if value == starlark.None { + return name, nil + } + integer, ok := value.(starlark.Int) + if !ok { + return "", fmt.Errorf("%w: argument %q must be an integer or None", ErrInvalidArguments, name) + } + converted, ok := integer.Int64() + if !ok { + return "", fmt.Errorf("%w: argument %q overflows int64", ErrInvalidArguments, name) + } + pointer := reflect.New(input.Field(field.index).Type().Elem()) + pointer.Elem().SetInt(converted) + input.Field(field.index).Set(pointer) + canonical[name] = converted + case fieldInt64, fieldBool, fieldFloat64: + return "", fmt.Errorf("%w: argument %q has an invalid compiled kind", ErrInvalidPlan, name) + } + return name, nil +} + +// requireArguments verifies that every required compiled field was present. +func (plan *Plan) requireArguments(seen map[string]struct{}) error { + for _, field := range plan.inputFields { + if !field.required { + continue + } + if _, ok := seen[field.name]; !ok { + return fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.name) + } + } + return nil +} diff --git a/internal/binding/input_test.go b/internal/binding/input_test.go new file mode 100644 index 0000000..c39935f --- /dev/null +++ b/internal/binding/input_test.go @@ -0,0 +1,147 @@ +package binding + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" +) + +// TestBindAsBuildsTypedInputAndCanonicalArguments proves one plan traversal produces both projections. +func TestBindAsBuildsTypedInputAndCanonicalArguments(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + bound, canonical, err := BindAs[representativeInput](plan, nil, []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.MakeInt64(25)), + }) + + require.NoError(t, err) + require.NotNil(t, bound.Limit) + assert.Equal(t, "meigma", bound.Org) + assert.Equal(t, int64(25), *bound.Limit) + assert.Equal(t, map[string]any{"org": "meigma", "limit": int64(25)}, canonical) +} + +// TestBindAsNormalizesOmittedAndExplicitNone proves both optional forms produce nil and omit the canonical key. +func TestBindAsNormalizesOmittedAndExplicitNone(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + tests := []struct { + // name identifies the optional argument form. + name string + + // kwargs contains the Starlark keyword arguments. + kwargs []starlark.Tuple + }{ + {name: "omitted", kwargs: []starlark.Tuple{keyword("org", starlark.String("meigma"))}}, + {name: "explicit None", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.None), + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bound, canonical, err := BindAs[representativeInput](plan, nil, tt.kwargs) + + require.NoError(t, err) + assert.Nil(t, bound.Limit) + assert.Equal(t, map[string]any{"org": "meigma"}, canonical) + }) + } +} + +// TestBindReturnsFreshCanonicalMaps proves one invocation cannot mutate another authorization input. +func TestBindReturnsFreshCanonicalMaps(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + kwargs := []starlark.Tuple{keyword("org", starlark.String("meigma"))} + + _, first, err := plan.Bind(nil, kwargs) + require.NoError(t, err) + first["org"] = "mutated" + _, second, err := plan.Bind(nil, kwargs) + require.NoError(t, err) + + assert.Equal(t, "meigma", second["org"]) +} + +// TestBindRejectsMalformedArguments proves invalid calls fail before authorization can observe them. +func TestBindRejectsMalformedArguments(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + overflow := new(big.Int).Lsh(big.NewInt(1), 80) + + tests := []struct { + // name identifies the malformed call. + name string + + // args contains positional arguments. + args starlark.Tuple + + // kwargs contains keyword arguments. + kwargs []starlark.Tuple + + // contains is the expected safe diagnostic fragment. + contains string + }{ + {name: "positional", args: starlark.Tuple{starlark.String("meigma")}, contains: "positional"}, + {name: "missing required", contains: "missing required"}, + {name: "duplicate", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("one")), + keyword("org", starlark.String("two")), + }, contains: "duplicate"}, + {name: "unknown", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("other", starlark.String("value")), + }, contains: "unknown"}, + {name: "mistyped required string", kwargs: []starlark.Tuple{ + keyword("org", starlark.MakeInt(1)), + }, contains: "must be a string"}, + {name: "mistyped optional integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.String("many")), + }, contains: "integer or None"}, + {name: "overflowing optional integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.MakeBigInt(overflow)), + }, contains: "overflows int64"}, + {name: "malformed keyword", kwargs: []starlark.Tuple{{starlark.String("org")}}, contains: "malformed keyword"}, + { + name: "non-string keyword", + kwargs: []starlark.Tuple{{starlark.MakeInt(1), starlark.String("value")}}, + contains: "keyword name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := plan.Bind(tt.args, tt.kwargs) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidArguments) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// TestBindAsRejectsTypeDrift proves callers cannot reuse a plan with a different typed input. +func TestBindAsRejectsTypeDrift(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + _, _, err = BindAs[struct{}](plan, nil, nil) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPlan) +} + +// keyword constructs one Starlark keyword tuple. +func keyword(name string, value starlark.Value) starlark.Tuple { + return starlark.Tuple{starlark.String(name), value} +} diff --git a/internal/binding/output.go b/internal/binding/output.go new file mode 100644 index 0000000..6f95b42 --- /dev/null +++ b/internal/binding/output.go @@ -0,0 +1,234 @@ +package binding + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + + "go.starlark.net/starlark" +) + +// ConvertOutput converts the plan's exact handler output directly to a Starlark object. +func (plan *Plan) ConvertOutput(output any) (starlark.Value, error) { + if plan == nil { + return nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) + } + value := reflect.ValueOf(output) + if !value.IsValid() || value.Type() != plan.outputType { + return nil, fmt.Errorf("%w: handler output type does not match the compiled plan", ErrUnsupportedValue) + } + + converted := starlark.NewDict(len(plan.outputFields)) + for _, field := range plan.outputFields { + item, err := convertOutputField(field, value.Field(field.index)) + if err != nil { + return nil, err + } + if err := converted.SetKey(starlark.String(field.name), item); err != nil { + return nil, fmt.Errorf("%w: output field %q: %w", ErrUnsupportedValue, field.name, err) + } + } + return converted, nil +} + +// ConvertFinal converts a final Starlark value to MCP-safe JSON data under depth and byte limits. +func ConvertFinal(value starlark.Value, maxDepth int, maxBytes int) (any, error) { + if maxDepth <= 0 || maxBytes <= 0 { + return nil, fmt.Errorf("%w: conversion limits must be positive", ErrValueLimit) + } + converter := finalConverter{ + maxDepth: maxDepth, + remainingNodes: maxBytes, + active: make(map[visitKey]struct{}), + } + converted, err := converter.convert(value, 1) + if err != nil { + return nil, err + } + encoded, err := json.Marshal(converted) + if err != nil { + return nil, fmt.Errorf("%w: encode final value: %w", ErrUnsupportedValue, err) + } + if len(encoded) > maxBytes { + return nil, fmt.Errorf("%w: final result is %d bytes; maximum is %d", ErrValueLimit, len(encoded), maxBytes) + } + return converted, nil +} + +// visitKey identifies an active pointer container during cycle detection. +type visitKey struct { + // kind distinguishes list and dictionary pointer identities. + kind byte + + // pointer is the active container's Go pointer identity. + pointer uintptr +} + +// finalConverter carries immutable depth configuration and path-local conversion state. +type finalConverter struct { + // maxDepth is the maximum accepted nesting depth. + maxDepth int + + // remainingNodes bounds materialization before exact JSON byte measurement. + remainingNodes int + + // active contains containers on the current recursive path. + active map[visitKey]struct{} +} + +// convert recursively converts one supported Starlark value. +func (converter *finalConverter) convert(value starlark.Value, depth int) (any, error) { + if isNilStarlarkValue(value) { + return nil, fmt.Errorf("%w: nil Starlark value", ErrUnsupportedValue) + } + if depth > converter.maxDepth { + return nil, fmt.Errorf("%w: value exceeds depth %d", ErrValueLimit, converter.maxDepth) + } + if converter.remainingNodes == 0 { + return nil, fmt.Errorf("%w: value exceeds byte-derived node budget", ErrValueLimit) + } + converter.remainingNodes-- + + switch value := value.(type) { + case starlark.NoneType: + return nil, nil //nolint:nilnil // Starlark None intentionally maps to JSON null. + case starlark.Bool: + return bool(value), nil + case starlark.String: + return string(value), nil + case starlark.Int: + return convertInteger(value) + case starlark.Float: + return convertFloat(value) + case starlark.Tuple: + return converter.convertTuple(value, depth) + case *starlark.List: + return converter.convertList(value, depth) + case *starlark.Dict: + return converter.convertDict(value, depth) + default: + return nil, fmt.Errorf("%w: Starlark type %s", ErrUnsupportedValue, value.Type()) + } +} + +// isNilStarlarkValue reports whether an interface contains no value or a typed nil pointer. +func isNilStarlarkValue(value starlark.Value) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} + +// convertOutputField converts one field according to its compiled output kind. +func convertOutputField(field outputField, value reflect.Value) (starlark.Value, error) { + switch field.kind { + case fieldString: + return starlark.String(value.String()), nil + case fieldInt64: + return starlark.MakeInt64(value.Int()), nil + case fieldBool: + return starlark.Bool(value.Bool()), nil + case fieldFloat64: + float := value.Float() + if math.IsNaN(float) || math.IsInf(float, 0) { + return nil, fmt.Errorf("%w: output field %q is not finite", ErrUnsupportedValue, field.name) + } + return starlark.Float(float), nil + case fieldOptionalInt64: + return nil, fmt.Errorf("%w: output field %q has an invalid compiled kind", ErrInvalidPlan, field.name) + } + return nil, fmt.Errorf("%w: output field %q has an unknown compiled kind", ErrInvalidPlan, field.name) +} + +// convertInteger converts a Starlark integer when it fits the MCP signed integer surface. +func convertInteger(value starlark.Int) (any, error) { + integer, ok := value.Int64() + if !ok { + return nil, fmt.Errorf("%w: integer overflows int64", ErrUnsupportedValue) + } + return integer, nil +} + +// convertFloat converts a Starlark float when it is finite. +func convertFloat(value starlark.Float) (any, error) { + float := float64(value) + if math.IsNaN(float) || math.IsInf(float, 0) { + return nil, fmt.Errorf("%w: float is not finite", ErrUnsupportedValue) + } + return float, nil +} + +// convertTuple recursively converts an immutable sequence. +func (converter *finalConverter) convertTuple(value starlark.Tuple, depth int) ([]any, error) { + items := make([]any, len(value)) + for index, item := range value { + converted, err := converter.convert(item, depth+1) + if err != nil { + return nil, err + } + items[index] = converted + } + return items, nil +} + +// convertList recursively converts a mutable sequence while rejecting active cycles. +func (converter *finalConverter) convertList(value *starlark.List, depth int) ([]any, error) { + key, err := converter.enter('l', value) + if err != nil { + return nil, err + } + defer converter.leave(key) + + items := make([]any, 0, value.Len()) + iterator := value.Iterate() + defer iterator.Done() + var item starlark.Value + for iterator.Next(&item) { + converted, err := converter.convert(item, depth+1) + if err != nil { + return nil, err + } + items = append(items, converted) + } + return items, nil +} + +// convertDict recursively converts a mutable dictionary with string keys while rejecting active cycles. +func (converter *finalConverter) convertDict(value *starlark.Dict, depth int) (map[string]any, error) { + key, err := converter.enter('d', value) + if err != nil { + return nil, err + } + defer converter.leave(key) + + object := make(map[string]any, value.Len()) + for _, item := range value.Items() { + name, ok := starlark.AsString(item[0]) + if !ok { + return nil, fmt.Errorf("%w: dictionary key must be a string", ErrUnsupportedValue) + } + converted, err := converter.convert(item[1], depth+1) + if err != nil { + return nil, err + } + object[name] = converted + } + return object, nil +} + +// enter adds a pointer container to the active recursion path. +func (converter *finalConverter) enter(kind byte, value any) (visitKey, error) { + key := visitKey{kind: kind, pointer: reflect.ValueOf(value).Pointer()} + if _, exists := converter.active[key]; exists { + return visitKey{}, fmt.Errorf("%w: cyclic value", ErrUnsupportedValue) + } + converter.active[key] = struct{}{} + return key, nil +} + +// leave removes a pointer container from the active recursion path. +func (converter *finalConverter) leave(key visitKey) { + delete(converter.active, key) +} diff --git a/internal/binding/output_test.go b/internal/binding/output_test.go new file mode 100644 index 0000000..350a172 --- /dev/null +++ b/internal/binding/output_test.go @@ -0,0 +1,169 @@ +package binding + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" +) + +// TestConvertOutputUsesCompiledFields proves handler output conversion follows the immutable output plan. +func TestConvertOutputUsesCompiledFields(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + output := representativeOutput{Name: "alpha", Count: 3, Active: true, Score: 1.5} + + value, err := plan.ConvertOutput(output) + require.NoError(t, err) + converted, err := ConvertFinal(value, 4, 1024) + require.NoError(t, err) + + assert.Equal(t, map[string]any{ + "name": "alpha", + "count": int64(3), + "active": true, + "score": 1.5, + }, converted) +} + +// TestConvertOutputRejectsTypeDriftAndNonFiniteFloats proves outputs cannot bypass their compiled shape. +func TestConvertOutputRejectsTypeDriftAndNonFiniteFloats(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + tests := []struct { + // name identifies the invalid handler output. + name string + + // output is the value presented to the compiled output plan. + output any + }{ + {name: "wrong type", output: struct { + // Name is an intentionally incompatible output field. + Name string + }{Name: "alpha"}}, + {name: "pointer", output: &representativeOutput{}}, + {name: "NaN", output: representativeOutput{Score: math.NaN()}}, + {name: "positive infinity", output: representativeOutput{Score: math.Inf(1)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := plan.ConvertOutput(tt.output) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + }) + } +} + +// TestConvertFinalAcceptsTheMCPValueSurface proves all supported final value categories convert safely. +func TestConvertFinalAcceptsTheMCPValueSurface(t *testing.T) { + dictionary := starlark.NewDict(2) + require.NoError(t, dictionary.SetKey(starlark.String("flag"), starlark.True)) + require.NoError(t, dictionary.SetKey(starlark.String("none"), starlark.None)) + value := starlark.Tuple{ + starlark.String("text"), + starlark.MakeInt64(7), + starlark.Float(1.25), + starlark.NewList([]starlark.Value{starlark.False}), + dictionary, + } + + converted, err := ConvertFinal(value, 4, 1024) + + require.NoError(t, err) + assert.Equal(t, []any{ + "text", + int64(7), + 1.25, + []any{false}, + map[string]any{"flag": true, "none": nil}, + }, converted) +} + +// TestConvertFinalRejectsUnsupportedAndOverflowingValues proves executable and unsafe values never cross MCP. +func TestConvertFinalRejectsUnsupportedAndOverflowingValues(t *testing.T) { + overflow := new(big.Int).Lsh(big.NewInt(1), 80) + nonStringKey := starlark.NewDict(1) + require.NoError(t, nonStringKey.SetKey(starlark.MakeInt(1), starlark.String("value"))) + var nilList *starlark.List + var nilDict *starlark.Dict + + tests := []struct { + // name identifies the unsafe final value. + name string + + // value is the Starlark value presented for final conversion. + value starlark.Value + }{ + {name: "integer overflow", value: starlark.MakeBigInt(overflow)}, + {name: "NaN", value: starlark.Float(math.NaN())}, + {name: "infinity", value: starlark.Float(math.Inf(1))}, + {name: "non-string dictionary key", value: nonStringKey}, + {name: "builtin", value: starlark.NewBuiltin("run", nil)}, + {name: "bytes", value: starlark.Bytes("secret")}, + {name: "nil interface", value: nil}, + {name: "typed nil list", value: nilList}, + {name: "typed nil dictionary", value: nilDict}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ConvertFinal(tt.value, 4, 1024) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + }) + } +} + +// TestConvertFinalEnforcesDepthAndEncodedSize proves both conversion budgets are positive hard limits. +func TestConvertFinalEnforcesDepthAndEncodedSize(t *testing.T) { + deep := starlark.NewList([]starlark.Value{ + starlark.NewList([]starlark.Value{starlark.String("value")}), + }) + + _, err := ConvertFinal(deep, 2, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "depth") + + _, err = ConvertFinal(starlark.String("long result"), 2, 4) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "bytes") + + _, err = ConvertFinal(starlark.None, 0, 10) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) +} + +// TestConvertFinalRejectsCycles proves recursive containers fail before JSON encoding. +func TestConvertFinalRejectsCycles(t *testing.T) { + list := starlark.NewList(nil) + require.NoError(t, list.Append(list)) + + _, err := ConvertFinal(list, 10, 1024) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + assert.Contains(t, err.Error(), "cyclic") +} + +// TestConvertFinalBoundsSharedSubstructure proves a compact DAG cannot expand beyond the result budget. +func TestConvertFinalBoundsSharedSubstructure(t *testing.T) { + var value starlark.Value = starlark.String("x") + for range 10 { + value = starlark.Tuple{value, value} + } + + _, err := ConvertFinal(value, 16, 64) + + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "node budget") +} diff --git a/internal/binding/plan.go b/internal/binding/plan.go new file mode 100644 index 0000000..0f5d462 --- /dev/null +++ b/internal/binding/plan.go @@ -0,0 +1,265 @@ +package binding + +import ( + "errors" + "fmt" + "reflect" + "strings" + "unicode" +) + +var ( + // ErrInvalidPlan classifies an input or output type that cannot form a restricted binding plan. + ErrInvalidPlan = errors.New("invalid binding plan") + + // ErrInvalidArguments classifies Starlark arguments rejected before authorization. + ErrInvalidArguments = errors.New("invalid capability arguments") + + // ErrUnsupportedValue classifies a Go or Starlark value outside the supported conversion surface. + ErrUnsupportedValue = errors.New("unsupported value") + + // ErrValueLimit classifies converted output that exceeds a configured depth or byte limit. + ErrValueLimit = errors.New("converted value limit exceeded") +) + +// fieldKind identifies one supported direct conversion. +type fieldKind uint8 + +const ( + fieldString fieldKind = iota + 1 + fieldOptionalInt64 + fieldInt64 + fieldBool + fieldFloat64 +) + +// inputField is one immutable input-field conversion step. +type inputField struct { + // name is the Starlark keyword and canonical authorization key. + name string + + // index is the direct field index in the non-embedded input struct. + index int + + // kind determines validation and direct conversion behavior. + kind fieldKind + + // required reports whether omission is invalid. + required bool +} + +// outputField is one immutable output-field conversion step. +type outputField struct { + // name is the string key returned to Starlark. + name string + + // index is the direct field index in the non-embedded output struct. + index int + + // kind determines direct conversion behavior. + kind fieldKind +} + +// Plan is an immutable compiled input, output, signature, and canonical-argument plan. +type Plan struct { + // inputType is the exact non-pointer Go input struct type. + inputType reflect.Type + + // outputType is the exact non-pointer Go output struct type. + outputType reflect.Type + + // inputFields preserves declaration order for binding and signatures. + inputFields []inputField + + // inputByName maps each accepted keyword to its field-plan index. + inputByName map[string]int + + // outputFields preserves declaration order for deterministic conversion. + outputFields []outputField +} + +// CompileFor compiles the exact generic input and output types once. +func CompileFor[Input, Output any]() (*Plan, error) { + return Compile(reflect.TypeFor[Input](), reflect.TypeFor[Output]()) +} + +// Compile creates an immutable restricted plan for exact input and output struct types. +func Compile(inputType reflect.Type, outputType reflect.Type) (*Plan, error) { + inputFields, inputByName, err := compileInput(inputType) + if err != nil { + return nil, err + } + outputFields, err := compileOutput(outputType) + if err != nil { + return nil, err + } + return &Plan{ + inputType: inputType, + outputType: outputType, + inputFields: inputFields, + inputByName: inputByName, + outputFields: outputFields, + }, nil +} + +// InputType returns the exact Go input type compiled into the plan. +func (plan *Plan) InputType() reflect.Type { + return plan.inputType +} + +// OutputType returns the exact Go output type compiled into the plan. +func (plan *Plan) OutputType() reflect.Type { + return plan.outputType +} + +// compileInput validates and compiles the restricted input struct. +func compileInput(inputType reflect.Type) ([]inputField, map[string]int, error) { + if inputType == nil || inputType.Kind() != reflect.Struct { + return nil, nil, fmt.Errorf("%w: input must be a non-pointer struct", ErrInvalidPlan) + } + fields := make([]inputField, 0, inputType.NumField()) + byName := make(map[string]int, inputType.NumField()) + for index := range inputType.NumField() { + field := inputType.Field(index) + name, options, err := compileFieldName(field) + if err != nil { + return nil, nil, fmt.Errorf("%w: input field %s: %w", ErrInvalidPlan, field.Name, err) + } + if _, exists := byName[name]; exists { + return nil, nil, fmt.Errorf("%w: duplicate input name %q", ErrInvalidPlan, name) + } + + compiled := inputField{name: name, index: index} + switch { + case field.Type.Kind() == reflect.String: + if options.omitempty { + return nil, nil, fmt.Errorf("%w: required input %q cannot use omitempty", ErrInvalidPlan, name) + } + compiled.kind = fieldString + compiled.required = true + case field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Int64: + compiled.kind = fieldOptionalInt64 + default: + return nil, nil, fmt.Errorf("%w: input field %q has unsupported type %s", ErrInvalidPlan, name, field.Type) + } + byName[name] = len(fields) + fields = append(fields, compiled) + } + return fields, byName, nil +} + +// compileOutput validates and compiles the restricted output struct. +func compileOutput(outputType reflect.Type) ([]outputField, error) { + if outputType == nil || outputType.Kind() != reflect.Struct { + return nil, fmt.Errorf("%w: output must be a non-pointer struct", ErrInvalidPlan) + } + fields := make([]outputField, 0, outputType.NumField()) + seen := make(map[string]struct{}, outputType.NumField()) + for index := range outputType.NumField() { + field := outputType.Field(index) + name, options, err := compileFieldName(field) + if err != nil { + return nil, fmt.Errorf("%w: output field %s: %w", ErrInvalidPlan, field.Name, err) + } + if options.omitempty { + return nil, fmt.Errorf("%w: output field %q cannot use omitempty", ErrInvalidPlan, name) + } + if _, exists := seen[name]; exists { + return nil, fmt.Errorf("%w: duplicate output name %q", ErrInvalidPlan, name) + } + + compiled := outputField{name: name, index: index} + switch field.Type.Kind() { //nolint:exhaustive // Unsupported reflect kinds share registration-time rejection. + case reflect.String: + compiled.kind = fieldString + case reflect.Int64: + compiled.kind = fieldInt64 + case reflect.Bool: + compiled.kind = fieldBool + case reflect.Float64: + compiled.kind = fieldFloat64 + default: + return nil, fmt.Errorf("%w: output field %q has unsupported type %s", ErrInvalidPlan, name, field.Type) + } + seen[name] = struct{}{} + fields = append(fields, compiled) + } + return fields, nil +} + +// tagOptions contains the supported JSON tag option state. +type tagOptions struct { + // omitempty reports whether the field may be omitted when absent. + omitempty bool +} + +// compileFieldName validates field visibility, embedding, tags, and Starlark identifier syntax. +func compileFieldName(field reflect.StructField) (string, tagOptions, error) { + if field.Anonymous { + return "", tagOptions{}, errors.New("embedded fields are not supported") + } + if field.PkgPath != "" { + return "", tagOptions{}, errors.New("field must be exported") + } + + name := field.Name + var options tagOptions + if field.Tag != "" { + jsonTag, ok := field.Tag.Lookup("json") + if !ok || string(field.Tag) != fmt.Sprintf("json:%q", jsonTag) { + return "", tagOptions{}, errors.New("only one json struct tag is supported") + } + parts := strings.Split(jsonTag, ",") + if parts[0] == "-" { + return "", tagOptions{}, errors.New("ignored JSON fields are not supported") + } + if parts[0] != "" { + name = parts[0] + } + for _, option := range parts[1:] { + switch option { + case "omitempty": + if options.omitempty { + return "", tagOptions{}, errors.New("duplicate omitempty option") + } + options.omitempty = true + case "": + return "", tagOptions{}, errors.New("empty JSON tag option") + default: + return "", tagOptions{}, fmt.Errorf("unsupported JSON tag option %q", option) + } + } + } + if !ValidIdentifier(name) { + return "", tagOptions{}, fmt.Errorf("JSON name %q is not a Starlark identifier", name) + } + return name, options, nil +} + +// ValidIdentifier reports whether name is accepted as an identifier by the pinned Starlark scanner. +func ValidIdentifier(name string) bool { + if name == "" || isKeyword(name) { + return false + } + for index, character := range name { + if character == '_' || unicode.IsLetter(character) || + index > 0 && character >= '0' && character <= '9' { + continue + } + return false + } + return true +} + +// isKeyword reports whether name is reserved by Starlark syntax. +func isKeyword(name string) bool { + switch name { + case "False", "None", "True", + "and", "as", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", + "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "load", + "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield": + return true + default: + return false + } +} diff --git a/internal/binding/plan_test.go b/internal/binding/plan_test.go new file mode 100644 index 0000000..4bd8f4a --- /dev/null +++ b/internal/binding/plan_test.go @@ -0,0 +1,170 @@ +package binding + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// representativeInput is the restricted MVP input shape. +type representativeInput struct { + // Org is the required string argument. + Org string `json:"org"` + + // Limit is the optional signed integer argument. + Limit *int64 `json:"limit,omitempty"` +} + +// representativeOutput is a simple supported result structure. +type representativeOutput struct { + // Name is a string output field. + Name string `json:"name"` + + // Count is a signed integer output field. + Count int64 `json:"count"` + + // Active is a Boolean output field. + Active bool `json:"active"` + + // Score is a finite floating-point output field. + Score float64 `json:"score"` +} + +// embeddedInputBase supplies a field that must not be promoted implicitly. +type embeddedInputBase struct { + // Value is an embedded candidate argument. + Value string `json:"value"` +} + +// embeddedInput contains an unsupported anonymous field. +type embeddedInput struct { + // embeddedInputBase is intentionally anonymous to exercise rejection. + embeddedInputBase +} + +// TestCompileAcceptsRepresentativeTypes proves the restricted MVP shape compiles once. +func TestCompileAcceptsRepresentativeTypes(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + + require.NoError(t, err) + assert.Equal(t, reflect.TypeFor[representativeInput](), plan.InputType()) + assert.Equal(t, reflect.TypeFor[representativeOutput](), plan.OutputType()) +} + +// TestCompileRejectsUnsupportedInputShapes proves malformed binding plans fail during registration. +func TestCompileRejectsUnsupportedInputShapes(t *testing.T) { + tests := []struct { + // name identifies the invalid input shape. + name string + + // inputType is compiled as a capability input. + inputType reflect.Type + + // contains is the expected diagnostic fragment. + contains string + }{ + {name: "pointer input", inputType: reflect.TypeFor[*representativeInput](), contains: "non-pointer struct"}, + {name: "required integer", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value int64 `json:"value"` + }{}), contains: "unsupported type"}, + {name: "wrong optional pointer", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value *string `json:"value,omitempty"` + }{}), contains: "unsupported type"}, + {name: "embedded field", inputType: reflect.TypeFor[embeddedInput](), contains: "embedded fields"}, + {name: "unexported field", inputType: reflect.TypeOf(struct { + // value is intentionally unexported. + value string + }{}), contains: "must be exported"}, + {name: "duplicate JSON name", inputType: duplicateJSONNameType(), contains: "duplicate input name"}, + {name: "invalid identifier", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"not-valid"` + }{}), contains: "Starlark identifier"}, + {name: "reserved identifier", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"for"` + }{}), contains: "Starlark identifier"}, + {name: "future reserved identifier", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"from"` + }{}), contains: "Starlark identifier"}, + {name: "non-ASCII digit", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"a١"` + }{}), contains: "Starlark identifier"}, + {name: "ignored field", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"-"` + }{}), contains: "ignored JSON fields"}, + {name: "unknown tag option", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"value,string"` + }{}), contains: "unsupported JSON tag option"}, + {name: "unknown struct tag", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"value" xml:"value"` + }{}), contains: "only one json struct tag"}, + {name: "required omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"value,omitempty"` + }{}), contains: "cannot use omitempty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Compile(tt.inputType, reflect.TypeFor[representativeOutput]()) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPlan) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// TestCompileRejectsUnsupportedOutputShapes proves outputs cannot smuggle pointers or unsupported kinds. +func TestCompileRejectsUnsupportedOutputShapes(t *testing.T) { + tests := []struct { + // name identifies the invalid output shape. + name string + + // outputType is compiled as a capability output. + outputType reflect.Type + + // contains is the expected diagnostic fragment. + contains string + }{ + {name: "pointer output", outputType: reflect.TypeFor[*representativeOutput](), contains: "non-pointer struct"}, + {name: "pointer field", outputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value *int64 `json:"value"` + }{}), contains: "unsupported type"}, + {name: "output omitempty", outputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value string `json:"value,omitempty"` + }{}), contains: "cannot use omitempty"}, + {name: "duplicate JSON name", outputType: duplicateJSONNameType(), contains: "duplicate output name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Compile(reflect.TypeFor[representativeInput](), tt.outputType) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPlan) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// duplicateJSONNameType constructs a type that intentionally violates unique JSON field naming. +func duplicateJSONNameType() reflect.Type { + stringType := reflect.TypeFor[string]() + return reflect.StructOf([]reflect.StructField{ + {Name: "First", Type: stringType, Tag: `json:"value"`}, + {Name: "Second", Type: stringType, Tag: `json:"value"`}, + }) +} diff --git a/internal/binding/signature.go b/internal/binding/signature.go new file mode 100644 index 0000000..e547fe1 --- /dev/null +++ b/internal/binding/signature.go @@ -0,0 +1,98 @@ +package binding + +import "strings" + +const unsupportedTypeSignature = "unsupported" + +// FieldShape is one model-facing field in a supported capability input or output structure. +type FieldShape struct { + // Name is the field's exact Starlark and JSON name. + Name string `json:"name"` + + // Type is the compact Starlark-facing value notation. + Type string `json:"type"` + + // Required reports whether the caller or handler must provide the field. + Required bool `json:"required"` +} + +// Signature renders the compact keyword-only model documentation from the immutable plan. +func (plan *Plan) Signature(capabilityName string) string { + var signature strings.Builder + signature.WriteString(capabilityName) + signature.WriteByte('(') + if len(plan.inputFields) > 0 { + signature.WriteString("*, ") + for index, field := range plan.inputFields { + if index > 0 { + signature.WriteString(", ") + } + signature.WriteString(field.name) + signature.WriteString(": ") + signature.WriteString(inputKindSignature(field.kind)) + } + } + signature.WriteString(") -> ") + if plan.outputType.Name() == "" { + signature.WriteString("object") + } else { + signature.WriteString(plan.outputType.Name()) + } + return signature.String() +} + +// InputShape returns a fresh model-facing description of the compiled input fields. +func (plan *Plan) InputShape() []FieldShape { + shape := make([]FieldShape, len(plan.inputFields)) + for index, field := range plan.inputFields { + shape[index] = FieldShape{ + Name: field.name, + Type: inputKindSignature(field.kind), + Required: field.required, + } + } + return shape +} + +// OutputShape returns a fresh model-facing description of the compiled output fields. +func (plan *Plan) OutputShape() []FieldShape { + shape := make([]FieldShape, len(plan.outputFields)) + for index, field := range plan.outputFields { + shape[index] = FieldShape{ + Name: field.name, + Type: outputKindSignature(field.kind), + Required: true, + } + } + return shape +} + +// inputKindSignature returns the model-facing notation for one supported input conversion. +func inputKindSignature(kind fieldKind) string { + switch kind { + case fieldString: + return "str" + case fieldOptionalInt64: + return "int | None" + case fieldInt64, fieldBool, fieldFloat64: + return unsupportedTypeSignature + } + return unsupportedTypeSignature +} + +// outputKindSignature returns the model-facing notation for one supported output conversion. +func outputKindSignature(kind fieldKind) string { + switch kind { + case fieldString: + return "str" + case fieldInt64: + return "int" + case fieldBool: + return "bool" + case fieldFloat64: + return "float" + case fieldOptionalInt64: + return unsupportedTypeSignature + } + return unsupportedTypeSignature +} diff --git a/internal/binding/signature_test.go b/internal/binding/signature_test.go new file mode 100644 index 0000000..e649424 --- /dev/null +++ b/internal/binding/signature_test.go @@ -0,0 +1,26 @@ +package binding + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSignatureUsesTheCompiledInputPlan proves documentation cannot drift from binding behavior. +func TestSignatureUsesTheCompiledInputPlan(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + signature := plan.Signature("records.lookup") + + assert.Equal(t, "records.lookup(*, org: str, limit: int | None) -> representativeOutput", signature) +} + +// TestSignatureHandlesAnEmptyInputStruct proves zero-argument capabilities omit the keyword-only marker. +func TestSignatureHandlesAnEmptyInputStruct(t *testing.T) { + plan, err := CompileFor[struct{}, representativeOutput]() + require.NoError(t, err) + + assert.Equal(t, "records.status() -> representativeOutput", plan.Signature("records.status")) +} diff --git a/internal/catalog/build.go b/internal/catalog/build.go new file mode 100644 index 0000000..d06c5d6 --- /dev/null +++ b/internal/catalog/build.go @@ -0,0 +1,171 @@ +package catalog + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/meigma/codemode/internal/binding" +) + +const minimumDottedSegments = 2 + +var ( + // ErrInvalidRegistration classifies malformed metadata, handlers, types, duplicates, or namespace collisions. + ErrInvalidRegistration = errors.New("invalid catalog registration") + + // ErrUnknownDisabledCapability classifies a disabled ID absent from the validated registration set. + ErrUnknownDisabledCapability = errors.New("unknown disabled capability") +) + +// candidate is one fully validated and compiled registration before static filtering. +type candidate struct { + // entry contains copied metadata, the compiled plan, and the handler adapter. + entry Entry +} + +// Build validates every registration, applies static filtering once, and returns an immutable catalog. +func Build(registrations []Registration, options Options) (*Catalog, error) { + if options.MaxSearchQueryBytes <= 0 { + return nil, fmt.Errorf("%w: MaxSearchQueryBytes must be positive", ErrInvalidRegistration) + } + if options.MaxSearchResults <= 0 { + return nil, fmt.Errorf("%w: MaxSearchResults must be positive", ErrInvalidRegistration) + } + + candidates := make([]candidate, 0, len(registrations)) + ids := make(map[string]struct{}, len(registrations)) + names := make(map[string]struct{}, len(registrations)) + for index, registration := range registrations { + if err := validateRegistration(registration); err != nil { + return nil, fmt.Errorf("%w: registration %d: %w", ErrInvalidRegistration, index, err) + } + if _, duplicate := ids[registration.ID]; duplicate { + return nil, fmt.Errorf("%w: duplicate ID %q", ErrInvalidRegistration, registration.ID) + } + if _, duplicate := names[registration.Name]; duplicate { + return nil, fmt.Errorf("%w: duplicate name %q", ErrInvalidRegistration, registration.Name) + } + plan, err := binding.Compile(registration.InputType, registration.OutputType) + if err != nil { + return nil, fmt.Errorf("%w: capability %q: %w", ErrInvalidRegistration, registration.Name, err) + } + ids[registration.ID] = struct{}{} + names[registration.Name] = struct{}{} + candidates = append(candidates, candidate{entry: Entry{ + ID: registration.ID, + Name: registration.Name, + Summary: registration.Summary, + Description: registration.Description, + Plan: plan, + Invoke: registration.Invoke, + signature: plan.Signature(registration.Name), + searchName: strings.ToLower(registration.Name), + searchSummary: strings.ToLower(registration.Summary), + inputShape: plan.InputShape(), + outputShape: plan.OutputShape(), + }}) + } + if err := validateNamespaceCollisions(names); err != nil { + return nil, err + } + + disabled, err := validateDisabled(options.DisabledCapabilities, ids) + if err != nil { + return nil, err + } + enabled := make([]Entry, 0, len(candidates)-len(disabled)) + for _, candidate := range candidates { + if _, isDisabled := disabled[candidate.entry.ID]; isDisabled { + continue + } + enabled = append(enabled, candidate.entry) + } + sort.Slice(enabled, func(left int, right int) bool { + return enabled[left].Name < enabled[right].Name + }) + + catalog := &Catalog{ + enabled: enabled, + byName: make(map[string]int, len(enabled)), + byID: make(map[string]int, len(enabled)), + namespaces: make([]NamespaceBinding, 0, len(enabled)), + maxSearchQueryBytes: options.MaxSearchQueryBytes, + maxSearchResults: options.MaxSearchResults, + } + for index, entry := range enabled { + catalog.byName[entry.Name] = index + catalog.byID[entry.ID] = index + segments := strings.Split(entry.Name, ".") + catalog.namespaces = append(catalog.namespaces, NamespaceBinding{ + Segments: append([]string(nil), segments[:len(segments)-1]...), + Function: segments[len(segments)-1], + Capability: entry, + }) + } + return catalog, nil +} + +// validateRegistration checks metadata and handler presence before any filtering occurs. +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") + } + if !isDottedName(registration.Name) { + return fmt.Errorf("name %q must contain valid dotted Starlark identifiers", registration.Name) + } + if registration.Summary == "" || registration.Summary != strings.TrimSpace(registration.Summary) { + return errors.New("summary must be non-empty without surrounding whitespace") + } + if registration.Description == "" || registration.Description != strings.TrimSpace(registration.Description) { + return errors.New("description must be non-empty without surrounding whitespace") + } + if registration.Invoke == nil { + return errors.New("handler must not be nil") + } + return nil +} + +// validateDisabled verifies the static disabled set against all validated registrations. +func validateDisabled(configured []string, known map[string]struct{}) (map[string]struct{}, error) { + disabled := make(map[string]struct{}, len(configured)) + for _, id := range configured { + if _, duplicate := disabled[id]; duplicate { + return nil, fmt.Errorf("%w: duplicate configured ID %q", ErrInvalidRegistration, id) + } + if _, exists := known[id]; !exists { + return nil, fmt.Errorf("%w: %q", ErrUnknownDisabledCapability, id) + } + disabled[id] = struct{}{} + } + return disabled, nil +} + +// validateNamespaceCollisions rejects a capability function that is also a namespace. +func validateNamespaceCollisions(names map[string]struct{}) error { + for name := range names { + segments := strings.Split(name, ".") + for end := 1; end < len(segments); end++ { + prefix := strings.Join(segments[:end], ".") + if _, collides := names[prefix]; collides { + return fmt.Errorf("%w: capability %q is also a namespace of %q", ErrInvalidRegistration, prefix, name) + } + } + } + return nil +} + +// isDottedName reports whether name contains at least one namespace and valid Starlark segments. +func isDottedName(name string) bool { + segments := strings.Split(name, ".") + if len(segments) < minimumDottedSegments { + return false + } + for _, segment := range segments { + if !binding.ValidIdentifier(segment) { + return false + } + } + return true +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..bfc5ee6 --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,151 @@ +package catalog + +import ( + "context" + "reflect" + "slices" + + "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/internal/binding" +) + +// Invoker is a type-erased adapter around one registered typed capability handler. +type Invoker func(context.Context, authz.Subject, any) (any, error) + +// Registration contains one capability before validation, compilation, and static filtering. +type Registration struct { + // ID is the stable deployment and policy identity. + ID string + + // Name is the dotted Starlark and model-facing capability name. + Name string + + // Summary is the compact text searched with the capability name. + Summary string + + // Description is the full exact-description text. + Description string + + // InputType is the exact typed handler input. + InputType reflect.Type + + // OutputType is the exact typed handler output. + OutputType reflect.Type + + // Invoke calls the registered typed handler through its registration-time adapter. + Invoke Invoker +} + +// Options controls one immutable catalog build. +type Options struct { + // DisabledCapabilities lists stable IDs removed from every live catalog surface. + DisabledCapabilities []string + + // MaxSearchQueryBytes bounds search input before normalization. + MaxSearchQueryBytes int + + // MaxSearchResults bounds the deterministic result list. + MaxSearchResults int +} + +// Entry is one validated, enabled capability with its compiled binding plan. +type Entry struct { + // ID is the stable deployment and policy identity. + ID string + + // Name is the dotted Starlark and model-facing capability name. + Name string + + // Summary is the compact search description. + Summary string + + // Description is the full exact-description text. + Description string + + // Plan is the immutable input, output, canonical-argument, and signature plan. + Plan *binding.Plan + + // Invoke calls the registered typed handler through its registration-time adapter. + Invoke Invoker + + // signature is generated once from the immutable binding plan. + signature string + + // searchName is the registration-time normalized capability name. + searchName string + + // searchSummary is the registration-time normalized capability summary. + searchSummary string + + // inputShape is the registration-time compiled model-facing input shape. + inputShape []binding.FieldShape + + // outputShape is the registration-time compiled model-facing output shape. + outputShape []binding.FieldShape +} + +// NamespaceBinding maps one enabled capability to its immutable namespace path. +type NamespaceBinding struct { + // Segments contains the namespace path before the final function name. + Segments []string + + // Function is the final Starlark function segment. + Function string + + // Capability is the enabled catalog entry represented by this function. + Capability Entry +} + +// Catalog is an immutable, statically filtered capability catalog. +type Catalog struct { + // enabled is the single name-sorted source for every live surface. + enabled []Entry + + // byName maps exact enabled names to indexes in enabled. + byName map[string]int + + // byID maps exact enabled stable IDs to indexes in enabled. + byID map[string]int + + // namespaces is derived only from enabled and remains name-sorted. + namespaces []NamespaceBinding + + // maxSearchQueryBytes is the positive search input budget. + maxSearchQueryBytes int + + // maxSearchResults is the positive search output budget. + maxSearchResults int +} + +// Entries returns a copy of the name-sorted enabled entries. +func (catalog *Catalog) Entries() []Entry { + return slices.Clone(catalog.enabled) +} + +// Lookup returns one enabled capability by exact model-facing name. +func (catalog *Catalog) Lookup(name string) (Entry, bool) { + index, ok := catalog.byName[name] + if !ok { + return Entry{}, false + } + return catalog.enabled[index], true +} + +// LookupID returns one enabled capability by exact stable ID. +func (catalog *Catalog) LookupID(id string) (Entry, bool) { + index, ok := catalog.byID[id] + if !ok { + return Entry{}, false + } + return catalog.enabled[index], true +} + +// NamespaceBindings returns deep copies of namespace data derived from enabled entries. +func (catalog *Catalog) NamespaceBindings() []NamespaceBinding { + bindings := make([]NamespaceBinding, len(catalog.namespaces)) + for index, namespace := range catalog.namespaces { + bindings[index] = namespace + bindings[index].Segments = slices.Clone(namespace.Segments) + } + return bindings +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 0000000..7b78afb --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -0,0 +1,323 @@ +package catalog + +import ( + "context" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/authz" +) + +// testInput is the catalog test capability input. +type testInput struct { + // Org is one required query value. + Org string `json:"org"` + + // Limit is one optional bound. + Limit *int64 `json:"limit,omitempty"` +} + +// testOutput is the catalog test capability output. +type testOutput struct { + // Name is one result value. + Name string `json:"name"` +} + +// TestBuildValidatesEveryRegistrationBeforeFiltering proves disabled entries cannot hide invalid contracts. +func TestBuildValidatesEveryRegistrationBeforeFiltering(t *testing.T) { + registration := validRegistration("cap.bad", "records.bad", "Bad record") + registration.InputType = reflect.TypeFor[int]() + + _, err := Build([]Registration{registration}, testOptions("cap.bad")) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidRegistration) + assert.Contains(t, err.Error(), "binding plan") +} + +// TestBuildRejectsInvalidMetadataDuplicatesAndNamespaceCollisions proves initialization fails closed. +func TestBuildRejectsInvalidMetadataDuplicatesAndNamespaceCollisions(t *testing.T) { + valid := validRegistration("cap.one", "records.one", "First record") + tests := []struct { + // name identifies the invalid catalog. + name string + + // registrations contains the candidate capabilities. + registrations []Registration + + // options configures filtering and search. + options Options + + // target is the expected classified error. + target error + }{ + { + name: "nil handler", + registrations: []Registration{withoutHandler(valid)}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "blank ID", + registrations: []Registration{withID(valid, "")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "undotted name", + registrations: []Registration{withName(valid, "records")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "invalid name", + registrations: []Registration{withName(valid, "records.not-valid")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "reserved name segment", + registrations: []Registration{withName(valid, "records.import")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "non-ASCII digit", + registrations: []Registration{withName(valid, "records.a١")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "blank summary", + registrations: []Registration{withSummary(valid, "")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + { + name: "blank description", + registrations: []Registration{withDescription(valid, "")}, + options: testOptions(), + target: ErrInvalidRegistration, + }, + {name: "duplicate ID", registrations: []Registration{ + valid, + validRegistration("cap.one", "records.two", "Second record"), + }, options: testOptions(), target: ErrInvalidRegistration}, + {name: "duplicate name", registrations: []Registration{ + valid, + validRegistration("cap.two", "records.one", "Second record"), + }, options: testOptions(), target: ErrInvalidRegistration}, + {name: "namespace collision", registrations: []Registration{ + validRegistration("cap.lookup", "records.lookup", "Lookup records"), + validRegistration("cap.detail", "records.lookup.detail", "Lookup detail"), + }, options: testOptions(), target: ErrInvalidRegistration}, + { + name: "unknown disabled ID", + registrations: []Registration{valid}, + options: testOptions("cap.unknown"), + target: ErrUnknownDisabledCapability, + }, + { + name: "duplicate disabled ID", + registrations: []Registration{valid}, + options: testOptions("cap.one", "cap.one"), + target: ErrInvalidRegistration, + }, + { + name: "invalid query limit", + registrations: []Registration{valid}, + options: Options{MaxSearchResults: 1}, + target: ErrInvalidRegistration, + }, + { + name: "invalid result limit", + registrations: []Registration{valid}, + options: Options{MaxSearchQueryBytes: 10}, + target: ErrInvalidRegistration, + }, + } + + 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, tt.target) + }) + } +} + +// TestBuildFiltersOnceAndDerivesEverySurface proves disabled capabilities have no live representation. +func TestBuildFiltersOnceAndDerivesEverySurface(t *testing.T) { + catalog, err := Build([]Registration{ + validRegistration("cap.zeta", "teams.zeta", "Zeta team"), + validRegistration("cap.disabled", "records.disabled", "Disabled record"), + validRegistration("cap.alpha", "records.alpha", "Alpha record"), + }, testOptions("cap.disabled")) + require.NoError(t, err) + + entries := catalog.Entries() + require.Len(t, entries, 2) + assert.Equal(t, []string{"records.alpha", "teams.zeta"}, []string{entries[0].Name, entries[1].Name}) + _, foundByName := catalog.Lookup("records.disabled") + _, foundByID := catalog.LookupID("cap.disabled") + _, foundDisabledDescription := catalog.Describe("records.disabled") + disabledSearch, err := catalog.Search("disabled") + require.NoError(t, err) + assert.False(t, foundByName) + assert.False(t, foundByID) + assert.False(t, foundDisabledDescription) + assert.Empty(t, disabledSearch) + + bindings := catalog.NamespaceBindings() + require.Len(t, bindings, 2) + assert.Equal(t, []string{"records"}, bindings[0].Segments) + assert.Equal(t, "alpha", bindings[0].Function) + assert.Same(t, entries[0].Plan, bindings[0].Capability.Plan) + + description, foundDescription := catalog.Describe("records.alpha") + require.True(t, foundDescription) + assert.Equal(t, "Alpha record", description.Summary) + assert.Equal(t, "Alpha record full description.", description.Description) + assert.Equal(t, "records.alpha(*, org: str, limit: int | None) -> testOutput", description.Signature) + require.Len(t, description.Input, 2) + assert.Equal(t, "org", description.Input[0].Name) + assert.Equal(t, "str", description.Input[0].Type) + assert.True(t, description.Input[0].Required) + assert.Equal(t, "limit", description.Input[1].Name) + assert.Equal(t, "int | None", description.Input[1].Type) + assert.False(t, description.Input[1].Required) + require.Len(t, description.Output, 1) + assert.Equal(t, "name", description.Output[0].Name) + assert.Equal(t, "str", description.Output[0].Type) + assert.True(t, description.Output[0].Required) + description.Input[0].Name = "mutated" + freshDescription, foundDescription := catalog.Describe("records.alpha") + require.True(t, foundDescription) + assert.Equal(t, "org", freshDescription.Input[0].Name) + + entries[0].Name = "mutated" + bindings[0].Segments[0] = "mutated" + assert.Equal(t, "records.alpha", catalog.Entries()[0].Name) + assert.Equal(t, []string{"records"}, catalog.NamespaceBindings()[0].Segments) +} + +// TestSearchIsSortedNormalizedAndBounded proves deterministic discovery from the filtered catalog. +func TestSearchIsSortedNormalizedAndBounded(t *testing.T) { + options := testOptions() + options.MaxSearchResults = 2 + catalog, err := Build([]Registration{ + validRegistration("cap.zeta", "records.zeta", "Alpha Zeta record"), + validRegistration("cap.beta", "records.beta", "Alpha Beta record"), + validRegistration("cap.alpha", "records.alpha", "Alpha primary record"), + }, options) + require.NoError(t, err) + + results, err := catalog.Search(" ALPHA ") + + require.NoError(t, err) + require.Len(t, results, 2) + assert.Equal(t, []string{"records.alpha", "records.beta"}, []string{results[0].Name, results[1].Name}) + assert.Equal(t, "records.alpha(*, org: str, limit: int | None) -> testOutput", results[0].Signature) + + nonContiguous, err := catalog.Search("alpha record") + require.NoError(t, err) + assert.Empty(t, nonContiguous) + + crossBoundary, err := catalog.Search("alpha alpha") + require.NoError(t, err) + assert.Empty(t, crossBoundary) + + empty, err := catalog.Search(" ") + require.NoError(t, err) + assert.Empty(t, empty) + + _, err = catalog.Search("query exceeding the configured byte budget") + require.Error(t, err) + require.ErrorIs(t, err, ErrSearchQueryLimit) +} + +// TestCatalogSupportsConcurrentReadOnlyUse proves cloned views and immutable indexes are race-safe. +func TestCatalogSupportsConcurrentReadOnlyUse(t *testing.T) { + catalog, err := Build([]Registration{ + validRegistration("cap.alpha", "records.alpha", "Alpha record"), + validRegistration("cap.beta", "records.beta", "Beta record"), + }, testOptions()) + require.NoError(t, err) + + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + for range 100 { + entries := catalog.Entries() + entries[0].Name = "local mutation" + _, _ = catalog.Lookup("records.alpha") + _, _ = catalog.LookupID("cap.beta") + _, _ = catalog.Search("record") + bindings := catalog.NamespaceBindings() + bindings[0].Segments[0] = "local mutation" + } + }) + } + workers.Wait() + + assert.Equal(t, "records.alpha", catalog.Entries()[0].Name) +} + +// validRegistration constructs one valid test registration. +func validRegistration(id string, name string, summary string) Registration { + return Registration{ + ID: id, + Name: name, + Summary: summary, + Description: summary + " full description.", + InputType: reflect.TypeFor[testInput](), + OutputType: reflect.TypeFor[testOutput](), + Invoke: func(context.Context, authz.Subject, any) (any, error) { + return testOutput{Name: name}, nil + }, + } +} + +// testOptions constructs valid bounded catalog options with optional disabled IDs. +func testOptions(disabled ...string) Options { + return Options{ + DisabledCapabilities: disabled, + MaxSearchQueryBytes: 32, + MaxSearchResults: 10, + } +} + +// withoutHandler returns a copy without a handler. +func withoutHandler(registration Registration) Registration { + registration.Invoke = nil + return registration +} + +// withID returns a copy with the selected stable ID. +func withID(registration Registration, id string) Registration { + registration.ID = id + return registration +} + +// withName returns a copy with the selected capability name. +func withName(registration Registration, name string) Registration { + registration.Name = name + return registration +} + +// withSummary returns a copy with the selected summary. +func withSummary(registration Registration, summary string) Registration { + registration.Summary = summary + return registration +} + +// withDescription returns a copy with the selected description. +func withDescription(registration Registration, description string) Registration { + registration.Description = description + return registration +} diff --git a/internal/catalog/doc.go b/internal/catalog/doc.go new file mode 100644 index 0000000..b163c3d --- /dev/null +++ b/internal/catalog/doc.go @@ -0,0 +1,2 @@ +// Package catalog validates, filters, and indexes immutable native capability registrations. +package catalog diff --git a/internal/catalog/search.go b/internal/catalog/search.go new file mode 100644 index 0000000..f12476a --- /dev/null +++ b/internal/catalog/search.go @@ -0,0 +1,97 @@ +package catalog + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/meigma/codemode/internal/binding" +) + +var ( + // ErrSearchQueryLimit classifies a search query that exceeds its byte budget. + ErrSearchQueryLimit = errors.New("search query limit exceeded") +) + +// SearchResult is one compact model-facing capability discovery record. +type SearchResult struct { + // Name is the enabled capability's exact dotted name. + Name string `json:"name"` + + // Signature is generated from the same compiled plan used for binding. + Signature string `json:"signature"` + + // Summary is the compact searchable description. + Summary string `json:"summary"` +} + +// Description is one exact model-facing capability description. +type Description struct { + // Name is the enabled capability's exact dotted name. + Name string `json:"name"` + + // Signature is generated from the same compiled plan used for binding. + Signature string `json:"signature"` + + // Summary is the compact searchable description. + Summary string `json:"summary"` + + // Description is the full registered description. + Description string `json:"description"` + + // Input is the ordered supported input field shape. + Input []binding.FieldShape `json:"input"` + + // Output is the ordered supported output field shape. + Output []binding.FieldShape `json:"output"` +} + +// Search performs a deterministic case-normalized linear scan over enabled names and summaries. +func (catalog *Catalog) Search(query string) ([]SearchResult, error) { + if len(query) > catalog.maxSearchQueryBytes { + return nil, fmt.Errorf( + "%w: query is %d bytes; maximum is %d", + ErrSearchQueryLimit, + len(query), + catalog.maxSearchQueryBytes, + ) + } + normalized := strings.ToLower(strings.TrimSpace(query)) + if normalized == "" { + return []SearchResult{}, nil + } + + results := make([]SearchResult, 0, min(catalog.maxSearchResults, len(catalog.enabled))) + for _, entry := range catalog.enabled { + if !strings.Contains(entry.searchName, normalized) && + !strings.Contains(entry.searchSummary, normalized) { + continue + } + results = append(results, SearchResult{ + Name: entry.Name, + Signature: entry.signature, + Summary: entry.Summary, + }) + if len(results) == catalog.maxSearchResults { + break + } + } + return results, nil +} + +// Describe returns the exact description of one enabled capability without fuzzy expansion. +func (catalog *Catalog) Describe(name string) (Description, bool) { + entry, ok := catalog.Lookup(name) + if !ok { + return Description{}, false + } + return Description{ + Name: entry.Name, + Signature: entry.signature, + Summary: entry.Summary, + Description: entry.Description, + Input: slices.Clone(entry.inputShape), + Output: slices.Clone(entry.outputShape), + }, true +} diff --git a/limits.go b/limits.go new file mode 100644 index 0000000..8eff162 --- /dev/null +++ b/limits.go @@ -0,0 +1,82 @@ +package codemode + +import ( + "fmt" + "time" +) + +const ( + defaultMaxSourceBytes = 64 * 1024 + defaultMaxExecutionSteps = 1_000_000 + defaultMaxExecutionTime = 5 * time.Second + defaultMaxNativeCalls = 100 + defaultMaxValueDepth = 32 + defaultMaxResultBytes = 1024 * 1024 + defaultMaxSearchQueryBytes = 256 + defaultMaxSearchResults = 20 +) + +// Limits bounds one execution and the model-facing catalog search surface. +type Limits struct { + // MaxSourceBytes is the maximum accepted Starlark source size in bytes. + MaxSourceBytes int + + // MaxExecutionSteps is the maximum number of Starlark bytecode steps. + MaxExecutionSteps uint64 + + // MaxExecutionTime is the maximum elapsed duration of one execution. + MaxExecutionTime time.Duration + + // MaxNativeCalls is the maximum number of attempted native capability calls. + MaxNativeCalls uint64 + + // MaxValueDepth is the maximum converted-value nesting depth. + MaxValueDepth int + + // MaxResultBytes is the maximum encoded final-result size in bytes. + MaxResultBytes int + + // MaxSearchQueryBytes is the maximum capability-search query size in bytes. + MaxSearchQueryBytes int + + // MaxSearchResults is the maximum number of capability-search results. + MaxSearchResults int +} + +// DefaultLimits returns positive development defaults for every supported budget. +func DefaultLimits() Limits { + return Limits{ + MaxSourceBytes: defaultMaxSourceBytes, + MaxExecutionSteps: defaultMaxExecutionSteps, + MaxExecutionTime: defaultMaxExecutionTime, + MaxNativeCalls: defaultMaxNativeCalls, + MaxValueDepth: defaultMaxValueDepth, + MaxResultBytes: defaultMaxResultBytes, + MaxSearchQueryBytes: defaultMaxSearchQueryBytes, + MaxSearchResults: defaultMaxSearchResults, + } +} + +// Validate rejects zero and negative limits; zero never means unlimited. +func (limits Limits) Validate() error { + switch { + case limits.MaxSourceBytes <= 0: + return fmt.Errorf("%w: MaxSourceBytes must be positive", ErrInvalidRegistration) + case limits.MaxExecutionSteps == 0: + return fmt.Errorf("%w: MaxExecutionSteps must be positive", ErrInvalidRegistration) + case limits.MaxExecutionTime <= 0: + return fmt.Errorf("%w: MaxExecutionTime must be positive", ErrInvalidRegistration) + case limits.MaxNativeCalls == 0: + return fmt.Errorf("%w: MaxNativeCalls must be positive", ErrInvalidRegistration) + case limits.MaxValueDepth <= 0: + return fmt.Errorf("%w: MaxValueDepth must be positive", ErrInvalidRegistration) + case limits.MaxResultBytes <= 0: + return fmt.Errorf("%w: MaxResultBytes must be positive", ErrInvalidRegistration) + case limits.MaxSearchQueryBytes <= 0: + return fmt.Errorf("%w: MaxSearchQueryBytes must be positive", ErrInvalidRegistration) + case limits.MaxSearchResults <= 0: + return fmt.Errorf("%w: MaxSearchResults must be positive", ErrInvalidRegistration) + default: + return nil + } +} diff --git a/limits_test.go b/limits_test.go new file mode 100644 index 0000000..881dd56 --- /dev/null +++ b/limits_test.go @@ -0,0 +1,82 @@ +package codemode_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode" +) + +// TestDefaultLimitsAreValid proves every default budget is explicitly positive. +func TestDefaultLimitsAreValid(t *testing.T) { + require.NoError(t, codemode.DefaultLimits().Validate()) +} + +// TestLimitsRejectNonPositiveValues proves zero never selects an unlimited budget. +func TestLimitsRejectNonPositiveValues(t *testing.T) { + tests := []struct { + // name identifies the rejected limit. + name string + + // mutate makes one limit invalid. + mutate func(*codemode.Limits) + + // field is the expected diagnostic field name. + field string + }{ + { + name: "source bytes", + mutate: func(limits *codemode.Limits) { limits.MaxSourceBytes = 0 }, + field: "MaxSourceBytes", + }, + { + name: "execution steps", + mutate: func(limits *codemode.Limits) { limits.MaxExecutionSteps = 0 }, + field: "MaxExecutionSteps", + }, + { + name: "execution time", + mutate: func(limits *codemode.Limits) { limits.MaxExecutionTime = 0 }, + field: "MaxExecutionTime", + }, + { + name: "native calls", + mutate: func(limits *codemode.Limits) { limits.MaxNativeCalls = 0 }, + field: "MaxNativeCalls", + }, + { + name: "value depth", + mutate: func(limits *codemode.Limits) { limits.MaxValueDepth = 0 }, + field: "MaxValueDepth", + }, + { + name: "result bytes", + mutate: func(limits *codemode.Limits) { limits.MaxResultBytes = 0 }, + field: "MaxResultBytes", + }, + { + name: "search query bytes", + mutate: func(limits *codemode.Limits) { limits.MaxSearchQueryBytes = 0 }, + field: "MaxSearchQueryBytes", + }, + { + name: "search results", + mutate: func(limits *codemode.Limits) { limits.MaxSearchResults = 0 }, + field: "MaxSearchResults", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limits := codemode.DefaultLimits() + tt.mutate(&limits) + + err := limits.Validate() + require.Error(t, err) + require.ErrorIs(t, err, codemode.ErrInvalidRegistration) + assert.Contains(t, err.Error(), tt.field) + }) + } +}