Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Ferricel compiles CEL (Common Expression Language) expressions into WebAssembly modules.

The produced .wasm files can then be executed in any Wasm runtime.

Components

Ferricel provides the following components:

  • ferricel: a CLI tool that can be used to either compile or run a .wasm module produced by it
  • ferricel-core: the pure Rust crate used by ferricel CLI. It can be used to embed a compiler or a runtime inside of your Rust program

The next sections cover in depth how to handle compilation of CEL expressions and how to run them.

Spec

The “Wasm Spec” section illustrates the low level details of the WebAssembly modules produced by the ferricel compiler.

Compilation

The ferricel CLI tool can be used to build a CEL program to WebAssembly. You can find more details here.

CEL Compliance

Ferricel targets full compliance with the CEL specification and the cel-go extension libraries. Conformance is validated against the official CEL conformance test suite.

Specification Coverage

The next sections outline which parts of the different CEL specifications are currently supported by the ferricel compiler.

Core CEL Specification

FeatureStatus
Integer arithmetic (+, -, *, /, %) with overflow detectionSupported
Floating-point arithmeticSupported
Unsigned integer (uint) arithmeticSupported
Boolean logic (&&, ||, !) with short-circuit evaluationSupported
Comparison operators (==, !=, <, <=, >, >=)Supported
String operations (+, size, contains, startsWith, endsWith, matches)Supported
Bytes operationsSupported
List literals, indexing, membership (in)Supported
Map literals, field access, indexingSupported
Conditional expressions (? :)Supported
Null handling and null propagationSupported
Type conversions (int(), uint(), double(), string(), bytes(), bool())Supported
Timestamp and Duration typesSupported
Timestamp/Duration arithmetic and field accessorsSupported
size() functionSupported
Single-variable comprehensions (all, exists, exists_one, map, filter)Supported
Logical error propagation through && / ||Supported
Optional types (optional.of, optional.none, .orValue, .value)Supported
Protocol Buffer message construction and field accessSupported
Protobuf wrapper type semantics (google.protobuf.*Value)Supported
dyn() type erasureSupported
type() introspectionSupported

cel-go Extension Libraries

ExtensionFunctionsStatus
Bindingscel.bind(var, init, body)Supported
Encodersbase64.encode, base64.decodeSupported
Mathmath.greatest, math.leastSupported
math.bitOr, math.bitAnd, math.bitXor, math.bitNotSupported
math.bitShiftLeft, math.bitShiftRightSupported
math.ceil, math.floor, math.round, math.truncSupported
math.abs, math.signSupported
math.isInf, math.isNaN, math.isFiniteSupported
math.sqrtSupported
StringscharAt, indexOf, lastIndexOfSupported
lowerAscii, upperAscii, trimSupported
replace, split, substring, joinSupported
reverse, strings.quoteSupported
format (string interpolation)Supported
Listsslice, flatten, distinct, reverseSupported
sort, sortBySupported
first, lastSupported
lists.range(n)Supported
Setssets.contains, sets.equivalent, sets.intersectsSupported
TwoVarComprehensionsall(i, v, pred), exists(i, v, pred), existsOne(i, v, pred)Supported
transformList(i, v, [filter,] expr)Supported
transformMap(i, v, [filter,] expr)Supported
transformMapEntry(i, v, [filter,] mapExpr)Supported
Regexregex.replace, regex.extract, regex.extractAllSupported
Protosproto.getExt, proto.hasExtNot supported

Kubernetes CEL Extensions

Ferricel also supports the Kubernetes CEL validation libraries:

ExtensionStatus
IP address functions (ip(), isIP(), family(), etc.)Supported
CIDR functions (cidr(), isCIDR(), containsIP(), etc.)Supported
URL functions (url(), isURL(), getHost(), etc.)Supported
Quantity functions (quantity(), isQuantity(), add(), sub(), etc.)Supported
Semver functions (semver(), isSemver(), major(), minor(), patch(), etc.)Supported
Format validation (format.named(), format.dns1123Label(), etc.)Supported
List extensions (isSorted(), sum(), min(), max())Supported
Regex extensions (find(), findAll())Supported

Conformance Tests

This is an overview of the current status of the conformance tests:

Test SuiteSuccessfulFailedSkipped
basic4120
bindings_ext800
block_ext18811
comparisons40600
conversions10900
encoders_ext400
fp_math3000
integer_math6400
lists3900
logic3000
macros24600
macros4400
math_ext19900
namespace1130
network_ext6900
optionals7000
parse1287417
string_ext21600
string5100
timestamps7600
type_deduction17129
Total16768857

Wasm Spec

This chapter provides the low-level details about the WebAssembly module produced by the ferricel compiler.

This information is useful if you plan to load the .wasm modules directly without using the helpers provided by the ferricel-core crate.

Data Exchange

The host and the Wasm guest exchange data using the JSON format.

The host reads and writes data to the WebAssembly linear memory.

Several functions pass or return memory regions through a single i64 value. The rest of this document refers to this value as “packed pointer”.

The encoding packs a pointer and a byte length into the two 32-bit halves:

bits  0–31  → pointer (offset into Wasm linear memory)
bits 32–63  → byte length

In pseudo-code:

packed = (len as i64) << 32 | (ptr as i64)
ptr    = (packed & 0xFFFF_FFFF) as u32
len    = (packed >> 32) as u32

Memory Management

Important

The code produced by the compiler uses a leaking allocator: memory is never released during evaluation.

Given the short lifetime of a CEL program, this is not an issue in practice.

Do not reuse an instantiated WASM module for multiple evaluations—its memory usage will keep growing. Instead, instantiate a new WASM module for each evaluation.

Exported Functions

cel_malloc(len: usize) -> *mut u8

Allocates len bytes in Wasm linear memory and returns a pointer to the allocated buffer.

ParameterTypeDescription
lenusizeNumber of bytes to allocate.

Returns a pointer to the newly allocated buffer within Wasm linear memory.

The allocator is a bump-pointer arena: dealloc is a no-op and memory is released only when the host drops the Wasm instance. Hosts must call this function to obtain a valid buffer before writing input data (e.g. bindings) into Wasm memory.

Use this function to load into the instantiate Wasm module the data to be processed.


cel_set_log_level(level: i32)

Sets the minimum log level for messages emitted via env::cel_log.

ParameterTypeDescription
leveli32Log level threshold (see table below). Values outside the valid range are clamped.

The log levels are:

ValueLevel
0Debug
1Info (default)
2Warn
3Error

Messages below the configured level are suppressed and env::cel_log will not be called for them.


evaluate(bindings: i64) -> i64

Evaluates the compiled CEL expression using JSON-encoded variable bindings.

ParameterTypeDescription
bindingsi64Packed pointer to a UTF-8 JSON object mapping variable names to their values.

The bindings value points to the data previously loaded via cel_malloc.

Returns a packed i64 pointing to a UTF-8 JSON string.

For a plain CEL module, the string is the result of the expression. If the expression produces a runtime error (overflow, divide-by-zero, unbound variable, etc.) the module traps and the host receives an error from the call.

For a VAP module, the string is a ValidationResponse object: {"accepted": true} or {"accepted": false, "message": "...", "code": N}. Both forms can carry a warnings list. See Response Shape for the fields, and Runtime Errors for when the module traps and when it returns a warning instead.


evaluate_proto(bindings: i64) -> i64

Evaluates the compiled CEL expression using Protobuf-encoded variable bindings.

ParameterTypeDescription
bindingsi64Packed pointer to a serialized ferricel.Bindings protobuf message.

Returns a packed i64 pointing to a UTF-8 JSON string that contains the result of the CEL expression (same format as evaluate).

Runtime errors cause a trap, identical to evaluate.

Imported Functions

The module imports three functions from the env module. All three must be provided (or stubbed) by the host at instantiation time.

env::cel_log(ptr: i32, len: i32)

Called by the runtime to emit a structured log event.

ParameterTypeDescription
ptri32Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object.
leni32Byte length of the JSON payload.

The LogEvent object is a JSON structure like:

{
  "level": "error",
  "message": "division by zero",
  "file": "main.rs",
  "line": 42,
  "column": 15,
  "extra": { ... }
}

The level field is one of "error", "warn", "info", or "debug". The extra field is optional and contains arbitrary key-value pairs.

A host that does not need log output can satisfy this import with a no-op function.


env::cel_abort(packed: i64)

Called by the runtime when a fatal runtime error occurs (e.g. divide-by-zero, integer overflow, unbound variable, a failed host extension call).

ParameterTypeDescription
packedi64Packed pointer to a UTF-8 JSON document.

