@muretai/agent-entry 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,7 +114,7 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
114
114
  | `domains` | none | the domains this entry speaks for (see below) |
115
115
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
116
116
  | `wbaVerifiers` | none | a JWKS document (`{"keys":[…]}`) of Ed25519 keys whose holders this entry should **recognise** on inbound signed requests (Web Bot Auth / RFC 9421 — see *Who is knocking*). Recognition only adds `env.wba_did` and a visit count; it never changes a verdict |
117
- | `observer` | none | called once per message with the same envelope your responder gets, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-your-customer-list) |
117
+ | `observer` | none | called once per message with the same envelope your responder gets, plus `stage`, `identified`, `ua_family` and `client_class`, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-your-customer-list) |
118
118
  | `howToUrl` | none | a page a keyless visitor is pointed at as a worked example. **Empty means omitted** — the refusal already teaches the whole recipe without it, and a reference implementation must not stamp somebody else's docs host into every door built from it. Only set it to a URL you operate, and only after checking it resolves |
119
119
  | `name`, `description`, `version` | — | the card's own words. `description` is the line a person reads in a directory listing — and the right place to say what you record about visitors, since it is fetched **before** the knock |
120
120
 
@@ -131,12 +131,21 @@ door counts it:
131
131
  ```js
132
132
  entry.stats()
133
133
  // { gptbot: { card_get: 12, signed_post: 3 },
134
- // browser: { notice_get: 5 } }
134
+ // browser: { notice_get: 5, card_get: 2 } }
135
+
136
+ entry.clientStats()
137
+ // { 'declared-agent': { card_get: 12, signed_post: 3 },
138
+ // 'human-like': { notice_get: 5 },
139
+ // 'stealth-agent': { card_get: 2 } }
135
140
  ```
136
141
 
137
142
  Each request's `User-Agent` is classified into a fixed family (`claude-user`,
138
143
  `claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
139
- `curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
144
+ `camoufox`, `playwright`, `puppeteer`, `selenium`, `headless-chrome`,
145
+ `curl`, `browser`, `none`/`other`) and counted by stage. `clientStats()` then
146
+ folds those families into four owner-facing classes — `declared-agent`,
147
+ `named-tool`, `stealth-agent`, `human-like` — so a Firefox-looking fetch of the
148
+ card is counted as a stealth agent, not as a person. In-process state like the
140
149
  ledger — read it, log it, ship it to your analytics; it is never served on the wire.
141
150
  Every caller also gets one nudge: `GET /` answers with a single `Link:` field carrying
142
151
  two relations — `rel="service-desc"` (RFC 8631) first, then the door pointer
@@ -145,6 +154,24 @@ handed the machine-readable door, whatever its `User-Agent` claims. The body sta
145
154
  byte-identical for every caller, and so does the header: classification feeds the
146
155
  counters above and never a byte on the wire.
147
156
 
157
+ **Since 1.8.0 the watcher is told the same thing.** `entry.stats()` always counted family by
158
+ stage, but the per-visit row handed to your `observer` carried `ua_family` only on the card and
159
+ notice fetches — so you could see that a browser *read* your card and not that a browser was the
160
+ thing being *refused*. The two questions an operator actually has — is this a crawler, is
161
+ somebody's agent failing to sign — were answerable only for the visitors who never knocked. All
162
+ five stages now carry it, refusals included, so "which clients got in and which were turned away"
163
+ is one query instead of two half-answers. Nothing else moved: no wire byte, no verdict, no ledger
164
+ row, no rate lane, and `stats()` is unchanged.
165
+
166
+ **Since 1.9.0 a Firefox-looking card fetch is not a human.** `stats()` still files it under
167
+ `browser` — Camoufox's published shape is a clean Firefox UA, and that is the point of
168
+ stealth. `clientStats()` splits it: a browser that only opened the notice is `human-like`;
169
+ the same UA on the card or the door is `stealth-agent`; a leaking automation token
170
+ (`playwright`, `camoufox`, …) is `named-tool`. Export `bodySignpost()` and put that
171
+ `<a>` in the page body, because a snapshot client never sees the header or the
172
+ `<head>` tag. Still observation only — the same POST with or without that UA is the
173
+ same refusal.
174
+
148
175
  One rule holds this together, enforced by the contract suite rather than promised:
149
176
  **a User-Agent never affects `verified`, an account row, a rate limit, or any
150
177
  refusal.** A UA string is written by the client; a door that trusted it would be a
@@ -219,8 +246,8 @@ framework, an edge worker — that notice never renders**, and your home page is
219
246
  people with nothing machine-readable in it. The address ends up published in a card nobody was
220
247
  told to fetch.
221
248
 
222
- So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
223
- fallback for the other:
249
+ So put the pointer on every page a visitor might land on, in **all three** spellings. None is a
250
+ fallback for the others:
224
251
 
225
252
  ```
226
253
  Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
@@ -228,13 +255,17 @@ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
228
255
 
229
256
  ```html
230
257
  <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
258
+ <a href="/.well-known/agent-card.json" rel="https://muretai.net/rel/agent-entry">This site answers agents at /.well-known/agent-card.json</a>
231
259
  ```
232
260
 
233
261
  The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
234
- endpoint requires a request to that host. The two spellings exist because the two kinds of client
262
+ endpoint requires a request to that host. The three spellings exist because three kinds of client
235
263
  have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
236
- header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
237
- which kind arrived.
264
+ header; one that reads only headers never parses the HTML; and a snapshot / ARIA client
265
+ (Camofox, Playwright accessibility dumps) sees only `<body>`, so the `<link>` in `<head>`
266
+ vanishes too. Shipping one is a coin flip on which kind arrived. The module exports
267
+ `bodySignpost()` so the body `<a>` is one function call, not a string you have to keep in
268
+ sync with the relation URI.
238
269
 
239
270
  We know because we shipped one. An agent that had never been told about our door was handed only
240
271
  the domain, fetched the page, read the copy written for humans, and stopped — while the door had
@@ -244,13 +275,13 @@ Then check it from outside, because this is exactly the class of thing that look
244
275
 
245
276
  ```bash
246
277
  curl -sI https://studio.example/ | grep -i '^link:' # the header half
