Skip to content

Architecture

Guardian is an enterprise security layer for AI model-agent interactions. It establishes cryptographic identity for coding agents, signs every outbound API call, verifies those signatures server-side, enforces a trust registry and a security policy snapshot, and records audit trail tied to agent state at request time.

The system is a polyglot monorepo:

LayerStackDirectories
Agent sidecarRustviper/
Plugin toolingRustcrates/guardian-plugin, crates/guardian-plugin-sdk, crates/blob-utils
Gateway and control planeGollm-gateway/, control-plane/, shared/servicekit/
Console dashboardTypeScript / React 19 / Vite / Tailwind 4console/
DeploymentHelm + Docker Compose (RustFS-bundled)deploy/

┌───────────────────────────── Developer Workstation ─────────────────────────────┐
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────────────────┐ │
│ │ Coding Agent │──HTTP──▶ Viper Proxy (Rust CLI, localhost) │ │
│ │ (Claude Code, │ │ • State attestation snapshot │ │
│ │ Codex, etc.) │ │ • RFC 9421 signing (Ed25519 / P-256 / │ │
│ └─────────────────┘ │ YubiKey) │ │
│ │ • Content-Digest (SHA-256 + BLAKE3) │ │
│ └────────────────────┬────────────────────────┘ │
│ │ Signed HTTP │
└──────────────────────────────────────────────────┼──────────────────────────────┘
┌───────────────────────────────────────────▼──────────────────────────┐
│ LLM Gateway (Go) │
│ ┌─────────────┐ ┌───────────────────────────────────────────────┐ │
│ │ Verifier │ │ Hook Engine │ │
│ │ sig+body+ │──▶ request.start → core verify → candidate → │ │
│ │ replay+ │ │ project.bootstrap → proxy.pre/post/end │ │
│ │ registry │ │ (deterministic, snapshot-pinned chain) │ │
│ └─────────────┘ └──────────────┬────────────────────────────────┘ │
│ │ Capability Broker │
│ │ cid.resolve · request.body.read │
│ │ http.request (host-mediated) │
│ ┌─────────────┐ ┌──────────────▼──────┐ ┌──────────────────────┐ │
│ │ Audit │ │ Plugin Runtimes │ │ OTEL / OTLP + │ │
│ │ (durable) │ │ • Premade (Go) │ │ Prometheus metrics │ │
│ └─────────────┘ │ • WASM (wazero) │ └──────────────────────┘ │
│ └─────────────────────┘ │
└────┬────────────────────────┬───────────────────────┬────────────────┘
│ │ │
┌────────▼──────┐ ┌─────────▼──────┐ ┌────────▼────────────────┐
│ LLM Provider │ │ PostgreSQL │ │ Artifact Object Storage │
│ (Anthropic / │ │ registry + │ │ (local on-prem / cloud) │
│ OpenAI / │ │ audit + state │ │ for plugin WASM │
│ ChatGPT) │ │ blobs + │ │ artifacts │
└───────────────┘ │ plugin meta │ └──────────▲──────────────┘
└────────┬───────┘ │ signed
│ │ snapshots
┌─────────────────▼──────────────────────┐ │
│ Control Plane (Go) │───┘
│ Read-only Discovery API • plugin │
│ artifact + snapshot management • │
│ agent registration token minting │
└─────────────────┬──────────────────────┘
│ REST
┌───────▼────────┐
│ Console │
│ (React SPA) │
└────────────────┘

Viper is a CLI sidecar that wraps agent invocations (e.g. running viper claude to start Claude Code). When an agent makes an outbound HTTP call, Viper intercepts it on a localhost reverse proxy, enriches and signs the request, then forwards it to the gateway.

Request enrichment pipeline:

  1. State attestation — Viper snapshots the agent’s full context into a StateAttestationData blob: host info (hostname, arch, kernel), proxy metadata (version, self-CID), session info (session ID, username, parent process, git state), per-app fields (Claude Code loaded files + permissions, Codex task, etc.), and a selected set of environment variables (see Environment variable capture below). The blob is serialized to canonical JSON, content-addressed (CID), and uploaded via PUT /v1/blobs/{cid}.

  2. Content integrity — A Content-Digest header (RFC 9530) is computed over the request body using both SHA-256 and BLAKE3.

  3. Request signing — An RFC 9421 HTTP Message Signature is produced covering @method, @target-uri, x-viper-state-cid, x-viper-nonce, x-viper-timestamp, content-digest, and the optional unified x-viper-credential-cid list. The signature carries created (Unix timestamp), keyid (the agent’s did:key), and alg (ed25519 or ecdsa-p256-sha256). YubiKey keys are supported alongside software keys, and in the future Verifiable Compute notaries would be supported.

