This is a more trim version of (DRAFT) Intermesh WASM Integration Plan -- v4.1 , the intent is to be more focused on feasibility rather than future scaffolding that does not offer immediate functionality/impact.
Intermesh WASM Integration Plan -- v5.6
Intermesh knows who is connecting -- mTLS proves that. What it doesn't know is whether that peer should reach this particular target. Today, any authenticated peer can CONNECT to any service on a node. That's the gap.
This plan adds one thing: a sandboxed policy check at the moment Intermesh already has all the information it needs -- peer identity proven, target parsed, relay not yet started. A WASM module looks at the connection metadata, returns Pass or Reject, and gets out of the way. The trust engine, the gossip layer, and the relay path are untouched.
One module. Two verdicts. ~355 lines of new code. Everything else is deferred until this proves its worth.
How it works
Intermesh's proxy has two connection paths:
- Inbound (
run_external_acceptor -> handle_from_external): accepts mTLS connections from remote peers, parses the HTTP CONNECT target into (Name, port), relays to localhost
- Outbound (
run_local_acceptor -> forward_external): intercepts nftables-redirected local traffic, resolves VIP to mesh name, tunnels via HTTP CONNECT over mTLS
The WASM hook sits on the inbound path, between target parsing and the relay. The peer IMID is already known from the mTLS handshake. The target name and port are already parsed. The module sees these facts, makes a decision, and returns. If there's no module configured, nothing changes -- the hook is a zero-cost no-op.
What v1 delivers
Per-target access control. "The database team's IMID can reach db.prod.mesh:5432 but not admin.prod.mesh:22." Policy that varies per deployment, changes faster than release cycles, and doesn't require recompiling Intermesh.
Stateful decisions. The module's init() runs once at load time. The instance persists across connections, so counters, rate-limit windows, and peer tracking survive within a daemon lifetime.
Audit trail. Every verdict carries an optional context string -- human-readable, logged by the host, never parsed. Operators get rejection reasons without building a telemetry pipeline.
What v1 deliberately leaves out
v1 is scoped to prove one thing: that WASM policy at the connection level is worth the complexity cost. Everything below is preserved in the v2+ roadmap but excluded from this implementation:
- Connection redirection (Update verdict -- exists in ABI, gated off)
- Passive observation of outbound connections
- In-module telemetry beyond context strings
- Multiple modules or composition
- Payload inspection or L7 rewriting
- Module distribution, signing, or hot-reload
- Candidate selection for IMID/IP resolution
- Store pooling for concurrency
Boundaries
- This is a policy hook, not a plugin system. The module decides Pass or Reject on connection metadata. It cannot see tunnel bytes, mutate trust state, or call out to the network.
- Modules load from local filesystem at daemon startup. No gossip distribution, no runtime replacement.
- The WASM runtime is feature-gated. Without
--features wasm, the binary is identical to today.
1. Why WASM, not just Rust
handle_from_external accepts any mTLS-authenticated peer for any CONNECT target. A hospital network, a multi-tenant platform, a compliance-constrained deployment -- they all need per-target policy, and that policy changes faster than release cycles.
Compiled-in Rust would work for one deployment. WASM lets operators ship policy as a file.
The plan hedges this bet: Phases 1-3 deliver a working ConnectionPolicy trait with pure Rust implementations and zero new dependencies. If WASM proves unjustified, the trait stays and the wasmtime dependency never lands.
2. Verdict model
The WASM module returns a verdict. The host acts on it.
v1 verdicts
enum Verdict {
Pass,
Reject,
}
- Pass -- no objection, proceed with default behavior.
- Reject -- deny this connection. Host responds with the
reject-status-code if provided, otherwise 403. Only 403, 429, and 503 are valid; any other value is treated as 403.
Unsupported verdict: Update
The Update variant exists in the WIT definition to avoid a breaking ABI change when v2 enables it.
- In v1, returning
Update is treated as a WASM failure -- the host applies the configured failure mode (fail-closed: Reject 403; fail-open: Pass)
- A
warn-level log is emitted once per module (not per connection) to avoid log flooding
- If
updated-context is present in the verdict result, it is ignored
- Module authors should not return
Update in v1
Verdict result
record verdict-result {
verdict: verdict,
context: option<string>,
reject-status-code: option<u16>,
updated-context: option<connection-context>,
}
context -- optional human-readable string (max 256 bytes, hard-truncated). Logged for all verdicts. Never parsed or branched on by the host.
reject-status-code -- only read when verdict is Reject. Must be 403, 429, or 503. Default 403.
updated-context -- reserved for Update verdict (v2). Ignored in v1.
3. WIT interface
Communication between host and WASM uses the WIT (WebAssembly Interface Types) component model, not direct memory read/write. WIT is to WASM components what protobuf is to gRPC -- a typed interface definition language that generates bindings for both host and guest. The wasmtime::component::bindgen! macro generates Rust types from .wit files at compile time, similar to how tonic-build generates from .proto files.
Module metadata
package intermesh:module@0.1.0;
interface metadata {
name: func() -> string;
version: func() -> string;
direction: func() -> traffic-direction;
failure-mode: func() -> module-failure-mode;
}
enum traffic-direction {
inbound,
outbound,
both,
}
enum module-failure-mode {
fail-open,
fail-closed,
}
- name: short identifier, e.g.,
"connect-authz". Logged at startup.
- version: semver string, e.g.,
"0.1.0". Logged at startup for debugging.
- direction: which connection path this module applies to. v1 only invokes modules with
inbound or both (in v1, both behaves the same as inbound because only inbound hooks exist). Kept in v1 so we don't need a WIT-breaking change when outbound hooks are added.
- failure-mode: the module's declared preference for
fail-closed or fail-open. This is advisory only -- the host configuration determines actual enforcement (see Configuration section). Logged at startup so operators can see what the module expects.
Hook interface
interface hook {
enum verdict {
pass,
reject,
update,
}
record connection-context {
peer-imid: string,
target-name: string,
target-port: u16,
subsystem: string,
}
record verdict-result {
verdict: verdict,
context: option<string>,
reject-status-code: option<u16>,
updated-context: option<connection-context>,
}
init: func();
evaluate: func(ctx: connection-context) -> verdict-result;
}
peer-imid is the only identity input. Names are derived views that can differ per node's constraint set; IMID is stable cryptographic identity. subsystem is always "proxy" in v1.
init() is called once at load time. The wasmtime Store persists across calls, so state set during init() is available in subsequent evaluate() calls.
update exists in the verdict enum for ABI forward-compatibility but is treated as a WASM failure in v1 (see Verdict model section).
Host-provided functions
interface host {
log: func(message: string);
}
log is the only host-call in v1. It writes to the host's tracing system at debug level.
- Per-message cap: each message truncated at
max_log_bytes (default 1024); one debug note on first truncation per invocation
- Per-evaluate cap: max
max_log_calls (default 3) calls; excess silently dropped; one debug note on first drop per invocation
- Module continues executing normally in both cases
- Same caps apply during
init()
World definition
world intermesh-module {
import host;
export metadata;
export hook;
}
What is excluded from v1 ABI
- Payload/packet bytes
- Network egress
- Filesystem access
- Inter-module communication
- Trust engine queries or mutation (by construction: no host-call exists)
4. Module lifecycle and loading
v1 supports exactly one inbound policy module for the CONNECT authorization hook. The module is specified by explicit path in config, not by directory scan.
Loading sequence
- Read
path from [modules.connect_authz] config
- Instantiate the
.wasm component with wasmtime (with memory cap applied)
- Call
metadata::name(), metadata::version(), metadata::direction(), metadata::failure_mode()
- Log module metadata (name, version, direction, declared failure-mode preference)
- Call
hook::init() under the same resource limits as evaluate() (fuel + memory + log caps)
- If
init() fails: daemon startup fails (fail-closed -- a configured module that can't initialize is a deployment error)
- If
direction is outbound (and does not include inbound): daemon startup fails with a clear error ("configured module does not apply to any active hook in v1"). This surfaces misconfig early rather than silently falling back to AllowAll.
No hot-reload. Module changes require daemon restart.
Configuration
[modules.connect_authz]
path = "~/.intermesh/modules/connect-authz.wasm"
failure_mode = "fail-closed" # "fail-closed" (default) | "fail-open"
fuel_limit = 100_000 # max instructions per evaluate() and init()
max_memory_bytes = 33_554_432 # 32MB hard cap on module memory
max_log_calls = 3 # max host::log() calls per evaluate()
max_log_bytes = 1024 # max bytes per host::log() message
Loading rules:
- If
[modules.connect_authz] is absent or path is unset: no module loads. Behavior identical to today.
path points to a single .wasm component file. No directory scanning, no duplicate detection, no sorting ambiguity.
- The module must export
metadata and hook interfaces and must successfully run hook::init().
Failure mode is host-owned:
- Module's
metadata::failure_mode() is advisory only -- logged at startup for operator visibility
- Host configuration (
failure_mode in [modules.connect_authz]) determines actual enforcement
- Default:
fail-closed; operator must explicitly opt into fail-open
- Prevents a buggy/malicious module from declaring
fail-open and deliberately trapping to bypass policy
Failure handling
v1 uses a single deterministic execution budget (fuel) plus hard resource limits enforced by the host. No wall-clock timeout -- fuel is a deterministic instruction budget (wall-clock time to burn that fuel still depends on CPU, but the instruction count is fixed).
Resource limits (host-enforced, applied to both init() and evaluate()):
- Fuel: default 100,000 instructions. Reset fresh for each
evaluate() call (no carry-over).
- Memory: hard cap on module instance memory (default 32MB). Enforced via wasmtime's
ResourceLimiter.
host::log() cap: max max_log_calls (default 3) calls per evaluate(), each truncated at max_log_bytes (default 1024) bytes. One debug note on first truncation per invocation; one debug note on first call drop per invocation.
What counts as a WASM failure:
- Fuel exhausted
- Trap (panic/abort)
- Memory limit exceeded
- Invalid/ill-typed return value
- Returning
Update verdict (unsupported in v1)
(host::log() cap exceeded is not a failure -- calls are silently dropped, execution continues.)
Outcome on failure (host-owned policy):
| Failure |
fail-closed (default) |
fail-open |
| Any WASM failure |
Reject (403) |
Pass (skip module) |
| 5 consecutive failures |
Disable module, log |
Disable module, log |
Failure outcome rules:
- For fail-closed: rejection status is always 403 (even if the module attempted to return a different status before failing)
- For fail-open: the failure is logged and the module is skipped for that evaluation
Consecutive failure tracking:
- After 5 consecutive failures, the module is disabled for the remainder of the daemon lifetime
- Disabling is logged at
warn once
- A successful evaluation resets the counter
- A
Reject verdict is a normal policy decision, not a failure -- both Pass and Reject reset the consecutive failure counter
Disabled module behavior:
- When disabled, the policy behaves as if
evaluate() failed: fail-closed returns Reject 403; fail-open returns Pass
"No module" fast path
If no module is loaded (no [modules.connect_authz] config), evaluate() returns Pass immediately with no context allocation. Zero overhead on the CONNECT hot path when WASM is not in use.
5. Hook point: CONNECT authorization
Remote peer
│
▼
mTLS handshake (IntermeshVerifier + StrictPolicy)
│
▼ peer IMID known
│
HTTP CONNECT request received
│
▼
parse_connect_target → (Name, port)
│
▼
┌─────────────────────────────┐
│ WASM policy hook │
│ evaluate(peer_imid, name, │
│ port, "proxy") │
│ │
│ Pass → continue │
│ Reject → 403/429/503 │
└─────────────────────────────┘
│
▼ Pass
upgrade::on(req)
│
▼
copy_bidirectional (relay)
- Where:
handle_from_external in src/modules/proxy/mod.rs at line 198, after parse_connect_target succeeds and returns (Name, port), but before upgrade::on(req) and the relay spawn
- Today: any mTLS-authenticated peer can CONNECT to any localhost port; the peer IMID is known (from
tls_stream.connect_info().peer) but no per-target policy is applied
- With hook: build a
connection-context from the peer IMID and parsed target, call evaluate() on the loaded inbound module
- Pass: proceed to upgrade/relay as today
- Reject: respond with HTTP status code (default 403), do not upgrade, close connection
- Update: treat as WASM failure (apply host failure mode)
- Inputs: peer IMID (string), target name, target port, subsystem=
"proxy"
- Fallback on WASM failure: determined by host configuration (
failure_mode in [modules.connect_authz]); default: fail-closed
- No module loaded: hook is a no-op -- returns Pass immediately with no allocation; behavior identical to today
6. Code isolation: the wasmrunner module
All WASM-related code lives in src/wasmrunner/, not under src/modules/. This is deliberate: modules/proxy and modules/adhoc are Intermesh feature modules that implement mesh functionality. wasmrunner is infrastructure -- it provides a policy evaluation service that the proxy module consumes via a trait. It sits alongside connect.rs, verifier.rs, and gossip.rs as a peer utility.
src/
├── wasmrunner/
│ ├── mod.rs # Public API: ConnectionPolicy trait, WasmPolicy impl
│ ├── loader.rs # Load module from path, read metadata, call init()
│ └── types.rs # Verdict, ConnectionContext, VerdictResult, AllowAll
├── modules/proxy/mod.rs # Calls wasmrunner via ConnectionPolicy trait
├── daemon.rs # Creates WasmPolicy at startup, passes to proxy
└── ... # Everything else unchanged
With single-path loading and one module per hook, loader.rs is ~40 lines. There is no registry. If config is absent, the daemon uses AllowAll; if configured, load_module returns exactly one LoadedModule or fails startup. If multi-module support is added in v2, a registry.rs can be extracted then.
The proxy module never imports wasmtime. It calls connection_policy.evaluate(ctx) and gets back a Verdict. Whether that policy is a compiled-in Rust struct or a WASM module is invisible to the caller.
Feature-gated:
[features]
default = []
wasm = ["wasmtime"]
[dependencies]
wasmtime = { version = "...", features = ["component-model"], optional = true }
Without --features wasm, the wasmrunner module provides AllowAll and adds zero dependencies.
7. Implementation sequence
Each phase is a single PR. Every phase must pass cargo local and all existing tests.
Phase 1: Define the ConnectionPolicy trait
Goal: Introduce the trait that will be the boundary between proxy and policy logic. No behavioral change.
What changes:
- New:
src/wasmrunner/types.rs -- ConnectionPolicy trait, Verdict enum (Pass, Reject), ConnectionContext struct, VerdictResult struct, AllowAll default implementation
- New:
src/wasmrunner/mod.rs -- re-exports the public API
Tests: Unit tests for AllowAll returning Pass for any input.
Phase 2: Wire ConnectionPolicy into the proxy
Goal: handle_from_external calls the policy after parsing the CONNECT target. With AllowAll, behavior is identical to today.
What changes:
proxy::Handle gains a policy: Arc<dyn ConnectionPolicy> field. Handle is Clone (it carries Arc references to shared state), so the policy is shared across all connection-handling tasks.
handle_from_external calls policy.evaluate() between parse_connect_target (line 197) and upgrade::on(req) / relay spawn (line 199)
daemon.rs passes AllowAll to proxy::Handle::new()
Tests: Existing e2e tests pass unchanged (proving AllowAll preserves behavior).
Phase 3: Test the policy trait with Rust implementations
Goal: Prove the hook point works with real policy logic, no WASM yet.
What changes:
- Add
DenyAll and AllowList test implementations in src/wasmrunner/types.rs (behind #[cfg(test)])
- Tests that exercise the full proxy path with each policy
Tests:
AllowAll: connection proceeds (baseline)
DenyAll: connection gets 403
AllowList { targets: ["db.test.mesh:5432"] }: selective gating
- Verdict context string appears in logs
Phase 4: Add wasmtime and create the engine
Goal: Introduce the wasmtime dependency behind a feature gate. Initialize the engine. No module loading yet.
What changes:
Cargo.toml: add wasmtime as optional dependency with component-model feature
src/wasmrunner/mod.rs: conditionally create wasmtime::Engine when wasm feature is enabled
Tests: Engine initializes successfully. Compiles cleanly with and without --features wasm.
Phase 5: Write the WIT interface
Goal: Define the contract between host and WASM modules.
What changes:
- New:
wit/intermesh-module.wit -- full WIT definition (metadata, hook with init/evaluate, host with log)
src/wasmrunner/mod.rs: add wasmtime::component::bindgen! macro to generate Rust bindings
Tests: Bindings compile. Generated types align with the Verdict/ConnectionContext types from Phase 1.
Phase 6: Module loader
Goal: Load a single .wasm file from the configured path, read metadata, call init() under resource limits.
What changes:
- New:
src/wasmrunner/loader.rs -- load_module(path, limits) -> Result<LoadedModule> that reads the file, instantiates the component with memory cap, reads metadata, and calls hook::init() under fuel + memory + log caps. If direction does not include inbound, returns an error (configured module does not apply to any active hook in v1).
Tests:
- Valid
.wasm component: metadata read correctly, init() called
- Corrupt/invalid file: returns error
- Missing metadata exports: returns error
- init() trap: returns error (module not loaded)
- init() exceeds fuel: returns error
- Outbound-only module: returns error, startup fails
Phase 7: Daemon wiring
Goal: Wire module loading into daemon startup. Pass the loaded module (or AllowAll) to the proxy.
What changes:
src/wasmrunner/mod.rs: WasmPolicy struct that implements ConnectionPolicy. The wasmtime Store is not Send + Sync, but handle_from_external runs in tokio::spawn tasks concurrently. WasmPolicy wraps the Store in a Mutex to serialize access (acceptable for v1; see risk register).
src/daemon.rs: if [modules.connect_authz] config has path, call load_module(), create WasmPolicy, pass to proxy Handle. Otherwise pass AllowAll. Read host-enforced limits from config.
src/daemon_state.rs: add modules config fields (path, failure_mode, fuel_limit, max_memory_bytes, max_log_calls, max_log_bytes)
Tests:
- No modules config: daemon starts normally, AllowAll used
- Valid path with one module: module loaded, logged with metadata
- Invalid/missing path: daemon startup fails with clear error
- init() failure: daemon startup fails
Phase 8: Active hook execution with failure handling
Goal: WasmPolicy.evaluate() calls the WASM module with resource limits, handles failures per host-configured failure-mode, tracks consecutive failures, disables on fault. Implements host::log import.
What changes:
src/wasmrunner/mod.rs: implement evaluate() -- reset fuel fresh each call, apply memory cap via wasmtime ResourceLimiter, call hook::evaluate(), check result. On failure (including Update verdict): use host-configured failure_mode (fail-closed -> Reject 403, fail-open -> Pass). Track consecutive failures; disable after 5.
src/wasmrunner/mod.rs: implement the host::log import in the wasmtime linker. Track call count per evaluate() invocation; cap at max_log_calls (default 3). Each message truncated at max_log_bytes (default 1024). One debug note on first truncation per invocation; one debug note on first call drop per invocation.
Tests:
- Module returning Pass: connection proceeds
- Module returning Reject with context: context string logged
- Module returning Reject with status 429: status code returned
- Module returning Update: treated as failure, outcome per host failure-mode
- Fuel exhaustion with fail-closed: Reject (403)
- Fuel exhaustion with fail-open: Pass
- Memory limit exceeded: Reject (403) under fail-closed
- Module trap with fail-closed: Reject (403)
- 5 consecutive failures: module disabled, logged at warn
- 4 failures then success: counter resets, module stays active
- Module disabled: subsequent calls return per host failure-mode
host::log() call: message appears in tracing output
host::log() exceeding max_log_bytes: truncated
host::log() called 4+ times: excess silently dropped
Phase 9: Verdict handling in the proxy
Goal: handle_from_external acts on the verdict -- Reject with status code, Pass proceeds.
What changes:
src/modules/proxy/mod.rs: after policy.evaluate(), match on verdict. Reject -> respond with HTTP status code (403/429/503, default 403), do not upgrade the connection, close it. Pass -> proceed to upgrade/relay. Log verdict + context string at info level.
Tests:
- Reject with no status code: HTTP 403
- Reject with 429: HTTP 429
- Reject with 503: HTTP 503
- Reject with invalid status (e.g., 200): treated as 403
- Pass: connection proceeds, context string in logs
- No module loaded: connection proceeds (AllowAll)
Phase 10: Reference module and documentation
Goal: Ship a working example so module authors have a template.
What changes:
- New:
examples/wasm-modules/connect-authz/ -- Rust crate that compiles to a WASM component. Implements intermesh-module world with a simple allow-list policy. Demonstrates: metadata exports, init() for setup, evaluate() returning Pass/Reject, context strings, host::log() calls. Documents clearly: "do not return Update in v1."
- New:
docs/wasm-modules.md -- how to write, build, and install modules. Covers: WIT interface, guest bindings with wit-bindgen, building with cargo component, configuring the module path.
Tests: The example module compiles, loads, and correctly gates CONNECT requests in an integration test.
8. Testing strategy
Invariants enforced across all phases
- No-module baseline:
cargo local passes with no modules configured. Zero behavioral regression.
- Feature-gate baseline: Compiles without
--features wasm with no wasmtime dependency. AllowAll is the only policy.
- Trust boundary: No WASM module can produce an
Endor, modify a Derivation, or influence constraint_authority. Enforced by WIT design -- no host-call exists for trust mutation. WASM cannot influence trust resolution; it can only accept or reject connections that the trust engine has already authorized at the mTLS layer.
- Identity input:
peer-imid is the only identity input to WASM. Names are derived views that differ per node's constraint set; IMID is stable cryptographic identity.
- Isolation: No file outside
src/wasmrunner/ imports wasmtime.
- Resource bounding: Fuel, memory, and log calls are all capped and host-enforced. Failure mode is host-owned -- a module cannot opt itself into fail-open.
Deferred to hardening
- ABI fuzzing (malformed WIT payloads, oversized strings)
- N-1 compatibility (old module / new host)
- Load testing under sustained connection pressure
- Benchmark: latency impact of fuel-budgeted WASM on CONNECT path
- Concurrency stress test (many concurrent CONNECT requests hitting Mutex)
9. Explicit v2+ deferrals
- Update verdict -- enable via
allow_update = true config; requires trust engine validation (Derivation.name_to_imid check) and immutable-field enforcement
- Passive observation -- bounded channel, background executor, outbound connection events
- emit() host-call -- in-module telemetry with bounded queue and drop-on-pressure
- Multiple modules per hook -- composition, ordering, conflict resolution
- Payload windows and L7 inspection
- Gossip-based module distribution
- Module signing and trust-store verification
- Hot-reload and runtime module replacement
- Network egress host-calls
- Store pooling for WASM concurrency (replace Mutex with pool)
- Candidate selection (
Select verdict for IMID/IP choice in connect_name)
- Gossip abuse filtering via WASM (do in plain Rust first)
10. Risk register
| Risk |
Severity |
Mitigation |
| wasmtime dependency size -- adds ~200 transitive crates, ~10-20MB to release binary, significant compile-time increase |
High |
Feature-gate behind wasm cargo feature. Without the feature, zero impact. Phases 1-3 deliver value with no wasmtime. |
| WIT component model maturity |
Medium |
Stable since 2025. Pin wasmtime version. WIT surface is minimal (one world, three interfaces) so migration cost is low. |
| Latency on CONNECT hot path |
Medium |
Fuel budget (100K instructions) caps worst case. Benchmark in Phase 8. If unacceptable, make the hook opt-in via config. |
Store concurrency -- Mutex<Store> serializes all WASM calls |
Medium |
Acceptable for v1 (CONNECT auth is not high-QPS). If contention is visible, reduce fuel_limit first. Store pooling is v2. |
| Module authoring complexity |
Low |
Ship working example in Phase 10. wit-bindgen generates guest bindings for Rust, Go, JS, Python. |
Appendix A: Files changed and estimated lines of code
New files
| File |
Phase |
Est. lines |
Purpose |
src/wasmrunner/mod.rs |
1, 4, 7, 8 |
~75 |
Re-exports, engine init, WasmPolicy with fuel/memory/log limits, failure tracking |
src/wasmrunner/types.rs |
1 |
~35 |
Verdict, ConnectionContext, VerdictResult, ConnectionPolicy trait, AllowAll |
src/wasmrunner/loader.rs |
6 |
~40 |
Load module from path, read metadata, call init() with resource limits |
wit/intermesh-module.wit |
5 |
~40 |
WIT interface definition |
examples/wasm-modules/connect-authz/src/lib.rs |
10 |
~50 |
Reference guest module |
examples/wasm-modules/connect-authz/Cargo.toml |
10 |
~15 |
Guest crate manifest |
docs/wasm-modules.md |
10 |
~100 |
Module authoring guide |
Total new code: ~355 lines (excluding docs and tests)
Modified files
| File |
Phase |
Est. change |
What changes |
Cargo.toml |
4 |
+5 lines |
Add optional wasmtime dependency, wasm feature |
src/lib.rs |
1 |
+1 line |
Add pub mod wasmrunner; |
src/modules/proxy/mod.rs |
2, 9 |
+15 lines |
Add policy field to Handle, call evaluate() in handle_from_external, act on verdict |
src/daemon.rs |
7 |
+10 lines |
Create WasmPolicy or AllowAll at startup, pass to proxy |
src/daemon_state.rs |
7 |
+10 lines |
Add modules config fields (path, failure_mode, fuel_limit, max_memory_bytes, max_log_calls, max_log_bytes) |
Total modified: ~41 lines across 5 existing files
Summary
- ~355 lines of new Rust in
src/wasmrunner/ (3 files, self-contained)
- ~41 lines changed in existing Intermesh code
- ~40 lines of WIT interface definition
- ~100 lines of docs
- 5 existing files touched, all with minimal changes
- 0 lines changed in trust_engine.rs, gossip.rs, verifier.rs, connect.rs, endor/, constraint.rs, ident.rs, imid.rs
This is a more trim version of (DRAFT) Intermesh WASM Integration Plan -- v4.1 , the intent is to be more focused on feasibility rather than future scaffolding that does not offer immediate functionality/impact.
Intermesh WASM Integration Plan -- v5.6
Intermesh knows who is connecting -- mTLS proves that. What it doesn't know is whether that peer should reach this particular target. Today, any authenticated peer can CONNECT to any service on a node. That's the gap.
This plan adds one thing: a sandboxed policy check at the moment Intermesh already has all the information it needs -- peer identity proven, target parsed, relay not yet started. A WASM module looks at the connection metadata, returns Pass or Reject, and gets out of the way. The trust engine, the gossip layer, and the relay path are untouched.
One module. Two verdicts. ~355 lines of new code. Everything else is deferred until this proves its worth.
How it works
Intermesh's proxy has two connection paths:
run_external_acceptor->handle_from_external): accepts mTLS connections from remote peers, parses the HTTP CONNECT target into(Name, port), relays to localhostrun_local_acceptor->forward_external): intercepts nftables-redirected local traffic, resolves VIP to mesh name, tunnels via HTTP CONNECT over mTLSThe WASM hook sits on the inbound path, between target parsing and the relay. The peer IMID is already known from the mTLS handshake. The target name and port are already parsed. The module sees these facts, makes a decision, and returns. If there's no module configured, nothing changes -- the hook is a zero-cost no-op.
What v1 delivers
Per-target access control. "The database team's IMID can reach
db.prod.mesh:5432but notadmin.prod.mesh:22." Policy that varies per deployment, changes faster than release cycles, and doesn't require recompiling Intermesh.Stateful decisions. The module's
init()runs once at load time. The instance persists across connections, so counters, rate-limit windows, and peer tracking survive within a daemon lifetime.Audit trail. Every verdict carries an optional context string -- human-readable, logged by the host, never parsed. Operators get rejection reasons without building a telemetry pipeline.
What v1 deliberately leaves out
v1 is scoped to prove one thing: that WASM policy at the connection level is worth the complexity cost. Everything below is preserved in the v2+ roadmap but excluded from this implementation:
Boundaries
--features wasm, the binary is identical to today.1. Why WASM, not just Rust
handle_from_externalaccepts any mTLS-authenticated peer for any CONNECT target. A hospital network, a multi-tenant platform, a compliance-constrained deployment -- they all need per-target policy, and that policy changes faster than release cycles.Compiled-in Rust would work for one deployment. WASM lets operators ship policy as a file.
The plan hedges this bet: Phases 1-3 deliver a working
ConnectionPolicytrait with pure Rust implementations and zero new dependencies. If WASM proves unjustified, the trait stays and the wasmtime dependency never lands.2. Verdict model
The WASM module returns a verdict. The host acts on it.
v1 verdicts
reject-status-codeif provided, otherwise 403. Only 403, 429, and 503 are valid; any other value is treated as 403.Unsupported verdict: Update
The
Updatevariant exists in the WIT definition to avoid a breaking ABI change when v2 enables it.Updateis treated as a WASM failure -- the host applies the configured failure mode (fail-closed: Reject 403; fail-open: Pass)warn-level log is emitted once per module (not per connection) to avoid log floodingupdated-contextis present in the verdict result, it is ignoredUpdatein v1Verdict result
context-- optional human-readable string (max 256 bytes, hard-truncated). Logged for all verdicts. Never parsed or branched on by the host.reject-status-code-- only read when verdict is Reject. Must be 403, 429, or 503. Default 403.updated-context-- reserved for Update verdict (v2). Ignored in v1.3. WIT interface
Communication between host and WASM uses the WIT (WebAssembly Interface Types) component model, not direct memory read/write. WIT is to WASM components what protobuf is to gRPC -- a typed interface definition language that generates bindings for both host and guest. The
wasmtime::component::bindgen!macro generates Rust types from.witfiles at compile time, similar to howtonic-buildgenerates from.protofiles.Module metadata
"connect-authz". Logged at startup."0.1.0". Logged at startup for debugging.inboundorboth(in v1,bothbehaves the same asinboundbecause only inbound hooks exist). Kept in v1 so we don't need a WIT-breaking change when outbound hooks are added.fail-closedorfail-open. This is advisory only -- the host configuration determines actual enforcement (see Configuration section). Logged at startup so operators can see what the module expects.Hook interface
peer-imidis the only identity input. Names are derived views that can differ per node's constraint set; IMID is stable cryptographic identity.subsystemis always"proxy"in v1.init()is called once at load time. The wasmtimeStorepersists across calls, so state set duringinit()is available in subsequentevaluate()calls.updateexists in the verdict enum for ABI forward-compatibility but is treated as a WASM failure in v1 (see Verdict model section).Host-provided functions
logis the only host-call in v1. It writes to the host's tracing system atdebuglevel.max_log_bytes(default 1024); one debug note on first truncation per invocationmax_log_calls(default 3) calls; excess silently dropped; one debug note on first drop per invocationinit()World definition
What is excluded from v1 ABI
4. Module lifecycle and loading
v1 supports exactly one inbound policy module for the CONNECT authorization hook. The module is specified by explicit path in config, not by directory scan.
Loading sequence
pathfrom[modules.connect_authz]config.wasmcomponent with wasmtime (with memory cap applied)metadata::name(),metadata::version(),metadata::direction(),metadata::failure_mode()hook::init()under the same resource limits asevaluate()(fuel + memory + log caps)init()fails: daemon startup fails (fail-closed -- a configured module that can't initialize is a deployment error)directionisoutbound(and does not includeinbound): daemon startup fails with a clear error ("configured module does not apply to any active hook in v1"). This surfaces misconfig early rather than silently falling back to AllowAll.No hot-reload. Module changes require daemon restart.
Configuration
Loading rules:
[modules.connect_authz]is absent orpathis unset: no module loads. Behavior identical to today.pathpoints to a single.wasmcomponent file. No directory scanning, no duplicate detection, no sorting ambiguity.metadataandhookinterfaces and must successfully runhook::init().Failure mode is host-owned:
metadata::failure_mode()is advisory only -- logged at startup for operator visibilityfailure_modein[modules.connect_authz]) determines actual enforcementfail-closed; operator must explicitly opt intofail-openfail-openand deliberately trapping to bypass policyFailure handling
v1 uses a single deterministic execution budget (fuel) plus hard resource limits enforced by the host. No wall-clock timeout -- fuel is a deterministic instruction budget (wall-clock time to burn that fuel still depends on CPU, but the instruction count is fixed).
Resource limits (host-enforced, applied to both
init()andevaluate()):evaluate()call (no carry-over).ResourceLimiter.host::log()cap: maxmax_log_calls(default 3) calls perevaluate(), each truncated atmax_log_bytes(default 1024) bytes. One debug note on first truncation per invocation; one debug note on first call drop per invocation.What counts as a WASM failure:
Updateverdict (unsupported in v1)(
host::log()cap exceeded is not a failure -- calls are silently dropped, execution continues.)Outcome on failure (host-owned policy):
Failure outcome rules:
Consecutive failure tracking:
warnonceRejectverdict is a normal policy decision, not a failure -- both Pass and Reject reset the consecutive failure counterDisabled module behavior:
evaluate()failed: fail-closed returns Reject 403; fail-open returns Pass"No module" fast path
If no module is loaded (no
[modules.connect_authz]config),evaluate()returns Pass immediately with no context allocation. Zero overhead on the CONNECT hot path when WASM is not in use.5. Hook point: CONNECT authorization
handle_from_externalinsrc/modules/proxy/mod.rsat line 198, afterparse_connect_targetsucceeds and returns(Name, port), but beforeupgrade::on(req)and the relay spawntls_stream.connect_info().peer) but no per-target policy is appliedconnection-contextfrom the peer IMID and parsed target, callevaluate()on the loaded inbound module"proxy"failure_modein[modules.connect_authz]); default: fail-closed6. Code isolation: the
wasmrunnermoduleAll WASM-related code lives in
src/wasmrunner/, not undersrc/modules/. This is deliberate:modules/proxyandmodules/adhocare Intermesh feature modules that implement mesh functionality.wasmrunneris infrastructure -- it provides a policy evaluation service that the proxy module consumes via a trait. It sits alongsideconnect.rs,verifier.rs, andgossip.rsas a peer utility.With single-path loading and one module per hook,
loader.rsis ~40 lines. There is no registry. If config is absent, the daemon usesAllowAll; if configured,load_modulereturns exactly oneLoadedModuleor fails startup. If multi-module support is added in v2, aregistry.rscan be extracted then.The proxy module never imports
wasmtime. It callsconnection_policy.evaluate(ctx)and gets back aVerdict. Whether that policy is a compiled-in Rust struct or a WASM module is invisible to the caller.Feature-gated:
Without
--features wasm, thewasmrunnermodule providesAllowAlland adds zero dependencies.7. Implementation sequence
Each phase is a single PR. Every phase must pass
cargo localand all existing tests.Phase 1: Define the ConnectionPolicy trait
Goal: Introduce the trait that will be the boundary between proxy and policy logic. No behavioral change.
What changes:
src/wasmrunner/types.rs--ConnectionPolicytrait,Verdictenum (Pass, Reject),ConnectionContextstruct,VerdictResultstruct,AllowAlldefault implementationsrc/wasmrunner/mod.rs-- re-exports the public APITests: Unit tests for
AllowAllreturning Pass for any input.Phase 2: Wire ConnectionPolicy into the proxy
Goal:
handle_from_externalcalls the policy after parsing the CONNECT target. WithAllowAll, behavior is identical to today.What changes:
proxy::Handlegains apolicy: Arc<dyn ConnectionPolicy>field.HandleisClone(it carriesArcreferences to shared state), so the policy is shared across all connection-handling tasks.handle_from_externalcallspolicy.evaluate()betweenparse_connect_target(line 197) andupgrade::on(req)/ relay spawn (line 199)daemon.rspassesAllowAlltoproxy::Handle::new()Tests: Existing e2e tests pass unchanged (proving
AllowAllpreserves behavior).Phase 3: Test the policy trait with Rust implementations
Goal: Prove the hook point works with real policy logic, no WASM yet.
What changes:
DenyAllandAllowListtest implementations insrc/wasmrunner/types.rs(behind#[cfg(test)])Tests:
AllowAll: connection proceeds (baseline)DenyAll: connection gets 403AllowList { targets: ["db.test.mesh:5432"] }: selective gatingPhase 4: Add wasmtime and create the engine
Goal: Introduce the wasmtime dependency behind a feature gate. Initialize the engine. No module loading yet.
What changes:
Cargo.toml: addwasmtimeas optional dependency withcomponent-modelfeaturesrc/wasmrunner/mod.rs: conditionally createwasmtime::Enginewhenwasmfeature is enabledTests: Engine initializes successfully. Compiles cleanly with and without
--features wasm.Phase 5: Write the WIT interface
Goal: Define the contract between host and WASM modules.
What changes:
wit/intermesh-module.wit-- full WIT definition (metadata, hook with init/evaluate, host with log)src/wasmrunner/mod.rs: addwasmtime::component::bindgen!macro to generate Rust bindingsTests: Bindings compile. Generated types align with the Verdict/ConnectionContext types from Phase 1.
Phase 6: Module loader
Goal: Load a single
.wasmfile from the configured path, read metadata, callinit()under resource limits.What changes:
src/wasmrunner/loader.rs--load_module(path, limits) -> Result<LoadedModule>that reads the file, instantiates the component with memory cap, reads metadata, and callshook::init()under fuel + memory + log caps. Ifdirectiondoes not includeinbound, returns an error (configured module does not apply to any active hook in v1).Tests:
.wasmcomponent: metadata read correctly, init() calledPhase 7: Daemon wiring
Goal: Wire module loading into daemon startup. Pass the loaded module (or AllowAll) to the proxy.
What changes:
src/wasmrunner/mod.rs:WasmPolicystruct that implementsConnectionPolicy. The wasmtimeStoreis notSend + Sync, buthandle_from_externalruns intokio::spawntasks concurrently.WasmPolicywraps theStorein aMutexto serialize access (acceptable for v1; see risk register).src/daemon.rs: if[modules.connect_authz]config haspath, callload_module(), createWasmPolicy, pass to proxy Handle. Otherwise passAllowAll. Read host-enforced limits from config.src/daemon_state.rs: add modules config fields (path, failure_mode, fuel_limit, max_memory_bytes, max_log_calls, max_log_bytes)Tests:
Phase 8: Active hook execution with failure handling
Goal:
WasmPolicy.evaluate()calls the WASM module with resource limits, handles failures per host-configured failure-mode, tracks consecutive failures, disables on fault. Implementshost::logimport.What changes:
src/wasmrunner/mod.rs: implementevaluate()-- reset fuel fresh each call, apply memory cap via wasmtimeResourceLimiter, callhook::evaluate(), check result. On failure (including Update verdict): use host-configuredfailure_mode(fail-closed -> Reject 403, fail-open -> Pass). Track consecutive failures; disable after 5.src/wasmrunner/mod.rs: implement thehost::logimport in the wasmtime linker. Track call count perevaluate()invocation; cap atmax_log_calls(default 3). Each message truncated atmax_log_bytes(default 1024). One debug note on first truncation per invocation; one debug note on first call drop per invocation.Tests:
host::log()call: message appears in tracing outputhost::log()exceedingmax_log_bytes: truncatedhost::log()called 4+ times: excess silently droppedPhase 9: Verdict handling in the proxy
Goal:
handle_from_externalacts on the verdict -- Reject with status code, Pass proceeds.What changes:
src/modules/proxy/mod.rs: afterpolicy.evaluate(), match on verdict. Reject -> respond with HTTP status code (403/429/503, default 403), do not upgrade the connection, close it. Pass -> proceed to upgrade/relay. Log verdict + context string atinfolevel.Tests:
Phase 10: Reference module and documentation
Goal: Ship a working example so module authors have a template.
What changes:
examples/wasm-modules/connect-authz/-- Rust crate that compiles to a WASM component. Implementsintermesh-moduleworld with a simple allow-list policy. Demonstrates: metadata exports,init()for setup,evaluate()returning Pass/Reject, context strings,host::log()calls. Documents clearly: "do not return Update in v1."docs/wasm-modules.md-- how to write, build, and install modules. Covers: WIT interface, guest bindings withwit-bindgen, building withcargo component, configuring the module path.Tests: The example module compiles, loads, and correctly gates CONNECT requests in an integration test.
8. Testing strategy
Invariants enforced across all phases
cargo localpasses with no modules configured. Zero behavioral regression.--features wasmwith no wasmtime dependency.AllowAllis the only policy.Endor, modify aDerivation, or influenceconstraint_authority. Enforced by WIT design -- no host-call exists for trust mutation. WASM cannot influence trust resolution; it can only accept or reject connections that the trust engine has already authorized at the mTLS layer.peer-imidis the only identity input to WASM. Names are derived views that differ per node's constraint set; IMID is stable cryptographic identity.src/wasmrunner/importswasmtime.Deferred to hardening
9. Explicit v2+ deferrals
allow_update = trueconfig; requires trust engine validation (Derivation.name_to_imidcheck) and immutable-field enforcementSelectverdict for IMID/IP choice inconnect_name)10. Risk register
wasmcargo feature. Without the feature, zero impact. Phases 1-3 deliver value with no wasmtime.Mutex<Store>serializes all WASM callsfuel_limitfirst. Store pooling is v2.wit-bindgengenerates guest bindings for Rust, Go, JS, Python.Appendix A: Files changed and estimated lines of code
New files
src/wasmrunner/mod.rssrc/wasmrunner/types.rssrc/wasmrunner/loader.rswit/intermesh-module.witexamples/wasm-modules/connect-authz/src/lib.rsexamples/wasm-modules/connect-authz/Cargo.tomldocs/wasm-modules.mdTotal new code: ~355 lines (excluding docs and tests)
Modified files
Cargo.tomlsrc/lib.rspub mod wasmrunner;src/modules/proxy/mod.rssrc/daemon.rssrc/daemon_state.rsTotal modified: ~41 lines across 5 existing files
Summary
src/wasmrunner/(3 files, self-contained)