@muretai/agent-entry 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,7 +114,7 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
114
114
  | `domains` | none | the domains this entry speaks for (see below) |
115
115
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
116
116
  | `wbaVerifiers` | none | a JWKS document (`{"keys":[…]}`) of Ed25519 keys whose holders this entry should **recognise** on inbound signed requests (Web Bot Auth / RFC 9421 — see *Who is knocking*). Recognition only adds `env.wba_did` and a visit count; it never changes a verdict |
117
- | `observer` | none | called once per message with the same envelope your responder gets, 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) |
117
+ | `observer` | none | called once per message with the same envelope your responder gets, plus `stage`, `identified`, `ua_family` and `client_class`, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-your-customer-list) |
118
118
  | `howToUrl` | none | a page a keyless visitor is pointed at as a worked example. **Empty means omitted** — the refusal already teaches the whole recipe without it, and a reference implementation must not stamp somebody else's docs host into every door built from it. Only set it to a URL you operate, and only after checking it resolves |
119
119
  | `name`, `description`, `version` | — | the card's own words. `description` is the line a person reads in a directory listing — and the right place to say what you record about visitors, since it is fetched **before** the knock |
120
120
 
@@ -131,12 +131,21 @@ door counts it:
131
131
  ```js
132
132
  entry.stats()
133
133
  // { gptbot: { card_get: 12, signed_post: 3 },
134
- // browser: { notice_get: 5 } }
134
+ // browser: { notice_get: 5, card_get: 2 } }
135
+
136
+ entry.clientStats()
137
+ // { 'declared-agent': { card_get: 12, signed_post: 3 },
138
+ // 'human-like': { notice_get: 5 },
139
+ // 'stealth-agent': { card_get: 2 } }
135
140
  ```
136
141
 
137
142
  Each request's `User-Agent` is classified into a fixed family (`claude-user`,
138
143
  `claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
139
- `curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
144
+ `camoufox`, `playwright`, `puppeteer`, `selenium`, `headless-chrome`,
145
+ `curl`, `browser`, `none`/`other`) and counted by stage. `clientStats()` then
146
+ folds those families into four owner-facing classes — `declared-agent`,
147
+ `named-tool`, `stealth-agent`, `human-like` — so a Firefox-looking fetch of the
148
+ card is counted as a stealth agent, not as a person. In-process state like the
140
149
  ledger — read it, log it, ship it to your analytics; it is never served on the wire.
141
150
  Every caller also gets one nudge: `GET /` answers with a single `Link:` field carrying
142
151
  two relations — `rel="service-desc"` (RFC 8631) first, then the door pointer
@@ -154,6 +163,15 @@ five stages now carry it, refusals included, so "which clients got in and which
154
163
  is one query instead of two half-answers. Nothing else moved: no wire byte, no verdict, no ledger
155
164
  row, no rate lane, and `stats()` is unchanged.
156
165
 
166
+ **Since 1.9.0 a Firefox-looking card fetch is not a human.** `stats()` still files it under
167
+ `browser` — Camoufox's published shape is a clean Firefox UA, and that is the point of
168
+ stealth. `clientStats()` splits it: a browser that only opened the notice is `human-like`;
169
+ the same UA on the card or the door is `stealth-agent`; a leaking automation token
170
+ (`playwright`, `camoufox`, …) is `named-tool`. Export `bodySignpost()` and put that
171
+ `<a>` in the page body, because a snapshot client never sees the header or the
172
+ `<head>` tag. Still observation only — the same POST with or without that UA is the
173
+ same refusal.
174
+
157
175
  One rule holds this together, enforced by the contract suite rather than promised:
158
176
  **a User-Agent never affects `verified`, an account row, a rate limit, or any
159
177
  refusal.** A UA string is written by the client; a door that trusted it would be a
@@ -228,8 +246,8 @@ framework, an edge worker — that notice never renders**, and your home page is
228
246
  people with nothing machine-readable in it. The address ends up published in a card nobody was
229
247
  told to fetch.
230
248
 
231
- So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
232
- fallback for the other:
249
+ So put the pointer on every page a visitor might land on, in **all three** spellings. None is a
250
+ fallback for the others:
233
251
 
