Skip to content

[EPIC]: CPEX Rust Core and multi-language plugin runtime #12

@terylt

Description

@terylt

Summary

Rewrite the CPEX PluginManager as a Rust core library with C FFI, Python (PyO3), and Go (cgo) bindings. Rust enforces CMF invariants (monotonic labels, immutable SubjectExtension, scope narrowing) internally through the type system; inputs crossing language/runtime boundaries are validated at ingress before entering the core. This gives us a single plugin execution engine shared across all language consumers, with support for native Rust, WASM, and Python plugin hosts.

The Rust core replaces the current Python PluginManager while maintaining backward compatibility with existing Python plugins via a PyO3 bridge host.

Motivation

The current Python PluginManager works, but:

  1. Invariants are enforced by convention, not the type system. Monotonic label sets, immutable security extensions, and delegation scope narrowing are documented rules that plugins can violate at runtime. Rust enforces these internally through the type system; inputs from other languages are validated at ingress.
  2. Every consumer reimplements the runtime. If Kagenti (Go), ContextForge (Python), or an Envoy filter (Rust) wants CPEX plugins, they each need a full PluginManager implementation. A shared Rust core with language bindings solves this.
  3. WASM plugin sandboxing requires a native runtime. Python cannot efficiently host WASM plugins. A Rust core with wasmtime gives us sandboxed third-party plugins where the host grants no filesystem, network, or memory capabilities by default — only those explicitly configured by the runtime.
  4. Performance-sensitive paths (identity resolution, policy evaluation, session lookup) benefit from native execution, especially in high-throughput gateway deployments.

User Stories

User Story 1: Gateway Integrator (Python — ContextForge)

  • As a: ContextForge gateway developer
  • I want: to replace from cpex import PluginManager with a Rust-backed PluginManager that exposes the same Python API
  • So that: I get compile-time CMF invariants, WASM plugin support, and faster policy evaluation without changing my integration code

Acceptance Criteria

Scenario: Drop-in replacement for Python PluginManager
  Given a ContextForge gateway using cpex.PluginManager
  When I upgrade the cpex package to the Rust-backed version
  Then existing Python plugin configurations load without changes
  And existing Python plugins execute through the PyO3 bridge host
  And the 5-phase execution order is preserved (sequential → transform → audit → concurrent → fire_and_forget)

Scenario: Monotonic security labels enforced at compile time
  Given a plugin attempts to remove a label from SecurityExtension
  When the Rust type system evaluates the operation
  Then compilation fails — MonotonicSet has no remove() method

Scenario: WASM plugin loading
  Given a plugin declared as kind: "wasm://path/to/plugin.wasm"
  When the PluginManager initializes
  Then the plugin loads in a wasmtime sandbox
  And the WASM host grants no filesystem, network, or host memory access by default (only explicitly configured capabilities)
  And the plugin implements the same Plugin trait as native plugins

User Story 2: Gateway Integrator (Go — Kagenti)

  • As a: Kagenti developer working in Go
  • I want: to use cpex.NewManager() from a Go package backed by the same Rust core
  • So that: Kagenti gets identical policy enforcement behavior as ContextForge without reimplementing the PluginManager in Go

Acceptance Criteria

Scenario: Go bindings via cgo + C FFI
  Given the cpex-ffi crate is compiled to libcpex_ffi.so
  And include/cpex.h is generated by cbindgen
  When a Go application imports "github.com/contextforge/cpex/go"
  Then cpex.NewManager() initializes the Rust PluginManager via cgo
  And cpex.InvokeHook() dispatches through the same 5-phase executor

Scenario: Monorepo developer workflow
  Given a developer has cloned the cpex monorepo
  When they run "cargo build --release -p cpex-ffi && cd go && go test ./..."
  Then the Go tests pass using relative paths to the compiled library
  And no system-wide installation is required

User Story 3: Plugin Author (Rust)

  • As a: a security team member writing a content-filter plugin
  • I want: to write my plugin in Rust using the cpex-sdk crate
  • So that: my plugin compiles to a native shared library with zero runtime overhead

Acceptance Criteria

