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) + }) + } +}