@muretai/agent-entry 1.7.1 → 1.8.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 +21 -1
- package/conformance/run.mjs +12 -4
- package/muretai-agent-entry.mjs +28 -21
- 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` and `ua_family`, **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
|
|
|
@@ -145,6 +145,15 @@ handed the machine-readable door, whatever its `User-Agent` claims. The body sta
|
|
|
145
145
|
byte-identical for every caller, and so does the header: classification feeds the
|
|
146
146
|
counters above and never a byte on the wire.
|
|
147
147
|
|
|
148
|
+
**Since 1.8.0 the watcher is told the same thing.** `entry.stats()` always counted family by
|
|
149
|
+
stage, but the per-visit row handed to your `observer` carried `ua_family` only on the card and
|
|
150
|
+
notice fetches — so you could see that a browser *read* your card and not that a browser was the
|
|
151
|
+
thing being *refused*. The two questions an operator actually has — is this a crawler, is
|
|
152
|
+
somebody's agent failing to sign — were answerable only for the visitors who never knocked. All
|
|
153
|
+
five stages now carry it, refusals included, so "which clients got in and which were turned away"
|
|
154
|
+
is one query instead of two half-answers. Nothing else moved: no wire byte, no verdict, no ledger
|
|
155
|
+
row, no rate lane, and `stats()` is unchanged.
|
|
156
|
+
|
|
148
157
|
One rule holds this together, enforced by the contract suite rather than promised:
|
|
149
158
|
**a User-Agent never affects `verified`, an account row, a rate limit, or any
|
|
150
159
|
refusal.** A UA string is written by the client; a door that trusted it would be a
|
|
@@ -696,6 +705,17 @@ npm test
|
|
|
696
705
|
|
|
697
706
|
Write a third implementation and point it at the same vectors.
|
|
698
707
|
|
|
708
|
+
## Contributing
|
|
709
|
+
|
|
710
|
+
This repo is a **published mirror**, rendered out of a private working repository — not the
|
|
711
|
+
place the next change is written. A pull request opened here will not merge: the next release
|
|
712
|
+
overwrites this checkout wholesale from the source, carrying no memory of a branch made
|
|
713
|
+
against it.
|
|
714
|
+
|
|
715
|
+
That is not a closed door. **Open an issue** — a bug, a wire-vector disagreement, a place the
|
|
716
|
+
docs are wrong, a design question — and it gets read and, where it's right, becomes the next
|
|
717
|
+
release here. That path works; a PR against these files does not.
|
|
718
|
+
|
|
699
719
|
## What this is part of
|
|
700
720
|
|
|
701
721
|
[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/muretai-agent-entry.mjs
CHANGED
|
@@ -2254,6 +2254,12 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
2254
2254
|
* callback, and a watcher that dialled out on the hot path would make the
|
|
2255
2255
|
* visitor's answer depend on somebody else's uptime.
|
|
2256
2256
|
*
|
|
2257
|
+
* WHAT IT IS TOLD. Every stage reports `stage`, `identified` and
|
|
2258
|
+
* `ua_family` — the door's own bounded classification of the client, one
|
|
2259
|
+
* of the fixed `UA_FAMILIES` names and NEVER a substring of what the caller
|
|
2260
|
+
* sent, so a stranger cannot write its own label into your metrics. The
|
|
2261
|
+
* POST stages add the envelope on top.
|
|
2262
|
+
*
|
|
2257
2263
|
* WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
|
|
2258
2264
|
* which a visitor handed you to transact with YOU. Forwarding a raw DID to
|
|
2259
2265
|
* a third party shares a durable identifier its owner never offered them;
|
|
@@ -2509,7 +2515,9 @@ export function createAgentEntry({
|
|
|
2509
2515
|
*
|
|
2510
2516
|
* A GET carries no envelope, so the watcher is told what is true and nothing invented: no
|
|
2511
2517
|
* 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.
|
|
2518
|
+
* `observeRefusal()` instead, which know the envelope — one visit, one row, never two.
|
|
2519
|
+
* `ua_family` is the one field BOTH sides report, so a watcher can ask "which clients got
|
|
2520
|
+
* in and which were turned away" as one question instead of two half-answers. */
|
|
2513
2521
|
function tally(family, stage) {
|
|
2514
2522
|
let row = uaStats.get(family);
|
|
2515
2523
|
if (!row) { row = new Map(); uaStats.set(family, row); }
|
|
@@ -2771,6 +2779,22 @@ export function createAgentEntry({
|
|
|
2771
2779
|
* than being passed it. */
|
|
2772
2780
|
let pendingStage = null;
|
|
2773
2781
|
|
|
2782
|
+
/** The client family for the POST currently in flight, handed to the same observation
|
|
2783
|
+
* points as `pendingStage` and for the same reason. The GET stages already carry
|
|
2784
|
+
* `ua_family` — it is what `tally()` counts under — and a KNOCK, the stage where "who is
|
|
2785
|
+
* this client" matters most, was the one arriving without it. A watcher could see that a
|
|
2786
|
+
* browser fetched the card and NOT that a browser was the thing being refused, which
|
|
2787
|
+
* leaves the two questions an operator actually has (is this a crawler? is somebody's
|
|
2788
|
+
* agent failing to sign?) answerable only for the visitors who did not try.
|
|
2789
|
+
*
|
|
2790
|
+
* Derived from the same request `route()` derived its own `family` from, and `uaFamily` is
|
|
2791
|
+
* a pure function of that one header, so the two cannot disagree. Recomputed rather than
|
|
2792
|
+
* threaded through a signature every call site would have to remember to pass — the same
|
|
2793
|
+
* argument `rpcError` makes for shadowing itself a few lines below. Set and read together
|
|
2794
|
+
* with `pendingStage`, so it inherits exactly that field's accepted skew under an async
|
|
2795
|
+
* responder and can never disagree with the stage it is reported beside. */
|
|
2796
|
+
let pendingFamily = 'none';
|
|
2797
|
+
|
|
2774
2798
|
/** Shadows the module-level `rpcError` for the whole entry: same return value, and it
|
|
2775
2799
|
* remembers the code on the way out. A local alias rather than seventeen edits, and rather
|
|
2776
2800
|
* than a parameter every refusal site would have to remember to pass. */
|
|
@@ -2806,6 +2830,7 @@ export function createAgentEntry({
|
|
|
2806
2830
|
// whichever observation point fires. `tally()` computes the same thing afterwards from the
|
|
2807
2831
|
// finished reply, for the counters; the two agree because they ask the same question.
|
|
2808
2832
|
pendingStage = postRequestStage(rawBody);
|
|
2833
|
+
pendingFamily = uaFamily(uaOf(reqHeaders));
|
|
2809
2834
|
const out = handlePostLadder(rawBody, reqHeaders);
|
|
2810
2835
|
if (isThenable(out)) return out.then((o) => { observeRefusal(); return o; });
|
|
2811
2836
|
observeRefusal();
|
|
@@ -2817,7 +2842,7 @@ export function createAgentEntry({
|
|
|
2817
2842
|
function observeRefusal() {
|
|
2818
2843
|
if (typeof observer !== 'function' || lastRefusal === null) return;
|
|
2819
2844
|
observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
|
|
2820
|
-
identified: pendingStage === 'signed_post' ? 1 : 0,
|
|
2845
|
+
identified: pendingStage === 'signed_post' ? 1 : 0, ua_family: pendingFamily,
|
|
2821
2846
|
peer_did: null, owner_did: null, wba_did: null, text: null });
|
|
2822
2847
|
}
|
|
2823
2848
|
|
|
@@ -3063,7 +3088,7 @@ export function createAgentEntry({
|
|
|
3063
3088
|
// The answered case: the envelope already says who this was, and the stage says how they
|
|
3064
3089
|
// arrived. `identified` is read off the envelope rather than the stage, because the
|
|
3065
3090
|
// anonymous lane answers a visitor who genuinely presented no DID.
|
|
3066
|
-
observe({ ...env, stage: pendingStage,
|
|
3091
|
+
observe({ ...env, stage: pendingStage, ua_family: pendingFamily,
|
|
3067
3092
|
identified: env && env.peer_did ? 1 : 0 });
|
|
3068
3093
|
let answer;
|
|
3069
3094
|
try {
|
|
@@ -3211,24 +3236,6 @@ export function createAgentEntry({
|
|
|
3211
3236
|
// and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
|
|
3212
3237
|
// host, which belongs to the site (or to the neighbour agent) and not to us.
|
|
3213
3238
|
if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
|
|
3214
|
-
// A QUERY STRING MEANS THE POST IS NOT OURS. The door's address is the signed
|
|
3215
|
-
// card's `url`, byte-exact: a base URL carrying a query is refused at startup, and
|
|
3216
|
-
// a visitor's walk drops any query it was handed before it POSTs the card's `url` —
|
|
3217
|
-
// so no conformant caller can arrive here, while a site's own query-multiplexed
|
|
3218
|
-
// traffic (`?wc-api=`, `?wc-ajax=`, `?rest_route=`) always does. Disjoint BY
|
|
3219
|
-
// CONSTRUCTION, which is what makes this a rule and not a heuristic. Measured:
|
|
3220
|
-
// WooCommerce Stripe delivers its only webhook to `/?wc-api=wc_stripe` (path `/`,
|
|
3221
|
-
// application/json); a bare-origin door that claimed it answered HTTP 200/-32601
|
|
3222
|
-
// echoing the event id, the sender recorded the event as delivered and never
|
|
3223
|
-
// retried — payment events lost SILENTLY. The answer is the unowned-path 404
|
|
3224
|
-
// above, same bytes, one meaning — the DECISION(non-door-post-answers-404-not-405)
|
|
3225
|
-
// non-disclosure again, because the caller most likely to land here is the site's
|
|
3226
|
-
// own webhook, which deserves the most anonymous answer, never a door verdict.
|
|
3227
|
-
const hashless = target.split('#')[0];
|
|
3228
|
-
const queryAt = hashless.indexOf('?');
|
|
3229
|
-
if (queryAt !== -1 && queryAt + 1 < hashless.length) {
|
|
3230
|
-
return jsonResponse(404, { error: 'not found' });
|
|
3231
|
-
}
|
|
3232
3239
|
const buf = bodyBuffer || Buffer.alloc(0);
|
|
3233
3240
|
const out = handlePost(buf, headers);
|
|
3234
3241
|
// The stage is read off the finished answer, so an async responder tallies when it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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.
|