Scenario: Plugin author uses lean SDK crate
  Given a Rust developer adds cpex-sdk as a dependency
  When they implement the Plugin trait and compile
  Then the resulting .so/.dylib can be loaded by cpex-hosts as a native plugin
  And cpex-sdk does not pull in wasmtime, PyO3, or the full PluginManager

Scenario: Plugin author targets WASM
  Given a Rust developer compiles with --target wasm32-wasip1
  When they use only cpex-sdk types
  Then the plugin compiles to .wasm and loads via the WASM host

Design

Crate Architecture

cpex/
├── Cargo.toml                    # Workspace root
├── pyproject.toml                # maturin build for Python package
├── Makefile                      # Top-level: rust-ffi, go-test, python-build
│
├── crates/
│   ├── cpex-core/                # Pure Rust — no FFI deps
│   │   └── src/
│   │       ├── manager.rs        # PluginManager + lifecycle
│   │       ├── executor.rs       # 5-phase execution engine
│   │       ├── registry.rs       # PluginInstanceRegistry + HookRegistry
│   │       ├── plugin.rs         # Plugin trait, PluginRef, HookRef
│   │       ├── config.rs         # Unified YAML config (serde)
│   │       ├── context.rs        # GlobalContext, PluginContext
│   │       ├── policy.rs         # PluginMode, OnError, HookPayloadPolicy
│   │       ├── error.rs          # PluginError, Violation, Timeout
│   │       ├── hooks/
│   │       │   ├── types.rs      # HookType enum, payload/result traits
│   │       │   └── builtin.rs    # tool_pre_invoke, prompt_pre_fetch, etc.
│   │       ├── cmf/
│   │       │   ├── message.rs    # Message, ContentPart, Role, Channel
│   │       │   ├── view.rs       # MessageView (zero-copy projection)
│   │       │   └── extensions/
│   │       │       ├── security.rs    # MonotonicSet, SubjectExt (immutable)
│   │       │       ├── delegation.rs  # DelegationChain (append-only, scope-narrowing)
│   │       │       ├── request.rs
│   │       │       ├── agent.rs
│   │       │       ├── http.rs        # Guarded (capability-gated)
│   │       │       ├── mcp.rs
│   │       │       └── filter.rs      # Capability-gated extension filtering
│   │       └── session/
│   │           ├── state.rs      # SessionState (monotonic merge)
│   │           ├── store.rs      # SessionStore trait + in-memory impl
│   │           └── resolver.rs   # Multi-tier session resolution
│   │
│   ├── cpex-hosts/               # Plugin host runtimes (feature-gated)
│   │   └── src/
│   │       ├── native.rs         # dlopen .so/.dylib — libloading
│   │       ├── wasm.rs           # wasmtime sandbox
│   │       └── python.rs         # PyO3 bridge (calls INTO Python plugins)
│   │
│   ├── cpex-ffi/                 # C ABI for Go/Swift/other consumers
│   │   ├── src/
│   │   │   ├── lib.rs            # #[no_mangle] extern "C" functions
│   │   │   ├── handles.rs        # Opaque handle types + Arc safety
│   │   │   ├── manager.rs        # cpex_manager_new(), cpex_invoke_hook()
│   │   │   ├── payload.rs        # Byte buffer exchange across boundary
│   │   │   └── error.rs          # cpex_last_error(), error codes
│   │   └── cbindgen.toml         # Generates include/cpex.h
│   │
│   ├── cpex-python/              # PyO3 bindings (Python calls INTO Rust)
│   │   └── src/
│   │       ├── lib.rs            # #[pymodule]
│   │       ├── manager.rs        # PyPluginManager
│   │       ├── config.rs         # from_yaml()
│   │       ├── types.rs          # PyPluginResult, PyExtensions
│   │       └── plugin.rs         # PyPlugin trait adapter
│   │
│   └── cpex-sdk/                 # Lean crate for plugin authors
│       └── src/
│           ├── plugin.rs         # Plugin trait + derive macro stubs
│           ├── payload.rs        # PluginPayload, PluginResult
│           ├── context.rs        # PluginContext (read-only view)
│           └── extensions.rs     # Extension types (re-exported from core)
│
├── include/
│   └── cpex.h                    # Generated by cbindgen
│
├── python/
│   └── cpex/                     # Thin Python wrapper
│       ├── __init__.py           # Imports from cpex._native
│       ├── _native.pyi           # Type stubs
│       ├── compat.py             # Backward-compat shims
│       └── testing.py            # Test harness for plugin authors
│
├── go/                           # module: github.com/contextforge/cpex/go
│   ├── go.mod                    # package name: cpex
│   ├── cpex.go                   # NewManager, InvokeHook, etc.
│   ├── config.go
│   ├── types.go
│   ├── errors.go
│   └── internal/
│       ├── ffi.go                # cgo → cpex.h (pkg-config + relative fallback)
│       └── memory.go             # Handle lifecycle, free() safety
│
└── tests/
    ├── rust/
    ├── python/
    └── go/