The JSON document is a CelRuntimeError (defined in ferricel-types):

{ "message": "divide by zero" }

When a host extension produced the error, the document also has an origin field:

{
  "message": "configmap not found",
  "origin": { "namespace": "kw.k8s", "function": "get" }
}

The host implementation is expected to decode the document and return an error to the caller (e.g. by trapping or returning Err). The ferricel-core runtime returns an error that downcasts to ferricel_core::CelRuntimeError. The payload must be a valid JSON document of this shape. If it is not, ferricel-core returns a generic error that does not downcast to CelRuntimeError.


env::cel_call_extension(request: i64) -> i64

Invokes a host-provided extension function by name.

ParameterTypeDescription
requesti64Packed pointer to a UTF-8 JSON-encoded extension call request.

The request JSON has the structure:

{
  "namespace": "math",
  "function": "greatest",
  "args": [10, 20, 15]
}

Where namespace is optional (may be null), function is the function name, and args is an array of JSON-encoded CEL values.

Returns a packed i64 that points to a UTF-8 JSON string. The string is a JSON object with one key. The key is "ok" or "error" and names the outcome. On success:

{ "ok": 20 }

On failure:

{ "error": "math.greatest expects 3 argument(s), got 2" }

A failure has one of three causes: the extension is unknown, the argument count does not match the registered count, or the implementation returned an error.

The "ok" payload is a serialized CelValue. For example, 20 is an int, and a JSON object or array is a map or list. The "error" payload is always a string. The tag lets the guest tell a failure apart from a valid result that contains an "error" key. For example, {"ok": {"error": "not found"}} is a map value, not a failure.

Before the host calls an extension, it must make sure that args.len() matches the registered argument count. If the count is wrong, the host must return {"error": ...}. It must not read args out of bounds. ferricel_core::runtime::Extensions does this check for all extensions, so an implementation does not have to. When the guest receives {"error": ...}, it creates a CEL runtime error and records the extension (namespace and function) as its origin. The && and || operators can absorb this error like any other runtime error. If no operator absorbs it, the evaluation fails and the origin reaches the host through cel_abort.

The Wasm module calls this import whenever a compiled CEL expression invokes a function that was registered as a host extension at compile time. The host is responsible for dispatching the call to the correct implementation based on the namespace and function fields in the request. If no extensions are used, this import can be satisfied with a stub that traps.

Note

This import is only present if the compiled CEL expression uses host extensions.

Producers Metadata

Each compiled module includes a standard WebAssembly producers custom section that records which tools produced it. The section has no semantic effect on execution and can be safely stripped, but it is useful for debugging and toolchain analytics.

The processed-by: ferricel entry names the compiler version, but it is not a compatibility check: nothing reads it before evaluation, and a tool that strips the section removes it. Use ABI Version for a check the runtime enforces.

Ferricel adds the following entries:

FieldNameVersion
languageCEL(empty)
processed-byferricelcrate version (e.g. 0.2.0-rc.1)

These are merged with entries already present in the embedded runtime template (contributed by the Rust toolchain), so the final section typically contains:

language:
    Rust
    CEL
processed-by:
    rustc: 1.95.0 (59807616e 2026-04-14)
    walrus: 0.26.1
    ferricel: 0.2.0-rc.1

Inspecting the section

Use wasm-tools to print the producers section in a human-readable form:

wasm-tools metadata show policy.wasm

Alternatively, wabt’s wasm-objdump can dump the raw bytes of the section:

wasm-objdump -s -j producers policy.wasm

ABI Version

Each compiled module embeds a ferricel.abi-version custom section. The content is the decimal ASCII text of a single number, for example 1.

The number names the shape of the imports, the exports, and the payloads that a compiled module and the ferricel-core runtime agree on. It changes only when that shape changes. It does not change on every release: most releases keep the same ABI version across several crate versions.

runtime::Builder::build_pre reads the section and compares it against the version this runtime supports. It returns Err before it links or instantiates the module when:

  • the section is missing (the module predates ABI versioning, or the section was stripped), or
  • the number does not match.

This check runs only when the module comes from raw bytes (Builder::with_wasm). It does not run for a pre-compiled wasmtime::Module (Builder::with_module), because the raw bytes are not available at that point.

A host that reads a module before deciding whether to load it can call ferricel_core::abi_version(&wasm), which returns Ok(None) for a module with no section and Ok(Some(n)) otherwise.

Inspecting the section

wasm-objdump -s -j ferricel.abi-version policy.wasm

Source Custom Sections

Each compiled module embeds the original source and a manifest of host extensions as raw UTF-8 custom sections, making it possible to recover this information from a .wasm file without any external metadata.

Section nameContentProduced by
ferricel.cel-sourceThe original CEL expression[compile()]
ferricel.vap-sourceThe full ValidatingAdmissionPolicy serialized as YAML[compile_vap()], [compile_vap_from_policy()]
ferricel.extensionsJSON array of host extensions used by this moduleall compile paths
ferricel.vap-variablesJSON array of well-known VAP variables referenced by this policy[compile_vap()], [compile_vap_from_policy()]

ferricel.extensions section

The ferricel.extensions section contains a JSON array of objects, sorted by (namespace, function), listing every host extension that the module may call at evaluation time:

[
  { "namespace": null, "function": "abs" },
  { "namespace": "kw.k8s", "function": "get" },
  { "namespace": "kw.k8s", "function": "list" },
  { "namespace": "kw.net", "function": "lookupHost" }
]

namespace is null for flat (non-namespaced) extensions. The section is always present; it is an empty array [] when the module uses no host extensions.

The section records extensions that may be called — due to CEL’s short-circuit operators (&&, ||), an extension in the list might not be invoked for every evaluation. A host should use the list to decide which extension implementations to register, not as a guarantee that all listed extensions will be called.

Read the section at runtime with ferricel_core::extensions_used:

#![allow(unused)]
fn main() {
use ferricel_core::extensions_used;

let wasm = std::fs::read("policy.wasm")?;
for ext in extensions_used(&wasm)? {
    println!("{}/{}", ext.namespace.as_deref().unwrap_or("(none)"), ext.function);
}
}

ferricel.vap-variables section

Only present in modules produced by [compile_vap()] / [compile_vap_from_policy()]. It contains a sorted JSON array of strings naming which of the well-known VAP variables — object, oldObject, request, params, namespaceObject, authorizer — the policy actually references (matchConditions, spec.variables[] expressions, validations, and messageExpressions are all considered):

["namespaceObject", "object"]

Some of these variables require extra host-side setup — most notably namespaceObject: the compiled module fetches the Namespace itself through the kw.k8s.get host extension (also recorded in ferricel.extensions), so the host must register that extension and grant the policy access to v1/Namespace in its own authorization model. Kubewarden and similar hosts can inspect this section at policy-setup time to decide whether that extra wiring is needed, instead of always granting access unconditionally or trying to detect usage by re-parsing the CEL/YAML source.

The section is absent for plain CEL modules (compile()), and only ever lists names from the well-known set above — internal implementation details such as the VAP variables map, or unrelated identifiers, are never recorded.

Read the section at runtime with ferricel_core::vap_variables_used:

#![allow(unused)]
fn main() {
use ferricel_core::vap_variables_used;

let wasm = std::fs::read("policy.wasm")?;
if vap_variables_used(&wasm)?.iter().any(|v| v == "namespaceObject") {
    println!("policy needs the v1/Namespace grant and a kw.k8s.get handler");
}
}

Inspecting source sections

# Print the CEL expression embedded in a compiled module
wasm-objdump -s -j ferricel.cel-source policy.wasm

# Print the VAP YAML embedded in a compiled module
wasm-objdump -s -j ferricel.vap-source policy.wasm

# Print the extensions manifest
wasm-objdump -s -j ferricel.extensions policy.wasm

# Print the referenced VAP variables
wasm-objdump -s -j ferricel.vap-variables policy.wasm

With wasm-tools, the raw UTF-8 content can be extracted directly:

wasm-tools dump policy.wasm | grep -A1 "ferricel.cel-source"

Inspecting a Module

The ferricel inspect command reads all embedded metadata and prints it in a human-readable form with syntax highlighting:

ferricel inspect policy.wasm

Output (with color):

Module: policy.wasm
ABI version: 1

Source (ValidatingAdmissionPolicy):
  apiVersion: admissionregistration.k8s.io/v1
  ...

Host extensions (may be called):
  - kw.k8s/get
  - kw.net/lookupHost

VAP variables (referenced):
  - namespaceObject
  - object