Headers injected:

Viper injects different header sets on each path.

LLM proxy requests (agent → viper → gateway → provider):

HeaderContent
X-Viper-State-CIDCID of the uploaded StateAttestationData blob (always set)
X-Viper-NoncePer-request nonce — replay protection (always set)
X-Viper-TimestampRequest timestamp (always set)
X-Viper-Identity-CIDCID of identity attestation — only when identity attestation is produced
X-Viper-Credential-CIDCanonical sorted list containing the agent VC CID and any presented membership VC CIDs
X-Viper-App-TypeKebab-case agent type: generic, claude-code, openclaw, opencode, codex
Content-DigestRFC 9530 SHA-256 + BLAKE3 over the request body
Signature-Input + SignatureRFC 9421 Ed25519 or ECDSA P-256 signature

Covered components in the proxy signature:

@method, @target-uri, x-viper-state-cid, x-viper-nonce,
x-viper-timestamp, content-digest
+ x-viper-identity-cid (if identity CID present)
+ x-viper-credential-cid (if any credential CID is present)

On retry the original headers are reused as-is and Host is removed.

Blob upload (gateway /v1/blobs/{cid} — the StateAttestationData sidecar push):

HeaderContent
Content-Typeapplication/octet-stream
Content-DigestSHA-256 + BLAKE3 over blob bytes
Signature-Input + SignatureRFC 9421 signature over @method, @target-uri, content-digest

Agent registration (POST /v1/agents/registrations):

HeaderContent
Content-Typeapplication/json

No signature — registration runs before the agent has a registered key. Authentication is the provisioning token carried in the body.

Credential renewal:

HeaderContent
X-Viper-NonceFresh UUIDv4
Signature-Input + SignatureRFC 9421 signature over @method, @target-uri, x-viper-nonce

Viper does not inject Authorization, x-api-key, anthropic-version, or similar provider-auth headers — those flow through unchanged from the wrapped agent. Gateway-side verification relies entirely on the viper-injected signature, CID, nonce, and timestamp headers. The X-Guardian-Correlation-Id header is server-side: it’s set by the gateway and control-plane and read back by viper for log correlation.

Environment variable capture:

Viper captures a configurable, name-restricted subset of the host process environment and includes it in the state attestation blob under a top-level environment.vars field (alongside agent and derived). Only variable names listed in the env_vars config field are read; missing names are silently skipped, so the captured map only ever contains names that were actually set. Values are stored verbatim — Viper does not normalize, hash, or redact them — so operators should be deliberate about which names they enable.

The default env_vars list targets sandbox provenance:

VariableSource
DAYTONA_SANDBOX_SNAPSHOT, DAYTONA_SANDBOX_ID, DAYTONA_ORGANIZATION_IDDaytona sandboxes
E2B_SANDBOX, E2B_TEMPLATE_ID, E2B_SANDBOX_IDE2B sandboxes

Override or extend the list by setting env_vars = [...] in ~/.config/viper/config.toml. Since the captured values land in the content-addressed state blob signed by Viper and persisted by the gateway, gateway-side policies (OPA/Rego, etc.) can read them out of the attestation and gate requests on sandbox identity or any other captured signal.

Key management: Ed25519 / P-256 keys stored in ~/.config/viper/keys/. DIDs use the did:key:z... multikey encoding. viper did register registers a key with the gateway and receives a W3C Verifiable Credential in return; both browser-mediated and headless token paths are supported (see Agent Lifecycle).


The gateway is the trust enforcement point. Every governed request passes through: route resolution → environment request.start hooks → cryptographic verification → core project-presentation binding → candidate project snapshot selection and pinning → mandatory typed project.bootstrap membership decision → authoritative project context establishment → pinned proxy.pre → upstream proxy → pinned proxy.post → audit persist → pinned async request.end.

