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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ func main() {

builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()})
codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{
Name: "records.lookup",
Summary: "Look up one record by key.",
Handler: lookup,
Name: "records.lookup",
Summary: "Look up one record by key.",
SearchTerms: []string{"fetch entry"},
Handler: lookup,
})

server, err := builder.Build()
Expand All @@ -50,6 +51,11 @@ func main() {
}
```

`SearchTerms` adds task and resource vocabulary for discovery only. Search
terms are not returned or callable, but callers can infer them by probing.
Never put secrets, credentials, policy facts, tenant identifiers, or sensitive
examples in search terms.

The repository also contains shorter, compile-checked examples:

- [`example_test.go`](example_test.go) — typed registration with default limits and an explicit subject, plus direct execution
Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ attempted native calls, concurrent workers, and the depth and encoded
size of every value that crosses the worker boundary.
`MaxIntermediateValueBytes` is the cumulative encoded size of successful
parent-to-child native-result value bodies per execution, independent of
the per-value `MaxValueBytes` bound. Search query bytes and
search result counts are bounded separately in the parent. An elapsed deadline
the per-value `MaxValueBytes` bound. Search query bytes, result counts, and the
structured search response size are bounded separately in the parent. The
structured-response bound excludes the surrounding JSON-RPC envelope and the
MCP SDK's JSON text mirror. An elapsed deadline
or request cancellation kills and reaps the worker. Each native call whose
arguments bind successfully is rebound in the parent and passes through the
host-supplied `authz.Authorizer` before its handler runs. The MCP adapter
Expand Down
1 change: 1 addition & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func Register[Input, Output any](builder *Builder, capability Capability[Input,
Name: string(capability.Name),
Summary: capability.Summary,
Description: description,
SearchTerms: slices.Clone(capability.SearchTerms),
Plan: plan,
Invoke: func(ctx context.Context, subject authz.Subject, input any) (any, error) {
typed, ok := input.(Input)
Expand Down
32 changes: 30 additions & 2 deletions builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,37 @@ func TestBuilderCopiesStaticFilteringOptions(t *testing.T) {
server, err := builder.Build()

require.NoError(t, err)
results, err := server.Search("disabled")
response, err := server.Search("disabled")
require.NoError(t, err)
assert.Empty(t, results)
require.NotNil(t, response.Results)
assert.Empty(t, response.Results)
assert.False(t, response.Truncated)
}

// TestBuilderCopiesSearchTerms proves caller search-term mutation cannot alter discovery.
func TestBuilderCopiesSearchTerms(t *testing.T) {
terms := []string{"open ticket"}
capability := validBuilderCapability("cap.alpha", "records.alpha")
capability.SearchTerms = terms
builder := codemode.New(codemode.Options{Authorizer: authz.AllowAll()})
codemode.Register(builder, capability)
terms[0] = "mutated after register"

server, err := builder.Build()
require.NoError(t, err)

response, err := server.Search("ticket")
require.NoError(t, err)
require.NotNil(t, response.Results)
require.Len(t, response.Results, 1)
assert.Equal(t, "records.alpha", response.Results[0].Name)
assert.False(t, response.Truncated)

mutated, err := server.Search("mutated")
require.NoError(t, err)
require.NotNil(t, mutated.Results)
assert.Empty(t, mutated.Results)
assert.False(t, mutated.Truncated)
}

// TestBuilderRejectsNilAuthorizersAndNegativeLimits proves required construction policy fails closed.
Expand Down
7 changes: 7 additions & 0 deletions capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ type Capability[Input, Output any] struct {
// requests. An empty Description defaults to Summary.
Description string

// SearchTerms contains alternative task vocabulary used only for discovery.
// Terms are not callable aliases and are not accepted by Describe or Execute.
// They are not returned in search results, but callers can infer indexed
// vocabulary by probing. Do not put secrets, policy facts, credentials,
// tenant identifiers, or sensitive examples in search terms.
SearchTerms []string

// Handler executes the capability after binding and authorization succeed.
Handler Handler[Input, Output]
}
17 changes: 17 additions & 0 deletions docs/docs/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ See [Use Rego for authorization](../how-to/use-rego-authorization.md) for config

Static filtering is useful for deployment-wide availability, but it is not dynamic authorization. It cannot express subject-specific or argument-specific decisions. Conversely, authorization alone does not hide a capability's metadata from discovery. Use static filtering to remove a capability from the deployment surface and authorization to decide whether an enabled native call may dispatch.

## Discovery metadata is observable

Search indexes each enabled capability's name, `SearchTerms`, summary, and
description. A search response returns only the exact name, signature, and
summary, but omission is not secrecy. A caller can submit different queries
and infer whether particular vocabulary changes the ranked results.

Treat `SearchTerms` as model-visible discovery metadata even though the terms
are not returned directly. Do not put secrets, credentials, policy facts,
tenant identifiers, or sensitive examples in them. Search terms do not create
callable aliases and are not accepted by exact `Describe` or by execution.

Static filtering removes a disabled capability before the search index is
built, so its metadata does not contribute tokens or ranking. Authorization is
different: search does not run the per-capability authorizer and does not hide
enabled discovery metadata based on the resolved subject.

## Client errors are intentionally coarse

Detailed causes exist only on the trusted side of the public boundary. Internal packages and host authorizers, resolvers, and handlers can hold or log those causes. A direct call to `authz/rego.Authorize` can return an ordinary error that identifies an undefined or non-Boolean decision, or carries an OPA evaluation or builtin failure.
Expand Down
12 changes: 7 additions & 5 deletions docs/docs/how-to/disable-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ explicit ID before creating a deployment filter:

```go
codemode.Register(builder, codemode.Capability[lookupInput, lookupOutput]{
ID: "records.entry.lookup",
Name: "records.lookup",
Summary: "Look up one record by key.",
Handler: lookup,
ID: "records.entry.lookup",
Name: "records.lookup",
Summary: "Look up one record by key.",
SearchTerms: []string{"fetch entry"},
Handler: lookup,
})
```

Expand Down Expand Up @@ -66,7 +67,8 @@ go build -o codemode-first-server .

## Verify the filter

1. Call `search_api` with `{"query":"records.lookup"}`. The result is `[]` and no longer lists `records.lookup`.
1. Call `search_api` with `{"query":"records.lookup"}`. The result is
`{"results":[],"truncated":false}` and no longer lists `records.lookup`.

2. Call `describe_api` with `{"name":"records.lookup"}`. The call returns the tool error `capability not found`.

Expand Down
132 changes: 110 additions & 22 deletions docs/docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ On success, `CallToolResult.StructuredContent` contains the value described by t

## `search_api`

Search enabled names and summaries with a short literal substring. Retry an empty result with a shorter term.
Search enabled capabilities using task, resource, or exact-name vocabulary.
Results are relevance-ranked. Pass the exact returned name to `describe_api`.
If `truncated` is `true` and no result fits, submit a more specific
task/resource query.

### Input

Expand All @@ -34,37 +37,122 @@ Search enabled names and summaries with a short literal substring. Retry an empt
}
```

