@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 +55 -13
- package/conformance/run.mjs +12 -4
- package/examples/server.mjs +8 -1
- package/muretai-agent-entry.mjs +107 -7
- package/package.json +2 -1
- package/spec/v1.md +648 -0
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
|
-
`
|
|
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 **
|
|
223
|
-
fallback for the
|
|
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
|
|
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
|
|
237
|
-
|
|
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: **
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
package/conformance/run.mjs
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 ??
|
|
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
|
}
|
package/examples/server.mjs
CHANGED
|
@@ -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
|
|
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
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -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
|
|
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
|
|
3364
|
-
//
|
|
3365
|
-
|
|
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.
|
|
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.
|