Key Design Decisions

1. cpex-core is pure Rust — no FFI, no wasmtime, no PyO3

This is the most important structural decision. Core contains the PluginManager, executor, CMF types, session store, and config parser — with zero external runtime dependencies. This means:

  • Core compiles to any Rust target including wasm32-wasip1
  • Core is testable without standing up Python or WASM runtimes
  • Plugin hosts (cpex-hosts) depend on core, not the other way around

2. cpex-hosts is separate and feature-gated

[features]
default = ["native", "wasm", "python"]
native = ["libloading"]
wasm = ["wasmtime"]
python = ["pyo3"]

Each host implements the Plugin trait by bridging to foreign plugin code. A Go-only deployment can compile with default-features = false, features = ["native", "wasm"] and skip PyO3 entirely.

3. cpex-python vs cpex-hosts::python — opposite directions

These both use PyO3 but serve different roles:

Crate Direction Purpose
cpex-python Python → Rust Python code calls the Rust PluginManager
cpex-hosts::python Rust → Python Rust PluginManager calls existing Python plugins

ContextForge needs both. Kagenti needs neither.

4. cpex-sdk is lean for plugin authors

Plugin authors depend only on cpex-sdk, which re-exports the Plugin trait and payload/result types from core. It does not pull in the PluginManager, hosts, or FFI. This is also the crate WASM plugins compile against.

5. Go bindings use coarse-grained C FFI with zero-copy where possible

The FFI boundary is coarse-grained: one round trip per hook invocation, no chatty per-field accessors. Data crosses the boundary with minimal copying:

Direction What crosses Copy?
Go → Rust (payload in) Pointer + length to Go's existing bytes Zero-copy read; Rust deserializes into owned types
Rust → Go (result envelope) #[repr(C)] struct with scalar fields Zero-copy (shared C layout)
Rust → Go (modified payload) Pointer + length to Rust-owned bytes Zero-copy read; Go copies only if it needs to keep it
Cleanup cpex_result_free() Go tells Rust when to drop

Serialization only happens when necessary: always on input (Go bytes → Rust types), and on output only if a plugin modified the payload. A deny with no modification requires zero serialization on the result path — Go reads the C struct directly.

// cpex.h
typedef struct cpex_manager cpex_manager_t;

// Result envelope — #[repr(C)], Go reads fields directly
typedef struct {
    int             continue_processing;   // 0 = denied, 1 = allowed
    const char*     violation_code;        // NULL if allowed
    const char*     violation_reason;      // NULL if allowed
    const uint8_t*  modified_payload;      // NULL if unmodified
    size_t          modified_payload_len;  // 0 if unmodified
} cpex_result_t;

// ABI version for compatibility checking
uint32_t            cpex_abi_version(void);

// Lifecycle
cpex_manager_t*     cpex_manager_new(const char* config_path);
void                cpex_manager_free(cpex_manager_t* mgr);

// Hook dispatch — zero-copy pointer+length in, C struct out
cpex_result_t*      cpex_invoke_hook(cpex_manager_t* mgr,
                                      const char* hook_type,
                                      const uint8_t* payload_buf,
                                      size_t payload_len);
void                cpex_result_free(cpex_result_t* result);

// Error reporting
const char*         cpex_last_error(void);

Go consumers interact via cpex.NewManager() / cpex.InvokeHook() — the cgo layer is internal.

