aic-agent — README (EN)EN

aic-agent

Go client SDK for AIC-protected services. The consumer-side counterpart of aic-verifier: it mints and carries the agent's AIC credential and calls services protected by AIC authorization.

Status: early. The API, and the CLC revision it evaluates (currently CLC-1.8), may change before the first stable release.

Documentation: start at docs/index.md — quickstart, API, reference, architecture, threat model, deployment, examples.

Stability. Frozen until v1.0 (additive-only): Agent and its Do/Close lifecycle, Config, the identity/bearer/transport package entry points, and the request/response types they exchange. Experimental (may change in a minor release): the evidence-transport helpers and anything under examples/.

What it does

An agent (a service-to-service caller or an autonomous agent) needs to prove an AIC credential to reach an API protected by aic-verifier. This SDK covers the whole consumer side:

  • identity — generate/load the agent key (P-256 ECDSA or Ed25519), derive SPKI (kid) and RFC 7638 JWK thumbprints (jkt for cnf), and build the AIC principal.
  • bearer — mint a local Bearer AIC-JWT (aic+jwt) signed with the agent key, and exchange a delegation-authorization (DA) assertion for a short-lived token at an issuer token endpoint (RFC 7523 jwt-bearer grant).
  • transporthttp.RoundTrippers that attach the credential: a Bearer header transport with refresh-on-401, and an mTLS transport with an AIC-bearing client certificate.
  • aicagent — one-stop Agent client combining identity, minting and the HTTP transports.

Install

go get github.com/varwof/aic-agent@latest

Requires Go 1.26+. It builds against the published github.com/varwof/types v0.6.0 and github.com/varwof/register v0.6.0; no local replace directives are needed.

Quickstart (Bearer AIC-JWT)

key, _ := identity.GenerateKey(identity.ES256) // persisted as agent-key.pem
agent, _ := aicagent.New(aicagent.Config{
    Mode:     aicagent.Bearer,
    Key:      key,
    Issuer:   "aic-verifier-example",
    Audience: []string{"myapi"},
    Subject:  "agent-001",
    Realm:    "example",
    ID:       "agent-001",
    Capabilities: []aicjwt.Capability{{Scheme: "demo", ID: "api:read"}},
})
resp, _ := agent.Get("https://api.example/myapi")

Locally-signed bearer credentials are minted fresh per request: aic-verifier treats each token's jti as single-use by default (ReplayProtection), so the agent never reuses a cached token for local Key mode. Token() still returns a cached token for inspection; the transport calls the per-request mint path. A pre-minted Config.Token is necessarily single-use under the same policy.

Quickstart (mTLS client certificate)

agent, _ := aicagent.New(aicagent.Config{
    Mode:     aicagent.MTLS,
    CertFile: "client-cert.pem", // carries the AIC X.509 extension
    KeyFile:  "client-key.pem",
    ServerCA: "ca-cert.pem",     // service CA; empty = system roots
})
resp, _ := agent.Get("https://api.example/myapi")

Exchange from an issuer (RFC 7523)

agent, _ := aicagent.New(aicagent.Config{
    Mode:         aicagent.Bearer,
    RemoteIssuer: issuerURL,            // token endpoint
    DA:           daAssertion,          // minted with bearer.SignDA
    Audience:     []string{"myapi"},
    Subject:      "agent-001",
    Realm:        "example",
    ID:           "agent-001",
})
// tokens are fetched from the issuer and auto-refreshed on 401

AIC issuance through user-signer

When the agent has no AIC certificate yet, it obtains one through a human-in-the-loop flow: the agent never holds the principal's private key, so it asks user-signer for a Delegation Authorization, a human approves the request in the console, and the resulting evidence is presented to varwof core, which mints the AIC certificate for a key the agent generated locally.

submit AIC request ──▶ user-signer ──▶ human approves (console)
                     ◀── DA evidence {da, user_cert_pem, constraints}
build CSR (own key) + evidence ──▶ core POST /api/v1/certs (agent-proxy)
                     ◀── AIC certificate

One-shot:

key, _ := identity.GenerateKey(identity.ES256)
agent, _ := aicagent.New(aicagent.Config{Mode: aicagent.Bearer, Key: key})