Exports: cel_malloc, evaluate, evaluate_proto
Producers:
  language: CEL, Rust
  processed-by: rustc 1.95.0, walrus 0.26.1, ferricel 0.2.0-rc.1

The source is syntax-highlighted: YAML for VAP modules, CEL for plain modules. The theme is chosen automatically based on the terminal background (light or dark), using Solarized Light or Solarized Dark respectively.

For machine-readable output, use --json:

ferricel inspect --json policy.wasm

Disable color with --no-color (e.g. for CI or piping):

ferricel inspect --no-color policy.wasm

Compiling a CEL Program to Wasm

The ferricel build command compiles a CEL expression into a self-contained WebAssembly module.

The compiler removes all unused runtime functions from the final Wasm module. Beyond that, it performs no additional optimizations. For smaller or faster binaries, you can post-process the output with wasm-opt.

Basic Usage

Pass a CEL expression directly with --expression:

ferricel build --expression '1 + 1' --output result.wasm

Or provide the expression in a file with --expression-file:

ferricel build --expression-file my_program.cel --output result.wasm

The two flags are mutually exclusive. If --output is omitted, the module is written to final_cel_program.wasm in the current directory.

Host Extensions

Host extensions allow the compiled CEL expression to call functions implemented by the host at evaluation time. See the Host Extensions chapter for full documentation, including CLI flags, the Rust API, and builder chains.

Defining a CEL container

A CEL container acts as a namespace for type name resolution. When a container is set, unqualified type names are resolved relative to it first.

For example, with --container google.protobuf you can write Timestamp instead of google.protobuf.Timestamp in your CEL expression:

ferricel build \
  --expression 'Timestamp{seconds: 0}' \
  --container google.protobuf \
  --proto-descriptor descriptor.pb \
  --output result.wasm

Using Proto

To use Protocol Buffer message types in a CEL expression, provide a compiled descriptor set with --proto-descriptor. The flag can be repeated to merge multiple descriptor files:

ferricel build \
  --expression 'req.user_id != ""' \
  --proto-descriptor api.pb \
  --output result.wasm

Generate a descriptor set from your .proto files with protoc:

protoc --descriptor_set_out=api.pb --include_imports api.proto

Multiple descriptor files are merged automatically:

ferricel build \
  --expression 'a.field == b.field' \
  --proto-descriptor types_a.pb \
  --proto-descriptor types_b.pb \
  --output result.wasm

Running a CEL program compiled to Wasm

There are two ways to run a CEL program compiled to Wasm:

  • ferricel run CLI — simple command-line execution; does not support host extensions.
  • ferricel-core Rust crate — more flexible; supports host extensions and custom configurations.

Using the ferricel CLI

Here’s a simple CEL program. Save it to a file named validate-balance.cel:

account.balance >= transaction.withdrawal
    || (account.overdraftProtection
    && account.overdraftLimit >= transaction.withdrawal  - account.balance)

Compile it to a Wasm module:

ferricel build --expression-file validate-balance.cel -o validate-balance.wasm

Create a bindings file named validate-balance.bindings.json with the input data:

{
  "account": {
    "balance": 500,
    "overdraftProtection": true,
    "overdraftLimit": 1000
  },
  "transaction": {
    "withdrawal": 700
  }
}

Run the compiled Wasm module with the bindings:

ferricel run \
    --bindings-file validate-balance.bindings.json \
    validate-balance.wasm

Output:

true

Alternatively, pass bindings as an inline JSON string:

ferricel run \
    --bindings-json '{"account":{"balance":500,"overdraftProtection":true,"overdraftLimit":0},"transaction":{"withdrawal":700}}' \
    validate-balance.wasm 

Output:

false

Using the ferricel-core Rust crate

The ferricel-core crate provides programmatic control over compilation and evaluation. This is useful when you need to evaluate many expressions, integrate CEL into a larger system, or use host extensions.

Basic evaluation

Compile a CEL expression and evaluate it with bindings:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};

let wasm = compiler::Builder::new()
    .build()
    .compile("x * 2 + 1")?;

