Typed WASM bindings for Dogwood, the Cedar-derived governance language for AI agents (temporal conditions + information providers).
Two shapes are exposed:
- Batch operations (
validate,lower,replay,explain, schema checks) — source strings in, typed report objects out. For policy authoring, CI, and tooling. - Live authorizer (
DogwoodAuthorizer) — lower once, feed events as they happen, get a decision per event with temporal history accumulating across calls. For agent gateways, MCP proxies, and anything deciding in real time.
The bindings wrap the same public surface of dogwood-language that the CLI drives. ops.rs and error.rs are copies of dogwood-cli's, plus wasm-only additions (Tsify derives, and blocks marked // wasm-only). CI checks the copies against the pinned upstream release on every push, and against upstream main weekly — see Syncing with dogwood-cli.
dogwood-wasm/
├── src/
│ ├── lib.rs # wasm-bindgen surface (batch ops + error projection)
│ ├── live.rs # stateful DogwoodAuthorizer class
│ ├── ops.rs # frontend
8000
operations (copied from dogwood-cli)
│ ├── error.rs # miette → structured diagnostic (copied from dogwood-cli)
│ ├── fail_closed.rs # deny-on-evaluation-error rule shared by replay and live
│ └── providers.rs # wasm-specific guard on provider declarations
├── scripts/
│ ├── check-cli-sync.sh # diff ops.rs/error.rs against upstream dogwood-cli
│ ├── prepare-release.sh # pre-publish checks on pkg/ (run by the release workflow)
│ └── third-party-notices.sh # THIRD-PARTY-NOTICES for the npm package (cargo-about)
├── pkg/ # generated by `npm run build` (git-ignored)
├── test.cjs # batch-operation smoke tests
├── test-live.cjs # live-authorizer semantics (parity with replay)
├── test-coverage.cjs # full surface coverage (every function, field, error path)
├── example.ts # strict-mode type-check of the generated .d.ts
├── package.json # build / test / typecheck scripts
├── RELEASING.md # npm release procedure
└── .github/workflows/ # ci (build, tests, audits), dogwood-cli sync, release
Requires Rust ≥ 1.85 (edition 2024), the wasm target, wasm-pack, and Node ≥ 16.
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
git clone https://github.com/dogwood-policy/dogwood-wasm.git
cd dogwood-wasm
npm run build # → pkg/ (Node/CommonJS target, uses wasm-pack)
npm install && npm run verify # runtime tests + tsc --strict typecheckOther targets: npm run build:bundler (Vite/webpack, ESM) and npm run build:web (no bundler).
npm run build:dev is an unoptimized debug build with the dev feature, which logs Rust panic messages to console.error. Release builds leave the panic hook out, because a panic message can contain policy or event data.
All take source strings and return typed report objects. Fatal errors are thrown as DogwoodError; non-fatal findings come back in the report.
| Function | Purpose | Returns |
|---|---|---|
checkParse(src, …) |
parse + macro-expand | CheckParseReport |
validate(src, actionSchema, …) |
lower + type-check | ValidateReport |
lower(src, actionSchema, …) |
emit Cedar artifacts | LowerArtifacts |
replay(src, actionSchema, log, …) |
lower + replay a .log trace |
ReplayReport |
explain(src, actionSchema?, …) |
informalize rules into semi-English | ExplainReport |
checkActionSchema(src) |
validate a .cedarschema |
SchemaCheckReport |
checkEventSchema(src) |
validate a .dwschema |
SchemaCheckReport |
checkProviders(json) |
validate a providers.json |
SchemaCheckReport |
mcpToCedarSchema(json) |
MCP tools/list → Cedar action schema |
string |
The trailing eventSchema? / providers? / macros? arguments are optional service-schema overrides. Omit for defaults.
const dw = require("./pkg/dogwood_wasm.js");
const actionSchema = `namespace Drupe {
entity User; entity Gateway;
action "Read" appliesTo {
principal: [User], resource: [Gateway],
context: { input: { document: String, user: String } }
};
}`;
const policy = `permit(principal, action == Drupe::Action::"Read", resource)
when { context.input.document like "*report*" };`;
const report = dw.validate(policy, actionSchema);
console.log(report.passed); // trueconst auth = new dw.DogwoodAuthorizer(policy, actionSchema);
const decision = auth.isAuthorized({
action: "Drupe::Action::Read",
kind: "request",
timestamp: Math.floor(Date.now() / 1000),
principal: { type: "Drupe::User", id: "alice" },
resource: { type: "Drupe::Gateway", id: "gw1" },
logged: { input: { document: "quarterly report", user: "alice" } },
context: { input: { document: "quarterly report", user: "alice" } },
entities: [{ type: "Drupe::User", id: "alice", attrs: { dept: "eng" } }],
});
if (!decision.allowed) throw new Error("denied");History-only events (anything other than kind: "request" by default) return undefined — they update temporal state but yield no verdict.
- Timestamps are seconds, not milliseconds. A
within 1hwindow is 3600. PassingDate.now()raw inflates every window by 1000×. loggedandcontextare distinct.loggedis the durable temporal record;contextis the ephemeral Cedar request bag. A field both need (typicallyinput) must appear in both.- Entity references are structured (
{ type, id }), never string literals. The id is escaped exactly once — no double-escaping footgun. - Any evaluation error is a deny. Cedar skips a rule whose condition errors (for example, a context argument of the wrong type, or a missing one) and decides from the remaining rules, so an errored
forbidcould otherwise let apermitthrough. The authorizer returnsallowed: falsewhenevererrorsis non-empty, andreplayapplies the same rule to every verdict, so a deny with errors is a fail-closed evaluation failure, not "policy said no."
The authorizer rejects (without observing into history):
| Rejected | Why |
|---|---|
| Unknown action | Would silently deny with empty errors, indistinguishable from "policy said no" |
| Unknown kind | Would return undefined forever — nothing is ever authorized |
| Backwards timestamp | Would silently produce wrong temporal answers |
Interior :: in action id |
Would resolve to nothing via the :: split |
| Oversized event (see below) | Would overflow the wasm stack and leave the module unusable |
Event data usually comes from a model's tool arguments, so its size and shape are untrusted. Each limit is checked before any recursive code sees the event, and an event over a limit throws a DogwoodError rather than being decided:
| Limit | Value |
|---|---|
Nesting depth of a value, counting each context / logged group (context.input) or entity attribute as level 0 |
128 levels, the same limit replay applies to .log values |
| Values in one event (scalars, arrays and objects at any depth) | 100,000 |
| Total length of all strings in one event, object keys included | 16 Mi characters (string.length units) |
mcpToCedarSchema rejects a manifest over 4 MiB or nested deeper than 128 levels.
A thrown isAuthorized call means no decision. The host must treat it as a deny.
A WebAssembly.RuntimeError (for example out-of-memory) is not a DogwoodError. It means the wasm module itself is in an undefined state, not just one instance: every later call into the module, on any DogwoodAuthorizer, may fail or trap. Deny the current request, stop using the module, and restart the process or worker that loaded it. The input limits above exist so that event data cannot cause a trap.
Introspection getters (actions, eventKinds, decisionKinds, lastTimestamp) let a host verify its wiring at startup.
DogwoodAuthorizer owns wasm-heap memory that GC does not reclaim. Free it explicitly:
const auth = new DogwoodAuthorizer(policy, actionSchema);
try { /* … */ } finally { auth.free(); }
// or with explicit resource management (TS ≥ 5.2):
using auth = new DogwoodAuthorizer(policy, actionSchema);reset() drops accumulated history and re-lowers the same sources. decisionCount tracks decisions returned.
Temporal history is never pruned: every event is kept until reset() or free() (on the order of 1.6 KB per event). Use one instance per session or per principal, and reset() or replace it when the session ends, so history does not grow without limit.
Fatal failures throw a DogwoodError with structured .diagnostic:
import { validate, type DogwoodError } from "./pkg/dogwood_wasm.js";
try {
validate("permit(", actionSchema);
} catch (e) {
const err = e as DogwoodError;
// err.diagnostic: { message, severity, code?, labels[], help?, related? }
}Byte offsets in labels index the source string you passed in.
Rhai providers work — the interpreter compiles to wasm. But the script must be inline (implementation.script). A scriptFile reference is rejected up front with an actionable error, because wasm32-unknown-unknown has no filesystem and an unresolvable reference would silently fail closed at authorize time.
npm run verify runs 89 assertions across three suites plus tsc --strict.
| Suite | Coverage |
|---|---|
test.cjs |
Smoke: does each batch operation work? |
test-live.cjs |
Replay-parity: does the live authorizer produce identical verdicts to replay on the same scenario? |
test-coverage.cjs |
Every exported function, field, optional argument, error path, and value-conversion branch |
scripts/check-cli-sync.sh [REF] strips the wasm-only additions from src/ops.rs and src/error.rs and diffs the rest against dogwood-cli/src/ at REF in dogwood-policy/dogwood. REF defaults to PINNED_REF in the script, which must match the dogwood-language version in Cargo.lock (the script checks this).
To take a new upstream release:
- Bump
dogwood-languageinCargo.toml(cargo update -p dogwood-language) andPINNED_REFin the script. - Run
scripts/check-cli-sync.shand apply the upstream changes it shows, keeping theTsifyderives and the// wasm-onlyblocks. - Expose any new operation in
src/lib.rs, thennpm run build && npm run verify.
- Dogwood is a reference interpreter, not for production; these bindings inherit that.
- The
netfeature (HTTP providers) is off — cannot work in wasm. - Temporal history is never pruned. Growth is ~1.6 KB/event with no plateau. Long-lived instances must
reset()periodically or cap their lifetime. - The live authorizer's input checks do not apply to
replay(which uses the frontend's internal parser). A bare action id or backwards timestamps in a.logtrace pass through unremarked. - Module size is ~8.7 MB (~2.6 MB gzipped). Cedar, Rhai, and the parser dominate.
See CONTRIBUTING.md and CODE_OF_CONDUCT.md. To report a security issue, follow SECURITY.md rather than opening a public issue. Releases are published to npm by CI only; see RELEASING.md. Licensed under Apache-2.0; see NOTICE.