function-wasm
Caution
Experimental. function-wasm is young: the sandbox hasn’t had an independent security review, and nothing here has run in production yet. From 1.0.0 the Input, the guest ABI, the runtime flags and the rest of what Compatibility lists change only through its deprecation policy; before it they could change between minor releases. Try it, break it and open an issue, but don’t build a platform on it yet.
This page is for Crossplane users deciding whether to run composition logic as WebAssembly modules. It assumes you know Compositions and pipeline mode.
function-wasm is a Crossplane composition function
that runs a WebAssembly module in a Wasmtime sandbox.
A module follows the contract of a native function,
RunFunction(RunFunctionRequest) → RunFunctionResponse. You write an ordinary
function, in Go with
function-sdk-go or in Rust,
Zig, C, TypeScript or Python, compile it to WebAssembly, publish the module
and point a Composition step at it.
It fits when a pipeline step’s logic should change without a new Function package, or when the people who maintain that logic aren’t the people who maintain the Composition. One installed function-wasm serves any number of modules, so a new module version is a new reference in the Input.
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: example
spec:
compositeTypeRef:
apiVersion: example.crossplane.io/v1
kind: XR
mode: Pipeline
pipeline:
- step: greet
functionRef:
name: function-wasm
input:
apiVersion: wasm.fn.crossplane.io/v1
kind: Input
module:
type: OCI
oci:
ref: ghcr.io/example/greeter:v1@sha256:4c9d… # printed by guestfn push; the digest pins the module, the tag is for humans
config:
greeting: hi
The module receives the whole RunFunctionRequest and returns the whole
RunFunctionResponse, so desired resources, results, conditions, requirements
(extra resources), context and TTL pass through as they do for a native
function. The runtime withholds only the step credentials a module wasn’t
granted.
Where to go next
- Write a module to scaffold, build and publish a guest. Other languages lists the rest.
- Install the runtime to run function-wasm in a cluster.
- A request, step by step to see what the runtime does between Crossplane’s request and the module’s response.
Use cases
This page is for platform engineers and composition authors deciding whether function-wasm fits a pipeline step. It lists what the runtime suits, where its limits are and the examples that show it. It assumes you know Crossplane compositions. The introduction covers what function-wasm is.
function-wasm suits a step whose logic should change without building, publishing and installing a Function package. It also suits a step whose logic someone other than the Composition’s author maintains.
- A customization hook at the end of a pipeline. The platform team
publishes the Composition and reserves its last step for the module each
composite resource names in
module.from; withallowEmpty: truethe step does nothing until the field is set. That team changes labels, sizing or sidecars in its own language, without a change request against the Composition. The platform team keeps the guardrails: the digest pin and thecompositionPolicyfence on repositories in the Input, and the operator’s signature check. - Policy gates. A module that runs last sees the complete observed and desired state. It can check the composed resources against organisational rules such as naming, mandatory tags or allowed regions. A fatal result stops the reconcile, and a warning leaves a trace without stopping it.
- Fast iteration and rollback. Write the function,
guestfn build, render locally,guestfn pushand reference the digest in the Composition. Rolling forward or back is a digest change in that Composition; nothing is reinstalled. - Many small functions, one runtime. Each team maintains its own modules, in any of the supported languages. The cluster runs one function-wasm, which compiles each module once per digest and caches it.
- Restricted networks. Modules can come from an internal registry, an
internal HTTP server (
http) or a volume mounted into the runtime (path). The runtime keeps fetched modules and compiled code on disk, so with a volume at/tmp/function-wasm-cachea module it has already fetched keeps running across restarts while the registry is down, unless a signature is required: the runtime verifies that over the registry once per process. A module it has never fetched can’t be loaded.
Limits
By default a module doesn’t get network, filesystem or environment access; cluster state comes in through the request and the module returns desired state. A module can request more in its manifest, and the runtime grants it only when the Composition’s policy and the operator’s policy both permit it. See HTTP egress and the trust model.
function-wasm doesn’t support Crossplane Operations: its package declares
only the composition capability.
Worked examples
Each example under
examples/
is a guest in one language that solves one of these cases. Every /e2e run
builds, tests and lints them, but nothing in CI asserts what an example
composes. The use-case examples design record
explains why these were chosen and how they’re published.
A release publishes cloudflare-origin, policy-gate and pdb-addon to
ghcr.io/jonasz-lasut/wasmfn/examples/<name>, signed and attested, when the
version in the example’s wasmfn.yaml isn’t in the registry yet. The
digest-pinned references are in the release’s
function-wasm-examples-<release>.yaml.
examples/cloudflare-origin(Rust): only Cloudflare may connect to an origin. The module composes the origin’s AWS security group and one ingress rule per range Cloudflare publishes, read at reconcile time through the host’s egress - a capability its manifest requests and both policy layers must grant.examples/policy-gate(TypeScript): the organisation’s policy as the last pipeline step. The module asks Crossplane for the environment’s EnvironmentConfig throughrequirements, adds its mandatory tags to every composed resource without overwriting the team’s, and refuses a region or instance class the environment doesn’t allow - one digest-pinned module the platform team rolls out on its own.examples/pdb-addon(Go): a customization hook at the end of a pipeline. AWebAppComposition reserves its last step for the add-on eachWebAppnames inspec.addOn(module.from, skipped while unset), fenced to one repository by thecompositionPolicy; this add-on gives every Deployment the platform composed a PodDisruptionBudget, in the default sandbox, with no capability at all.examples/team-tags(Python): a team’s owner and cost center come from the CMDB, never from the composite resource. After function-go-templating composes an S3 bucket, the module reads the record of the team the composite resource’s namespace belongs to, with that team’s token from a step credential holding every team’s, and stampsteam,ownerandcost-centertags on every composed managed resource. The module receives that credential only where its manifest requires it and both policy layers permit it.examples/dashboard-bundle(C#): dashboards as code, delivered as a bundle. A composite resource pins a team’s zip of Grafana dashboards by sha256; the module fetches it through the host’s egress, refuses any other content, unpacks it into its private/tmpand composes one ConfigMap per dashboard for Grafana’s sidecar - the two capabilities its manifest requests.
A request, step by step
This page is for someone evaluating function-wasm. It follows one Crossplane request through the runtime, so you can see what’s checked before a module runs, what the module can do and how a failure ends. It assumes you know what function-wasm is. Each step links to the page with the detail.
A module gets a capability only when three layers agree. The module’s
manifest has to request it, and the
Composition’s compositionPolicy and the operator’s policy, the Cedar file
named by --sandbox-policy-file, both have to permit it. The
design record has the reasoning.
1. Admission
The runtime decodes the Input, compiles the compositionPolicy and checks
limits against its ceilings. Malformed Cedar, or a timeout or memory
limit over its ceiling, is a fatal result. A concurrency limit over its
ceiling is capped, with no error. A step that takes its module from the composite
resource (module.from) is fenced by the compositionPolicy: pullModule
for the repository, spendCredential for a credential. The fields are in the
Input reference.
An OCI or HTTP source is pinned by digest and a path source is hashed by
content, so resolving a module never waits on a registry.
2. Loading
Where the operator requires a signature, the runtime verifies it first, once per manifest digest per process and before any cache (signatures).
The runtime then looks the digest up in the compiled modules in memory (kept ten minutes after last use), the Wasmtime artifacts on disk and the fetched modules on disk. Only a module this pod has never seen is fetched, checked against its digest and compiled. A component that fails the world’s typecheck is refused here, and so is a core module, the ABI v1 shape removed in 1.0.0. The stores are described under cache storage. The cache design record has the reasoning.
The runtime then checks the manifest, if the module has one, against the other
two layers. A requires entry that isn’t permitted, or a config outside the
module’s schema, is a fatal result before anything runs.
3. Running
Each request runs in a fresh instance. The module receives the caller’s
request bytes, less every step credential it wasn’t granted. It gets WASI
without network access. The filesystem is a private /tmp removed after the
request, and the environment variables are those its manifest binds to step
credentials, if the three layers granted them.
Granted HTTP requests go through the host (HTTP egress). Guest logs go to the runtime’s logger with the module reference attached, and stdout and stderr go to the pod’s log.
4. Response
The runtime returns the response as the module produced it. Where a policy
layer scopes composeResource, every change to the desired state must first
be permitted. A refused change is a fatal result that specifies the kind, the
name, the change and the layer
(Cedar policy reference).
A trap, a timeout, the memory limit or an unusable module is also a fatal
result, and the function pod keeps running. The timeout counts guest compute
only: time waiting for the host’s HTTP answers is credited back. A request
whose deadline passes while it waits for a run slot gets
waiting for a run slot: deadline exceeded and never runs.
Troubleshooting lists the results and their
causes.
The full host and guest contract is in ABI v2.
Write a module
This page is for a module author. It takes you from guestfn init to a module
published as an OCI artifact, using the Go scaffold as the worked example. The
other scaffolds (--lang rust, zig, c, ts or python) use the same
commands. See Other languages for what differs.
Install the CLI and scaffold a project:
cargo install --git https://github.com/jonasz-lasut/function-wasm guestfn
guestfn init greeter --module github.com/example/greeter # --lang go (default), rust, zig, c, ts or python
cd greeter
The Go scaffold writes fn.go, fn_test.go and main.go. fn.go is a
plain function-sdk-go RunFunction that composes a ConfigMap greeting the
composite resource, with no code specific to WebAssembly. main.go only
registers it:
func init() {
wasmfn.Register(&Function{log: wasmfn.NewLogger(), http: wasmfn.HTTPClient()})
}
func main() {}
The glue is a small internal/wasmfn package that the project owns. It
exports run for the ABI v2 world: it decodes the request,
calls your RunFunction and encodes the response. It also provides a
logging.Logger over the runtime’s typed log import, and
wasmfn.GetConfig(req, &cfg) to read the Input’s config block. The Go
bindings under internal/bindings are written by
wit-bindgen and checked in.
examples/pdb-addon
is a Go module built on what guestfn init writes. Its internal/wasmfn,
wit/ and internal/bindings are the scaffold’s, byte for byte.
Build, inspect and publish
go test ./... # unit tests run natively
guestfn build # fn.wasm (an ABI v2 component); prints the ABI verdict and the manifest summary
guestfn inspect fn.wasm # size, ABI verdict, exports, imports
guestfn push ghcr.io/example/greeter:v0.1.0 # OCI artifact with the manifest; prints the module block and what the module requires
guestfn build runs go build, embeds the world from wit/ and wraps the
result into a component. It then compiles the component with the runtime’s own
Wasmtime engine and prints the verdict a load gives. For the Go scaffold
the output is a Componentized fn.wasm (wasip1 adapter linked) line, then a
Built line with the file, its size, ABI v2, the world’s imports and the
manifest summary.
A module the runtime would refuse fails the build with the runtime’s own
words, and guestfn push refuses to publish it for the same reason. The
refusals are listed in Troubleshooting.
guestfn inspect fn.wasm shows what the runtime sees: size, verdict,
exports and imports. guestfn inspect ghcr.io/example/greeter:v0.1.0
describes a published artifact from its manifest without pulling it, and
--pull reads the module too. --output json prints JSON for scripts.
Manifest
The scaffold includes a wasmfn.yaml, the module’s manifest. It gives the
module a name and version, declares the sandbox capabilities the module can’t
run without, and contains the JSON Schema of its config. guestfn build
validates it and guestfn push publishes it beside the module. See
Module manifests for the fields and for how the runtime
checks a module’s requirements.
guestfn scaffold composition --from ghcr.io/example/greeter:v0.1.0 (or
--from fn.wasm) writes a Composition step for the module, pinned by digest,
with a config skeleton from the schema and a commented compositionPolicy
skeleton. The policy block is a commented starting point for narrowing.
--full writes a whole Composition.
What guestfn push publishes
guestfn push produces a CNCF Wasm OCI artifact: one application/wasm
layer, the manifest as an application/vnd.wasmfn.manifest.v1+json layer
when the project has one, a config object listing both digests in
layerDigests, and the standard org.opencontainers.image.* annotations
from the manifest.
oras push ghcr.io/example/greeter:v0.1.0 fn.wasm:application/wasm wasmfn.json:application/vnd.wasmfn.manifest.v1+json gives the same result.
A FROM scratch image whose only layer copies the module to /fn.wasm works
too, without a manifest. The runtime reads that exact path and doesn’t search
the archive.
Module size
A Go module that uses function-sdk-go’s helpers is about 75 MB. See Sizing for what that costs the runtime.
Other languages
This page is for a module author choosing a guest language. It lists the six
flavours guestfn init scaffolds, the guests that exist only as examples, and
what the project hasn’t tested. Pick a flavour, scaffold it, then follow Write
a module. Any toolchain that produces a component for the
ABI v2 world, wasmfn:function, works: the runtime checks the
world and never the language.
Scaffolded flavours
guestfn init --lang <flavour> writes the same greeting function in each of
these, and guestfn build detects the toolchain from the project. All six build
an ABI v2 component, and the guest suite runs them through the host, and the
example build jobs build, test and lint them, on every /e2e. The sizes are of
the built module.
--lang | example | toolchain | Protobuf codec | module size |
|---|---|---|---|---|
go (default) | examples/pdb-addon | Go | function-sdk-go’s request/response/resource helpers | ~75 MB |
rust | examples/cloudflare-origin | Rust 1.100+, target wasm32-wasip3, cargo and protoc | prost over the vendored proto | ~240 KB scaffolded, ~315 KB for the example |
zig | examples/hello-zig | Zig 0.16 | zig-protobuf over the vendored proto | ~60 KB |
c | examples/hello-c | C compiled by zig cc | nanopb over the vendored proto | ~62 KB |
ts | examples/policy-gate | node and npm | protobuf-es over the vendored proto | ~14 MB (SpiderMonkey) |
python | examples/team-tags | python3 | protoc’s Python codec on the pure-Python protobuf runtime | ~21 MB (CPython) |
Each flavour keeps its ABI glue in the project, so you own and can edit it. The
sections below give each toolchain and how guestfn build finishes the
component.
Go
The scaffold vendors internal/wasmfn over the Go bindings that
wit-bindgen writes under
internal/bindings. Both are checked in. make gen-bindings regenerates them
with wit-bindgen-cli 0.62.0. Your function is a plain function-sdk-go
RunFunction. go build emits a wasip1 reactor, and guestfn build embeds the
world from the project’s wit/ and wraps the result into a component. run is
sync-lifted, and an *http.Client reaches the host’s egress over
wasi:http@0.2.
Rust
The rust flavour needs Rust 1.100 and the wasm32-wasip3 target. The
scaffold’s rust-toolchain.toml pins the toolchain, which your Rust installer
fetches on first use. run is async, and a fetch awaits wasi:http. See ABI
v2 for the world.
Zig and the C flavour
Both compile with the zig binary and share wit-bindgen’s C bindings, checked
in under src/gen and regenerated by make gen-bindings. Zig reads them
through translate-c, and the C flavour compiles them with zig cc without
wasi-sdk. run is sync-lifted, with wasi:http@0.2 fetch. The glue is beside
the module in src/wasmfn.zig and src/wasmfn.c. zig links a core module
without libc, and guestfn build wraps it into a component without an adapter
or wasm-tools. protoc or nanopb_generator is needed only to regenerate the
codec.
TypeScript
npm install is the whole toolchain. esbuild
bundles the guest, and jco
componentizes it with componentize-js. The types come from protobuf-es, the test
gate runs tsc --noEmit, and the greeting comes from the platform’s fetch(),
served over wasi:http@0.2 through the same egress policy as every other guest.
run is sync-lifted because componentize-js can’t async-lift a custom world
yet, and your TypeScript can still await. The scaffold has no
package-lock.json: guestfn build runs npm install without one and npm ci
with one.
Python
componentize-py is the
toolchain, and guestfn build makes the venv from requirements.txt when the
project has none. The guest bundles the pure-Python protobuf runtime, and
run is sync-lifted. Fetches go over wasi:http@0.2 on componentize-py’s poll
loop, through the same egress policy.
How build chooses a toolchain
guestfn build reads the project: Cargo.toml selects cargo, build.zig
selects zig (both the zig and c flavours), package.json selects npm,
requirements.txt selects componentize-py, go.mod selects go, and
moon.mod or moon.mod.json selects moon. --lang overrides the choice, and
build also accepts moonbit.
When a build leaves a core module, guestfn build wraps it into a component
with the wasip1 adapter of the runtime’s own Wasmtime, which is the adapter
version the runtime was built with. The zig and c builds already leave a
core module with wit-bindgen’s component-type section, and guestfn build
wraps it directly. For go and MoonBit, wit-bindgen writes bindings only, so
guestfn build embeds the world from wit/ first.
Examples without a scaffold
These guests solve their own use case or show one more language. Each has unit
tests in its language and a build job that checks its generated code for drift.
guestfn init scaffolds none of them, and guestfn build detects only Odin (as
a c project) and MoonBit.
- C#:
examples/dashboard-bundle, ~4.5 MB, needs the .NET 10 SDK.make buildcompiles it with NativeAOT-LLVM and componentize-dotnet, in the .NET SDK container on macOS, which has no NativeAOT-LLVM compiler. - Odin:
examples/hello-odin, ~71 KB, built with Odin dev-2026-10 and Zig 0.16 bymake build. It reuses thecflavour’s bindings, nanopb codec, WIT and proto. Odin compiles the guest to an object,zig cclinks it with the C half, andguestfn buildtreats the project as acone and wraps it without an adapter.make testruns the unit tests natively. - MoonBit:
examples/hello-moonbit, ~107 KB, built withmoon(version0.1.20260920). It uses wit-bindgen’s MoonBit bindings and the protoc-gen-mbt codec, regenerated bymake gen-bindingsandmake gen-proto.guestfn builddetects it bymoon.mod, embeds the world stated as UTF-16, MoonBit’s string encoding, and wraps it without an adapter. The unit tests run onmoonrun. A guest must never callprintln: it importsspectest.print_char.
Untested
- C++: no guest here.
- TinyGo: the scaffold was retired on 2026-10-07 (#114). The
goflavour is the Go path. - AssemblyScript: not supported, because it has no component path.
Module manifests
This page is for module authors deciding what to put in wasmfn.yaml. It
covers its contents, how the runtime checks it, the refusals you can expect
and the composes declaration. It assumes a scaffolded project
(Write a module); what each policy layer permits is in
the Cedar policy reference.
Manifest contents
guestfn init writes a wasmfn.yaml like this one (the Go scaffold’s):
# The module manifest: what this module declares about itself. guestfn build
# validates it, guestfn push publishes it beside the module as the artifact's
# manifest layer, and the runtime holds it against what the policy layers grant
# before every run - a manifest can refuse a run earlier and say why, never
# make one possible.
abi: 2
name: my-fn
version: 0.1.0
# requires: the sandbox capabilities the module cannot run without
# (egress.http rules, filesystem.privateTmp, env credential bindings),
# granted where the compositionPolicy and the operator's Cedar policy permit. This guest fetches a
# greeting only when config.greetingUrl is set, so it requires nothing and
# renders without any grant.
config:
# The JSON Schema (draft 2020-12) of the Input's config; the runtime refuses
# a request whose config does not match, naming the field.
schema:
type: object
properties:
greeting: {type: string}
greetingUrl: {type: string, format: uri}
additionalProperties: false
The fields:
-
abi: the module’s ABI version. Required, and2is the only value the runtime accepts. -
name,version,descriptionandsource: what the module says about itself. -
requires: the sandbox capabilities the module can’t run without. These are egress rules (egress.http),filesystem.privateTmp,envcredential bindings and the stepcredentialsthe module reads whole from its request. The runtime reads the manifest with a YAML 1.2 parser written in Rust. Unquotedyes,no,onandoffare booleans, an unquoted0755or1_000parses as755and1000, a merge key (<<: *anchor) merges, and.infand.nanare refused. Quote a value that must stay a string.function validatereads a Composition file the same way. -
config.schema: a JSON Schema (draft 2020-12) for the Input’sconfig. It must be self-contained: a$refto a URL or a file is refused at load, andpatterntakes theregexcrate’s syntax, which has no look-around and no back-references. Non-secret configuration belongs inconfig, not in env. -
minRuntime: the oldest runtime that serves the module. -
composes: what the module writes, advisory (below).
guestfn push publishes the file as a second layer of the OCI artifact, with
the media type application/vnd.wasmfn.manifest.v1+json. The layer is covered
by the manifest digest the Composition pins, and so by any cosign signature on
it.
A path or http source has no OCI manifest layer. It can reference its
wasmfn.yaml instead, with module.manifestPath or module.http.manifestURL
and manifestDigest (Input reference). A
module with no manifest runs in the default sandbox: nothing but the request,
and no step credential.
Policy check and refusals
A manifest is a request. After the module loads, the runtime checks each
requires entry against the Input’s compositionPolicy and the operator’s
--sandbox-policy-file, and a capability is granted only when both permit
it. The manifest can make a run fail earlier, with a message saying why. It
can’t make a run possible or widen a grant.
A required capability that a layer doesn’t permit is a fatal result before the module runs:
module oci ghcr.io/example/greeter@sha256:… requires egress GET to host "api.example.com" (requires.egress.http[0]), which the operator policy (--sandbox-policy-file) does not permit
… requires a private /tmp (requires.filesystem.privateTmp), which the compositionPolicy does not permit for this request
… requires credential "cmdb" (requires.credentials[0]), which the operator policy (--sandbox-policy-file) does not permit
A runtime older than the manifest’s minRuntime and a config that doesn’t
match the schema fail the same way, and a schema that reaches outside itself
is refused when the manifest is read:
… requires runtime v0.3.0 or newer, this is v0.2.1
… config does not match the module's schema: /greeting: got number, want string
… has an invalid manifest: config.schema: Resource 'http://example.com/schema.json' is not present in a registry and retrieving it failed: $ref http://example.com/schema.json is external; config.schema must be self-contained
guestfn push prints the requires: block under the module: block. A
Composition author reads it to see what the layers must permit.
guestfn inspect <ref> shows what a published module requires, and
function validate --resolve applies the same checks offline.
The composes declaration
A manifest can declare what the module writes, beside what it needs:
composes:
- kind: policy/PodDisruptionBudget # <group>/<Kind>, the core group spelled core
changes: [add] # add, modify, remove; all three when omitted
The declaration is for tooling. The policy layers fence a module’s output
with composeResource rules, judged over the actual response after the run
(the output fence), and the
runtime doesn’t read composes.
The tooling reads it in three places:
function validate --resolvewarns where the step’scompositionPolicyor the operator policy would refuse a declared kind and change, such asthe module declares it composes policy/PodDisruptionBudget (add), which the compositionPolicy does not permit (composeResource).guestfn inspectshows it in the manifest summary.guestfn scaffold compositionwrites the matchingcomposeResourcepermits into thecompositionPolicyskeleton it derives fromrequires.
guestfn build refuses an unknown field inside composes, which catches a
typo in the file you’re editing. Parsing a published manifest doesn’t check
composes for unknown fields. The runtime ignores them.
Render locally
This page is for a module author with a scaffolded guest project who wants to
see what their Composition produces before publishing anything. It runs the
runtime built from a checkout of this repository against a Path module on
your machine. Installing the runtime in a cluster is out of scope.
You need the crossplane CLI, Docker (the render engine runs in it) and a
Rust toolchain to build the runtime from the checkout.
Use two terminals. In the checkout, start the runtime with
--module-dir set to your project directory (an absolute path is simplest):
cargo run --release -p function-wasm -- --insecure --debug --module-dir=<your project directory>
In your project directory, build the module and render with the Development
runtime that the scaffold’s example/functions.yaml declares:
guestfn build
crossplane render example/xr.yaml example/composition.yaml example/functions.yaml
The example Composition uses module.type: Path with path: fn.wasm. For a
cluster, use type: OCI with an oci reference, as the
Input reference describes.
Rendering asserts nothing. To run this loop on the examples in this repository, see the development guide.
Check a composition
This page is for module authors and composition authors who want to know,
before the first reconcile, whether the runtime admits a Composition’s
function-wasm steps. It covers function validate, the validate subcommand of
the runtime binary. It doesn’t run a module.
Crossplane never installs a function’s Input CRD, so the runtime is the only
gate an Input passes. function validate
runs that gate offline. It applies the same checks to every function-wasm step
of the Compositions (or bare Input documents) you give it.
It takes the same flags as the serving runtime, such as --module-timeout,
--module-memory-limit, --sandbox-policy-file and --cosign-key. Pass the
flags your pod runs with, or the verdict can differ from the cluster’s.
cargo run --release -p function-wasm -- validate \
example/composition.yaml --module-dir=. --resolve
# or, with the released image (its entrypoint is the runtime):
docker run --rm -v "$PWD:/w" ghcr.io/jonasz-lasut/function-wasm:<version> validate /w/composition.yaml \
--sandbox-policy-file /w/policy.cedar
Each step prints one line, with its warnings indented below. A step checked through --resolve also prints the module’s digest and what its request contains. These lines come from the conformance fixtures, so their paths aren’t a real project’s:
testdata/validate/credentials.yaml: Composition/credentials pipeline[1] permitted: OK (path fn.wasm, compositionPolicy)
module: sha256:09e6ac0cbcc0abcea78afa7df96114b5e4c3bf4020eb97a9aa188e17473d9714, 255 B, ABI v2; manifest: cmdb-reader 0.1.0, credentials cmdb
credentials: cmdb
testdata/validate/credentials.yaml: Composition/credentials pipeline[2] fenced: refused: module module file fn.wasm requires credential "cmdb" (requires.credentials[0]), which the compositionPolicy does not permit
The refusal is the runtime’s own sentence, so a message you find here is the one the pod logs. Troubleshooting explains each.
Flags
By default validate checks the Input alone: the compositionPolicy compiles, limits stay within the ceilings, and the module source is well formed. These flags change the check or its output.
--resolveresolves and fetches each module. It checks the signature against--cosign-keyand compiles the module with Wasmtime for the runtime’s own verdict on size, ABI and host imports. It then decides the manifest’srequiresas the runtime does, and lists on acredentials:line the step credentials the module’s request would contain. With none listed, the module receives none. OCI pulls use the local Docker config and never a step credential. When the module’s manifest declares a kind incomposesthat a policy layer would refuse, the step gets a warning such asthe module declares it composes core/Secret (add), which the compositionPolicy does not permit (composeResource).--xr xr.yamlmaterializesmodule.fromsources against that composite resource, as the observed XR would. Without it, afromsource is checked only for thecompositionPolicyit requires, and a field the XR leaves unset undermodule.allowEmptyis reported as no module.--function-namekeeps only the steps of one function.--output jsonprints one JSON object per step, one per line, for CI annotations.-reads stdin.
A step whose compositionPolicy scopes composeResource ends its OK line’s parenthesis with composeResource, as in OK (path fn.wasm, compositionPolicy, composeResource). The policy fences the module’s output, which only a run can judge, so validate reports the scoping and not a verdict.
Warnings and exit codes
Warnings never change the exit code. The runtime warns about:
- A
Pathsource in a Composition:module.type Path names a file under the runtime's --module-dir and carries no digest; a cluster Composition should pin an OCI or HTTP source by digest - A module that requires egress when the runtime can’t verify signatures, under
--resolve:the module requires egress but is not signature-verified: neither --cosign-key nor --cosign-trusted-root was given - A limit equal to its ceiling:
limits.timeout <value> equals --module-timeout: it narrows nothing, and the same forlimits.memoryagainst--module-memory-limit - A field the runtime ignores:
unknown field "<name>" is ignored by the runtime - The deprecated
v1beta1apiVersion:apiVersion wasm.fn.crossplane.io/v1beta1 is deprecated and is removed in function-wasm 2.0.0; write apiVersion: wasm.fn.crossplane.io/v1 (the same fields)
The exit code is:
- 0 when every step is admitted.
- 1 when at least one step is refused.
- 2 when the tool itself failed: an unreadable file, unparsable YAML or a bad flag.
To run it as part of the development loop, see Render locally.
Install
This page is for operators adding function-wasm to a cluster that already runs Crossplane. It installs the function package and checks that Crossplane accepted it.
Apply a Function with the package reference:
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-wasm
spec:
package: ghcr.io/jonasz-lasut/function-wasm:v0.6.0
The same package is published to xpkg.upbound.io/jonasz-lasut/function-wasm, for clusters that pull from xpkg.upbound.io.
One installed function serves any number of modules: a Composition step puts its module reference in the step’s Input, so adding a module needs no further install.
Crossplane supports the three most recent minor releases, and function-wasm supports the same three. Pushing a release branch runs the end-to-end suite on all three, and a release is cut only when that run is green (the release matrix lists the versions).
Check the install
Crossplane pulls the package and starts the function’s pod. The function is ready when INSTALLED and HEALTHY are both True:
kubectl get function function-wasm
If it stays unhealthy, read the pod’s logs and the Function’s conditions (kubectl describe function function-wasm). Troubleshooting lists the runtime’s own refusals.
The side ports
The function port speaks mTLS to Crossplane. /metrics on 8080 and the
probes on 8081 are plain HTTP. Their answers contain no secret and no
module name, and nothing authenticates a caller. Admit them only from where
they’re read. A NetworkPolicy on the function’s pods, which
Crossplane labels pkg.crossplane.io/function: function-wasm, admits the
metrics port from the monitoring namespace and leaves the probe port to the
kubelet:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: function-wasm-side-ports
namespace: crossplane-system
spec:
podSelector:
matchLabels:
pkg.crossplane.io/function: function-wasm
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- port: 8080
- ports:
- port: 8081
The probe rule admits 8081 from anywhere, because the kubelet’s probes
come from the node’s address, which a namespaceSelector can’t name. Most
network plugins let the kubelet’s probes through whatever the policy says;
where yours doesn’t, replace that rule with an ipBlock of the node CIDR.
The function port isn’t in the policy, so Crossplane keeps reaching it.
Next steps
A fresh install runs every module in the default sandbox, which gives a module no network, filesystem or environment beyond the request (trust model).
- Set flags, such as limits and caches, through a
DeploymentRuntimeConfig: see runtime flags. - Keep the caches across restarts on a volume that only this Function’s pods mount: see cache storage.
- Add readiness and liveness probes and pre-load modules: see readiness and warm-up.
- Require signed modules: see signatures.
- Allow a module more than the default sandbox, such as egress or a private
/tmp: see the grant policy. - Admit the metrics and probe ports only from their readers: see the side ports.
Input reference
This page is for composition authors writing a function-wasm step and for
operators who need to predict what the runtime admits. It lists every field
of the step’s Input and what the runtime enforces for each. Crossplane never
installs the Input’s CRD, so the runtime is the only enforcement: it checks
every rule on every request, and function validate applies the same checks
offline. The CRD under package/input/ is for tooling and IDEs only.
The Input is apiVersion: wasm.fn.crossplane.io/v1, kind: Input. A step
that still says wasm.fn.crossplane.io/v1beta1 (the same fields) is accepted
throughout 1.x: function validate prints a warning under it, the runtime
logs one line per request naming v1, and 2.0.0 removes it
(Compatibility).
module: # required
type: OCI # required: OCI | HTTP | Path - the Composition's choice
oci: {ref, credentials} # exactly one of the object matching type …
http: {url, digest}
path: fn.wasm
from: status.module # … or the observed XR field holding it
allowEmpty: true # optional, with from: an unset field runs nothing
compositionPolicy: | # optional; the composition author's own Cedar layer
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
limits: # optional; each at most the runtime's ceiling
timeout: 5s
memory: 128Mi
config: {...} # optional; opaque, forwarded to the module
Everything but the source is read from the Input: compositionPolicy and
limits are the Composition’s, never the composite resource’s. The Input
has no sandbox block. Capabilities come from the module’s manifest and
two Cedar layers, described under Capabilities.
The fields are:
| field | type | description |
|---|---|---|
module | object | Required. Where the module comes from |
module.type | string | Required. OCI, HTTP or Path. Exactly one of the object it names (oci, http, path) or module.from is set, and no object of another type may be present |
module.oci.ref | string | OCI artifact reference pinned to its manifest digest, registry/repo@sha256:…, as guestfn push prints it. See OCI references |
module.oci.credentials | string | name of the pipeline-step credential used to pull. See Pull credentials |
module.http.url | string | the HTTP or HTTPS URL the module is downloaded from |
module.http.digest | string | Required. sha256:<hex> of the module, which the runtime verifies the download against. A digest the runtime already caches is served without a download |
module.http.manifestURL | string | Optional. A wasmfn.yaml served beside the module. Set with module.http.manifestDigest. Without it an HTTP source has no manifest and gets only the default sandbox. For a module.from http source, compositionPolicy pullModule fences it like the module URL |
module.http.manifestDigest | string | sha256:<hex> of the manifest, verified against it. Required with module.http.manifestURL, refused without it |
module.path | string | a file relative to the runtime’s --module-dir. Refused unless that flag is set. A Path source has no digest |
module.manifestPath | string | Optional, type: Path only. A wasmfn.yaml under --module-dir, the manifest of a local or volume-mounted module. Never set through module.from, and re-read on each request. A local edit takes effect without a restart |
module.from | string | a field of the observed composite resource, under spec. or status., that holds the source. See Modules chosen by the XR |
module.allowEmpty | boolean | Optional, with module.from only. See Skipping a step |
compositionPolicy | string | the composition author’s Cedar layer. See The composition policy |
limits.timeout | duration | compute budget of one run, e.g. 5s. See Limits |
limits.memory | quantity | memory a run may use, its linear memories and tables together, e.g. 128Mi. See Limits |
limits.concurrency | int32 | at most N runs of this step at once. See Limits |
config | object | opaque, passed to the module untouched inside the request input. Non-secret module configuration belongs here: the module’s environment comes only from its manifest’s requires.env credential bindings |
OCI references
module.oci.ref must include a manifest digest. A tag alone is refused, and
the runtime doesn’t resolve tags at request time. With
registry/repo:tag@sha256:… only the digest is fetched. The manifest states
its layer’s digest, and the runtime verifies both on fetch.
The runtime takes the first layer whose media type is application/wasm,
application/vnd.wasm.content.layer.v1+wasm or
application/vnd.module.wasm.content.layer.v1+wasm. Failing that, it takes
the only layer that isn’t the manifest layer. A tar layer (a FROM scratch
image) must hold the module at exactly /fn.wasm.
Pull credentials
module.oci.credentials names a pipeline-step credential: a Secret with
.dockerconfigjson, or with username and password keys. It’s declared
on the step:
- step: greet
functionRef:
name: function-wasm
credentials:
- name: registry
source: Secret
secretRef:
namespace: crossplane-system
name: ghcr-pull
input:
apiVersion: wasm.fn.crossplane.io/v1
kind: Input
module:
type: OCI
oci:
ref: ghcr.io/example/private-fn@sha256:…
credentials: registry
Without credentials, the runtime tries its Docker config (DOCKER_CONFIG),
then anonymous access. The module never receives the pull credential, and a
manifest that names it as an env binding source or a required credential is
refused.
Modules chosen by the XR
With module.from, each composite resource chooses its module. The
Composition states the type and the XR field, and the field contains the source:
an object {ref, credentials} for OCI, {url, digest} for HTTP, a
string for Path. The runtime reads it on every request and rejects unknown fields. A typo or a wrong structure is a fatal result that states the
field. An unset field is a fatal result naming it, unless module.allowEmpty
says otherwise.
input:
apiVersion: wasm.fn.crossplane.io/v1
kind: Input
module:
type: OCI
from: spec.module # spec.module: {ref: ghcr.io/example-org/greeter@sha256:…}
compositionPolicy: |
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
A source chosen this way is fenced by the compositionPolicy, which is
required for OCI and HTTP sources. See
The composition policy.
Skipping a step
module.allowEmpty: true lets the composite resource leave the field unset
(absent, or null). The step then runs nothing and returns the request’s
desired state and context unchanged. The pipeline continues. A set field
is handled as any static source is. The Input alone decides this, so an XR
can leave a step empty only where the Composition allows it, and
allowEmpty without module.from is refused. The runtime logs a skipped
step and counts it as requests_total{outcome="skipped"}.
Spending the pull credential
An XR-chosen module may specify a pull credential only where the
compositionPolicy permits spendCredential for it, and only for a
repository that a pullModule permit admits. The pull check runs first. The
spendCredential rule tests context has repository, because the context
carries repository only for this pull credential:
module:
type: OCI
from: status.module # status.module: {ref: ghcr.io/example-org/…@sha256:…, credentials: registry}
compositionPolicy: |
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
permit (principal, action == Action::"spendCredential",
resource == Credential::"registry")
when { context has repository &&
context.repository in Repository::"ghcr.io/example-org" };
The composition policy
compositionPolicy is Cedar text. Malformed Cedar is a fatal result at admission. It’s required whenever
module.from names an OCI or HTTP source: without it the XR author could
point the runtime at any host.
For a module.from source, a pullModule permit must match the module’s
normalized location, and a step credential may be spent only where a
spendCredential permit matches. For sandbox actions the layer can only
narrow what the manifest and the operator grant. The actions, entities,
evaluation rules and refusal sentences are in the
Cedar policy reference.
Limits
limits can only narrow what the runtime allows, never widen it.
limits:
timeout: 5s # ≤ --module-timeout
memory: 128Mi # ≤ --module-memory-limit
limits.timeoutis at most--module-timeout, otherwise a fatal result states both:limits.timeout 1m0s exceeds the runtime's --module-timeout of 30s. Time spent waiting on the host’s HTTP answers is credited back (see HTTP egress), and the request’s own gRPC deadline still caps the run if shorter.limits.memoryis at most--module-memory-limit, otherwise a fatal result states both:limits.memory 1Gi exceeds the runtime's --module-memory-limit of 512Mi.limits.concurrencycaps runs of this step at once, across all requests, keyed by the module’s content digest. A further request waits under its own deadline. If the deadline passes first, the result is fatal and isn’t counted as a run. A value over--max-concurrent-runsis capped to it without an error.
Capabilities
A module declares the capabilities it can’t run without in its manifest, as
requires (Module manifests). The runtime
grants each one only when the Input’s compositionPolicy and the operator’s
--sandbox-policy-file both permit it
(three-layer authorization). The same
two layers may fence what the module composes with composeResource: see the
output fence. Egress is covered on
HTTP egress.
Step credentials
Of the step’s credentials, the request a module receives contains only those
its manifest names and both policy layers permit with spendCredential: the
credential a requires.env binding reads, or one it reads whole with
requires.credentials. The runtime edits every other credential out before
the module runs, so a module with no manifest sees none.
A required credential that the step doesn’t declare is a fatal result before
the run: … requires.credentials[0]: the request carries no credential "cmdb"; declare it on the pipeline step.
Private /tmp and environment
The private /tmp needs usePrivateTmp permitted in both Cedar layers. An
env binding needs setEnv and spendCredential in both. A required
capability that either layer doesn’t permit, or any on a runtime with no
--sandbox-policy-file, is a fatal result before the module runs. The
manifest syntax is on Module manifests.
Runtime flags
This page is for operators who run the function-wasm runtime from a Crossplane package and set its flags through a DeploymentRuntimeConfig. It lists each flag with its environment variable and default, one line each. The linked pages have the detail.
The binary has two subcommands, serve and validate. serve is the default, so function --insecure --module-dir=. and a DeploymentRuntimeConfig’s args don’t name it. validate takes the flags marked “yes” in the validate column below, with the same defaults and environment variables, so it checks a Composition against what a runtime started with those flags would admit.
Limits and sources
These flags set the ceilings that a step’s limits can’t exceed (Input reference) and where path sources come from.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--module-dir | MODULE_DIR | unset | yes | directory served for path sources; unset refuses them |
--max-module-size | 128 MB | yes | largest module accepted | |
--module-timeout | 30s | yes | wall-clock budget of one run, and the ceiling for limits.timeout | |
--module-memory-limit | 512 MB | yes | memory one run may use: its linear memories and tables (a host pointer per element) together, the initial memory included; also the ceiling for limits.memory | |
--module-stack-limit | MODULE_STACK_LIMIT | 512 KB | no | call stack one run may use. Past it the run fails with trap: call stack exhausted. No Input field narrows it |
--ttl | 60s | no | TTL of responses the runtime itself produces (fatal results); a module sets its own |
Concurrency
By default nothing is bounded: concurrency is the caller’s, set by Crossplane’s reconcile workers.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--max-concurrent-runs | MAX_CONCURRENT_RUNS | 0 (unbounded) | no | module runs executing at once; a request that can’t get a slot before its deadline is a fatal result (waiting for a run slot: deadline exceeded) and never ran |
--max-total-run-memory | MAX_TOTAL_RUN_MEMORY | 0 (unbounded) | no | total memory budget in MB across running modules, linear memories and tables together; see below |
--max-concurrent-compiles | MAX_CONCURRENT_COMPILES | 1 | no | modules compiled at once; a compile is heavy on CPU and memory, so further first requests wait their turn |
--module-compile-timeout | MODULE_COMPILE_TIMEOUT | 0s (unbounded) | no | wall-clock bound on a cold load, the fetch and compile of a module that isn’t cached yet. Past it the request fails with cannot load module …: compile exceeded --module-compile-timeout <duration>; the compile runs to its end, keeps its compile slot until then and stores its artifact for the next request. The syntax of --module-timeout |
A component reserves nothing up front. The runtime charges each memory or table growth, the initial memory included, to the --max-total-run-memory pool as the guest claims it, and a run waits under its deadline while the pool is full. A growth the pool can’t serve before the deadline fails inside the guest as a failed memory.grow, counted in function_wasm_module_memory_denials_total. See Sizing for what these bounds cost.
Caches
Compiled modules are cached in memory, and fetched modules and compiled artifacts on disk. See Cache storage for the directory.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--enable-memory-cache | ENABLE_MEMORY_CACHE | true | no | keep compiled modules in memory between requests; the flag takes a value, and with --enable-memory-cache=false each request maps the compiled artifact from disk and releases it afterward |
--max-cached-modules | MAX_CACHED_MODULES | 0 (unbounded) | no | most compiled modules resident at once; past it the least recently used is dropped, freed once its last run ends |
--max-cache-size | MAX_CACHE_SIZE | 0 (unbounded) | no | MB the two on-disk caches may hold together; past it the runtime removes the least recently used entries, at startup and every ten minutes. Size the volume, or set this below its size |
--warm-modules | WARM_MODULES | unset | no | modules loaded before readiness; see Readiness and warm-up |
Signing
These flags set which module signatures the runtime accepts. Signatures explains each mode and how they combine.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--cosign-key | COSIGN_KEY | unset | yes | PEM file of cosign public keys for key-based cosign 3 signatures |
--cosign-trusted-root | COSIGN_TRUSTED_ROOT | unset | yes | Sigstore trusted_root.json that verifies keyless signatures offline; excludes --cosign-trust-instance |
--cosign-trust-instance | COSIGN_TRUST_INSTANCE | unset | yes | public-good, staging or github: fetch that instance’s trusted root over TUF and refresh it while serving |
--cosign-tuf-refresh | COSIGN_TUF_REFRESH | 1h | no | how often --cosign-trust-instance refreshes the root, at least 1m |
Policy and egress
See Operator grant policy for writing and mounting the policy and HTTP egress for the egress budgets.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--sandbox-policy-file | SANDBOX_POLICY_FILE | unset | yes | Cedar document holding the operator’s grant policy; unset, every sandbox capability is refused. Compiled once at startup: restart to reload |
--egress-rate-limit-per-minute | EGRESS_RATE_LIMIT_PER_MINUTE | 0 (off) | yes | sustained egress requests per minute per module digest, as a process-wide token bucket |
--egress-rate-limit-burst | EGRESS_RATE_LIMIT_BURST | 0 (derived) | yes | burst tokens for the rate limit; 0 derives max(1, requestsPerMinute) |
Health and metrics
Both endpoints are plain HTTP, because the function port speaks mTLS. Readiness and warm-up covers wiring probes.
| flag | env | default | validate | purpose |
|---|---|---|---|---|
--health-address | HEALTH_ADDRESS | :8081 | no | /livez and /readyz (503 while warming); empty disables them |
--metrics-address | METRICS_ADDRESS | :8080 | no | Prometheus /metrics (Metrics); empty disables it |
The SDK’s flags --insecure, --debug, --tls-certs-dir, --address and --max-recv-message-size apply too.
Logging
--debug turns on the runtime’s own debug lines in a human-readable format: guest log records sent at debug level, the credentials withheld from a module and trap details. Everything else logs at info, so a compile under --debug doesn’t print the compiler’s per-pass lines. A set RUST_LOG replaces the flag’s filter in full.
Cache storage
The caches live under /tmp/function-wasm-cache, which isn’t configurable. Mount an emptyDir there if the pod’s root filesystem is read-only, or a volume to keep the fetched modules and manifests across pod restarts:
spec:
deploymentTemplate:
spec:
template:
spec:
containers:
- name: package-runtime
volumeMounts:
- name: cache
mountPath: /tmp/function-wasm-cache
volumes:
- name: cache
persistentVolumeClaim:
claimName: function-wasm-cache
A volume keeps the fetched modules and the manifests, which the runtime verifies against their digest on every read. It doesn’t keep the compiled code. Each process signs the artifacts it writes under a key drawn at startup and refuses every other (trust model). A restarted pod compiles its modules again. Pods must not share a volume, because each would refuse and overwrite the other’s artifacts. Give each pod its own volume, with the runtime as its only writer. The runtime removes the compiled directories of other Wasmtime versions after a day without writes.
Opening the sandbox
Mount the Cedar --sandbox-policy-file through the same DeploymentRuntimeConfig, and put a tmpfs behind TMPDIR to bound the private /tmp:
spec:
deploymentTemplate:
spec:
template:
spec:
containers:
- name: package-runtime
args:
- --sandbox-policy-file=/etc/function-wasm/policy.cedar
env:
- name: TMPDIR
value: /scratch
volumeMounts:
- {name: policy, mountPath: /etc/function-wasm, readOnly: true}
- {name: scratch, mountPath: /scratch}
volumes:
- name: policy
configMap: {name: function-wasm-policy}
- name: scratch
emptyDir: {medium: Memory, sizeLimit: 64Mi}
A fuller example with every operator-authorable option is examples/deployment-runtime-config-cedar.yaml.
Operator grant policy
This page is for operators of function-wasm. It shows how to write the Cedar
policy that --sandbox-policy-file loads, mount it into the function’s pod and
check it offline with function validate. It covers the operator layer only:
the schema and how each layer decides are in the
Cedar policy reference.
The policy file is the only thing that enables a sandbox capability. A module’s manifest requests a capability and the Input’s compositionPolicy can narrow it, but neither can grant more than the operator’s policy does.
Evaluation is default-deny: a capability is refused unless a permit matches it, and a forbid overrides a permit. Without a --sandbox-policy-file, no capability is grantable and every module runs in the default sandbox, which has nothing but the request. A file that permits one capability still refuses every other one, so permit each capability you mean to allow.
Actions and principal
The capability actions are:
usePrivateTmp: a private/tmp.setEnv: environment variables bound to step credentials.spendCredential: the step credentials a module reads.grantEgress: HTTP egress, which is also the host allowlist.
The operator layer also decides dialAddress, on the egress page, and requireSignature and trustSigner on signatures. composeResource, below, fences a module’s output rather than granting a capability.
The principal’s attributes, and the resources and context of each action, are in the Cedar policy reference.
Write the policy
This policy lets only team-a use a private /tmp, and lets any
namespace reach *.example.com with GET:
permit (principal, action == Action::"usePrivateTmp", resource)
when { principal.namespace == "team-a" };
permit (principal, action == Action::"grantEgress", resource)
when { resource in HostPattern::"example.com" && context.method == "GET" };
Mount it and set the flag
Put the policy in a ConfigMap in the namespace the Function runs in (here crossplane-system):
kubectl create configmap function-wasm-policy -n crossplane-system \
--from-file=policy.cedar
Then mount it through the Function’s DeploymentRuntimeConfig:
spec:
deploymentTemplate:
spec:
template:
spec:
containers:
- name: package-runtime
args:
- --sandbox-policy-file=/etc/function-wasm/policy.cedar
volumeMounts:
- {name: policy, mountPath: /etc/function-wasm, readOnly: true}
volumes:
- name: policy
configMap: {name: function-wasm-policy}
The runtime compiles the policy at startup and doesn’t reread it. After changing the ConfigMap, restart the function’s pod.
Check it offline
function validate reports the same verdicts as the runtime, without a cluster:
function validate composition.yaml --resolve \
--sandbox-policy-file policy.cedar --xr xr.yaml
The principal comes from --xr. Without it, the zero principal matches no per-tenant condition. The command and its other flags are in Check a composition.
Fence what modules compose
composeResource isn’t a capability. It’s a ceiling on what any module may compose, judged over the response after the run. It applies only where a rule scopes it. Otherwise every module’s output is applied unchanged.
A forbid alone scopes the action and permits nothing. A ceiling needs a permit beside it. This policy refuses RBAC resources and Secrets and permits everything else:
forbid (principal, action == Action::"composeResource", resource)
when { resource in Group::"rbac.authorization.k8s.io" || resource == Kind::"core/Secret" };
permit (principal, action == Action::"composeResource", resource);
Cedar policy reference
This page is for operators writing the policy file that --sandbox-policy-file
names and for composition authors writing a step’s compositionPolicy. It
assumes you know Cedar’s permit and forbid syntax and the
module manifest’s requires. Use it to write a
rule and to read the refusal it produces. Mounting the operator’s file is in the
operator grant policy, signing setup in
signatures and egress budgets in HTTP egress.
A module gets a capability beyond the default sandbox only when three layers
agree: its manifest requests it (requires), the Input’s compositionPolicy
permits it and the operator’s policy file permits it. Both policy layers are
Cedar documents over one schema. A rule means the
same in both, and a layer can only narrow what the layer before it allows. The
trust model explains the split.
A minimal policy
This operator policy lets modules in the team-a namespace use a private /tmp
and send GET requests to example.com and its subdomains:
permit (principal, action == Action::"usePrivateTmp", resource)
when { principal.namespace == "team-a" };
permit (principal, action == Action::"grantEgress", resource)
when { resource in HostPattern::"example.com" &&
context.method == "GET" };
A compositionPolicy is written the same way. The rest of this page defines
the names these rules use.
The layers
| layer | written by | actions it may use |
|---|---|---|
| operator policy | the operator | usePrivateTmp, setEnv, spendCredential, grantEgress, composeResource, requireSignature, trustSigner, dialAddress |
| composition policy | the composition author | pullModule, spendCredential, usePrivateTmp, setEnv, grantEgress, composeResource |
The runtime compiles the operator policy at startup and doesn’t reread it. A
change needs a restart. The composition policy is the Input’s
compositionPolicy string, compiled per Composition and cached by content hash.
The Input reference says where it goes in the Input and
that the runtime reads it from the Input only, never from the composite
resource.
requireSignature, trustSigner and dialAddress are the operator’s alone:
the runtime never evaluates a compositionPolicy rule for them. pullModule is
the composition author’s alone. The operator policy has no say in where an
XR-chosen module comes from, only in what it’s granted once chosen.
The principal
The runtime decides each request for the principal Request::"self", the caller
of the step. Its attributes are strings:
| attribute | value |
|---|---|
principal.namespace | the observed composite resource’s metadata.namespace |
principal.xrKind | the observed composite resource’s kind |
principal.composition | always empty: a RunFunctionRequest doesn’t include the Composition name |
A request with no observed composite resource gets the zero principal, with
every attribute empty. It matches no condition that tests one, and that’s safe
because both layers only narrow. function validate builds the principal from
--xr and uses the zero principal without it.
requireSignature and trustSigner see the placeholder principal
Module::"module", which has no attributes. A rule for either that conditions
on principal never matches.
Entity types
Rules name entities in their resource scope, and in follows each type’s
hierarchy. in is reflexive: an entity is in itself.
Repositoryis a module’s location without its tag or digest. An OCI reference isregistry/repository, normalized as go-containerregistry does (docker.io/xisindex.docker.io/x). An HTTP URL isscheme://host/pathwith a lowercase host and without its query. Every path-boundary prefix is an ancestor, with and without its trailing slash:ghcr.io/teamis an ancestor ofghcr.io/team/mod, andghcr.io/team-otherandghcr.io/teamsuffixaren’t.HostPatternis an egress rule’shost, in lowercase without a trailing dot, or itshostPatternas written (*.example.com). Itshostattribute is the same string, forlike. The DNS label suffixes are ancestors:api.example.comand*.example.comare bothin HostPattern::"example.com"andin HostPattern::"com".example.com.attacker.netandbadexample.comaren’t.CapabilityisprivateTmporenv.Credentialis the name of a pipeline-step credential.Kindis<group>/<Kind>of a resource a module composed, read from itsapiVersionandkind:apps/Deployment,policy/PodDisruptionBudget,core/Secret(the core group is spelledcore). The API version is dropped, sopolicy/v1andpolicy/v1beta1are one kind.Groupis an API group (apps,policy,core) and the parent of everyKindin it:resource in Group::"apps"is every kind of the apps group and no other.
Actions
Each action takes one resource type and a context. The table lists them and the notes below it cover what a cell can’t:
| action | layer | resource | context | what a matching permit grants |
|---|---|---|---|---|
pullModule | composition | Repository | none | an XR-chosen source (module.from) may be fetched from that location, and an XR-chosen HTTP source’s manifestURL is fenced by the same rules |
spendCredential | both | Credential | repository, for an XR-chosen pull credential only | the credential an env binding reads, one a module reads whole (requires.credentials) or an XR-chosen module’s pull credential |
usePrivateTmp | both | Capability::"privateTmp" | none | the private /tmp the manifest requires |
setEnv | both | Capability::"env" | keys, the set of every environment variable name the manifest binds | the env bindings as a whole |
grantEgress | both | HostPattern | method (upper case) and path (the rule’s pathPrefix, empty when it has none) | one rule’s host and method |
composeResource | both | Kind | change, name and target | nothing: it fences the module’s output |
requireSignature | operator | Repository | none | nothing: it demands a cosign signature for modules at that location |
trustSigner | operator | Repository | issuer and identity | nothing by itself: it makes a keyless signer trusted for requireSignature |
dialAddress | operator | none | ip | nothing at request time: it adds to the egress block list |
spendCredentialhascontext.repository, the module’sRepositoryentity, only for an XR-chosen OCI source’s pull credential in the composition layer. It’s absent for an env binding’s credential and forrequires.credentials, so test it withcontext has repository.setEnvis decided once for all bindings, andspendCredentialthen decides each binding’s credential.grantEgressis decided once per rule and per method the rule lists.composeResourceis decided once per change the module made:changeisadd,modifyorremove,nameis the composition resource name (empty for the composite resource) andtargetiscompositeorcomposed. See the output fence.trustSigner’sissueris the OIDC issuer of a keyless signature’s Fulcio certificate andidentityits subject alternative name, both strings for==andlike. Signatures has the precedence with--cosign-key.dialAddressmust leaveprincipalandresourceunconstrained. Itsipis the resolved address, tested withisInRange(ip("CIDR"))orisLoopback(). Aforbidadds a blocked prefix and apermita hole in the default list.
Evaluation per layer
The operator layer
The operator layer is default-deny, as Cedar evaluates it: a request is
permitted when at least one permit matches and no forbid does. A policy that
permits one capability still refuses every other capability a module requires.
With no --sandbox-policy-file nothing is grantable, and a module that requires
anything is refused with a sentence that mentions the flag.
The composition layer
The composition layer evaluates the same way, in two regimes.
The module.from fences are default-deny. A module the composite resource
chooses from an OCI or HTTP source must be permitted by a pullModule rule, and
an Input with no compositionPolicy is refused for such a source. An XR-chosen
OCI source may specify a step credential only where a spendCredential rule
permits it, and context.repository lets one rule cover both halves. A static
source, specified in the Input, isn’t fenced.
The sandbox actions are scoped default-permit. The layer narrows usePrivateTmp,
setEnv, grantEgress and spendCredential (for env bindings and
requires.credentials) only when one of its rules, permit or forbid, scopes
that action with action == Action::"setEnv" or
action in [Action::"setEnv", ...]. Until then the manifest and the operator
decide that action alone. Once scoped, a matching permit and no matching
forbid allow it. A rule with an unconstrained action
(permit (principal, action, resource);) scopes nothing. A forbid alone scopes
an action and permits nothing. A ceiling needs a permit beside it.
composeResource follows the same rule in both layers. It’s the one action where
the operator layer isn’t default-deny.
The runtime evaluates the composition layer first and whole. A composition
author reads their own layer’s refusal even where the operator would also deny.
A compositionPolicy that isn’t valid Cedar is refused at admission, before
anything is resolved.
The output fence
composeResource fences what a module writes. It’s default-permit in both
layers because a default-deny would have refused every module’s output on
upgrade. After the run, the runtime compares the response’s desired state with the
request’s by composition resource name and judges every change:
- A name the response has and the request didn’t is an
add. - A name both have whose resource differs is a
modify. The comparison is by value over the body, connection details and ready flag, so a guest that re-encodes a map in another order doesn’t change it. - A name the request had and the response dropped is a
remove. A response with no desired state drops everything. - A kind replaced under one name is a
removeof the old kind and anaddof the new one.
The composite resource’s own desired state is judged as
Kind::"<xr group>/<xrKind>" with context.target == "composite" and an empty
name. A status write is a modify.
Every layer that scopes the action must permit each change. A refusal is a fatal result that states the module, the kind, the name, the change and the layer, and none of the module’s output is applied. The runtime doesn’t judge a response that already contains a fatal result.
The fence judges the desired state and nothing else. A module can ask
Crossplane for any resource the Function’s RBAC lets Crossplane read, through
the response’s requirements. It can also write context and conditions.
The fence sees none of these. Scope the Function’s RBAC to what its modules
should see. A fence over the requirements, a requireResource action judged the
same way, is proposed for 1.0.0.
Worked examples
One rule per action. The refusal each produces is in Refusal sentences.
pullModule
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example");
This permits an XR-chosen module at ghcr.io/example/greeter and refuses
ghcr.io/example-evil/greeter: the hierarchy follows path boundaries. A principal.namespace condition narrows it per tenant.
spendCredential
In a compositionPolicy, for the pull credential of an XR-chosen module:
permit (principal, action == Action::"spendCredential",
resource == Credential::"regcred")
when { context has repository &&
context.repository in Repository::"ghcr.io/example" };
This permits regcred for a module chosen from ghcr.io/example/greeter. It
refuses ghcr.io/other/greeter, and it refuses an env binding or a
requires.credentials entry that names regcred, because neither has a
repository. Permit those with a second rule that doesn’t test
context.repository.
In the operator policy, which never sees a repository:
permit (principal, action == Action::"spendCredential",
resource == Credential::"cmdb");
A module whose manifest lists credentials: [cmdb, db] is refused for db.
setEnv
permit (principal, action == Action::"setEnv", resource)
when { principal.namespace == "team-a" &&
context.keys.contains("DATABASE_URL") };
This permits the env bindings as a set for team-a when DATABASE_URL is among
the bound names. Each binding’s credential still needs a spendCredential
permit in the same layer.
usePrivateTmp and grantEgress
The minimal policy shows both. In a compositionPolicy, a
usePrivateTmp rule scopes the action and narrows it for every request of the
step. A grantEgress permit for example.com covers
api.example.com, example.com and *.example.com with GET, and refuses a
rule that lists methods: [GET, POST] on its POST. context.path is the
rule’s pathPrefix, so context.path like "/v1/*" narrows by path.
requireSignature
permit (principal, action == Action::"requireSignature", resource)
when { resource in Repository::"ghcr.io/secure" };
Modules pulled from under ghcr.io/secure need a cosign signature that a
--cosign-key key verifies, or a keyless one by a trusted signer. Modules from
ghcr.io/open/greeter run unsigned. A required HTTP source is refused before
any fetch, since only OCI artifacts can be signed. A Path source is never
required.
trustSigner
permit (principal, action == Action::"requireSignature", resource)
when { resource in Repository::"ghcr.io/jonasz-lasut/wasmfn/examples" };
permit (principal, action == Action::"trustSigner", resource)
when {
resource in Repository::"ghcr.io/jonasz-lasut/wasmfn/examples" &&
context.issuer == "https://token.actions.githubusercontent.com" &&
context.identity like "https://github.com/jonasz-lasut/function-wasm/.github/workflows/publish-pkg.yml@refs/tags/v*"
};
A keyless signature on a module under that repository counts only when its
Fulcio certificate was issued to this repository’s release workflow on a version
tag, authenticated by GitHub Actions. like takes * as a wildcard. The runtime
verifies the signature against the trusted root first. The rule only sets which
verified signers are trusted, so a rule with no when trusts
every identity the root’s authorities certified.
dialAddress
forbid (principal, action == Action::"dialAddress", resource)
when { context.ip.isInRange(ip("203.0.113.0/24")) || context.ip.isLoopback() };
permit (principal, action == Action::"dialAddress", resource)
when { context.ip.isInRange(ip("10.96.0.0/12")) };
The runtime judges a dialed address against an operator forbid first, then the
permit holes, then the default block list. Here the forbid blocks a public
range and loopback, and the permit opens an in-cluster range the default list
refuses. A permit inside a range a forbid covers never takes effect. A || is the union of its tests, a bare address literal is a
/32 or /128, and a rule with no when applies to every address. The block
list and budgets are on the HTTP egress page.
composeResource
To confine a step to adding PodDisruptionBudgets:
permit (principal, action == Action::"composeResource", resource in Group::"policy")
when { context.change == "add" };
To keep every module from composing RBAC or Secrets, an operator needs a
permit beside the forbid:
forbid (principal, action == Action::"composeResource", resource)
when { resource in Group::"rbac.authorization.k8s.io" || resource == Kind::"core/Secret" };
permit (principal, action == Action::"composeResource", resource);
function validate can’t judge output offline. It marks a step whose
compositionPolicy scopes the action with composeResource on its OK line, and
with --resolve it warns where a kind the module’s manifest declares it composes
would be refused.
Refusal sentences
A refused capability is a fatal result that states the module (module oci ghcr.io/example/greeter@sha256:… requires …) and the layer that refused it.
function validate prints the same sentences under the step. This table gives
the sentence for each requires entry, by who refuses it:
requires entry | no --sandbox-policy-file | the composition layer refuses | the operator layer refuses |
|---|---|---|---|
requires.filesystem.privateTmp | requires a private /tmp (requires.filesystem.privateTmp), but the runtime has no --sandbox-policy-file, which is required to grant sandbox capabilities | requires a private /tmp (requires.filesystem.privateTmp), which the compositionPolicy does not permit for this request | requires a private /tmp (requires.filesystem.privateTmp), which the operator policy (--sandbox-policy-file) does not permit for this request |
requires.egress.http[i], per method | requires egress (requires.egress.http), but the runtime has no --sandbox-policy-file, which is required to grant egress (grantEgress) | requires egress GET to host "api.example.com" (requires.egress.http[0]), which the compositionPolicy does not permit | requires egress GET to host "api.example.com" (requires.egress.http[0]), which the operator policy (--sandbox-policy-file) does not permit |
requires.env (setEnv) | requires env [API_TOKEN] (requires.env), but the runtime has no --sandbox-policy-file, which is required to grant sandbox capabilities | requires env [API_TOKEN] (requires.env), which the compositionPolicy does not permit (setEnv) | requires env [API_TOKEN] (requires.env), which the operator policy (--sandbox-policy-file) does not permit (setEnv) |
an env binding’s credential (spendCredential) | as in the preceding row | requires env API_TOKEN from credential "apikeys", which the compositionPolicy does not permit (spendCredential) | requires env API_TOKEN from credential "apikeys", which the operator policy (--sandbox-policy-file) does not permit (spendCredential) |
requires.credentials[i] | requires credential "cmdb" (requires.credentials[0]), but the runtime has no --sandbox-policy-file, which is required to grant step credentials (spendCredential) | requires credential "cmdb" (requires.credentials[0]), which the compositionPolicy does not permit | requires credential "cmdb" (requires.credentials[0]), which the operator policy (--sandbox-policy-file) does not permit |
a change to the desired state (composeResource, after the run) | not refused: no policy file narrows the output | composed apps/Deployment "deployment" (modify), which the compositionPolicy does not permit (composeResource); for the composite resource composed platform.example.org/WebApp (composite, modify), which … | composed core/Secret "db-password" (add), which the operator policy (--sandbox-policy-file) does not permit (composeResource) |
Signature refusals
When a rule requires a signature, a module is refused with one of these:
- no key or trusted root to verify with:
cannot verify module oci ghcr.io/secure/greeter@sha256:…: the operator policy requires a cosign signature, but the runtime has neither --cosign-key nor --cosign-trusted-root to verify it - an HTTP source:
module.http "https://example.com/fn.wasm" requires a cosign signature (operator policy), but only OCI modules can be signature-verified - a verified keyless signer no rule trusts:
cannot verify module oci …: no valid cosign signature for <ref>: keyless signature by <identity> (issuer <issuer>) is not trusted for <repository>: no trustSigner rule in the operator policy permits it
The module.from fences
These refuse before the module is resolved, prefixed cannot resolve module::
- no
compositionPolicyat all:module.from: status.module of the composite resource names a OCI source, but the Input has no compositionPolicy: a module the composite resource chooses must be permitted by the compositionPolicy's pullModule rules, or its author could point the runtime at any host - no matching
pullModulepermit:module.from: status.other of the composite resource names ref "index.docker.io/someone/else", which the compositionPolicy does not permit (pullModule)(ornames manifestURL "…"for an HTTP source’s manifest) - a pull credential no
spendCredentialpermit covers:module.from: status.module of the composite resource names credentials "registry", which the compositionPolicy does not permit (spendCredential) for "ghcr.io/example/greeter": a module chosen by the composite resource cannot spend a step credential (the registry host would be its author's) unless the compositionPolicy permits it for that repository; otherwise pull it with the runtime's Docker config or anonymously
A compositionPolicy that doesn’t parse is compositionPolicy is invalid: cannot compile the compositionPolicy as Cedar: <Cedar's error>.
Load-time errors
The runtime compiles the operator policy when it starts, and function validate
does when it runs. A document that can’t be compiled stops both. The runtime
prints the error and exits 1. function validate prefixes it with function validate: and exits 2.
cannot read operator policy: open <path>: <error>cannot compile the operator policy <path>: <Cedar's parse error>operator policy: dialAddress rule "<id>": <reason>, where<id>is the rule’s id as Cedar numbers the file (policy0for the first rule) and the reason is one of:must scope the action as == Action::"dialAddress", not an "in" setmust not constrain the principal or resource: the dial has neither identitytakes at most one when conditionmay only use a when condition, not unlessthe condition must be a single ip testunsupported operation "isMulticast" (an ip test uses isInRange, isLoopback or ||)an ip test may only test context.ipisInRange takes context.ip and one ip("CIDR") literal,isLoopback takes context.ip and no argument,isInRange takes one ip("CIDR") literal, or"<value>" is not an ip literal: <reason>
A policy with a usePrivateTmp rule makes the runtime create a temporary
directory under TMPDIR at startup, and exit 1 if it can’t: the operator policy grants a private /tmp (usePrivateTmp), but the runtime cannot create one under <dir>: <error>. function validate doesn’t run this check. The startup
warnings about a cosign flag without its matching policy rule are on the
signatures page.
Testing a policy
function validate applies both layers to a Composition offline, with the flags
the runtime is started with:
function validate composition.yaml \
--sandbox-policy-file policy.cedar \
--xr xr.yaml \
--resolve
--xr gives the principal, and without it function validate uses the zero
principal. The options, output and exit codes are on the
offline validation page.
Further reading
The design record is the three-layer authorization model.
HTTP egress
This page is for operators who let modules make HTTP and HTTPS requests. It
covers what to enable, the budgets, the block list and CIDR rules, and what
the runtime refuses and logs. It assumes a runtime started with a Cedar
--sandbox-policy-file (see operator grant policy); the
guest-side client is covered in write a module.
A module never opens a socket. The runtime resolves the name and checks the address against its block list, then sends the request over TLS with its own roots. A request the runtime doesn’t perform is a transport error that states the reason, never a trap, and any status from the server is a response.
A request is allowed only when all three layers agree:
- The operator’s Cedar policy enables egress.
- The module’s manifest lists the hosts, methods and paths it needs.
- The Composition’s
compositionPolicymay narrow that list.
Credentials and egress
With egress on, a module can send any host it’s granted whatever its request
contains, including the step credentials granted to it. A grantEgress
permit that matches any host grants every public host. Grant narrowly, prefer pathPrefix in the manifest’s rules, and pair the
capability with --cosign-key so only modules your organisation signed run
(see signatures).
Enabling egress
With no --sandbox-policy-file, egress isn’t grantable: a module that
requires it fails with a fatal result before it runs. With one, egress is
default-deny and the policy opens it through two actions:
grantEgressdecides which hosts, methods and paths a caller’s modules (principal.namespace,principal.xrKind) may reach.dialAddressdecides which resolved IP addresses the runtime may dial, overcontext.ip.
function --sandbox-policy-file /etc/function-wasm/policy.cedar \
--egress-rate-limit-per-minute 60 --egress-rate-limit-burst 10
// Only team-a may grant egress, and only to *.googleapis.com over GET/HEAD.
permit (principal, action == Action::"grantEgress", resource)
when { principal.namespace == "team-a" &&
resource in HostPattern::"googleapis.com" &&
["GET", "HEAD"].contains(context.method) };
// Open one in-cluster service range: the default block list already
// refuses 10.0.0.0/8, and a permit punches a hole in it.
permit (principal, action == Action::"dialAddress", resource)
when { context.ip.isInRange(ip("10.96.0.0/12")) };
// Block a public range the default list does not cover. A forbid wins
// over any permit for the same address, so never pair it with a hole
// inside it.
forbid (principal, action == Action::"dialAddress", resource)
when { context.ip.isInRange(ip("203.0.113.0/24")) };
The schema and the refusal sentences are in the Cedar policy reference.
Budgets
Each run has fixed budgets: timeout 10 seconds, maxRequests 16,
maxResponseBytes 4 MiB and maxRedirects 5. Only the rate limit is
tunable, through --egress-rate-limit-per-minute and
--egress-rate-limit-burst.
The run’s remaining deadline also bounds each request. The deadline is
limits.timeout or --module-timeout, and the time a guest waits for the
host’s HTTP answer is credited back to that compute budget. A request is cut
short at the remaining deadline when that comes before the 10 seconds, and the
run then ends as a timeout, so the guest can’t handle that error.
Block list and CIDR rules
The default block list refuses these ranges:
- loopback
- link-local, which includes a cloud provider’s metadata endpoint
- RFC 1918
- carrier-grade NAT (
100.64.0.0/10) - IPv6 unique-local
- the NAT64 and IPv4-compatible prefixes
- the unspecified, multicast and reserved ranges
It applies to every address a name resolves to. A zoned IPv6 literal such as
[::1%25lo] is never dialled. The runtime dials the address it checked, so a
name can’t rebind between the check and the connection.
A dialAddress forbid adds a range to the list and a permit punches a
hole in it, and a forbid wins over a permit. To reach a loopback service
in a local test, both 127.0.0.0/8 and ::1/128 need a permit.
Each dialAddress condition is one IP test: context.ip.isInRange(ip("CIDR")),
context.ip.isLoopback(), or a || of those. The conditions compile at load
into an ordered prefix list, and Cedar isn’t evaluated per resolved IP. A
malformed rule is refused at startup, and function validate reports it too.
HTTP_PROXY isn’t honoured, because the host must see the destination
address to judge it.
Manifest rules
The manifest’s requires.egress.http rules (see
module manifests) are what the module requests. Each
rule has exactly one of host (an exact name) and hostPattern
(*.example.com: every name under it, not the apex), at least one of
GET HEAD POST PUT PATCH DELETE OPTIONS, and an optional pathPrefix the
normalized path must start with.
# wasmfn.yaml
requires:
egress:
http:
- host: api.example.com
methods: [GET]
pathPrefix: /v1/prices/
- hostPattern: "*.googleapis.com"
methods: [GET, POST]
The runtime checks every request and every redirect hop against these rules.
The Input’s compositionPolicy narrows them only when it scopes
grantEgress, and a rule is then refused unless one of its permits matches it.
When an XR author picks a module through module.from, the module’s manifest
comes with it, and both Cedar layers still gate every rule it declares.
A rule the operator’s policy doesn’t permit is a fatal result before the module runs:
module oci … requires egress GET to host "evil.example.com" (requires.egress.http[0]), which the operator policy (--sandbox-policy-file) does not permit
A runtime with no --sandbox-policy-file refuses any egress a manifest
requires:
… requires egress (requires.egress.http), but the runtime has no --sandbox-policy-file, which is required to grant egress (grantEgress)
Audit log and metric
The runtime writes one log line per request, with the module reference and digest. The line contains:
- method, host and path, never the query, headers or body
- the status, or the reason for the refusal
- response bytes, duration and outcome
- for a blocked address, the resolved address and the block-list entry, which the guest isn’t told
A redirect adds one line per hop. Repeated calls without a grant or past
maxRequests log one info line, then debug lines.
Each request also increments function_wasm_module_http_requests_total{outcome},
where the outcome is ok, refused, budget or error. The series has no
host label.
Readiness and warm-up
This page is for operators who wire Kubernetes probes for a function-wasm pod
and decide whether to preload modules. It assumes Crossplane runs the function
from a package with a DeploymentRuntimeConfig.
Probes
The runtime reports readiness in two places: the gRPC health service
(grpc.health.v1.Health) on the function port, and plain HTTP /readyz on
--health-address (:8081), which also serves /livez. Readiness means the
caches are open, the engine is up and the modules named by --warm-modules
(none by default) are loaded.
Use the HTTP endpoints for Kubernetes probes. Crossplane runs functions with
mTLS on the function port and kubelet’s gRPC probe dials without credentials,
so a grpc: probe never succeeds outside --insecure. The health port is
plain HTTP with no authentication; the side ports
shows a NetworkPolicy for it.
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: function-wasm
spec:
deploymentTemplate:
spec:
template:
spec:
containers:
- name: package-runtime
readinessProbe:
httpGet:
path: /readyz
port: 8081
livenessProbe:
httpGet:
path: /livez
port: 8081
Warm-up
A ready pod hasn’t necessarily loaded any module. The first request for a
module on a pod compiles it. The compiled code is signed per process, and a
restart compiles again even on a volume, which keeps only the fetched module.
--warm-modules moves that work ahead of readiness.
With the flag set, the runtime listens at once but reports NOT_SERVING (and /readyz answers 503)
while it loads the listed modules, one after another. With a warm volume each load takes
milliseconds. With a cold cache each costs one compile. The runtime reports
SERVING when every entry has loaded or failed. /livez answers throughout, so
a liveness probe is unaffected.
Warm-up runs the path a request does, so a warmed module is a memory hit for
its first request. With --enable-memory-cache=false the artifact stays on
disk instead.
Failed entries
A failed entry is logged as Cannot warm module with the entry and the reason,
and never holds readiness back. The module loads on its first request, and the
pod remains ready. Troubleshooting covers a readiness
probe that fails for a while after start.
Trusted root
With --cosign-trust-instance, the first fetch of the Sigstore trusted root
runs just before the modules and is bounded to one minute. It never holds
readiness back either; signatures covers what a pod does
without a root.
Sizing
This page is for operators choosing CPU, memory and disk for a function-wasm pod. It gives the formulas first and the measurements they rest on after. Those are the module sizes and costs, by guest toolchain, that the formulas take as inputs.
What a request and a module cost depends on the guest’s toolchain, and the Measurements section gives the per-toolchain figures the formulas use.
Memory
Budget a pod’s memory as the sum of three terms:
- the resident compiled modules;
--max-concurrent-compilestimes the peak memory of compiling your largest module (about 1 GB for a large Go module, far less for Rust, per Measurements);- concurrent runs times the per-run figure (about 16 MB for a large Go module, per Measurements).
--max-concurrent-runs caps the third term, which is useful when the callers’ concurrency isn’t the number you want to size for.
Cold start
A cold start compiles every module once. --max-module-size bounds a
compile’s input, --max-concurrent-compiles how many run at once (each
about 1 GB at peak for a large Go module, per Measurements) and
--module-compile-timeout how long one may take. The request’s own deadline
doesn’t cut a compile short, so leave --max-concurrent-compiles times that
peak in the pod’s memory limit. Every process compiles its modules again, with or without a volume. A volume under /tmp/function-wasm-cache keeps the fetched modules, and a restart pulls nothing. The compiled code is signed per process (cache storage). --warm-modules moves the compile ahead of readiness, as readiness describes.
Disk
Disk grows by one module and one compiled artifact per digest ever served, until --max-cache-size bounds it.
Throughput
Runs execute in parallel up to --max-concurrent-runs and a step’s limits.concurrency. The --max-total-run-memory pool bounds how many can hold their memory at once, as runtime flags describes. A large observed state costs on every request whatever the guest is.
Measurements
The figures below were measured on 2026-08-16 on linux/arm64, on the Go host with ABI v1 guests, and weren’t re-measured on the Rust host, so treat them as a rough scale.
| Go (~75 MB) | Go, raw proto (~20 MB) | Rust (~240 KB) | |
|---|---|---|---|
| one request (CPU) | 8-11 ms | 1.2 ms | 0.05 ms |
| first compile of a module | 23-28 CPU-seconds, ~1 GB peak | 6 CPU-s | 0.1 CPU-s |
| resident in memory | ~90 MB, file-backed | ~40 MB | 0.7 MB |
| per in-flight run | 11-16 MB | 4-8 MB | < 1 MB |
| on disk per module version | ~230 MB (module + artifact) | ~60 MB | 0.9 MB |
| load from a warm volume | 6-8 ms | 2 ms | 0.05 ms |
“Go” is the go scaffold and “Go, raw proto” a Go guest that uses only the scaffold’s own code, without function-sdk-go. The full model of bounds and budgets is in the resource governance one-pager.
Metrics
This page is for operators building dashboards and alerts on the runtime. It
lists every series served on --metrics-address and the formats it speaks.
The endpoint is :8080/metrics by default, and an empty value disables it.
It’s plain HTTP with no authentication: admit it from the scraper’s namespace
only (the side ports). Probes are on
Readiness and warm-up.
| metric | labels | meaning |
|---|---|---|
function_wasm_module_requests_total | outcome = ok, refused, error, skipped | requests by outcome |
function_wasm_module_run_duration_seconds | outcome = ok, error, timeout | histogram of one guest run, instantiate to response; a wait for a run slot isn’t part of it, and a request cut short while waiting for one isn’t counted |
function_wasm_module_runs_in_flight | gauge of guest runs executing now, at most --max-concurrent-runs; a gauge held at that value means requests are queuing for a slot | |
function_wasm_module_hostcall_duration_seconds | histogram of the time a run spent inside host imports (the log import, HTTP egress over wasi:http, and WASI); the rest of run_duration_seconds is guest compute | |
function_wasm_module_compile_duration_seconds | histogram of Wasmtime compile time on compiled-cache misses | |
function_wasm_module_fetch_duration_seconds | source = oci, http, path | histogram of fetch and verify time, blob-cache hits and served-file reads included |
function_wasm_module_cache_events_total | cache = compiled (memory), compiled-disk, blob; event = hit, miss, stale, timeout | cache lookups; timeout is a cold load cut short by --module-compile-timeout; stale is an artifact on disk that Wasmtime refused, and appears for compiled-disk only |
function_wasm_module_cache_bytes | cache = compiled-disk, blob | bytes on disk per store, measured every ten minutes |
function_wasm_module_http_requests_total | outcome = ok, refused, budget, error | HTTP requests modules made through the host (requires.egress.http) |
function_wasm_module_memory_denials_total | reason = limit, pool | guest memory and table growths denied; the guest sees memory.grow or table.grow fail |
function_wasm_trusted_root_refreshes_total | outcome = updated, unchanged, error | refreshes of the Sigstore trusted root |
function_wasm_trusted_root_last_refresh_timestamp_seconds | Unix time of the last successful refresh of the trusted root, 0 until the first; time() minus this value is the root’s age |
The requests_total outcomes:
ok: the module ran and returned a response.refused: the runtime declined the request before the run (input, policy, grants, limits, resolution or verification), or refused the module’s output at thecomposeResourcefence. In the second case the module ran, so the run is inrun_duration_secondstoo.error: the load or the run failed.skipped: the composite resource chose no module andmodule.allowEmptyallowed it, so nothing ran (logged asNo module chosen by the composite resource).
The http_requests_total outcomes:
ok: the server answered.refused: the grant or the egress policy refused the request (HTTP egress).budget: a per-run budget or the timeout was hit.error: the request failed.
The memory_denials_total reasons:
limit: the growth passed the run’s ceiling (limits.memoryor--module-memory-limit).pool:--max-total-run-memorycouldn’t serve the growth before the run’s deadline.
The trusted-root series exist only with --cosign-trust-instance (signatures), and every outcome starts at zero. The refreshes_total outcomes:
updated: a root was put in use, the first one or one that differs from the last. The verified-digest memo is forgotten with it.unchanged: the same root was fetched again.error: the fetch failed and the last good root stays. The log has the reason.
Alert on the root’s age growing past a few refresh intervals (--cosign-tuf-refresh), and on the error outcome moving.
No metric has a module or host label, because the sets of digests and hosts a Function serves are unbounded. The logs include the module reference and digest, and the egress audit line states the host.
Formats
The main format is OpenMetrics
1.0 (application/openmetrics-text; version=1.0.0). A scraper whose Accept
header asks for the classic Prometheus text format without accepting
OpenMetrics gets text/plain; version=0.0.4. Both contain the same series.
gRPC server metrics
The runtime serves the gRPC server series of the Prometheus gRPC interceptor, with the names and labels function-sdk-go uses:
grpc_server_started_totalgrpc_server_handled_total, with agrpc_codelabel holding the gRPC code namegrpc_server_msg_received_totalgrpc_server_msg_sent_total
Each is labelled grpc_type, grpc_service and grpc_method.
Only unary calls (RunFunction, health Check) are counted. Streaming methods (reflection, health Watch) exist from startup, at zero until the first call.
For latency, use function_wasm_module_run_duration_seconds.
Trust model
This page is for operators deciding what a function-wasm installation trusts. It names who can do what, and which policy or flag limits each party. It assumes Composition authors are trusted and that composite resource (XR) authors and module code aren’t. The reasoning and the threats considered are in the design record for the trust model.
Module privileges
A module is the Composition author’s code. It gets only what the
Composition’s step and the operator’s policy grant it. It sees the request’s
observed state, desired state and context. Of the step credentials, it sees only those
granted to it: those its
manifest names in a requires.env
binding or in requires.credentials, and that both Cedar layers permit
through spendCredential. The runtime removes every other credential from
the request before the module runs.
The credential that pulled the module (module.oci.credentials) belongs to
the host. The runtime never forwards it, whatever the manifest asks.
The sandbox protects the runtime, and every other Composition sharing the Function, from a module that crashes, loops or exhausts memory. Beyond that, a module gets only what its manifest requests and both policy layers permit (Cedar policy):
- A private
/tmpthat exists for one request. Host directories can’t be mounted. - The environment variables its manifest binds to step credentials.
Non-secret configuration goes in
config. - HTTP requests through the host (HTTP egress).
What a module writes to the desired state can be fenced per kind with
composeResource. What it reads can’t: through the response’s
requirements it can ask Crossplane for any resource the Function’s RBAC
permits, as a native function can. Scope that RBAC to what the modules
should see (the output fence).
Modules chosen by an XR author
With module.from, the XR author picks the module. Use it only where you
trust XR authors to choose code, and limit the choice in two ways:
- Permit sources with
pullModulerules in the Input’scompositionPolicy.OCIandHTTPsources need one. Without it, the XR author could point the runtime at any host. - Require signatures with
--cosign-keyor the keyless options in Signatures.
The runtime reads compositionPolicy and limits from the Input only,
never from the XR. An XR author can choose code but can’t widen its
permissions, grants or budget. The runtime enforces every rule on every request, because Crossplane never
installs the Input’s CRD. function validate runs
the same checks over a Composition offline.
Credentials and the registry pull
A source read from the XR can specify a step credential only where a
spendCredential permit matches it and a pullModule permit admits the
repository. Otherwise the XR author would choose the registry host that
receives the secret. The runtime sends the pull credential to a registry
as HTTP Basic when the registry challenges for it, or exchanges it for a bearer
token.
Without such a permit, the runtime pulls an XR-chosen module anonymously or
with its own Docker config. Mount a Docker config through a
DeploymentRuntimeConfig and set DOCKER_CONFIG. Its credentials apply
only to their registry host.
The credential protects the pull, never the cache. The runtime caches a
module’s bytes by content digest and serves them, without a fetch, to any
Composition on the runtime that states the digest, from an OCI, HTTP or
Path source alike. A private module remains private only while no
Composition another tenant can read quotes its digest. The runtime checks
the credential and the signature at the first load of a digest in a process,
for the module’s bytes and its manifest alike, and a warm request doesn’t
contact the registry. A credential withdrawn later stops a cached module at
its next cold load: a restart, an idle eviction past the ten-minute TTL with the
disk entry swept, or a key rotation under requireSignature. To stop a
module now, forbid it: the pullModule fence for a module.from source, or
requireSignature on its repository with the signature revoked.
The cache directory
The caches under /tmp/function-wasm-cache belong to the runtime. The
runtime checks each tier before it serves it. Fetched modules and manifests are
verified against their digest on every read. Compiled artifacts, native
code that Wasmtime loads without a check of its own, carry an HMAC under a
key the process draws at startup and never writes anywhere, so an artifact
this process didn’t write, planted or left by an earlier process, is refused
and the module is compiled again (the cache key design
record). Back the directory with an emptyDir,
or with a volume for this pod alone to keep the fetched modules across
restarts (cache storage).
Egress
A module granted egress can forward its whole request to the hosts its rules allow, step credentials included. Only the policy layers grant egress: a manifest can request it, and an XR author can’t widen it. Every request leaves an audit line with the module digest.
Require signatures wherever egress is granted, so only code you’ve approved can send data out (Signatures).
Pinning
A remote module that a Composition names is pinned by a digest the
Composition states: the OCI reference’s manifest digest or http.digest.
The runtime verifies the manifest and layer digests when it fetches, so the
code can’t change without the Composition changing. This doesn’t hold for
modules chosen with module.from, which the XR author picks, or for Path
sources, which have no digest.
Signatures
This page is for operators who want the runtime to refuse modules that aren’t signed by a signer they trust. It covers key-based and keyless verification, the Sigstore trusted root and per-repository requirements. It assumes cosign 3 and OCI modules; how to sign in CI is out of scope beyond the cosign commands shown.
Without a --sandbox-policy-file, --cosign-key makes every module need a
signature. With one, its requireSignature rules select the repositories
that must be signed and the rest run unsigned. --cosign-key then only
supplies keys (see per repository).
Only OCI artifacts have a cosign signature, so a source of another type is
refused wherever a signature is required. With --cosign-key alone, that’s
every Path and HTTP source. Under a policy it’s an HTTP source whose URL a
requireSignature rule matches. A Path source has no repository, so no
rule matches it and it runs unsigned.
Key-based signatures
Sign a module with cosign sign --key cosign.key <ref> and start the runtime
with --cosign-key cosign.pub. A DeploymentRuntimeConfig mounts the key and
sets the flag or COSIGN_KEY.
The runtime verifies a module once per manifest digest per process, before it consults any cache. An artifact left on a persisted volume by a runtime without the key isn’t served by one with it.
cosign 3 stores a signature as a Sigstore bundle: a DSSE envelope around an
in-toto statement whose subject is the module’s manifest digest, pushed as an
OCI 1.1 referrer of that manifest. The runtime lists the referrers through
the registry’s referrers API. Where the registry has none (GHCR,
registry:3), it reads the sha256-<hex> tag index cosign maintains, with
the same credentials as the pull.
A module is signed when one bundle verifies with a configured key and its
statement is a cosign signature of that digest (predicate type
https://sigstore.dev/cosign/sign/v1). Other attestations among the
referrers, such as SLSA provenance, never admit a module. For a key-based
bundle the keys are the trust root, so its key hint and transparency-log
entries aren’t consulted.
cosign 2’s legacy signatures (the sha256-<hex>.sig tag, which cosign 3
writes with --new-bundle-format=false) aren’t read. A module signed only
that way counts as unsigned and is refused: re-sign it with cosign 3.
Keyless signatures
A keyless signature is made with cosign sign <ref> under an OIDC identity.
This repository’s release workflow signs the examples it publishes that way.
The bundle has a Fulcio certificate valid for ten minutes, a Rekor
transparency-log entry and an RFC 3161 timestamp instead of a key hint.
The trusted root
The runtime verifies a keyless bundle against a Sigstore trusted root, which
lists the Fulcio authorities, the Rekor and certificate-transparency keys and
the timestamp authorities, each with a validity window. Pass it as
--cosign-trusted-root trusted_root.json (COSIGN_TRUSTED_ROOT).
cosign initialize fetches the public-good instance’s root over TUF:
cosign initialize
cp ~/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json .
Verification is offline and rests on the signing time, not the clock. Every RFC 3161 timestamp in the bundle must verify with a timestamp authority of the root, and the Rekor entry’s signed entry timestamp with a Rekor key. The certificate must chain to a Fulcio authority and be valid at each of those times. The runtime then checks the entry’s inclusion proof and signed checkpoint, and last the signature. A bundle with no verifiable signing time is refused, and so is a bundle whose certificate had expired at signing time.
Trusting a signer
A trusted root alone admits nothing. The signer the certificate names (its
OIDC issuer and its subject alternative name, a workflow URI or an email)
must be trusted for the module’s repository by a trustSigner rule in the
operator policy, default-deny. The repository also needs its
requireSignature rule (see per repository):
permit (principal, action == Action::"requireSignature", resource)
when { resource in Repository::"ghcr.io/jonasz-lasut/wasmfn/examples" };
permit (principal, action == Action::"trustSigner", resource)
when {
resource in Repository::"ghcr.io/jonasz-lasut/wasmfn/examples" &&
context.issuer == "https://token.actions.githubusercontent.com" &&
context.identity like "https://github.com/jonasz-lasut/function-wasm/.github/workflows/publish-pkg.yml@refs/tags/v*"
};
With that policy and the trusted root, the examples this repository publishes run on the identity of its release workflow, with no countersignature.
A keyless signature by any other signer is refused:
keyless signature by <identity> (issuer <issuer>) is not trusted for <repository>: no trustSigner rule in the operator policy permits it
A keyless bundle met by a runtime with neither flag is refused:
keyless signature cannot be verified: the runtime has neither --cosign-trusted-root nor --cosign-trust-instance
--cosign-key and a trusted root combine. The runtime checks a bundle with a
key hint against the keys and one with a certificate against the root, and a
module is signed when one of its bundles verifies.
Fetching the trusted root over TUF
The trusted root lists each key’s and authority’s validity window, and a
bundle verifies against the material valid at its signing time. A file that
predates a rotation can’t verify bundles signed with the new material, so the
root has to be refreshed. --cosign-trusted-root is the air-gapped mode. The runtime reads
the file and nothing else, so you refresh it yourself, such as through a
mounted ConfigMap that cosign initialize rewrites.
--cosign-trust-instance public-good (COSIGN_TRUST_INSTANCE; also
staging and github) fetches the root instead and refreshes it while
serving. You can’t set both flags. The refresh interval,
--cosign-tuf-refresh, is described in runtime flags.
The runtime verifies the metadata chain starting from the TUF root built into
the runtime. It caches the verified
metadata and the root under /tmp/function-wasm-cache/tuf/<instance>, outside
the --max-cache-size sweep. A volume under the cache root preserves it
across restarts.
At startup the runtime fetches the root before it warms modules and reports ready, giving up after one minute. It then falls back to the cache, re-verified offline from the built-in root, until the cached metadata expires. With neither available, the runtime still starts and refuses every keyless signature until a refresh succeeds, retried once a minute:
keyless signature cannot be verified: the trusted root of public-good has not been fetched yet (the last attempt failed: …)
Key-signed and unsigned modules, readiness and liveness are unaffected.
While serving, a changed root replaces the one in use and clears the
verified-digest memo, so a module whose signer the new root no longer vouches
for is verified again on its next request. A failed refresh keeps the last
good root. Verification never touches the network: a failed refresh makes the
root older and never slows a request. The metrics
function_wasm_trusted_root_refreshes_total{outcome} and
function_wasm_trusted_root_last_refresh_timestamp_seconds show the refreshes
and the root’s age (see metrics).
function validate --resolve --cosign-trust-instance public-good … runs the
same fetch, or reads the same cache, before it verifies, so you can rehearse
the flags offline. Without --resolve nothing is fetched. A private TUF
mirror isn’t supported.
Requiring signatures per repository
An operator policy (--sandbox-policy-file) can require a signature per
repository through a requireSignature rule over the same Repository
hierarchy the module fence uses. Only the repositories you name must be
signed:
permit (principal, action == Action::"requireSignature", resource)
when { resource in Repository::"ghcr.io/acme/prod" };
A repository without a rule needn’t be signed. A signature no configured key can check is refused: the rule fails closed. A required module that’s unsigned, or that the runtime has no flag to verify, is refused before any cache is consulted.
Startup warnings
The runtime warns at startup when a signing flag and the operator policy don’t match, and serves anyway. Each line says what it leaves unverified:
--cosign-key is set but the operator policy has no requireSignature rule: no module will be signature-verified
--cosign-trusted-root is set but the operator policy has no trustSigner rule: no keyless signature will be trusted
the operator policy has trustSigner rules but neither --cosign-trusted-root nor --cosign-trust-instance is set: every keyless signature will be refused
The second line names --cosign-trust-instance when that flag is set
instead. function validate --resolve adds a warning of its own for a module
that requires egress when it can’t verify signatures (see the
warnings of function validate).
Troubleshooting
This page is for the operator of a function-wasm pod and for the module author whose module the runtime refuses. Find the message in the pod’s logs, in a step’s fatal result or in the output of function validate or guestfn, then follow the entry. Entries are grouped by what failed and match the current release. Each quoted message is a prefix or a fragment: match on the part that doesn’t vary.
Module load
cannot instantiate module: trap: …: the guest trapped while the runtime instantiated it, such as by panicking in its start-up code. The stack is in the function pod’s stderr.component does not implement the wasmfn:function@2.0.0 world: …: the component doesn’t typecheck againstwit/wasmfn-function.wit: it has norunexport (no exportrunfound) or imports something the runtime doesn’t provide. Rebuild against the currentwit/, and useguestfn inspect fn.wasmto list what the module exports and imports.cannot load module …: cannot compile module: failed to parse WebAssembly module: threads support is not enabled, or the same for another proposal: the module uses a WebAssembly proposal the engine refuses: threads, GC, function references, exceptions, tail calls, multi-memory or memory64 (ABI v2 lists what it accepts). Build the guest without that feature, which no scaffold’s toolchain emits by default.cannot load module …: module is a core module, which function-wasm 1.0.0 no longer runs (ABI v1 was removed); build it as an ABI v2 component (docs/abi-v2.md): rebuild the guest as a component. For a Go guest,guestfn buildturns the core module thatgo buildemits into one; a guest written for ABI v1 is rebuilt on a current scaffold (guestfn init, then move the function over).guestfn inspectandfunction validate --resolveprint the same sentence.built fn.wasm, but the runtime would refuse it: …fromguestfn build, or a refusal fromguestfn push: the message is the runtime’s own load-time refusal. The entries before this one cover the common ones.module.oci.ref … tags are not supported: pin the reference to the manifest digest,repo@sha256:…orrepo:tag@sha256:…, asguestfn pushprints it.module layer is a tar archive without /fn.wasm: aFROM scratchimage mustCOPYthe module to/fn.wasm. Preferguestfn pushororas push, which write a rawapplication/wasmlayer.
Input and admission
function validate runs the same checks as the runtime. Run it with the flags you start the runtime with: exit 1 means a step is refused and the line states the reason, and exit 2 means the tool itself failed. The fields are in the Input reference.
module.path is refused: the runtime was started without--module-dir.module.from: … names a OCI source, but the Input has no compositionPolicy: amodule.fromsource that isn’t static needs acompositionPolicywhosepullModulepermits its repository. Add the policy or make the source static.module.from: cannot read status.module from the composite resource: module: no such field: the composite resource hasn’t set the field the step reads. Set it, or addmodule.allowEmpty: trueso the step runs nothing until it’s set. The pod logs the skipped step. See Metrics.module.allowEmpty is set but module.from is not:allowEmptyapplies only to a field the composite resource may leave unset. Remove it, or switch the step tomodule.from.apiVersion wasm.fn.crossplane.io/v1beta1 is deprecated and is removed in function-wasm 2.0.0; write apiVersion: wasm.fn.crossplane.io/v1 (the same fields): a warning fromfunction validateandguestfn build, and a log line per request. Change theapiVersionline.limits.memory 1Gi exceeds the runtime's --module-memory-limit of 512Mi, or the same forlimits.timeoutand--module-timeout: lower the limit or raise the flag.module oci … has an invalid manifest: config.schema: Resource '…' is not present in a registry and retrieving it failed: $ref … is external; config.schema must be self-contained: the module’sconfig.schemareferences a URL or a file. The runtime never fetches a reference. The author inlines the referenced schema. A#/$defs/…reference inside the schema is fine.module oci … config does not match the module's schema: /greeting: got number, want string: the Input’sconfigfails the module’sconfig.schema.
Policy grants
The layers are explained in Cedar policy, and the operator’s side in Grant policy.
-
module oci … requires egress GET to host "x" (requires.egress.http[0]), which the operator policy (--sandbox-policy-file) does not permit: the manifest requests a capability that the layer named in the message doesn’t permit. Variants:… which the compositionPolicy does not permit: the composition layer refuses it.module … requires a private /tmp (requires.filesystem.privateTmp), but the runtime has no --sandbox-policy-file, which is required to grant sandbox capabilities, and the same for env and egress: the runtime has no policy file at all.module oci … requires credential "cmdb" (requires.credentials[0]), which the operator policy (--sandbox-policy-file) does not permit, or… but the runtime has no --sandbox-policy-file, which is required to grant step credentials (spendCredential): the layer has nospendCredentialpermit forCredential::"cmdb".requires runtime vX or newer, this is vY: the module needs a newer runtime.
Add a
permitto the layer named, or mount a policy file. A module that requests less avoids the refusal. -
A module stops seeing a step credential it used to read: the runtime forwards only the credentials a module was granted. Declare it in the manifest, with
requires.credentials: [name]for the whole credential or arequires.envbinding for one key. Then permitspendCredentialfor it in the operator policy, and in acompositionPolicythat scopes the action.function validate --resolvelists what each step’s module receives, and the runtime logs the withheld names at debug level. See Module manifests. -
module … requires.credentials[0]: the request carries no credential "cmdb"; declare it on the pipeline step: add the credential to the step’scredentials. -
the operator policy grants a private /tmp (usePrivateTmp), but the runtime cannot create one under …at startup: pointTMPDIRat a writable directory. AnemptyDirwith asizeLimitalso bounds what a module may write. -
A guest gets
EPERMunder/tmp: the path is outside the private/tmp, the only directory the guest has. -
operator policy …: dialAddress rule "…" must scope the action as == Action::"dialAddress", or another Cedar load error: the--sandbox-policy-fileis malformed and refused at load. AdialAddresscondition accepts onlycontext.ip.isInRange(ip("CIDR")),context.ip.isLoopback()or a||of them. -
module oci …: composed apps/Deployment "deployment" (modify), which the compositionPolicy does not permit (composeResource), or… which the operator policy (--sandbox-policy-file) does not permit …, or… composed platform.example.org/WebApp (composite, modify), which …: the module changed a resource that nopermitof the scoped layer matches, and the runtime applied none of its output. Widen the layer named or use a module that composes less. The rules are in the Cedar policy reference. -
module oci …: composed resource "thing" states no apiVersion or kind, so the policy layers cannot judge it (composeResource): a resource the module returned indesired.resourceshas noapiVersionorkindstring. Fix the module.
Signatures
Signing, keyless verification and the trusted root are explained on the Signatures page.
cannot verify module oci …: the operator policy requires a cosign signature, but the runtime has neither --cosign-key nor --cosign-trusted-root to verify it: add--cosign-key. For keyless signatures add--cosign-trusted-rootand atrustSignerrule instead. Or narrow therequireSignaturerule.cannot verify module oci …: <ref> carries no cosign signature (no Sigstore bundle among its referrers): none of the manifest’s OCI 1.1 referrers is a Sigstore bundle whose subject is this digest. Sign the module with cosign 3. A module signed only with cosign 2 or with--new-bundle-format=false(asha256-<hex>.sigtag) lands here too: the legacy format isn’t read, so re-sign it.cannot verify module oci …: no valid cosign signature for <ref>: signature does not verify with the configured keys: a bundle exists but no--cosign-keykey signed it. The same list also reportssigned statement's predicate type is "https://slsa.dev/provenance/v1", not https://sigstore.dev/cosign/sign/v1(an attestation),signed statement is for another digestand bundle fetch, size or digest failures.… keyless signature cannot be verified: the runtime has neither --cosign-trusted-root nor --cosign-trust-instance: the signature is keyless. Add--cosign-trusted-rootor--cosign-trust-instance public-good, and atrustSignerrule for the signer. The reverse,key-based signature cannot be verified: the runtime has no --cosign-key, means a key-based bundle met a runtime with only a trusted root.… keyless signature cannot be verified: the trusted root of <instance> has not been fetched yet (the last attempt failed: …):--cosign-trust-instanceis set and every fetch has failed. The message quotes the last failure. The runtime retries every minute and admits the module once a refresh succeeds, which the log reports asTrusted root updated. For a pod without network, mount the root with--cosign-trusted-root.--cosign-trusted-root and --cosign-trust-instance are mutually exclusive: the file is the trusted root, or the instance's root is fetched over TUFat startup, or--cosign-tuf-refresh must be at least 1m0s: choose the file for an air-gapped pod and the instance for one that can fetch.… keyless signature does not verify against the trusted root: <reason>: the runtime refused the bundle, and the reason is the verification library’s own text. Typical causes are a signature made under a different Sigstore instance, an invalid signature or log promise, or a bundle with neither a Rekor entry nor an RFC 3161 timestamp. Runcosign verifywith the certificate identity and issuer against the same root to tell which.… keyless signature by <identity> (issuer <issuer>) is not trusted for <repository>: no trustSigner rule in the operator policy permits it: the signature verifies but notrustSignerrule covers that signer for the repository. Add or widen a rule for the identity and issuer.
Egress
Egress is explained on the HTTP egress page.
sandbox.egress: <host> resolves to an address the egress policy blocks: the host refuses private, loopback, link-local and cluster ranges by default. Add the range to the policy’sallowedCIDRsto permit an in-cluster service.sandbox.egress: HTTP egress is not granted to this module: its manifest requires no egress (requires.egress.http): the module made an HTTP request but its manifest doesn’t require egress. Addrequires.egress.httpto the manifest. A Go guest sees the message prefixed withwasmfn:.sandbox.egress: the module's request rate exceeds the egress policy's rateLimit: raise--egress-rate-limit-per-minuteand--egress-rate-limit-burst, or reduce the module’s request frequency.
Runtime and startup
cannot create cache directory /tmp/function-wasm-cache: …at startup: the pod’s filesystem is read-only there. Mount anemptyDirat that path through aDeploymentRuntimeConfig.module … failed: waiting for a run slot: deadline exceeded, or the step-slot and run-memory forms: the request’s deadline passed while it waited for a bound, so nothing ran. Raise the bound, shorten runs, or watchfunction_wasm_module_runs_in_flight(see Metrics).cannot load module … : compile exceeded --module-compile-timeout 5m0s: a cold load (fetch and compile) ran past the bound. The compile finishes on its own and stores its artifact, so the next request for that digest is served; a module that hits the bound every time is too large to compile under it, so raise the bound or lower--max-module-size.- The first request is slow, later ones are fast: expected, because the compile happens once per module digest per process. A volume under
/tmp/function-wasm-cachespares the next process the fetch, not the compile, which--warm-modulesmoves ahead of readiness. See the cache section of Runtime flags. Cannot warm moduleat startup: a--warm-modulesentry didn’t load, and the log line gives the entry and the reason. The pod serves anyway and loads that module on its first request.- The readiness probe fails for a while after start: the pod is warming
--warm-modules. See Readiness.
Guest ABI v2
This page is for people who write a guest toolchain or its glue. It states what the runtime requires of a module and what it provides, so you can build to the contract exactly. It assumes you know WIT and the component model; to write a function, start at Write a module. Grants are on Cedar policy, the Input on the Input reference and the manifest on Module manifests.
A guest is a WebAssembly component targeting the WIT world wasmfn:function@2.0.0 (wit/wasmfn-function.wit). The payload is Protobuf: RunFunctionRequest bytes in, RunFunctionResponse bytes out. The canonical ABI moves memory, so a guest has no allocator export. ABI v2 is the only ABI the runtime serves.
The world
package wasmfn:function@2.0.0;
world function {
enum log-level { debug, info, warn, error }
import log: func(level: log-level, msg: string, kv: list<tuple<string, string>>);
export run: async func(request: list<u8>) -> result<list<u8>, string>;
}
runhandles one request. The host passes the caller’s rawRunFunctionRequestbytes and returns the guest’sRunFunctionResponsebytes verbatim. The host edits out the step credentials the module wasn’t granted, and the pull credential in every case.runisasync, so a guest may await its imports,wasi:httpespecially. The host meters the guest’s compute while it does. A sync-liftedrunalso satisfies the world, because the canonical ABI accepts a sync function where an async one is expected.logis structured logging through the host, with typed values. The host attaches the module’s identity to every line and renders it at the runtime’s level of the same name.debuglines appear only under--debug.- The runtime typechecks
log-levelexactly, sologchanges only with3.0.0.
WASI and the sandbox
The world doesn’t import WASI. A guest brings whatever its toolchain emits, and the host links both WASI generations:
- WASI 0.2: components built by jco, componentize-py or Rust’s
wasm32-wasip2, and a guest that importswasi_snapshot_preview1, the Go scaffold’s, through the wasip1 adapterguestfn buildlinks in. - WASI 0.3: a
wasm32-wasip3build.
The engine accepts WebAssembly 2.0 plus the extended-const and
relaxed-simd proposals. Relaxed SIMD runs with its deterministic semantics,
and a module computes the same result on every host. The engine refuses the
other WebAssembly 3.0 proposals at compile: threads, GC, function references,
exceptions, tail calls, multi-memory and memory64. No guest toolchain in the matrix emits any of
them (every scaffold and example was measured with wasm-tools validate on
2026-10-28), and a proposal can be enabled later as a minor.
The sandbox allows:
- no network sockets;
- no filesystem beyond the granted private
/tmp, the only pre-opened directory; - environment exactly as granted;
argvis["function"].
The three-layer decision grants these capabilities. limits and the deadlines apply to every run: limits.timeout meters guest compute, and the request’s gRPC deadline is the hard cap. A component has no top-level memory export, and the host reserves nothing from --max-total-run-memory before the run. It charges the whole footprint as the guest claims its memories, and a growth the pool can’t serve fails inside the run: memory.grow returns -1.
HTTP egress
A guest sends HTTP requests through wasi:http/client@0.3.0 (send: async func) or wasi:http@0.2’s outgoing-handler, which componentize-js’s fetch() uses. One host bridge serves both. The runtime’s egress client performs each send or refuses it under the grant. The policy is on HTTP egress.
- Bodies are complete on both sides, and the response budget applies to whole responses.
- The guest receives a host-reported failure as the
internal-errorcode, whose payload is the refusal string, for a grant refusal, a blocked address, an exhausted budget or a transport failure. The wording is contract, held by the conformance goldens. - The time a guest spends awaiting a send is credited back to its compute deadline.
Errors
A guest that can produce a response encodes failures into it as a fatal Result. run returning err(string) is for failures before a response can be built, such as a codec that can’t decode the request. The host turns it into the request’s fatal result: module <desc> failed: run returned an error: <string>.
Traps, WASI exits, deadline interrupts and memory denials are fatal results from the host that name the module, never gRPC errors:
run failed: trap: …run failed: module exited with status Nrun failed: module exceeded its execution deadline (…)
Detection
The runtime reads the binary format from the first eight bytes of the module. A component (layer 1 in the WASM header) is loaded and typechecked against the world. A core module (layer 0) is refused with one sentence, whatever it exports:
module is a core module, which function-wasm 1.0.0 no longer runs (ABI v1 was removed); build it as an ABI v2 component (docs/abi-v2.md)
The runtime reports it as a fatal result (cannot load module <desc>: …), and guestfn build, guestfn inspect and function validate --resolve print the same sentence. Detection has no flag and no Input field.
A typecheck refusal begins component does not implement the wasmfn:function@2.0.0 world: ….
A module manifest declares abi: 2. Any other value is refused before the module is looked at: abi must be 2 (this runtime implements ABI v2 only; ABI v1 was removed in function-wasm 1.0.0), got 1.
Versioning
wasmfn:function@2.0.0 is the contract from function-wasm 1.0.0 on. The world’s version moves on its own, by what a change asks of guests:
- A 2.x minor may only add imports: a new interface or a new function. A component imports only what it uses. The runtime serves a guest built against an earlier 2.x unchanged. A guest that imports the addition needs a runtime that provides it, and its manifest’s
minRuntimesays which. - Within 2.x,
run’s signature, thelogimport (thelog-levelcases included) and anything else already in the world stay as they are. Changing or removing any of them is3.0.0, a world every guest is rebuilt against. - A new field of
RunFunctionRequestorRunFunctionResponsemoves no world version. Payload evolution is Protobuf’s.
A guest vendors the WASI 0.2 and 0.3 interfaces it imports under its wit/deps/, from the wasmtime-wasi-http release the runtime builds on. The release notes list the WASI versions the runtime links. A guest built against an older vendored set keeps running while those versions are linked, and otherwise fails the world typecheck at load.
Until 2.0.0 is published, a Wasmtime release that moves the WASI 0.3 interfaces is taken on 2.0.0 itself, and guests are rebuilt.
Getting the world
Guest toolchains read a local wit/ directory, and a guest keeps the world there as a file.
guestfn init --lang rust writes it to wit/deps/wasmfn-function.wit, byte-identical to the runtime’s copy. The guest’s own world, wit/world.wit in a local:guest package, includes it beside whatever else the guest imports. Keep the contract’s file unmodified, because a WIT package that appears twice must have the same contents. Put a guest’s additions in its own world.
The ts, python, c, zig and go scaffolds restate the world in wit/world.wit with run declared sync. That signature is the one jco, componentize-py, the shared wit-bindgen C bindings of the c and zig scaffolds, and wit-bindgen’s Go bindings take today. It satisfies the world.
Publication
The release that brings a new world version publishes it, signed and attested:
- as an OCI artifact in the CNCF Wasm OCI layout, the format of
wkg:ghcr.io/jonasz-lasut/wasmfn/function:<version>; - as
wasmfn-wit-<version>.tar.gzon that GitHub release.
A published version is never pushed again. publish-wit holds a world back while the rust scaffold’s rust-toolchain.toml pins a beta channel, so 2.0.0 is unpublished until the scaffold builds on stable Rust (#106, #129).
Once a version is published, map the namespace in the wkg configuration:
[namespace_registries]
wasmfn = { registry = "wasmfn", metadata = { preferredProtocol = "oci", "oci" = { registry = "ghcr.io", namespacePrefix = "jonasz-lasut/" } } }
wkg get wasmfn:function@2.0.0 --format wit then writes wasmfn_function@2.0.0.wit, the file a guest keeps under wit/deps/.
Compatibility
This page is for anyone who depends on function-wasm across releases: composition authors, operators and module authors. It lists what’s stable throughout 1.x, what a minor release may add and how a removal is announced.
What a Composition, a flag, a dashboard or a CI job relies on keeps working on every 1.x release, with one exception: a minor release may add a refusal for a rule that didn’t exist before.
What’s stable
- The Input,
wasm.fn.crossplane.io/v1: its field names, types and semantics. A field the runtime doesn’t know is ignored, andfunction validatewarns naming it; the removedpolicyandsandboxfields it refuses by name. - Each runtime flag’s meaning and default.
- The wording of refusals and fatal results, as the conformance goldens pin
it (
crates/function/testdata/conformance/). Operators grep logs and XR conditions for these strings. - The module manifest (
wasmfn.yaml): its fields and what each requests. - The ABI v2 world, which has its own version (see ABI v2).
- The metric names and labels (
function_wasm_*,grpc_server_*). function validate: its exit codes (0 admitted, 1 refused, 2 the tool failed) and the field names of--output json.- The
guestfncommands, their arguments and flags.
The log line layout, the on-disk cache format and when a sweep runs aren’t on the list, so they aren’t promised.
Minor and major releases
A minor release may add to the list without changing what’s already on it: new Input fields, flags, manifest fields and metrics, and refusals for rules that didn’t exist. It may also widen what’s accepted, so a document one release refused is admitted by the next.
Removing or renaming anything on the list takes a major release, after the deprecation below, and so does changing a pinned wording or a default or narrowing what’s accepted.
Deprecation policy
A removal runs in two steps:
- The next release deprecates it. A deprecated thing keeps working for at
least one minor release.
function validatewarns under the steps that use it, and the runtime logs one line per load or request that does. - The next major release removes it.
wasm.fn.crossplane.io/v1beta1 is the current instance: it’s accepted
throughout 1.x with the warning and removed in 2.0.0. ABI v1 set the
precedent: deprecated in 0.6.0, removed in 1.0.0.
Development
This page is for contributors to function-wasm. It lists the commands that build, test and lint a change, and says which of them CI runs for you. It assumes a checkout with a Rust toolchain.
Commands that run on every push
CI runs these on every push, and on a pull request that changes code (a pull request touching only Markdown, JSON or SVG files skips them):
cargo build --workspace && cargo test --workspace
cargo fmt --all --check && cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo deny check advisories bans licenses sources # cargo install cargo-deny --locked
The tests include the conformance goldens and the scaffold goldens. The
cargo deny run checks Cargo.lock against deny.toml: every crate is
free of published advisories, under an allowed license and from crates.io. A pull request that touches the documentation (docs/, README.md, SECURITY.md or book.toml) also runs Vale:
make vale
Commands that run on demand
These need Go, Docker or kind on your machine:
(cd examples/pdb-addon && go test ./...)
make -C examples/pdb-addon render
make e2e
The first line tests the Go example and its vendored internal/wasmfn glue. The second is the development loop. The third runs the end-to-end suite in a kind cluster. Testing explains the two tiers and the /e2e comment that runs them on a pull request.
Guest toolchains
The workspace tests build every scaffold (what guestfn init writes) to WebAssembly and run each through the host. The tests skip a scaffold whose toolchain isn’t on PATH, so an all-green run can mean a guest never ran. The toolchains are go, cargo and rustup for the rust scaffold’s pinned toolchain (which also needs protoc), zig, npm and python3. Set FUNCTION_WASM_REQUIRE_TOOLCHAINS=1 to make a missing toolchain a failure.
Where to go next
Testing says where a new test goes. AGENTS.md describes the source layout and conventions. Design records lists the design documents.
Testing
This page is for contributors adding or changing a test. It says which of the two test tiers a change belongs in and how to run the end-to-end suite on your machine. It assumes a Rust toolchain for the first tier and Docker for the second.
crossplane render belongs to the
development loop and isn’t part of either tier.
| Tier | Runs | What it covers |
|---|---|---|
| unit and component | cargo test --workspace, on every push and on a pull request that changes code | the engine, the runtime and function validate against in-memory stand-ins and recorded goldens, and every scaffold built and run through the host (the guest suite) |
| end-to-end | make e2e, /e2e on a pull request, the release matrix on a release branch | the package installed by Crossplane in a kind cluster, as an XR author and an operator see it |
The tier for a new test
| The change | The test |
|---|---|
| a guest-visible behaviour of the engine (a trap, a limit, a WASI interface) | a WAT fixture in crates/engine/tests/engine.rs, or a setting of the committed guest driven from crates/engine/tests/committed_guest.rs |
| an Input rule, a refusal, a policy decision | a unit test beside the code, a fixture under crates/function/testdata/validate/ and a conformance golden |
| a scaffold change | the template, then UPDATE_GOLDENS=1 cargo test -p guestfn; the guest suite builds the goldens |
| a fixture or stand-in a test needs | function_wasm_engine::testing for a guest component, function_wasm::testing for a registry, a signer, an in-process runtime, a server or a policy (both behind the testutil feature) |
something only Crossplane shows, such as a condition, an event, a resolved requirements entry, a pod restart or a scraped metric | a feature in the end-to-end suite |
UPDATE_GOLDENS=1 cargo test -p function-wasm --test conformance records a
conformance golden. A re-record is a behaviour change, so say so in the commit.
A suite uses the shared testing modules and never declares its own copy of a
fixture.
The end-to-end suite
The suite is in crates/e2e (package function-wasm-e2e). It gives each
test its own namespace (with_temp_ns in crates/e2e/src/ns.rs) and uses typed
kube-rs helpers. Tests wait with timeouts and compare what the cluster contains with
assert_eq!. The crate is empty without its cluster feature, so
cargo test --workspace builds none of it.
The cluster
make e2e.up provisions the cluster before the tests. It uses the Crossplane build submodule’s make library (build/makelib, with
pinned kind, kubectl, helm and crossplane CLI under .cache/tools) and
the suite’s own setup binary, in three steps:
controlplane.upcreates a kind cluster (KIND_CLUSTER_NAME, defaultfunction-wasm-e2e) with Crossplane atCROSSPLANE_VERSION. The Makefile’s default is the current Crossplane release, which Renovate moves, and the environment overrides it.e2e.crossplanethen waits for the Crossplane deployment and its package CRDs.e2e-setup(crates/e2e/src/bin/setup.rs) creates thee2enamespace and aregistry:3behind Basic auth and TLS from a CA it generates. It installs provider-nop (PROVIDER_NOP_VERSION), a provider whoseNopResourcemanaged resources do nothing, so the tests can compose resources without a cloud. It pushes and signs the test module under every variant, creates the profiles’ ConfigMaps, Secrets and volume incrossplane-systemand the Guest XRD, and writes_output/e2e/state.json, which the tests read.make e2e.imagebuilds the runtime image from the Dockerfile, or fromDockerfile.devaround a prebuilt binary named byFUNCTION_BIN.xpkg.build.function-wasmbuilds the Function package from it, andlocal.xpkg.deploy.function.function-wasminstalls it from Crossplane’s cache withpackagePullPolicy: Never, on thedefaultprofile.
make e2e.test.<profile> runs one profile’s tests, make e2e.test runs the
three in sequence, make e2e.down deletes the cluster and make e2e does
everything. After a failed run, make e2e.dump writes the cluster’s state under
_output/controlplane-dump. A failed test also keeps its namespace and dumps
its objects, events and the function pod’s log under
_output/e2e/logs/<namespace>.
The profiles
A profile is one runtime configuration: a set of flags, an operator policy and
the mounts they need. Each test binary switches the Function’s
runtimeConfigRef to its profile’s DeploymentRuntimeConfig
(crates/e2e/profiles/<profile>/drc.yaml) before its first test and waits for
the rollout. Each binary uses one profile.
| Profile | What it tests |
|---|---|
default | every case a runtime admits, under both signature verifiers |
strict | the refusals: no verifier and no grants |
lifecycle | warm-up, the caches across a restart and the trusted root over TUF |
The default profile runs the runtime with --cosign-key (the suite’s key)
and --cosign-trusted-root (the in-memory Sigstore instance’s root merged with
the public-good root). Its policy requires signatures from the suite’s registry
and the published examples, and grants the egress, credential, env and private
/tmp cases the tests need. The policy is crates/e2e/profiles/default/policy.cedar
and the tests are in crates/e2e/tests/default.rs.
The strict profile has no verifier. Its policy requires a signature for the
plain variant only and grants egress to the registry host with no
dialAddress hole. Its tests cover the no-verifier refusal, the block list
refusing the service CIDR in band, and the env, credential and /tmp
refusals.
The lifecycle profile runs with --debug, --warm-modules on the published
pdb-addon, a PVC-backed cache bounded below that module’s size, a readiness
probe on /readyz, and --cosign-trust-instance public-good with no root file
mounted. Its policy requires the published examples’ signatures and accepts
this repository’s release workflow as a signer. Its tests cover readiness
waiting for the warm-up, the caches across a restart, and the trusted root
fetched over TUF from the real public-good repository.
The test module
crates/e2e/guest is the one module the suite drives: a Rust ABI v2 guest in
its own Cargo workspace, reading the rust scaffold’s wit/ and proto/ in
place. make e2e.guest builds it on the scaffold’s pinned
toolchain and the result is committed as fn.wasm.
The Guest XR is the composite resource the suite creates for each test, and its
spec says what the module exercises:
| Field | What the module does |
|---|---|
nopResources, readyAfterSeconds | composes that many provider-nop NopResources, Ready after the delay, and marks each ready once its observed counterpart is |
egress.url | sends a GET request to the URL through wasi:http and writes the status and body, or the host’s refusal, to status.egress |
env, credentials | writes those variables and the keys of those step credentials to status |
privateTmp | writes and reads back a file under /tmp, reporting the outcome |
requirements.environmentConfig | asks Crossplane for that EnvironmentConfig and echoes its data once resolved |
fatal, spin, growMiB | a fatal result with the message, a spin past the deadline, a memory growth refused at the limit |
A variant is the same fn.wasm published under one manifest. The suite has
nine, one for each of the six manifests in crates/e2e/guest/manifests, plus
three signature variants derived from the plain one. Each has its own digest,
because the runtime caches a signature verdict per digest and two variants
sharing one would share a verdict. The same fn.wasm is the engine’s wasi:http fixture
(crates/engine/tests/committed_guest.rs).
Running it locally
make e2e.up # kind, Crossplane, provider-nop, the registry, the package (Docker running)
make e2e.test.default # or strict, lifecycle; cargo test -p function-wasm-e2e --features cluster --test <profile> does the same
make e2e.down
FUNCTION_BIN=<linux binary> make e2e.up wraps a prebuilt runtime with
Dockerfile.dev instead of compiling the workspace in the Dockerfile’s build
stage, as CI does. After controlplane.up, KUBECONFIG and the
current context point at the cluster. CROSSPLANE_VERSION=2.2.7 make e2e runs
the whole suite on another Crossplane, given as the chart version without a
v. 2.2.7 is also the release matrix’s oldest pin.
The workflows
ci.yml runs lint with --all-features and cargo test --workspace on every
push, and on a pull request unless it touches only Markdown, JSON or SVG files.
A /e2e comment on a pull request runs e2e.yml:
- the guest suite, with
FUNCTION_WASM_REQUIRE_TOOLCHAINS=1so a missing toolchain fails instead of skipping - one build job per example toolchain: build, unit tests, lint, and codec and bindings drift
- the cluster job, which runs the three profiles one after another on one kind
cluster at the Makefile’s
CROSSPLANE_VERSIONand uploadsmake e2e.dumpon failure
The jobs cache under e2e- keys that no trusted workflow restores, and the
workflow sets cache-mode: write because a comment-started run gets a
read-only cache token.
The release matrix
Crossplane maintains its three most recent minor releases
(release cycle), and
the function supports the same three. Every /e2e run tests the current minor, and the matrix
adds the two before it.
release-matrix-e2e.yml runs on every push to a release-X.Y branch. It
builds the runtime from the branch and runs make e2e three times in parallel,
one kind cluster per version, and uploads the cluster dump for a failed
version. gh workflow run "Release Matrix E2E" --ref <ref> runs it by hand on
any ref.
The Makefile’s CROSSPLANE_VERSION default names the current version. The
workflow’s env (CROSSPLANE_PREVIOUS, CROSSPLANE_OLDEST) pins the latest
patch of the two older minors. A red version blocks the release: the release
process pushes the branch, waits for the matrix and tags only when it’s green.
Security policy
This policy is for anyone who depends on function-wasm or wants to report a vulnerability in it: how to report one, which releases receive fixes and where the fixes for each kind of code in this repository come from.
Reporting a vulnerability
Report a vulnerability through GitHub’s private reporting form, “Report a vulnerability” under the repository’s Security tab, rather than in a public issue. The report is private to you and the maintainers until the advisory is published.
Supported releases
Through 0.x only the latest release line receives fixes; upgrade to the latest release to get one. From 1.0.0 on, an older release line can be patched on request.
The runtime
The function binary, its image, the workspace Cargo.lock it’s built
from, and the Crossplane package that embeds the image: the code that runs
in your cluster. The maintainers fix a vulnerability here as a patch release
of the latest release line. Every pull request runs cargo deny over the
workspace’s dependency graph (deny.toml), so a published advisory against
a pinned crate fails the merge, and the same check runs every week against
main. The grype-scan.yml workflow scans the image of the latest release
every week and reports what it finds to GitHub code scanning, and Renovate
opens a pull request when an advisory affects one of the workspace’s crates.
Scaffolded projects
The projects guestfn init writes, from the templates under
crates/guestfn/templates/. You build and publish these. Each release
refreshes their dependency pins, so a project scaffolded with the latest
guestfn starts current. Once written, the project is yours: guestfn
doesn’t update it, so update its dependencies as you would in any other
project.
Examples
The guests under examples/ illustrate the guest languages. Three of them,
cloudflare-origin, policy-gate and pdb-addon, are published as signed
OCI artifacts under ghcr.io/jonasz-lasut/wasmfn/examples/, so a Composition
can reference them. The runtime image contains only the runtime binary, and
the package embeds only the example manifests of examples/pdb-addon/example.
Renovate refreshes the examples’ dependencies in a monthly batch that merges
once the end-to-end suite passes, and its vulnerability pull requests against
them merge when their checks pass. The maintainers dismiss an alert against
an example when a sandboxed WebAssembly guest can’t reach the code or no fix
exists.
Use-case examples
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.7 (rewritten in the present tense over the Rust workspace; the cluster-tier E2E question answered)
- Tracking: https://github.com/jonasz-lasut/function-wasm/issues/112
Context
A platform team deciding whether to put a WebAssembly module into a Composition needs to see a real use case, and a hello function in every language doesn’t show one. This record says which use cases the examples solve, why those, how the project tests the examples and how it publishes them.
What function-wasm adds
Existing functions already cover each use case below on its own:
function-cel-filter does conditional resources, function-tag-manager does
tags, function-status-transformer and function-auto-ready do status and
readiness, and Crossplane core is moving towards per-step, digest-pinned
function references chosen by the Composition author
(crossplane#7840). What
remains distinct to function-wasm:
- The composite resource can choose the logic.
module.fromreads the module source from an XR field, fenced by thecompositionPolicy’spullModule, andmodule.allowEmptymakes a step an empty hook until the XR fills it in. function-pythonic is the only other function with per-XR code, and it runs that code withexec(). - Untrusted code runs sandboxed by default. A module starts with neither network nor files nor environment. The manifest requests each capability, and both Cedar layers must permit it. function-python, function-shell and function-kcl run user code with the pod’s network and environment.
- Network access is requested, granted and audited. A module sends its request to the host, which resolves, checks the address against its block list, applies budgets and writes an audit line per request.
- One runtime, many modules. No install step per module: modules are pinned by digest and can be signature-checked.
An example belongs here when its use case needs at least one of these.
Selection
- Real demand, cited from crossplane/crossplane and crossplane-contrib issues (reactions at the time of writing) or a common infrastructure-as-code pattern.
- A different function-wasm feature per example, so the five together cover what the runtime offers.
- Renders from files alone: no cloud account and no internet. Egress examples fetch from a local fixture server.
- Crossplane v2 shapes: namespaced XRDs (
apiextensions.crossplane.io/v2) and namespaced provider resources (*.aws.m.upbound.io), with no claims. - The languages platform engineers use get the strongest stories, TypeScript and Python over C#.
The examples
| Example | Language | Use case | What it shows | Demand |
|---|---|---|---|---|
cloudflare-origin | Rust (async, wasi:http 0.3) | Only Cloudflare may connect to an origin: a security group plus one ingress rule per range Cloudflare publishes, read at reconcile time | Egress through the host under both policy layers, the block list, conditional resources | crossplane#2712 (conditional resources); Terraform’s data "http" over Cloudflare’s lists |
policy-gate | TypeScript | The organisation’s policy as the last step: mandatory tags, allowed regions and instance classes per environment | requirements through the wire-level proxy, fatal versus warning results | crossplane#1225, discussion #2617, provider-upjet-aws#169 |
pdb-addon | Go (function-sdk-go) | A tenant add-on hook: a WebApp Composition’s last step runs the module the XR names; this add-on derives a PodDisruptionBudget from the desired Deployment | module.from, allowEmpty, the pullModule fence, the default sandbox | function-go-templating#24, crossplane#6139 (staggered rollout), crossplane#2524 |
team-tags | Python | Team metadata from a CMDB, looked up with that team’s own token from one shared Secret keyed by the XR’s namespace | Step credentials, egress with an Authorization header, the composition layer fencing where tokens may go | crossplane#2099, crossplane#7343, provider-upjet-aws#169 |
dashboard-bundle | C# | Dashboards as code: a sha256-pinned zip of Grafana dashboards unpacked into one sidecar-labelled ConfigMap per dashboard | The private /tmp, egress, integrity pinning, refusing archive entries that escape | Grafana’s dashboard sidecar, delivered as a versioned bundle |
Design choices to keep:
- A failed external read is a fatal result, never an empty answer.
Crossplane applies nothing from a fatal pipeline, so
cloudflare-origin’s rules from the last reconcile stay in place while the module can’t read Cloudflare’s list (an empty answer would have deleted them). - Tenancy comes from the namespace, never from the
spec.team-tagskeys the shared Secret by the XR’s namespace, which cluster RBAC sets. Keying by aspecfield would let an XR in team A’s namespace spend team B’s token. - A module sees only the credentials its manifest names.
team-tagsdeclaresrequires.credentials: [cmdb], and the runtime forwards that credential only where both policy layers permitspendCredential; the pull credential and every other step credential, which the runtime edits out of the request before the run (#122). The tokens’ boundary is then egress, which the same two layers fence.
Unpublished hello examples
hello-zig, hello-c, hello-odin and hello-moonbit are the same greeting
guest in four more languages, each an ABI v2 component. Zig and C share their
scaffolds’ plumbing; Odin and MoonBit have no scaffold, and guestfn build
detects the MoonBit one by its moon.mod
(#155). Rust,
Python, TypeScript and Go are covered by the use-case examples in the table.
C# has only dashboard-bundle. The language matrix is in
other languages.
Testing
- Scaffolds are minimal hello projects. The guest suite
(
crates/function/tests/guests.rs) builds every language’s scaffold from its golden, exactly whatguestfn initwrites, and runs the same ABI-level expectations over each: greetings, egress granted and refused, a guest-side fatal, logs. A scaffold has no lockfile. The suite resolves dependencies the way a user’s first build does, which caught the Go 1.27 break in #117. - Examples share only the plumbing with their scaffold: the vendored ABI
glue, the generated codecs, the proto and the WIT, held byte-identical by
examples_share_the_scaffold_plumbing. The function, its manifest and its example manifests are the example’s own. - Each example has unit tests in its language, covering each fatal path.
- Each example has a
build-<lang>job in the/e2eworkflow (e2e.yml): its toolchain, the build, test and lint targets, and a drift check of its generated codec and bindings. - Nothing in CI asserts what an example composes. The Kubernetes-native
end-to-end suite (
crates/e2e, testing, #185) drives a committed test module of its own through Crossplane in a kind cluster. It covers the runtime behaviour the examples rely on (signatures,module.from, egress, credentials, env, the private/tmp, requirements, fatals, limits), and an example’s unit tests cover its own logic. make renderis each example’s development loop.examples/render.shrunsfunction validateover everyexample/xr*.yaml, then serves the directory withexample/policy.cedaras the operator policy andexample/fixtures/on a local HTTP server forcrossplane render(render locally).
Publishing
The publish-examples job of publish-pkg.yml releases the examples that work
as published as CNCF Wasm OCI artifacts, with their module manifests:
- Names:
ghcr.io/jonasz-lasut/wasmfn/examples/<name>, beside the WIT world atwasmfn/function. A namespace per kind of artifact is the convention across OCI-distributed WebAssembly plugins (Kubewarden’spolicies/, wasmCloud’scomponents/). - Versions: each example’s own semver from its
wasmfn.yaml, taggedv<version>, independent of the runtime’s. A version already published is never pushed again (the same registry probe aspublish-wit), so a release publishes only the examples whose version moved, and a published tag remains immutable.minRuntimein a manifest states the runtime it needs. - When: at a release, so a published example only ever targets a released runtime.
- Signing: keyless cosign plus SLSA provenance from the release workflow,
with no key in the repository. The runtime verifies the keyless signature
against a Sigstore trusted root (
--cosign-trusted-root) and admits it where the operator’strustSignerrule names this repository’s release workflow on a version tag (#116; the signatures page has the rule); an operator may still countersign with its own key instead. - References: a release asset,
function-wasm-examples-<release>.yaml, lists every published example’s reference pinned by digest (repo:v<version>@sha256:…: the tag for people, the digest for the runtime). The release commits nothing back. - Which:
cloudflare-origin,policy-gateandpdb-addon.team-tagsanddashboard-bundlerequest egress to placeholder hosts (cmdb.example.org,artifacts.example.com) that their users replace before publishing their own.
Alternatives
A health rollup (crossplane#5643) lost because an XR’s Ready already
aggregates its composed resources’ readiness, and Crossplane drops a function’s
Healthy condition (with Ready, Synced, UpToDate and Responsive).
A subnet layout from a VPC CIDR lost because upbound/function-cidr already
covers it and it doesn’t show a feature the table lacks.
Operations (Operation, CronOperation, WatchOperation) lost because the
package declares only the composition capability, and module.from has no
composite resource to read in an Operation.
An AssemblyScript example and a TinyGo scaffold with hello-tinygo were
retired on 2026-10-07 (#114).
AssemblyScript has no component-model path, and #114’s spike showed that
hand-written canonical-ABI glue covers run and log but makes wasi:http a
hand-maintained cost. TinyGo builds the world on its own wasip2 target, but
its bindings generator is unmaintained and the Go flavour can’t share it. The
go scaffold and pdb-addon instead build a wasip1 reactor that guestfn build embeds the world into and wraps.
Open questions
The world’s publication (#106):
the examples target wasmfn:function@2.0.0, frozen on 2026-10-07
(#77). The
publish-wit job of publish-pkg.yml holds the world back while the rust
scaffold’s rust-toolchain.toml pins a beta channel (beta-2026-10-05 today),
and publishes it at the first release after the scaffold builds on stable Rust
(#129).
Design records
The design records live under docs/ as one-pagers, one per feature, each
headed by its owner, reviewers and status (draft, implemented with a
revision, withdrawn or superseded). Each describes the design in today’s Rust workspace,
the alternatives it rejected and the questions still open. The
status below is each document’s own, at the time of writing.
| design record | status |
|---|---|
| ABI v2 and the Rust host | Implemented, revision 0.7 |
| Compiled-artifact cache key | Implemented, revision 1.0 |
| Module and compiled artifacts cache | Implemented, revision 2.3 |
| Module manifest | Implemented, revision 1.4 |
| Module source schema | Implemented, revision 2.3 |
| Runtime resource governance | Implemented, revision 1.4 |
| Three-layer authorization model | Implemented, revision 1.3 |
| Trust model | Implemented, revision 1.10 |
| Use-case examples | Implemented, revision 1.7 |
| WASM sandbox | Implemented, revision 1.9 |
The guest ABI isn’t a one-pager: ABI v2 is the contract itself, with its own version.
ABI v2 and the Rust host
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 0.7 (rewritten in the present tense over the
Rust workspace. The Rust host has served since v0.3.0, the world froze at
wasmfn:function@2.0.0on 2026-10-07 and ABI v1 was removed in 1.0.0; publishing the world, #106, waits on therustscaffold’s stable toolchain)
function-wasm runs guests as WebAssembly components against one WIT world, and its host is written in Rust. This record explains why those are one decision, what ABI v2 is at design level, what the move left alone, and how the risks turned out. The contract itself, the world and its rules, is the ABI v2 reference.
Why Rust and v2 are one decision
ABI v2 means running components, and the component gap was in the Go binding,
not in the engine. wasmtime-go could load a component but couldn’t call one,
define host functions on the component linker or register WASI 0.2 and
wasi:http (#280),
and the draft PRs #290 to #292 were still open. The Rust wasmtime crate is the
reference component host: component::bindgen! over a WIT world, host
functions, wasmtime-wasi, wasmtime-wasi-http with an overridable
send_request and the WASI 0.3 async work. Committing to v2 meant either waiting on a volunteer binding effort or
leaving Go. A CGo shim for components was the middle road, and the other
road was moving the host to the engine’s own language. The project took the
latter (Jonasz, 2026-08-25).
The move brings these with it:
- Native
cedar-policy, Cedar’s reference engine. The hierarchy and pattern code the Go runtime wrote around cedar-go (the boundary-correctRepositoryhierarchy,dialAddresshandling) ported across and now sits incrates/function/src/authz.rs. - The Wasmtime API the Go binding never exposed: parse-only inspection, a
pre-linked
InstancePreper module so a run only instantiates, and the engine’s pooled memory budget, which suit a fresh instance per request. guestfnfollows the runtime, since it links the engine for its verdicts, so one move covered both.
What ABI v2 is
A guest is a component targeting the WIT world wasmfn:function@2.0.0:
- It exports
run: async func(list<u8>) -> result<list<u8>, string>. ProtobufRunFunctionRequestbytes go in andRunFunctionResponsebytes come out, the same wire format ABI v1 used. The world isn’t a typed WIT mirror of the messages: WIT has no recursive types, so it can’t expressStructorValue, and payload evolution stays Protobuf’s job. The canonical ABI moves the memory, so a guest has no allocator export. - It imports a typed
log, and WASI 0.3 and 0.2 under the sandbox. Egress iswasi:http, 0.3client.sendand 0.2outgoing-handleralike, which the host bridges onto one grant held to the egress policy. Guests use their language’s own HTTP client, and whether the import is backed by the egress client follows fromrequires.egress.httpunder the three layers. - The binary format is the version: a component is ABI v2, and the runtime refuses a core module at load.
The world targets WASI 0.3 from the start (Jonasz, 2026-08-25), accepting
young toolchains for early guests. wasi:http@0.2 and @0.3 are different
interfaces, so defining the world against 0.2 and lifting it later would
have meant cutting two worlds and designing the streams and budgets twice.
Wasmtime enabled component-model async by default in v46, so the host had
nothing to wait for. A run lifted sync satisfies the async world,
which is what the WAT test fixtures and the ts, python, c, zig and
go scaffolds use. Wasmtime calls its 0.3 support experimental, so a release that
moves those interfaces is re-vendored and taken on 2.0.0 in place until
the world is published (abi-v2.md, “Versioning”).
Async matters less here than for most hosts. A fresh instance per request
doesn’t need concurrent calls into one instance, which leaves the benefit in
the wasi:http import. Its bodies are native streams, and the egress
budgets count bytes as the host forwards them.
What didn’t change
Everything host-language-neutral moved over verbatim: the Input schema and
manifest format, the Cedar semantics of both policy layers, digest-pinned
sources, the three cache stores, the refusal strings, the flags, /readyz
and gRPC health, and the metrics series. AGENTS.md (“Parity with the Go
runtime”) lists the recorded divergences. The ABI is host-agnostic: the five example guests passed the
ported behavioural suite unchanged.
The port was built against a differential harness that diffed
function validate output and exit codes against the Go binary over shared
fixtures and live servers and registries. When the Go tree was removed, the
recorded outputs became the conformance goldens, so a behaviour change is a
deliberate re-record.
Risks and how they turned out
- Bandwidth. The port was months of feature-frozen solo work while the Go host kept getting fixes. The conformance suites bounded what done meant, and the Rust host replaced the Go one in v0.3.0.
- Security-sensitive ports: the egress dialer judgment, redirect re-checks and the repository hierarchy. Their tests ported with them, and the fixtures diff the refusal strings.
- function-sdk-rust maturity: the runtime became its first hard consumer.
The gRPC layer ended up a raw-codec transport the runtime owns
(
crates/function/src/grpc.rs), with the SDK supplying types and helpers. - Registry and signing clients. The registry client is hand-written over
reqwest(crates/function/src/oci.rs) rather than a published OCI crate. Signature checking moved to cosign 3 bundles oversigstore-verify, so the Go runtime’s cosign 2.sigformat isn’t read, the one divergence beyond wording. wasi:httpbudgets. Budgets that counted whole responses count bytes on the stream (crates/engine/src/wasihttp.rs).- Specification and toolchain youth. Churn did land: ABI v1 was removed
rather than served indefinitely, because Go reached v2 through
guestfn build’s embed and wrap (#114), and therustscaffold still pins a beta toolchain, which holds back the world’s publication (#106). - The static musl binary on a
FROM scratchimage, once expected from dropping CGo, didn’t happen: the image stays on the Chainguardglibc-dynamicbase.
Non-goals
- A typed WIT mirror of the RunFunction messages.
- A second world for WASI 0.2 alone. A toolchain that reaches only 0.2 would need a second world defined, tested and kept consistent with the first.
- Changing a guest-facing semantic during the port.
Alternatives
- A Go host with a CGo component shim, or waiting on wasmtime-go’s binding PRs. Both put the project’s critical path in someone else’s work, and the shim would have to track Wasmtime’s component API by hand.
- wazero. A probe showed the guest ABI is host-agnostic, which is what made the host swap safe, but wazero doesn’t run components, so it couldn’t carry v2.
- ABI v1 kept alongside v2, served indefinitely. The first plan, because mainline Go had no WASI beyond wasip1. The spike for #114 moved Go onto v2 by embedding the world into a wasip1 reactor and wrapping it with an adapter, so no scaffold needed v1 and 1.0.0 removed it.
- componentize-go for Go guests. It proved the approach, but
guestfn builddoes the same embed and wrap with the runtime’s own adapter, so users don’t install anything extra. - A
run-jsonexport besiderun, for guests that would rather skip a Protobuf codec. Decided against (Jonasz, 2026-08-26): the TypeScript guest came together on protobuf-es and the Python guest on the Protobuf package’s pure-Python fallback, and a second export would double what conformance covers. - A sibling repository for the Rust host. The workspace lived beside the Go modules in this repo while the port ran against their fixtures; the Go tree was then removed.
Compiled-artifact cache key
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.0 (a key drawn per process and no flag, for finding F-2 of the 1.0 security audit)
The compiled tier of the cache, /tmp/function-wasm-cache/compiled/<version>/,
contains Wasmtime artifacts: native code the runtime maps and runs through
Engine::deserialize_file, whose soundness rests on the file being the
runtime’s own output. The fetched-module and manifest stores verify what
they read against the digest in the file’s name. The compiled store can’t do
that, because an artifact’s name is the digest of the module the runtime
compiled it from, which says nothing about the artifact’s own bytes. Anyone
with write access to the directory could plant an artifact under a
legitimate digest, and the runtime would run it once the module’s own checks
had passed. The store now refuses every artifact it didn’t write itself.
Design
The runtime draws 32 random bytes at startup (getrandom) and keys the
compiled store with them (store.rs, Store::with_key). It keeps the key
in memory and writes it nowhere, logs it nowhere and shares it with nothing.
putwrites the artifact, then an HMAC-SHA256 of its bytes under the key as<name>.macbeside it, each through a temporary file and a rename. A crash between the two leaves an entry with no sidecar, and the next lookup refuses it.pathreads the entry and its sidecar, recomputes the MAC and compares it in constant time before it hands the path to the engine. A missing or wrong sidecar isLookup::Stale: the store removes both files, the cache countscache_events_total{cache="compiled-disk",event="stale"}and compiles the module again, writing a new entry under its own key.- The sweep and the
cache_bytesgauge skip sidecars, and the sweep removes a sidecar with its entry.
A restart draws a new key. The previous process’s artifacts on a persisted volume fail the check once, and the runtime compiles the modules again. The volume still spares the pod the fetch: the modules store serves the bytes, verified against their digest. Pods must not share a volume, since each would refuse and overwrite the other’s artifacts. Each pod gets its own.
Decision
Jonasz (2026-10-28) chose a key drawn at startup over a key mounted from a
Secret behind a flag. A flag means one more thing for every operator to
configure, and he accepted the price of the per-process key: a cold compile
of every module after a restart, with or without a persisted volume. The
audit’s draft of this record proposed the opt-in --cache-key-file.
Alternatives
Storing the module’s digest inside the artifact, or beside it, binds nothing: whoever can plant an artifact can plant the digest with it. Only a secret the writer of a planted file doesn’t hold distinguishes the runtime’s output from anyone else’s.
A key mounted from a Secret (--cache-key-file) would keep a persisted
volume’s artifacts valid across a restart. It also means one more flag and
one more Secret for every operator, and a key outside the process is a key
someone can read. Rejected (Jonasz, 2026-10-28).
Signing the artifacts with the operator’s cosign key would tie the cache to the signature path and cost a public-key verification per load. The MAC is symmetric and local. No party in the trust model needs to verify the runtime’s own artifacts except the runtime.
Trade-offs
A MAC over the whole artifact is one pass over the file per cold load from
disk, proportional to the artifact’s size (tens of megabytes for a large Go
module). The runtime pays it once per process per module, since the memory
tier keeps the mapped module from then on. The sidecar doubles the number of
files in the compiled store. The compile after a restart is the accepted
price. --warm-modules moves it ahead of readiness.
Tests
store.rs checks that a keyed store serves what it wrote and refuses an
entry under another key, a planted entry and a tampered one, removing what
it refuses. cache.rs makes the audit’s reproduction permanent. The cache
refuses an artifact planted under a digest and fetches and compiles the
module, and a second cache under the same key serves the entry the compile
wrote. The lifecycle end-to-end profile, whose cache is a PVC, checks that a
restart reuses the fetched module and refuses the previous process’s
artifact.
Contract impact
An operator configures nothing, and the refusal wording and the goldens are the same as before. The trust model’s cache section, the flags page’s cache storage section, the sizing and readiness pages describe the per-process key and what a volume keeps.
Module and compiled artifacts cache
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 2.7 (revision 2.7 keys the compiled tier per process, the cache key record’s design. Revision 2.6 puts the manifests tier to use, the performance audit’s perf-02 under the F-5 rule. Revision 2.5 states the cache posture of the 1.0 audit’s F-5, and 2.4 the compiled tier’s trust boundary. Revision 2.3 rewrote the record in the present tense over the Rust workspace, where warm-up loads its entries one after another and skips the signature check, which every request makes before it reads a cache)
function-wasm keeps its caches on disk under one fixed directory, every entry
addressed by a content digest, plus a bounded in-memory tier for compiled
modules. The runtime checks each tier before it serves it. It verifies the
fetched modules and the manifests against their digest. It verifies the
compiled artifacts, native code Wasmtime loads without a check of its own,
against an HMAC under a key the process draws at startup
(the cache key record), and compiles a module again
when another process wrote its artifact. Fetched modules are always written to disk and never held in memory.
Compiled modules are persisted as Wasmtime artifacts, mapped from disk when
needed and held in memory while they’re used. An entry never has to be
invalidated: its name is its content. The flags are safety limits (how many
modules stay resident, how many compile at once), not cache tuning, and
--warm-modules decides when the runtime fills the caches (before readiness),
not how.
Layout
/tmp/function-wasm-cache/
├── modules/ fetched blobs, as delivered by the source
│ └── sha256-<hex> verified against its name on every read
├── compiled/ Wasmtime artifacts
│ └── <engine::version()>/ e.g. rust-v<wasmtime version>-linux-aarch64
│ ├── sha256-<hex> the artifact for the module that digest pins
│ └── sha256-<hex>.mac its HMAC under this process's key; an entry
│ with no sidecar or another key's is refused
├── manifests/ module manifests by their own digest (the manifest
│ └── sha256-<hex> layer's for an OCI source, manifestDigest for an HTTP
│ one), verified against the name on every read
└── tuf/<instance>/ with --cosign-trust-instance: the verified TUF
metadata and trusted root of that Sigstore instance,
outside the LRU sweep (which walks the stores above)
The directory is DEFAULT_DIR in crates/function/src/store.rs, created at
startup. A pod that must keep its caches across restarts backs it with a
volume (a DeploymentRuntimeConfig with an emptyDir survives container
restarts, a PVC lasts across rescheduling). With a read-only root filesystem, the
pod must mount something writable there. A directory that can’t be created
stops the runtime at startup, naming it: running without caches would re-pull
and recompile on every restart, unnoticed.
Store writes each entry through a temporary file and a rename. A crash
can’t leave a partial entry for a later read to serve. The modules/
store is opened verifying: a read recomputes the digest, and a mismatch removes
the entry and counts as a miss. The compiled store can’t verify by name,
since an artifact’s name is its module’s digest, so it verifies by key.
put writes an HMAC-SHA256 sidecar beside each entry under 32 bytes the
process draws at startup, and path checks it before it hands the path out.
A missing or wrong sidecar is a stale miss that removes the entry. The
runtime keeps the key in memory and nowhere else. A restart compiles every
module again, a volume keeps only the fetched modules and the manifests, and
the store refuses a planted artifact.
Keys
Every key is a sha256:<hex> stated in the Input (or computed for a served
file, module.path, hashed when its size or mtime change), so resolving never
touches the network and no reference is resolved at request time:
- An OCI source is keyed by the manifest digest in its reference
(
repo[:tag]@sha256:..., printed byguestfn push; a tag alone isn’t accepted). The manifest pins the module by stating its layer’s digest. The registry client verifies the manifest against the reference and the layer against the manifest. The compiled artifact is keyed by the manifest digest;modules/keeps the layer under its own digest, as delivered. A raw WASM layer’s digest is the module’s. A tar layer is stored as a tar and extracted when the module is compiled, so every blob is verifiable against its name. - An HTTP source is keyed by
module.http.digest, the digest of the module bytes. The runtime verifies the download against it before storing or compiling it.
A cache entry is then exactly the content its name promises, and a module
fetched from two sources shares its blob when the bytes are the same. The
key is also everything a hit checks. A module one Composition pulled with a
registry credential is served without a fetch to any later request that
states its digest, whatever the source type. The runtime checks the
credential at the pull and never at a hit, and a digest quoted in a
Composition is no secret. The signature verdict and the module’s manifest
are kept per digest per process the same way. The resolver remembers a
manifest by the digest that pins it, and a warm request doesn’t contact the
registry. The raw bytes also go to the manifests/ store under their own
digest, verified on read like every disk tier but the compiled one. A
process that lost the memory tier reads the artifact’s manifest once per
digest, with the credential, to learn the layer’s digest, and takes the
layer from disk. A wasmfn.yaml under --module-dir is never cached: it’s
the development loop.
The compiled store is namespaced by engine::version()
(crates/engine/src/lib.rs): the Wasmtime crate version build.rs reads
from Cargo.lock, plus the OS and architecture. A serialized artifact loads
only in the Wasmtime that produced it on the same host type, and a bump moves the namespace on its own. The rust-v
prefix keeps the namespace apart from any directory an earlier runtime left on
a volume. At startup remove_stale_versions deletes the
other version directories under compiled/ once nothing has written them for
STALE_VERSION_AGE (a day). The running binary can’t load them, but during a
rolling upgrade pods of both versions share the volume, and deleting each
other’s artifacts on start would recompile everything twice, while a rollback
within a day finds its cache warm. Loading is defensive anyway: an artifact
that Wasmtime refuses counts as stale and is recompiled over.
Tiers and the request path
RunFunction (crates/function/src/runner.rs)
└─ resolver.resolve(module) no I/O: digest from the Input (or file stat)
└─ verifier cosign, when required - before any tier
└─ cache.get(digest, fetch) crates/function/src/cache.rs
│
├─ memory hit → run (idle TTL 10 min, refreshed on every use;
│ LRU beyond --max-cached-modules)
├─ compiled/<version>/<digest> hit → map the file (no copy) → memory → run
└─ miss → one load per digest, shared by every waiter
fetch: (resolver.rs)
oci: manifest GET (verified against the ref) → layer digest
├─ modules/<blob digest> hit → bytes (blob hit)
└─ miss → source (registry layer / HTTP GET / file) (blob miss)
→ verify sha256 → modules/<blob digest> ← write
oci tar layer: extract /fn.wasm
→ wait for a compile slot (--max-concurrent-compiles, default 1)
→ compile → serialize → compiled/<version>/<digest> ← write
→ memory → run
- Memory (compiled modules). One entry per module in use, dropped after
DEFAULT_IDLE_TTL(10 minutes) of idleness or, with--max-cached-modules, when it’s the least recently used entry past the bound. A request after that goes to the artifact on disk, not back to the network or the compiler. Loads are single-flight per digest: concurrent first requests for one module wait for one load. A failed load isn’t remembered, and the next request retries. At most--max-concurrent-compiles(default 1) modules compile at once. A compile uses every core and about a gigabyte for a large guest, and more in flight only multiplies that. A cold load isn’t cut short by the request’s deadline: the compile completes and caches, and the run that follows gets the remaining budget.--enable-memory-cache=falseturns the tier off: every request maps the artifact from disk and releases it. - Compiled artifacts on disk. Written after every compile, mapped on every
memory miss with
Engine::deserialize_file, and the code is file-backed and shared with the page cache instead of copied onto the heap. Restarts and rolling upgrades cost that map instead of a compile; a Wasmtime upgrade costs one compile per module and, a day later, drops the old directory. - Fetched modules on disk. Written after every successful fetch, read
before any download. An HTTP server outage doesn’t stop reconciling for
modules seen before, and a rescheduled pod with a volume never re-pulls. An
OCI module whose artifact is gone costs one manifest read (a few KB, to
learn the layer digest) and no download. Served files (
module.path) aren’t copied: they’re on disk already.guestfn pushandoras pushproduce rawapplication/wasmlayers, which cost nothing to extract; a compressed tar layer is decompressed on every artifact miss.
The runtime holds raw module bytes in memory only during the compile call. Warm-up is the same path entered at startup instead of at a request.
Warm-up
Ready isn’t warm by default: the first request for each module on a pod pays a
map (warm volume) or a compile (cold). --warm-modules (repeatable, one
comma-separated value, or WARM_MODULES) names modules to load first: OCI
references pinned to their manifest digest (pulled with the runtime’s Docker
config, since there is no step credential at startup) and, with
--module-dir, path:<file> entries. warm in crates/function/src/ops.rs
resolves each entry and calls the same cache.get a request does, so a warmed
module is what a request would have loaded, whichever tier supplies it. It doesn’t check signatures. That stays the
request’s job, made before any cache is read, so a warmed module that isn’t
signed is never served where a signature is required. The order:
- The caches open. A failure here refuses to start.
- The gRPC and health servers start listening, both reporting not serving. A probe reads not-ready rather than a refused connection, and a request that arrives early is served cold or joins the load already in flight for its digest.
- With
--cosign-trust-instance, the first trusted-root fetch runs, bounded and logged on failure, because a warmed module may need it. - The entries load one after another.
/readyzand gRPC health flip to serving.
A failure is logged (Cannot warm module, with the entry and the reason, such as a
tag instead of a digest or an unreachable registry) and nothing else. Readiness is never held back by an entry, and
the module it names loads on its first request as before. Warm-up costs time
between listen and serving, one map per entry on a warm volume and one compile
per entry on a cold cache, so a gRPC liveness probe on the function port needs
a failure threshold that outlasts it. A volume under
/tmp/function-wasm-cache is what makes that time short. The operator’s view
is readiness.
Failure behavior
- Disk write failure at run time (full volume, transient I/O error): ignored by the caller. The request proceeds with the fetched or compiled result, and the next miss tries again.
- Corrupt blob (digest mismatch on read): removed, treated as a miss.
- Corrupt or foreign artifact (Wasmtime refuses it): treated as a miss and counted
stale. The recompile overwrites it. - The signature check happens before any tier is consulted, once per manifest digest per process, so an artifact that a keyless runtime left on a shared or persisted volume is never served by a runtime that requires a key.
- A digest stated in the Input (or by the manifest it pins) that differs from what the source delivers is a fatal result stating both digests, and nothing is stored.
Observability
function_wasm_module_cache_events_total{cache,event} with cache in
compiled (memory tier), compiled-disk (artifacts) and blob (fetched
modules), and event in hit, miss and stale (compiled-disk only: an
artifact Wasmtime refused). function_wasm_module_fetch_duration_seconds{source}
and function_wasm_module_compile_duration_seconds time the miss paths.
function_wasm_module_cache_bytes{cache} is the size of the blob and
compiled-disk stores as of the last sweep. The metrics page lists the full
series: metrics.
Eviction
Disk entries aren’t evicted by default: they’re immutable and named by digest,
and a Composition that stops referencing a module leaves its files behind.
--max-cache-size (in MB) turns the sweep on. At startup and every ten minutes
it removes the least recently used entries across the three stores (a read
counts as a use: the store touches what it serves) until nine tenths of the
bound is left, so consecutive sweeps don’t each remove one entry. A removal is
always safe, since every entry is reproducible; the next request fetches or
compiles again. A mapped artifact whose file is swept keeps serving the
resident module, because the open mapping doesn’t depend on the file’s name.
Alternatives
Eviction on by default. Entries are reproducible and cheap to keep, and a default bound would be a number the project can’t choose for every volume. The sweep is opt-in.
Flags for the disk tiers. Every option for them was a way to run without a
cache, so the design has one directory and one idle TTL. The flags that remain
(--enable-memory-cache, --max-cached-modules, --max-concurrent-compiles)
bound memory and CPU and leave both disk caches on.
Warm-up from a list of every module ever served. What to warm is the operator’s list, each entry a digest (never a tag) stated the way a Composition states a module. A pod doesn’t resolve anything at startup either.
Module manifest
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.4 (rewritten in the present tense over the Rust workspace; merges in the manifest-less-sources record, revision 0.1, whose manifests by reference are a section here)
A module declares what it needs in a manifest: the sandbox capabilities it
can’t run without, the schema of the config it reads and the oldest runtime
it works on, plus, advisory only, the kinds it composes. For an OCI artifact
the manifest is a second layer of the artifact. A Path or HTTP source
names its wasmfn.yaml by reference. The runtime reads it before the run and fails
fast, with a message that identifies the module and the refusing policy layer,
when a requires entry isn’t permitted or the config doesn’t match the
module’s schema. Registries and guestfn inspect show what a module needs
before anyone runs it, and guestfn scaffold composition writes the Input
from it.
A manifest never widens anything. It’s the request of the
three-layer decision, granted only where the
Input’s compositionPolicy and the operator’s --sandbox-policy-file both
permit. The manual page is Module manifests.
Principles
- A manifest is a request, never a grant. Its only effects are an earlier, clearer refusal, documentation and scaffolding. The composition and operator policy layers still grant, and either can refuse.
- Beside the module, inside the artifact. The manifest is a layer of the same OCI artifact as the module, so the manifest digest a Composition pins names it and a cosign signature of that digest covers it. The module bytes are untouched, and nothing parses WebAssembly to find it (Wasmtime is the only decoder of a module).
- Absent is fine. A module without a manifest gets the default sandbox.
Unknown top-level fields are ignored in a published manifest, and an
unknown entry under
requiresfails closed. - Language-agnostic.
guestfnwrites it from a file next to the project, independent of any SDK or toolchain feature, so every guest language is equal.
Shape
The source is wasmfn.yaml in the project (every scaffold includes one):
abi: 2 # required; the world of docs/abi-v2.md
name: greeter # → org.opencontainers.image.title
version: 0.1.0 # → …image.version; guestfn push --module-version overrides
source: https://github.com/example/greeter # → …image.source (optional)
requires: # what the run must be granted; each optional
egress:
http: # the same rule shape as the Input's egress rules
- host: api.example.com
methods: [GET]
pathPrefix: /v1/
filesystem: {privateTmp: true}
env: # secret env: bindings to step-credential keys
- name: DATABASE_URL
fromCredential: {name: db, key: url}
credentials: [cmdb] # step credentials read whole from the request
config:
schema: # JSON Schema 2020-12, inline; validates the Input's config
type: object
properties: {greeting: {type: string}, greetingUrl: {type: string, format: uri}}
additionalProperties: false
minRuntime: v0.2.0 # oldest function-wasm runtime that serves this module
composes: # advisory: what the module writes
- kind: policy/PodDisruptionBudget
changes: [add]
The layer. guestfn push adds the manifest as UTF-8 JSON in a layer of
media type application/vnd.wasmfn.manifest.v1+json after the
application/wasm layer and lists both digests in the artifact config’s
layerDigests. It also sets the standard org.opencontainers.image.*
annotations (title, version, source, description, revision) from the
manifest and --revision, for registry UIs. The runtime never reads them. A
manifest is at most 64 KiB. requires.egress.http takes the same rules as the
Input’s egress rules, so a request and a compiled grant compare like with
like. requires.env binds variable names to step-credential keys and
requires.credentials names the step credentials the module reads whole from
its request. The values arrive at the pipeline step, never in the manifest,
and the request the module receives contains only the credentials these two
name: the runtime edits every other one out of it. oras push fn.wasm:application/wasm wasmfn.json:application/vnd.wasmfn.manifest.v1+json produces the same
artifact.
Who writes it. guestfn build reads wasmfn.yaml when present and
validates it, so a project with a bad manifest doesn’t build. The parse is
strict and refuses unknown fields, and the egress rules and env bindings pass
the runtime’s own checks. guestfn push reads it again and
adds the layer. The manifest is a data file, not something declared in the
guest’s code: anything in code would need instantiating the module to read,
and the manifest must be readable without running the module. A guest’s own glue never writes it.
Decided by the policy layers. In the request path, after admission and the
load and before the run, admission::admit_requires decides every requires
entry by the three-layer rule. Each egress rule (once per rule and method, so
a policy can key on context.method), filesystem.privateTmp, each env
binding (setEnv over the bound names, spendCredential per binding) and
each required credential (spendCredential) must be permitted by the Input’s
compositionPolicy (scoped default-permit: it narrows only the actions it
writes rules for) and by the operator’s --sandbox-policy-file
(default-deny). Then Manifest::check holds the rest of the manifest against
the run:
abiis 2, andminRuntimeis no newer than the runtime’s stamped version (a development build, which has none, passes);- the Input’s
config(absent is{}) validates againstconfig.schema.
A miss is a fatal result, counted as refused, before the module runs:
module oci ghcr.io/example/greeter@sha256:3f2a… requires egress GET to host "api.example.com" (requires.egress.http[0]), which the operator policy (--sandbox-policy-file) does not permit
… which the compositionPolicy does not permit
… requires a private /tmp (requires.filesystem.privateTmp), which the operator policy (--sandbox-policy-file) does not permit for this request
… requires runtime v0.3.0 or newer, this is v0.2.1
… config does not match the module's schema: /greeting: got number, want string
The runtime checks the composition layer first, whole: the author closest to the fix reads their own layer’s refusal even where the operator would also deny. A manifest can make a run fail earlier. It can’t make a run possible.
Declared output. composes lists the <group>/<Kind> a module writes and
the changes it makes (add, modify, remove, all three when omitted). It’s
advisory (#174). The runtime never reads it and judges the module’s actual
output against the policy layers’ composeResource rules, since a
refusal before the run would be unsound for rules conditioned on a resource’s
name. The tooling reads it: function validate --resolve warns where a
declared change would be refused, guestfn inspect shows it and guestfn scaffold composition writes the matching permits.
guestfn scaffold composition prints a Composition step, or with --full
a Composition, whose module is a Path for a file or the pinned OCI
reference, whose config is a skeleton from the schema (required keys with
placeholders, defaults where the schema has them) and whose limits are
commented. The module’s sandbox needs are its manifest’s requires, granted
by the policy layers and never copied into the Input. guestfn push prints
the requires: block under the module: block it prints, which shows a
Composition author what the policy layers must permit.
Manifests by reference
A Path source (a .wasm file under --module-dir) or an HTTP source (a
file fetched by URL and digest) has no artifact to hold a layer, and an OCI
artifact pushed without a manifest layer has none either. With no manifest the
request is empty, so by the model’s default-deny the module gets only the
default sandbox. That’s safe, but it leaves an operator who legitimately runs a
Path or HTTP module that needs egress, a private /tmp or a
credential-sourced env var with no way to request it (tracked in
#30).
A manifest is only a request. A manifest-less source misses nothing but a
place to put its wasmfn.yaml: the document the module author already writes
and guestfn push embeds, stored beside the module instead of inside the
artifact. Such a source names it by reference:
module:
type: HTTP
http:
url: https://example.com/fn.wasm
digest: sha256:...
manifestURL: https://example.com/fn-manifest.yaml # optional
manifestDigest: sha256:... # pins it
---
module:
type: Path
path: fn.wasm
manifestPath: fn-manifest.yaml # optional, under --module-dir
- The referenced file is a
wasmfn.yaml. The resolver normalizes it to the JSON an OCI manifest layer contains, soManifest::parseis the one decoder. It verifiesmanifestURLagainstmanifestDigest, bounds the file to the manifest size limit and readsmanifestPathunder--module-dirwith the same confinement as the module file. - It arrives through the same
Resolver::manifestcall as an OCI manifest layer, soadmit_requires,Manifest::checkandfunction validate --resolveare unchanged. A referenced manifest meets the same three layers as an embedded one, and it can only refuse a run earlier, never make one possible. A file the runtime can’t read or parse is a fatal result,cannot read the manifest of module <desc>: …ormodule <desc> has an invalid manifest: …. - Caching follows the manifest’s own identity, not the module’s: the same
module file may run with one manifest, another, or none. An
HTTPmanifest is content-addressed bymanifestDigestand kept in the blob store. AmanifestPathmanifest is read fresh on every request and never cached, so the local loop (--module-dirandcrossplane render) reflects an edit immediately. manifestPathis allowed withtype: Pathonly. An OCI artifact takes no reference: its manifest is a layer.
The from fence
When the composite resource chooses an HTTP source (module.from), a
compositionPolicy pullModule rule must permit the manifest’s normalized
location, as it must the module URL; otherwise the XR author could point the
runtime at any host to GET. The refusal is module.from: <field> of the composite resource names manifestURL "<location>", which the compositionPolicy does not permit (pullModule). A manifestPath has no host, so the fence
doesn’t apply to it, and it comes from the Input only: module.from can’t set
it, since an XR author chooses the module file, not what its manifest
requests.
Mechanics
crates/function/src/manifest.rsholds theManifesttypes.loadreads thewasmfn.yamlof a project and refuses unknown fields,parsedecodes a layer (at most 64 KiB, unknown top-level fields ignored, an unknownrequiresfield refused),validatechecks the egress rules, env bindings and credentials through the same functions the Input’s use,minRuntimeas semver and the schema compiled as JSON Schema 2020-12 with thejsonschemacrate.checkholds the manifest against the run’sGrants, andsummaryis the line the CLIs print.crates/function/src/resolver.rs:Resolver::manifestreturns the manifest as JSON bytes, empty when the source has none. The OCI case reads the artifact’s manifest, picks the manifest layer and fetches it through the blob store, verified against the layer’s digest and bounded to the size limit. The other two cases are in the preceding section.crates/function/src/runner.rsreads and parses the manifest between the load and the run, then callsadmit_requiresandManifest::checkin that order.function validate --resolve(validate.rs) reads the same manifest, prints its summary on the resolved line and applies the same decision with the same refusal.crates/guestfn:buildvalidateswasmfn.yaml;push [--manifest f] [--module-version v] [--revision r]adds the layer and annotations;inspect <ref>andmanifest show <ref>read the layer;manifest validate [wasmfn.yaml]checks the file;scaffold compositionwrites a step or a Composition. The flag is--module-versionbecause the root--versionis taken by the CLI’s own.
Trust and threat notes
The manifest is a request both policy layers must permit, so it can refuse a
run or document a need. It never grants or widens, and it never affects
another module’s run (there is no “denies” list). In an artifact it travels
under the manifest digest the Composition pins: whoever can change it can
change the module, and --cosign-key covers both. A manifest by reference is
pinned where it can be: an HTTP manifest by manifestDigest. config.schema
runs a validator over Composition-authored data, and a manifest, and so its
schema, is bounded to 64 KiB. Reading the manifest parses JSON, never
WebAssembly. Failing closed on unknown requires.* means a module built for a
newer runtime is refused with a message, not run without a capability it
counted on. These checks never read the composite resource.
Decisions
- An OCI layer, not a custom section (Jonasz, 2026-08-17). The manifest
is a second layer of the artifact, so the same digest and signature cover
it, no parser is needed, and
oras pushcan produce it. The cost accepted:PathandHTTPsources have no layer, so they name theirwasmfn.yamlby reference, and aguestfn manifest setthat embeds into an existing module has no meaning and doesn’t exist. - Environment variables are a
requiresentry (Jonasz, 2026-08-19, which reversed 2026-08-17). The first design left environment values to the Composition. The three-layer change removed the Input’s env fields, so the manifest is where a module declares its env contract:requires.envbinds a variable to a step-credential key, allowed only wheresetEnvandspendCredentialpermit it in both Cedar layers. requiresis hard. An unmet entry refuses the run. Anoptional: trueper rule can be added later without breaking anything.config.schemais enforced by the runtime, not only byguestfn. A guest already fails on a bad config, only later and with a worse message; the runtime’s refusal identifies the field.- The module author is the one request author for every source kind. A manifest by reference keeps one author and one schema for what a module needs, at the cost of a second small file to publish or place.
- The home of the manifest is the artifact or the referenced file.
guestfn pushderives the OCI annotations from it for registry UIs and the runtime never reads them. - The source file is
wasmfn.yaml.
Alternatives
- Manifest-less means the default sandbox only. The simplest option, and
it nudges everyone to OCI and
guestfn push. Rejected as the only answer: it breaks the local loop for any module that needs egress, and a module hosted over HTTP has no path to a capability at all. - An inline
requestblock in the Input, valid only for manifest-less sources. The Composition would author the request for these sources only, so a module would have two possible request authors and two schemas to keep aligned. - A sidecar
wasmfn.yamldiscovered beside aPathmodule. Rejected for an explicitmanifestPath: a source states what it loads, nothing is picked up implicitly, and one field pattern (manifestURL,manifestPath) covers both kinds. - The manifest as a
wasmfn.manifestcustom section of the module. Reading it needed a walker over WebAssembly sections beside Wasmtime, which the admission-tooling design had ruled out (a second reader of a module). - Environment variables listed under
requiresas keys the module reads. Dropped in the first design, when environment values were the Composition’s, and superseded by the env bindings in the decisions.
Non-goals
- Letting a referenced manifest grant anything. It’s a request; the two Cedar layers decide. A permissive manifest on a runtime with no operator policy still gets the default sandbox.
- An inline
requestin the Input. If a use case appears for a Composition-authored request on a manifest-less source, it can be added later over the sameRequiresschema. - A reference and an embedded manifest on the same OCI artifact.
manifestURLandmanifestPathare for the sources with no layer.
Module source schema
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 2.3 (rewritten in the present tense over the Rust workspace; the Input is
wasm.fn.crossplane.io/v1)
The Input is what a Composition step hands the runtime. A discriminated
module says what runs, and the fields the Composition author controls sit
beside it at the top level. The 2026-08-16 architecture review
raised it. The user-facing field reference is
the Input reference; this record keeps why the
Input is structured this way and what compositionPolicy and limits decide. The
trust model describes what the compositionPolicy
fence means, resource governance
limits, and the sandbox the capabilities a manifest
may request.
The Input, in one place
apiVersion: wasm.fn.crossplane.io/v1
kind: Input
module: # what runs - required
type: OCI # OCI | HTTP | Path - always the Composition's choice
oci: # exactly one of the typed object …
ref: ghcr.io/example/greeter:v1@sha256:…
credentials: registry
# http: {url, digest, manifestURL?, manifestDigest?} # a manifest-less source may name its wasmfn.yaml
# path: fn.wasm # with, optionally, manifestPath: fn-manifest.yaml (under --module-dir)
# from: status.module # … or the XR field holding it (an object of `type`)
# allowEmpty: true # with from: an unset field runs nothing (desired state unchanged)
compositionPolicy: | # the composition author's Cedar layer (three-layer-authz one-pager)
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
limits: # what this step's run may consume, ≤ the runtime's ceilings
timeout: 5s
memory: 128Mi
config: # opaque, forwarded to the module
greeting: hi
module is required; compositionPolicy, limits and config are
optional. A v1beta1 document with the same fields is accepted through 1.x
and removed in 2.0.0 (input::V1BETA1_DEPRECATION, see
Compatibility).
Everything but module (and within it, the object from names) is read from
the Input only, never through module.from: an XR author may choose the
module but may not widen its permissions, grants or budget. The Input has no
sandbox subtree. A module requests anything beyond the default sandbox in
its manifest’s requires. The runtime grants a capability only when the
manifest, the compositionPolicy and the operator’s --sandbox-policy-file
all permit it (three-layer authorization).
function validate refuses an Input with the removed policy
or sandbox field and names the replacement (the Input's policy field was removed: …, the Input's sandbox field was removed: …).
Why this structure
A source is chosen in one of two places: the Composition author writes it
into the Input, or an XR author names it in a field of the composite resource.
Rules that depend on where a source came from (an XR-chosen source may reference a
credential only where the Composition permits it, and only inside an allowed
registry) belong in the structure of the schema. A type discriminator
next to a typed object is the Crossplane idiom, and from is an orthogonal
switch on it:
module.typesays what kind of source the step runs. The Composition author always chooses the kind, an XR author only the instance.- Exactly one of the typed object (
oci,http,path) orfromis set, and no object of another type may be present. The runtime checks it on every request. Three CEL rules on themoduleobject say the same for tooling that reads the schema (crossplane resource validate, IDEs), such asself.type == 'OCI' ? (has(self.oci) != has(self.from)) : !has(self.oci)and its two siblings. Crossplane never installs a function’s Input CRD (an Input is a fragment of a Composition), so the runtime is the only gate that always runs:admission::admitandfrom::from_compositeapply every rule the CRD underpackage/input/states. - The Composition-owned rules are top-level siblings of
module, never read from the XR. The CRD’s CEL requires a non-emptycompositionPolicywhenmodule.fromnames an OCI or HTTP source. limitsis the other top-level sibling.- An object read through
frommay reference a credential only where thecompositionPolicypermitsspendCredential, and only for a ref apullModulepermit admits. The XR can’t bypass that rule, because the policy never comes from the XR.
module.from and allowEmpty
module.from names a field of the observed composite resource under spec.
or status. (pattern-checked), read on every request. from_composite
decodes it, refusing unknown fields, into the object type names (an
{ref, credentials} object for OCI, {url, digest} for HTTP, a string
for Path) and then validates it like a static source. An error quotes the
field: module.from: status.module of the composite resource is not a {ref, credentials} object: …. A field the composite resource hasn’t set is
an error naming it (module.from: cannot read status.module from the composite resource: module: no such field).
module.allowEmpty: true changes that case. The step doesn’t run a module and
returns the request’s desired state and context unchanged, built at the wire
level from the caller’s own bytes and counted as
requests_total{outcome="skipped"}. A field that’s set is read, fenced and
run exactly as without it. allowEmpty is read from the Input only and
refused without from: the Composition author chooses whether a step may be
left empty, the composite resource only whether that step is (issue #82). It
serves the reserved hook of the first use case (use cases)
before any tenant fills it in, and a smoke test of a deployed runtime that
doesn’t need a module built.
compositionPolicy and what an XR-chosen module may be and spend
Refusing credentials on XR-chosen modules would be safe but blunt: a platform
team that wants tenants to pick modules from the platform’s private registry
would have to mount a Docker config into the runtime. compositionPolicy
fences what an XR-chosen module may be and spend instead:
module:
type: OCI
from: status.module # the XR names {ref, credentials}
compositionPolicy: |
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
permit (principal, action == Action::"spendCredential", resource == Credential::"registry")
when { context.repository in Repository::"ghcr.io/example-org" };
It’s raw Cedar over the same schema as the operator’s policy, compiled at
admission (content-hash cached). Malformed Cedar is a fatal result. For an
XR-chosen source it’s a required, default-deny fence. The XR’s oci.ref (or
http.url), normalized, must be permitted by a pullModule rule over the
Repository hierarchy. A named credential also needs a spendCredential
permit, whose context.repository contains the ref’s location so both halves
co-locate. For sandbox actions it only narrows, and only where it scopes the
action. A static source skips the fence: the Composition named it and is
trusted with it. The XR keeps choosing the artifact; the Composition keeps
choosing where from and with what. The same field serves signature-free
deployments that still need to fence tenants to a registry.
The Repository hierarchy is boundary-correct: Repository::"ghcr.io/example-org"
admits ghcr.io/example-org/mod but never the sibling namespace
ghcr.io/example-org-other/…. A trailing slash is optional and doesn’t
change the boundary.
A manifest-less source can reference its manifest instead: module.http.manifestURL
together with manifestDigest, or module.manifestPath with type: Path
(module manifests). The runtime loads it as it
loads an OCI manifest layer, so the three-layer decision is identical. For a module.from http source the manifestURL is fenced by
pullModule like the module URL. The Input alone supplies manifestPath.
limits and per-composition budgets
The ceilings are global (--module-timeout, --module-memory-limit), so a
trusted internal module and a tenant’s labelling module would get the same
budget. The operator sets the ceiling and the Composition requests less, so
the Input has a top-level limits, read from the Input only (an XR author
must not widen a budget). admission::admit checks each value against its
flag at request time. Asking for more is a fatal result that quotes both
(limits.memory 1Gi exceeds the runtime's --module-memory-limit of 512Mi);
asking for less narrows that run’s epoch deadline and memory limiter in the
engine’s RunOptions. Raising a limit past the ceiling remains operator-only.
A Composition author who sets limits with little headroom gets fatal results
the operator didn’t cause. The benefit is a shared runtime where the loudest
module can’t spend the whole ceiling.
Alternatives
Optional fields under module (oci, http, path and a *From
pointer for each) with a CEL rule that exactly one is set would work. It
presents every source kind twice. Each new kind adds two fields, and the
policy fields would still have no home.
source: {kind, oci, http, path} nested one level down is the chosen
structure with an extra level and gains nothing.
Putting from inside the typed object (oci: {from: status.module}) reads
well for one kind, but path: {from: …} turns a string into an object, and
the credentials rule then needs an “if from is set” check inside each object.
policy and limits under module would tie Composition-owned policy to the
thing an XR may choose. As top-level siblings they’re visibly not part of
what from reads.
Dropping from sources altogether removes the whole class of XR-author
decisions, but loses the per-tenant case that the use cases lead
with.
Runtime resource governance
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.7 (the per-run ceiling is checked over every region together, not per region; a cold load bounded by
--module-compile-timeout, the 1.0 audit’s F-4; table growth charged to the ceiling and the pool like memory growth, the 1.0 audit’s F-1; rewritten in the present tense over the Rust workspace; merges in the governance-perf record, revision 0.2: the egress rate limit and the fair run slots are part of the model, and its dropped fuel phase is under Alternatives)
One runtime serves many Compositions, many teams and many modules. This document is the model of what a module may consume, what bounds the runtime as a whole, how the bounds divide the runtime between modules, and how an operator sizes and probes it. The limits exist so that one team’s module can’t take every other team’s reconcile down with it.
Every ceiling is a runtime flag the operator sets. A Composition may lower a
ceiling through the Input’s limits and can’t raise it (the
module source schema defines the fields).
The runtime reads limits from the Input only, never through module.from, so
an XR author can’t widen a budget. A value over its ceiling is a fatal result
that states both, checked before anything is resolved or fetched:
limits.timeout 1m0s exceeds the runtime's --module-timeout of 30s,
limits.memory 1Gi exceeds the runtime's --module-memory-limit of 512Mi. The
exception is limits.concurrency, which has no ceiling of its own and is
capped to --max-concurrent-runs without a message.
Background
A first request for a large guest costs seconds of CPU and about a gigabyte of peak memory to compile (sizing has the measurements), and the run itself costs far less. Without bounds, N cold requests would run N compiles at once and every module used within ten minutes would stay resident. Every version ever served would also stay on disk. A pod with a core or two serving twenty large modules from a cold cache wouldn’t converge: it would be killed for memory, restart with an empty cache and begin again.
The model
| dimension | bound | flag or Input field | default | at the bound |
|---|---|---|---|---|
| one run, wall clock | epoch deadline, or the request’s gRPC deadline when that’s sooner | --module-timeout; limits.timeout narrows it | 30 s | the run is interrupted and the fatal result states the module and the budget |
| one run, guest memory | Wasmtime store limiter over the run’s linear memories and tables together, a host pointer per table element | --module-memory-limit; limits.memory narrows it | 512 MB | memory.grow or table.grow fails and the guest decides; counted in memory_denials_total{reason="limit"} |
| one run, call stack | Wasmtime’s stack ceiling, engine-wide | --module-stack-limit | 512 KB | the guest traps: a fatal result |
| resident compiled modules | LRU over the memory tier, on top of its 10-minute idle TTL | --max-cached-modules | 0 (TTL only) | the least recently used module is dropped |
| the memory tier itself | off maps the artifact per request and releases it | --enable-memory-cache | true | nothing stays resident |
| concurrent compiles | semaphore around fetch and compile | --max-concurrent-compiles | 1 | further first requests, and those waiting on the same load, queue |
| one cold load, wall clock | tokio::time::timeout around the load’s thread | --module-compile-timeout | 0s (unbounded) | the request fails with a fatal result that states the bound; the compile finishes on its thread, keeps its slot until then and stores its artifact; counted in cache_events_total{cache="compiled",event="timeout"} |
| disk | LRU sweep over the stores, a read counting as a use | --max-cache-size | 0 (unbounded) | entries removed down to nine tenths of the bound, at startup and every ten minutes |
| module size | cap on fetched bytes, and on tar extraction at eight times the cap | --max-module-size | 128 MB | the fetch fails: a fatal result |
| concurrent runs | fair run slots, taken after the module is loaded | --max-concurrent-runs | 0 (the caller’s concurrency) | a further request waits under its own deadline |
| concurrent runs of one step | per-digest semaphore, taken before the global slot | limits.concurrency | 0 (the global bound only) | a further request of this step waits under its own deadline |
| total run memory | pool charged as guests grow | --max-total-run-memory | 0 (unbounded) | a growth the pool can’t serve before the run’s deadline is denied |
private /tmp | none in the runtime: the filesystem behind $TMPDIR | TMPDIR | the pod’s /tmp | the guest’s write fails (ENOSPC); the runtime is unaffected |
| one run, HTTP egress | per-run budgets, fixed at 16 requests, 4 MiB of response bytes, 5 redirects and a 10 s timeout per request | none | as stated | the request is answered with an error the guest reads; the run goes on |
| HTTP egress rate | token bucket per module digest | --egress-rate-limit-per-minute, --egress-rate-limit-burst | off | a budget error the guest reads; the run goes on |
A run’s wall clock meters guest compute. Time blocked in wasi:http is
credited back to the epoch deadline, so a slow upstream doesn’t spend
limits.timeout. The request’s own gRPC deadline stays the hard cap. Without
one the credit is still bounded: every egress request is cut at the run’s
deadline, and a run stretches to about twice its budget at most. Only
wasi:http time is creditable. Crediting arbitrary host time would let a guest
stretch its wall clock through a host import with no such bound.
Compiles and the memory tier
The compile semaphore defaults to one because Wasmtime compiles in parallel across every core it’s given: two compiles at once finish no sooner than one after the other and add a second gigabyte of peak memory. A larger value suits large nodes serving many small modules, where a compile takes milliseconds and the queue, not the CPU, is the bottleneck. A failed load isn’t cached, and the next request retries. The cache record describes the caches.
Run slots and fairness
The caller (Crossplane’s reconcile workers) bounds concurrency unless the
operator sets --max-concurrent-runs. The flag is off by default because a
run’s memory is capped per run and a semaphore adds head-of-line blocking. It
exists for deployments where the caller’s concurrency isn’t the number to size
for: many Crossplane instances or a large worker count sharing one runtime, or
a memory-tight pod where concurrent runs times --module-memory-limit must not
be reachable.
With the flag set, the engine’s FairScheduler (crates/engine/src/concurrency.rs)
queues waiters per module digest and admits them round-robin. A freed slot goes
to the next digest in rotation, first in first out within one digest. One hot
module takes at most its share of the slots, and a request’s wait
scales with the modules queued ahead of it, not the requests. With one
digest waiting the scheduler is a plain FIFO.
limits.concurrency adds a bound per step. StepSlots keeps a semaphore per
module digest, so two Compositions naming the same module share it. A run takes
its step slot before the global one, so a step that has used its share doesn’t
occupy a global slot. A digest’s slot count is set by the first step to use it
and applies until the entry is swept after ten idle minutes.
The run’s budget or the request’s gRPC deadline, whichever comes first, caps each wait. A queue never outgrows what its callers still wait for, and a request that gives up took no slot and isn’t counted as a run. The refusals are fatal results:
waiting for a run slot: deadline exceededwaiting for one of this step's 2 run slots (limits.concurrency): deadline exceeded
A run takes its slot after the module has loaded, around exactly one run, so
function_wasm_module_run_duration_seconds measures runs rather than queues.
function_wasm_module_runs_in_flight pinned at --max-concurrent-runs means
the queue is the bottleneck.
Run memory pool
--max-total-run-memory bounds the total memory of all running guests, their
linear memories and tables together.
A component has no top-level memory to reserve ahead of the run. Each run
charges the pool as its guest grows, the initial memory included, and releases
everything when its store drops. The pool reflects what runs use, and a small
guest doesn’t wait on a worst-case ceiling it never claims. A growth the pool
can’t serve before the run’s deadline is denied. The guest sees memory.grow
or table.grow fail, as at the per-run ceiling, the runtime logs Denied a memory growth or Denied a table growth and the denial counts in
memory_denials_total{reason="pool"}. The runtime refuses
at startup a pool smaller than --module-memory-limit, since no full-limit run
could ever fit.
The acquisition order is the step slot, the global slot, then memory as the run grows. A run waiting for the pool keeps its run slots, and runs with partial reservations can wait on each other. The run’s deadline bounds each wait and denies the growth when it passes. A cycle lasts no longer than a deadline.
Egress rate
A module called once per XR against one endpoint makes a request per reconcile
per XR. RateLimiters in crates/function/src/egress.rs keeps a token bucket
per module digest, process-wide, consulted after the per-run request count. The
digest, not a host or a Composition, is the key: it’s the unit of the audit
line, idle buckets expire so the map follows the modules in use, and one
module’s traffic can’t exhaust another’s rate. A bucket starts full.
--egress-rate-limit-burst sets its size, and 0 derives
max(1, requests per minute). Over the limit, the guest reads
sandbox.egress: the module's request rate exceeds the egress policy's rateLimit
and http_requests_total{outcome="budget"} counts the request. The check
returns at once, and the module retries on its next reconcile. The ten-minute sweep drops
idle buckets. The operator sets the rate, and a Composition can’t change it.
Sizing
The sizing page has the measurements. The terms are:
- Memory is the resident compiled modules, plus
--max-concurrent-compilestimes the peak of compiling the largest module, plus concurrent runs times the per-run figure.--max-concurrent-runs, when set, caps the last factor (at--module-memory-limitper run in the worst case), and--max-total-run-memorycaps the sum of what runs claim. - CPU is requests per second times the per-request cost, plus the compiles, one per module version per cache.
- Cold start is the compile CPU of every module over the cores. A volume
under
/tmp/function-wasm-cachereplaces compiles with loading the stored artifact, and--warm-modulesmoves either ahead of readiness. - Disk is a module and its artifact per version served, until
--max-cache-sizebounds it.
The dominant per-request cost is the guest’s own initialisation, not the host, so the toolchain choice is the sizing decision.
Readiness and warmth
The runtime reports the gRPC health service on the function port and plain-HTTP
/readyz on --health-address, ready once the caches are open and the
--warm-modules entries are loaded. Ready isn’t warm by default: the first
request for each module on a pod pays a map from a warm volume or a compile from
cold. The volume-backed cache covers restarts, and the compile semaphore keeps a
cold fleet from thrashing. Readiness and warm-up is the
manual page, and the cache record covers what warm-up loads.
Failure containment
A run that traps, exits, times out or exhausts its memory is a fatal result. A panic in the host on the run path becomes a fatal result too, and a load that fails isn’t cached. The process is never the unit of failure for one request, which is the property every bound here protects.
The sandbox’s other grants have bounds in the table. The filesystem behind
$TMPDIR bounds the private /tmp, because WASI pre-opens don’t support a
quota and the runtime adds none. The per-run budgets bound egress, and an
operator can’t configure them. Host directories aren’t mountable, so nothing
else on the pod’s filesystem is a module’s to read.
Observability
The series are documented. Those that govern resources
are function_wasm_module_run_duration_seconds{outcome} (timeouts are an
outcome of their own, and a wait for a run slot is outside it),
function_wasm_module_runs_in_flight,
function_wasm_module_compile_duration_seconds,
function_wasm_module_cache_events_total{cache,event},
function_wasm_module_cache_bytes{cache} from the sweep, and
function_wasm_module_memory_denials_total{reason}. No series has a module,
digest or host label: the set is unbounded.
Alternatives
Fuel. Wasmtime’s instruction counting (a fuel budget per run) would have
bounded CPU rather than wall clock. The project dropped it while wazero was the
fallback engine, because it couldn’t build a feature tied to one WebAssembly
engine. It also changes the generated code, which needs a separate compiled
cache namespace. The epoch deadline, a wall clock that credits
wasi:http time back, remains the run bound.
A response cache for egress. Caching a module’s HTTP responses would cut
the thousand identical requests per sweep that the rate limit now bounds. The
invalidation semantics (TTL, Cache-Control, key correctness across headers)
add more complexity than the benefit repays when modules already run in
milliseconds. The limit refuses instead.
A metric label from the Input’s name. An Input is a fragment embedded in a
Composition, so its metadata.name is unset in practice and, when set, is
neither unique nor informative across the many Compositions one runtime handles.
The label’s signal was unreliable.
One FIFO for run slots. The first form of --max-concurrent-runs was one
queue, which put a slow module’s requests ahead of every other module’s. The
per-digest round-robin replaced it.
Reserving the whole memory limit up front. A run reserved its effective limit before starting, which capped parallelism at what a worst case would claim. The pool charges growth as it happens instead.
Open questions
- Should the egress rate limit also key by destination host? A per-host bucket would protect a third party across modules, and needs a bounded key set first. It stays per module digest until then.
- Should warm-up load from a bounded “last served” file on the cache volume? It
would make the warm set a function of history that a fresh volume lacks and an
operator can’t read from the deployment.
--warm-modulescovers cold starts and a persistent volume keeps artifacts; revisit if a real fleet shows the gap.
Three-layer authorization model
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.3 (rewritten in the present tense over the Rust workspace; supersedes the request-delivered-secrets record)
Summary
A capability is granted if and only if three parties agree, down the trust ladder (Jonasz, 2026-08-19). The module’s manifest is the request. The composition policy and the operator policy, both Cedar, are the grants, and each can only narrow what the preceding layer allows.
grant(capability) =
manifest REQUESTS it (module author, least trusted: the ask)
∧ composition policy PERMITS it (composition author: narrows this Composition)
∧ operator policy PERMITS it (operator: the ceiling, --sandbox-policy-file)
The capabilities are a private /tmp (usePrivateTmp), HTTP egress to a host,
method and path (grantEgress), an environment variable bound to a step
credential (setEnv and spendCredential), a step credential read whole from
the request (spendCredential) and the repository of a source an XR chose
(pullModule). A further action, composeResource, fences what a module
writes rather than what it may do (see “The output fence”).
Because each decision belongs to a layer, the Input has no sandbox or
policy field. It holds module, limits, config and an optional
compositionPolicy, the composition author’s raw Cedar. limits stay numeric
clamps. Non-secret module settings live in config, which the guest reads
through GetConfig.
Trust safety
Each layer is a separate Cedar PolicySet, and the layers are AND-combined, so
no layer can widen another. A greedy manifest still meets both policies, and a
composition author who writes permit(everything) has only declined to narrow:
the operator policy still caps. Each layer is evaluated apart from the others.
Defaults per layer
The layers don’t share a default: each matches its trust role.
The operator layer is default-deny. Without --sandbox-policy-file, a module
gets the default sandbox and nothing else.
An absent manifest requests nothing, so a path, http or OCI source without
a manifest runs in the default sandbox. A manifest can only refuse earlier; it
never makes a run possible. A path or http source can point at a
wasmfn.yaml by reference (manifestPath, manifestURL). Such a source is a static source,
fenced like any other, and the manifest read through it makes the same request.
See Module manifest and
Module manifests.
The composition layer has two regimes, by capability:
- Sandbox capabilities (
grantEgress,usePrivateTmp,setEnv, andspendCredentialfor a credential the module reads from its request) are scoped default-permit. A composition policy that’s absent, or has no rule for an action, doesn’t narrow it, and the operator and manifest decide. Once a rule scopes an action, that action becomes default-deny within this layer: the author has opted in to narrowing it. - Source fencing (
pullModule, andspendCredentialfor the source’s pull credential) of afrom-chosen source is default-deny. An XR’s choice of module is refused unless the composition policy permits its repository. A static source, one the Composition specifies itself, isn’t judged by this layer: it’s the composition author’s own choice.
| composition policy state | sandbox capability | from source fence | output fence |
|---|---|---|---|
| absent | permit | deny | permit |
| present, no rule for the action | permit | deny | permit |
present, a permit for it | permit if matched, else deny | permit if matched, else deny | permit if matched, else deny |
present, a forbid for it | forbid wins | forbid wins | forbid wins |
A malformed compositionPolicy is a fatal result at admission, and a
malformed operator policy is a startup or function validate error. The
runtime compiles the composition policy once per distinct text, cached by
content hash.
Environment and step credentials
A module that reads only $DATABASE_URL declares the binding in its manifest:
requires.env: [{name: DATABASE_URL, fromCredential: {name: db, key: url}}].
The runtime resolves it from the request’s step credentials, and
spendCredential for db must be permitted by both Cedar layers. The module
defines its env contract and never supplies the value. A binding lists exactly
the keys it needs. There’s no bulk import of every key of a credential.
Step credentials arrive in every RunFunctionRequest. Crossplane hands them
all to a native function, and forwarding them all made spendCredential gate
a convenience: a module denied an env binding could still read the key from
its request (issue #122, 2026-10). The runtime edits the request at the wire
level instead, and the module receives only:
- the credential each admitted
requires.envbinding reads, whole; - each credential the manifest lists under
requires.credentials, whole: a shared Secret the module picks a key from, admitted byspendCredentialunder both layers like an env binding’s.
A module without a manifest, or whose manifest doesn’t require a credential, sees none. The pull credential is never forwarded, and the runtime refuses a manifest that names it. A required credential that either layer doesn’t permit, or that the step doesn’t include, is a fatal result before the run:
… requires credential "cmdb" (requires.credentials[0]), which the operator policy (--sandbox-policy-file) does not permit
function validate --resolve lists the credentials an admitted step’s module
receives.
The output fence
composeResource (Jonasz, 2026-10-27, #174) fences what a module composes.
After the run, the runtime diffs the response’s desired state against the
request’s and judges every change (an addition, a modification or a removal,
the composite’s included) as Kind::"<group>/<Kind>" in a Group hierarchy,
with context.change, name and target. modify is judged on the entire
resource, compared by value. The composition layer is evaluated first and
whole, then the operator’s. A refusal is a fatal result naming kind, name,
change and layer.
It’s the one action that’s scoped default-permit in the operator layer too.
A module’s output was never gated before the fence, and a default-deny would
have refused every module’s output on upgrade. A runtime with no policy file,
or a layer with no rule for the action, narrows nothing. Once scoped, Cedar’s
usual rule applies, and a forbid alone permits nothing. A context.fields
set that would let a policy permit the ready flag alone is a possible additive
extension no use case has asked for yet.
Cedar schema
Both policies compile against one schema. The composition policy can use
pullModule, spendCredential, grantEgress, usePrivateTmp, setEnv and
composeResource, and the operator policy everything but pullModule, plus
requireSignature, trustSigner and dialAddress. The principal is
Request { namespace, xrKind, composition }. The entities, the contexts and
the evaluation rules are on Cedar policy.
Code shape
crates/function/src/authz.rs is the only file that imports cedar-policy. It
holds CompositionPolicy and OperatorPolicy with a permits_* method per
action, and scopes_action (the composition layer’s opt-in signal, with
scopes_compose_resource for the operator’s). crates/function/src/admission.rs
combines them: admit runs the Input’s checks, admit_requires intersects the
manifest’s request with both layers into Capabilities, and admit_compose
judges the output fence. crates/function/src/from.rs applies the from
fence. crates/function/src/sandboxenv.rs materializes the env bindings and
checks the required credentials, and crates/function/src/protowire.rs drops
the credentials that weren’t granted. function validate calls the same
functions.
Alternatives
Cedar for limits was rejected: a numeric clamp isn’t an authorization
question. The limits remain Input fields under the runtime’s ceiling flags.
A default-deny output fence in the operator layer would have matched the other capabilities. It lost because it would have broken every existing module on upgrade.
Non-goals
- Letting the manifest or the composition policy widen past the operator ceiling. The AND combination makes it impossible.
- A permissive default for the operator layer or the
fromfence. Both stay default-deny. The output fence is the recorded exception. - Fencing
results,requirementsorcontextwrites.resultsare messages,requirementsare reads the proxy already relays, and acontextwrite could take an action of the same form later without touching this design.
Trust model
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.10 (rewritten in the present tense over the Rust workspace; keyless signatures (#116) and the trusted root over TUF (#183) folded into the body as the design)
Who decides what code runs, what that code can see and reach, and what the runtime guarantees to each of them. The 2026-08-16 review found two ways an earlier model leaked: a step credential could be aimed at a host an XR author chose, and a module could read every credential its step declared. The model below closes both. The operator-facing summary is the manual’s trust model. This record keeps the reasoning.
Parties
- The operator installs the Function, sets its flags (limits, the signature keys and trusted root, the served directory, the runtime’s Docker config) and controls the pod: its volume, network policy and resource limits.
- A Composition author references a module in a pipeline step’s Input
(
module.typeplusoci,httporpath), states its digest, may specify a step credential for the pull, sets thecompositionPolicyand thelimitsof the run, and passesconfig. ThecompositionPolicyis their own Cedar layer: it fences the modules an XR may choose and may narrow sandbox capabilities. They’re trusted with the request their step receives, exactly as the author of a native function’s Composition is. - A composite resource author may, when the Composition says so
(
module.from), choose which module runs by writing a field of their XR. They’re trusted with less: they can pick code, within thecompositionPolicy’spullModulepermits, but can’t widen what it may do or spend what it may not. The runtime readscompositionPolicyandlimitsfrom the Input only. - A module author publishes signed or unsigned artifacts and declares in
the manifest what the module can’t run without (
requires), the step credentials it reads among them. Their code runs in the sandbox with the request less the step credentials it wasn’t granted. A manifest can only make a run fail earlier, never widen it. - A signer, for a keyless signature, is the OIDC identity the certificate
names. The operator decides per repository which issuer and identity may
vouch for a module (
trustSignerrules).
The Input’s schema is in the module source schema. Every capability a module gets is the AND of three layers, the manifest, the composition layer and the operator layer, described in the three-layer authorization record; the sandbox’s grants are in the sandbox record.
What pins the code
Each remote module is pinned by a digest stated in the Composition or the XR
field: the manifest digest of an OCI reference (repo[:tag]@sha256:…; a tag
alone is refused) or http.digest. The manifest states its layer’s digest,
and the registry client verifies both. Served files (path) are unpinned by
design: the operator put them on the pod. The caches key on these digests and
the runtime verifies every fetch before it stores or compiles it, so nothing
that runs can change without the Composition (or the XR field it delegated
to) changing.
With --cosign-key, only OCI modules carrying a valid signature run, and
http and path sources are refused. Verification is a precondition of
running, checked once per digest per process before the runtime looks in any
cache tier, so a runtime with a key never serves an artifact that a runtime
without one (or with a since-rotated key) left on a shared or persisted
volume.
The signature format
The signature is cosign 3’s: a Sigstore bundle (v0.3) holding a DSSE envelope
around an in-toto statement, pushed as an OCI 1.1 referrer whose subject is
the module’s manifest. The runtime lists the referrers through the
registry’s referrers API, or through the sha256-<hex> index of the
referrers tag schema where the API answers 404 (GHCR, registry:3), over its
own registry client with the pull’s credentials. Because the listings aren’t
pinned, a referrer counts only if its subject is the pinned digest, and the
runtime fetches the bundle with a size bound and verifies it against its
digest.
A module is signed when an envelope signature over the DSSE
pre-authentication encoding verifies and the statement it signs is a cosign
signature (predicateType https://sigstore.dev/cosign/sign/v1) whose
subject is the manifest digest. The runtime reads the statement only after
the signature verifies. Other attestations, such as SLSA provenance, never
count, whichever key signed them. cosign 2’s legacy sha256-<hex>.sig
signature isn’t read: a module signed only that way is unsigned to this
runtime until it’s re-signed with cosign 3.
Key-based and keyless bundles
For a key-based bundle the keys --cosign-key names are the trust root. The
bundle’s verification material (key hint and Rekor entries) isn’t consulted.
A bundle that contains a certificate is keyless (#116). sigstore-verify
takes the signing time from the bundle’s RFC 3161 timestamps and its Rekor
entry’s signed entry timestamp (a source that’s present but fails is fatal,
with no fallback to the clock). It verifies the inclusion proof and
checkpoint, then chains the certificate to a Fulcio authority at that time.
Only then is the
statement read, under the same rules as a key-based bundle’s. The signer is
then held to the operator’s trustSigner rules, in the policy file that
requires the signature: context.issuer and context.identity over the
repository hierarchy, default-deny, so a trusted root alone admits nothing.
A keyless bundle met by a runtime with no trusted root is refused by name
(both flags) and never fails the check for a key bundle beside it.
The trusted root
The root is a file read at startup (--cosign-trusted-root, the air-gapped
mode) or fetched over TUF from a well-known instance
(--cosign-trust-instance public-good|staging|github, #183) and refreshed
while serving every --cosign-tuf-refresh. The TUF root embedded in the
sigstore-trust-root crate is the trust anchor. The walk (root, timestamp,
snapshot, targets, each signed and expiring) pins trusted_root.json by
length and hashes, and the runtime caches the verified metadata under the
module cache root. --cosign-trusted-root and --cosign-trust-instance
exclude each other.
A pod that can’t fetch starts from that cache, re-verified offline from the embedded TUF root. With no cache it serves, refuses keyless signatures naming the instance and the last failure, and retries once a minute. That’s fail-closed per module, and it never becomes a crash loop that takes key-signed and unsigned modules down with it. A changed root forgets the verified-digest memo, so a signer a rotation dropped is re-verified on the module’s next request, while a failed refresh keeps the last good root. Verification never touches the network. Both root flags are opt-in: the runtime doesn’t open a connection the operator didn’t ask for.
Which repositories must be signed
An operator --sandbox-policy-file makes the rule per repository. A
requireSignature rule demands a signature for the repositories it names,
--cosign-key and the trusted root supply what verifies it, and a required
module the runtime can’t verify is refused (fail-closed). A repository
outside every rule runs unsigned. The runtime warns at startup when a key is
set but the policy requires a signature nowhere, when a trusted root is set
without a trustSigner rule, and when trustSigner rules have no root. The
choice of which repositories must be signed is operator policy, described in
the Cedar policy reference.
Credentials and the compositionPolicy fence
A step credential (module.oci.credentials) is spent on a module the
Composition itself names, or on an XR-chosen module the Composition’s
compositionPolicy explicitly permits. Otherwise a source read from the XR
may not specify one: the XR author would also pick the registry host, and a
registry that sends a Basic challenge receives the secret (the review
demonstrated it). XR-chosen modules are then pulled with the
runtime’s own Docker config (DOCKER_CONFIG, mounted by the operator, its
entries bound to their registry host) or anonymously, and an XR object with
credentials is a fatal result that explains why and points at the policy.
The compositionPolicy is a top-level Input field, never read through
module.from. It fences what an XR-chosen module may be and spend:
module:
type: OCI
from: status.module # the XR names {ref, credentials}
compositionPolicy: |
permit (principal, action == Action::"pullModule",
resource in Repository::"ghcr.io/example-org");
permit (principal, action == Action::"spendCredential", resource == Credential::"registry")
when { context.repository in Repository::"ghcr.io/example-org" };
pullModule: an XR-chosenref(orurlforHTTP) must be permitted for its normalized location (registry/repository,scheme://host/path) over the boundary-correctRepositoryhierarchy. A permitted prefix never admits a sibling namespace or adjacent host (a trailing slash is optional), and a ref or URL with dot segments is refused before matching. Anything else is a fatal result that states the policy and the ref. AcompositionPolicyis required whenevermodule.fromnames an OCI or HTTP source: an unfenced XR author could point the runtime at any host and read what its answer says.spendCredential: the step credentials an XR-chosen object may name, spent only on a refpullModuleadmits. The pull check runs first, so a permitted credential never goes to a host the Composition didn’t admit, andcontext.repositorylets a permit co-locate credential and repository. With no matching permit there is none, and the credential is a fatal result.
A static source isn’t subject to the fence: the Composition named it and is
trusted with it. crates/function/src/from.rs applies the rule, judged by
crates/function/src/authz.rs.
Credentials the module receives
The credential that pulled the module is the host’s. The runtime removes it
from the request before the guest sees it, and refuses a manifest that binds
it to an env variable or requires it whole. Of the step’s other credentials
the guest receives only those granted to it: each one its manifest names (a
requires.env binding’s credential, or one it reads whole through
requires.credentials) and both Cedar layers permit (spendCredential: the
composition layer’s scoped default-permit and the operator’s default-deny).
The runtime edits every other one out of the forwarded request at the wire
level. crates/function/src/protowire.rs drops only those map entries, so
fields newer than the vendored proto still arrive at the guest. A module with
no manifest, or one that doesn’t require a credential, sees none. A
credential the manifest requires that the step doesn’t declare is a fatal
result before the run, as an env binding’s is.
Declaring a credential for the step doesn’t hand it to every module that
runs there: spendCredential keeps a secret from a module, not
only out of its environment (issue #122, 2026-10). A granted credential can
leave only where egress is granted. Without a grant the module has no
network. With one it reaches only the hosts its manifest listed within both
Cedar layers, a grant that’s the policy layers’ alone, so nothing an XR
author writes widens where a credential may go.
The guest’s request and sandbox
The guest receives the whole RunFunctionRequest less every step credential
it wasn’t granted (the pull credential always): observed and desired state,
context, extra resources, config, and the credentials its manifest names
that both Cedar layers permit. It runs in a fresh Wasmtime store with WASI
0.3 and 0.2 and no sockets. It has two ways out: the typed log import, and
wasi:http, which the host performs only within an egress grant its manifest
requires and both Cedar layers permit (grantEgress) and refuses otherwise.
A wall-clock deadline and a memory cap over linear memories and tables bound the run, and stdout and
stderr go to the pod’s log.
The guest has no pre-opened directories and no environment unless its
manifest’s requires asks and the policy layers permit. Then it gets a
private /tmp created for the request and removed after it, and exactly the
environment variables its bindings resolve from step credentials, never the
runtime’s own. The private /tmp is the only directory a module ever gets:
host directories aren’t mountable by design (a module’s inputs come through
the request), so no operator path is readable by a module. The runtime reads
the policy layers from the Input and the operator’s file only, so an XR
author can’t widen them.
Everything the module produces leaves in the response, which the host
forwards verbatim, adding meta when the guest omitted it. A trap, exit,
timeout, memory exhaustion, bad ABI or host-side panic on its request is a
fatal result, never a crashed process, so one module can’t take the runtime
down for the Compositions sharing it (the bounds are in the
resource governance record).
Through the module they pick, an XR author can write desired state and
results into their own XR. Any module of that Composition has the same power
over that XR, unless the compositionPolicy or the operator’s policy fences
composeResource (#174). Then each change the module makes to the desired
state (a resource added, modified or removed, the XR’s own status included)
must be permitted by kind, and a refused change is a fatal result: none of
what the module composed is applied. They can’t pick a module outside the
compositionPolicy’s pullModule permits or one the runtime’s signature
policy refuses, spend a step credential no spendCredential permit names for
that repository, widen the run’s limits or the sandbox, or read another
XR’s request.
Supply chain of the runtime itself
The release image builds in a Rust stage and runs on a digest-pinned
Chainguard glibc-dynamic base, chosen over distroless for scanning clean,
with Renovate bumping the digest. It runs as nonroot, and supplychain.yml
signs and attests it. grype-scan.yml scans the latest release weekly, and
Wasmtime bumps arrive as their own Renovate PRs so a review sees the
sandbox’s own security fixes alone.
Threats considered
| threat | answer |
|---|---|
| tag moved to malicious content | tags alone are refused; a tag next to a digest is decoration |
| registry or CDN serves other bytes | manifest verified against the reference, layer against the manifest, module against the layer digest, twice (registry client, host) |
| tampered cache volume | blobs verified against their name on every read; artifacts validated by Wasmtime; signature checked before any tier |
| XR author aims a credential at their host | refused unless a compositionPolicy spendCredential permit names the credential and a pullModule permit admits the host |
| XR author widens the policy, limits or sandbox through the XR | impossible: compositionPolicy and limits are top-level Input fields, the runtime reads only module.from from the XR, and the sandbox is the manifest’s ask under both Cedar layers |
| XR author picks unsigned code | --cosign-key or a requireSignature rule refuses it |
| module reads or exfiltrates a step credential | a module sees only the step credentials its manifest names and both Cedar layers permit (spendCredential); the runtime edits every other one, the pull credential always, out of its request before the module runs. No network by default. Where both Cedar layers permit egress (grantEgress) a module reaches only the hosts, methods and paths its own manifest’s requires.egress.http rules declare, over a default block list covering loopback, link-local, private and cluster ranges (dialAddress adds more). The runtime judges each resolved address and dials the checked one, and each request leaves an audit line with the module digest. A signature rule is strongly recommended wherever egress is granted |
| module attacks the host | Wasmtime sandbox; host panics are caught into a fatal result |
| module reads or writes host files | never: host directories aren’t mountable into a module. A module gets at most its private /tmp, a fresh directory per request that’s removed afterward. The filesystem behind $TMPDIR bounds it, and Wasmtime refuses .. escapes inside the pre-open (EPERM) |
| module reads the runtime’s environment | never: the WASI environment is empty, or exactly the manifest’s requires.env bindings resolved from step credentials the request already included |
| module starves the runtime | deadline, memory cap, compile slots, bounded tiers |
SSRF through module.from (type: HTTP or OCI) | a compositionPolicy pullModule permit is required for an XR-chosen network source. The runtime fetches only from repositories the Composition permitted; permits match a normalized location over the boundary-correct hierarchy and dot segments are refused, so a prefix can’t be escaped; the runtime sends only GET requests; restrict the pod’s egress with a NetworkPolicy where internal endpoints matter |
| decompression bomb in a tar layer | extraction bounded at eight times the module size limit |
Alternatives
Forward every step credential. The runtime’s first versions, like a
native function, forwarded every step credential but the pull credential. That made
spendCredential gate a convenience: a module denied an env binding still
read the key from its request. The runtime forwards only what the module was
granted.
Verify key-based bundles through the keyless verifier. One verification
path would be simpler, but sigstore-verify refuses a public-key bundle
that has neither a transparency log entry nor a timestamp. That’s what
cosign sign --key --tlog-upload=false writes. Key bundles stay on a path
where the configured keys are the whole trust root.
WASM sandbox
- Owner: @jonasz-lasut (Jonasz Małecki)
- Reviewers: function-wasm maintainers
- Status: implemented, revision 1.9 (rewritten in the present tense over the Rust workspace; host mounts removed (Jonasz, 2026-08-16); Cedar is the one authority that enables a capability (Jonasz, 2026-08-19); the ask is stated in the module’s manifest)
How the sandbox grants a module some filesystem, network or environment access without giving up what makes it safe to run other people’s modules. The operator-facing reference is HTTP egress and Cedar policy. This record gives the reasoning.
Background
A module runs in a fresh Wasmtime store per request. By default it has the
clock and entropy, stderr and stdout inherited from the pod, the typed log
import, an epoch deadline (--module-timeout) and a memory cap
(--module-memory-limit). It has nothing else: the guest sees an empty environment, and WASI offers it neither a directory nor a socket. Everything a module needs arrives in the
RunFunctionRequest (observed and desired state, context, credentials,
required resources) and everything it produces leaves in the response. That
covers composition logic in full. It doesn’t cover a call to an external
system or a secret read from the environment. The grants below are for those.
Principles
- Capabilities are explicit grants. Nothing is ambient, and the operator denies by default.
- The policy layers are the Composition’s and the operator’s, never the
composite resource’s.
compositionPolicyis read from the Input only, not throughmodule.from: an XR author who can pick a module must not be able to widen what modules may do. - The host enforces and the guest asks. Paths and network requests are checked on the host against the grant; the module is never trusted with policy.
- Grants are bounded in bytes, requests and time. The metrics count their use and each log line has the module’s digest.
- The ABI is language-agnostic. A capability is WASI itself or a host import with a documented contract, so no guest language is second-class.
- A module may request a capability, never take it. Its manifest lists the
egress rules, the private
/tmpand the env bindings it can’t run without (module manifest); a capability is granted only where both policy layers permit it (three-layer authorization). The manifest can make a run fail earlier, never succeed.
Design
The ask is stated in the module’s manifest, not in the Input:
# wasmfn.yaml
requires:
filesystem:
privateTmp: true # a private, empty, writable /tmp for the duration of the request; nothing else is mountable
egress:
http: # egress through the host, HTTP(S) only
- host: api.example.com # exact host, or hostPattern: "*.internal.example.com" - exactly one
methods: [GET, POST] # at least one; nothing is admitted implicitly
pathPrefix: /v1/ # optional
env: # secret env: bindings to step credentials; non-secret configuration is the Input's config
- name: DATABASE_URL
fromCredential: {name: db, key: url}
The manifest loader (crates/function/src/manifest.rs, egress_rules.rs,
sandboxenv.rs) checks these formats. A rule has exactly one of host and hostPattern, host
names are bare, path prefixes are normalized and binding names are
identifiers.
The operator’s Cedar --sandbox-policy-file is the one authority that enables
a capability: usePrivateTmp for the private /tmp, setEnv (with
spendCredential per binding) for the environment, grantEgress for egress
(also the host allowlist) and dialAddress for the CIDR rules. It’s evaluated
default-deny, so with no policy file every requires entry is refused and the
module gets only the default sandbox, which is all most modules need
(Jonasz, 2026-08-19). The Input’s compositionPolicy may narrow any of
those actions (scoped default-permit: it narrows only the actions it writes
rules for). admission::admit_requires decides between load and run, and the
run gets its sandbox or a fatal result such as module … requires a private /tmp (requires.filesystem.privateTmp), but the runtime has no --sandbox-policy-file, which is required to grant sandbox capabilities.
At startup the runtime exits on a --sandbox-policy-file that doesn’t parse
(a malformed IP rule included). It also exits on an unwritable $TMPDIR when
the policy can grant a private /tmp. Both mistakes would otherwise fail
every request that hit them.
Mechanics
Filesystem
privateTmp is a per-run directory under the host’s temp dir ($TMPDIR),
named function-wasm-private-tmp-* and created before the store
(crates/engine/src/sandbox.rs). It’s pre-opened read/write at /tmp,
and removed when the run’s value drops, on every path out (success, trap,
exit, deadline, host error); a removal failure is logged, never a result. It’s
the only directory a guest is ever given, so a module can’t read an
operator-declared host path. The ABI is unchanged:
guests use their language’s file APIs, and their temp directory already
defaults to /tmp on WASI. The cost on the request path
is one mkdir and one removal. docs/abi-v2.md states the guest-visible
contract.
The directory has no byte quota of its own. Only the filesystem behind
$TMPDIR limits it. Put a tmpfs emptyDir with a sizeLimit there to cap a
request’s writes: a full tmpfs fails the guest’s write, not the runtime
(runtime flags).
Network
A module can’t open a socket. It asks through wasi:http (0.3 client.send
and 0.2 outgoing-handler), and crates/engine/src/wasihttp.rs bridges both
onto one HttpRequester grant that crates/function/src/egress.rs
provides. The policy stays in the runtime, and every guest language
with a wasi:http client has egress. The host:
- applies the grant on the first request and on every redirect hop: host or pattern, method, and a normalized path prefix (dot segments are refused);
- resolves DNS itself and judges every address a name resolves to against the
block list (
DEFAULT_BLOCKED_CIDRS: loopback, link-local, RFC 1918, CGNAT, ULA, NAT64, IPv4-compatible, unspecified, multicast, reserved); the operator’sdialAddressrules punch holes or add blocks, and an explicit block takes precedence; - dials the address it checked without re-resolving or using a proxy, and handles TLS itself;
- enforces fixed per-run budgets (
timeout10 s, 16 requests, 4 MiB of response, 5 redirects) and the process-wide rate limit (--egress-rate-limit-per-minute,--egress-rate-limit-burst); a request is also cut at the run’s remaining deadline; - counts
function_wasm_module_http_requests_total{outcome}and writes one log line per request (method, host, path without query, status or reason, bytes and duration, never headers or body).
A failure returns to the guest as internal-error with the refusal string,
never as a trap. Raw sockets stay out: they would move TLS and address policy
into the guest, where the host can’t see them.
Environment
The manifest’s requires.env bindings ({name, fromCredential: {name, key}})
become the run’s WASI environment (sandboxenv::materialize, applied in
sandbox.rs), resolved against the request’s own credentials. The runtime’s
environment is never inherited, so without a granted binding the guest’s
environ is empty. Each binding needs a setEnv and a spendCredential
permit in both Cedar layers. Literal values aren’t allowed: non-secret
configuration is the Input’s config, read through GetConfig. The pull
credential (module.oci.credentials) is refused as a source, since a module
must never see the secret that fetched it. The same rule keeps it out of
requires.credentials.
Threat model
With egress granted, a malicious or careless module can leak what it
sees in the request, step credentials included. The destination is any host
the grant allows. The
host allowlist, the block list and the per-request audit line are required
parts of the grant for that reason, and --cosign-key is recommended wherever
egress is granted. The private /tmp is per request, empty at the start and
removed at the end: one module can’t leave state for the next or read
another’s. Environment values come only from step credentials the step
already receives. A binding doesn’t widen what the request included.
The per-request deadline and memory cap apply under every grant. The
sandbox protects the runtime and the operator’s boundaries. It doesn’t protect
the data the Composition passes to the module.
Alternatives
Host mounts. A Composition could map an operator-declared host directory
into a module, read-only. The project built it, then removed it (Jonasz, 2026-08-16): a
module’s inputs come through the request, so mapping part of the pod’s
filesystem into a module is a boundary the runtime doesn’t offer. Data a
module needs (templates, CA bundles) travels through config, the context or
a required resource.
Extism’s http_request. Evaluated on 2026-08-16 with probes against
extism/go-sdk v1.7.1. Its allowed_hosts is a hostname glob checked once
before the request, on a hard-coded http.DefaultClient: it ignores the
method, the path and the resolved addresses (it dials a name that resolves to
loopback). It follows redirects to other hosts and honours HTTP_PROXY. A
refusal fails the guest call. The host function can’t be replaced without forking
the SDK, so it couldn’t meet the requirements.
A custom host import for HTTP. Under ABI v1 the guest called a JSON
wasmfn.http import. wasi:http superseded it, which guests’ own
toolchains already speak, with the same host policy in front of it (ABI v1 was
removed in 1.0.0, docs/abi-v2.md).
Enabling capabilities by flag. Per-capability --enable-sandbox-* flags
were replaced by the Cedar policy as the only authority (Jonasz, 2026-08-19,
Cedar policy).
The ask in the Input. The Input once had a sandbox block. The
manifest’s requires replaced it. A module states what it can’t run without,
and the Input’s compositionPolicy only narrows it
(three-layer authorization).
Open questions
- Should the audit line also be a structured
Resulton the response, visible incrossplane render --include-function-results, as well as a log line? - Should the per-request budgets be lowerable per Input, the way
limitsnarrows the run’s timeout and memory? Today they’re fixed defaults (egress.rs), not a policy file or an Input field. The run’slimits.timeoutstill bounds every request.