Skip to content

Local Hooks + WASM Plugin E2E

This runbook exercises the real local stack:

  • control-plane on :10010
  • gateway on :10000
  • shared Postgres metadata
  • shared local filesystem artifact storage
  • a custom WASM plugin artifact created locally
  • a real signed proxied request through the gateway

From repo root:

Terminal window
just --justfile llm-gateway/justfile audit-db-up
Terminal window
SIGNER_SEED=$(just --justfile llm-gateway/justfile gen-key | awk -F= '/seed_b64url/{print $2; exit}')
SIGNER_PUB=$(just --justfile llm-gateway/justfile pubkey-from-seed "$SIGNER_SEED" | awk -F= '/^public_key_b64url=/{print $2; exit}')
ARTIFACT_ROOT="$PWD/.tmp/plugin-artifacts"
PUBLISHER_SEED_FILE="$PWD/.tmp/plugin-publisher.seed"
mkdir -p "$ARTIFACT_ROOT"
printf '%s' "$SIGNER_SEED" > "$PUBLISHER_SEED_FILE"

Initialize local config files if you have not already:

Terminal window
just --justfile control-plane/justfile runtime-config-local-init
just --justfile llm-gateway/justfile runtime-config-local-init

Update runtime.local.toml:

[server]
listen = ":10010"
db_dsn = "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable"
cors_allow_all = true
[auth]
enabled = false
registration_token_secret = "guardian-local-bootstrap-secret"
registration_token_issuer = "control-plane"
[plugins.publisher_signer]
key_id = "signer-local"
seed_file = "<PUBLISHER_SEED_FILE>"
[plugins.artifact_storage]
provider = "file"
bucket = "<ABS_ARTIFACT_ROOT>"
prefix = "development"
max_upload_bytes = 536870912

Update runtime.local.toml:

[server]
listen = ":10000"
deployment_environment = "development"
[registry]
backend = "postgres"
db_dsn = "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable"
registration_enabled = true
registration_token_secret = "guardian-local-bootstrap-secret"
registration_token_issuer = "control-plane"
[audit]
enabled = true
db_dsn = "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable"
[upstreams]
anthropic_url = "http://127.0.0.1:18080"
openai_url = "http://127.0.0.1:18080"
chatgpt_backend_url = "http://127.0.0.1:18080"
[plugins]
enabled = true
poll_interval = "2s"
authorizer_shadow_enabled = false
end_queue_dir = "data/plugin-end-queue"
end_queue_max_bytes = 134217728
artifact_cache_dir = "data/plugin-artifacts"
[plugins.trusted_signer_keys]
signer-local = "<SIGNER_PUB>"
[plugins.artifact_storage]
provider = "file"
bucket = "<ABS_ARTIFACT_ROOT>"
prefix = "development"

Replace:

  • <SIGNER_PUB> with $SIGNER_PUB
  • <ABS_ARTIFACT_ROOT> with the absolute value of $ARTIFACT_ROOT
  • <PUBLISHER_SEED_FILE> with the absolute value of $PUBLISHER_SEED_FILE

Terminal 1:

Terminal window
just --justfile llm-gateway/justfile mock-upstream

Terminal 2:

Terminal window
just --justfile control-plane/justfile run-config

Terminal 3:

Terminal window
just --justfile llm-gateway/justfile gateway-config

The MVP developer path is the guardian-plugin CLI plus a guardian.plugin.toml manifest. The sample Rust allow-all plugin is in test-plugins/rust-allow-all:

Terminal window
cargo run -p guardian-plugin -- \
--manifest test-plugins/rust-allow-all/guardian.plugin.toml \
deploy --activate

This command builds the Rust guest, validates the core Guardian WASM ABI, uploads the artifact without client-side signatures, lets the control-plane sign the artifact/snapshot, creates a snapshot, and activates it because --activate is present.

To create a draft and activate separately:

Terminal window
cargo run -p guardian-plugin -- \
--manifest test-plugins/rust-allow-all/guardian.plugin.toml \
deploy
cargo run -p guardian-plugin -- \
--manifest test-plugins/rust-allow-all/guardian.plugin.toml \
activate <SNAPSHOT_ID>

The manifest owns the hook chain. The sample includes builtin.authorization at proxy.pre order 0 and the Rust WASM plugin at order 1.

To demo CID resolution plus host-managed outbound HTTP, deploy the Rust host capability probe instead:

Terminal window
cargo run -p guardian-plugin -- \
--manifest test-plugins/rust-host-capability-probe/guardian.plugin.toml \
deploy --activate

