Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func New(options Options) *Builder {
// binding contract first.
//
// Capability-specific failures are accumulated and returned together by Build.
// A name whose first dotted segment collides with a reserved Starlark universe
// root, including standard builtins, sum, json, and math, is recorded as an
// invalid registration; nested leaves such as stats.sum remain legal.
// Register panics when builder is nil or already closed because no future Build
// call can report those lifecycle violations.
func Register[Input, Output any](builder *Builder, capability Capability[Input, Output]) {
Expand Down
60 changes: 60 additions & 0 deletions builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,66 @@ func TestBuildRejectsWholeCatalogFailures(t *testing.T) {
})
}

// TestBuildRejectsReservedUniverseRoots proves colliding namespace roots fail at Register and Build.
func TestBuildRejectsReservedUniverseRoots(t *testing.T) {
tests := []struct {
// name identifies the reserved-root collision.
name string

// id is the stable capability identity.
id codemode.CapabilityID

// capName is the colliding dotted capability name.
capName codemode.CapabilityName

// disabled lists static filters that must not bypass validation.
disabled []codemode.CapabilityID

// root is the reserved first dotted segment named in the diagnostic.
root string
}{
{name: "sum root", id: "cap.sum", capName: "sum.x", root: "sum"},
{name: "json root", id: "cap.json", capName: "json.fetch", root: "json"},
{name: "math root", id: "cap.math", capName: "math.add", root: "math"},
{name: "len root", id: "cap.len", capName: "len.items", root: "len"},
{name: "set root", id: "cap.set", capName: "set.members", root: "set"},
{
name: "disabled json root still rejected",
id: "cap.json",
capName: "json.fetch",
disabled: []codemode.CapabilityID{"cap.json"},
root: "json",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := codemode.New(codemode.Options{
Authorizer: authz.AllowAll(),
DisabledCapabilities: tt.disabled,
})
codemode.Register(builder, validBuilderCapability(tt.id, tt.capName))

server, err := builder.Build()

require.ErrorIs(t, err, codemode.ErrInvalidRegistration)
assert.Nil(t, server)
require.ErrorContains(t, err, `capability "`+string(tt.capName)+`"`)
require.ErrorContains(t, err, `reserved root "`+tt.root+`"`)
})
}

t.Run("nested leaf remains legal", func(t *testing.T) {
builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()})
codemode.Register(builder, validBuilderCapability("cap.stats", "stats.sum"))

server, err := builder.Build()

require.NoError(t, err)
assert.NotNil(t, server)
})
}

