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
4 changes: 4 additions & 0 deletions dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ func invoke(ctx context.Context, subject authz.Subject, entry catalog.Entry, inp
if errors.Is(outcome.err, catalog.ErrInputTypeMismatch) {
return nil, fmt.Errorf("%w: %w", execution.ErrInternal, outcome.err)
}
var agentErr *AgentError
if errors.As(outcome.err, &agentErr) && agentErr != nil {
return nil, execution.WithSafeDetail(execution.ErrCapabilityFailure, sanitizeAgentMessage(agentErr.Message))
}
return nil, fmt.Errorf("%w: %w", execution.ErrCapabilityFailure, outcome.err)
}

Expand Down
8 changes: 5 additions & 3 deletions docs/docs/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,13 @@ enabled discovery metadata based on the resolved subject.

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.

`codemode.Server.Execute` removes those trusted causes. It returns the documented public sentinel for execution, policy, handler, resource, and internal failures. Request cancellation returns `context.Canceled`. A deadline returns `ErrResourceLimit` and preserves `context.DeadlineExceeded` for `errors.Is`. Root `Error()` strings stay exactly coarse. Approved model-derived parser, resolver, and binding detail may travel with the sentinel for MCP formatting, but it is not part of the root error text.
`codemode.Server.Execute` removes trusted causes. It returns the documented public sentinel for execution, policy, handler, resource, and internal failures. Request cancellation returns `context.Canceled`. A deadline returns `ErrResourceLimit` and preserves `context.DeadlineExceeded` for `errors.Is`. Root `Error()` strings stay exactly coarse. Approved parser, resolver, and binding detail, and explicitly disclosed handler messages, may travel with the sentinel for MCP formatting, but are not part of the root error text.