let result = runtime::Builder::new()
    .with_wasm(wasm)
    .build()?
    .eval(Some(r#"{"x": 10}"#))?;

assert_eq!(result, "21");
}

The eval method accepts JSON-encoded variable bindings and returns a JSON-encoded result string.

Host extensions

Register host-provided functions that the CEL expression can call. Extensions are declared at compile time and implemented at runtime:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::ExtensionDecl;

let abs_decl = ExtensionDecl {
    namespace: None,
    function: "abs".to_string(),
    receiver_style: false,
    global_style: true,
    num_args: 1,
};

let wasm = compiler::Builder::new()
    .with_extension(abs_decl.clone())
    .build()
    .compile("abs(x)")?;

let result = runtime::Builder::new()
    .with_extension(abs_decl, |args| {
        let n = args[0].as_i64().unwrap_or(0);
        Ok(serde_json::Value::Number(n.abs().into()))
    })
    .with_wasm(wasm)
    .build()?
    .eval(Some(r#"{"x": -42}"#))?;

assert_eq!(result, "42");
}

For full documentation on flat extensions, dotted namespaces, and builder chains, see the Host Extensions chapter.

Logging

Configure logging during evaluation with a custom logger and log level:

#![allow(unused)]
fn main() {
use ferricel_core::runtime;
use ferricel_types::LogLevel;
use slog::{Drain, Logger, o};

let decorator = slog_term::PlainSyncDecorator::new(std::io::stderr());
let drain = slog_term::FullFormat::new(decorator).build().fuse();
let logger = Logger::root(drain, o!());

let result = runtime::Builder::new()
    .with_logger(logger)
    .with_log_level(LogLevel::Debug)
    .with_wasm(wasm)
    .build()?
    .eval(Some(r#"{"x": 10}"#))?;
}

Custom Wasmtime configuration

For advanced use cases, provide your own wasmtime::Engine with custom settings (e.g. fuel metering, epoch interruption):

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use wasmtime::{Config, Engine as WasmEngine};

let mut config = Config::new();
config.consume_fuel(true);

let wasm_engine = WasmEngine::new(&config)?;
let wasm = compiler::Builder::new().build().compile("1 + 1")?;

let result = runtime::Builder::new()
    .with_engine(wasm_engine)
    .with_wasm(wasm)
    .build()?
    .eval(None)?;
}

Timeouts via epoch interruption

To bound evaluation time, enable Config::epoch_interruption on the engine, set a deadline via with_epoch_deadline, and drive Engine::increment_epoch() from a background thread:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use wasmtime::{Config, Engine as WasmEngine};
use std::time::Duration;

let mut config = Config::new();
config.epoch_interruption(true);

let wasm_engine = WasmEngine::new(&config)?;
let wasm = compiler::Builder::new().build().compile("1 + 1")?;

// Drive the epoch forward periodically; this determines the granularity of
// the timeout below.
let ticker_engine = wasm_engine.clone();
std::thread::spawn(move || {
    loop {
        std::thread::sleep(Duration::from_millis(10));
        ticker_engine.increment_epoch();
    }
});

let result = runtime::Builder::new()
    .with_engine(wasm_engine)
    // Trap once the epoch advances by 1 tick beyond the current one, i.e.
    // after roughly one ticker interval (~10ms here).
    .with_epoch_deadline(1)
    .with_wasm(wasm)
    .build()?
    .eval(None)?;
}

Important

If the supplied engine has epoch_interruption(true) set but no deadline is configured via with_epoch_deadline, every evaluation traps immediately — this is wasmtime’s documented behavior for a Store with no configured deadline on an interruption-enabled engine.

Resource limits

To bound the amount of linear memory (and table elements) a single evaluation is allowed to allocate, use with_resource_limits. This leverages wasmtime’s ResourceLimiter facility (via wasmtime::StoreLimits) and protects the host from a malicious, or misbehaving, CEL expression that keeps growing memory — for example building up huge strings or lists in a comprehension:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_core::runtime::ResourceLimits;

let wasm = compiler::Builder::new().build().compile("1 + 1")?;

let result = runtime::Builder::new()
    .with_resource_limits(ResourceLimits {
        max_memory_size: Some(64 * 1024 * 1024), // 64 MiB
        ..Default::default()
    })
    .with_wasm(wasm)
    .build()?
    .eval(None)?;
}

When a limit is exceeded, the corresponding memory.grow/table.grow Wasm instruction fails and returns -1 to the guest. The ferricel guest runtime treats a failed allocation as a fatal error and aborts, which is reported back as an Err from eval/eval_proto — the memory/table cap itself is always enforced by the host regardless of how the guest reacts to the failed growth.

Protobuf bindings

For type-safe variable bindings that preserve full fidelity (bytes, uint, timestamp, duration, etc.), use eval_proto with protobuf-encoded bindings:

let result = engine.eval_proto(&bindings_proto_bytes)?;

This avoids the JSON round-trip and preserves exact type information.

Memory management and performance

Important

CEL programs compiled to Wasm use a leaking allocator: memory is never deallocated during evaluation. This is not an issue in practice because CEL expressions are short-lived.

However, the consequence is that each evaluation starts with a fresh Wasm instance. This ensures no memory carries over between calls and prevents unbounded memory growth.

To optimize performance despite this, the Builder uses wasmtime::InstancePre to pre-link the module at build time. This amortizes compilation cost:

  • Builder::build() parses the Wasm bytes once and pre-links all host functions.
  • Each eval() call only pays the cost of instantiation.

For workloads with many evaluations, creating a single Engine and reusing it across multiple eval() calls is the recommended pattern.

Even though memory does not carry over between calls, a single evaluation can still allocate an unbounded amount of memory (e.g. a pathological expression building a huge string or list). Use with_resource_limits to cap this.

Host Extensions

Host extensions allow compiled CEL expressions to call functions implemented by the host at evaluation time. Extensions follow a two-phase model:

  1. Compile time — declare the extension so the compiler emits a real host call instead of a static no matching overload error.
  2. Runtime — register an implementation that the Wasm module invokes via the cel_call_extension host import.

ferricel supports two kinds of host extensions:

  • Flat extensions — standalone functions like math.abs(x) or kw.net.lookupHost(host).
  • Builder chains — fluent APIs like kw.k8s.apiVersion("v1").kind("Pod").list() where intermediate calls accumulate state into a map and a terminal call invokes the host.

Flat Extensions

CLI

Extensions can be declared inline with --extensions (repeatable):

ferricel build \
  --expression 'math.sqrt(x)' \
  --extensions math.sqrt:global:1 \
  --output result.wasm

Or in a JSON file with --extensions-file:

ferricel build \
  --expression 'math.sqrt(x)' \
  --extensions-file extensions.json \
  --output result.wasm

The two flags are mutually exclusive.

--extensions format

Each --extensions value follows the pattern [namespace.]function:style:arity:

SegmentDescription
namespaceOptional dot-separated namespace prefix (e.g. math in math.sqrt).
functionFunction name (the last dot-separated segment).
styleOne of global, receiver, or both (see below).
arityTotal number of arguments the host receives, including the receiver for receiver-style calls.

Calling styles:

StyleInvocation form
globalfunc(args) or ns.func(args)
receivervalue.func(extra_args) — receiver is always args[0]
bothBoth of the above

Examples:

--extensions abs:global:1            # abs(x) — 1 arg
--extensions math.sqrt:global:1      # math.sqrt(x) — namespace "math", 1 arg
--extensions math.pow:global:2       # math.pow(base, exp) — 2 args
--extensions reverse:receiver:1      # x.reverse() — receiver counts as the 1 arg
--extensions greet:both:2            # greet(name, lang) and name.greet(lang)

--extensions-file format

The file must contain a JSON array of extension declaration objects:

[
  { "namespace": "math", "function": "sqrt",
    "global_style": true, "receiver_style": false, "num_args": 1 },
  { "namespace": null, "function": "reverse",
    "global_style": false, "receiver_style": true, "num_args": 1 },
  { "namespace": null, "function": "greet",
    "global_style": true, "receiver_style": true, "num_args": 2 }
]

Rust API

Use ExtensionDecl to declare the extension at compile time, and with_extension to provide the implementation at runtime:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::ExtensionDecl;

// Declare the extension at compile time
let abs_decl = ExtensionDecl {
    namespace: None,
    function: "abs".to_string(),
    receiver_style: false,
    global_style: true,
    num_args: 1,
};

// Compile the CEL expression with the extension
let wasm = compiler::Builder::new()
    .with_extension(abs_decl.clone())
    .build()
    .compile("abs(x)")?;

// Register the implementation at runtime
let result = runtime::Builder::new()
    .with_extension(abs_decl, |args| {
        let n = args[0].as_i64().unwrap_or(0);
        Ok(serde_json::Value::Number(n.abs().into()))
    })
    .with_wasm(wasm)
    .build()?
    .eval(Some(r#"{"x": -42}"#))?;

assert_eq!(result, "42");
}

The extension declaration specifies the function signature at compile time (for validation), and the implementation is provided at runtime. The host is responsible for marshalling JSON values to and from the extension function.

Runtime guarantees

The compiler checks num_args at each call site. The runtime checks it again each time the Wasm module calls cel_call_extension. The second check is necessary because a .wasm file can come from a source other than the ferricel compiler. As a result, the host cannot trust the argument count in an extension call request.

runtime::Builder::with_extension and the Extensions type store the ExtensionDecl with the implementation. If the argument count of a call does not match decl.num_args, the runtime rejects the call with an error. It does not call the closure. So an implementation can trust args.len() == decl.num_args and read args[0], args[1], and so on.

A failure at the extension boundary becomes a CEL runtime error. This includes four cases: an unknown extension, a rejection by the extension authorizer, a wrong argument count, and an Err(_) value from the implementation. The && and || operators can absorb this error. For example, x.myFunc() || true evaluates to true when myFunc fails. If no operator absorbs the error, the evaluation fails, the same way a division by zero fails.

In that case Engine::eval returns an error that downcasts to ferricel_core::CelRuntimeError. Its origin field names the extension (namespace and function) that produced the error. A runtime error from a built-in operator has no origin.

The runtime cannot check the call style (receiver_style and global_style). The wire format does not contain this information. Only the compiler checks the call style, at the CEL call sites.

Reject a call before its arguments are parsed

A host often decides whether it allows a call from the extension name alone. For example, a policy engine grants each policy a set of host capabilities, one per (namespace, function). Such a host does not need the arguments to make this decision.

An extension authorizer is a hook that runs before the runtime parses the arguments. Register one to reject a call by name alone. A denied call then costs the host almost nothing, no matter the size of the arguments.

The runtime calls the authorizer with the ExtensionKey of each call. It calls the authorizer after it finds the extension and before it parses args. Err(msg) rejects the call. The guest receives msg as a CEL runtime error, with the extension as its origin, the same way it receives an Err(_) from the implementation. The implementation does not run.

#![allow(unused)]
fn main() {
use ferricel_core::runtime;

let allowed = ["abs", "reverse"];

let engine = runtime::Builder::new()
    .with_extension(abs_decl, abs_impl)
    .with_extension_authorizer(move |key| {
        if allowed.contains(&key.function.as_str()) {
            Ok(())
        } else {
            Err(format!("extension {} is not allowed", key.function))
        }
    })
    .with_wasm(wasm)
    .build()?;
}

You can also set this hook on Extensions, with with_extension_authorizer and set_extension_authorizer, for the EnginePre::rehydrate path. Build a new Extensions per request when the decision depends on request context.

The authorizer sees only the name. A check that depends on argument values still belongs in the implementation. For example, a check on a Kubernetes kind needs the parsed arguments.

Dotted namespaces

Namespaces can contain dots, e.g. kw.net.lookupHost. The compiler resolves the full dotted target name from the CEL AST, so both single-segment (math) and multi-segment (kw.net) namespaces work identically:

#![allow(unused)]
fn main() {
let decl = ExtensionDecl {
    namespace: Some("kw.net".to_string()),
    function: "lookupHost".to_string(),
    receiver_style: false,
    global_style: true,
    num_args: 1,
};
}

Note: extensions declared at compile time but not implemented by the host produce a runtime error when the expression is evaluated.

Reuse extensions with EnginePre

runtime::Builder::build_pre links a Wasm module without extension implementations. For each request, call EnginePre::rehydrate with an Extensions value to get an Engine. The module is not compiled again. This is useful when you evaluate the same policy many times with a different context for each request (for example, a request-scoped logger):

#![allow(unused)]
fn main() {
use ferricel_core::runtime::{self, Extensions};
use ferricel_core::compiler;
use ferricel_types::extensions::ExtensionDecl;

let abs_decl = ExtensionDecl {
    namespace: None,
    function: "abs".to_string(),
    receiver_style: false,
    global_style: true,
    num_args: 1,
};

let wasm = compiler::Builder::new()
    .with_extension(abs_decl.clone())
    .build()
    .compile("abs(x)")?;

let engine_pre = runtime::Builder::new().with_wasm(wasm).build_pre()?;

let mut extensions = Extensions::new();
extensions.register(abs_decl, |args| {
    let n = args[0].as_i64().unwrap_or(0);
    Ok(serde_json::Value::Number(n.abs().into()))
});

let logger = slog::Logger::root(slog::Discard, slog::o!());
let result = engine_pre
    .rehydrate(extensions, logger, None)
    .eval(Some(r#"{"x": -42}"#))?;

assert_eq!(result, "42");
}

Extensions::decls() returns an iterator over all registered ExtensionDecl values. Pass these declarations to compiler::Builder::with_extension. Then the compiler and the runtime use the same argument count for each extension.

Builder Chains

Builder chains model fluent APIs where intermediate method calls accumulate state into a map and a terminal call invokes the host with the accumulated state. This is the pattern used by cel-go libraries like kw.k8s and kw.sigstore.

How it works

At runtime, each intermediate builder object is a CelValue::Object (map) with a reserved "__type__" key that tracks the builder’s current type. For example, after kw.k8s.apiVersion("v1").kind("Pod").namespace("default") the map is:

{
  "__type__": "kw.k8s.Client",
  "apiVersion": "v1",
  "kind": "Pod",
  "namespace": "default"
}

When a terminal method like .list() or .get("nginx") is called, the host receives this map as the single argument of the extension call.

Declaring a builder chain

A builder chain is declared with BuilderChainDecl, which contains a list of BuilderStep variants. Register it on the compiler builder with with_builder_chain:

#![allow(unused)]
fn main() {
use ferricel_core::compiler;
use ferricel_types::extensions::{BuilderChainDecl, BuilderStep};

let chain = BuilderChainDecl {
    steps: vec![
        BuilderStep::Entry { /* ... */ },
        BuilderStep::Chain { /* ... */ },
        BuilderStep::Terminal { /* ... */ },
    ],
};

let wasm = compiler::Builder::new()
    .with_builder_chain(chain)
    .build()
    .compile("my.api.start('val').method('arg').run()")?;
}

Step types

Entry

A global entry-point that starts a new chain. Called as a dotted global function (e.g. kw.k8s.apiVersion("v1")).

#![allow(unused)]
fn main() {
BuilderStep::Entry {
    function: "kw.k8s.apiVersion".into(),
    state_keys: vec!["apiVersion".into()],
    output_type: "kw.k8s.ClientBuilder".into(),
}
}
FieldDescription
functionFull dotted CEL function name.
state_keysJSON keys under which each positional argument is stored. The number of keys determines the expected arity.
output_typeType tag written to "__type__" in the output map.

Chain

A receiver-style chaining step that stores positional arguments under fixed keys. Supports single-arg and multi-arg steps.

#![allow(unused)]
fn main() {
// Single-arg: .kind("Pod")
BuilderStep::Chain {
    function: "kind".into(),
    input_type: "kw.k8s.ClientBuilder".into(),
    state_keys: vec!["kind".into()],
    output_type: "kw.k8s.Client".into(),
    accumulate: false,
}

// Multi-arg: .keyless("https://issuer", "user@example.com")
BuilderStep::Chain {
    function: "keyless".into(),
    input_type: "sig.VerifierBuilder".into(),
    state_keys: vec!["issuer".into(), "subject".into()],
    output_type: "sig.KeylessVerifier".into(),
    accumulate: false,
}
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver. Used for compile-time disambiguation.
state_keysJSON keys for each positional argument. Determines arity.
output_typeType tag for the output map.
accumulateWhen true, values are appended to an array under each key instead of overwriting (e.g. repeated .fieldMask() calls).

MapEntry

A receiver-style step that inserts a runtime key/value pair into a nested map. Always takes exactly 2 arguments: arg0 is the map key, arg1 is the value. Repeated calls merge into the same nested map.

#![allow(unused)]
fn main() {
// .annotation("env", "prod") → annotations["env"] = "prod"
BuilderStep::MapEntry {
    function: "annotation".into(),
    input_type: "sig.VerifierBuilder".into(),
    state_key: "annotations".into(),
    output_type: "sig.VerifierBuilder".into(),
}
}

After .annotation("env", "prod").annotation("team", "sec"), the state map contains:

{
  "__type__": "sig.VerifierBuilder",
  "annotations": { "env": "prod", "team": "sec" }
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver.
state_keyField name of the nested map in the state object.
output_typeType tag for the output map.

Note: the choice between Chain and MapEntry is made by the extension author, not inferred. The CEL call site looks identical — .annotation("env", "prod") could be either — but the semantics differ: Chain stores arguments under fixed positional keys; MapEntry uses arg0 as a dynamic key into a nested map and accumulates across repeated calls.

Terminal

A terminal step that invokes the host with the accumulated state map. Extra positional arguments (if any) are folded into the map before the call.

#![allow(unused)]
fn main() {
// Zero-arg terminal: .list()
BuilderStep::Terminal {
    function: "list".into(),
    input_type: "kw.k8s.Client".into(),
    extra_arg_keys: vec![],
    host_namespace: "kw.k8s".into(),
    host_function: "list".into(),
}

// One-arg terminal: .get("nginx") — folds "name" into the map
BuilderStep::Terminal {
    function: "get".into(),
    input_type: "kw.k8s.Client".into(),
    extra_arg_keys: vec!["name".into()],
    host_namespace: "kw.k8s".into(),
    host_function: "get".into(),
}
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver.
extra_arg_keysKeys for extra positional arguments folded into the map before the host call. Empty for zero-arg terminals.
host_namespaceNamespace in the ExtensionCallPayload sent to the host.
host_functionFunction name in the ExtensionCallPayload sent to the host.

The host must also register a flat ExtensionDecl for each terminal so the runtime can dispatch the call:

#![allow(unused)]
fn main() {
let ext = ExtensionDecl {
    namespace: Some("kw.k8s".to_string()),
    function: "list".to_string(),
    global_style: false,
    receiver_style: false,
    num_args: 1,  // the accumulated map is the single argument
};
}

Disambiguation

When multiple chains register the same method name (e.g. both kw.sigstore and kw.crypto define a .verify() terminal), the compiler disambiguates at compile time using two criteria:

  1. Receiver type — each step declares an input_type. The compiler tracks the static __type__ of the receiver expression through the chain and selects the step whose input_type matches.

  2. Argument count — steps with the same method name and receiver type but different arities (e.g. .githubAction("owner") vs .githubAction("owner", "repo")) are distinguished by the number of positional arguments, determined by state_keys.len() for Chain steps and extra_arg_keys.len() for Terminal steps.

If disambiguation produces zero or more than one candidate, the compiler reports an error.

Complete example

This example declares a small query builder chain, compiles an expression that uses it, and evaluates it with a host implementation that receives the accumulated state map:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::{BuilderChainDecl, BuilderStep, ExtensionDecl};

// A tiny query API:  query.field("age").between(18, 65).count()
let chain = BuilderChainDecl {
    steps: vec![
        BuilderStep::Entry {
            function: "query.field".into(),
            state_keys: vec!["field".into()],
            output_type: "query.Builder".into(),
        },
        // Multi-arg step: stores both bounds under "min" and "max".
        BuilderStep::Chain {
            function: "between".into(),
            input_type: "query.Builder".into(),
            state_keys: vec!["min".into(), "max".into()],
            output_type: "query.Range".into(),
            accumulate: false,
        },
        BuilderStep::Terminal {
            function: "count".into(),
            input_type: "query.Range".into(),
            extra_arg_keys: vec![],
            host_namespace: "query".into(),
            host_function: "count".into(),
        },
    ],
};

// Flat extension decl for the terminal
let count_ext = ExtensionDecl {
    namespace: Some("query".to_string()),
    function: "count".to_string(),
    global_style: false,
    receiver_style: false,
    num_args: 1,
};

// Compile
let wasm = compiler::Builder::new()
    .with_builder_chain(chain)
    .build()
    .compile("query.field('age').between(18, 65).count()")?;

// Run
let result = runtime::Builder::new()
    .with_extension(count_ext, |args| {
        // args[0] is the accumulated state map:
        // { "__type__": "query.Range",
        //   "field": "age", "min": 18, "max": 65 }
        let map = &args[0];
        assert_eq!(map["field"], "age");
        assert_eq!(map["min"], 18);
        assert_eq!(map["max"], 65);
        // A real host would run the query and return the matching row count.
        Ok(serde_json::json!(42))
    })
    .with_wasm(wasm)
    .build()?
    .eval(None)?;
}

For a real-world example, see the built-in kw.k8s builder chain used by ValidatingAdmissionPolicy support.

Inspecting Used Extensions

The compiler embeds a ferricel.extensions custom section into every compiled Wasm module. It contains a JSON array listing every host extension the module may call at evaluation time — one entry per unique (namespace, function) pair, sorted and deduplicated.

[
  { "namespace": null,     "function": "abs"        },
  { "namespace": "kw.k8s", "function": "get"        },
  { "namespace": "kw.net", "function": "lookupHost" }
]

The section is always present; it is an empty array [] for modules that use no host extensions.

Note: because CEL’s && and || operators do not short-circuit at compile time, an extension listed here may not be called for every evaluation. The list records what the module can call, not what it will call.

Reading the section from Rust

Use ferricel_core::extensions_used to read the section back:

#![allow(unused)]
fn main() {
use ferricel_core::extensions_used;

let wasm = std::fs::read("policy.wasm")?;
for ext in extensions_used(&wasm)? {
    println!("{}/{}", ext.namespace.as_deref().unwrap_or("(none)"), ext.function);
}
}

Returns an empty Vec if the section is absent (e.g. modules produced by an older version of ferricel).

Reading the section from the command line

Use ferricel inspect for a human-readable view of all embedded metadata, including the extensions list with syntax-highlighted source:

ferricel inspect policy.wasm

Or for just the raw extensions JSON:

wasm-objdump -s -j ferricel.extensions policy.wasm

For the full specification of all custom sections ferricel embeds, and documentation of the ferricel inspect command, see the Wasm Spec chapter.

Kubernetes ValidatingAdmissionPolicy Support

ferricel can compile a Kubernetes ValidatingAdmissionPolicy (VAP) into a self-contained WebAssembly module. The compiled module fetches Kubernetes data it needs — the namespace object and param resources — itself, through host extensions. The host supplies everything else (the resource under admission, the admission request) via bindings.

This is a ferricel-core library feature. Enable it with the k8s-vap Cargo feature.

Response Shape

The module’s evaluate export returns a JSON object.

Request accepted:

{ "accepted": true }

Or, on rejection:

{ "accepted": false, "message": "too many replicas", "code": 422 }

The message field comes from the failing validation’s message field, or from its messageExpression if one is specified. If neither is set, a default message is generated from the validation expression text. If the messageExpression produces a runtime error or a non-string value, the module falls back to the message field (or the default message), as Kubernetes does.

The code field is derived from the validation’s reason field:

reasonHTTP code
Forbidden403
Unauthorized401
RequestEntityTooLarge413
Invalid or unset422

Both responses can carry an optional warnings list of strings. The list is present only under failurePolicy: Ignore, and only when the module skipped at least one expression. The module skips an expression when it evaluates to a runtime error. See failurePolicy: Ignore.

Evaluation Order

The compiled module follows the evaluation order of the Kubernetes VAP validator:

  1. namespaceObject (only when the policy references it). The module resolves the Namespace of the resource under admission from request. See Namespace object for the rules.

  2. params (only when paramKind is set). The module resolves the list of param resources from paramRef. See Params for the rules. Without paramKind, the module evaluates the steps below once, with no params binding.

  3. For each param resource:

    1. matchConditions. The module evaluates every condition in declaration order first. If any evaluates to false, this param does not apply to the request. Only a false result skips the param; a non-boolean result, like Kubernetes, counts as a match. A false result wins even when another condition in the same param evaluates to a runtime error. Only when no condition is false does failurePolicy decide what an error means (see Runtime Errors). The module skips the remaining matchConditions, the variables, and the validations for this param, and moves to the next param. A skip is not a rejection. params and namespaceObject are both available in matchConditions.

    2. variables. Evaluated in declaration order. Each result is stored under variables.<name>. It is accessible to later variables expressions and to all validations. The module rebuilds the variables map for each param.

    3. validations. Evaluated in declaration order. The first expression that does not evaluate to true makes the module return a rejection response. A non-boolean result, for example a string or null, is a rejection, like Kubernetes. The module does not evaluate the remaining validations or the remaining params.

  4. When no param produced a rejection, the module returns {"accepted": true}. An empty param list also produces this response.

When several params match a paramRef.selector and more than one rejects the request, the module returns the first rejection only. Kubernetes aggregates every rejection message. See LIMITATIONS.md.

Runtime Errors

A CEL expression can evaluate to a runtime error. Examples: division by zero, a missing field, an unbound variable, or a host extension (such as kw.k8s) that returns an error.

The host passes the failurePolicy of the policy in the failurePolicy binding (see Required Bindings). The value is "Fail" or "Ignore". A missing or null binding means Fail. If the value is anything else, the module traps with a runtime error before the first expression runs. The module does not read spec.failurePolicy from the VAP. The host owns that value.

failurePolicy: Fail

When a validations expression evaluates to a runtime error, the module does not return {"accepted": true} or a rejection. The module traps, exactly like a plain CEL module does.

A matchConditions expression works the same way, but only when no condition in the same param is false. A false result always skips the param, even when another condition in the same param errors. This matches Kubernetes: a false match condition wins over a runtime error.

When the module traps, the call to evaluate fails, and Engine::eval() returns Err. The error downcasts to ferricel_core::CelRuntimeError. The host denies the request.

Engine::eval() can also fail for other reasons: an epoch-deadline interrupt, a memory limit, a Wasm trap, or a bug in a host extension. These errors do not downcast to CelRuntimeError, so the host can tell the two kinds apart. See Runtime Errors and failurePolicy for the host-side code.

failurePolicy: Ignore

The module applies the policy to each expression on its own, the same way Kubernetes does:

  • When a validations expression evaluates to an error, the module skips it. The next validation runs. A false result from any other validation rejects the request.
  • When a matchConditions expression evaluates to an error, and no condition in the same param is false, the module skips the current param, the same as for a false result. With paramKind, the next param runs. Without paramKind, the policy does not apply, and the module accepts the request. A false result in another condition of the same param wins over the error, and the module records no warning for it.

Each skipped expression adds one entry to the warnings list of the response. The list is present on both accept and reject responses. It is absent when the module skipped nothing:

{"accepted": true, "warnings": ["The module skipped validation[0] because the expression evaluated to an error (failurePolicy is Ignore): no such key: 'count'"]}
{"accepted": false, "message": "...", "code": 422, "warnings": ["The module skipped validation[0] because the expression evaluated to an error (failurePolicy is Ignore): no such key: 'count'"]}

The text names the expression (validation[<i>] or matchCondition '<name>') and carries the error message. With paramKind, the text names the param (params <namespace>/<name>), so an operator can tell the params apart. Kubernetes sends no client warning for a skipped expression. It records a metric and an audit annotation instead. The warnings are a ferricel addition.

Ignore does not cover the params and namespaceObject lookups. An error there always traps (see below). See LIMITATIONS.md.

Errors that are never errors

Two cases are not runtime errors under either policy:

  • A variables entry that evaluates to an error is stored as-is. The error propagates only into the expressions that reference variables.<name>. An erroring variable that no expression uses is harmless. This matches the lazy evaluation of variables in Kubernetes.
  • Errors absorbed by CEL short-circuit operators are not errors. For example, (1 / 0) == 1 || true evaluates to true.

Lookups that always trap

The params lookup traps before any matchConditions or validations run, under both Fail and Ignore. If the host’s kw.k8s extension fails, or the lookup finds no resource, and paramRef.parameterNotFoundAction is not Allow, the module traps. For a host error, the origin field of the CelRuntimeError is kw.k8s.get (for paramRef.name) or kw.k8s.list (for paramRef.selector). For an empty result, the message is no parameters found and origin is None. See Params.

The namespaceObject lookup traps before the params lookup, and before any matchConditions or validations run, when the request binding is missing or the host’s kw.k8s.get call fails. For a host error, origin is kw.k8s.get — the same origin a failed paramRef.name lookup uses, so a host that only checks origin == kw.k8s.get cannot tell the two apart from the error alone. Unlike params, there is no parameterNotFoundAction equivalent: a cluster-scoped request, or a request for the Namespace resource itself, does not trap — it makes namespaceObject null without calling the host at all. See Namespace object.

Known Limitations

The following VAP features are not yet implemented or are not part of ferricel’s scope:

FeatureStatusNotes
failurePolicyImplementedVia the failurePolicy binding (see Runtime Errors). Under Ignore, the module skips an expression that evaluates to an error and records a warning.
auditAnnotationsNot implementedRequires a separate compilation path and an additional field in the response JSON.
matchConstraintsOut of scopeThis is a server-side filter applied by the API server, not a CEL expression. The compiled module does not enforce it.

Compiling a ValidatingAdmissionPolicy to Wasm

From YAML

Pass the raw YAML string to compile_vap:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;

let yaml = std::fs::read_to_string("policy.yaml")?;
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
}

The YAML must contain exactly one ValidatingAdmissionPolicy document.

From a Parsed Policy

The ValidatingAdmissionPolicy type is defined by the k8s-openapi crate.

#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;
use k8s_openapi::api::admissionregistration::v1::ValidatingAdmissionPolicy;

let policy: ValidatingAdmissionPolicy = /* ... */;
let wasm_bytes = Builder::new().build().compile_vap_from_policy(&policy)?;
}

Example

The following policy enforces a maximum replica count read from a ConfigMap parameter resource:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "demo-policy.example.com"
spec:
  failurePolicy: Fail
  paramKind:
    apiVersion: v1
    kind: ConfigMap
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  variables:
    - name: replicas
      expression: "object.spec.replicas"
  validations:
    - expression: "variables.replicas <= int(params.data.maxreplicas)"
      message: "The number of replicas must be less than or equal to the configured maximum"

Compile it to a Wasm module:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;

let yaml = std::fs::read_to_string("policy.yaml")?;
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
}

Running a Compiled ValidatingAdmissionPolicy

Use runtime::Builder exactly as you would for any other compiled CEL module. Pass the variable bindings as a JSON object:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime};

let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .build()?
    .eval(Some(&bindings_json))?;

let result: serde_json::Value = serde_json::from_str(&result_str)?;
// result["accepted"] == true / false
}

build() (and build_pre()) check the module’s ABI version before they link it. A module compiled by a ferricel with an incompatible ABI makes build() return Err, naming both ABI versions. See ABI Version in the Wasm Spec chapter.

Runtime Errors and failurePolicy

The host passes the failurePolicy of the policy in the bindings, as the failurePolicy key. The value is "Fail" or "Ignore". A missing or null key means Fail. The module does not read spec.failurePolicy from the VAP.

Make sure that the value is valid before it reaches the bindings. If the value is anything else (for example "ignore", in lowercase), the module traps with a CEL runtime error before the first expression runs. A host that maps every CelRuntimeError to “allow” under Ignore turns its own configuration error into an accept.

Under Fail, eval() returns Err when a validations expression evaluates to a runtime error. Examples: division by zero, an unbound variable, or a kw.k8s extension that returns an error. The error downcasts to ferricel_core::CelRuntimeError. Its Display text starts with CEL runtime error:. The module never turns such an error into an accept or a reject response.

A matchConditions expression works the same way, but only when no condition in the same param is false. A false result always wins, even over an error in another condition of the same param. See matchConditions for the exact rule.

Under Ignore, the module skips the expression that evaluates to an error and continues. eval() returns Ok, and the response carries a warnings list with one entry per skipped expression. See Runtime Errors for the exact rules. Under Ignore, eval() still returns Err when the params or namespaceObject lookup fails, and when the failurePolicy value is invalid.

eval() can also fail for reasons that are not CEL runtime errors: an epoch-deadline interrupt, a memory limit, a Wasm trap, or a bug in a host extension. These errors do not downcast to CelRuntimeError.

With downcast_ref, the host can tell a CEL runtime error apart from other failures:

#![allow(unused)]
fn main() {
use ferricel_core::CelRuntimeError;

match engine.eval(Some(&bindings_json)) {
    Ok(result_str) => {
        let result: serde_json::Value = serde_json::from_str(&result_str)?;
        // result["accepted"] == true / false
        // result["warnings"]: present under `Ignore` when the module skipped
        // an expression. Forward it to the client and the audit log.
    }
    Err(err) => match err.downcast_ref::<CelRuntimeError>() {
        Some(cel_err) => {
            // A CEL expression evaluated to an error under `Fail`, or the
            // `params` or `namespaceObject` lookup failed, or the
            // `failurePolicy` binding is invalid. Apply failurePolicy:
            //   Fail   -> deny the request, report `cel_err.message`
            //   Ignore -> allow the request
        }
        None => {
            // Not a CEL runtime error: a deadline, a resource limit, a trap,
            // or a host bug. The host decides how to handle it.
        }
    },
}
}

CelRuntimeError has two fields:

FieldTypeDescription
messageStringThe error message, for example divide by zero.
originOption<ExtensionOrigin>The host extension that produced the error, or None.

ExtensionOrigin holds the namespace (for example Some("kw.k8s")) and the function (for example get) of the extension call. When the params lookup fails in the host, origin is kw.k8s.get or kw.k8s.list. When the namespaceObject lookup fails, origin is kw.k8s.get. A host can use this to tell a failed params or namespaceObject lookup apart from other runtime errors.

See Runtime Errors for the exact rules.

Required Bindings

BindingRequired when…
objectAlways (the resource being admitted)
oldObjectPolicy expressions reference oldObject
requestPolicy expressions reference request or namespaceObject, or paramKind is set
paramRefparamKind is set (see below)
failurePolicyOptional. "Fail" (the default when missing or null) or "Ignore". See Runtime Errors and failurePolicy

object, oldObject, and request correspond directly to the fields of the Kubernetes AdmissionReview request object. The module does not need namespaceObject in the bindings: it resolves the Namespace itself from request, through the gated kw.k8s.get host extension. See Namespace object below.

Determining whether a given policy actually references oldObject, request, or namespaceObject — without evaluating the policy or re-parsing its CEL/YAML source — is exactly what the ferricel.vap-variables custom section is for. Read it with ferricel_core::vap_variables_used() (or ferricel inspect --json) at policy-setup time. This is particularly relevant for namespaceObject: a host that finds it in the section must register kw.k8s.get and grant the policy access to v1/Namespace — see Custom sections and inspection for details.

Kubernetes Resource Fetching

Namespace object

When a policy references namespaceObject, the compiled module resolves it itself at evaluation time, the same way it resolves params. The host does not supply namespaceObject in the bindings; it supplies request instead (see Required Bindings). A host that still passes namespaceObject is not an error — the module’s own value overwrites it — but a 0.11+ host should stop doing so.

The module applies these rules, in order:

  1. request missing, or not a map: the module traps with request binding is missing. The host contract requires request whenever the policy references namespaceObject.
  2. request.kind is the core v1/Namespace GroupVersionKind (the resource under admission is itself a Namespace): namespaceObject is null, and the host is not called. On CREATE, request.namespace equals the new Namespace’s own name, and the Namespace does not exist yet — fetching it would 404.
  3. request.namespace missing or empty (a cluster-scoped request): namespaceObject is null, and the host is not called.
  4. Otherwise: one kw.k8s.get call for {apiVersion: "v1", kind: "Namespace", name: request.namespace}. Unlike the params request map, this map has no namespace key — Namespace is cluster-scoped. A host error traps with origin kw.k8s.get, and the host applies its failurePolicy.

The fetch runs once per evaluation, before params, so matchConditions and params-dependent expressions can read namespaceObject too.

Register kw.k8s.get the same way as for params:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::vap;

runtime::Builder::new()
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        let map = &args[0];
        let name        = map["name"].as_str().unwrap();
        let api_version = map["apiVersion"].as_str().unwrap(); // "v1"
        let kind        = map["kind"].as_str().unwrap();       // "Namespace"

        // Fetch the Namespace from Kubernetes and return it as JSON.
        let namespace = fetch_from_k8s(api_version, kind, "", name)?;
        Ok(namespace)
    })
    // ... .with_wasm(...), other extensions, .build()?
}