res, _ := agent.ObtainAIC(ctx, aicagent.AICRequestOptions{
    SignerURL:    "https://127.0.0.1:8444", // user-signer mTLS
    CoreURL:      "https://127.0.0.1:4433", // varwof core
    AgentID:      "spiffe://varwof.com/agent/ci-bot",
    PrincipalUID: "pki:alice@example.com",
    Capabilities: []aicagent.AICCapability{
        {SchemeID: "varwof/demo-mysql-v1", CapabilityID: "SELECT:*"},
    },
    ReasonCode:  "operator-request",
    Description: "CI bot needs one-time DB read access",
    LifetimeSec: 900,
})
// res.CertPEM is the issued AIC certificate; res.Constraints mirrors the
// effective constraint set written into the AIC extension.

ObtainAIC is SubmitAICRequestWaitForAICGenerateCSRIssueAICCertificate; each step is exported when you need to interleave your own logic (e.g. surface the request id to an operator before waiting).

  • SubmitAICRequest(ctx, opt) (string, error) — enqueue the request, returns its id. A configured Config.Key makes the delegation a v2 DA bound to the agent's SPKI (DAVersion: 1 opts into legacy v1).
  • WaitForAIC(ctx, opt, id, timeout, poll) (*AICRequestStatus, error) — poll until a human approves or rejects.
  • IssueAICCertificate(ctx, opt, status, csrPEM) (*AICResult, error) — present the evidence plus the agent's CSR to core; the effective constraints the DA was signed with are forwarded as authorization_constraints, so the AIC extension stays aligned with the delegation.

MCP client over the issued AIC (mcpclient/)

mcpclient.New runs the flow above and presents the minted (short-lived, memory-only) AIC certificate as the mTLS identity on every JSON-RPC request to an AIC-gated MCP server. The certificate is re-obtained as its validity runs out; no on-disk cache.

c, _ := mcpclient.New(ctx, mcpclient.Options{
    SignerURL:    "https://127.0.0.1:8444", // user-signer mTLS
    CoreURL:      "https://127.0.0.1:4433", // varwof core
    ServerURL:    "https://127.0.0.1:9443/mcp",
    ServerCA:     "/data/ca.pem",           // pin the MCP server certificate
    AgentID:      "mcp-agent",
    PrincipalUID: "pki:alice@example.com",
    Capabilities: []aicagent.AICCapability{
        {SchemeID: "varwof/demo-mysql-v1", CapabilityID: "SELECT:*"},
    },
    ReasonCode: "operator-request", Description: "MCP DB reads",
})
defer c.Close()

sess, _ := c.Connect(ctx)        // initialize over mTLS
tools, _ := sess.Tools(ctx)      // tools/list
res,  _ := sess.Call(ctx, "echo", map[string]any{"message": "hi"}) // tools/call

examples/mcp-client drives the chain from the CLI (list tools, then call one). The certificate is bound to the agent's own key via the CSR, so the server's IdentityAIC admission sees the same agent identity that obtained the delegation.

To present an already-issued AIC (operator-provisioned path — e.g. the AIC X.509 cert the mcp-behind-proxy --mtls demo mints), set Options.AICCertPEM / Options.AICKeyPEM; mcpclient then skips the user-signer/core pipeline and uses the pair as its mTLS identity.

CLC client-side pre-check

aic-agent/clc.go lets an agent decide an operation against its declared capabilities before spending a request, using the same CLC-v1 core (register/semantics) that aic-verifier decides with, so client and server verdicts agree by construction:

cfg := aicagent.Config{Capabilities: []aicjwt.Capability{
    {Scheme: "std/database-v1", ID: "query:SELECT", Params: json.RawMessage(`{"limit":100}`)},
}}
dec, _ := cfg.Authorize("std/database-v1:query:SELECT", map[string]any{"limit": 50})
switch dec.Verdict {
case semantics.VerdictAllow:
    // proceed
case semantics.VerdictAllowUR:
    // recognized residual obligation (dec.Unresolved) still needs confirming
default: // VerdictDeny — dec.Reason carries the normative code
}

Server denials: plain, or evidence-challenge-bearing

aic-verifier (v0.2.0) denies in two shapes, and aic-agent parses both into a *RefusalError that is matchable with errors.As:

  • plain denialContent-Type: application/json, body {"code":"access_denied","message":…};
  • remediable denialContent-Type: application/problem+json (RFC 9457), body carries type / title / status / detail plus an extended challenge member (a CLC-CHALLENGE-v1), and the response may also carry a Retry-After header (seconds).

Reading the challenge is one errors.As:

var ref *aicagent.RefusalError
if errors.As(err, &ref) && ref.Challenge != nil {
    // ref.Challenge.Required[].id/constraint/reason says what evidence is still
    // missing; ref.RetryAfterSec says when a corrected presentation may retry.
    // Non-2xx that cannot possibly be remediated is no challenge — but a
    // challenge that was announced yet unparseable sets
    // ref.ChallengeUnavailable, never a silent "no challenge".
}

allow_unresolved is not allow: neither the client pre-check above nor the server's challenge is a verdict. An operation granted allow outright is safe to run; one that comes back allow_unresolved (or with a challenge) still has an outstanding obligation the caller must discharge first.

Consuming the challenge: closing an evidence-required denial

There is no wire channel for evidence — the agent never uploads recorded records to the verifier. Evidence is presented by the deployment: the verifier's EvidenceRequirement / EvidenceFacts hook decides admission from facts the deployment itself established (e.g. human authorization receipts the deployment recorded and stored). The challenge tells the agent what is missing (required[].id/constraint) and when it may retry (Retry-After, an integer seconds header; the client treats it as retry_timing.not_before).

aic-agent implements the client half of that mechanism. Configure a provider:

a, err := aicagent.New(aicagent.Config{
    Token: "…",
    // after an evidence-required denial and any Retry-After wait, the SDK asks
    // the deployment whether the facts are now in place for the verifier gate.
    EvidenceProvider: func(ctx context.Context, ref *aicagent.RefusalError) (bool, error) {
        if !recordHumanAuthorization(ref.Challenge) { // deployment glue
            return false, nil
        }
        return true, nil // retry the identical request
    },
})

When the core (or a peer reached through coreDo) answers an evidence-required challenge, the SDK:

  1. rejects the retry if the challenge is already stale — Challenge.Usable: expired challenges are never retried (that is the load amplification the challenge specification forbids);
  2. waits out Retry-After (or retry_timing.not_before), never past expires_at;
  3. asks your EvidenceProvider after the wait (the challenge must still be live the instant it retries);
  4. retries the byte-identical request exactly once.

A second evidence-required denial is returned as-is. A plain denial, a challenge that was announced but unparseable (ChallengeUnavailable), a stale challenge, a declined provider, or a provider error all reach the caller as the *RefusalError (or its error) — never as a blind retry.

The same machine guards data-plane requests through Agent.Do: with an EvidenceProvider configured, Do closes an evidence-required denial with the identical wait → provider → single byte-identical retry loop, and returns the refusal when it cannot be closed. Without a provider (or for a denial with no usable challenge), Do hands the response back unchanged — the request is replayed verbatim and never more than once.

Smoke tests

go test -tags smoke -v ./smoke/

smoke/ drives the normative CLC-v1 corpus (the register vectors) through the SDK glue and exercises the user-signer → core issuance flow against stub servers.

Structure

aicagent.go     Agent client aggregating identity + transports
aicrequest.go   user-signer submission → approval → core AIC issuance
mcpclient/      MCP client presenting the issued AIC over mTLS
clc.go          CLC-v1 client-side capability/operation decision glue
identity/        agent key material and fingerprints
bearer/          local minting (Sign) and DA exchange (SignDA, IssuerClient)
transport/       Bearer and mTLS http.RoundTrippers
smoke/           CLC corpus conformance + issuance-flow smoke (build tag smoke)
examples/
  call-bearer/   calls aic-verifier/examples/bearer-jwt-backend
  call-mtls/     calls aic-verifier/examples/mtls-backend
  mcp-client/    lists/calls tools on an AIC-gated MCP server
  smoke-call/    presents an issued AIC cert to an aic-verifier service

See examples/ headers for the exact run commands that pair with the aic-verifier service examples.

Related repositories

Repository Role
varwof/types Shared Go types: AIC, AIC-JWT, capabilities
varwof/register Capability registry, PKCS#7 signing, CLC semantics
varwof/capability Capability definitions, CLC spec and conformance corpus
varwof/core CA and AIC issuance
varwof/client CLI for issuance and delegation signing
varwof/gateway-core · varwof/gateway Full gateway; this SDK is its embeddable admission core
varwof/aic-verifier Service-side SDK that verifies the credentials this agent presents

License

Apache-2.0. See LICENSE.

See also SECURITY.md (how the credential is held, vulnerability reporting, hardening), CONTRIBUTING.md (development and the check list), and CHANGELOG.md.