Synsema docsENES

Agent identity & auth

Engine v0.5.6+.

Login & sessions is about a human with a browser: a password, a

cookie, a 2FA code. This page is about the other subject your server talks to — an

agent. It doesn't have a browser, it shouldn't hold a long-lived secret, and it

often needs to hand a weaker slice of its own authority to a sub-agent it spawned

five seconds ago.

Synsema treats that as a first-class case: the runtime knows who is calling, and

meters spend and rate limits per identity rather than per IP or per process.

agent-identity.syn
-- Doc example: agent identity — capability tokens that can only narrow, signed
-- requests (proof-of-possession) and per-identity metering.
-- (The server side rides on `serve`, which doesn't terminate — the page shows the
-- full flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: agent identity"
require random
-- Signing a request goes through the same deny-by-default gate as signing a
-- blockchain transaction, scoped to the key's name (here the label given to
-- as_secret) and audited. Without this line http_sign is refused.
require sign("SIG")

let root be "root-key-for-the-doctest"

let full be captoken_mint({"net": "*.example.com", "spend": "ETH"}, root,
                          {"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
let sub be captoken_attenuate(full, {"net": "api.example.com"},
                              {"ttl": 60, "spend": {"ETH": 0.01}})
print("delegated token → " + slice(sub, 0, 12) + "…")

test "a capability token verifies and reports what it grants"
    let v be captoken_verify(full, root)
    assert_ne(v, nothing)
    assert_eq(v.id, "orchestrator")
    assert_eq(v.depth, 1)
    assert(captoken_allows(v, "net", "api.example.com"))

test "attenuating needs no key, and the result is strictly weaker"
    let v be captoken_verify(sub, root)
    assert_eq(v.depth, 2)
    -- the delegated host still works…
    assert(captoken_allows(v, "net", "api.example.com"))
    -- …but the rest of the parent's glob does not, and `spend` was not delegated
    assert_eq(captoken_allows(v, "net", "other.example.com"), false)
    assert_eq(captoken_allows(v, "spend", "ETH"), false)
    -- the spend caveat came down from 0.5 to 0.01 (any unit: fiat, crypto, commodities)
    assert_eq(v.caveats.spend.ETH, "0.01")

test "attenuation can never widen — the error names the problem"
    assert_error(() => captoken_attenuate(sub, {"net": "*.example.com"}, nothing))
    assert_error(() => captoken_attenuate(sub, {"exec": nothing}, nothing))
    assert_error(() => captoken_attenuate(sub, {"net": "api.example.com"}, {"ttl": 99999}))

test "a forged or tampered token is nothing, never a partial success"
    assert_eq(captoken_verify(full, "another-root"), nothing)
    assert_eq(captoken_verify("not-a-token", root), nothing)
    assert_eq(captoken_verify(slice(full, 0, 20), root), nothing)

test "expiry and revocation"
    let short be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "id": "temp-1"})
    assert_ne(captoken_verify(short, root), nothing)
    -- `at` moves the clock forward for a deterministic test
    assert_eq(captoken_verify(short, root, {"at": 4102444800}), nothing)
    -- revocation is a denylist of ids (typically read from redis)
    assert_eq(captoken_verify(short, root, {"revoked": ["temp-1"]}), nothing)

test "contextual caveats are fail-closed"
    let scoped be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "aud": "orders-api"})
    assert_ne(captoken_verify(scoped, root, {"aud": "orders-api"}), nothing)
    assert_eq(captoken_verify(scoped, root, {"aud": "other-api"}), nothing)
    -- not supplying the context at all is a rejection: you cannot claim a
    -- condition holds if you never checked it
    assert_eq(captoken_verify(scoped, root), nothing)

test "signed requests: the signature covers method, URL and body"
    let key be as_secret("shared-signing-key", "SIG")
    let req be {"method": "POST", "url": "https://api.example.com/orders", "body": "{\"n\":1}"}
    let headers be http_sign(req, key, {"alg": "hmac-sha256", "keyid": "agent-7"})
    let incoming be {"method": "POST", "url": "https://api.example.com/orders",
                     "headers": headers, "body": "{\"n\":1}"}
    let v be http_signature_verify(incoming, key, {"alg": "hmac-sha256"})
    assert_eq(v.keyid, "agent-7")
    -- same signature, different body → the content-digest no longer matches
    let tampered be {"method": "POST", "url": "https://api.example.com/orders",
                     "headers": headers, "body": "{\"n\":999}"}
    assert_eq(http_signature_verify(tampered, key, {"alg": "hmac-sha256"}), nothing)