A host that derives required capabilities from ferricel.extensions grants kubernetes/get_resource for kw.k8s.get automatically; it must also grant the policy access to v1/Namespace in its own authorization model (the ferricel.vap-variables section names the policies that need this grant).

Params

When a policy sets paramKind, the compiled module fetches the param resources itself at evaluation time. It calls the host-provided kw.k8s.get or kw.k8s.list extension. The host does not supply params directly in the bindings.

The host must supply paramRef in the bindings. paramRef is the spec.paramRef of the ValidatingAdmissionPolicyBinding, as JSON. The host must also supply request, so the module can default the namespace (see below).

The module supports the two ways Kubernetes selects param resources:

  • paramRef.name: one resource, by name. The module calls kw.k8s.get.
  • paramRef.selector: every resource that matches a label selector. The module formats the selector as a label selector string, for example app=web,tier in (api,web), and calls kw.k8s.list. Each item in the items list of the response is one param resource.

If paramRef has both name and selector, name wins. If it has neither, the module traps with the error paramRef must have either name or selector.

Namespace defaulting

The namespace in the request map is paramRef.namespace. If that is empty, the module uses request.namespace. If that is also empty, namespace is "". The params request map always contains the namespace key. This is different from a kw.k8s chain written in CEL, where namespace is present only when the policy calls .namespace().

