@muretai/agent-entry 1.6.3 → 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 +27 -5
- package/conformance/run.mjs +12 -4
- package/muretai-agent-entry.mjs +182 -29
- 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
|
|
|
@@ -138,10 +138,21 @@ Each request's `User-Agent` is classified into a fixed family (`claude-user`,
|
|
|
138
138
|
`claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
|
|
139
139
|
`curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
|
|
140
140
|
ledger — read it, log it, ship it to your analytics; it is never served on the wire.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
that landed on prose is
|
|
144
|
-
|
|
141
|
+
Every caller also gets one nudge: `GET /` answers with a single `Link:` field carrying
|
|
142
|
+
two relations — `rel="service-desc"` (RFC 8631) first, then the door pointer
|
|
143
|
+
`rel="https://muretai.net/rel/agent-entry"` — so a crawler that landed on prose is
|
|
144
|
+
handed the machine-readable door, whatever its `User-Agent` claims. The body stays
|
|
145
|
+
byte-identical for every caller, and so does the header: classification feeds the
|
|
146
|
+
counters above and never a byte on the wire.
|
|
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.
|
|
145
156
|
|
|
146
157
|
One rule holds this together, enforced by the contract suite rather than promised:
|
|
147
158
|
**a User-Agent never affects `verified`, an account row, a rate limit, or any
|
|
@@ -694,6 +705,17 @@ npm test
|
|
|
694
705
|
|
|
695
706
|
Write a third implementation and point it at the same vectors.
|
|
696
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
|
+
|
|
697
719
|
## What this is part of
|
|
698
720
|
|
|
699
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
|
@@ -336,10 +336,12 @@ export const UA_FAMILIES = [
|
|
|
336
336
|
['mozilla', 'browser'],
|
|
337
337
|
];
|
|
338
338
|
|
|
339
|
-
/** The families that read as an AI agent
|
|
340
|
-
* `Link`
|
|
341
|
-
*
|
|
342
|
-
*
|
|
339
|
+
/** The families that read as an AI agent. Since v1.11 this set steers NO wire byte —
|
|
340
|
+
* the notice route's `Link` signpost is emitted for every caller (see `steerHeaders`
|
|
341
|
+
* and ISSUE(agent-entry-unconditional-link)) — so it remains only as a published
|
|
342
|
+
* classification an operator's own observation code can lean on. `muretai-node` is
|
|
343
|
+
* deliberately absent: its Outbox already walks the well-known card paths. Must match
|
|
344
|
+
* the same set in examples/agent_entry_reference.py. */
|
|
343
345
|
export const AI_AGENT_FAMILIES = new Set([
|
|
344
346
|
'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
|
|
345
347
|
]);
|
|
@@ -1706,7 +1708,7 @@ function jsonResponse(status, obj, extraHeaders = {}) {
|
|
|
1706
1708
|
};
|
|
1707
1709
|
}
|
|
1708
1710
|
|
|
1709
|
-
function
|
|
1711
|
+
function baseRpcError(id, error, data) {
|
|
1710
1712
|
const err = { ...error };
|
|
1711
1713
|
if (data) err.data = data;
|
|
1712
1714
|
return jsonResponse(200, { jsonrpc: '2.0', id: id ?? null, error: err });
|
|
@@ -2252,6 +2254,12 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
2252
2254
|
* callback, and a watcher that dialled out on the hot path would make the
|
|
2253
2255
|
* visitor's answer depend on somebody else's uptime.
|
|
2254
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
|
+
*
|
|
2255
2263
|
* WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
|
|
2256
2264
|
* which a visitor handed you to transact with YOU. Forwarding a raw DID to
|
|
2257
2265
|
* a third party shares a durable identifier its owner never offered them;
|
|
@@ -2394,6 +2402,18 @@ export function createAgentEntry({
|
|
|
2394
2402
|
},
|
|
2395
2403
|
};
|
|
2396
2404
|
card.security = [{ [SIGNED_ENVELOPE_SCHEME]: [] }];
|
|
2405
|
+
// WHERE A 1.0-NATIVE READER FINDS THE ENDPOINT. A2A 1.0 removed the top-level `url` /
|
|
2406
|
+
// `preferredTransport` pair in favour of `supportedInterfaces`, so a client written
|
|
2407
|
+
// against 1.0 greps for exactly this field and, without it, learns nothing from this
|
|
2408
|
+
// card about where to POST. `url` is doorUrl — the address message/send is actually
|
|
2409
|
+
// POSTed to, the same string the securitySchemes `exampleRequest`'s `endpoint` names —
|
|
2410
|
+
// and deliberately NOT canonUrl, which at a bare origin differs from the door by the
|
|
2411
|
+
// trailing slash (canonUrl's bytes are pinned and signed; this entry is what carries
|
|
2412
|
+
// the canonical POST form). Appended last so every field an already-deployed entry
|
|
2413
|
+
// publishes keeps its bytes AND its position.
|
|
2414
|
+
card.supportedInterfaces = [
|
|
2415
|
+
{ url: doorUrl, protocolBinding: 'JSONRPC', protocolVersion: PROTOCOL_VERSION },
|
|
2416
|
+
];
|
|
2397
2417
|
|
|
2398
2418
|
const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
|
|
2399
2419
|
// ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
|
|
@@ -2479,10 +2499,34 @@ export function createAgentEntry({
|
|
|
2479
2499
|
// five stage names — an attacker choosing UA strings cannot grow it.
|
|
2480
2500
|
const uaStats = new Map();
|
|
2481
2501
|
|
|
2502
|
+
/** Count the stage, and tell the watcher about it.
|
|
2503
|
+
*
|
|
2504
|
+
* Every stage a visitor can reach already passes through here — `card_get`, `notice_get`,
|
|
2505
|
+
* `anon_post`, `signed_post`, `refused_post` — so this is where the observer learns HOW FAR
|
|
2506
|
+
* somebody got. It could previously see only the conversations: a site shipping visits to
|
|
2507
|
+
* analytics got the answered messages and nothing else, no card fetch, no notice read, no
|
|
2508
|
+
* keyless walk-in. The interesting shape of agent traffic is exactly that drop-off — how
|
|
2509
|
+
* many looked, how many tried, how many got in.
|
|
2510
|
+
*
|
|
2511
|
+
* Reported from HERE and not from a second place, so the watcher and `entry.stats()` can
|
|
2512
|
+
* never disagree about what happened, and a stage added later is reported without anyone
|
|
2513
|
+
* remembering to. `identified` rides along because "no DID at all" and "had a DID and was
|
|
2514
|
+
* refused" are different answers for a site deciding whether to open the anonymous lane.
|
|
2515
|
+
*
|
|
2516
|
+
* A GET carries no envelope, so the watcher is told what is true and nothing invented: no
|
|
2517
|
+
* DIDs, no text, `verified: false`. The POST stages are handed to `respond()` /
|
|
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. */
|
|
2482
2521
|
function tally(family, stage) {
|
|
2483
2522
|
let row = uaStats.get(family);
|
|
2484
2523
|
if (!row) { row = new Map(); uaStats.set(family, row); }
|
|
2485
2524
|
row.set(stage, (row.get(stage) || 0) + 1);
|
|
2525
|
+
if (typeof observer !== 'function') return;
|
|
2526
|
+
if (stage === 'card_get' || stage === 'notice_get') {
|
|
2527
|
+
observe({ stage, identified: 0, verified: false, ua_family: family,
|
|
2528
|
+
peer_did: null, owner_did: null, wba_did: null, text: null });
|
|
2529
|
+
}
|
|
2486
2530
|
}
|
|
2487
2531
|
|
|
2488
2532
|
/** A plain JSON-able copy of the counters: { family: { stage: n } }. */
|
|
@@ -2495,25 +2539,32 @@ export function createAgentEntry({
|
|
|
2495
2539
|
return out;
|
|
2496
2540
|
}
|
|
2497
2541
|
|
|
2498
|
-
/** The `Link` header the notice route carries
|
|
2499
|
-
* in ONE header field (RFC 8288 allows several link-values in
|
|
2500
|
-
* is what keeps the two twins' bytes identical through their
|
|
2542
|
+
/** The `Link` header the notice route carries — the SAME one-field value for EVERY
|
|
2543
|
+
* caller. TWO relations in ONE header field (RFC 8288 allows several link-values in
|
|
2544
|
+
* one field, and one field is what keeps the two twins' bytes identical through their
|
|
2545
|
+
* single-header plumbing):
|
|
2501
2546
|
*
|
|
2502
|
-
* -
|
|
2503
|
-
*
|
|
2547
|
+
* - `rel="service-desc"` (RFC 8631's registered relation for "service description …
|
|
2548
|
+
* primarily intended for consumption by machines"), FIRST. It was emitted only to
|
|
2549
|
+
* the UA families that read as an AI agent (the T119 signpost) until a
|
|
2550
|
+
* third-party scanner (agentcard.org, 2026-08-21) measured that conditioned
|
|
2551
|
+
* emission as invisible: its crawler is none of our families, so a working door
|
|
2552
|
+
* scored as publishing no service description at all — the revisit trigger
|
|
2553
|
+
* recorded in ISSUE(agent-entry-unconditional-link). A header conditioned on a
|
|
2554
|
+
* guess about the reader is invisible to exactly the readers the guess missed,
|
|
2555
|
+
* so it is now emitted for every caller.
|
|
2556
|
+
* - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`. This is the
|
|
2557
|
+
* coexistence primitive (E4): an agent that fetched a page finds the
|
|
2504
2558
|
* machine-readable door in the RESPONSE, with no HTML to parse and no prose to
|
|
2505
2559
|
* read, and a browser ignores it — which is what lets a site keep its own front
|
|
2506
2560
|
* page and add ONE header instead of migrating. An absolute URI because RFC 8288
|
|
2507
2561
|
* §2.1.2 permits a bare token only for an IANA-registered relation.
|
|
2508
|
-
* - `rel="service-desc"` (RFC 8631's registered relation for "service description …
|
|
2509
|
-
* primarily intended for consumption by machines"), FIRST and only for the UA
|
|
2510
|
-
* families that read as an AI agent — the T119 signpost, unchanged in meaning.
|
|
2511
2562
|
*
|
|
2512
|
-
* HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller,
|
|
2513
|
-
*
|
|
2514
|
-
|
|
2563
|
+
* HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, and now
|
|
2564
|
+
* so is this header — the UA family still steers observation (`tally`/`stats`), never
|
|
2565
|
+
* a byte on the wire. */
|
|
2566
|
+
function steerHeaders(family) { // `family` kept for observation symmetry, unread here
|
|
2515
2567
|
const door = `<${mount}${AGENT_CARD_PATH}>; rel="${AGENT_ENTRY_REL}"`;
|
|
2516
|
-
if (!AI_AGENT_FAMILIES.has(family)) return { Link: door };
|
|
2517
2568
|
return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc", ${door}` };
|
|
2518
2569
|
}
|
|
2519
2570
|
|
|
@@ -2718,7 +2769,97 @@ export function createAgentEntry({
|
|
|
2718
2769
|
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
2719
2770
|
* a check the attacker simply skips.
|
|
2720
2771
|
*/
|
|
2772
|
+
/** The code of the refusal this request produced, or null when it was answered. Set by the
|
|
2773
|
+
* entry-local `rpcError` below so the funnel can report it without re-parsing a Buffer. */
|
|
2774
|
+
let lastRefusal = null;
|
|
2775
|
+
|
|
2776
|
+
/** The stage `tally()` computed for the POST currently in flight, handed to whichever
|
|
2777
|
+
* observation point reports it. `tally()` runs AFTER the ladder returns, so the answered
|
|
2778
|
+
* case is observed before this is set — which is why `respond()` reads it lazily rather
|
|
2779
|
+
* than being passed it. */
|
|
2780
|
+
let pendingStage = null;
|
|
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
|
+
|
|
2798
|
+
/** Shadows the module-level `rpcError` for the whole entry: same return value, and it
|
|
2799
|
+
* remembers the code on the way out. A local alias rather than seventeen edits, and rather
|
|
2800
|
+
* than a parameter every refusal site would have to remember to pass. */
|
|
2801
|
+
const rpcError = (id, error, data) => {
|
|
2802
|
+
lastRefusal = error && typeof error.code === 'number' ? error.code : null;
|
|
2803
|
+
return baseRpcError(id, error, data);
|
|
2804
|
+
};
|
|
2805
|
+
|
|
2806
|
+
/** Every POST outcome, observed once, at the single point they all funnel through.
|
|
2807
|
+
*
|
|
2808
|
+
* `respond()` observes the answered case with the full envelope. Everything else — the
|
|
2809
|
+
* keyless walk-in, the bad signature, the flood that hit a ceiling — returned an rpcError
|
|
2810
|
+
* and was never seen at all, so a door could count who it TALKED to and never who it TURNED
|
|
2811
|
+
* AWAY. For a site asking whether agents are arriving, the refusals are the signal: an agent
|
|
2812
|
+
* that could not get in is the one nobody hears from again. It also made the documented
|
|
2813
|
+
* contract false, since the guide says the observer runs once per message, after the verdict,
|
|
2814
|
+
* and a refusal IS a verdict.
|
|
2815
|
+
*
|
|
2816
|
+
* Structural rather than enumerated ON PURPOSE. Seventeen refusal sites would have been
|
|
2817
|
+
* seventeen chances to forget, and the eighteenth would be forgotten by construction.
|
|
2818
|
+
*
|
|
2819
|
+
* The code is read from `lastRefusal`, set by `rpcError` on its way out, rather than by
|
|
2820
|
+
* re-parsing the response — the body is already a Buffer by then, and a watcher must never
|
|
2821
|
+
* cost a JSON round-trip on the reply path.
|
|
2822
|
+
*
|
|
2823
|
+
* A refusal hands the watcher only what was actually established: `refused` carries the
|
|
2824
|
+
* JSON-RPC code and the DIDs are null, because a walk-in that named nobody named nobody. The
|
|
2825
|
+
* watcher still cannot matter — same swallowed throw, same discarded return. */
|
|
2721
2826
|
function handlePost(rawBody, reqHeaders) {
|
|
2827
|
+
lastRefusal = null;
|
|
2828
|
+
// The stage a POST reaches is decided by whether it CARRIED a signature, which is knowable
|
|
2829
|
+
// from the request alone — so it is settled here, before the ladder answers, and read by
|
|
2830
|
+
// whichever observation point fires. `tally()` computes the same thing afterwards from the
|
|
2831
|
+
// finished reply, for the counters; the two agree because they ask the same question.
|
|
2832
|
+
pendingStage = postRequestStage(rawBody);
|
|
2833
|
+
pendingFamily = uaFamily(uaOf(reqHeaders));
|
|
2834
|
+
const out = handlePostLadder(rawBody, reqHeaders);
|
|
2835
|
+
if (isThenable(out)) return out.then((o) => { observeRefusal(); return o; });
|
|
2836
|
+
observeRefusal();
|
|
2837
|
+
return out;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/** The answered case is already observed inside `respond()`; this adds refusals only, so no
|
|
2841
|
+
* message is ever observed twice. */
|
|
2842
|
+
function observeRefusal() {
|
|
2843
|
+
if (typeof observer !== 'function' || lastRefusal === null) return;
|
|
2844
|
+
observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
|
|
2845
|
+
identified: pendingStage === 'signed_post' ? 1 : 0, ua_family: pendingFamily,
|
|
2846
|
+
peer_did: null, owner_did: null, wba_did: null, text: null });
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/** Did this POST body carry a signature? That is the whole difference between a keyless
|
|
2850
|
+
* walk-in and an identified visitor, and it is answerable from the request without waiting
|
|
2851
|
+
* for the verdict — a bad signature is still a visitor who HAD a key. Parsed defensively:
|
|
2852
|
+
* anything unreadable is a walk-in, because it certainly did not present an identity. */
|
|
2853
|
+
function postRequestStage(rawBody) {
|
|
2854
|
+
try {
|
|
2855
|
+
const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
|
|
2856
|
+
const req = JSON.parse(buf.toString('utf8'));
|
|
2857
|
+
const meta = req?.params?.message?.metadata;
|
|
2858
|
+
return (meta && typeof meta.sig === 'string' && meta.sig) ? 'signed_post' : 'anon_post';
|
|
2859
|
+
} catch { return 'anon_post'; }
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function handlePostLadder(rawBody, reqHeaders) {
|
|
2722
2863
|
// RAW BYTES, always. A host app that hands us a decoded string has already destroyed
|
|
2723
2864
|
// the evidence the strict decode below exists to find, so normalise once and measure
|
|
2724
2865
|
// the SIZE in bytes rather than in UTF-16 code units.
|
|
@@ -2871,18 +3012,26 @@ export function createAgentEntry({
|
|
|
2871
3012
|
if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
|
|
2872
3013
|
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
|
|
2873
3014
|
}
|
|
2874
|
-
// 7.
|
|
2875
|
-
// gate: a non-string
|
|
3015
|
+
// 7. the signature itself, under the key DERIVED FROM `from`. (`messageId`'s type was
|
|
3016
|
+
// settled by the shape gate: a non-string never reaches here on either implementation.)
|
|
2876
3017
|
const messageId = msg.messageId;
|
|
2877
|
-
if (!replay.checkAndRemember(messageId)) {
|
|
2878
|
-
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
2879
|
-
}
|
|
2880
|
-
// 8. the signature itself, under the key DERIVED FROM `from`.
|
|
2881
3018
|
const fields = { from, to, messageId, contextId: msg.contextId ?? null,
|
|
2882
3019
|
timestamp: ts, text, sig };
|
|
2883
3020
|
if (!verifyEnvelope(fields, { recipientDid: did })) {
|
|
2884
3021
|
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
|
|
2885
3022
|
}
|
|
3023
|
+
// 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
|
|
3024
|
+
// property. The table is state an authenticated sender depends on, so a caller who has
|
|
3025
|
+
// proved nothing must not write into it. Ahead of the verify a stranger could BURN an id
|
|
3026
|
+
// its real sender was about to use, and because the table is capped and evicts
|
|
3027
|
+
// oldest-first, flood past the cap to discard genuine entries and re-open real messages
|
|
3028
|
+
// to replay — invalid signatures are not rate-limited, so that flood is free. This is the
|
|
3029
|
+
// rule the signed lane's ceiling already follows one step below, for the same reason; it
|
|
3030
|
+
// was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
|
|
3031
|
+
// replayed VALID message, which an attacker must first have obtained.
|
|
3032
|
+
if (!replay.checkAndRemember(messageId)) {
|
|
3033
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
3034
|
+
}
|
|
2886
3035
|
|
|
2887
3036
|
// 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's device
|
|
2888
3037
|
// DIDs to ONE account; a present-but-INVALID binding fails closed with the SAME
|
|
@@ -2936,18 +3085,22 @@ export function createAgentEntry({
|
|
|
2936
3085
|
}
|
|
2937
3086
|
|
|
2938
3087
|
function respond(env, reqId, msg, toDid) {
|
|
2939
|
-
|
|
3088
|
+
// The answered case: the envelope already says who this was, and the stage says how they
|
|
3089
|
+
// arrived. `identified` is read off the envelope rather than the stage, because the
|
|
3090
|
+
// anonymous lane answers a visitor who genuinely presented no DID.
|
|
3091
|
+
observe({ ...env, stage: pendingStage, ua_family: pendingFamily,
|
|
3092
|
+
identified: env && env.peer_did ? 1 : 0 });
|
|
2940
3093
|
let answer;
|
|
2941
3094
|
try {
|
|
2942
3095
|
answer = responder(env);
|
|
2943
3096
|
} catch (e) {
|
|
2944
|
-
return rpcError(reqId, ERRORS.INTERNAL_ERROR,
|
|
3097
|
+
return rpcError(reqId, ERRORS.INTERNAL_ERROR, 'the site backend failed to answer');
|
|
2945
3098
|
}
|
|
2946
3099
|
const inbound = { contextId: msg.contextId ?? null, messageId: msg.messageId ?? null };
|
|
2947
3100
|
if (isThenable(answer)) {
|
|
2948
3101
|
return answer.then(
|
|
2949
3102
|
(v) => finishReply(reqId, v, { inbound, toDid }),
|
|
2950
|
-
(e) => rpcError(reqId, ERRORS.INTERNAL_ERROR,
|
|
3103
|
+
(e) => rpcError(reqId, ERRORS.INTERNAL_ERROR, 'the site backend failed to answer'));
|
|
2951
3104
|
}
|
|
2952
3105
|
return finishReply(reqId, answer, { inbound, toDid });
|
|
2953
3106
|
}
|
|
@@ -3055,8 +3208,8 @@ export function createAgentEntry({
|
|
|
3055
3208
|
return { status: 200,
|
|
3056
3209
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
3057
3210
|
'Content-Length': String(body.length),
|
|
3058
|
-
//
|
|
3059
|
-
//
|
|
3211
|
+
// Every caller is pointed at the machine-readable door — the same one-field
|
|
3212
|
+
// Link value for all of them (v1.11). The body above is byte-identical too.
|
|
3060
3213
|
...steerHeaders(family) },
|
|
3061
3214
|
body };
|
|
3062
3215
|
}
|
|
@@ -3180,7 +3333,7 @@ export function createAgentEntry({
|
|
|
3180
3333
|
const body = oversize ? oversizeSentinel() : Buffer.concat(chunks, total);
|
|
3181
3334
|
Promise.resolve()
|
|
3182
3335
|
.then(() => handleRequestAsync(req.method, req.url, req.headers, body))
|
|
3183
|
-
.catch((
|
|
3336
|
+
.catch(() => rpcError(null, ERRORS.INTERNAL_ERROR, 'the entry failed to answer'))
|
|
3184
3337
|
.then(({ status, headers, body: out }) => {
|
|
3185
3338
|
res.writeHead(status, headers);
|
|
3186
3339
|
res.end(req.method === 'HEAD' ? undefined : out);
|
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.
|