Project selection and project authorization are intentionally separate. Core may safely bind a signed presentation to a non-authoritative project candidate and use that candidate to select policy, but selecting the snapshot grants no access. Only a successful typed result from the snapshot’s builtin.project_membership entry establishes protected project.* context. Unsigned/malformed/ambiguous presentation failures stay in non-configurable core; issuer, status, expiration, revocation, role, required-membership, and observe/enforce policy belong to the project snapshot.

Request lifecycle:

1. Correlation-ID assignment
2. Route / provider recognition from inbound gateway path
3. Request body read + restore
4. HOOK: request.start (pre-verify policy, untrusted input)
5. Verification (RFC 9421 signature, Content-Digest, freshness, replay CAS,
keyid → agent_id lookup, agent/key status, X-Viper-* claims,
optional YubiKey cert chain)
6. Core project-presentation binding to a non-authoritative candidate
7. Exact project snapshot selection + request pin, when a candidate exists
8. HOOK: project.bootstrap (typed builtin.project_membership only)
9. Authoritative project context establishment on validated typed success
10. Upstream metadata finalization
11. HOOK: proxy.pre (post-verify policy, incl. builtin.authorization)
12. Proxy upstream
13. HOOK: proxy.post (response/headers-stage decisions, no body mutation)
14. Audit capture
15. HOOK: request.end (durable async, observability-only)

Steps 6–9 are skipped for projectless traffic, which pins the environment-default snapshot after verification. A bound project candidate never falls back to that environment snapshot.

Verification rejects requests outside the configured clock-skew window, with mismatched body digests, with already-seen (keyid, nonce, created) tuples (TTL = created + sig_max_age + clock_skew), or pointing to revoked/disabled agents. On the happy path, the gateway reverse-proxies to the upstream provider; on rejection it returns a structured deny envelope (code, message, correlation_id) and records the denial.

Provider routing:

Path prefixUpstream
/v1/anthropic_api/*Anthropic API
/v1/openai_api/*OpenAI API
/v1/chatgpt_backend/*ChatGPT Backend

Other gateway routes — /v1/agents/registrations, /v1/blobs/{cid}, /v1/agents/{id}/state-cids, /health, /metrics — are not hook-instrumented in V1.

Agent registry (PostgreSQL):

  • registry_agents — agent identity and lifecycle status (active, pending, disabled, revoked)
  • registry_agent_keys — one-to-many DID keys per agent, with per-key status
  • registry_users / registry_agent_user_bindings — user associations (operator role-based)
  • registry_registration_events — append-only provisioning audit log

Audit log (gateway_audit_exchanges, append-only): every request captures correlation ID, agent ID, key ID, full HTTP request/response (headers + body), LLM metadata (provider, operation, model, streaming), decision code (authorized, sig_invalid, sig_replay, agent_revoked, plugin_denied, …), plugin summary attribution, and a 150-character request preview extracted from the last meaningful chat message. State CID history is recorded separately in agent state history tables and the underlying blobs land in gateway_blobs.


The gateway runs a deterministic, snapshot-driven plugin chain at five canonical hook stages. project.bootstrap is privileged and typed; the other four stages retain the generic hook decision contract. The system is documented in detail in rfcs/rfc-hook-plugin-system.md and was foundationally implemented under GAT-66.

Hook stages:

StageWhenEnforcementNotes
request.startPre-verify, on untrusted inputYesBounded preview, no verified identity yet
project.bootstrapAfter candidate binding and exact project snapshot pinningTyped membership decisionPrivileged builtin.project_membership only; no generic context authority
proxy.prePost-verify, pre-proxyYesVerified identity available; primary authorization seam
proxy.postAfter upstream response headersYes (pre-commit only)No body or header mutation; stream headers stage once
request.endAfter response completesNo (observability only)Durable async queue with retries + DLQ

Plugin runtimes:

  • Premade — first-party native Go plugins (builtin.project_membership, builtin.authorization, builtin.verifiable_credential, builtin.opa_rego, builtin.config_validation, builtin.vtscan, builtin.intent_monitor, builtin.static_response). Customer customization for these is configuration- and policy-driven (e.g. membership trust/role policy, Rego modules, model allowlists), not customer-authored binaries. See builtin-plugins.md for the full catalog — hook stages, modes, required capabilities, and source locations.
  • WASM — customer-authored modules compiled to wasm32-wasip1 and executed in-process via wazero. The Rust SDK is in crates/guardian-plugin-sdk; the manifest + deploy CLI is crates/guardian-plugin.

Snapshot model: projectless traffic pins the active immutable environment-default snapshot after verification. A bound project candidate resolves only the active (environment, project_urn) snapshot—never an environment fallback—and pins its compiled identity for bootstrap, pre/post hooks, audit, and request-end. Project snapshots require one enabled builtin.project_membership bootstrap entry at creation and activation. A bounded concurrency-safe compiled cache (default 64 project snapshot identities) compiles each identity once, observes activation changes on later requests, retains in-flight pins, and evicts unpinned least-recently-used entries. Environment polling and last-known-good recovery remain for the environment default. There is no request-time policy overlay.

Capability broker: plugins are sandboxed from raw filesystem, env, and network. Privileged operations go through the host-mediated capability broker, gated per snapshot entry:

  • cid.resolve — read-only lookup of request-referenced CIDs from the embedded CID router / blob store; non-transitive in V1
  • request.body.read — opt-in full body access with byte cap
  • http.request — host-dispatched HTTPS to allow-listed destinations, with secret-ref auth injection and per-call timeout / size limits

Failure semantics:

  • Enforcement deny → request denied (code + message from plugin, defaults plugin_denied / 403)
  • Enforcement timeout/runtime/capability error → fail-closed (code=plugin_execution_error, default 503)
  • Observability error → fail-open, event recorded
  • request.end failures never block the response

A Go service that exposes a read-only Discovery API on top of the same PostgreSQL views the gateway writes to, plus admin APIs for agent registration token minting and plugin snapshot management. OIDC (Google SSO), session management (12-hour max / 2-hour idle), CSRF protection, and admin-vs-operator RBAC sit in front. In the on-prem profile OIDC is disabled and bearer auth is wired by the operator.

Key endpoints:

MethodPathPurpose
GET/v1/agentsPaginated agent list with throughput histograms
GET/PATCH/DELETE/v1/agents/{id}Agent detail, update, deregister
POST/v1/agents/{id}/revoke / /restoreKill-switch and re-activation
GET/v1/agents/{id}/state-cidsState attestation timeline
GET/v1/agents/{id}/state-cids/{cid}/blobFull state snapshot bytes
GET/v1/agents/{id}/audit-exchanges (+ /timeseries)Paginated audit log + bucketed counts
GET/v1/agents/{id}/audit-exchanges/{xid}/{request,response}-bodyRaw bodies
GET/v1/agents/{id}/plugin-eventsSync + async plugin decision log
POST/v1/agent-registration-tokensMint short-lived (~15-min) HMAC provisioning tokens
POST GET PATCH/v1/plugins/{artifacts,definitions}Plugin marketplace management (admin)
POST GET/v1/plugin-snapshots (+ /{id}/activate, /current)Snapshot lifecycle (admin)

Sensitive headers (Authorization, Proxy-Authorization, X-Api-Key, Cookie) are redacted in audit responses; bodies are returned as UTF-8 text or base64 and should be treated as sensitive operational data.


5. Console (TypeScript/React — console/)

Section titled “5. Console (TypeScript/React — console/)”

A React 19 / Vite / Tailwind CSS 4 SPA using the @eqtylab/equality design system. Local dev defaults to VITE_API_MODE="mock" (in-memory stubs); set "local" for the Vite proxy → control-plane on :10010, or "remote" with VITE_API_URL to target a deployed API (the dev:remote npm script wires this to https://guardian.dev.eqtylab.io/api).

Main views: agent list (search, filter by user/type/last-active, throughput sparklines), agent detail (keys, users, status controls), audit log (infinite scroll + body viewer), state CID timeline with blob diff, plugin intent monitor, agent registration wizard, user management.


ConcernBacking storeNotes
Registry, audit, state blobs, plugin metadata, async work queue + DLQPostgreSQLGateway owns all migrations (llm-gateway/service/migrations/); applied on startup
Plugin WASM artifact bytesObject storageRustFS bundled in the Compose demo; external S3 expected in Helm
Local artifact cache + last-known-good snapshot metadataGateway diskCold-start recovery when control-plane is unreachable

State attestation blobs currently live in Postgres (gateway_blobs), not in object storage. Plugin artifacts are content-addressed by SHA-256 and signature-verified against trusted platform release keys before load.


Four complementary signals (see observability.mdx for the full reference):

SignalProduced byStored / exported toUse
Audit exchangesGatewayPostgres → control-plane API + consoleForensics, governance, request-level debugging
State CID historyGateway (when X-Viper-State-CID present)Postgres → control-plane API + consoleReconstruct agent state at request time
OpenTelemetry traces, logs, metricsGatewayOTLP-compatible backendLifecycle tracing, plugin runtime health
Prometheus metricsGateway/metrics scrapeSLIs, dashboards, alerting

OTEL lifecycle trace shape:

gateway.request
gateway.request.prepare
gateway.plugin.invocation # request.start
gateway.verification
gateway.proxy
gateway.plugin.invocation # proxy.pre
gateway.upstream
gateway.plugin.invocation # proxy.post

Async request.end invocations continue the originating trace via W3C propagation. OTEL bootstrap is fail-open: a misconfigured exporter degrades to a no-op provider rather than taking the request path down. Lifecycle OTEL logs never carry bodies or raw headers — payload inspection stays in the audit subsystem.

Plugin runtime metrics (instrumentation scope guardian.llm-gateway.hooks) include invocation counts and latency histograms labeled by hook/plugin/mode/outcome, async queue depth/drops/retries/DLQ, snapshot load failures, and active snapshot version.


1. Provisioning token issued
├─ Browser flow: viper opens the Guardian UI, operator authenticates,
│ control-plane mints a token bound to the assigned DID
└─ Headless flow: POST /v1/agent-registration-tokens with bearer auth
(use for CI, remote shells, or custom runtimes)
Token format: ggr1.<payload_b64url>.<HMAC-SHA256-sig>
Claims: agent_id, sub (operator email), exp (~15 min); single-use, jti-tracked.
2. Operator: viper did add → generates a software keypair (Ed25519 by default, P-256 when selected), stored encrypted locally
(or viper did add --assign <app> to add and assign in one step)
3. Operator: viper did assign --app <app> → binds the local DID to a specific
agent application (claude, codex, opencode, openclaw, proxy) before registration.
Skip this step if step 2 was run with --assign.
4. Operator: viper did register --app <app> → POST /v1/agents/registrations
├─ Gateway validates HMAC token, upserts registry_agents + registry_agent_keys
├─ Binds the token's subject to the agent (user binding)
└─ Issues W3C Verifiable Credential (Ed25519Signature2020, stored as blob)
registration_result ∈ { created, updated, noop }
5. Operator runs the agent through Viper: viper claude (or codex / opencode / openclaw)
└─ Proxy starts on localhost, intercepts agent HTTP calls
└─ Each call: collect state → sign → upload blob → forward signed request
6. Gateway runs hooks + verification on every request; proxies if authorized
7. Console operator monitors activity; can revoke (instant kill-switch) or restore.
Revoked agents cannot self-register again until restored.

Manual gateway-direct registration (POST /v1/agents/registrations from a custom runtime that holds its own Ed25519 or P-256 key) is supported as a third path; see agent-self-registration.mdx for the full guide.


PropertyMechanism
Agent identityEd25519 / ECDSA P-256 did:key DIDs; hardware-backed via YubiKey HSM
Request integrityRFC 9421 HTTP signatures + RFC 9530 Content-Digest (SHA-256 + BLAKE3)
Replay preventionCAS store keyed on (keyid, nonce, created) with TTL = created + sig_max_age + clock_skew
FreshnessSignature created checked within configurable clock-skew window
State provenanceContent-addressed StateAttestationData blobs tying every LLM call to a full agent/host snapshot
Bootstrap credentialsHMAC provisioning tokens: single-use (jti-tracked), short-lived (~15 min), scoped to one agent_id
Kill-switchRevocation is immediate: agent status checked on every request
Policy enforcementSigned immutable snapshots, atomic activation, last-known-good fallback; deterministic ordered plugin chain
Plugin sandboxWASM modules (wazero) with no fs/env/process/network; privileged ops only via host-mediated capability broker
Artifact trustPlugin WASM artifacts content-addressed (SHA-256) and Ed25519-signed by platform release keys; verified before load
Audit completenessAppend-only PostgreSQL log captures full request/response + decision rationale + plugin attribution
Header redactionAuthorization, Proxy-Authorization, X-Api-Key, Cookie redacted in control-plane audit responses