Per-param evaluation

The module evaluates the policy once per param resource. For each param, it sets the params binding, then evaluates matchConditions, variables, and validations. The first rejection stops the evaluation and becomes the response. When every param passes, or the param list is empty, the module returns {"accepted": true}. See Evaluation Order.

parameterNotFoundAction

When the host call fails, or kw.k8s.list returns zero items, the module reads paramRef.parameterNotFoundAction:

  • Allow: the param list is empty. The module returns {"accepted": true}.
  • Deny, or unset: the module traps with a CelRuntimeError. The host then applies its failurePolicy. A host error keeps its origin (kw.k8s.get or kw.k8s.list). An empty list produces the message no parameters found.

Under Allow, a host error of any kind counts as “not found”. The module cannot tell a missing resource apart from an authorization error. See LIMITATIONS.md.

Bindings and host registration

A binding with paramRef.name:

{
  "paramRef": {
    "name": "my-params",
    "namespace": "default",
    "parameterNotFoundAction": "Deny"
  },
  "request": { "namespace": "team-a", ... },
  "object": { ... }
}

A binding with paramRef.selector:

{
  "paramRef": {
    "selector": {
      "matchLabels": { "app": "web" },
      "matchExpressions": [
        { "key": "tier", "operator": "In", "values": ["api", "web"] }
      ]
    },
    "parameterNotFoundAction": "Allow"
  },
  "request": { "namespace": "team-a", ... },
  "object": { ... }
}

