Architecture
Overview
Section titled “Overview”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:
| Layer | Stack | Directories |
|---|---|---|
| Agent sidecar | Rust | viper/ |
| Plugin tooling | Rust | crates/guardian-plugin, crates/guardian-plugin-sdk, crates/blob-utils |
| Gateway and control plane | Go | llm-gateway/, control-plane/, shared/servicekit/ |
| Console dashboard | TypeScript / React 19 / Vite / Tailwind 4 | console/ |
| Deployment | Helm + Docker Compose (RustFS-bundled) | deploy/ |
System Components
Section titled “System Components”┌───────────────────────────── 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) │ └────────────────┘Component Deep-Dives
Section titled “Component Deep-Dives”1. Viper (Rust — viper/)
Section titled “1. Viper (Rust — viper/)”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:
-
State attestation — Viper snapshots the agent’s full context into a
StateAttestationDatablob: 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 viaPUT /v1/blobs/{cid}. -
Content integrity — A
Content-Digestheader (RFC 9530) is computed over the request body using both SHA-256 and BLAKE3. -
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 unifiedx-viper-credential-cidlist. The signature carriescreated(Unix timestamp),keyid(the agent’sdid:key), andalg(ed25519orecdsa-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):
| Header | Content |
|---|---|
X-Viper-State-CID | CID of the uploaded StateAttestationData blob (always set) |
X-Viper-Nonce | Per-request nonce — replay protection (always set) |
X-Viper-Timestamp | Request timestamp (always set) |
X-Viper-Identity-CID | CID of identity attestation — only when identity attestation is produced |
X-Viper-Credential-CID | Canonical sorted list containing the agent VC CID and any presented membership VC CIDs |
X-Viper-App-Type | Kebab-case agent type: generic, claude-code, openclaw, opencode, codex |
Content-Digest | RFC 9530 SHA-256 + BLAKE3 over the request body |
Signature-Input + Signature | RFC 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):
| Header | Content |
|---|---|
Content-Type | application/octet-stream |
Content-Digest | SHA-256 + BLAKE3 over blob bytes |
Signature-Input + Signature | RFC 9421 signature over @method, @target-uri, content-digest |
Agent registration (POST /v1/agents/registrations):
| Header | Content |
|---|---|
Content-Type | application/json |
No signature — registration runs before the agent has a registered key. Authentication is the provisioning token carried in the body.
Credential renewal:
| Header | Content |
|---|---|
X-Viper-Nonce | Fresh UUIDv4 |
Signature-Input + Signature | RFC 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:
| Variable | Source |
|---|---|
DAYTONA_SANDBOX_SNAPSHOT, DAYTONA_SANDBOX_ID, DAYTONA_ORGANIZATION_ID | Daytona sandboxes |
E2B_SANDBOX, E2B_TEMPLATE_ID, E2B_SANDBOX_ID | E2B 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).
2. LLM Gateway (Go — llm-gateway/)
Section titled “2. LLM Gateway (Go — llm-gateway/)”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 assignment2. Route / provider recognition from inbound gateway path3. Request body read + restore4. 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 candidate7. Exact project snapshot selection + request pin, when a candidate exists8. HOOK: project.bootstrap (typed builtin.project_membership only)9. Authoritative project context establishment on validated typed success10. Upstream metadata finalization11. HOOK: proxy.pre (post-verify policy, incl. builtin.authorization)12. Proxy upstream13. HOOK: proxy.post (response/headers-stage decisions, no body mutation)14. Audit capture15. 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 prefix | Upstream |
|---|---|
/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 statusregistry_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.
3. Hook & Plugin System
Section titled “3. Hook & Plugin System”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:
| Stage | When | Enforcement | Notes |
|---|---|---|---|
request.start | Pre-verify, on untrusted input | Yes | Bounded preview, no verified identity yet |
project.bootstrap | After candidate binding and exact project snapshot pinning | Typed membership decision | Privileged builtin.project_membership only; no generic context authority |
proxy.pre | Post-verify, pre-proxy | Yes | Verified identity available; primary authorization seam |
proxy.post | After upstream response headers | Yes (pre-commit only) | No body or header mutation; stream headers stage once |
request.end | After response completes | No (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. Seebuiltin-plugins.mdfor the full catalog — hook stages, modes, required capabilities, and source locations. - WASM — customer-authored modules compiled to
wasm32-wasip1and executed in-process viawazero. The Rust SDK is incrates/guardian-plugin-sdk; the manifest + deploy CLI iscrates/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 V1request.body.read— opt-in full body access with byte caphttp.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+messagefrom plugin, defaultsplugin_denied/ 403) - Enforcement timeout/runtime/capability error → fail-closed (
code=plugin_execution_error, default 503) - Observability error → fail-open, event recorded
request.endfailures never block the response
4. Control Plane (Go — control-plane/)
Section titled “4. Control Plane (Go — control-plane/)”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:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/agents | Paginated agent list with throughput histograms |
GET/PATCH/DELETE | /v1/agents/{id} | Agent detail, update, deregister |
POST | /v1/agents/{id}/revoke / /restore | Kill-switch and re-activation |
GET | /v1/agents/{id}/state-cids | State attestation timeline |
GET | /v1/agents/{id}/state-cids/{cid}/blob | Full state snapshot bytes |
GET | /v1/agents/{id}/audit-exchanges (+ /timeseries) | Paginated audit log + bucketed counts |
GET | /v1/agents/{id}/audit-exchanges/{xid}/{request,response}-body | Raw bodies |
GET | /v1/agents/{id}/plugin-events | Sync + async plugin decision log |
POST | /v1/agent-registration-tokens | Mint 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.
Storage Layers
Section titled “Storage Layers”| Concern | Backing store | Notes |
|---|---|---|
| Registry, audit, state blobs, plugin metadata, async work queue + DLQ | PostgreSQL | Gateway owns all migrations (llm-gateway/service/migrations/); applied on startup |
| Plugin WASM artifact bytes | Object storage | RustFS bundled in the Compose demo; external S3 expected in Helm |
| Local artifact cache + last-known-good snapshot metadata | Gateway disk | Cold-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.
Observability
Section titled “Observability”Four complementary signals (see observability.mdx for the full reference):
| Signal | Produced by | Stored / exported to | Use |
|---|---|---|---|
| Audit exchanges | Gateway | Postgres → control-plane API + console | Forensics, governance, request-level debugging |
| State CID history | Gateway (when X-Viper-State-CID present) | Postgres → control-plane API + console | Reconstruct agent state at request time |
| OpenTelemetry traces, logs, metrics | Gateway | OTLP-compatible backend | Lifecycle tracing, plugin runtime health |
| Prometheus metrics | Gateway | /metrics scrape | SLIs, 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.postAsync 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.
Agent Lifecycle
Section titled “Agent Lifecycle”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.
Security Properties
Section titled “Security Properties”| Property | Mechanism |
|---|---|
| Agent identity | Ed25519 / ECDSA P-256 did:key DIDs; hardware-backed via YubiKey HSM |
| Request integrity | RFC 9421 HTTP signatures + RFC 9530 Content-Digest (SHA-256 + BLAKE3) |
| Replay prevention | CAS store keyed on (keyid, nonce, created) with TTL = created + sig_max_age + clock_skew |
| Freshness | Signature created checked within configurable clock-skew window |
| State provenance | Content-addressed StateAttestationData blobs tying every LLM call to a full agent/host snapshot |
| Bootstrap credentials | HMAC provisioning tokens: single-use (jti-tracked), short-lived (~15 min), scoped to one agent_id |
| Kill-switch | Revocation is immediate: agent status checked on every request |
| Policy enforcement | Signed immutable snapshots, atomic activation, last-known-good fallback; deterministic ordered plugin chain |
| Plugin sandbox | WASM modules (wazero) with no fs/env/process/network; privileged ops only via host-mediated capability broker |
| Artifact trust | Plugin WASM artifacts content-addressed (SHA-256) and Ed25519-signed by platform release keys; verified before load |
| Audit completeness | Append-only PostgreSQL log captures full request/response + decision rationale + plugin attribution |
| Header redaction | Authorization, Proxy-Authorization, X-Api-Key, Cookie redacted in control-plane audit responses |