Monorepo devs: cargo build -p cpex-ffi && cd go && go test — cgo finds the library via relative paths with pkg-config fallback for system installs.

5a. Wire format: MessagePack (default), JSON (debug)

Payloads are serialized at the FFI boundary using MessagePack by default (~10x faster than JSON, same data model, schema-less). JSON is available as a debug option for human-readable inspection.

Format Serialize Deserialize Schema required? Use case
MessagePack (default) ~20µs ~20µs No Production — fast, compact binary
JSON (debug) ~200µs ~200µs No Development — human-readable

MessagePack is a drop-in replacement for JSON at the serde layer — same data model (maps, arrays, strings, numbers), just binary-encoded. No schema files, no code generation, no type model changes.

Alternatives considered:

Format Why not (for now)
Protocol Buffers Requires .proto schema files + codegen for internal FFI — overkill
FlatBuffers / Cap'n Proto Zero-copy reads but requires generated accessor types instead of plain structs — premature optimization
CBOR Same family as MessagePack, slightly less Rust/Go library support
Bincode Rust-specific, no stable Go library

FlatBuffers remains an option if profiling shows serialization is a bottleneck on a hot path. The FFI surface is format-agnostic — swapping the wire format doesn't change the C API.

6. CMF invariants enforced in the Rust core

Rust's type system enforces invariants internally; inputs from other languages are validated at ingress before entering the core:

// Security labels: add-only (no remove, no clear)
pub struct MonotonicSet<T: Eq + Hash> { inner: HashSet<T> }
impl<T: Eq + Hash> MonotonicSet<T> {
    pub fn insert(&mut self, value: T) { self.inner.insert(value); }
    pub fn contains(&self, value: &T) -> bool { self.inner.contains(value) }
    // No remove(). No clear(). No drain().
}

// Delegation chain: append-only, scopes must narrow
pub struct DelegationChain { hops: Vec<DelegationHop> }
impl DelegationChain {
    pub fn push(&mut self, hop: DelegationHop) -> Result<(), ScopeEscalation> {
        if let Some(prev) = self.hops.last() {
            if !hop.scopes.is_subset(&prev.scopes) {
                return Err(ScopeEscalation { .. });
            }
        }
        self.hops.push(hop);
        Ok(())
    }
}

Execution Model (preserved from Python)

The 5-phase execution order is identical to the current Python implementation:

SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
Phase Block? Modify? Execution Use Case
Sequential Yes Yes Serial, chained Policy enforcement + transformation
Transform No Yes Serial, chained Data shaping (PII redaction)
Audit No No Serial Observation, logging
Concurrent Yes No Parallel (fail-fast) Independent policy gates
Fire-and-forget No No Background (tokio tasks) Telemetry, async side effects

Plugin Trait

#[async_trait]
pub trait Plugin: Send + Sync {
    fn config(&self) -> &PluginConfig;
    async fn initialize(&self) -> Result<(), PluginError>;
    async fn execute(
        &self,
        hook_type: &str,
        payload: &serde_json::Value,
        context: &PluginContext,
    ) -> Result<PluginResult, PluginError>;
    async fn shutdown(&self) -> Result<(), PluginError>;
}

Note: The manager wraps each plugin in a PluginRef that holds the authoritative config from the config loader — not from the plugin. The plugin's config() is available for the plugin's own reading during execute(), but the manager/executor never reads it. Trust flows one direction: config loader → manager → PluginRef → executor.

