@kirkelabs/agent-readiness-scan 0.1.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/src/fetcher.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * fetcher.js
3
+ *
4
+ * Retrieves a URL the same way a non-JavaScript AI crawler does, and
5
+ * also fetches site-rooted sibling paths (robots.txt and .well-known/*).
6
+ * GPTBot, ClaudeBot, PerplexityBot and Google-Extended do not execute
7
+ * JavaScript — they read the raw HTML the server returns. This module
8
+ * deliberately performs a single plain HTTP GET with an AI-crawler
9
+ * user agent and never runs scripts.
10
+ */
11
+
12
+ const USER_AGENTS = {
13
+ gptbot:
14
+ 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.1; +https://openai.com/gptbot',
15
+ claudebot:
16
+ 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://www.anthropic.com/claude-bot)',
17
+ perplexitybot:
18
+ 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)',
19
+ google:
20
+ 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; Google-Extended',
21
+ default:
22
+ 'agent-readiness-scan/0.1 (+https://github.com/KirkeLabs/agent-readiness-scan)',
23
+ };
24
+
25
+ /**
26
+ * Fetch a resource as an AI crawler would.
27
+ * @param {string} url
28
+ * @param {object} opts
29
+ * @param {string} [opts.agent='gptbot']
30
+ * @param {number} [opts.timeoutMs=15000]
31
+ * @returns {Promise<{ok:boolean,status:number,html:string,headers:object,finalUrl:string,error?:string}>}
32
+ */
33
+ export async function fetchAsCrawler(url, opts = {}) {
34
+ const agent = USER_AGENTS[opts.agent] || USER_AGENTS.gptbot;
35
+ const timeoutMs = opts.timeoutMs ?? 15000;
36
+ const controller = new AbortController();
37
+ const t = setTimeout(() => controller.abort(), timeoutMs);
38
+
39
+ try {
40
+ const res = await fetch(url, {
41
+ redirect: 'follow',
42
+ signal: controller.signal,
43
+ headers: {
44
+ 'User-Agent': agent,
45
+ Accept: 'text/html,application/xhtml+xml,application/json,*/*',
46
+ },
47
+ });
48
+ const html = await res.text();
49
+ const headers = {};
50
+ res.headers.forEach((v, k) => {
51
+ headers[k] = v;
52
+ });
53
+ return {
54
+ ok: res.ok,
55
+ status: res.status,
56
+ html,
57
+ headers,
58
+ finalUrl: res.url || url,
59
+ };
60
+ } catch (err) {
61
+ return {
62
+ ok: false,
63
+ status: 0,
64
+ html: '',
65
+ headers: {},
66
+ finalUrl: url,
67
+ error: err.name === 'AbortError' ? `Timed out after ${timeoutMs}ms` : err.message,
68
+ };
69
+ } finally {
70
+ clearTimeout(t);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch a sibling resource (robots.txt, /.well-known/*) at the site root.
76
+ * @param {string} baseUrl
77
+ * @param {string} path e.g. '/.well-known/mcp/server-card.json'
78
+ */
79
+ export async function fetchSibling(baseUrl, path, opts = {}) {
80
+ let origin;
81
+ try {
82
+ origin = new URL(baseUrl).origin;
83
+ } catch {
84
+ return { ok: false, status: 0, html: '', headers: {}, finalUrl: path };
85
+ }
86
+ return fetchAsCrawler(origin + path, opts);
87
+ }
88
+
89
+ export { USER_AGENTS };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * generators.js
3
+ *
4
+ * Produces the "customs declaration" — a drop-in set of files ready to
5
+ * paste at the site root that close most of the policy gaps the
6
+ * scanner identifies. The user reviews and edits before deploy;
7
+ * these are conservative scaffolds, not finished output.
8
+ *
9
+ * Generated artefacts:
10
+ * - robots.txt (per-bot policy + Content-Signals)
11
+ * - .well-known/security.txt (RFC 9116)
12
+ * - .well-known/mcp/server-card.json (MCP server card scaffold)
13
+ * - .well-known/acp/manifest.json (ACP manifest scaffold)
14
+ */
15
+
16
+ const AI_BOTS = {
17
+ training: [
18
+ 'GPTBot',
19
+ 'ClaudeBot',
20
+ 'Google-Extended',
21
+ 'anthropic-ai',
22
+ 'CCBot',
23
+ 'Bytespider',
24
+ 'Amazonbot',
25
+ 'Applebot-Extended',
26
+ 'meta-externalagent',
27
+ ],
28
+ grounding: ['OAI-SearchBot', 'PerplexityBot', 'Claude-Web'],
29
+ userDirected: ['ChatGPT-User', 'Claude-User'],
30
+ };
31
+
32
+ function generateRobotsTxt(origin) {
33
+ const lines = [];
34
+ lines.push('# robots.txt — Customs Declaration');
35
+ lines.push('# Generated by @kirkelabs/agent-readiness-scan.');
36
+ lines.push('# Review and edit before deploying. Tighten or loosen rules to match');
37
+ lines.push('# your stated bargain with AI agents (training vs grounding vs user-directed).');
38
+ lines.push('');
39
+ lines.push('# --- Default allow-all baseline ---');
40
+ lines.push('User-agent: *');
41
+ lines.push('Allow: /');
42
+ lines.push('');
43
+ lines.push('# --- Training crawlers (use your content to train foundation models) ---');
44
+ for (const bot of AI_BOTS.training) {
45
+ lines.push(`User-agent: ${bot}`);
46
+ lines.push('Disallow: # TODO: set to / to disallow, blank to allow');
47
+ lines.push('');
48
+ }
49
+ lines.push('# --- Grounding crawlers (fetch in real time for answer-engine retrieval) ---');
50
+ for (const bot of AI_BOTS.grounding) {
51
+ lines.push(`User-agent: ${bot}`);
52
+ lines.push('Allow: /');
53
+ lines.push('');
54
+ }
55
+ lines.push('# --- User-directed crawlers (fetched on behalf of a logged-in user) ---');
56
+ for (const bot of AI_BOTS.userDirected) {
57
+ lines.push(`User-agent: ${bot}`);
58
+ lines.push('Allow: /');
59
+ lines.push('');
60
+ }
61
+ lines.push('# --- Cloudflare Content Signals (which uses you permit) ---');
62
+ lines.push('# See https://contentsignals.org/');
63
+ lines.push('Content-Signal: search=yes, ai-input=yes, ai-train=no');
64
+ lines.push('');
65
+ lines.push(`Sitemap: ${origin}/sitemap.xml`);
66
+ lines.push('');
67
+ return lines.join('\n');
68
+ }
69
+
70
+ function generateSecurityTxt() {
71
+ const expires = new Date();
72
+ expires.setFullYear(expires.getFullYear() + 1);
73
+ const lines = [];
74
+ lines.push('# /.well-known/security.txt — RFC 9116');
75
+ lines.push('# Generated by @kirkelabs/agent-readiness-scan. Review before deploying.');
76
+ lines.push('');
77
+ lines.push('Contact: mailto:security@your-domain.example');
78
+ lines.push(`Expires: ${expires.toISOString()}`);
79
+ lines.push('Preferred-Languages: en');
80
+ lines.push('# Canonical: https://your-domain.example/.well-known/security.txt');
81
+ lines.push('# Encryption: https://your-domain.example/pgp-key.asc');
82
+ lines.push('');
83
+ return lines.join('\n');
84
+ }
85
+
86
+ function generateMcpServerCard(origin, name) {
87
+ return JSON.stringify(
88
+ {
89
+ $schema: 'https://modelcontextprotocol.io/schemas/server-card/v1',
90
+ name: name || 'TODO: Your service name',
91
+ description: 'TODO: One-sentence description of what this MCP server lets agents do.',
92
+ version: '0.1.0',
93
+ url: `${origin}/mcp`,
94
+ contact: {
95
+ name: 'TODO: maintainer',
96
+ email: 'mcp@your-domain.example',
97
+ },
98
+ tools: [
99
+ {
100
+ name: 'TODO_tool_name',
101
+ description: 'TODO: what this tool does',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {},
105
+ required: [],
106
+ },
107
+ },
108
+ ],
109
+ authentication: {
110
+ type: 'oauth2',
111
+ authorizationEndpoint: `${origin}/oauth/authorize`,
112
+ tokenEndpoint: `${origin}/oauth/token`,
113
+ pkce: { codeChallengeMethods: ['S256'] },
114
+ },
115
+ },
116
+ null,
117
+ 2,
118
+ );
119
+ }
120
+
121
+ function generateAcpManifest(origin, name) {
122
+ return JSON.stringify(
123
+ {
124
+ version: '2026-04-17',
125
+ merchant: {
126
+ name: name || 'TODO: Your merchant name',
127
+ url: origin,
128
+ contact: 'merchant@your-domain.example',
129
+ },
130
+ capabilities: {
131
+ checkout: { endpoint: `${origin}/acp/checkout`, supportedMethods: ['card'] },
132
+ productCatalog: { endpoint: `${origin}/acp/products` },
133
+ },
134
+ keys: [
135
+ {
136
+ kty: 'TODO',
137
+ kid: 'TODO',
138
+ alg: 'RS256',
139
+ use: 'sig',
140
+ n: 'TODO: base64url-encoded RSA modulus',
141
+ e: 'AQAB',
142
+ },
143
+ ],
144
+ },
145
+ null,
146
+ 2,
147
+ );
148
+ }
149
+
150
+ function deriveName($, finalUrl) {
151
+ const t = ($('title').first().text() || '').trim();
152
+ if (t) return t.split('—')[0].split('|')[0].trim();
153
+ try {
154
+ return new URL(finalUrl).hostname.replace(/^www\./, '');
155
+ } catch {
156
+ return 'Your Brand';
157
+ }
158
+ }
159
+
160
+ export function generateCustomsDeclaration(ctx) {
161
+ let origin = ctx.finalUrl;
162
+ try {
163
+ origin = new URL(ctx.finalUrl).origin;
164
+ } catch {
165
+ /* keep */
166
+ }
167
+ const name = deriveName(ctx.$, ctx.finalUrl);
168
+ return {
169
+ 'robots.txt': generateRobotsTxt(origin),
170
+ '.well-known/security.txt': generateSecurityTxt(),
171
+ '.well-known/mcp/server-card.json': generateMcpServerCard(origin, name),
172
+ '.well-known/acp/manifest.json': generateAcpManifest(origin, name),
173
+ };
174
+ }
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * index.js — the scan orchestrator (public API).
3
+ *
4
+ * Programmatic entry point:
5
+ * import { scan } from '@kirkelabs/agent-readiness-scan';
6
+ * const result = await scan('https://example.com');
7
+ *
8
+ * Pre-fetches the target page + robots.txt + 7 .well-known/* paths in
9
+ * parallel, then runs 8 pure check functions against the assembled
10
+ * context. Each check is a synchronous module that exports
11
+ * `meta` ({id, title, weight, why}) and `run(ctx)`.
12
+ */
13
+
14
+ import { load } from 'cheerio';
15
+ import { fetchAsCrawler, fetchSibling } from './fetcher.js';
16
+ import { generateCustomsDeclaration } from './generators.js';
17
+
18
+ import * as c01 from './checks/01-per-bot-policy.js';
19
+ import * as c02 from './checks/02-declared-use-signals.js';
20
+ import * as c03 from './checks/03-bot-auth-readiness.js';
21
+ import * as c04 from './checks/04-mcp-exposure.js';
22
+ import * as c05 from './checks/05-agentic-commerce.js';
23
+ import * as c06 from './checks/06-product-offer.js';
24
+ import * as c07 from './checks/07-identity-corroboration.js';
25
+ import * as c08 from './checks/08-source-regulatory.js';
26
+
27
+ const CHECKS = [c01, c02, c03, c04, c05, c06, c07, c08];
28
+
29
+ const WELL_KNOWN_PATHS = {
30
+ securityTxt: '/.well-known/security.txt',
31
+ mcpServerCard: '/.well-known/mcp/server-card.json',
32
+ acpManifest: '/.well-known/acp/manifest.json',
33
+ ucp: '/.well-known/ucp',
34
+ botAuthDirectory: '/.well-known/http-message-signatures-directory',
35
+ oauthProtectedResource: '/.well-known/oauth-protected-resource',
36
+ oauthAuthorizationServer: '/.well-known/oauth-authorization-server',
37
+ };
38
+
39
+ async function fetchWellKnown(baseUrl, opts) {
40
+ const entries = await Promise.all(
41
+ Object.entries(WELL_KNOWN_PATHS).map(async ([key, path]) => {
42
+ const res = await fetchSibling(baseUrl, path, opts);
43
+ return [
44
+ key,
45
+ {
46
+ found: res.ok && res.html && res.html.trim().length > 0,
47
+ content: res.html,
48
+ headers: res.headers,
49
+ status: res.status,
50
+ },
51
+ ];
52
+ }),
53
+ );
54
+ return Object.fromEntries(entries);
55
+ }
56
+
57
+ export async function scan(url, opts = {}) {
58
+ const page = await fetchAsCrawler(url, opts);
59
+ if (!page.ok && !page.html) {
60
+ return {
61
+ url,
62
+ ok: false,
63
+ error: page.error || `HTTP ${page.status}`,
64
+ score: 0,
65
+ grade: 'F',
66
+ dimensions: [],
67
+ };
68
+ }
69
+
70
+ const $ = load(page.html);
71
+ const [robots, wellKnown] = await Promise.all([
72
+ fetchSibling(url, '/robots.txt', opts),
73
+ fetchWellKnown(url, opts),
74
+ ]);
75
+
76
+ const ctx = {
77
+ $,
78
+ html: page.html,
79
+ finalUrl: page.finalUrl,
80
+ headers: page.headers,
81
+ robotsTxt: robots.ok ? robots.html : null,
82
+ wellKnown,
83
+ };
84
+
85
+ const dimensions = [];
86
+ let weightedSum = 0;
87
+ let weightTotal = 0;
88
+
89
+ for (const mod of CHECKS) {
90
+ const res = mod.run(ctx);
91
+ const w = mod.meta.weight;
92
+ weightedSum += (res.score / res.max) * w;
93
+ weightTotal += w;
94
+ dimensions.push({
95
+ id: mod.meta.id,
96
+ title: mod.meta.title,
97
+ why: mod.meta.why,
98
+ weight: w,
99
+ score: res.score,
100
+ max: res.max,
101
+ findings: res.findings,
102
+ detail: res.detail || {},
103
+ });
104
+ }
105
+
106
+ const score = Math.round((weightedSum / weightTotal) * 100);
107
+ return {
108
+ url,
109
+ finalUrl: page.finalUrl,
110
+ ok: true,
111
+ status: page.status,
112
+ scannedAt: new Date().toISOString(),
113
+ score,
114
+ grade: grade(score),
115
+ dimensions,
116
+ generated: generateCustomsDeclaration(ctx),
117
+ };
118
+ }
119
+
120
+ export function grade(s) {
121
+ if (s >= 90) return 'A';
122
+ if (s >= 80) return 'B';
123
+ if (s >= 65) return 'C';
124
+ if (s >= 50) return 'D';
125
+ return 'F';
126
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * scorecard.js — renders a self-contained, shareable HTML scorecard.
3
+ * No external assets; deployable to GitHub Pages or saved as a file.
4
+ */
5
+
6
+ const C = {
7
+ bg: '#0A0E0D',
8
+ panel: '#121A17',
9
+ line: '#23302B',
10
+ txt: '#E6EDE9',
11
+ soft: '#A7B5AF',
12
+ mute: '#6C7C75',
13
+ acc: '#00D08A',
14
+ warn: '#E0A93C',
15
+ fail: '#E0623C',
16
+ };
17
+
18
+ function gradeColor(g) {
19
+ return g === 'A' || g === 'B' ? C.acc : g === 'C' ? C.warn : C.fail;
20
+ }
21
+ function lvlColor(l) {
22
+ return l === 'pass'
23
+ ? C.acc
24
+ : l === 'warn'
25
+ ? C.warn
26
+ : l === 'fail'
27
+ ? C.fail
28
+ : C.mute;
29
+ }
30
+ function esc(s) {
31
+ return String(s).replace(/[&<>"]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[m]);
32
+ }
33
+
34
+ export function renderScorecard(r) {
35
+ const gc = gradeColor(r.grade);
36
+ const dims = r.dimensions
37
+ .map((d) => {
38
+ const pct = Math.round((d.score / d.max) * 100);
39
+ const findings = d.findings
40
+ .map(
41
+ (f) =>
42
+ `<li style="border-left:2px solid ${lvlColor(f.level)};padding-left:10px;margin:6px 0;color:${C.soft}">
43
+ <span style="font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:${lvlColor(f.level)}">${f.level}</span><br>${esc(f.msg)}</li>`,
44
+ )
45
+ .join('');
46
+ return `<div style="background:${C.panel};border:1px solid ${C.line};border-radius:12px;padding:20px 22px;margin:12px 0">
47
+ <div style="display:flex;justify-content:space-between;align-items:baseline;gap:14px;flex-wrap:wrap">
48
+ <h3 style="margin:0;font-size:17px;color:${C.txt}">${esc(d.title)}</h3>
49
+ <span style="font-family:'JetBrains Mono',monospace;font-size:15px;color:${pct >= 70 ? C.acc : pct >= 40 ? C.warn : C.fail}">${d.score}/${d.max}</span>
50
+ </div>
51
+ <div style="height:6px;background:${C.line};border-radius:4px;margin:12px 0;overflow:hidden">
52
+ <div style="height:100%;width:${pct}%;background:${pct >= 70 ? C.acc : pct >= 40 ? C.warn : C.fail}"></div>
53
+ </div>
54
+ <p style="margin:6px 0 10px;font-size:13px;color:${C.mute};font-style:italic">${esc(d.why)}</p>
55
+ <ul style="list-style:none;margin:0;padding:0;font-size:13.5px;line-height:1.5">${findings}</ul>
56
+ </div>`;
57
+ })
58
+ .join('');
59
+
60
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
61
+ <meta name="viewport" content="width=device-width,initial-scale=1">
62
+ <title>Agent Readiness Scorecard — ${esc(r.url)}</title>
63
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
64
+ <style>body{margin:0;background:${C.bg};color:${C.txt};font-family:'Archivo',sans-serif;line-height:1.6;
65
+ background-image:radial-gradient(800px 400px at 80% -5%,rgba(0,208,138,.06),transparent 60%)}
66
+ .wrap{max-width:820px;margin:0 auto;padding:48px 28px}a{color:${C.acc}}</style></head><body>
67
+ <div class="wrap">
68
+ <div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:${C.acc};margin-bottom:24px">Agent Readiness Scorecard · @kirkelabs/agent-readiness-scan</div>
69
+ <div style="display:flex;align-items:center;gap:28px;flex-wrap:wrap;border-bottom:1px solid ${C.line};padding-bottom:28px;margin-bottom:24px">
70
+ <div style="width:120px;height:120px;border-radius:20px;border:2px solid ${gc};display:flex;flex-direction:column;align-items:center;justify-content:center">
71
+ <div style="font-family:'Archivo';font-weight:900;font-size:46px;color:${gc};line-height:1">${r.grade}</div>
72
+ <div style="font-family:'JetBrains Mono',monospace;font-size:13px;color:${C.soft}">${r.score}/100</div>
73
+ </div>
74
+ <div style="flex:1;min-width:240px">
75
+ <div style="font-size:13px;color:${C.mute};font-family:'JetBrains Mono',monospace">SCANNED URL</div>
76
+ <div style="font-size:19px;font-weight:600;word-break:break-all;margin:4px 0 12px">${esc(r.url)}</div>
77
+ <div style="font-size:12px;color:${C.mute};font-family:'JetBrains Mono',monospace">${esc(r.scannedAt)}</div>
78
+ </div>
79
+ </div>
80
+ ${dims}
81
+ <div style="margin-top:34px;border-top:1px solid ${C.line};padding-top:22px;font-size:12px;color:${C.mute};font-family:'JetBrains Mono',monospace;line-height:1.8">
82
+ Open-source · MIT · Built by Soleman El Gelawi (CTO, Kirke Labs), with Steve Kirton — as a gift to the Algorand ecosystem.<br>
83
+ github.com/KirkeLabs/agent-readiness-scan · www.kirkelabs.com<br>
84
+ Heuristic indicators, not a guarantee of agent action. See the methodology doc in the repo.
85
+ </div>
86
+ </div></body></html>`;
87
+ }