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.wasmmodule produced by itferricel-core: the pure Rust crate used byferricelCLI. 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
| Feature | Status |
|---|---|
Integer arithmetic (+, -, *, /, %) with overflow detection | Supported |
| Floating-point arithmetic | Supported |
Unsigned integer (uint) arithmetic | Supported |
Boolean logic (&&, ||, !) with short-circuit evaluation | Supported |
Comparison operators (==, !=, <, <=, >, >=) | Supported |
String operations (+, size, contains, startsWith, endsWith, matches) | Supported |
| Bytes operations | Supported |
List literals, indexing, membership (in) | Supported |
| Map literals, field access, indexing | Supported |
Conditional expressions (? :) | Supported |
| Null handling and null propagation | Supported |
Type conversions (int(), uint(), double(), string(), bytes(), bool()) | Supported |
| Timestamp and Duration types | Supported |
| Timestamp/Duration arithmetic and field accessors | Supported |
size() function | Supported |
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 access | Supported |
Protobuf wrapper type semantics (google.protobuf.*Value) | Supported |
dyn() type erasure | Supported |
type() introspection | Supported |
cel-go Extension Libraries
| Extension | Functions | Status |
|---|---|---|
| Bindings | cel.bind(var, init, body) | Supported |
| Encoders | base64.encode, base64.decode | Supported |
| Math | math.greatest, math.least | Supported |
math.bitOr, math.bitAnd, math.bitXor, math.bitNot | Supported | |
math.bitShiftLeft, math.bitShiftRight | Supported | |
math.ceil, math.floor, math.round, math.trunc | Supported | |
math.abs, math.sign | Supported | |
math.isInf, math.isNaN, math.isFinite | Supported | |
math.sqrt | Supported | |
| Strings | charAt, indexOf, lastIndexOf | Supported |
lowerAscii, upperAscii, trim | Supported | |
replace, split, substring, join | Supported | |
reverse, strings.quote | Supported | |
format (string interpolation) | Supported | |
| Lists | slice, flatten, distinct, reverse | Supported |
sort, sortBy | Supported | |
first, last | Supported | |
lists.range(n) | Supported | |
| Sets | sets.contains, sets.equivalent, sets.intersects | Supported |
| TwoVarComprehensions | all(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 | |
| Regex | regex.replace, regex.extract, regex.extractAll | Supported |
| Protos | proto.getExt, proto.hasExt | Not supported |
Kubernetes CEL Extensions
Ferricel also supports the Kubernetes CEL validation libraries:
| Extension | Status |
|---|---|
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 Suite | Successful | Failed | Skipped |
|---|---|---|---|
| basic | 41 | 2 | 0 |
| bindings_ext | 8 | 0 | 0 |
| block_ext | 18 | 8 | 11 |
| comparisons | 406 | 0 | 0 |
| conversions | 109 | 0 | 0 |
| encoders_ext | 4 | 0 | 0 |
| fp_math | 30 | 0 | 0 |
| integer_math | 64 | 0 | 0 |
| lists | 39 | 0 | 0 |
| logic | 30 | 0 | 0 |
| macros2 | 46 | 0 | 0 |
| macros | 44 | 0 | 0 |
| math_ext | 199 | 0 | 0 |
| namespace | 11 | 3 | 0 |
| network_ext | 69 | 0 | 0 |
| optionals | 70 | 0 | 0 |
| parse | 128 | 74 | 17 |
| string_ext | 216 | 0 | 0 |
| string | 51 | 0 | 0 |
| timestamps | 76 | 0 | 0 |
| type_deduction | 17 | 1 | 29 |
| Total | 1676 | 88 | 57 |
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.
| Parameter | Type | Description |
|---|---|---|
len | usize | Number 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.
| Parameter | Type | Description |
|---|---|---|
level | i32 | Log level threshold (see table below). Values outside the valid range are clamped. |
The log levels are:
| Value | Level |
|---|---|
0 | Debug |
1 | Info (default) |
2 | Warn |
3 | Error |
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.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed 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.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed 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.
| Parameter | Type | Description |
|---|---|---|
ptr | i32 | Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object. |
len | i32 | Byte 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).
| Parameter | Type | Description |
|---|---|---|
packed | i64 | Packed 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.
| Parameter | Type | Description |
|---|---|---|
request | i64 | Packed 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:
| Field | Name | Version |
|---|---|---|
language | CEL | (empty) |
processed-by | ferricel | crate 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 name | Content | Produced by |
|---|---|---|
ferricel.cel-source | The original CEL expression | [compile()] |
ferricel.vap-source | The full ValidatingAdmissionPolicy serialized as YAML | [compile_vap()], [compile_vap_from_policy()] |
ferricel.extensions | JSON array of host extensions used by this module | all compile paths |
ferricel.vap-variables | JSON 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 runCLI — simple command-line execution; does not support host extensions.ferricel-coreRust 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 viawith_epoch_deadline, every evaluation traps immediately — this iswasmtime’s documented behavior for aStorewith 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:
- Compile time — declare the extension so the compiler emits a real host
call instead of a static
no matching overloaderror. - Runtime — register an implementation that the Wasm module invokes via
the
cel_call_extensionhost import.
ferricel supports two kinds of host extensions:
- Flat extensions — standalone functions like
math.abs(x)orkw.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:
| Segment | Description |
|---|---|
namespace | Optional dot-separated namespace prefix (e.g. math in math.sqrt). |
function | Function name (the last dot-separated segment). |
style | One of global, receiver, or both (see below). |
arity | Total number of arguments the host receives, including the receiver for receiver-style calls. |
Calling styles:
| Style | Invocation form |
|---|---|
global | func(args) or ns.func(args) |
receiver | value.func(extra_args) — receiver is always args[0] |
both | Both 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(),
}
}
| Field | Description |
|---|---|
function | Full dotted CEL function name. |
state_keys | JSON keys under which each positional argument is stored. The number of keys determines the expected arity. |
output_type | Type 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,
}
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. Used for compile-time disambiguation. |
state_keys | JSON keys for each positional argument. Determines arity. |
output_type | Type tag for the output map. |
accumulate | When 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" }
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. |
state_key | Field name of the nested map in the state object. |
output_type | Type tag for the output map. |
Note: the choice between
ChainandMapEntryis made by the extension author, not inferred. The CEL call site looks identical —.annotation("env", "prod")could be either — but the semantics differ:Chainstores arguments under fixed positional keys;MapEntryuses 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(),
}
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. |
extra_arg_keys | Keys for extra positional arguments folded into the map before the host call. Empty for zero-arg terminals. |
host_namespace | Namespace in the ExtensionCallPayload sent to the host. |
host_function | Function 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:
-
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 whoseinput_typematches. -
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 bystate_keys.len()for Chain steps andextra_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:
reason | HTTP code |
|---|---|
Forbidden | 403 |
Unauthorized | 401 |
RequestEntityTooLarge | 413 |
Invalid or unset | 422 |
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:
-
namespaceObject(only when the policy references it). The module resolves the Namespace of the resource under admission fromrequest. See Namespace object for the rules. -
params(only whenparamKindis set). The module resolves the list of param resources fromparamRef. See Params for the rules. WithoutparamKind, the module evaluates the steps below once, with noparamsbinding. -
For each param resource:
-
matchConditions. The module evaluates every condition in declaration order first. If any evaluates tofalse, this param does not apply to the request. Only afalseresult skips the param; a non-boolean result, like Kubernetes, counts as a match. Afalseresult wins even when another condition in the same param evaluates to a runtime error. Only when no condition isfalsedoesfailurePolicydecide what an error means (see Runtime Errors). The module skips the remainingmatchConditions, thevariables, and thevalidationsfor this param, and moves to the next param. A skip is not a rejection.paramsandnamespaceObjectare both available inmatchConditions. -
variables. Evaluated in declaration order. Each result is stored undervariables.<name>. It is accessible to latervariablesexpressions and to allvalidations. The module rebuilds thevariablesmap for each param. -
validations. Evaluated in declaration order. The first expression that does not evaluate totruemakes the module return a rejection response. A non-boolean result, for example a string ornull, is a rejection, like Kubernetes. The module does not evaluate the remaining validations or the remaining params.
-
-
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
validationsexpression evaluates to an error, the module skips it. The next validation runs. Afalseresult from any other validation rejects the request. - When a
matchConditionsexpression evaluates to an error, and no condition in the same param isfalse, the module skips the current param, the same as for afalseresult. WithparamKind, the next param runs. WithoutparamKind, the policy does not apply, and the module accepts the request. Afalseresult 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
variablesentry that evaluates to an error is stored as-is. The error propagates only into the expressions that referencevariables.<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 || trueevaluates totrue.
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:
| Feature | Status | Notes |
|---|---|---|
failurePolicy | Implemented | Via the failurePolicy binding (see Runtime Errors). Under Ignore, the module skips an expression that evaluates to an error and records a warning. |
auditAnnotations | Not implemented | Requires a separate compilation path and an additional field in the response JSON. |
matchConstraints | Out of scope | This 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:
| Field | Type | Description |
|---|---|---|
message | String | The error message, for example divide by zero. |
origin | Option<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
| Binding | Required when… |
|---|---|
object | Always (the resource being admitted) |
oldObject | Policy expressions reference oldObject |
request | Policy expressions reference request or namespaceObject, or paramKind is set |
paramRef | paramKind is set (see below) |
failurePolicy | Optional. "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:
requestmissing, or not a map: the module traps withrequest binding is missing. The host contract requiresrequestwhenever the policy referencesnamespaceObject.request.kindis the corev1/NamespaceGroupVersionKind (the resource under admission is itself a Namespace):namespaceObjectisnull, and the host is not called. OnCREATE,request.namespaceequals the new Namespace’s own name, and the Namespace does not exist yet — fetching it would 404.request.namespacemissing or empty (a cluster-scoped request):namespaceObjectisnull, and the host is not called.- Otherwise: one
kw.k8s.getcall for{apiVersion: "v1", kind: "Namespace", name: request.namespace}. Unlike theparamsrequest map, this map has nonamespacekey — Namespace is cluster-scoped. A host error traps with originkw.k8s.get, and the host applies itsfailurePolicy.
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 callskw.k8s.get.paramRef.selector: every resource that matches a label selector. The module formats the selector as a label selector string, for exampleapp=web,tier in (api,web), and callskw.k8s.list. Each item in theitemslist 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 aCelRuntimeError. The host then applies itsfailurePolicy. A host error keeps its origin (kw.k8s.getorkw.k8s.list). An empty list produces the messageno 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.k8sAPI 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:
| Key | Set by chain step | Notes |
|---|---|---|
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.