The host must register both kw.k8s.get and kw.k8s.list on the runtime builder. Which one the module calls depends on the binding, not on the policy:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime, compiler::vap};

let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        // args[0] is the request map (see shape below)
        let map = &args[0];
        let name        = map["name"].as_str().unwrap();
        let namespace   = map["namespace"].as_str().unwrap(); // can be ""
        let api_version = map["apiVersion"].as_str().unwrap();
        let kind        = map["kind"].as_str().unwrap();

        // Fetch from Kubernetes and return the resource as a JSON value.
        let resource = fetch_from_k8s(api_version, kind, namespace, name)?;
        Ok(resource)
    })
    .with_extension(vap::kw_k8s_list_extension(), |args| {
        let map = &args[0];
        let label_selector = map["labelSelector"].as_str().unwrap();
        let namespace      = map["namespace"].as_str().unwrap(); // can be ""
        let api_version    = map["apiVersion"].as_str().unwrap();
        let kind           = map["kind"].as_str().unwrap();

        // List from Kubernetes and return `{"items": [...]}`.
        let list = list_from_k8s(api_version, kind, namespace, label_selector)?;
        Ok(list)
    })
    .build()?
    .eval(Some(&bindings_json))?;
}

Fetching Data from the Kubernetes API

The kw.k8s API is implemented as a builder chain. See the Host Extensions chapter for general documentation on declaring and consuming builder chains.

