Extensions & Capabilities#

Extensions carry typed contextual metadata — identity, security labels, HTTP headers, delegation chains — through the plugin pipeline. The capability system controls which plugins can see and modify which extension slots.


The Extensions Container#

Extensions is a frozen Pydantic model that attaches to payloads flowing through the pipeline. Each field is an optional typed slot:

from cpex.framework.extensions.extensions import Extensions
from cpex.framework.extensions.request import RequestExtension
from cpex.framework.extensions.security import SecurityExtension

ext = Extensions(
    request=RequestExtension(environment="production", request_id="req-001"),
    security=SecurityExtension(labels=frozenset({"pii", "confidential"})),
)

ext.request.environment   # "production"
ext.security.labels        # frozenset({"pii", "confidential"})
ext.http                   # None — not populated

Extensions are frozen. To modify, use model_copy(update={...}):

updated = ext.model_copy(update={"custom": {"trace_id": "abc-123"}})

Extension Slots#

SlotTypeDescriptionAccess
requestRequestExtensionEnvironment, request ID, timestamp, tracingUnrestricted
agentAgentExtensionSession tracking, multi-agent lineageread_agent
httpHttpExtensionHTTP headersread_headers / write_headers
securitySecurityExtensionLabels, classification, subject identityMixed (see below)
delegationDelegationExtensionToken delegation chainread_delegation / append_delegation
mcpMCPExtensionTool, resource, or prompt metadataUnrestricted
completionCompletionExtensionStop reason, token usage, model, latencyUnrestricted
provenanceProvenanceExtensionSource, message ID, parent IDUnrestricted
llmLLMExtensionModel identity and capabilitiesUnrestricted
frameworkFrameworkExtensionAgentic framework contextUnrestricted
metaMetaExtensionHost-provided operational metadataUnrestricted
customdict[str, Any]Free-form plugin dataUnrestricted

Unrestricted slots are visible to all plugins. Capability-gated slots require a declared capability.


Mutability Tiers#

Each extension slot has a mutability tier that the pipeline enforces:

TierRuleExample
ImmutableSet once, never changed. Pipeline rejects any delta.request, provenance, agent
MonotonicCan only grow — elements can be added, never removed. Pipeline validates before ⊆ after.security.labels, delegation.chain
MutableFreely modifiable via copy-on-write.custom

Capabilities#

Capabilities are declared in the plugin’s YAML config and control what a plugin can access:

plugins:
  - name: header_injector
    kind: my_app.HeaderInjectorPlugin
    hooks:
      - tool_pre_invoke
    mode: sequential
    capabilities:
      - read_headers
      - write_headers

Available capabilities:

CapabilityGrants
read_subjectRead subject ID and type
read_rolesRead subject roles (implies read_subject)
read_teamsRead subject teams (implies read_subject)
read_claimsRead subject claims (implies read_subject)
read_permissionsRead subject permissions (implies read_subject)
read_agentRead agent extension
read_headersRead HTTP headers
write_headersRead + write HTTP headers
read_labelsRead security labels
append_labelsRead + append security labels (monotonic)
read_delegationRead delegation chain
append_delegationRead + append delegation chain (monotonic)

Write capabilities imply their corresponding read capability. A plugin with write_headers can also read headers.


How It Works#

The framework applies two filters around every plugin execution:

  1. Beforefilter_extensions() builds a new Extensions containing only the slots the plugin has access to. Slots the plugin can’t see are None.
  2. Aftermerge_extensions() accepts back only the changes the plugin was authorized to make. Immutable slots are ignored. Monotonic slots are validated for growth. Unauthorized writes are silently discarded.

This means plugins can’t even see data they lack capabilities for, and they can’t sneak in unauthorized changes.


Accepting Extensions in a Hook#

Add a third parameter to your hook signature:

from cpex.framework import hook, Plugin, PluginContext, PluginResult, ToolPreInvokePayload, ToolPreInvokeResult
from cpex.framework.extensions.extensions import Extensions


class HeaderInspectorPlugin(Plugin):
    @hook("tool_pre_invoke")
    async def inspect_headers(
        self,
        payload: ToolPreInvokePayload,
        context: PluginContext,
        extensions: Extensions,
    ) -> ToolPreInvokeResult:
        if extensions.http:
            auth = extensions.http.headers.get("authorization", "none")
            context.set_state("auth_method", auth.split()[0] if " " in auth else auth)
        return ToolPreInvokeResult(continue_processing=True)

The framework detects the 3-parameter signature automatically and passes the capability-filtered extensions.


Returning Modified Extensions#

To modify extensions, return modified_extensions in the result:

from cpex.framework.extensions.extensions import Extensions
from cpex.framework.extensions.http import HttpExtension


class TokenDelegationPlugin(Plugin):
    @hook("tool_pre_invoke")
    async def delegate_token(
        self,
        payload: ToolPreInvokePayload,
        context: PluginContext,
        extensions: Extensions,
    ) -> ToolPreInvokeResult:
        delegated_token = await self._exchange_token(extensions)

        updated_http = HttpExtension(
            headers={**(extensions.http.headers if extensions.http else {}),
                     "authorization": f"Bearer {delegated_token}"},
        )
        updated_ext = extensions.model_copy(update={"http": updated_http})

        return ToolPreInvokeResult(
            continue_processing=True,
            modified_extensions=updated_ext,
        )

The manager merges only the fields the plugin is authorized to write. In this case, the plugin needs write_headers in its capabilities.


Security Sub-Field Gating#

The security extension has granular sub-field access control. A plugin with read_roles can see security.subject.roles but not security.subject.claims:

capabilities:
  - read_roles
  - read_labels

This plugin sees:

  • security.subject.id and security.subject.type (implied by read_roles)
  • security.subject.roles (granted by read_roles)
  • security.labels (granted by read_labels)
  • security.objects, security.data, security.classification (always unrestricted)

It does not see:

  • security.subject.teams, security.subject.claims, security.subject.permissions