Policy engines, Cedar, Rego and Wasm
Choose how MAQPNA policies are evaluated - native rules, Cedar in a ToolPolicy, an OPA sidecar for Rego, or the native engine compiled to a WebAssembly module you embed elsewhere.
The MAQPNA gateway decides every tool call with its native policy engine, pkg/policy. Three adapters extend it. Cedar text inside a ToolPolicy votes in the same policy. An OPA sidecar can vote on every call with Rego. And the same native engine, compiled to WebAssembly, can decide calls outside the gateway: in Node, in a Go program, or in any WASI host.
| Engine | Where it runs | How you enable it | Status |
|---|---|---|---|
| Native rules | Gateway | Always on | Built in |
Cedar (spec.cedar) |
Gateway, CLI, operator | Build tag cedar; on in release binaries and images |
Built in |
| Rego (OPA) | OPA sidecar next to the gateway | Helm gateway.policyAdapters.rego |
Built in, off by default |
| Wasm module | Any WASI preview 1 host | Build bin/maqpna-policy.wasm from source |
New; not a release artifact yet |
Every combination is strictest wins, and every adapter failure is a deny.
flowchart LR
R["tool call"] --> N["native rules<br/>(per policy)"]
N --> C{"Cedar text<br/>in the policy?"}
C -- yes --> S["stricter of rule<br/>and Cedar"]
C -- no --> D["policy decision"]
S --> D
D --> O{"OPA sidecar<br/>configured?"}
O -- yes --> W["stricter of gateway<br/>and OPA"]
O -- no --> F["decision"]
W --> F
B["policies.json"] -.-> M["maqpna-policy.wasm<br/>in your host"]
M -.-> F2["same decision,<br/>no audit"]
When to use which#
- Native rules cover most needs: globs, arguments, principals,
whenconditions, schedules, taint, sinks, rate limits and approvals. Start here: Write and test policies. - Cedar when a team already writes Cedar, or a rule is easier to say in Cedar (entity hierarchies, typed comparisons).
- Rego when an existing OPA practice must keep a second, independent say over agent tool calls.
- Wasm when another system must decide exactly as the gateway does, with the full language, without calling the gateway. For example a desktop host, an edge proxy written in Go, or a test harness in Node.
To translate policies into another engine's language instead, see Policy export.
Cedar in a ToolPolicy#
A ToolPolicy can carry Cedar text in spec.cedar (up to 64 KiB). It is evaluated with the policy's rules; the stricter decision wins, and defaultAction applies only when neither a rule nor a Cedar policy decides. The schema is config/cedar/maqpna.cedarschema. The full syntax and an example are in Write and test policies.
Enable it#
The Cedar adapter is compiled in with the Go build tag cedar. Release binaries (maqpna, maqpna-gateway, maqpna-operator and the others) and release images are built with it. You only need to act if you build images yourself:
docker build -f build/Dockerfile --build-arg CMD=maqpna-gateway --build-arg GO_TAGS=cedar .
GO_TAGS=cedar is the default in build/Dockerfile. An image built with GO_TAGS= has no Cedar adapter.
Verify#
The gateway's /metrics endpoint reports:
| Metric | Meaning |
|---|---|
maqpna_gateway_cedar_available |
1 when this gateway has the Cedar adapter |
maqpna_gateway_cedar_policies_unavailable |
Policies with Cedar text this gateway cannot evaluate (they deny) |
Test a Cedar policy offline with maqpna policy test --policy <file>; the release CLI has the adapter too.
Rego through an OPA sidecar#
The gateway does not link OPA. When configured, it POSTs {"input": ...} to OPA's REST Data API on every call, after its own policies have decided, and applies the stricter result. OPA can never turn "no applicable policy" into an allow.
OPA's answer is read as follows:
| OPA result | Meaning |
|---|---|
undefined (no result) |
No contribution |
true / false |
allow / deny |
{"action", "reason", "rule", "approval"} |
Used as given (action is allow, deny or require_approval) |
{"allow": true\|false, "reason"} |
allow / deny |
| Timeout, transport error, non-2xx, malformed | deny, reason rego_unavailable |
Step 1: Write or compile the Rego#
The package must match the URL the gateway queries; the chart's default is maqpna.authz with rule decision. Write it yourself, or compile your ToolPolicy files:
maqpna policy compile --to rego policies/ --out policy.rego
Step 2: Put it in a ConfigMap#
kubectl -n maqpna-system create configmap maqpna-opa-policies --from-file=policy.rego
Step 3: Enable the sidecar#
# values-opa.yaml
gateway:
policyAdapters:
rego:
enabled: true
url: "" # default: http://127.0.0.1:<port>/v1/data/maqpna/authz/decision
timeoutMs: 250
enforcement: enforce # enforce | dryRun (audited, never enforced)
sidecar:
enabled: true
image: openpolicyagent/opa:1.4.2-static
port: 8181
policiesConfigMap: maqpna-opa-policies # *.rego files, mounted at /policies
maqpna upgrade -f values-opa.yaml
The chart adds a container named opa to the gateway pod. It runs opa run --server on 127.0.0.1 only, with telemetry off, and loads /policies. It writes policy.rego.url, timeoutMs and enforcement into gateway.json. A policy key you set yourself in gateway.config wins over these values. To use your own OPA instead of the sidecar, set sidecar.enabled: false and url to its Data API.
Verify#
kubectl -n maqpna-system get pods -l app.kubernetes.io/name=maqpna-gateway -o jsonpath='{.items[0].spec.containers[*].name}'
kubectl -n maqpna-system logs -l app.kubernetes.io/name=maqpna-gateway -c opa
The first command lists gateway and opa. The gateway's /metrics endpoint then counts evaluations in maqpna_gateway_rego_evaluations_total{outcome="ok"} and {outcome="error"}; an error is a call that failed closed.
The policy engine as a Wasm module#
maqpna-policy.wasm is pkg/policy compiled for WASI preview 1 (GOOS=wasip1 GOARCH=wasm, -buildmode=c-shared). It is the gateway's own code, not a re-implementation. It loads a policies.json bundle and returns the gateway's decision.
What it decides, and what it refuses#
It evaluates the whole native language: matchers, principals, scopes, when conditions (regex and CIDR included), schedules with IANA time zones (the zone database is embedded), session taints, tool labels and sinks, substrate matches, rate limits, approval requirements and dry-run policies.
It refuses at load time what it cannot run, and never skips anything silently:
- Cedar text (
spec.cedar): the module has no Cedar adapter. Load returns status2and names the policies. - The OPA sidecar is gateway configuration, not part of a bundle, and its HTTP client is left out of WASI builds.
A refused or invalid bundle leaves the previous one active.
Step 1: Build the module#
The module is not in the release downloads yet. Build it from a source checkout (maqpna.com/github) with Go:
make policy-wasm
This writes bin/maqpna-policy.wasm and bin/maqpna-policy.wasm.sha256. The target runs CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -trimpath -buildvcs=false -ldflags "-s -w" -o bin/maqpna-policy.wasm ./cmd/maqpna-policy-wasm. The module is about 4.9 MB (1.4 MB gzipped).
Step 2: Get a bundle#
The module reads the same input as maqpna policy compile: {"policies": [...]}, an array of policies, or one policy, in the JSON form the gateway reads from policies.json. It parses strictly (unknown fields are errors) and validates as the gateway does. examples/eval/policies.json is a ready example.
Step 3: Evaluate a call#
examples/policy-wasm/eval.mjs needs Node 20 or later and no dependencies:
node examples/policy-wasm/eval.mjs bin/maqpna-policy.wasm examples/eval/policies.json \
'{"request":{"namespace":"team-a","agent":"payer","server":"payments","tool":"create_transfer","args":{"amount":25000}}}'
pkg/policy/wasmhost embeds the module with wazero (pure Go, no cgo). It is behind the build tag policywasm, so wazero is never linked into the gateway or the CLI. Build your program with -tags policywasm.
mod, err := wasmhost.Compile(ctx, wasmBytes) // options: wasmhost.WithInterpreter(), wasmhost.WithClock(now)
eng, err := mod.Instantiate(ctx) // one instance, its own rate-limit windows
err = eng.Load(ctx, policiesJSON) // errors.Is(err, wasmhost.ErrUnsupported) for Cedar
d, err := eng.Evaluate(ctx, policy.Request{...}) // or eng.EvaluateJSON(ctx, input) for raw decision JSON
The input is the document the gateway sends its OPA sidecar:
{"request": {"namespace": "team-a", "agent": "payer", "user": "[email protected]",
"server": "payments", "tool": "create_transfer", "args": {"amount": 25000},
"groups": ["treasury"], "sessionTaints": ["untrusted-input"],
"at": "2026-10-16T12:00:00Z"}}
The output is the gateway's decision, as the gateway's encoding/json writes it:
{"action":"require_approval","policy":"payments-guard","rule":"big-transfer","reason":"four-eyes for large transfers","approval":{"minApprovers":2}}
An input the module cannot decide (malformed JSON, no request) gets {"action":"deny","reason":"wasm_invalid_input","error":"..."}, so a host that reads only action fails closed. With no bundle loaded, every call is {"action":"deny","reason":"no_applicable_policy"}.
The ABI (version 1)#
For your own host. The module exports memory, _initialize (call it once after instantiating; it is a reactor) and:
| Export | Signature | Meaning |
|---|---|---|
maqpna_abi_version |
() -> i32 |
1 |
maqpna_alloc |
(size) -> ptr |
A buffer of 1 byte to 16 MiB; 0 means refused |
maqpna_free |
(ptr, size) |
Releases a buffer or a result |
maqpna_load |
(ptr, len) -> status |
0 loaded, 1 invalid, 2 unsupported (Cedar), 3 bad argument |
maqpna_error |
() -> packed |
The last load error as UTF-8, or 0 |
maqpna_eval |
(ptr, len) -> packed |
One decision as JSON (input up to 4 MiB) |
A packed result is an i64 holding ptr << 32 | len; free it with maqpna_free(ptr, len). Linear memory can grow during any call, so a JavaScript host re-reads memory.buffer after each one.
Verify#
Run the same call through the native engine and compare:
maqpna policy test --policy examples/eval/policies.json --ns team-a --agent payer \
--server payments --tool create_transfer --args '{"amount":25000}'
The decision must match the module's output field for field.
Limits of the module#
- Not audited. It decides but records nothing. Approvals, the audit ledger, budgets and taint stay in the gateway. Log the input and decision yourself if you need evidence.
- Rate limits are per instance. Sliding windows live in the instance; two instances, or a module and the gateway, do not share counts.
- Single-threaded. Call one export at a time per instance.
wasmhost.Engineserialises calls; instantiate more for parallelism (about 4 ms each). - Slower than native. About 75–170 µs per decision with wazero's compiler, against 5–16 µs native, measured on an Apple M4.
- Time. Schedules use
request.at, elsetime, else the host's WASI clock. In Go,wasmhostpasses the system clock unless you setWithClock. - Decision only. No explain trace, no dry-run detail and no tool pins through the ABI.
Planned#
- Publishing
maqpna-policy.wasmas a signed release artifact. - A proxy-wasm filter for Envoy and agentgateway. The current module does not implement the proxy-wasm ABI, so a proxy cannot load it as a filter. Until then, put the gateway in the MCP path, or embed the compiled CEL.
- Cedar inside the module.
- Browser and Rust (wasmtime) hosts are expected to work through a WASI preview 1 shim, but are untested.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
Calls denied with cedar_unavailable |
The gateway image was built without -tags cedar |
Use the release image, or rebuild with GO_TAGS=cedar |
Calls denied with rego_unavailable |
OPA timed out, is down, or returned an error | Check kubectl logs ... -c opa; raise timeoutMs; check the URL path contains /v1/data |
| OPA has no effect | The result is undefined (wrong package or rule), or no native policy applied | Query the URL with curl from the pod; the package must match the URL path |
gateway.policyAdapters.rego.enforcement must be enforce or dryRun |
Invalid Helm value | Set enforce or dryRun |
maqpna_load returns 2 |
The bundle has Cedar text | Remove cedar from those policies for the module |
maqpna_load returns 1 |
Parse or validation error | Read maqpna_error(); run maqpna policy lint on the source |
maqpna-policy-wasm is a WebAssembly module on run |
You built it for your OS, not WASI | Build with make policy-wasm |