Policy variables (and other expressions) can call kw.k8s directly to fetch arbitrary resources. The API mirrors the kw.k8s Kubernetes library provided by the Kubewarden CEL policy:

kw.k8s
  .apiVersion(<string>)     → kw.k8s.ClientBuilder
  .kind(<string>)           → kw.k8s.Client
  .namespace(<string>)      → kw.k8s.Client   (optional)
  .labelSelector(<string>)  → kw.k8s.Client   (optional)
  .fieldSelector(<string>)  → kw.k8s.Client   (optional)
  .fieldMask(<string>)      → kw.k8s.Client   (optional, repeatable)
  .get(<string>)            → dyn              (host call — returns one resource)
  .list()                   → dyn              (host call — returns a list)

Example — fetch a ConfigMap in a variable, then check a field in a validation:

// variables entry
kw.k8s.apiVersion('v1').kind('ConfigMap').namespace('default').get('my-config')

// validation expression
variables.cfg.data.allowedTeam == request.userInfo.groups[0]

Host Extension Request Map

When a kw.k8s.get or kw.k8s.list terminal is called, the host receives a single argument — a JSON object containing the accumulated builder state:

KeySet by chain stepNotes
apiVersion.apiVersion()Always present
kind.kind()Always present
namespace.namespace()Present only if .namespace() was called
labelSelector.labelSelector()Present only if called
fieldSelector.fieldSelector()Present only if called
fieldMasks.fieldMask()Array; present only if called
name.get(<name>)Present only for get terminal

For the params lookup, the module builds this map itself. It always sets apiVersion, kind, and namespace. It sets name for paramRef.name and labelSelector for paramRef.selector. See Params.

For the namespaceObject lookup, the module also builds the map itself, but never sets namespace — Namespace is cluster-scoped. It sets apiVersion ("v1"), kind ("Namespace"), and name (request.namespace). See Namespace object.

Register the extensions using the helpers from ferricel_core::compiler::vap:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::vap;

// For policies that call .get(...), or set paramKind
runtime::Builder::new()
    .with_extension(vap::kw_k8s_get_extension(), |args| { ... })

// For policies that call .list(), or set paramKind
runtime::Builder::new()
    .with_extension(vap::kw_k8s_list_extension(), |args| { ... })
}

Example

This example mirrors the scenario from the Kubewarden CEL policy README: a policy that enforces a maximum replica count read from a ConfigMap parameter resource.

The ValidatingAdmissionPolicyBinding

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "demo-policy-binding.example.com"
spec:
  policyName: "demo-policy.example.com"
  validationActions: [Deny]
  paramRef:
    name: "my-params"
    namespace: "default"
    parameterNotFoundAction: Deny
  matchResources:
    namespaceSelector:
      matchLabels:
        environment: test

The ConfigMap Parameter Resource

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-params
  namespace: default
data:
  maxreplicas: "5"

The Incoming Deployment

This is the resource being admitted:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-app
          image: my-app:latest

Rust Integration

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::{Builder, vap}, runtime};

// The host extracts these from the AdmissionReview and the PolicyBinding.
let bindings = serde_json::json!({
    "paramRef": {
        "name": "my-params",
        "namespace": "default",
        "parameterNotFoundAction": "Deny"
    },
    "request": request_json,
    "object": object_json,
});

let wasm_bytes = Builder::new().build().compile_vap(vap_yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        // The module calls this to fetch the ConfigMap.
        // In production, make a real Kubernetes API call here.
        let map = &args[0];
        assert_eq!(map["apiVersion"], "v1");
        assert_eq!(map["kind"], "ConfigMap");
        assert_eq!(map["name"], "my-params");
        assert_eq!(map["namespace"], "default");
        Ok(serde_json::json!({
            "apiVersion": "v1",
            "kind": "ConfigMap",
            "metadata": { "name": "my-params", "namespace": "default" },
            "data": { "maxreplicas": "5" }
        }))
    })
    .with_extension(vap::kw_k8s_list_extension(), |args| {
        // Not called for this binding, because `paramRef.name` is set.
        // A binding with `paramRef.selector` calls this instead.
        unreachable!("this binding uses paramRef.name")
    })
    .build()?
    .eval(Some(&bindings.to_string()))?;

let result: serde_json::Value = serde_json::from_str(&result_str)?;
assert_eq!(result["accepted"], true);  // replicas 3 <= maxreplicas 5
}

Compiler Architecture

Ferricel compiles CEL expressions into self-contained WebAssembly modules. The process involves two distinct Rust artifacts: the runtime and the compiler.

The Runtime

The runtime crate is a Rust library compiled to wasm32-unknown-unknown. It provides the low-level functions that a compiled CEL program calls at execution time, like memory allocation, value serialization, arithmetic helpers, string operations, and so on. Because the target is bare Wasm (no WASI, no OS), it is entirely self-contained.

The Compiler

ferricel-core contains the compiler. It parses the CEL source using the parser that is part of the cel crate. The parser produces a typed AST, which the compiler then traverses, emitting WebAssembly instructions. The WebAssembly instructions are produced using the walrus crate.

Rather than generating a Wasm module from scratch, the compiler loads the pre-embedded runtime.wasm module and injects the compiled CEL program into it. A dead-code-elimination pass (based on walrus::passes::gc) then removes any runtime functions the program does not call, keeping output files small.

The result is a single .wasm file that is fully self-contained and can be executed anywhere a WebAssembly runtime is available.