The sample stays in observability mode. It reads X-Viper-State-CID, requests the full inbound body, extracts http(s) URLs from the prompt, and POSTs a summary payload to the local mock upstream at http://127.0.0.1:18080/scan. CID resolution uses the host-managed cid.resolve grant to read from the gateway blob store; request_referenced and verified_by_host are returned as metadata on the resolution result.

The older fixture publisher is still useful when debugging the raw control-plane API. It signs from the CLI instead of using [plugins.publisher_signer]:

Terminal window
just --justfile control-plane/justfile plugin-demo-publish-wasm \
"$PWD/test-plugins/artifacts/allow-all.wasm" \
"$SIGNER_SEED" \
"http://localhost:10010" \
"development" \
"signer-local" \
"custom.test.allow_all"

The helper handles the two easy-to-miss pieces correctly:

  • artifact signature is over the artifact SHA-256 digest
  • snapshot manifest is signed over the hydrated entry, including plugin_id and runtime, not just plugin_definition_id

Optional check:

Terminal window
curl -s "http://localhost:10010/v1/plugin-snapshots/current?environment=development" | jq .

6. Register an agent and send a signed gateway request

Section titled “6. Register an agent and send a signed gateway request”
Terminal window
TOKEN=$(just --justfile llm-gateway/justfile reg-token | awk -F= '/^token=/{print $2; exit}')
SEED=$(just --justfile llm-gateway/justfile gen-key | awk -F= '/seed_b64url/{print $2; exit}')
just --justfile llm-gateway/justfile e2e-prepare-registry "$TOKEN" "$SEED"
just --justfile llm-gateway/justfile e2e-success

Expected outcome:

  • gateway returns 200
  • the mock upstream logs a POST to /v1/messages
  • the active plugin snapshot remains development

If you published the authorization-then-deny snapshot instead, the expected outcome is:

  • gateway returns 403
  • the audit record shows plugin_denied_by = custom.test.deny_all
  • no upstream request is sent

Host capability probe request through Viper

Section titled “Host capability probe request through Viper”

This variant uses the real Viper proxy so the request carries X-Viper-State-CID and the plugin can resolve it.

Make sure Viper already has:

  • a configured llm_gateway_api_url pointing at http://127.0.0.1:10000/v1
  • a registered DID for the proxy app

Reset any prior mock scanner observations:

Terminal window
just --justfile llm-gateway/justfile mock-upstream-scan-reset

Start Viper in a fourth terminal:

Terminal window
cargo run -p viper -- proxy \
--app proxy \
--port 18081 \
--gateway-endpoint http://127.0.0.1:10000/v1/openai_api \
--stdout-traffic summary

Send an OpenAI-style request with URLs in the prompt:

Terminal window
curl -s http://127.0.0.1:18081/v1/chat/completions \
-H 'Content-Type: application/json' \
--data @test-plugins/rust-host-capability-probe/demo-request.openai.json

Expected outcome:

  • Viper forwards the request with X-Viper-State-CID
  • the gateway returns 200
  • the plugin emits an observability event and remains allow-only
  • the mock scanner receives a POST to /scan

Inspect the recorded scanner payload:

Terminal window
just --justfile llm-gateway/justfile mock-upstream-scan-logs

The response includes the plugin’s outbound payload, including:

  • state_cid
  • CID resolution metadata under cid, including request_referenced and verified_by_host
  • the extracted prompt URLs under request.urls

Most recent hook events:

Terminal window
psql "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable" \
-c "SELECT plugin_id, hook, outcome, latency_ms, created_at FROM gateway_plugin_hook_events ORDER BY event_id DESC LIMIT 10;"

Most recent audit rows with plugin summary:

Terminal window
psql "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable" \
-c "SELECT plugin_snapshot_version, plugin_denied_by, plugin_decision_code, plugin_summary_json FROM gateway_audit_exchanges ORDER BY exchange_id DESC LIMIT 5;"

Most recent hook events for the host capability probe:

Terminal window
psql "postgres://guardian:guardian@127.0.0.1:15432/guardian_gateway?sslmode=disable" \
-c "SELECT plugin_id, hook, outcome, capability_summary_json, created_at FROM gateway_plugin_hook_events WHERE plugin_id = 'custom.test.rust_host_capability_probe' ORDER BY event_id DESC LIMIT 10;"
  • The local artifact provider uses bucket as a shared filesystem root.
  • Use an absolute path for bucket so both services resolve the same files.
  • The shared fixtures are described in README.md.