This single trait is implemented by:

  • Native Rust plugins (directly)
  • cpex-hosts::wasm (bridges to WASM guest)
  • cpex-hosts::python (bridges to Python plugin classes)
  • cpex-hosts::native (bridges to dlopen'd shared libraries)

Additional Framework Features

The following features are supported by the Rust core:

Dynamic Hook Types

Hook types use a HookType(String) newtype for runtime extensibility. Built-in hook names are provided as &str constants in hook_names:: and cmf_hook_names:: modules for compile-time checking of known hooks, while hosts can register custom hooks at runtime:

// Built-in string constants (compile-time checked)
pub mod hook_names {
    pub const TOOL_PRE_INVOKE: &str = "tool_pre_invoke";
    pub const TOOL_POST_INVOKE: &str = "tool_post_invoke";
    // ...
}

// Create a HookType from a built-in constant or custom string
let builtin = HookType::new(hook_names::TOOL_PRE_INVOKE);
let custom = HookType::new("generation_pre_call");

Payload-Agnostic Design

The framework operates on any type implementing PluginPayload, not just CMF messages. MessagePayload is a built-in payload type; domain-specific hooks use domain-specific payloads through the same pipeline, modes, and capability gating.

Function Hooks

In addition to the class-based Plugin trait, standalone functions can be registered directly as hook handlers. This is the simplest way to add a single hook without a full plugin struct.

PluginSet (Composable Groupings)

PluginSet groups related plugins into reusable, composable units. Sets are inert containers — they organize plugins for registration but do not execute anything themselves. Sets can nest other sets.

Scoped Registration

Plugins can be activated for a specific scope and automatically deregistered when the scope exits. In Rust this uses RAII (Drop); Python and Go bindings expose context managers / defer respectively.

PipelineResult

PipelineResult wraps the aggregate outcome of a full hook invocation with factory methods (allowed(), denied()). Callers treat it identically to PluginResult.

COW Validation & Hook Payload Policies

Modify plugins operate on copy-on-write payloads. After modification, the framework validates against extension mutability tiers (immutable, monotonic, guarded, mutable). Per-hook HookPayloadPolicy can further restrict which payload fields are writable.

Global Settings

plugin_settings:
  plugin_timeout: 30
  cow_validation: strict        # strict | warn | disabled
  short_circuit_on_deny: true

Unified Configuration

The Rust core parses the unified YAML config:

global:
  identity:
    provider: cedarling
  session:
    store: memory
    ttl: 3600
  policies:
    pii:
      policy: require(perm.pii_access)
    hr:
      policy: require(role.hr | role.auditor)

plugins:
  - name: apl-policy
    kind: builtin
    hooks: [tool_pre_invoke, tool_post_invoke]
    mode: sequential
    capabilities: [read_security, append_labels]
  - name: content-filter
    kind: "wasm://plugins/content_filter.wasm"
    hooks: [tool_pre_invoke]
    mode: sequential
  - name: legacy-audit
    kind: "python://plugins.audit.AuditPlugin"
    hooks: [tool_post_invoke]
    mode: audit

routes:
  - tool: get_compensation
    meta:
      tags: [pii, hr]
      scope: hr-services
    policy:
      - "args.include_ssn & !perm.view_ssn": deny
    result:
      salary: "redact(!role.hr)"
      ssn: "redact(!perm.view_ssn)"

Typed Hook System

  • HookType trait defines typed payload + result per hook. No serde_json::Value in the Rust core — native Rust plugins work with typed structs at zero cost.
  • Mode drives scheduling — the hook type doesn't declare read-only vs mutating. The executor decides borrow vs clone based on the plugin's mode from PluginRef.trusted_config.
  • Extensions are a separate parameter — capability-filtered per plugin, modified independently from the payload (no COW needed for extension-only changes).
  • CMF: one handler, multiple hook names — a plugin implements CmfHookHandler once, registers for cmf.tool_pre_invoke, cmf.llm_input, etc. The MessageHookType field tells the handler which hook fired.
  • Python compatibility preserved@hook decorators, Plugin base class, and block()/allow() helpers are unchanged. Rust-defined payloads surface in Python as PyO3-wrapped objects with typed attributes. Python can also define custom hooks at runtime via register_hook_type().
  • Serialization only at language boundaries — native Rust: zero-cost. Python: PyO3 attribute conversion. Go/WASM: MessagePack (default), JSON (debug).

Implementation Phases

Phase 1 is deliberately narrow: minimal Rust execution kernel, one payload path (JSON values), and Go as the first binding target (there is active interest from Red Hat, and Python already has a complete implementation). We prove the execution semantics before expanding.

Phase Deliverable Depends On Can Parallelize?
1a cpex-core: Plugin trait, PluginManager, 5-phase executor, hook registry
1b cpex-ffi + go/: C ABI + Go bindings — first consumer, proves FFI semantics Phase 1a
1c Conformance test corpus (YAML scenarios, runs against Python impl and Rust/Go) Phase 1a Yes (with 1b)
2 cpex-core config: unified YAML config parsing Phase 1a
3 cpex-core extensions: CMF types, monotonic sets, capability-gated filtering, session store Phase 1a Yes (with 2)
4 cpex-sdk: Lean plugin author crate Phase 1a Yes (with 2, 3)
5 cpex-python: PyO3 bindings (Python calls Rust PluginManager) Phase 1a Yes (with 2, 3, 4)
6 cpex-hosts::python: Run existing Python plugins from Rust Phase 5
7 cpex-hosts::wasm: wasmtime sandbox host Phase 1a + 4 Yes (with 5, 6)
8 cpex-hosts::native: dlopen for .so plugins Phase 1a Yes (with all)
9 Integrate apl-core + cedarling as built-in plugins Phase 3

Phases 2-8 are largely independent and can be worked in parallel once Phase 1 lands.

Backward Compatibility

Python backward compatibility has two layers:

Layer Guarantee Scope
API compatibility from cpex import PluginManager, public interfaces, config YAML format Hard requirement (Phase 5+6)
Behavioral compatibility Documented lifecycle semantics: 5-phase ordering, capability gating, COW validation, error isolation Hard requirement

Some undocumented behaviors may differ once execution moves into Rust — mutation timing, threading model, internal exception types. These are explicitly not guaranteed.

ABI Stability (cpex-ffi)

Documented as part of Phase 1b (FFI/Go bindings):

  • ABI version functioncpex_abi_version() returns a version integer; consumers check at init.
  • Ownership rules — Every handle-returning function documents who owns the handle and which _free function releases it.
  • Semver for cpex.h — Breaking changes to the C header require a major version bump.
  • Stable vs unstable surfaces — Functions prefixed cpex_experimental_ are not covered by stability guarantees.

Conformance Tests

Since the goal is shared semantics across languages, a common test corpus will be introduced in Phase 1c:

  • YAML scenario files specifying: hook type, plugins (mode, priority, config), payload, expected outcome (allow/deny/modify).
  • Each scenario runs against both the existing Python PluginManager and the Rust core (via Go bindings initially, Python bindings later).
  • Covers: hook ordering, allow/deny behavior, mutation rules, timeout semantics, error handling modes (fail/ignore/disable).
  • Scenarios are the source of truth for behavioral compatibility — if a scenario passes in both runtimes, the behavior is guaranteed.

Alternatives Considered

1. Keep Python PluginManager, add Rust only for hot paths (APL, session)

  • Pro: Less work. Already partially done with apl-core PyO3.
  • Con: Every new consumer (Go, Envoy) must reimplement the PluginManager. CMF invariants remain unenforced.
  • Rejected because: The PluginManager is the value — policy enforcement, phase ordering, capability gating. Optimizing leaves only gives you speed; a shared core gives you correctness across languages.

2. Use gRPC/HTTP between language runtimes instead of FFI

  • Pro: No cgo, no PyO3. Language-independent via network.
  • Con: ~1-10ms per call (network + serialization). Unacceptable for pre/post-invoke hooks on every tool call in a gateway hot path.
  • Rejected because: The plugin pipeline runs on every request. FFI overhead is ~50-200ns; gRPC is 1000-10000x more.

3. Single cpex-ffi crate that bundles everything

  • Pro: One library to link.
  • Con: Go consumers pull in PyO3 + wasmtime even if they don't need them. Compilation time explodes.
  • Rejected because: Feature-gated cpex-hosts + separate cpex-ffi keeps the dependency graph lean per consumer.

Additional Context

  • Existing Rust work: apl-core (APL evaluator + token service) already compiles via maturin with PyO3 bindings — same pattern cpex-python will use.
  • ContextForge gateway: Primary Python consumer. Backward compatibility with existing Python plugins is a hard requirement (Phase 5).
  • Kagenti: Primary Go consumer. Go bindings (Phase 7) enable shared policy enforcement across both gateways.

Metadata

Metadata

Assignees

Projects

Status

In progress

Relationships

None yet

Development

No branches or pull requests

Issue actions