234
252
  ```
235
253
  Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
@@ -237,13 +255,17 @@ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
237
255
 
238
256
  ```html
239
257
  <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
258
+ <a href="/.well-known/agent-card.json" rel="https://muretai.net/rel/agent-entry">This site answers agents at /.well-known/agent-card.json</a>
240
259
  ```
241
260
 
242
261
  The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
243
- endpoint requires a request to that host. The two spellings exist because the two kinds of client
262
+ endpoint requires a request to that host. The three spellings exist because three kinds of client
244
263
  have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
245
- header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
246
- which kind arrived.
264
+ header; one that reads only headers never parses the HTML; and a snapshot / ARIA client
265
+ (Camofox, Playwright accessibility dumps) sees only `<body>`, so the `<link>` in `<head>`
266
+ vanishes too. Shipping one is a coin flip on which kind arrived. The module exports
267
+ `bodySignpost()` so the body `<a>` is one function call, not a string you have to keep in
268
+ sync with the relation URI.
247
269
 
248
270
  We know because we shipped one. An agent that had never been told about our door was handed only
249
271
  the domain, fetched the page, read the copy written for humans, and stopped — while the door had
@@ -253,13 +275,13 @@ Then check it from outside, because this is exactly the class of thing that look
253
275
 
254
276
  ```bash
255
277
  curl -sI https://studio.example/ | grep -i '^link:' # the header half
