agentguard architecture

Goals

  1. Make every agent tool call an explicit authorization decision — no implicit “trust the agent”.
  2. Make every decision auditable — structured JSONL log of who, what, when, why.
  3. Make agent-to-agent delegation safe — scoped, time-boxed, signed tokens.
  4. Make policies first-class artifacts — Cedar, with schema validation, written by humans or generated by LLMs.
  5. Stay close to Cedar — agent-specific abstractions on top, not a replacement.
  6. Operate in production — observability, retry, drain, health checks, runbook.

Components

                    ┌───────────────────────────────────────────────┐
                    │            agentguard-core (Rust)              │
                    │                                                │
  cedar-policy      │  ┌─────────┐  ┌────────────┐  ┌──────────┐   │
  (Rust) ◄─────────┼─►│ Request │  │ Authorizer │  │  Cache   │   │
                    │  └─────────┘  │ (with cache)│  │(LRU+TTL) │   │
                    │               └────────────┘  └──────────┘   │
                    │  ┌───────────────────┐  ┌─────────────────┐  │
                    │  │ Delegation         │  │   Simulator     │  │
                    │  │ (Ed25519 JWS)      │  │   (what-if)     │  │
                    │  └───────────────────┘  └─────────────────┘  │
                    │  ┌──────────────────────────────────────────┐  │
                    │  │ DecisionLog (JSONL, hash-chained)        │  │
                    │  └──────────────────────────────────────────┘  │
                    └───────────────────────────────────────────────┘
                                       ▲   ▲   ▲
                                       │   │   │
                ┌──────────────────────┘   │   └──────────────────────┐
                │                          │                          │
        ┌───────┴────────┐                         ┌───────┴────────┐
        │  CLI (Rust)    │                         │ TypeScript SDK │
        │  agentguard    │                         │  (subprocess)  │
        └────────────────┘                         └────────────────┘

                ┌───────────────────────────────────────────────┐
                │            agentguard-server                  │
                │                                                │
                │   AuthZEN HTTP + repository gRPC mirror         │
                │   - /access/v1/evaluation (single)             │
                │   - /access/v1/evaluations  (batch ≤ 100)      │
                │   - /healthz, /readyz, /metrics                  │
                │   - AccessEvaluation gRPC                       │
                │                                                │
                │   auth_layer (ApiKey | Disabled)                │
                │   policy watcher + SIGHUP reload                │
                │   graceful shutdown; supervisor bounds drain     │
                └───────────────────────────────────────────────┘

The server

The standalone run entry point reads supported tuning values from the process environment and translates them into explicit runtime options. The embedded build_state and build_router APIs do not read environment variables: use build_state_with_options to supply cache and audit-rotation configuration. This keeps library behavior deterministic and leaves process configuration at the application boundary.

agentguard-server is an AuthZEN-compatible HTTP PDP with an optional repository-defined gRPC mirror. It exposes:

All /access/v1/* endpoints are protected by auth_layer:

A PolicyWatcher polls store_root every 500 ms, recursively observing Cedar files under policies/ and schema.cedarschema; on each relevant event the complete authorizer snapshots are atomically replaced and policy_reload_total is bumped on success. Runtime watcher errors are logged. The server also installs a SIGHUP handler (Unix) so operators can force a refresh without touching the filesystem; malformed edits retain the last-known-good snapshot.

On SIGTERM/SIGINT the server stops accepting new connections and lets in-flight requests drain. The process supervisor must enforce the hard termination deadline; the Kubernetes reference deployment uses a 30-second terminationGracePeriodSeconds.

The request model

Every tool call is normalized into a Cedar (Principal, Action, Resource, Context) tuple:

The decision log

The configured standalone server and CLI write DecisionRecord values to .audit/decisions.jsonl. Embedded Authorizer calls return decisions without writing audit automatically; the embedding application owns that wiring:

{
  "id": "uuid",
  "timestamp": "2026-07-14T12:34:56Z",
  "effect": "allow" | "deny",
  "policies": ["policy_id", ...],
  "principal": "alice",
  "action": "send_email",
  "resource": "alice@acme",
  "reasons": ["..."],
  "request_id": "...",
  "session_id": "..."
}

The log is append-only with one of two modes:

On startup the complete active log and its timestamped rotation segments are verified as one continuous HMAC chain before new writes are accepted. This also restores the chain head from the newest rotated segment if a crash happened after rotation and before the first append to the new active file. Malformed or tampered records refuse startup rather than silently extending untrusted history. Each append serializes the in-process file write and chain-head update under locks and calls write_all + sync_all before advancing the in-memory head. This does not coordinate multiple server processes sharing one audit file; the supported Kubernetes deployment keeps a single PDP replica.

fsync is called on every append in both modes — a power loss between flush and the kernel page-cache flush cannot lose records.

Delegation

When agent A calls agent B, B shouldn’t inherit A’s full identity. agentguard mints scoped delegation tokens as standard JWS (RFC 7515) using Ed25519:

{
  "iss": "Agent::\"research\"",
  "sub": "Agent::\"summarizer\"",
  "aud": "agentguard",
  "iat": 1700000000,
  "exp": 1700000900,
  "allowed_actions": ["ToolCall::send_email"],
  "resource_patterns": ["Mailbox::*"]
}

Compact form: base64url(header).base64url(payload).base64url(sig).

The verifier (DelegationVerifier::verify) checks, in order:

  1. JWS parses cleanly (3 segments, valid base64url, valid JSON).
  2. alg is EdDSA and kid is registered. HS256 + Ed255SA combinations (algorithm-confusion attacks) are rejected explicitly.
  3. Signature is valid against the registered key.
  4. exp is in the future (with a configurable clock skew, default 60 s).
  5. nbf is in the past (with the same clock skew).
  6. aud matches the configured audience.

Optional structured constraints (Cedar-style path expressions evaluated against the request context) further narrow the token’s applicability.

NL policy generation

agentguard gen "<natural language>" calls an LLM with:

  1. Your schema (schema.cedarschema) as context
  2. A constrained system prompt instructing Cedar-only output
  3. Your natural-language requirement

The output is validated against your schema before being written to disk. If the LLM produces invalid Cedar or policies that don’t type-check, the command fails and you refine the prompt.

The HTTP client has a 60 s request / 10 s connect timeout and retries up to 3 times on 5xx / connect / timeout failures with exponential backoff (250 ms / 500 ms / 1 s cap).

This forms a tight feedback loop — Cedar’s validator is the ground truth.

Schema as source of truth

The Cedar schema does two important jobs:

  1. Validates policies at authoring time. If a policy references an entity type that doesn’t exist, or uses a wrong attribute type, validation fails immediately — not at runtime.
  2. Validates context at request time. The action’s declared context shape is enforced when the SDK builds a request. If send_email requires subject: String, sending a request without it fails fast.

Local-first

All state lives on disk:

This makes it trivial to:

Embedded audit storage

Embedded applications can implement the public AuditAppender or AsyncAuditAppender port and inject it with AppState::with_audit_appender or AppState::with_async_audit_appender when audit persistence is managed outside the local filesystem. Adapters must durably append before returning, report health accurately, and report is_chained only when stored records are tamper-evident. The request path fails closed on append errors and holds a bounded per-state work permit until persistence completes; readiness requires a healthy chained adapter. Async adapters do not occupy blocking workers during remote I/O. Async appends have a five-second deadline and readiness checks have a two-second deadline. A timed-out append fails closed; because a remote commit can have an uncertain outcome, adapters should account for unknown append outcomes and use idempotency facilities where available. The standalone binary continues to use the file-backed DecisionLog; a distributed audit backend is not included in the supported deployment package.

Configuration

All settings have sensible defaults; overrides come from CLI flags or environment variables. The full table is in docs/operations/runbook.md. Highlights:

Operational concerns

See docs/operations/runbook.md for the day-2 runbook and docs/adr/ for the architectural decisions behind the design.