The MCP adapter narrows the boundary again. It emits the nine fixed error texts in the [MCP tool reference](../reference/mcp-tools.md#errors), plus two stable prefixes that may append approved CodeMode execution detail: `invalid program: ...` for parse and resolve positions and messages, and `invalid capability arguments: ...` for binding diagnostics. Resolver and custom-service details and recovered panic values become coarse responses. SDK input-schema errors are different: they occur before trusted subject resolution and can identify malformed client-owned fields or values.
The MCP adapter narrows the boundary again. The [MCP tool reference](../reference/mcp-tools.md#errors) lists the fixed categories and three suffix forms: `invalid program: ...` for parse and resolve positions and messages, `invalid capability arguments: ...` for binding diagnostics, and `capability failed: ...` for handler-authored messages. Resolver and arbitrary custom-service details and recovered panic values become coarse responses. SDK input-schema errors are different: they occur before trusted subject resolution and can identify malformed client-owned fields or values.

This projection prevents host-derived diagnostic detail from becoming model-visible. MCP responses do not expose budget values, filtered capability identities, unknown requested names, host-derived argument values, Rego decision paths or rule names, handler messages, credentials, panic values, or stack details. The only MCP exceptions are parse or resolve positions and messages and binding argument diagnostics produced by the program that the service executed. With the shipped `*codemode.Server`, that program is the submitted `source`.
Handler text stays hidden by default. A handler can deliberately disclose a message by returning or wrapping [`*codemode.AgentError`](../reference/public-api.md#agenterror). Only its `Message` crosses the worker and MCP boundaries, never surrounding error text or another cause. CodeMode replaces non-printable runes with spaces, converts invalid UTF-8 to replacement characters, and bounds the suffix to 256 UTF-8 bytes, including `...` when truncated. The worker still aborts the program; this does not add Starlark exception handling. Policy errors, panic values, and return-value conversion failures stay bare even if an `AgentError` is involved.

Sanitization bounds presentation; it does not redact secrets. As with `SearchTerms`, the host author is responsible for treating `AgentError.Message` as model-visible data. Do not put credentials, sensitive tenant information, policy facts, or backend diagnostics in it. A wrong-name or not-ready message should reveal only resource information the caller is allowed to learn. Ordinary host-derived diagnostics, budget values, filtered capability identities, Rego decision paths, panic values, and stack details remain excluded unless a host deliberately puts such information into an agent-facing message.

If a host needs detailed diagnostics, its trusted authorizer, resolver, or handler must record them before returning. CodeMode cannot recover a discarded cause after the root or MCP projection. Apply the host's normal access controls and redaction rules to those logs.

Expand Down
6 changes: 4 additions & 2 deletions docs/docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,12 @@ Only the final converted value from the worker process is exposed in the success

## Authoring and recovery

The listed descriptions above are the model-facing contract. Recovery uses the nine fixed texts and two stable prefixes on this page. When recording or reporting a failed call, keep the error text and the recovery action; do not echo credentials, unknown requested names, or host-derived handler or policy text.
The listed descriptions above are the model-facing contract. Recovery uses the fixed texts and three stable suffix forms on this page. When recording or reporting a failed call, keep the error text and the recovery action; do not add credentials or undisclosed host diagnostics.

- 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`, 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 `capability failed`, use any handler-authored suffix to choose the next action, such as correcting a resource name or waiting for it to become ready. The failed program has aborted; submit a new program to retry.
- 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: `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.
Expand All @@ -445,7 +446,7 @@ The listed descriptions above are the model-facing contract. Recovery uses the n

## Errors

After a well-formed call reaches the adapter, a resolver or service failure becomes a successful MCP protocol response with `isError` set. Nine texts are fixed. Two classes keep a stable prefix and may append model-derived detail: `invalid program: ...` and `invalid capability arguments: ...`. The adapter removes resolver and custom-service details and recovered panic values. It does not expose budget values, filtered capability identities, unknown requested names, host-derived argument values, Rego decision paths or rule names, handler messages, credentials, panic values, or stack details. Parse and resolve suffixes may include a source position in the submitted program. Binding suffixes may include an argument name from the submitted call.
After a well-formed call reaches the adapter, a resolver or service failure becomes a successful MCP protocol response with `isError` set. Error categories keep stable text. `invalid program: ...` and `invalid capability arguments: ...` may append model-derived detail. `capability failed: ...` may append a message explicitly disclosed by the handler through `codemode.AgentError`. The adapter removes resolver and arbitrary custom-service details and recovered panic values. Parse and resolve suffixes may include a source position in the submitted program. Binding suffixes may include an argument name from the submitted call.

| Text | Meaning |
| --- | --- |
Expand All @@ -457,6 +458,7 @@ After a well-formed call reaches the adapter, a resolver or service failure beco
| `authorization policy failure` | Policy evaluation failed. |
| `resource limit exceeded` | A discovery, execution, depth, per-value, or aggregate intermediate-value budget was exceeded. |
| `capability failed` | A handler failed or returned an invalid value, including a non-finite float or an unsigned integer above `math.MaxInt64`. |
| `capability failed: ...` | A handler returned or wrapped `*codemode.AgentError`. Only its `Message` is disclosed: non-printable runes become spaces, invalid UTF-8 becomes replacement characters, and the suffix is truncated on a rune boundary to at most 256 bytes including trailing `...`. An empty message leaves the failure bare. |
| `context canceled` | The request context was canceled. |
| `context deadline exceeded` | A service returned a bare deadline error. Root CodeMode execution deadlines are projected as `resource limit exceeded`. |
| `internal failure` | Any unknown service error or recovered adapter failure. |
Expand Down
32 changes: 32 additions & 0 deletions docs/docs/reference/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ func(context.Context, authz.Subject, Input) (Output, error)

The subject is the trusted subject supplied to `Server.Execute`. The input and output are the exact generic types registered for the capability.

#### `AgentError`

`AgentError` opts a handler failure into agent-visible detail:

```go
return output, &codemode.AgentError{
Message: `instance "web" not found in sandbox "demo"`,
}
```

`Message string` is the explanation the handler author has chosen to disclose.
Return `*AgentError` directly or wrap it with `fmt.Errorf("lookup: %w", err)`;
CodeMode finds it with `errors.As` and attaches only `Message`, not wrapper
text or other causes. The MCP error is
`capability failed: instance "web" not found in sandbox "demo"`.

CodeMode replaces control characters and other non-printable runes with spaces,
including newlines and tabs. Invalid UTF-8 bytes become replacement characters.
The sanitized suffix is at most 256 UTF-8 bytes, including a trailing `...` when
truncated; truncation does not split a rune. An empty message or nil
`*AgentError` leaves the failure bare. `AgentError.Error()` returns the original
message (or an empty string for a nil receiver), not the sanitized suffix.

The failure still aborts the Starlark program. `Server.Execute` retains the
coarse `Error()` text `capability failed` and supports
`errors.Is(err, codemode.ErrCapabilityFailure)`; the MCP adapter formats the
suffix. Ordinary handler errors remain hidden. Panic values, policy errors,
and invalid handler return values cannot opt in through `AgentError`.
The host is responsible for keeping secrets and sensitive data out of `Message`.

#### Capability identity example

The policy and deployment-filter examples use this explicit capability identity:

| Property | Value |
Expand Down
48 changes: 47 additions & 1 deletion errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package codemode

import "errors"
import (
"errors"
"unicode"
"unicode/utf8"

"github.com/meigma/codemode/internal/execution"
)

var (
// ErrInvalidRegistration classifies invalid capability registration, limits, or server construction.
Expand Down Expand Up @@ -33,3 +39,43 @@ var (
// ErrInternal classifies an unexpected framework failure.
ErrInternal = errors.New("internal failure")
)

// AgentError carries a message the handler author has chosen to expose to the agent.
//
// Return it directly or wrap it using %w. Only Message is
// exposed, never surrounding error text. CodeMode replaces non-printable runes
// with spaces and truncates the message to 256 UTF-8 bytes, including a trailing
// "..." when truncated. Empty messages leave the capability failure bare.
// The host must not put secrets or other sensitive data in Message.
type AgentError struct {
// Message is the handler-authored, agent-facing failure explanation.
Message string
}

// Error returns the handler-authored message before sanitization.
func (err *AgentError) Error() string {
if err == nil {
return ""
}
return err.Message
}

// sanitizeAgentMessage bounds work and output independently of handler message size.
func sanitizeAgentMessage(message string) string {
var buffer [execution.MaxAgentErrorBytes]byte
output := buffer[:0]
for _, char := range message {
if !unicode.IsPrint(char) {
char = ' '
}
if len(output)+utf8.RuneLen(char) > len(buffer) {
end := len(buffer) - len("...")
for end < len(output) && !utf8.RuneStart(output[end]) {
end--
}
return string(append(output[:end], "..."...))
}
output = utf8.AppendRune(output, char)
}
return string(output)
}
9 changes: 7 additions & 2 deletions internal/execution/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package execution

import "errors"

// MaxAgentErrorBytes is the maximum UTF-8 size of an approved agent-visible
// capability-failure suffix, including a trailing ASCII ellipsis when truncated.
const MaxAgentErrorBytes = 256

// safeDetailError attaches one model-derived diagnostic suffix without changing the coarse error text.
type safeDetailError struct {
// cause is the coarse classified sentinel.
Expand All @@ -24,15 +28,16 @@ func (err *safeDetailError) Unwrap() error {
// WithSafeDetail attaches detail to cause without changing cause.Error.
//
// Empty detail returns cause unchanged. Callers must pass only model-derived
// suffixes; host-derived text must not be attached.
// suffixes or sanitized explicitly handler-authored detail; host-derived text
// must not be attached.
func WithSafeDetail(cause error, detail string) error {
if detail == "" {
return cause
}
return &safeDetailError{cause: cause, detail: detail}
}

// SafeDetail reports the model-derived suffix attached to err, if any.
// SafeDetail reports the approved suffix attached to err, if any.
//
// Extraction follows the error chain with [errors.As].
func SafeDetail(err error) (string, bool) {
Expand Down
20 changes: 20 additions & 0 deletions internal/execution/detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,23 @@ func TestExecuteKeepsGenericRuntimeErrorsCoarse(t *testing.T) {
assert.NotContains(t, err.Error(), "db password rejected")
assert.NotContains(t, err.Error(), "fail")
}

// TestExecutePreservesApprovedCapabilitySafeDetail proves handler-authored suffixes
// survive classification without changing the coarse Error text.
func TestExecutePreservesApprovedCapabilitySafeDetail(t *testing.T) {
const detail = `instance "web" not found in sandbox "demo"`
_, err := buildEngine(t).Execute(
`def main(): return records.lookup(value="alpha")`,
func(string, map[string]any) (any, error) {
return nil, execution.WithSafeDetail(execution.ErrCapabilityFailure, detail)
},
defaultExecutionLimits(),
)

require.ErrorIs(t, err, execution.ErrCapabilityFailure)
assert.Equal(t, execution.ErrCapabilityFailure.Error(), err.Error())
got, ok := execution.SafeDetail(err)
require.True(t, ok)
assert.Equal(t, detail, got)
assert.NotContains(t, err.Error(), detail)
}
2 changes: 1 addition & 1 deletion internal/execution/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func classifyRuntimeError(state *executionState, err error) error {
case errors.Is(cause, ErrResourceLimit):
return ErrResourceLimit
case errors.Is(cause, ErrCapabilityFailure):
return ErrCapabilityFailure
return classifiedSafeDetail(ErrCapabilityFailure, cause)
case errors.Is(cause, ErrInternal):
return ErrInternal
case errors.Is(cause, ErrInvalidProgram):
Expand Down
32 changes: 31 additions & 1 deletion internal/worker/child.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ import (
// remains authoritative because Engine intentionally coarsens unknown errors.
var errNativeAbort = errors.New("native abort")

// capabilityAbortError is the Starlark-visible unwind for an approved parent abort suffix.
type capabilityAbortError struct {
// detail is the approved capability-failure suffix.
detail string

// cause retains classification and detail without allocating during unwrapping.
cause error
}

// Error returns the Starlark-visible capability-failure text.
func (err *capabilityAbortError) Error() string {
return execution.ErrCapabilityFailure.Error() + ": " + err.detail
}

// Unwrap preserves [execution.ErrCapabilityFailure] and the approved SafeDetail.
func (err *capabilityAbortError) Unwrap() error {
return err.cause
}

// errChildService classifies a child protocol or internal service failure that
// must not become a final_error frame.
var errChildService = errors.New("worker service failure")
Expand Down Expand Up @@ -154,13 +173,24 @@ func nativeForwarder(conn *childConn) execution.NativeCall {
case nativeResultFrame:
return typed.Result, nil
case nativeAbortFrame:
return nil, errNativeAbort
return nil, nativeAbortError(typed.Detail)
default:
return nil, errChildService
}
}
}

// nativeAbortError maps a decoded abort frame onto the interpreter unwind error.
func nativeAbortError(detail string) error {
if detail == "" {
return errNativeAbort
}
return &capabilityAbortError{
detail: detail,
cause: execution.WithSafeDetail(execution.ErrCapabilityFailure, detail),
}
}

// writeExecutionError maps an Engine failure onto abort suppression or a final_error.
func writeExecutionError(conn *childConn, err error) error {
if errors.Is(err, errNativeAbort) {
Expand Down
Loading