256
- curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
278
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half AND the body <a>
257
279
  ```
258
280
 
259
- Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
260
- to markdown**, which is a common way an agent reads the web headers are dropped and so is
261
- everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
262
- that agents are answered here, and name the card path in text a reader can act on.
281
+ Worth knowing before you call it done: **the header and the `<head>` tag disappear in a fetch
282
+ that converts the page to markdown, and in an accessibility snapshot that only sees `<body>`**
283
+ a common way an agent browser reads the web. The body `<a>` is the spelling those clients
284
+ can still see.
263
285
 
264
286
  ### Check that your own CDN is not refusing your door
265
287
 
@@ -183,17 +183,24 @@ const server = entry.listen(port, host, () => {
183
183
  // printed only when they changed. `[ua]` is greppable; the shape is entry.stats()
184
184
  // verbatim ({family: {stage: n}}). `unref()` so the timer never holds the process open.
185
185
  let lastStats = '';
186
+ let lastClient = '';
186
187
  setInterval(() => {
187
188
  // Sorted keys at every level so the line is stable run to run (and diffable against
188
189
  // the Python runner's `json.dumps(..., sort_keys=True)` spelling of the same shape).
189
- const line = JSON.stringify(entry.stats(), (k, v) =>
190
+ const stable = (obj) => JSON.stringify(obj, (k, v) =>
190
191
  (v && typeof v === 'object' && !Array.isArray(v))
191
192
  ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]]))
192
193
  : v);
194
+ const line = stable(entry.stats());
193
195
  if (line !== '{}' && line !== lastStats) {
194
196
  console.log(`[ua] ${line}`);
195
197
  lastStats = line;
196
198
  }
199
+ const clientLine = stable(entry.clientStats());
200
+ if (clientLine !== '{}' && clientLine !== lastClient) {
201
+ console.log(`[client] ${clientLine}`);
202
+ lastClient = clientLine;
203
+ }
197
204
  }, 60_000).unref();
198
205
 
199
206
  // A port collision is the first thing anyone running this twice hits (a previous run that was
@@ -107,6 +107,17 @@ export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
107
107
  * examples/agent_entry_reference.py. */
108
108
  export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
109
109
 
110
+ /** The body `<a>` a site puts on its own front page so a snapshot / ARIA / `a[href]`
111
+ * client (Camofox, Playwright accessibility dumps, markdown converters) can still find
112
+ * the door. `<link rel>` in `<head>` and the HTTP `Link` header both vanish in those
113
+ * views; an in-body anchor with this relation does not. `href` is the card path this
114
+ * entry actually answers (mount-prefixed when the entry sits under a path). Must match
115
+ * `body_signpost` in examples/agent_entry_reference.py. */
116
+ export function bodySignpost(href = AGENT_CARD_PATH) {
117
+ const path = typeof href === 'string' && href ? href : AGENT_CARD_PATH;
118
+ return `<a href="${path}" rel="${AGENT_ENTRY_REL}">This site answers agents at ${path}</a>`;
119
+ }
120
+
110
121
  /** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
111
122
  * the card AND in the refusal, so an agent that has only one of the two still has the URL.
112
123
  *
@@ -332,6 +343,16 @@ export const UA_FAMILIES = [
332
343
  ['perplexity', 'perplexity'],
333
344
  ['google-extended', 'google-extended'],
334
345
  ['muretai-node', 'muretai-node'],
346
+ // Leaking automation UAs. Must sit BEFORE `mozilla`: Camoufox/Playwright Firefox UAs
347
+ // start with "Mozilla/5.0 …" and a needle after that would never fire. A patched
348
+ // Camoufox sends a clean Firefox UA and classifies as `browser` — that is the
349
+ // stealth-agent case `clientClass` exists for, not a miss in this table.
350
+ ['camoufox', 'camoufox'],
351
+ ['camofox', 'camoufox'],
352
+ ['playwright', 'playwright'],
353
+ ['puppeteer', 'puppeteer'],
354
+ ['selenium', 'selenium'],
355
+ ['headlesschrome', 'headless-chrome'],
335
356
  ['curl', 'curl'],
336
357
  ['mozilla', 'browser'],
337
358
  ];
@@ -346,6 +367,20 @@ export const AI_AGENT_FAMILIES = new Set([
346
367
  'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
347
368
  ]);
348
369
 
370
+ /** Automation / HTTP-tool families that named themselves in the UA. OBSERVATION ONLY —
371
+ * same rule as the rest of this table: never identity, never a verdict. Must match
372
+ * `NAMED_TOOL_FAMILIES` in examples/agent_entry_reference.py. */
373
+ export const NAMED_TOOL_FAMILIES = new Set([
374
+ 'camoufox', 'playwright', 'puppeteer', 'selenium', 'headless-chrome', 'curl',
375
+ ]);
376
+
377
+ /** The four owner-facing traffic classes `clientClass` may return. Bounded on purpose:
378
+ * an attacker-chosen UA must never become a class name. Must match
379
+ * `CLIENT_CLASSES` in examples/agent_entry_reference.py. */
380
+ export const CLIENT_CLASSES = new Set([
381
+ 'declared-agent', 'named-tool', 'stealth-agent', 'human-like',
382
+ ]);
383
+
349
384
  /** ASCII-only lowercase fold. NOT `toLowerCase()`: Unicode casing is runtime- and
350
385
  * locale-shaped (the Turkish-I class of surprise), and no needle in the table needs it —
351
386
  * folding only A-Z is what makes the same UA string classify identically in both twins. */
@@ -359,7 +394,7 @@ function asciiLower(s) {
359
394
  }
360
395
 
361
396
  /** UA string -> family. Absent/empty/non-string -> 'none'; no needle matched -> 'other'.
362
- * Total on untrusted input, and the RETURN VALUE is always one of the twelve fixed
397
+ * Total on untrusted input, and the RETURN VALUE is always one of the fixed
363
398
  * family names — never a substring of the input (bounded stats keyspace). */
364
399
  export function uaFamily(ua) {
365
400
  if (typeof ua !== 'string' || !ua) return 'none';
@@ -370,6 +405,23 @@ export function uaFamily(ua) {
370
405
  return 'other';
371
406
  }
372
407
 
408
+ /** Owner-facing traffic class. OBSERVATION ONLY — never `verified`, never a ledger row,
409
+ * never a rate lane, never a refusal. The split an operator asked for: tell agent
410
+ * traffic from human traffic WITHOUT treating a spoofable UA as a credential.
411
+ *
412
+ * Camoufox / patched Playwright send a clean Firefox UA, so `uaFamily` says `browser`.
413
+ * That is not a human reading the homepage. A human Firefox almost never GETs the
414
+ * agent card or POSTs the door; a stealth agent that found the door does. The one
415
+ * human-shaped case at the door is `browser` + `notice_get` (someone opened the
416
+ * address as a document on a site-owning mount). Must match `client_class` in
417
+ * examples/agent_entry_reference.py. */
418
+ export function clientClass(family, stage) {
419
+ if (AI_AGENT_FAMILIES.has(family) || family === 'muretai-node') return 'declared-agent';
420
+ if (NAMED_TOOL_FAMILIES.has(family)) return 'named-tool';
421
+ if (family === 'browser' && stage === 'notice_get') return 'human-like';
422
+ return 'stealth-agent';
423
+ }
424
+
373
425
  /** The FIRST User-Agent value out of a headers mapping, or null. Case-insensitive key
374
426
  * scan so an in-process host can pass any casing; Node's own `req.headers` already
375
427
  * lowercases keys and keeps only the FIRST user-agent of a duplicated pair — the Python
@@ -2254,11 +2306,14 @@ export function canonicalMount(canonUrl, basePath) {
2254
2306
  * callback, and a watcher that dialled out on the hot path would make the
2255
2307
  * visitor's answer depend on somebody else's uptime.
2256
2308
  *
2257
- * WHAT IT IS TOLD. Every stage reports `stage`, `identified` and
2309
+ * WHAT IT IS TOLD. Every stage reports `stage`, `identified`,
2258
2310
  * `ua_family` — the door's own bounded classification of the client, one
2259
2311
  * 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.
2312
+ * sent, so a stranger cannot write its own label into your metrics — and
2313
+ * `client_class`, the four-way split (`declared-agent` / `named-tool` /
2314
+ * `stealth-agent` / `human-like`) that tells an operator whether a
2315
+ * Firefox-looking knock is a stealth agent at the door or a human who
2316
+ * opened the notice. The POST stages add the envelope on top.
2262
2317
  *
2263
2318
  * WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
2264
2319
  * which a visitor handed you to transact with YOU. Forwarding a raw DID to
@@ -2498,6 +2553,7 @@ export function createAgentEntry({
2498
2553
  // composition to any stranger). Keyspace bounded by the fixed UA_FAMILIES table times
2499
2554
  // five stage names — an attacker choosing UA strings cannot grow it.
2500
2555
  const uaStats = new Map();
2556
+ const clientStatsMap = new Map();
2501
2557
 
2502
2558
  /** Count the stage, and tell the watcher about it.
2503
2559
  *
@@ -2522,9 +2578,14 @@ export function createAgentEntry({
2522
2578
  let row = uaStats.get(family);
2523
2579
  if (!row) { row = new Map(); uaStats.set(family, row); }
2524
2580
  row.set(stage, (row.get(stage) || 0) + 1);
2581
+ const cls = clientClass(family, stage);
2582
+ let crow = clientStatsMap.get(cls);
2583
+ if (!crow) { crow = new Map(); clientStatsMap.set(cls, crow); }
2584
+ crow.set(stage, (crow.get(stage) || 0) + 1);
2525
2585
  if (typeof observer !== 'function') return;
2526
2586
  if (stage === 'card_get' || stage === 'notice_get') {
2527
2587
  observe({ stage, identified: 0, verified: false, ua_family: family,
2588
+ client_class: cls,
2528
2589
  peer_did: null, owner_did: null, wba_did: null, text: null });
2529
2590
  }
2530
2591
  }
@@ -2539,6 +2600,17 @@ export function createAgentEntry({
2539
2600
  return out;
2540
2601
  }
2541
2602
 
2603
+ /** A plain JSON-able copy of the traffic-class counters: { class: { stage: n } }.
2604
+ * Same contract as `stats()`: in-process only, never on the wire, bounded keyspace. */
2605
+ function clientStats() {
2606
+ const out = {};
2607
+ for (const [cls, row] of clientStatsMap) {
2608
+ out[cls] = {};
2609
+ for (const [stage, n] of row) out[cls][stage] = n;
2610
+ }
2611
+ return out;
2612
+ }
2613
+
2542
2614
  /** The `Link` header the notice route carries — the SAME one-field value for EVERY
2543
2615
  * caller. TWO relations in ONE header field (RFC 8288 allows several link-values in
2544
2616
  * one field, and one field is what keeps the two twins' bytes identical through their
@@ -2843,6 +2915,7 @@ export function createAgentEntry({
2843
2915
  if (typeof observer !== 'function' || lastRefusal === null) return;
2844
2916
  observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
2845
2917
  identified: pendingStage === 'signed_post' ? 1 : 0, ua_family: pendingFamily,
2918
+ client_class: clientClass(pendingFamily, 'refused_post'),
2846
2919
  peer_did: null, owner_did: null, wba_did: null, text: null });
2847
2920
  }
2848
2921
 
@@ -3089,6 +3162,7 @@ export function createAgentEntry({
3089
3162
  // arrived. `identified` is read off the envelope rather than the stage, because the
3090
3163
  // anonymous lane answers a visitor who genuinely presented no DID.
3091
3164
  observe({ ...env, stage: pendingStage, ua_family: pendingFamily,
3165
+ client_class: clientClass(pendingFamily, pendingStage),
3092
3166
  identified: env && env.peer_did ? 1 : 0 });
3093
3167
  let answer;
3094
3168
  try {
@@ -3236,6 +3310,24 @@ export function createAgentEntry({
3236
3310
  // and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
3237
3311
  // host, which belongs to the site (or to the neighbour agent) and not to us.
3238
3312
  if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
3313
+ // A QUERY STRING MEANS THE POST IS NOT OURS. The door's address is the signed
3314
+ // card's `url`, byte-exact: a base URL carrying a query is refused at startup, and
3315
+ // a visitor's walk drops any query it was handed before it POSTs the card's `url` —
3316
+ // so no conformant caller can arrive here, while a site's own query-multiplexed
3317
+ // traffic (`?wc-api=`, `?wc-ajax=`, `?rest_route=`) always does. Disjoint BY
3318
+ // CONSTRUCTION, which is what makes this a rule and not a heuristic. Measured:
3319
+ // WooCommerce Stripe delivers its only webhook to `/?wc-api=wc_stripe` (path `/`,
3320
+ // application/json); a bare-origin door that claimed it answered HTTP 200/-32601
3321
+ // echoing the event id, the sender recorded the event as delivered and never
3322
+ // retried — payment events lost SILENTLY. The answer is the unowned-path 404
3323
+ // above, same bytes, one meaning — the DECISION(non-door-post-answers-404-not-405)
3324
+ // non-disclosure again, because the caller most likely to land here is the site's
3325
+ // own webhook, which deserves the most anonymous answer, never a door verdict.
3326
+ const hashless = target.split('#')[0];
3327
+ const queryAt = hashless.indexOf('?');
3328
+ if (queryAt !== -1 && queryAt + 1 < hashless.length) {
3329
+ return jsonResponse(404, { error: 'not found' });
3330
+ }
3239
3331
  const buf = bodyBuffer || Buffer.alloc(0);
3240
3332
  const out = handlePost(buf, headers);
3241
3333
  // The stage is read off the finished answer, so an async responder tallies when it
@@ -3367,9 +3459,10 @@ export function createAgentEntry({
3367
3459
 
3368
3460
  // `mount` is exported so a host app can route exactly what this entry answers (and log
3369
3461
  // it): it is derived, so reading it here can never disagree with the signed card.
3370
- // `stats` is the owner-facing UA-family counters and `wbaVisits` the DID->count of
3371
- // WBA-verified fetches both in-process only, like `ledger`.
3372
- return { did, card, ledger, mount, stats, wbaVisits,
3462
+ // `stats` is the owner-facing UA-family counters, `clientStats` the four-way
3463
+ // traffic-class split, and `wbaVisits` the DID->count of WBA-verified fetches
3464
+ // all in-process only, like `ledger`.
3465
+ return { did, card, ledger, mount, stats, clientStats, wbaVisits,
3373
3466
  handleRequest, handleRequestAsync, listen,
3374
3467
  cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
3375
3468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",