test "the verifier pins the algorithm — never reads it from the message"
    let key be as_secret("shared-signing-key", "SIG")
    let req be {"method": "GET", "url": "https://api.example.com/health"}
    let headers be http_sign(req, key, {"alg": "hmac-sha256"})
    let incoming be {"method": "GET", "url": "https://api.example.com/health", "headers": headers}
    -- a verifier expecting ed25519 rejects an hmac message outright
    assert_eq(http_signature_verify(incoming, key, {"alg": "ed25519"}), nothing)
    -- and omitting alg is a programmer error, not a permissive default
    assert_error(() => http_signature_verify(incoming, key))

test "oidc_verify demands iss and aud"
    assert_error(() => oidc_verify("a.b.c", {"aud": "my-client", "jwks": "{}"}))
    assert_error(() => oidc_verify("a.b.c", {"iss": "https://idp", "jwks": "{}"}))
    -- a bad token with complete options is nothing, not an error
    assert_eq(oidc_verify("not-a-jwt", {"iss": "https://idp", "aud": "my-client", "jwks": "{}"}), nothing)

test "spend_total reads per-identity totals"
    assert_eq(spend_total("ETH"), 0)
    assert_eq(spend_total("ETH", "orchestrator"), 0)

Why not just give the agent an API key

An API key in an agent's context is a bearer credential inside the most leak-prone

place in the system — a prompt. Three properties change that:

(oidc_verify below); terminals can use a device flow. Nothing to steal.

possession of a key that never leaves the sealed secret.

universal practice today is passing along the same key — total impersonation.

Capability tokens let you hand over strictly less.

Capability tokens — delegation that can only narrow

A captoken says what the holder may do, and the holder can weaken it offline,

without the root key:

-- the spend unit is whatever the host works in: fiat, crypto, commodities, credits
let full be captoken_mint({"net": "*.example.com", "db": "postgres://localhost/app", "spend": "ETH"},
                          secret("ROOT_KEY"),
                          {"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})

-- handing work to a sub-agent: one host, no database, a fiftieth of the budget
let sub be captoken_attenuate(full, {"net": "api.example.com", "spend": "ETH"},
                              {"ttl": 60, "spend": {"ETH": 0.01}})

captoken_attenuate takes no key — that's the whole point, and why delegation

doesn't need a round trip to whoever minted the token. It works because each block

of the chain is signed with the previous signature as its key (the macaroon

construction): the holder can append a block, never remove or edit one.

The permissions are the same shapes as requirenet("api.example.com"),

db("postgres://host/db"), file.read("./data/*") — and narrowing is checked with

the same covers() relation the capability system uses, so a scope in a token means

exactly what it means in a require. Widening is refused twice: when you

attenuate (with an error that says what didn't fit) and again when the token is

verified, so a hand-forged block can't widen either.

Verifying, and what "fail-closed" means here

let caps be captoken_verify(token, secret("ROOT_KEY"),
                            {"aud": "orders-api", "ip": request.ip, "method": request.method})
when caps == nothing
    give unauthorized("bad token")
when not captoken_allows(caps, "net", target_host)
    give fail(403, "that host is not in your token")

Every failure — bad signature, expired, forged, revoked, wrong audience — is

nothing, with no hint about which one: an endpoint must not be distinguishable by

why it rejected you. And a caveat you don't supply context for is a rejection,

not a pass: if the token says aud: "orders-api" and you never pass aud, you

cannot claim the condition holds.

Revocation, said out loud

Attenuation is offline, so there is no central check to hang revocation on. The

design answer is two things, and you should decide both before an incident, not

during one:

typically read from redis. id is what you revoke; set it explicitly for tokens

you'll want to name later.

Signed requests (proof-of-possession)

Instead of sending a token that anyone who copies it can replay, sign the request:

let key be secret("AGENT_KEY")             -- inside the handler, see the pitfall below
let req be {"method": "POST", "url": "https://api.partner.com/orders", "body": payload}
let headers be http_sign(req, key, {"alg": "ed25519", "keyid": "agent-7"})
let res be http_post("https://api.partner.com/orders", payload, headers)

Synsema implements a pinned profile of RFC 9421: the signature always covers

@method, @target-uri and content-digest (the digest goes even for an empty

body — if it were optional, someone could attach a body to a signed request without

breaking the signature), plus created, keyid and alg. No arbitrary component

lists, no general structured-field canonicalisation: that surface is where

implementations get quietly wrong.

Signing goes through the same door as signing a blockchain transaction:

require sign("AGENT_KEY"), deny-by-default, and every signature lands in the audit

log. The key is a sealed secret and never becomes a language value.

On the receiving end, http_signature_verify is the symmetric half — and it

requires you to pin the algorithm:

let v be http_signature_verify(incoming, pubkey, {"alg": "ed25519"})

That's not ceremony. If the verifier took alg from the message, an attacker would

switch it to hmac-sha256 and sign using your public key as the HMAC secret —

the same confusion attack that broke JWT libraries for years. The verifier chooses

the algorithm; the message never does. The default anti-replay window is ±300s on

created (a future timestamp is rejected too — otherwise it would be a deferred

replay), and if the client sent a nonce, verification hands it back so you can

check it against your own replay store on mutating routes.

Third-party identity: OIDC and cloud workloads

jwt_verify is for tokens you signed with your secret. For a token from

Google, GitHub, Auth0 — or from the cloud your agent is running in — use

oidc_verify:

require net("www.googleapis.com")

let claims be oidc_verify(id_token, {
    "iss": "https://accounts.google.com",
    "aud": "your-client-id.apps.googleusercontent.com",
    "jwks_url": "https://www.googleapis.com/oauth2/v3/certs"
})

iss and aud are mandatory, and that is a deliberate design decision rather

than an oversight to fix later: a token minted by the same provider for a *different

application* has a perfectly valid signature. Checking the signature without the

audience is the classic confused-deputy hole. RS256 and ES256 are supported (RSA

below 2048 bits is refused); the JWKS is fetched over net, cached for 10 minutes,

and re-fetched when a kid shows up that isn't in the cached set — which is exactly

what key rotation looks like.

Note which failures are nothing and which are errors: a bad token is

nothing; a JWKS that can't be fetched is an error. "I couldn't verify it" must

never be mistaken for "it isn't valid".

This is what makes workload identity work: an agent on AWS, GCP, Azure or GitHub

Actions already has an identity issued by the platform. Read it from the metadata

service, exchange it for short-lived credentials, and there is no long-lived secret

in your .env at all. (On AWS always use IMDSv2 — the token-first flow; v1 is the

classic SSRF vector.)

mTLS — identity by certificate

For service meshes and partners that require client certificates:

require file.read("./certs/*")
mtls_identity("./certs/agent.pem", "./certs/agent.key",
              {"hosts": ["*.mesh.internal", "vault.example"]})
-- https:// requests to those hosts present the certificate; the rest don't

It's per process, not per request, because a certificate identifies the

workload — the same idea as SPIFFE.

opts.hosts takes a list of hosts (or a single host as text) and follows the same

wildcard rule as require net: "*.mesh.internal" covers the domain and its

subdomains. Omit it and the certificate goes to every host the program can reach

— already bounded by require net, so a narrow net scope contains it on its own.

Declare hosts when your net scope is broad: presenting a client certificate is

saying "I am this agent", and a third party that merely asks for one shouldn't be

able to harvest your workload identity.

Terminating mTLS on the serve side (verifying incoming client certs) isn't in the

engine yet; put a reverse proxy in front for that.

Serving agents: identity, quotas and discovery

The server side lives in Serve. The short version:

for an agent, the session for a human, nothing for neither.

or a plain text) and uses it to meter:

- rate limit per identity in addition to the per-IP shield that runs before

auth (that shield is what stops an anonymous flood from burning a worker on

every auth attempt);

- spend, booked per identity in the ledger and in the audit line — and a

captoken's spend caveat becomes a real ceiling the server enforces, which

is how an orchestrator caps a sub-agent's budget for real rather than by

convention. Three ceilings can apply at once and the strictest wins: per unit

(SYNSEMA_SPEND_CEILING="EUR:500,ETH:0.1"), per identity

(SYNSEMA_SPEND_CEILING_PER_IDENTITY="agent-1=EUR:50" — its own variable, with

= before the unit, so a unit containing : can never collide with an identity

key) and the delegated one. The **unit is free text and no currency is

privileged**: fiat, crypto, commodities, credits, kWh — amounts keep up to 28

decimal places, so an 18-decimal crypto unit fits whole.

understands and which endpoints are protected — the machine-readable companion to

/llms.txt, for an agent that never read this page.

Choosing between the mechanisms

You needUse
A sub-agent to do less than you cancaptoken_mint + captoken_attenuate
A stolen credential to be worthlesshttp_sign / http_signature_verify
"Log in with Google/GitHub"oidc_verify
No secret in the environment at all (cloud)workload identity → oidc_verify
A service mesh that demands client certsmtls_identity
A human with a browserLogin & sessions