The raw query is limited by `MaxSearchQueryBytes` before trimming or case normalization. Whitespace padding counts. CodeMode then trims surrounding whitespace and normalizes case. Matching is a short literal substring over capability names and summaries. A blank normalized query or any other empty result is `[]`, not `null`. Retry an empty result with a shorter term. Search does not add fuzzy matching, aliases, or extra query rewriting.

Results are sorted by exact dotted name and limited by `MaxSearchResults`. Static filtering happens before search, so disabled capabilities never appear.
The raw query is limited by `MaxSearchQueryBytes` before trimming or
tokenization. Whitespace padding counts. CodeMode trims surrounding Unicode
whitespace, treats every rune that is not a Unicode letter or digit as a
separator, splits camel-case, acronym-to-word, and letter/number transitions,
and lowercases each token. Dots, underscores, and hyphens are therefore
separators.

For example, `GitHub.Pulls.createReview` becomes `github`, `pulls`, `create`,
`review`; `pull_request` becomes `pull`, `request`; and `sql` and `mysql`
remain different tokens.

Search removes the connector tokens `a`, `an`, `and`, `by`, `for`, `from`,
`in`, `of`, `on`, `or`, `the`, `to`, and `with`, then deduplicates the
remaining query tokens. A query with more than 16 distinct normalized tokens
returns `resource limit exceeded`.

Search compares the distinct query tokens with tokens from each enabled
capability's name, registered `SearchTerms`, summary, and description. Exact
token matches are supported. A query token of at least three Unicode characters
can also match the prefix of a capability token. Arbitrary infix matching,
fuzzy matching, stemming, and built-in synonym expansion are not supported.

For one query token within one field, an exact match ranks above a prefix
match. Search retains only the strongest contribution for that query token.
The field precedence used for weighting is the capability name, then
`SearchTerms`, then the summary, then the description. Terms found in fewer
enabled capabilities contribute more than catalog-wide terms. Numeric scoring
weights are internal.

Eligibility depends on the number `q` of distinct normalized query tokens:

| Query tokens | Required matched tokens |
| ---: | ---: |
| 1 | 1 |
| 2 | 2 |
| 3 or more | `ceil(2q / 3)` |

Search ranks every eligible capability before applying output bounds. A
case-insensitive exact dotted-name query, after trimming surrounding
whitespace, ranks first. Remaining ordering is relevance score descending,
then exact dotted name ascending. Static filtering happens before indexing, so
disabled capabilities cannot match or affect ranking.

### Successful structured output

```json
{
"type": "array",
"items": {
"type": "object",
"required": ["name", "signature", "summary"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"signature": { "type": "string" },
"summary": { "type": "string" }
"type": "object",
"required": ["results", "truncated"],
"additionalProperties": false,
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "signature", "summary"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"signature": { "type": "string" },
"summary": { "type": "string" }
}
}
},
"truncated": {
"type": "boolean"
}
}
}
```

The structured content itself is the array described above, not an object that wraps the array. When there are no matches, it is `[]`, not `null`.
A populated successful value is:

```json
{
"results": [
{
"name": "records.lookup",
"signature": "records.lookup(*, key: str, limit: int | None)",
"summary": "Look up one record by key."
}
],
"truncated": false
}
```

A blank query, a separator- or connector-only query, and a query with no
eligible matches all succeed with this exact object:

```json
{
"results": [],
"truncated": false
}
```

`results` is always a non-null array on success. CodeMode packs the
highest-ranked prefix under `MaxSearchResults` and an internal structured
response-byte bound. `truncated` is `true` when either bound omits at least one
eligible capability. It does not expose a total count or provide pagination.
The byte bound covers the compact JSON representation of the structured
`{results, truncated}` response. The surrounding JSON-RPC envelope and the MCP
SDK's JSON `TextContent` mirror are outside that cap.

| Field | Meaning |
| --- | --- |
| `name` | Exact enabled dotted capability name, such as `records.lookup`. |
| `signature` | Invocation-only keyword signature. It ends after the parameter list and never contains a Go output type. |
| `summary` | Registered compact summary. |
| `results[].name` | Exact enabled dotted capability name, such as `records.lookup`. |
| `results[].signature` | Invocation-only keyword signature. It ends after the parameter list and never contains a Go output type. |
| `results[].summary` | Registered compact summary. |
| `truncated` | Whether at least one eligible result was omitted by the result-count or structured-response byte bound. |

`signature` contains the dotted name, a `*` keyword-only marker when there are parameters, and the ordered input fields with their type notations. It ends at `)`. The exact forms are `records.lookup(*, key: str, limit: int | None)` and `records.status()`. The result contract is `describe_api.output`, not `signature`.
`signature` contains the dotted name, a `*` keyword-only marker when there are
parameters, and the ordered input fields with their type notations. It ends at
`)`. The exact forms are
`records.lookup(*, key: str, limit: int | None)` and `records.status()`. The
result contract is `describe_api.output`, not `signature`.

## `describe_api`

Expand Down Expand Up @@ -326,7 +414,7 @@ Only the final converted value from the worker process is exposed in the success

The listed descriptions above are the model-facing contract. Recovery uses the same fixed coarse errors on this page. When recording or reporting a failed call, keep the coarse text and the recovery action; do not echo the failed source, arguments, credentials, or unknown requested name.

- Search with a short literal substring over enabled names and summaries. If the result is empty, retry with a shorter term.
- Search with task, resource, or exact-name vocabulary. If `truncated` is `true`, use a more specific task/resource query. Pass an exact returned `name` to `describe_api`.
- After `capability not found`, search again and pass `describe_api` an exact returned `name`, without whitespace or case changes.
- After `invalid capability arguments`, compare the call with the published `signature` and `input` field shapes.
- After `invalid program`, check the program against these requirements:
Expand All @@ -335,9 +423,9 @@ The listed descriptions above are the model-facing contract. Recovery uses the s
- Return the final value from `main`.
- Use `describe_api.output` as the result contract. Do not parse a type name from `signature`.
- After `resource limit exceeded`, reduce the applicable bounded quantity:
query or source bytes, execution steps or time, native calls, crossing-value
depth or per-value encoded size, or the cumulative encoded size of successful
native results. Then retry the call.
query or source bytes, distinct normalized query tokens, execution steps or
time, native calls, crossing-value depth or per-value encoded size, or the
cumulative encoded size of successful native results. Then retry the call.
- After `permission denied` or `authorization policy failure`, contact the host if the access was expected. One allowed or denied input cannot establish whether the policy is default-open, default-deny, complete, or incomplete.

## Errors
Expand Down
Loading