247
- curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
278
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half AND the body <a>
248
279
  ```
249
280
 
250
- Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
251
- to markdown**, which is a common way an agent reads the web headers are dropped and so is
252
- everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
253
- that agents are answered here, and name the card path in text a reader can act on.
281
+ Worth knowing before you call it done: **the header and the `<head>` tag disappear in a fetch
282
+ that converts the page to markdown, and in an accessibility snapshot that only sees `<body>`**
283
+ a common way an agent browser reads the web. The body `<a>` is the spelling those clients
284
+ can still see.
254
285
 
255
286
  ### Check that your own CDN is not refusing your door
256
287
 
@@ -696,6 +727,17 @@ npm test
696
727
 
697
728
  Write a third implementation and point it at the same vectors.
698
729
 
730
+ ## Contributing
731
+
732
+ This repo is a **published mirror**, rendered out of a private working repository — not the
733
+ place the next change is written. A pull request opened here will not merge: the next release
734
+ overwrites this checkout wholesale from the source, carrying no memory of a branch made
735
+ against it.
736
+
737
+ That is not a closed door. **Open an issue** — a bug, a wire-vector disagreement, a place the
738
+ docs are wrong, a design question — and it gets read and, where it's right, becomes the next
739
+ release here. That path works; a PR against these files does not.
740
+
699
741
  ## What this is part of
700
742
 
701
743
  [Muretai](https://muretai.com) is a network where AI agents that belong to *different
@@ -92,12 +92,20 @@ for (const v of vectors.envelope) {
92
92
  // ---------------------------------------------------------------- the refusals
93
93
  // The half that catches an implementation which verifies nothing.
94
94
  for (const v of vectors.reject.message) {
95
- const fields = { from: v.from, to: v.to, messageId: v.messageId,
96
- contextId: v.contextId ?? null, timestamp: v.timestamp,
97
- text: v.text, sig: v.sig };
95
+ // The case's message lives under `input`; `recipientDid` (when a case pins one, as
96
+ // `wrong-recipient` does) sits beside it at the top level. Reading the message from the top
97
+ // level instead built `{contextId: null}` with an undefined recipient, which every verifier
98
+ // refuses for being empty - so all six checks passed without ever exercising the attack they
99
+ // are named for. Proven by mutation: with the signature check neutered and field-presence
100
+ // left intact, this file still printed "every case that must be refused was". With the
101
+ // wiring correct the same mutant turns four checks red, `from-not-signer` among them.
102
+ const m = v.input ?? v;
103
+ const fields = { from: m.from, to: m.to, messageId: m.messageId,
104
+ contextId: m.contextId ?? null, timestamp: m.timestamp,
105
+ text: m.text, sig: m.sig };
98
106
  let accepted;
99
107
  try {
100
- accepted = verifyEnvelope(fields, { recipientDid: v.recipientDid ?? v.to });
108
+ accepted = verifyEnvelope(fields, { recipientDid: v.recipientDid ?? m.recipientDid ?? m.to });
101
109
  } catch {
102
110
  accepted = false; // refusing by throwing is still refusing
103
111
  }
@@ -183,17 +183,24 @@ const server = entry.listen(port, host, () => {
183
183
  // printed only when they changed. `[ua]` is greppable; the shape is entry.stats()
184
184
  // verbatim ({family: {stage: n}}). `unref()` so the timer never holds the process open.
185
185
  let lastStats = '';
186
+ let lastClient = '';
186
187
  setInterval(() => {
187
188
  // Sorted keys at every level so the line is stable run to run (and diffable against
188
189
  // the Python runner's `json.dumps(..., sort_keys=True)` spelling of the same shape).
189
- const line = JSON.stringify(entry.stats(), (k, v) =>
190
+ const stable = (obj) => JSON.stringify(obj, (k, v) =>
190
191
  (v && typeof v === 'object' && !Array.isArray(v))
191
192
  ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]]))
192
193
  : v);
194
+ const line = stable(entry.stats());
193
195
  if (line !== '{}' && line !== lastStats) {
194
196
  console.log(`[ua] ${line}`);
195
197
  lastStats = line;
196
198
  }
199
+ const clientLine = stable(entry.clientStats());
200
+ if (clientLine !== '{}' && clientLine !== lastClient) {
201
+ console.log(`[client] ${clientLine}`);
202
+ lastClient = clientLine;
203
+ }
197
204
  }, 60_000).unref();
198
205
 
199
206
  // A port collision is the first thing anyone running this twice hits (a previous run that was
@@ -107,6 +107,17 @@ export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
107
107
  * examples/agent_entry_reference.py. */
108
108
  export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
109
109
 
110
+ /** The body `<a>` a site puts on its own front page so a snapshot / ARIA / `a[href]`
111
+ * client (Camofox, Playwright accessibility dumps, markdown converters) can still find
112
+ * the door. `<link rel>` in `<head>` and the HTTP `Link` header both vanish in those
113
+ * views; an in-body anchor with this relation does not. `href` is the card path this
114
+ * entry actually answers (mount-prefixed when the entry sits under a path). Must match
115
+ * `body_signpost` in examples/agent_entry_reference.py. */
116
+ export function bodySignpost(href = AGENT_CARD_PATH) {
117
+ const path = typeof href === 'string' && href ? href : AGENT_CARD_PATH;
118
+ return `<a href="${path}" rel="${AGENT_ENTRY_REL}">This site answers agents at ${path}</a>`;
119
+ }
120
+
110
121
  /** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
111
122
  * the card AND in the refusal, so an agent that has only one of the two still has the URL.
112
123
  *
@@ -332,6 +343,16 @@ export const UA_FAMILIES = [
332
343
  ['perplexity', 'perplexity'],
333
344
  ['google-extended', 'google-extended'],
334
345
  ['muretai-node', 'muretai-node'],
346
+ // Leaking automation UAs. Must sit BEFORE `mozilla`: Camoufox/Playwright Firefox UAs
347
+ // start with "Mozilla/5.0 …" and a needle after that would never fire. A patched
348
+ // Camoufox sends a clean Firefox UA and classifies as `browser` — that is the
349
+ // stealth-agent case `clientClass` exists for, not a miss in this table.
350
+ ['camoufox', 'camoufox'],
351
+ ['camofox', 'camoufox'],
352
+ ['playwright', 'playwright'],
353
+ ['puppeteer', 'puppeteer'],
354
+ ['selenium', 'selenium'],
355
+ ['headlesschrome', 'headless-chrome'],
335
356
  ['curl', 'curl'],
336
357
  ['mozilla', 'browser'],
337
358
  ];
@@ -346,6 +367,20 @@ export const AI_AGENT_FAMILIES = new Set([
346
367
  'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
347
368
  ]);
348
369
 
370
+ /** Automation / HTTP-tool families that named themselves in the UA. OBSERVATION ONLY —
371
+ * same rule as the rest of this table: never identity, never a verdict. Must match
372
+ * `NAMED_TOOL_FAMILIES` in examples/agent_entry_reference.py. */
373
+ export const NAMED_TOOL_FAMILIES = new Set([
374
+ 'camoufox', 'playwright', 'puppeteer', 'selenium', 'headless-chrome', 'curl',
375
+ ]);
376
+
377
+ /** The four owner-facing traffic classes `clientClass` may return. Bounded on purpose:
378
+ * an attacker-chosen UA must never become a class name. Must match
379
+ * `CLIENT_CLASSES` in examples/agent_entry_reference.py. */
380
+ export const CLIENT_CLASSES = new Set([
381
+ 'declared-agent', 'named-tool', 'stealth-agent', 'human-like',
382
+ ]);
383
+
349
384
  /** ASCII-only lowercase fold. NOT `toLowerCase()`: Unicode casing is runtime- and
350
385
  * locale-shaped (the Turkish-I class of surprise), and no needle in the table needs it —
351
386
  * folding only A-Z is what makes the same UA string classify identically in both twins. */
@@ -359,7 +394,7 @@ function asciiLower(s) {
359
394
  }
360
395
 
361
396
  /** UA string -> family. Absent/empty/non-string -> 'none'; no needle matched -> 'other'.
362
- * Total on untrusted input, and the RETURN VALUE is always one of the twelve fixed
397
+ * Total on untrusted input, and the RETURN VALUE is always one of the fixed
363
398
  * family names — never a substring of the input (bounded stats keyspace). */
364
399
  export function uaFamily(ua) {
365
400
  if (typeof ua !== 'string' || !ua) return 'none';
@@ -370,6 +405,23 @@ export function uaFamily(ua) {
370
405
  return 'other';
371
406
  }
372
407
 
408
+ /** Owner-facing traffic class. OBSERVATION ONLY — never `verified`, never a ledger row,
409
+ * never a rate lane, never a refusal. The split an operator asked for: tell agent
410
+ * traffic from human traffic WITHOUT treating a spoofable UA as a credential.
411
+ *
412
+ * Camoufox / patched Playwright send a clean Firefox UA, so `uaFamily` says `browser`.
413
+ * That is not a human reading the homepage. A human Firefox almost never GETs the
414
+ * agent card or POSTs the door; a stealth agent that found the door does. The one
415
+ * human-shaped case at the door is `browser` + `notice_get` (someone opened the
416
+ * address as a document on a site-owning mount). Must match `client_class` in
417
+ * examples/agent_entry_reference.py. */
418
+ export function clientClass(family, stage) {
419
+ if (AI_AGENT_FAMILIES.has(family) || family === 'muretai-node') return 'declared-agent';
420
+ if (NAMED_TOOL_FAMILIES.has(family)) return 'named-tool';
421
+ if (family === 'browser' && stage === 'notice_get') return 'human-like';
422
+ return 'stealth-agent';
423
+ }
424
+
373
425
  /** The FIRST User-Agent value out of a headers mapping, or null. Case-insensitive key
374
426
  * scan so an in-process host can pass any casing; Node's own `req.headers` already
375
427
  * lowercases keys and keeps only the FIRST user-agent of a duplicated pair — the Python
@@ -2254,6 +2306,15 @@ export function canonicalMount(canonUrl, basePath) {
2254
2306
  * callback, and a watcher that dialled out on the hot path would make the
2255
2307
  * visitor's answer depend on somebody else's uptime.
2256
2308
  *
2309
+ * WHAT IT IS TOLD. Every stage reports `stage`, `identified`,
2310
+ * `ua_family` — the door's own bounded classification of the client, one
2311
+ * of the fixed `UA_FAMILIES` names and NEVER a substring of what the caller
2312
+ * sent, so a stranger cannot write its own label into your metrics — and
2313
+ * `client_class`, the four-way split (`declared-agent` / `named-tool` /
2314
+ * `stealth-agent` / `human-like`) that tells an operator whether a
2315
+ * Firefox-looking knock is a stealth agent at the door or a human who
2316
+ * opened the notice. The POST stages add the envelope on top.
2317
+ *
2257
2318
  * WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
2258
2319
  * which a visitor handed you to transact with YOU. Forwarding a raw DID to
2259
2320
  * a third party shares a durable identifier its owner never offered them;
@@ -2492,6 +2553,7 @@ export function createAgentEntry({
2492
2553
  // composition to any stranger). Keyspace bounded by the fixed UA_FAMILIES table times
2493
2554
  // five stage names — an attacker choosing UA strings cannot grow it.
2494
2555
  const uaStats = new Map();
2556
+ const clientStatsMap = new Map();
2495
2557
 
2496
2558
  /** Count the stage, and tell the watcher about it.
2497
2559
  *
@@ -2509,14 +2571,21 @@ export function createAgentEntry({
2509
2571
  *
2510
2572
  * A GET carries no envelope, so the watcher is told what is true and nothing invented: no
2511
2573
  * DIDs, no text, `verified: false`. The POST stages are handed to `respond()` /
2512
- * `observeRefusal()` instead, which know the envelope — one visit, one row, never two. */
2574
+ * `observeRefusal()` instead, which know the envelope — one visit, one row, never two.
2575
+ * `ua_family` is the one field BOTH sides report, so a watcher can ask "which clients got
2576
+ * in and which were turned away" as one question instead of two half-answers. */
2513
2577
  function tally(family, stage) {
2514
2578
  let row = uaStats.get(family);
2515
2579
  if (!row) { row = new Map(); uaStats.set(family, row); }
2516
2580
  row.set(stage, (row.get(stage) || 0) + 1);
2581
+ const cls = clientClass(family, stage);
2582
+ let crow = clientStatsMap.get(cls);
2583
+ if (!crow) { crow = new Map(); clientStatsMap.set(cls, crow); }
2584
+ crow.set(stage, (crow.get(stage) || 0) + 1);
2517
2585
  if (typeof observer !== 'function') return;
2518
2586
  if (stage === 'card_get' || stage === 'notice_get') {
2519
2587
  observe({ stage, identified: 0, verified: false, ua_family: family,
2588
+ client_class: cls,
2520
2589
  peer_did: null, owner_did: null, wba_did: null, text: null });
2521
2590
  }
2522
2591
  }
@@ -2531,6 +2600,17 @@ export function createAgentEntry({
2531
2600
  return out;
2532
2601
  }
2533
2602
 
2603
+ /** A plain JSON-able copy of the traffic-class counters: { class: { stage: n } }.
2604
+ * Same contract as `stats()`: in-process only, never on the wire, bounded keyspace. */
2605
+ function clientStats() {
2606
+ const out = {};
2607
+ for (const [cls, row] of clientStatsMap) {
2608
+ out[cls] = {};
2609
+ for (const [stage, n] of row) out[cls][stage] = n;
2610
+ }
2611
+ return out;
2612
+ }
2613
+
2534
2614
  /** The `Link` header the notice route carries — the SAME one-field value for EVERY
2535
2615
  * caller. TWO relations in ONE header field (RFC 8288 allows several link-values in
2536
2616
  * one field, and one field is what keeps the two twins' bytes identical through their
@@ -2771,6 +2851,22 @@ export function createAgentEntry({
2771
2851
  * than being passed it. */
2772
2852
  let pendingStage = null;
2773
2853
 
2854
+ /** The client family for the POST currently in flight, handed to the same observation
2855
+ * points as `pendingStage` and for the same reason. The GET stages already carry
2856
+ * `ua_family` — it is what `tally()` counts under — and a KNOCK, the stage where "who is
2857
+ * this client" matters most, was the one arriving without it. A watcher could see that a
2858
+ * browser fetched the card and NOT that a browser was the thing being refused, which
2859
+ * leaves the two questions an operator actually has (is this a crawler? is somebody's
2860
+ * agent failing to sign?) answerable only for the visitors who did not try.
2861
+ *
2862
+ * Derived from the same request `route()` derived its own `family` from, and `uaFamily` is
2863
+ * a pure function of that one header, so the two cannot disagree. Recomputed rather than
2864
+ * threaded through a signature every call site would have to remember to pass — the same
2865
+ * argument `rpcError` makes for shadowing itself a few lines below. Set and read together
2866
+ * with `pendingStage`, so it inherits exactly that field's accepted skew under an async
2867
+ * responder and can never disagree with the stage it is reported beside. */
2868
+ let pendingFamily = 'none';
2869
+
2774
2870
  /** Shadows the module-level `rpcError` for the whole entry: same return value, and it
2775
2871
  * remembers the code on the way out. A local alias rather than seventeen edits, and rather
2776
2872
  * than a parameter every refusal site would have to remember to pass. */
@@ -2806,6 +2902,7 @@ export function createAgentEntry({
2806
2902
  // whichever observation point fires. `tally()` computes the same thing afterwards from the
2807
2903
  // finished reply, for the counters; the two agree because they ask the same question.
2808
2904
  pendingStage = postRequestStage(rawBody);
2905
+ pendingFamily = uaFamily(uaOf(reqHeaders));
2809
2906
  const out = handlePostLadder(rawBody, reqHeaders);
2810
2907
  if (isThenable(out)) return out.then((o) => { observeRefusal(); return o; });
2811
2908
  observeRefusal();
@@ -2817,7 +2914,8 @@ export function createAgentEntry({
2817
2914
  function observeRefusal() {
2818
2915
  if (typeof observer !== 'function' || lastRefusal === null) return;
2819
2916
  observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
2820
- identified: pendingStage === 'signed_post' ? 1 : 0,
2917
+ identified: pendingStage === 'signed_post' ? 1 : 0, ua_family: pendingFamily,
2918
+ client_class: clientClass(pendingFamily, 'refused_post'),
2821
2919
  peer_did: null, owner_did: null, wba_did: null, text: null });
2822
2920
  }
2823
2921
 
@@ -3063,7 +3161,8 @@ export function createAgentEntry({
3063
3161
  // The answered case: the envelope already says who this was, and the stage says how they
3064
3162
  // arrived. `identified` is read off the envelope rather than the stage, because the
3065
3163
  // anonymous lane answers a visitor who genuinely presented no DID.
3066
- observe({ ...env, stage: pendingStage,
3164
+ observe({ ...env, stage: pendingStage, ua_family: pendingFamily,
3165
+ client_class: clientClass(pendingFamily, pendingStage),
3067
3166
  identified: env && env.peer_did ? 1 : 0 });
3068
3167
  let answer;
3069
3168
  try {
@@ -3360,9 +3459,10 @@ export function createAgentEntry({
3360
3459
 
3361
3460
  // `mount` is exported so a host app can route exactly what this entry answers (and log
3362
3461
  // it): it is derived, so reading it here can never disagree with the signed card.
3363
- // `stats` is the owner-facing UA-family counters and `wbaVisits` the DID->count of
3364
- // WBA-verified fetches both in-process only, like `ledger`.
3365
- return { did, card, ledger, mount, stats, wbaVisits,
3462
+ // `stats` is the owner-facing UA-family counters, `clientStats` the four-way
3463
+ // traffic-class split, and `wbaVisits` the DID->count of WBA-verified fetches
3464
+ // all in-process only, like `ledger`.
3465
+ return { did, card, ledger, mount, stats, clientStats, wbaVisits,
3366
3466
  handleRequest, handleRequestAsync, listen,
3367
3467
  cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
3368
3468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",
@@ -12,6 +12,7 @@
12
12
  "conformance/",
13
13
  "examples/server.mjs",
14
14
  "LICENSE",
15
+ "spec/",
15
16
  "README.md"
16
17
  ],
17
18
  "engines": {
package/spec/v1.md ADDED
@@ -0,0 +1,648 @@
1
+ # Agent Entry v1
2
+
3
+ **An HTTP endpoint that lets a website recognise an AI agent it has never met, and answer it
4
+ in the same request.**
5
+
6
+ Status: **Draft.** Version 1. This document describes behaviour that is deployed and running.
7
+ Editor: Muretai. Feedback: <https://github.com/muretai/agent-entry/issues>.
8
+ Licence: this specification is published under the same MIT licence as the reference
9
+ implementation.
10
+
11
+ ---
12
+
13
+ ## 1. What this specifies, and the one rule that shaped it
14
+
15
+ An **Agent Entry** is a small HTTP surface a website serves so that an autonomous agent —
16
+ one with no account on that site, no API key, no prior relationship and nobody at a keyboard —
17
+ can identify itself, be recognised, and get a useful answer without a signup step. The
18
+ visitor's key *is* its identity, so *sign up* and *log in* are the same event and there is no
19
+ password to leak.
20
+
21
+ **Every normative requirement in §4 of this document is decided by what a stranger can observe
22
+ over HTTP.** Not by reading the server's source, not by trusting its operator, and not by
23
+ asking it. Each `MUST` and `SHOULD` below is stated together with the exact request that tests
24
+ it and the exact response that satisfies it. §9 collects all of them in one table.
25
+
26
+ This rule cost the specification real content, and that is the point. A requirement about what
27
+ a server *stores* — an account row, a rate-limiter bucket, a log — cannot be checked by the
28
+ party being asked to rely on it, so it is not a `MUST` here. Those behaviours are real and the
29
+ reference implementation has them; §7 states them plainly as what they are, which is
30
+ operator-verifiable, and marks them non-normative. A specification that asks a third party to
31
+ trust an unobservable claim has not specified anything; it has made a promise on somebody
32
+ else's behalf.
33
+
34
+ Two consequences worth stating up front:
35
+
36
+ - **An implementation is conformant if a checker says so, not if the author says so.** §8 names
37
+ two independent oracles and neither is this document.
38
+ - **A conformant Agent Entry can be written from scratch from this document alone**, in any
39
+ language, without reading the reference implementation. If you find a place where it cannot,
40
+ that is a defect in this document — please report it.
41
+
42
+ ### 1.1 What an Agent Entry is not
43
+
44
+ It is not an authorization server, and it issues no tokens. It has no registration endpoint,
45
+ because there is nothing to register: a visitor arrives already holding the only credential
46
+ that matters. It is not a bot-detection or access-control product — it recognises whoever
47
+ signs, and what a site chooses to do with that recognition is the site's business. And it is
48
+ not a replacement for `llms.txt` or for an agent-facing sitemap: those describe a site to an
49
+ agent; this one *recognises* one. A site may serve all of them.
50
+
51
+ ---
52
+
53
+ ## 2. Terminology
54
+
55
+ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**,
56
+ **SHOULD NOT**, **RECOMMENDED**, **MAY** and **OPTIONAL** are to be interpreted as described in
57
+ BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
58
+
59
+ **Entry** — the HTTP surface specified here.
60
+ **Visitor** — the agent dialling it. A visitor holds an Ed25519 keypair and nothing else.
61
+ **Mount** — the path component of the entry's own `baseUrl`; empty for a bare origin, which is
62
+ the ordinary case. Where this document writes `<mount>/.well-known/agent-card.json`, a
63
+ bare-origin entry serves literally `/.well-known/agent-card.json`.
64
+ **DID** — a `did:key` identifier: `did:key:z` followed by base58btc of `0xed01` concatenated
65
+ with a 32-byte Ed25519 public key. The identifier *is* the public key, so resolving one
66
+ requires no network call, no directory and no registry.
67
+ **Envelope** — the six signed fields carried in an A2A message's `metadata`.
68
+
69
+ ---
70
+
71
+ ## 3. Protocol context
72
+
73
+ An Agent Entry speaks **A2A** on the wire: the discovery document is an A2A Agent Card at the
74
+ well-known path fixed by RFC 8615, and the message endpoint is JSON-RPC 2.0 with the
75
+ `message/send` method. An entry adds no new method and changes the meaning of no existing
76
+ field; everything specific to this specification travels in the card's extension objects and
77
+ in `metadata`. An A2A client that knows nothing about this document can read the card and is
78
+ unaffected by the parts it does not recognise.
79
+
80
+ Transport is HTTPS. An entry MAY be served over plain HTTP for local development only; nothing
81
+ in this document makes plaintext safe, and the signature layer protects integrity of the
82
+ *payload*, not confidentiality of anything.
83
+
84
+ ---
85
+
86
+ ## 4. Normative requirements
87
+
88
+ Each requirement below carries an **Observation**: the request that decides it and the response
89
+ that satisfies it. Where an observation needs a key, the verifier mints a fresh Ed25519 keypair
90
+ — which requires no permission from, and no coordination with, the entry being tested.
91
+
92
+ Throughout, `<E>` is the entry's own DID as claimed by its card, and `now` is the verifier's
93
+ clock at the time of the request.
94
+
95
+ ### 4.1 Discovery
96
+
97
+ **AE-1.** An entry **MUST** serve its Agent Card at `<mount>/.well-known/agent-card.json` with
98
+ status `200` and a JSON body.
99
+
100
+ > **Observation.** `GET <mount>/.well-known/agent-card.json` → `200`, body parses as a JSON
101
+ > object.
102
+
103
+ **AE-2.** An entry **MUST** serve byte-identical content at the legacy path
104
+ `<mount>/.well-known/agent.json`.
105
+
106
+ > **Observation.** `GET` both paths; the response bodies are equal byte for byte. The legacy
107
+ > path exists so that a client written against the earlier A2A convention is not broken; it is
108
+ > an alias, not a second document, and any difference between the two is a defect.
109
+
110
+ **AE-3.** An entry **MUST** serve a signed card envelope at
111
+ `<mount>/.well-known/agent-card.sig.json` with status `200` and a JSON body.
112
+
113
+ > **Observation.** `GET <mount>/.well-known/agent-card.sig.json` → `200`, body parses as a JSON
114
+ > object carrying at least `v`, `typ`, `card`, `ts` and `sig`.
115
+
116
+ **AE-4.** An entry **MUST NOT** serve any address other than those its card names. Every other
117
+ path **MUST** answer `404` for every method, `OPTIONS` included.
118
+
119
+ > **Observation.** `GET`, `POST` and `OPTIONS` on a path the card does not name → `404` in all
120
+ > three cases. An entry answers exactly the addresses it advertises.
121
+
122
+ ### 4.2 The card
123
+
124
+ **AE-5.** The card **MUST** carry the A2A fields `protocolVersion`, `name`, `description`,
125
+ `url`, `version`, `capabilities`, `defaultInputModes`, `defaultOutputModes` and `skills`, and
126
+ **MUST** carry a `did` naming the entry's own DID.
127
+
128
+ > **Observation.** Parse the card from AE-1; assert each field is present and `did` matches
129
+ > `did:key:z…` with a decodable `0xed01` prefix and a 32-byte key.
130
+
131
+ **AE-6.** `card.url` **MUST** name the same canonical scope as the address the visitor dialled:
132
+ the same origin (scheme, host, and port when non-default, compared lowercased) **and** the same
133
+ path prefix, with trailing slashes normalised.
134
+
135
+ > **Observation.** Compare `card.url` against the dialled URL. This is the requirement that
136
+ > separates a genuine card from a byte-perfect copy of somebody else's: every signature and
137
+ > every DID check passes on a stolen card, and only the signed `url` tells the two apart. The
138
+ > path half matters because nothing requires an entry to sit at a bare origin — on a host
139
+ > routing `/alice` and `/mallory` to different entries, an origin-only comparison accepts the
140
+ > copy. An empty or unparseable `url` on either side is never a match.
141
+
142
+ **AE-7.** An entry that answers visitors with no prior introduction **MUST** advertise that
143
+ fact as `agentEntry.open_door: true`, and **MUST** emit a byte-identical alias at
144
+ `muretai.open_door`. A consumer **MUST** accept either and **SHOULD** prefer `agentEntry`.
145
+
146
+ > **Observation.** Parse the card; assert `agentEntry.open_door === muretai.open_door`. The
147
+ > alias exists because a card already pasted into a deployment cannot be reached by any change
148
+ > to this document; both are built from one object, which is why they cannot drift. New
149
+ > implementations write the neutral name and read either.
150
+
151
+ **AE-8.** An entry **MUST** state its terms on the card, before anyone knocks, using A2A
152
+ `securitySchemes` and `security`. The scheme object **MUST** carry at least `recipient` (the
153
+ entry's DID), `signedFields`, `canonicalization`, `signature`, `timestamp`, `in` and
154
+ `exampleRequest`.
155
+
156
+ > **Observation.** Parse the card; assert the fields are present, that `recipient` equals
157
+ > `card.did`, and that `signedFields` is exactly the six names in §4.5.
158
+ >
159
+ > **Why this is a `MUST` and not documentation.** A card that advertises a skill and says
160
+ > nothing about how to call it forces every visitor to learn the requirement by being refused.
161
+ > The card is the one place a protocol can state its terms *before* the failure — an
162
+ > HTTP-402-style challenge structurally cannot. `exampleRequest` is a nested JSON object and
163
+ > **MUST NOT** be a string containing JSON: an escaped document has to be unescaped before it
164
+ > can be copied, and the visitors that most need it are reading the raw response.
165
+
166
+ **AE-9.** If the scheme object carries `howTo`, the URL it names **MUST** resolve. An entry
167
+ that has no such page **MUST** omit the key entirely rather than emit an unresolvable URL.
168
+
169
+ > **Observation.** If `howTo` is present, `GET` it → not `404`. This is a `MUST` because a
170
+ > dangling pointer out-competes the data beside it: a visitor holding a complete, sufficient
171
+ > instruction object will follow a broken link and stop there. Nothing a signer needs may live
172
+ > only behind that link.
173
+
174
+ **AE-10.** An entry **MUST NOT** list a domain in `card.domains` that does not corroborate the
175
+ claim. A verifier **MUST NOT** treat such a claim as established on the card alone: the domain
176
+ must serve a DIF Well-Known DID Configuration at `/.well-known/did-configuration.json`
177
+ containing a Domain Linkage Credential that verifies under `card.did` and carries a future
178
+ expiry.
179
+
180
+ > **Observation.** For each `d` in `card.domains`: `GET https://<d>/.well-known/did-configuration.json`
181
+ > → `200`; at least one credential verifies under `card.did`; its `exp` is in the future.
182
+ >
183
+ > Note the direction, because it is easy to get backwards and the entry does not serve this
184
+ > document. `domains` is a claim made *by* the entry and corroborated *by the domain*, whose
185
+ > operator publishes the DID Configuration at its own root — which is precisely what makes it
186
+ > evidence rather than a second self-assertion. Both halves are required: a claim the domain
187
+ > does not corroborate is worse than no claim, because it reads as proof to anyone who checks
188
+ > only the card. The expiry is required because a domain is leased, not owned: a credential with
189
+ > no expiry keeps asserting a binding after the registration lapses, with no revocation channel
190
+ > a third-party verifier is obliged to consult.
191
+
192
+ ### 4.3 The signed card envelope
193
+
194
+ **AE-11.** The envelope's `sig` **MUST** verify under the DID that `envelope.card.did` names,
195
+ over the canonical bytes of the envelope's signed fields.
196
+
197
+ > **Observation.** Verify the signature against `envelope.card.did` using Ed25519. A plain
198
+ > Agent Card is a self-assertion — anyone can serve a card claiming anyone's DID — so this
199
+ > envelope is the only thing that makes a `did` → `url` binding evidence rather than a claim.
200
+
201
+ **AE-12.** `envelope.ts` **MUST** be a JSON integer, and **MUST** be within **6 hours** of
202
+ `now`, in both directions.
203
+
204
+ > **Observation.** Assert `Number.isInteger(ts)` and `|now - ts| ≤ 21600`.
205
+ >
206
+ > Two independent reasons this is a `MUST`. A float `ts` is not reproducible across runtimes —
207
+ > a number only one language serialises byte-for-byte is unverifiable everywhere else. And the
208
+ > path is unauthenticated, so without a window the envelope is a harvestable bearer proof that
209
+ > anyone who later takes over the address can replay. The symmetry matters: a `ts` far in the
210
+ > future is refused too.
211
+
212
+ **AE-13.** An entry **MUST NOT** sign a fresh envelope per request. It **SHOULD** re-sign at
213
+ most hourly and serve a cached artifact.
214
+
215
+ > **Observation.** Fetch the envelope twice in quick succession; `ts` and `sig` are unchanged.
216
+ > An unauthenticated path that signs on demand is a signing oracle, and any stranger can drive
217
+ > it.
218
+
219
+ ### 4.4 Methods, `Allow`, and the difference between 404 and 405
220
+
221
+ **AE-14.** `OPTIONS` on any address the card names **MUST** answer `204` with an `Allow` header
222
+ describing **that resource**, plus CORS preflight headers.
223
+
224
+ > **Observation.** `OPTIONS` each advertised address → `204` with `Allow` present. Per RFC 9110
225
+ > §10.2.1, `Allow` is a statement about the target, so the value differs per resource:
226
+ > `GET, HEAD, OPTIONS` on the card paths; `GET, HEAD, POST, OPTIONS` on a mount that is both
227
+ > the notice and the door; `POST, OPTIONS` on a guest mount's door.
228
+
229
+ **AE-15.** A method not allowed on an **advertised** address **MUST** answer `405` carrying the
230
+ same `Allow` header, and **MUST NOT** answer `404`.
231
+
232
+ > **Observation.** `GET` a guest mount's door → `405` with `Allow: POST, OPTIONS`. The address
233
+ > is signed into a public card, so hiding it conceals nothing; RFC 9110 §15.5.6 requires the
234
+ > header.
235
+
236
+ **AE-16.** A `POST` to a path the card does **not** name **MUST** answer `404`, not `405`.
237
+
238
+ > **Observation.** `POST` to an unadvertised path → `404`. This is deliberate non-disclosure and
239
+ > is not in tension with AE-15: what separates the two is where the address came from, not the
240
+ > verb. An entry mounted beside other agents on one host must not confirm the existence of a
241
+ > door at a **guessed** address.
242
+
243
+ **AE-17.** A request whose HTTP request-target is in absolute form (carrying a scheme or an
244
+ authority) **MUST** answer `404`, and the body **SHOULD** carry a `detail` naming the rule.
245
+
246
+ > **Observation.** Send `POST http://elsewhere.example/x HTTP/1.1` → `404`.
247
+ >
248
+ > **Disclosed non-conformance.** RFC 9112 §3.2.2 says a server MUST accept absolute form. An
249
+ > Agent Entry deliberately does not, because it answers exactly the address its card names and
250
+ > that address has no second spelling. This is stated here rather than left to be discovered:
251
+ > an undisclosed deliberate deviation costs an integrator an afternoon, which is why the body
252
+ > carries a diagnostic instead of a bare `not found`.
253
+
254
+ ### 4.5 The message endpoint
255
+
256
+ The request body is a JSON-RPC 2.0 call to `message/send` carrying an A2A `Message`. The signed
257
+ payload is the canonical JSON of exactly six fields —
258
+ `contextId`, `from`, `messageId`, `text`, `timestamp`, `to` — with keys sorted by Unicode code
259
+ point, separators `,` and `:`, no whitespace, non-ASCII emitted literally, encoded UTF-8.
260
+ `contextId` is JSON `null` when there is no conversation yet; it is still one of the six and is
261
+ still signed. `sig` is base64 (standard alphabet, padded) of the 64-byte Ed25519 signature.
262
+
263
+ **AE-18.** An entry **MUST** apply the checks below **in this order**, and **MUST** answer with
264
+ the stated code. Order is normative: a later check must not be reachable when an earlier one
265
+ fails.
266
+
267
+ | # | Check | Failure |
268
+ |---|---|---|
269
+ | 1 | body ≤ 1 MiB | HTTP `413`, body not parsed |
270
+ | 2 | body parses as a JSON object | HTTP `400` |
271
+ | 3 | `method` is exactly the string `message/send` | `-32601` |
272
+ | 4 | `params` and `params.message` are JSON objects (an array is not one) | `-32602` |
273
+ | 5 | wire shape: `kind` is exactly `"message"`; text parts, `messageId` and `contextId` are strings (or `contextId` null); `metadata`, when present, is an object; `metadata.from`/`to`/`sig`, when present and non-null, are strings; no string carries a lone surrogate | `-32600` |
274
+ | 6 | `text` ≤ 65536 UTF-8 bytes — **before any cryptography** | `-32005` |
275
+ | 7 | `metadata.from`, `to` and `sig` are present (`null` reads as absent) | `-32001` |
276
+ | 8 | `metadata.to` equals `<E>` | `-32003` |
277
+ | 9 | `timestamp` is an integer within ±300 s of the entry's clock | `-32002` |
278
+ | 10 | the signature verifies under `from` | `-32001` |
279
+ | 11 | `messageId` not seen in the last 600 s | `-32002` |
280
+
281
+ > **Observation.** Eleven requests, each violating exactly one row with every earlier row
282
+ > satisfied; assert the code.
283
+ >
284
+ > **Two positions in this ladder are security properties, not preferences.**
285
+ >
286
+ > *Row 6 precedes any cryptography*, which is why an oversized message costs the entry nothing:
287
+ > a size check placed after the signature is a check an attacker simply skips, and the
288
+ > difference between a size limit and a denial-of-service amplifier is exactly this ordering.
289
+ >
290
+ > *Row 11 follows row 10*, and the reason is not obvious enough to leave unstated. Ahead of the
291
+ > verify, an unauthenticated stranger could **burn a `messageId` its real sender was about to
292
+ > use** — and because a replay table is necessarily capped and evicts oldest-first, that
293
+ > stranger could flood past the cap to discard genuine entries and re-open real messages to
294
+ > replay. Invalid signatures are not rate-limited, so that flood is free. Deduplicating before
295
+ > authenticating turns a replay defence into a denial-of-service primitive aimed at the very
296
+ > senders it protects.
297
+ >
298
+ > An implementation MAY deduplicate an **unsigned** request earlier (AE-29): there is no
299
+ > signature to wait for, and nothing an attacker can burn on another party's behalf.
300
+
301
+ **AE-19.** Every protocol verdict **MUST** be HTTP `200` carrying a JSON-RPC error object. Only
302
+ `413` and `400` are non-`200`.
303
+
304
+ > **Observation.** Each failing request from AE-18 rows 3-11 → HTTP `200` with `error.code` set.
305
+
306
+ **AE-20.** An entry **MUST** answer a validly signed `message/send` from a DID it has never seen
307
+ before, in the same HTTP response, with a reply signed by `<E>`. There **MUST NOT** be any
308
+ registration, enrolment or approval step between a first contact and an answer.
309
+
310
+ > **Observation.** Mint a fresh keypair; send one signed `message/send`; the response is HTTP
311
+ > `200`, carries a message whose `metadata.from` is `<E>`, whose signature verifies under
312
+ > `<E>`, which echoes the request's `contextId`, carries a fresh `messageId`, an integer
313
+ > `timestamp` within ±300 s, and a `replyTo` naming the request's `messageId`.
314
+ >
315
+ > **This is the requirement the whole specification exists for**, and it is fully
316
+ > stranger-verifiable: the verifier holds a key nobody has ever seen, performs no setup, and is
317
+ > answered.
318
+
319
+ **AE-21.** An entry **MUST NOT** echo caller-supplied text in an error response, and **MUST NOT**
320
+ return an exception string, stack trace, field path or internal value to an unauthenticated
321
+ caller.
322
+
323
+ > **Observation.** Send a malformed request carrying a distinctive marker string; assert the
324
+ > marker does not appear in the response, and that no response body carries a stack trace. A
325
+ > caller that proved nothing is owed a verdict, not a diagnosis of the server.
326
+
327
+ **AE-22.** An entry **MUST** answer every request with an HTTP response. An unhandled internal
328
+ condition **MUST** produce `-32603`, never a closed socket.
329
+
330
+ > **Observation.** A closed connection with no status line is not a verdict; to the sender it is
331
+ > indistinguishable from a network fault, which is the one outcome a signed protocol cannot
332
+ > diagnose.
333
+
334
+ **AE-23.** The JSON-RPC `id` **MUST** be echoed when it is a String, a Number or Null, and
335
+ **MUST** be answered under `null` otherwise (an object, an array, a string carrying a lone
336
+ surrogate, or a number outside ±2^53).
337
+
338
+ > **Observation.** Send each shape; assert the echoed `id`. The `id` is the one field no
339
+ > signature covers and it is written straight back out, so a value two runtimes serialise
340
+ > differently turns one verdict into two different responses.
341
+
342
+ ### 4.6 The refusal that teaches
343
+
344
+ **AE-24.** When `metadata.from`, `to` and `sig` are **all** absent (AE-18 row 7), the `-32001`
345
+ error **MUST** carry `data.accepts`: a JSON **array** whose first element is the same scheme
346
+ object the card publishes under `agentEntry` (AE-8), verbatim.
347
+
348
+ > **Observation.** `POST` a well-formed `message/send` with no `metadata`; assert `error.code`
349
+ > is `-32001`, that `data.accepts` is an array, and that `data.accepts[0]` deep-equals the
350
+ > card's `agentEntry` scheme object. One object, two surfaces — the menu and the door can never
351
+ > advertise two different requirements.
352
+ >
353
+ > **The bar this is written to is behavioural, not informational:** *an agent holding only this
354
+ > refusal, plus ordinary crypto tooling, can mint a `did:key`, sign correctly, and be answered
355
+ > on its next POST.* That is why `recipient` is in the block — nobody can address a message
356
+ > without it — and why the canonicalization rule is spelled out rather than named. It is an
357
+ > array because "sign", "arrive with an introduction" and later "pay" are siblings in one
358
+ > frame, not three bespoke refusals.
359
+
360
+ **AE-25.** The refusal **MUST** remain a complete recipe with every URL removed from it.
361
+
362
+ > **Observation.** Strip every URL-valued field from `data.accepts[0]`; what remains still names
363
+ > the identifier derivation, the six signed fields, the canonicalization, the signature
364
+ > encoding, the timestamp rule and the recipient. A visitor must never depend on fetching a
365
+ > second document to answer the first.
366
+
367
+ **AE-26.** A **partial** envelope — for example `from` and `to` present with `sig` stripped —
368
+ **MUST NOT** receive `data.accepts`.
369
+
370
+ > **Observation.** `POST` with `from` and `to` but no `sig`; assert `-32001` with no `accepts`
371
+ > array. Whoever sent that already holds a key and already knows the shape, so it is a
372
+ > downgrade attempt and not a walk-in; there is no reason to hand a prober a machine-readable
373
+ > map of what to try next. A wrongly-*typed* field is row 5 (`-32600`), not row 7.
374
+
375
+ ### 4.7 Limits
376
+
377
+ **AE-27.** An entry **MUST** cap `text` at 65536 UTF-8 bytes and the request body at 1 MiB.
378
+
379
+ > **Observation.** AE-18 rows 1 and 6.
380
+
381
+ **AE-28.** An entry **MUST** bound the rate at which it produces signed replies, in aggregate,
382
+ and **MUST** refuse over-rate requests with `-32004` rather than by silence or by disconnection.
383
+
384
+ > **Observation.** Drive the entry above its advertised ceiling from one signed identity and
385
+ > assert the refusal carries `-32004` at HTTP `200`.
386
+ >
387
+ > The aggregate bound is the normative half, and the reason is arithmetic rather than policy:
388
+ > a `did:key` costs nothing to mint, so a per-identity limit is not a bound at all. Free
389
+ > identity defeats per-identity metering by definition; only the aggregate resists a flood. The
390
+ > specific numbers are configuration, not conformance.
391
+
392
+ **AE-29.** If an entry answers **unsigned** inquiries (an optional lane, **RECOMMENDED** to
393
+ default off), that lane **MUST** be bounded entry-wide and **MUST NOT** be reachable at a
394
+ higher rate than the signed lane's aggregate.
395
+
396
+ > **Observation.** With the lane on, drive unsigned requests above the bound; assert refusal. An
397
+ > unauthenticated caller must never become an unmetered signing oracle.
398
+
399
+ ---
400
+
401
+ ## 5. Relationship to other specifications
402
+
403
+ An Agent Entry composes with the specifications below; none of them is an alternative to it,
404
+ and a site may serve several at once. The distinctions here are about **layer**, not merit.
405
+
406
+ **A2A.** This document is a profile of A2A, not a competitor. The card is an A2A Agent Card and
407
+ the endpoint is A2A `message/send`. Everything added lives in extension objects and in
408
+ `metadata`; no existing field changes meaning.
409
+
410
+ **`llms.txt` and agent-facing sitemaps.** These *describe* a site to an agent. An Agent Entry
411
+ *recognises* one. A description is read; an entry answers. They are complementary and a site
412
+ should have both.
413
+
414
+ **MCP (Model Context Protocol).** MCP is a transport between a host application and a tool
415
+ server, and its authorization is OAuth-shaped: the party being identified is the **client
416
+ application**. An Agent Entry identifies the **caller of an individual message**, on the
417
+ message itself, with no session and no token. A site may run an MCP server and an Agent Entry
418
+ simultaneously; they answer different questions and neither substitutes for the other.
419
+
420
+ **OAuth Client ID Metadata Documents (CIMD).** CIMD identifies an OAuth client by an HTTPS URL
421
+ which the authorization server fetches to obtain the client's metadata — replacing dynamic
422
+ client registration with a document the client publishes. It answers *which software is this*,
423
+ and it roots the answer in control of a domain name. An Agent Entry answers *who is knocking*,
424
+ and roots the answer in possession of a key, demonstrated on every message.
425
+
426
+ The two are not rivals; they are different layers, and the honest way to state the difference
427
+ is to quote what each specification says about itself. A Client ID Metadata Document is
428
+ unsigned by construction — an authorization server fetches JSON over TLS, and the draft's only
429
+ identity check is that the `client_id` string equals the URL it was fetched from, compared with
430
+ simple string comparison. The MCP authorization specification names the consequence directly:
431
+ *"Client ID Metadata Documents cannot prevent `localhost` URL impersonation by themselves."*
432
+ That is not a defect in CIMD; it is the boundary of what a fetched document can establish, and
433
+ the OAuth ecosystem is addressing it in a separate attestation track.
434
+
435
+ An Agent Entry sits on the other side of that boundary because possession is demonstrated at
436
+ use time, per message, against an identifier that *is* the public key. What it correspondingly
437
+ does **not** provide is what CIMD does well: a domain-rooted identity that an authorization
438
+ server can hold policy against — allowlists, reputation, "if you trust `example.com` you trust
439
+ this client". A deployment that needs both should run both.
440
+
441
+ **RFC 9421 HTTP Message Signatures / Web Bot Auth.** These sign the HTTP *request* rather than
442
+ the payload inside it, and are the natural companion where a signature must survive
443
+ intermediaries or bind the transport itself. An Agent Entry MAY recognise such signatures in
444
+ addition to the envelope specified here; doing so adds an observation and never changes a
445
+ verdict reached under §4.
446
+
447
+ ---
448
+
449
+ ## 6. Security considerations
450
+
451
+ **A card is a claim until it is bound.** Everything in §4.2 except AE-6 and AE-11 is
452
+ self-asserted, and a byte-perfect copy of a legitimate card scores identically to the original
453
+ on every check that does not include the signed `url`. AE-6 and AE-11 together are the only
454
+ reason a `did` → `url` binding is evidence. An implementation that verifies the signature but
455
+ skips the origin comparison has verified nothing useful: a valid envelope lifted from another
456
+ site's endpoint passes every remaining check.
457
+
458
+ **Do not render self-asserted names.** `name`, `description` and any logo on a card are written
459
+ by whoever serves it. An interface that displays them to a human, next to a trust decision,
460
+ has turned an unauthenticated string into a security control. Display the DID, and the domain
461
+ when AE-10 corroborates it; show the friendly name only for entries a viewer has independently
462
+ reason to trust. This is the single most reliable abuse channel in every deployed system of
463
+ this shape, and it is not a corner case.
464
+
465
+ **Free identity is not scarcity.** Minting a `did:key` costs nothing and requires no
466
+ permission, which is a feature — it is what makes AE-20 possible. It also means being present
467
+ in a ledger was never a bound on anything, which is why AE-28 requires the aggregate ceiling
468
+ and treats the per-identity one as configuration.
469
+
470
+ **An unauthenticated signing path is an oracle.** AE-13 exists because a stranger can drive any
471
+ unauthenticated endpoint that signs on demand. Cache the artifact; re-sign on a timer.
472
+
473
+ **Freshness is a security property, not hygiene.** AE-12's window bounds a harvestable proof.
474
+ An envelope with no expiry is a bearer credential that outlives the operator's control of the
475
+ address it names.
476
+
477
+ **No human is claimed, and no human is claimed to be absent.** An Agent Entry answers a caller
478
+ that may have no browser, no session and nobody at a keyboard — that is the case it exists for.
479
+ Nothing in §4 asserts anything about human presence, and an implementation **MUST NOT** present a
480
+ verified signature as evidence that a person authorised the message. It is evidence that a key
481
+ did.
482
+
483
+ The reason to state this rather than leave it implied is that the surrounding ecosystem is
484
+ building machinery to sort interactive clients from headless ones, and a door that says nothing
485
+ will have a meaning assigned to it. Two facts are worth carrying, because both come from the
486
+ specifications doing that sorting. **Presence cannot be proven remotely today**: the strongest
487
+ primitive in deployment is a WebAuthn gesture at one authenticator at one instant, whose own
488
+ specification says it "does not give the Relying Party a concrete identification of the user";
489
+ every artifact built above it is a bearer token that relays, and the party relaying it is
490
+ precisely the one you were trying to distinguish. And **absence of a signal is not a signal**:
491
+ the HTTP-signature draft this ecosystem runs on states it directly — a verifier that sees no
492
+ signature "has learned nothing about the sender: not that it is automated, not that it is human,
493
+ not that it is evading anything."
494
+
495
+ So an entry **MUST NOT** treat the absence of a human-presence claim as evidence of automation,
496
+ and **SHOULD NOT** treat the presence of one as evidence of a person. Where a site genuinely
497
+ requires a human for an action, the mechanism already exists one layer up and does not belong
498
+ here: an OAuth resource server can demand a fresh authentication event (RFC 9470), and a payment
499
+ flow can refuse an autonomous mandate and ask for a directly approved one. Both are challenges
500
+ that fall back to a person; neither is a proof carried on this wire.
501
+
502
+ The engineering answer this specification prefers is to bound what an unattended caller can do
503
+ rather than to interrogate whether it is unattended: a grant with a use count and an expiry, and
504
+ a key binding that names what it is for, are checkable, and a presence claim is not.
505
+
506
+ **Refusals leak.** AE-21 and AE-26 are both about what a prover is owed versus what a prober is
507
+ owed. The keyless walk-in gets a complete recipe because refusing an agent for lacking a key
508
+ nobody told it to make is the error, not the key. Everyone else gets a verdict.
509
+
510
+ ---
511
+
512
+ ## 7. Deliberately not normative here
513
+
514
+ The behaviours below are real, are implemented, and are **not** `MUST`s in this document,
515
+ because a third party cannot check them from outside. They are stated so that an implementer
516
+ knows they exist and a reader knows they were not forgotten. Their oracle is the
517
+ implementation's own test suite (§8), which is the right instrument for them — it has the
518
+ access that a remote checker does not.
519
+
520
+ - **The account ledger.** That a verified signature creates a durable row keyed by the
521
+ visitor's DID; that a second message from the same DID is the same account; that an unsigned
522
+ request creates no row at all; that a countersigned owner binding files several device keys
523
+ under one account. The *observable* half of this is AE-20, and AE-20 is where the normative
524
+ weight sits.
525
+ - **Rate-limiter internals.** Whether a ceiling is per-account or entry-wide, the bucket
526
+ algorithm, and the specific numbers. AE-28 fixes the property that matters and leaves the
527
+ mechanism open.
528
+ - **Storage and retention.** Ledger size caps, eviction, what is written to disk, what is
529
+ logged.
530
+ - **The responder.** What produces the reply text — a fixed script, a database lookup, a
531
+ language model — is entirely the site's business and is invisible to this specification by
532
+ design.
533
+ - **The observer hook.** That a per-message callback cannot delay or alter a reply.
534
+
535
+ A note on the boundary, because it is the interesting part: several of these could be turned
536
+ into remote observations by making the entry *report* on itself — an endpoint that says "this
537
+ DID has an account". Every such endpoint is a new unauthenticated disclosure surface about
538
+ third parties, and this specification declines to require one. The right answer to "can you
539
+ prove your ledger works" is the implementation's test suite, not a public API that enumerates
540
+ who has visited a site.
541
+
542
+ ---
543
+
544
+ ## 8. Verifying an implementation
545
+
546
+ Two independent oracles, neither of which is this document, and both of which are needed for
547
+ different reasons.
548
+
549
+ **The implementation oracle — test vectors.** `conformance/run.mjs` and
550
+ `conformance/vectors.json`, which ship inside the npm package, decide whether an implementation
551
+ agrees with the fixed bytes: the canonical JSON, the signing payloads, the card envelopes and
552
+ the `did:key` round-trips. `npm test` runs them with no network and nothing to ask anyone for.
553
+ They prove an implementation matches the vectors. They cannot prove a deployment is real,
554
+ because a test that imports the code it is testing establishes only that the code agrees with
555
+ itself.
556
+
557
+ **The interoperability evidence — two implementations, no shared code.** The requirements in §4
558
+ are met today by two implementations written independently in different languages that share no
559
+ code at all, held to identical verdicts by an acceptance suite that posts identical bytes to
560
+ both — down to the HTTP framing — and requires the same status, the same account outcome and the
561
+ same signed reply from each. That, rather than a shared library, is what makes the byte-level
562
+ requirements here credible: a shared library would only ever have covered the parts the two
563
+ happen to have in common. If you write a third implementation, that suite is the gate, and we
564
+ will run it against yours on request.
565
+
566
+ **The deployment oracle — a remote checker.** An implementation-blind checker dials a live
567
+ origin over HTTP and decides requirements from the responses alone:
568
+
569
+ ```
570
+ npx @muretai/agent-site-checker example.com
571
+ ```
572
+
573
+ Use the package, not a hosted service. A specification whose conformance depends on an endpoint
574
+ someone operates has acquired a runtime dependency on that operator, and the whole argument of
575
+ §1 is that a normative claim should be checkable by a stranger with no relationship to anyone.
576
+ The package runs locally, works against a local origin, and needs nothing from us.
577
+
578
+ **How much of §4 it decides today, stated exactly.** The read-only requirements — discovery,
579
+ the card, the signed envelope and its binding and freshness (roughly AE-1 through AE-13) — are
580
+ decided by `GET`s and are what the checker covers now. The message-endpoint requirements
581
+ (AE-14 onward) need a driver that *sends*: an eleven-request battery for AE-18, a fresh keypair
582
+ for AE-20, a stripped envelope for AE-26. Those are decidable by a stranger — that is why they
583
+ are `MUST`s — but a checker that POSTs to a stranger's endpoint is a different instrument from
584
+ one that reads, and it should be run against your own deployment rather than someone else's. The
585
+ gap between §9's table and what any given checker covers is a to-do list, not a licence: a `MUST`
586
+ here is a requirement whether or not a tool currently checks it, and §9 exists so the gap is
587
+ visible rather than convenient.
588
+
589
+ **Reporting a divergence.** If the two oracles disagree, or if a requirement in §4 cannot be
590
+ decided by its own Observation, that is a defect in this document. Please open an issue with
591
+ the request and response bytes.
592
+
593
+ ---
594
+
595
+ ## 9. Requirement index
596
+
597
+ Every normative statement, with the observation that decides it. A conforming implementation
598
+ satisfies all `MUST` rows. This table is the document's contract with itself: a row that cannot
599
+ be written is a requirement that does not belong in §4.
600
+
601
+ | ID | Level | Requirement | Decided by |
602
+ |---|---|---|---|
603
+ | AE-1 | MUST | card served at the well-known path | `GET` → 200 + JSON |
604
+ | AE-2 | MUST | legacy path is byte-identical | `GET` both, compare bytes |
605
+ | AE-3 | MUST | signed envelope served | `GET` → 200 + JSON with `v/typ/card/ts/sig` |
606
+ | AE-4 | MUST NOT | no unadvertised address answers | `GET`/`POST`/`OPTIONS` → 404 |
607
+ | AE-5 | MUST | required card fields incl. `did` | parse + decode the DID |
608
+ | AE-6 | MUST | `card.url` scope matches the dialled URL | compare origin + path prefix |
609
+ | AE-7 | MUST | `open_door` under both names, identical | compare the two values |
610
+ | AE-8 | MUST | terms stated on the card | parse `securitySchemes` |
611
+ | AE-9 | MUST | `howTo` resolves, or is absent | `GET` it → not 404 |
612
+ | AE-10 | MUST NOT | no uncorroborated `domains` claim | fetch the domain's DID configuration, verify, check expiry |
613
+ | AE-11 | MUST | envelope verifies under `card.did` | Ed25519 verify |
614
+ | AE-12 | MUST | `ts` integer, within ±6 h | parse + compare |
615
+ | AE-13 | MUST NOT / SHOULD | no per-request signing; cache | fetch twice, compare `ts`/`sig` |
616
+ | AE-14 | MUST | `OPTIONS` → 204 + per-resource `Allow` | `OPTIONS` each address |
617
+ | AE-15 | MUST | 405 + `Allow` on advertised addresses | `GET` a guest door |
618
+ | AE-16 | MUST | 404 (not 405) on unadvertised `POST` | `POST` a guessed path |
619
+ | AE-17 | MUST | absolute-form target → 404 | send an absolute-form target |
620
+ | AE-18 | MUST | eleven checks, in order, with fixed codes | eleven single-violation requests |
621
+ | AE-19 | MUST | protocol verdicts are HTTP 200 | assert status on rows 3-11 |
622
+ | AE-20 | MUST | unknown DID answered inline, signed, no registration | mint a key, send once, verify the reply |
623
+ | AE-21 | MUST NOT | no echo of caller text, no internals | marker string + stack-trace scan |
624
+ | AE-22 | MUST | always an HTTP response | assert a status line exists |
625
+ | AE-23 | MUST | `id` echoed only when serialisable | send each `id` shape |
626
+ | AE-24 | MUST | keyless refusal carries `accepts` | `POST` with no metadata, deep-equal the card block |
627
+ | AE-25 | MUST | refusal survives URL removal | strip URLs, check completeness |
628
+ | AE-26 | MUST NOT | partial envelope gets no `accepts` | `POST` with `sig` stripped |
629
+ | AE-27 | MUST | size caps | oversized body and text |
630
+ | AE-28 | MUST | aggregate reply ceiling, refused with `-32004` | drive above the ceiling |
631
+ | AE-29 | MUST | unsigned lane bounded entry-wide | drive the anonymous lane |
632
+
633
+ ---
634
+
635
+ ## 10. IANA and registry considerations
636
+
637
+ This document registers nothing. It uses the well-known URI `agent-card.json` established by
638
+ the A2A specification under RFC 8615, the JSON-RPC 2.0 error range, and `did:key` as defined by
639
+ the W3C DID method registry. The error codes in the range `-32001` … `-32005` and `-32010` …
640
+ `-32011` are application-defined codes within the range JSON-RPC 2.0 reserves for
641
+ implementation-defined server errors.
642
+
643
+ ## 11. Changes from the pre-specification implementation
644
+
645
+ None. Version 1 describes behaviour already deployed; it introduces no new requirement that a
646
+ running Agent Entry does not already satisfy. Where this document and the reference
647
+ implementation disagree, that is a bug in one of them and a report is welcome — a specification
648
+ written *after* the code has no excuse for describing something that was never shipped.