// TestBuilderBuildsWorkerProbe proves Build completes the real same-binary handshake.
func TestBuilderBuildsWorkerProbe(t *testing.T) {
builder := codemode.New(codemode.Options{
Expand Down
1 change: 1 addition & 0 deletions capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Capability[Input, Output any] struct {
ID CapabilityID

// Name is the dotted Starlark name exposed to programs and discovery.
// The first segment must not collide with a reserved Starlark universe root.
Name CapabilityName

// Summary is a compact description used by capability search.
Expand Down
5 changes: 3 additions & 2 deletions docs/docs/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ Every `Server.Execute` call starts a fresh worker process by re-executing the
host binary. The worker process is a child of the host process. The worker
receives only the immutable enabled-capability manifest, positive execution
limits, and one submitted program through CodeMode's private protocol. It
constructs a fresh Starlark interpreter. Module loading is disabled, and the
only predeclared application functions are the enabled capability namespace.
constructs a fresh Starlark interpreter. Module loading is disabled. The
predeclared environment is the fixed language surface (`sum`, `json`, `math`,
and the standard Starlark builtins) plus the enabled capability namespace.
Native calls are rejected during top-level source loading and are accepted
only while the required zero-argument `main()` function runs.

Expand Down
26 changes: 20 additions & 6 deletions docs/docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,19 @@ There is no compatibility suffix, alias, or alternate signature field.

## `execute`

Execute one Starlark program that defines `def main():` with zero arguments, calls only names confirmed through `search_api` and `describe_api` inside `main`, and returns `main`'s final result. Starlark is not Python: `sum`, `import`, `while`, and f-strings are unavailable; `load` is disabled; `print` is discarded.
Execute one Starlark program that defines `def main():` with zero arguments, calls only names confirmed through `search_api` and `describe_api` inside `main`, and returns `main`'s final result. Standard Starlark builtins plus `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import. `import`, `while`, f-strings, `filter`, and `map` are unavailable; `load` is disabled; `print` is discarded.

### Language surface

The following block is the exact fixed language surface. These names are
directly available without `import` or `load`. `set` is reserved as a
capability root but is not available. There is no built-in `time` module.

```language-surface
top-level: False, None, True, abs, all, any, bool, bytes, chr, dict, dir, enumerate, fail, float, getattr, hasattr, hash, int, json, len, list, math, max, min, ord, print, range, repr, reversed, sorted, str, sum, tuple, type, zip
json: decode, encode, indent
math: acos, acosh, asin, asinh, atan, atan2, atanh, ceil, copysign, cos, cosh, degrees, e, exp, fabs, floor, gamma, hypot, log, mod, pi, pow, radians, remainder, round, sin, sinh, sqrt, tan, tanh
```

### Input

Expand All @@ -342,9 +354,9 @@ Execute one Starlark program that defines `def main():` with zero arguments, cal
}
```

The source must define `def main():` as a function with zero arguments. Starlark is not Python: `sum`, `import`, `while`, and f-strings are unavailable. Top-level source loading cannot make native calls; calls are accepted only while `main` runs, and only for names confirmed through `search_api` and `describe_api`. Module loading is disabled. `print` output is discarded.
The source must define `def main():` as a function with zero arguments. Standard Starlark builtins plus `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import. `import`, `while`, f-strings, `filter`, and `map` are unavailable; use comprehensions instead of `filter` and `map`. Top-level source loading cannot make native calls; calls are accepted only while `main` runs, and only for names confirmed through `search_api` and `describe_api`. Module loading is disabled. `print` output is discarded.

Capabilities are available by dotted name. The sample native call is `records.lookup(key="alpha", limit=2)`. Native calls accept keyword arguments only. Duplicate keyword syntax is rejected by the Starlark parser as `invalid program` before authorization or handler dispatch. Positional, unknown, missing, incorrectly typed, and out-of-range arguments reach binding and map to `invalid capability arguments`. For the sample, `key` is required and `limit` can be omitted, `None`, or an integer in the signed 64-bit range.
Capabilities are available by dotted name. The sample native call is `records.lookup(key="alpha", limit=2)`. Native calls accept keyword arguments only. Duplicate keyword syntax is rejected by the Starlark parser as `invalid program` before authorization or handler dispatch. Positional, unknown, missing, incorrectly typed, and out-of-range arguments reach binding and map to `invalid capability arguments`. For the sample, `key` is required and `limit` can be omitted, `None`, or an integer in the signed 64-bit range. The first dotted segment of a capability name must not be a reserved root (any standard Starlark universe name, plus `sum`, `json`, and `math`); nested leaves such as `stats.sum` remain legal. There is no built-in `time` module; a host-defined `time.*` capability remains legal.

For example, a capability described with the signature
`records.search(*, count: int, active: bool, score: float, label: str | None)`
Expand All @@ -366,9 +378,11 @@ def main():
return {"count": count, "score": total, "ids": ids}
```

Each `execute` call gets a fresh interpreter and fresh source, step,
Each `execute` call gets a fresh interpreter and fresh source, bytecode-step,
elapsed-time, native-call, conversion-depth, per-value size, and aggregate
intermediate-value budgets. There is no interpreter or aggregate accounting
intermediate-value budgets. `MaxExecutionSteps` counts Starlark bytecode steps
and does not claim that Go builtin internals consume steps. `MaxExecutionTime`
bounds elapsed worker execution. There is no interpreter or aggregate accounting
shared between calls.

### Successful structured output
Expand Down Expand Up @@ -418,7 +432,7 @@ The listed descriptions above are the model-facing contract. Recovery uses the n
- After `capability not found`, search again and pass `describe_api` an exact returned `name`, without whitespace or case changes.
- After `invalid capability arguments`, use any suffix after the stable prefix to identify the rejected argument, then compare the call with the published `signature` and `input` field shapes.
- After `invalid program`, use any suffix after the stable prefix. A parse or resolve suffix includes a `<codemode>:line:col:` position in the submitted source. Check the program against these requirements:
- Write Starlark, not Python: `sum`, `import`, `while`, and f-strings are unavailable.
- Write Starlark, not Python: `import`, `while`, f-strings, `filter`, and `map` are unavailable; `sum(iterable)`, `json.decode/encode/indent`, and `math.*` are directly available without import.
- Define `main` with zero arguments.
- Call only names confirmed through `search_api` and `describe_api`, and call them only inside `main`.
- Return the final value from `main`.
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/reference/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ to perform that setup; `IsWorker` does not serve worker mode.

### Capability registration

`CapabilityID` is the stable deployment and policy identity of a capability. `CapabilityName` is the dotted name visible in discovery and Starlark. A name has at least two valid Starlark identifier segments, such as `records.lookup`.
`CapabilityID` is the stable deployment and policy identity of a capability. `CapabilityName` is the dotted name visible in discovery and Starlark. A name has at least two valid Starlark identifier segments, such as `records.lookup`. The first dotted segment must not be a reserved root: any standard Starlark universe name, plus `sum`, `json`, and `math`. Nested segments and leaf functions are unaffected, so `stats.sum` remains legal. Roots that were previously legal capability namespaces, including `list`, `str`, `type`, `print`, `range`, `min`, and `max`, are now rejected. A colliding registration is recorded by `Register` and returned by `Build` as `ErrInvalidRegistration` with a host-side diagnostic. There is no built-in `time` module; a host-defined `time.*` capability remains legal.

`Capability[Input, Output]` contains:

Expand Down Expand Up @@ -444,7 +444,7 @@ Execute(ctx context.Context, subject authz.Subject, program Program) (any, error

`Program` is Starlark source. The context must be non-nil and the subject ID must be non-empty. A nil context is a caller-contract violation and is currently classified as `ErrInternal`.

Every call creates a fresh interpreter and fresh budgets. Module loading is disabled. The enabled capability names form the predeclared namespace. Top-level source loading must define a function named `main` that accepts no positional parameters, keyword-only parameters, variadic positional parameters, or variadic keyword parameters. Native calls are accepted only while `main` is running.
Every call creates a fresh interpreter and fresh budgets. Module loading is disabled. The predeclared environment is the fixed language surface (`sum`, `json`, `math`, and the standard Starlark builtins) plus the enabled capability namespace. Top-level source loading must define a function named `main` that accepts no positional parameters, keyword-only parameters, variadic positional parameters, or variadic keyword parameters. Native calls are accepted only while `main` is running. There is no built-in `time` module; a host-defined `time.*` capability remains legal.

For each native call, CodeMode performs these operations in order:

Expand Down
7 changes: 7 additions & 0 deletions internal/catalog/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/meigma/codemode/internal/binding"
"github.com/meigma/codemode/internal/universe"
)

const minimumDottedSegments = 2
Expand Down Expand Up @@ -160,13 +161,19 @@ func indexCatalog(catalog *Catalog) {
}

// ValidateRegistration reports whether one copied registration has valid metadata, a compiled plan, and a handler.
//
// The first dotted name segment must not collide with a reserved Starlark universe root.
func ValidateRegistration(registration Registration) error {
if registration.ID == "" || registration.ID != strings.TrimSpace(registration.ID) {
return errors.New("ID must be non-empty without surrounding whitespace")
}
if !isDottedName(registration.Name) {
return fmt.Errorf("name %q must contain valid dotted Starlark identifiers", registration.Name)
}
root, _, _ := strings.Cut(registration.Name, ".")
if universe.IsReservedRoot(root) {
return fmt.Errorf("name %q uses reserved root %q", registration.Name, root)
}
if registration.Summary == "" || registration.Summary != strings.TrimSpace(registration.Summary) {
return errors.New("summary must be non-empty without surrounding whitespace")
}
Expand Down
1 change: 1 addition & 0 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Registration struct {
ID string

// Name is the dotted Starlark and model-facing capability name.
// The first segment must not collide with a reserved Starlark universe root.
Name string

// Summary is the compact description used by capability search.
Expand Down
72 changes: 72 additions & 0 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,78 @@ func TestBuildRejectsInvalidMetadataDuplicatesAndNamespaceCollisions(t *testing.
}
}

// TestBuildRejectsReservedUniverseRoots proves colliding namespace roots fail closed.
func TestBuildRejectsReservedUniverseRoots(t *testing.T) {
valid := validRegistration("cap.one", "records.one", "First record")
tests := []struct {
// name identifies the reserved-root collision.
name string

// registrations contains the candidate capabilities.
registrations []Registration

// options configures filtering and search.
options Options

// root is the reserved first dotted segment named in the diagnostic.
root string
}{
{
name: "sum root",
registrations: []Registration{withName(valid, "sum.x")},
options: testOptions(),
root: "sum",
},
{
name: "json root",
registrations: []Registration{withName(valid, "json.fetch")},
options: testOptions(),
root: "json",
},
{
name: "math root",
registrations: []Registration{withName(valid, "math.add")},
options: testOptions(),
root: "math",
},
{
name: "len root",
registrations: []Registration{withName(valid, "len.items")},
options: testOptions(),
root: "len",
},
{
name: "set root",
registrations: []Registration{withName(valid, "set.members")},
options: testOptions(),
root: "set",
},
{
name: "disabled json root still rejected",
registrations: []Registration{withID(withName(valid, "json.fetch"), "cap.json")},
options: testOptions("cap.json"),
root: "json",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Build(tt.registrations, tt.options)

require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidRegistration)
require.ErrorContains(t, err, `name "`+tt.registrations[0].Name+`" uses reserved root "`+tt.root+`"`)
})
}

catalog, err := Build([]Registration{
validRegistration("cap.stats", "stats.sum", "Nested sum"),
}, testOptions())
require.NoError(t, err)
_, found := catalog.Lookup("stats.sum")
assert.True(t, found)
}

// TestBuildFiltersOnceAndDerivesEverySurface proves disabled capabilities have no live representation.
func TestBuildFiltersOnceAndDerivesEverySurface(t *testing.T) {
catalog, err := Build([]Registration{
Expand Down
6 changes: 3 additions & 3 deletions internal/execution/detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ func TestExecuteAttachesApprovedProgramDiagnostics(t *testing.T) {
contains: "<codemode>:",
},
{
name: "undefined sum",
source: "def main():\n return sum([1, 2])\n",
contains: "undefined: sum",
name: "undefined filter",
source: "def main():\n return filter([1, 2])\n",
contains: "undefined: filter",
},
}

Expand Down
6 changes: 3 additions & 3 deletions internal/execution/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package execution runs one restricted, bounded Starlark program at a time.
//
// Engine is compiled from process-neutral capability bindings. Each Execute
// call receives a request-specific NativeCall and converts only main's final
// value.
// Engine is compiled from the fixed language surface merged with
// process-neutral capability bindings. Each Execute call receives a
// request-specific NativeCall and converts only main's final value.
package execution
13 changes: 9 additions & 4 deletions internal/execution/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"go.starlark.net/starlark"

"github.com/meigma/codemode/internal/binding"
"github.com/meigma/codemode/internal/universe"
)

const minimumDottedSegments = 2
Expand All @@ -28,13 +29,13 @@ type CapabilityBinding struct {
// It returns one normalized process-neutral value or a classified error.
type NativeCall func(id string, arguments map[string]any) (any, error)

// Engine owns the frozen capability namespace shared by fresh execution threads.
// Engine owns the frozen language surface and capability namespace shared by fresh execution threads.
type Engine struct {
// predeclared contains immutable namespaced capability builtins.
// predeclared contains the frozen fixed language surface merged with capability namespaces.
predeclared starlark.StringDict
}

// New compiles one immutable execution engine from process-neutral capability bindings.
// New compiles one immutable execution engine from the fixed language surface and capability bindings.
func New(bindings []CapabilityBinding) (*Engine, error) {
copied := copyBindings(bindings)
if err := validateBindings(copied); err != nil {
Expand Down Expand Up @@ -62,7 +63,7 @@ func copyBindings(bindings []CapabilityBinding) []CapabilityBinding {
return copied
}

// validateBindings rejects empty or colliding identities and invalid input shapes.
// validateBindings rejects empty or colliding identities, reserved roots, and invalid input shapes.
func validateBindings(bindings []CapabilityBinding) error {
ids := make(map[string]struct{}, len(bindings))
names := make(map[string]struct{}, len(bindings))
Expand All @@ -76,6 +77,10 @@ func validateBindings(bindings []CapabilityBinding) error {
if !isDottedName(capability.Name) {
return fmt.Errorf("%w: capability name %q is not dotted", ErrInternal, capability.Name)
}
root, _, _ := strings.Cut(capability.Name, ".")
if universe.IsReservedRoot(root) {
return fmt.Errorf("%w: capability root %q is reserved", ErrInternal, root)
}
if _, duplicate := names[capability.Name]; duplicate {
return fmt.Errorf("%w: duplicate namespace function", ErrInternal)
}
Expand Down
Loading