1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'A2AAgentCardAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_BYTES = 65536;
9const MAX_BYTES = 524288;
10const WELL_KNOWN_PATH = '/.well-known/agent-card.json';
11
12
13const KNOWN_PROTOCOL_BINDINGS = new Set(['JSONRPC', 'GRPC', 'HTTP+JSON']);
14
15const KNOWN_PROTOCOL_VERSIONS = new Set(['0.2', '0.3', '1.0']);
16
17
18
19
20
21function isPrivateIPv4(ip) {
22 const parts = ip.split('.').map(Number);
23 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
24 const [a, b] = parts;
25 return a === 10
26 || (a === 172 && b >= 16 && b <= 31)
27 || (a === 192 && b === 168)
28 || a === 127
29 || a === 0
30 || (a === 169 && b === 254);
31}
32
33function isPrivateIPv6(ip) {
34 const normalized = ip.toLowerCase();
35 return normalized === '::1'
36 || normalized.startsWith('fc')
37 || normalized.startsWith('fd')
38 || normalized.startsWith('fe80:');
39}
40
41export async function normalizeAndValidateUrl(rawUrl) {
42 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
43 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
44 throw new Error('Only HTTP and HTTPS URLs are supported');
45 }
46
47 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
48 const url = new URL(withScheme);
49 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
50
51 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
52
53 const literalType = net.isIP(url.hostname);
54 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
55 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
56
57 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
58 for (const record of records) {
59 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
60 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
61 }
62 return url;
63}
64
65
66
67
68
69
70async function fetchAgentCard(originUrl, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
71 const wellKnownUrl = new URL(WELL_KNOWN_PATH, originUrl.href);
72 await normalizeAndValidateUrl(wellKnownUrl.href);
73 const controller = new AbortController();
74 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
75 try {
76 const response = await fetch(wellKnownUrl, {
77 redirect: 'manual',
78 signal: controller.signal,
79 headers: {
80 'user-agent': USER_AGENT,
81 accept: 'application/a2a+json, application/json, */*;q=0.1',
82 },
83 });
84
85 if ([301, 302, 303, 307, 308].includes(response.status)) {
86 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
87 const location = response.headers.get('location');
88 if (!location) throw new Error('Redirect without Location header');
89 const nextUrl = new URL(location, wellKnownUrl.href);
90 await normalizeAndValidateUrl(nextUrl.href);
91 return fetchAgentCard(nextUrl, timeoutSeconds, maxBytes, redirectsRemaining - 1);
92 }
93
94 const reader = response.body.getReader();
95 const chunks = [];
96 let total = 0;
97 let truncated = false;
98 while (true) {
99 const { done, value } = await reader.read();
100 if (done) break;
101 if (total + value.length > maxBytes) {
102 chunks.push(value.slice(0, Math.max(0, maxBytes - total)));
103 truncated = true;
104 break;
105 }
106 chunks.push(value);
107 total += value.length;
108 }
109 try { await reader.cancel(); } catch { }
110
111 const body = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks));
112 const headerMap = {};
113 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
114
115 return {
116 ok: response.ok,
117 status: response.status,
118 finalUrl: response.url || wellKnownUrl.href,
119 https: (response.url || wellKnownUrl.href).startsWith('https://'),
120 body,
121 headers: headerMap,
122 truncated,
123 error: null,
124 };
125 } catch (error) {
126 return {
127 ok: false,
128 status: null,
129 finalUrl: wellKnownUrl.href,
130 https: wellKnownUrl.protocol === 'https:',
131 body: '',
132 headers: {},
133 truncated: false,
134 error: error.message,
135 };
136 } finally {
137 clearTimeout(timeout);
138 }
139}
140
141
142
143
144
145export function parseCardJson(body) {
146 if (!body || !body.trim()) return { card: null, error: 'empty response body' };
147 try {
148 const parsed = JSON.parse(body);
149 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
150 return { card: null, error: 'AgentCard JSON must be an object' };
151 }
152 return { card: parsed, error: null };
153 } catch (err) {
154 return { card: null, error: `invalid JSON: ${err.message}` };
155 }
156}
157
158function isPlainObject(v) {
159 return v !== null && typeof v === 'object' && !Array.isArray(v);
160}
161
162function isNonEmptyString(v) {
163 return typeof v === 'string' && v.trim() !== '';
164}
165
166function isNonEmptyArray(v) {
167 return Array.isArray(v) && v.length > 0;
168}
169
170
171
172
173
174
175
176
177export function analyzeContentType(contentTypeHeader) {
178 const ct = (contentTypeHeader || '').toLowerCase();
179 const isA2aJson = ct.includes('application/a2a+json');
180 const isJson = ct.includes('application/json');
181 const isHtml = ct.includes('text/html') || ct.includes('application/xhtml');
182
183 const issues = [];
184 if (isHtml) {
185 issues.push('Content-Type is HTML — the well-known path likely returns an error page, not an Agent Card');
186 } else if (!isA2aJson && !isJson && ct !== '') {
187 issues.push(`Content-Type ${ct} is not application/a2a+json or application/json`);
188 }
189 return { contentType: contentTypeHeader || '', isA2aJson, isJson, isHtml, issues };
190}
191
192
193
194
195
196
197
198export function analyzeInterface(iface, index) {
199 const issues = [];
200 const block = {
201 index,
202 url: null,
203 protocolBinding: null,
204 protocolVersion: null,
205 tenant: null,
206 issues,
207 };
208
209 if (!isPlainObject(iface)) {
210 issues.push(`interface[${index}] is not an object`);
211 return block;
212 }
213
214
215 const url = iface.url;
216 if (!isNonEmptyString(url)) {
217 issues.push(`interface[${index}].url is missing or empty (required, must be HTTPS in production for HTTP-based transports)`);
218 } else {
219 block.url = url;
220 try {
221 const parsed = new URL(url);
222 if (parsed.protocol !== 'https:') {
223 issues.push(`interface[${index}].url is not HTTPS (production agents must use HTTPS for HTTP-based transports)`);
224 }
225 if (parsed.username || parsed.password) {
226 issues.push(`interface[${index}].url must not include credentials`);
227 }
228 } catch {
229 issues.push(`interface[${index}].url is not a valid URL: ${url}`);
230 }
231 }
232
233
234 const binding = iface.protocolBinding;
235 if (!isNonEmptyString(binding)) {
236 issues.push(`interface[${index}].protocolBinding is missing or empty (required)`);
237 } else {
238 block.protocolBinding = binding;
239
240 if (!KNOWN_PROTOCOL_BINDINGS.has(binding.toUpperCase())) {
241 issues.push(`interface[${index}].protocolBinding "${binding}" is not a core A2A binding (JSONRPC, GRPC, HTTP+JSON)`);
242 }
243 }
244
245
246 const version = iface.protocolVersion;
247 if (!isNonEmptyString(version)) {
248 issues.push(`interface[${index}].protocolVersion is missing or empty (required, e.g. "1.0")`);
249 } else {
250 block.protocolVersion = version;
251 if (!KNOWN_PROTOCOL_VERSIONS.has(version)) {
252 issues.push(`interface[${index}].protocolVersion "${version}" is not a known A2A version (0.2, 0.3, 1.0)`);
253 }
254 }
255
256
257 if (iface.tenant !== undefined && iface.tenant !== null) {
258 if (typeof iface.tenant !== 'string') {
259 issues.push(`interface[${index}].tenant must be a string when present`);
260 } else {
261 block.tenant = iface.tenant;
262 }
263 }
264
265 return block;
266}
267
268
269
270
271
272
273
274export function analyzeSkill(skill, index) {
275 const issues = [];
276 const block = {
277 index,
278 id: null,
279 name: null,
280 description: null,
281 tagsCount: 0,
282 examplesCount: 0,
283 inputModes: [],
284 outputModes: [],
285 issues,
286 };
287
288 if (!isPlainObject(skill)) {
289 issues.push(`skill[${index}] is not an object`);
290 return block;
291 }
292
293
294 const id = skill.id;
295 if (!isNonEmptyString(id)) {
296 issues.push(`skill[${index}].id is missing or empty (required)`);
297 } else {
298 block.id = id;
299 }
300
301
302 const name = skill.name;
303 if (!isNonEmptyString(name)) {
304 issues.push(`skill[${index}].name is missing or empty (required)`);
305 } else {
306 block.name = name;
307 }
308
309
310 const desc = skill.description;
311 if (!isNonEmptyString(desc)) {
312 issues.push(`skill[${index}].description is missing or empty (required)`);
313 } else {
314 block.description = desc;
315 }
316
317
318 const tags = skill.tags;
319 if (!isNonEmptyArray(tags)) {
320 issues.push(`skill[${index}].tags is missing or empty (required, must be a non-empty array of strings)`);
321 } else {
322 const allStrings = tags.every((t) => typeof t === 'string');
323 if (!allStrings) {
324 issues.push(`skill[${index}].tags must all be strings`);
325 } else {
326 block.tagsCount = tags.length;
327 }
328 }
329
330
331 if (skill.examples !== undefined && skill.examples !== null) {
332 if (!Array.isArray(skill.examples) || !skill.examples.every((e) => typeof e === 'string')) {
333 issues.push(`skill[${index}].examples must be an array of strings`);
334 } else {
335 block.examplesCount = skill.examples.length;
336 }
337 }
338
339
340 if (skill.inputModes !== undefined && skill.inputModes !== null) {
341 if (!Array.isArray(skill.inputModes) || !skill.inputModes.every((m) => typeof m === 'string')) {
342 issues.push(`skill[${index}].inputModes must be an array of strings`);
343 } else {
344 block.inputModes = skill.inputModes;
345 }
346 }
347
348
349 if (skill.outputModes !== undefined && skill.outputModes !== null) {
350 if (!Array.isArray(skill.outputModes) || !skill.outputModes.every((m) => typeof m === 'string')) {
351 issues.push(`skill[${index}].outputModes must be an array of strings`);
352 } else {
353 block.outputModes = skill.outputModes;
354 }
355 }
356
357 return block;
358}
359
360
361
362
363
364
365
366export function analyzeCapabilities(caps) {
367 const issues = [];
368 const block = {
369 streaming: null,
370 pushNotifications: null,
371 extendedAgentCard: null,
372 extensionCount: 0,
373 issues,
374 };
375
376 if (!isPlainObject(caps)) {
377 issues.push('capabilities is not an object');
378 return block;
379 }
380
381 for (const boolField of ['streaming', 'pushNotifications', 'extendedAgentCard']) {
382 if (caps[boolField] !== undefined && caps[boolField] !== null) {
383 if (typeof caps[boolField] !== 'boolean') {
384 issues.push(`capabilities.${boolField} must be a boolean`);
385 } else {
386 block[boolField] = caps[boolField];
387 }
388 }
389 }
390
391
392 if (caps.extensions !== undefined && caps.extensions !== null) {
393 if (!Array.isArray(caps.extensions)) {
394 issues.push('capabilities.extensions must be an array');
395 } else {
396 block.extensionCount = caps.extensions.length;
397 caps.extensions.forEach((ext, i) => {
398 if (!isPlainObject(ext)) {
399 issues.push(`capabilities.extensions[${i}] is not an object`);
400 return;
401 }
402 if (!isNonEmptyString(ext.uri)) {
403 issues.push(`capabilities.extensions[${i}].uri is missing or empty`);
404 }
405 if (ext.required !== undefined && ext.required !== null && typeof ext.required !== 'boolean') {
406 issues.push(`capabilities.extensions[${i}].required must be a boolean`);
407 }
408 });
409 }
410 }
411
412 return block;
413}
414
415
416
417
418
419
420export function analyzeProvider(provider) {
421 const issues = [];
422 const block = { url: null, organization: null, issues };
423
424 if (provider === undefined || provider === null) return block;
425 if (!isPlainObject(provider)) {
426 issues.push('provider is not an object');
427 return block;
428 }
429
430 const url = provider.url;
431 if (!isNonEmptyString(url)) {
432 issues.push('provider.url is missing or empty');
433 } else {
434 block.url = url;
435 try {
436 const parsed = new URL(url);
437 if (parsed.protocol !== 'https:') {
438 issues.push('provider.url should be HTTPS');
439 }
440 } catch {
441 issues.push(`provider.url is not a valid URL`);
442 }
443 }
444
445 const org = provider.organization;
446 if (!isNonEmptyString(org)) {
447 issues.push('provider.organization is missing or empty');
448 } else {
449 block.organization = org;
450 }
451
452 return block;
453}
454
455
456
457
458
459
460
461export function analyzeSignature(sig, index) {
462 const issues = [];
463 const block = { index, protected: null, signature: null, issues };
464
465 if (!isPlainObject(sig)) {
466 issues.push(`signatures[${index}] is not an object`);
467 return block;
468 }
469
470 if (!isNonEmptyString(sig.protected)) {
471 issues.push(`signatures[${index}].protected is missing or empty (required, base64url JWS header)`);
472 } else {
473 block.protected = sig.protected;
474 }
475
476 if (!isNonEmptyString(sig.signature)) {
477 issues.push(`signatures[${index}].signature is missing or empty (required, base64url JWS signature)`);
478 } else {
479 block.signature = sig.signature;
480 }
481
482 return block;
483}
484
485
486
487
488
489
490
491
492
493export function analyzeSecuritySchemes(schemes) {
494 const issues = [];
495 let count = 0;
496
497 if (schemes === undefined || schemes === null) return { count, issues };
498
499 if (!isPlainObject(schemes)) {
500 issues.push('securitySchemes is not an object (should be a map of name -> scheme)');
501 return { count: 0, issues };
502 }
503
504 const keys = Object.keys(schemes);
505 count = keys.length;
506 for (const key of keys) {
507 if (!isPlainObject(schemes[key])) {
508 issues.push(`securitySchemes["${key}"] is not an object`);
509 }
510 }
511
512 return { count, issues };
513}
514
515
516
517
518
519
520export function analyzeAgentCard(card) {
521 const issues = [];
522 const result = {
523 name: null,
524 description: null,
525 version: null,
526 documentationUrl: null,
527 iconUrl: null,
528 interfaces: [],
529 interfaceCount: 0,
530 capabilities: null,
531 provider: null,
532 skills: [],
533 skillCount: 0,
534 securitySchemes: { count: 0, issues: [] },
535 securitySchemeCount: 0,
536 signatures: [],
537 signatureCount: 0,
538 defaultInputModes: [],
539 defaultOutputModes: [],
540 issues,
541 };
542
543 if (!isPlainObject(card)) {
544 issues.push('AgentCard is not an object');
545 return result;
546 }
547
548
549 if (!isNonEmptyString(card.name)) {
550 issues.push('name is missing or empty (required)');
551 } else {
552 result.name = card.name;
553 }
554
555
556 if (!isNonEmptyString(card.description)) {
557 issues.push('description is missing or empty (required)');
558 } else {
559 result.description = card.description;
560 }
561
562
563 if (!isNonEmptyString(card.version)) {
564 issues.push('version is missing or empty (required)');
565 } else {
566 result.version = card.version;
567 }
568
569
570 const interfaces = card.supportedInterfaces;
571 if (!isNonEmptyArray(interfaces)) {
572 issues.push('supportedInterfaces is missing or empty (required, must be a non-empty array)');
573 } else {
574 result.interfaceCount = interfaces.length;
575 for (let i = 0; i < interfaces.length; i++) {
576 const block = analyzeInterface(interfaces[i], i);
577 result.interfaces.push(block);
578 for (const iss of block.issues) issues.push(iss);
579 }
580 }
581
582
583 result.capabilities = analyzeCapabilities(card.capabilities);
584 for (const iss of result.capabilities.issues) issues.push(iss);
585
586
587 const inputModes = card.defaultInputModes;
588 if (!isNonEmptyArray(inputModes)) {
589 issues.push('defaultInputModes is missing or empty (required, must be a non-empty array of media types)');
590 } else {
591 if (!inputModes.every((m) => typeof m === 'string')) {
592 issues.push('defaultInputModes must all be strings');
593 } else {
594 result.defaultInputModes = inputModes;
595 }
596 }
597
598
599 const outputModes = card.defaultOutputModes;
600 if (!isNonEmptyArray(outputModes)) {
601 issues.push('defaultOutputModes is missing or empty (required, must be a non-empty array of media types)');
602 } else {
603 if (!outputModes.every((m) => typeof m === 'string')) {
604 issues.push('defaultOutputModes must all be strings');
605 } else {
606 result.defaultOutputModes = outputModes;
607 }
608 }
609
610
611 const skills = card.skills;
612 if (!isNonEmptyArray(skills)) {
613 issues.push('skills is missing or empty (required, must be a non-empty array)');
614 } else {
615 result.skillCount = skills.length;
616 for (let i = 0; i < skills.length; i++) {
617 const block = analyzeSkill(skills[i], i);
618 result.skills.push(block);
619 for (const iss of block.issues) issues.push(iss);
620 }
621 }
622
623
624 result.provider = analyzeProvider(card.provider);
625 for (const iss of result.provider.issues) issues.push(iss);
626
627
628 if (card.documentationUrl !== undefined && card.documentationUrl !== null) {
629 if (!isNonEmptyString(card.documentationUrl)) {
630 issues.push('documentationUrl must be a non-empty string when present');
631 } else {
632 result.documentationUrl = card.documentationUrl;
633 }
634 }
635
636
637 if (card.iconUrl !== undefined && card.iconUrl !== null) {
638 if (!isNonEmptyString(card.iconUrl)) {
639 issues.push('iconUrl must be a non-empty string when present');
640 } else {
641 result.iconUrl = card.iconUrl;
642 }
643 }
644
645
646 result.securitySchemes = analyzeSecuritySchemes(card.securitySchemes);
647 result.securitySchemeCount = result.securitySchemes.count;
648 for (const iss of result.securitySchemes.issues) issues.push(iss);
649
650
651 if (card.signatures !== undefined && card.signatures !== null) {
652 if (!Array.isArray(card.signatures)) {
653 issues.push('signatures must be an array');
654 } else {
655 result.signatureCount = card.signatures.length;
656 for (let i = 0; i < card.signatures.length; i++) {
657 const block = analyzeSignature(card.signatures[i], i);
658 result.signatures.push(block);
659 for (const iss of block.issues) issues.push(iss);
660 }
661 }
662 }
663
664 return result;
665}
666
667
668
669
670
671
672
673
674export function scoreAgentCard(summary, https) {
675 const { issues } = summary;
676 if (!summary.name && issues.some((i) => i.includes('not an object'))) return 0;
677
678 let earned = 0;
679 const possible = 100;
680
681
682 if (summary.name) earned += 5;
683 if (summary.description) earned += 5;
684 if (summary.version) earned += 5;
685 if (summary.interfaceCount > 0) earned += 8;
686 if (summary.capabilities && summary.capabilities.issues.length === 0) earned += 5;
687 if (summary.defaultInputModes.length > 0) earned += 5;
688 if (summary.defaultOutputModes.length > 0) earned += 5;
689 if (summary.skillCount > 0) earned += 7;
690
691
692 if (https) earned += 10;
693
694
695
696 if (summary.capabilities && (summary.capabilities.streaming || summary.capabilities.pushNotifications)) earned += 8;
697
698 if (summary.skills.some((s) => s.examplesCount > 0)) earned += 6;
699
700 if (summary.securitySchemeCount > 0) earned += 6;
701
702 if (summary.provider && summary.provider.organization) earned += 5;
703
704 if (summary.documentationUrl) earned += 5;
705
706
707 const issuePenalty = Math.min(issues.length * 8, 40);
708 earned = Math.max(0, earned - issuePenalty);
709
710 return Math.round((earned / possible) * 100);
711}
712
713export function gradeFromScore(score) {
714 if (score >= 95) return 'A+';
715 if (score >= 85) return 'A';
716 if (score >= 75) return 'B';
717 if (score >= 65) return 'C';
718 if (score >= 50) return 'D';
719 if (score >= 30) return 'E';
720 return 'F';
721}
722
723
724
725
726
727export function buildRecommendations(summary, score, https, httpStatus, contentTypeInfo) {
728 const recs = new Set();
729 const { issues } = summary;
730
731
732 if (issues.some((i) => i.includes('invalid JSON') || i.includes('must be an object'))) {
733 recs.add('Fix the Agent Card JSON — the response body must be a valid JSON object, not an HTML error page or malformed text.');
734 }
735
736
737 if (!https) {
738 recs.add('Serve the /.well-known/agent-card.json endpoint over HTTPS. Production A2A agents must use HTTPS for HTTP-based transports.');
739 }
740
741
742 if (contentTypeInfo && contentTypeInfo.isHtml) {
743 recs.add('The well-known endpoint returned HTML, not JSON. Ensure your server routes /.well-known/agent-card.json to the Agent Card, not a fallback error page.');
744 } else if (contentTypeInfo && !contentTypeInfo.isA2aJson && !contentTypeInfo.isJson && contentTypeInfo.contentType !== '' && !contentTypeInfo.isHtml) {
745 recs.add(`Set the Content-Type to application/a2a+json (preferred) or application/json for the well-known endpoint.`);
746 }
747
748
749 if (issues.some((i) => i.includes('name is missing'))) recs.add('Add a human-readable `name` field to the Agent Card.');
750 if (issues.some((i) => i.includes('description is missing'))) recs.add('Add a `description` field explaining what the agent does.');
751 if (issues.some((i) => i.includes('version is missing'))) recs.add('Add a semantic `version` string (e.g. "1.0.0") to the Agent Card.');
752 if (issues.some((i) => i.includes('supportedInterfaces'))) recs.add('Add at least one entry to `supportedInterfaces` with url, protocolBinding, and protocolVersion.');
753 if (issues.some((i) => i.includes('defaultInputModes'))) recs.add('Add `defaultInputModes` as a non-empty array of supported input media types (e.g. ["text/plain"]).');
754 if (issues.some((i) => i.includes('defaultOutputModes'))) recs.add('Add `defaultOutputModes` as a non-empty array of supported output media types (e.g. ["text/plain"]).');
755 if (issues.some((i) => i.includes('skills is missing'))) recs.add('Add at least one `skill` with id, name, description, and tags to declare what the agent can do.');
756
757
758 if (issues.some((i) => i.includes('not HTTPS'))) {
759 recs.add('Use HTTPS URLs for all HTTP-based interface endpoints in `supportedInterfaces`.');
760 }
761
762
763 if (issues.some((i) => i.includes('protocolBinding') && i.includes('core A2A'))) {
764 recs.add('Use a supported `protocolBinding` (JSONRPC, GRPC, or HTTP+JSON) for each interface.');
765 }
766
767
768 if (issues.some((i) => i.includes('protocolVersion') && i.includes('known A2A'))) {
769 recs.add('Set `protocolVersion` to a known A2A version (0.2, 0.3, or 1.0) for each interface.');
770 }
771
772
773 if (summary.capabilities && !summary.capabilities.streaming && !summary.capabilities.pushNotifications) {
774 recs.add('If your agent supports streaming or push notifications, declare them in `capabilities` to enable richer client interactions.');
775 }
776
777
778 if (summary.securitySchemeCount === 0) {
779 recs.add('Declare `securitySchemes` if the agent requires authentication (OAuth2, API key, HTTP bearer, etc.).');
780 }
781
782
783 if (!summary.provider || !summary.provider.organization) {
784 recs.add('Declare a `provider` with the organization name and a documentation URL for discoverability and trust.');
785 }
786
787
788 if (summary.signatureCount === 0) {
789 recs.add('Consider adding JWS `signatures` to allow clients to verify Agent Card authenticity and detect tampering.');
790 }
791
792
793 if (httpStatus !== null && httpStatus === 404) {
794 recs.add('The /.well-known/agent-card.json path returned 404. Create the endpoint so A2A clients can discover your agent via the standard well-known URI (RFC 8615).');
795 } else if (httpStatus !== null && httpStatus >= 500) {
796 recs.add('The well-known endpoint returned a server error. Check your server configuration and ensure /.well-known/agent-card.json is routed correctly.');
797 }
798
799
800 if (issues.length === 0 && summary.name) {
801 recs.add('The A2A Agent Card is well-structured and conformant. Schedule this audit periodically to catch deployment regressions.');
802 }
803
804 return [...recs];
805}
806
807
808
809
810
811export async function auditAgentCard(input) {
812 const baseUrl = await normalizeAndValidateUrl(input.startUrl);
813 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
814 const maxBytes = Math.min(Math.max(Number(input.maxBytes || DEFAULT_MAX_BYTES), 1024), MAX_BYTES);
815
816 const wellKnownUrl = new URL(WELL_KNOWN_PATH, baseUrl.href).href;
817 const fetchResult = await fetchAgentCard(baseUrl, timeoutSeconds, maxBytes);
818
819 if (fetchResult.error) {
820 return {
821 inputUrl: input.startUrl,
822 wellKnownUrl,
823 finalUrl: fetchResult.finalUrl,
824 https: fetchResult.https,
825 httpStatus: fetchResult.status,
826 contentType: '',
827 cardFound: false,
828 jsonValid: false,
829 parseError: null,
830 name: null,
831 description: null,
832 version: null,
833 interfaces: [],
834 interfaceCount: 0,
835 capabilities: null,
836 provider: null,
837 skills: [],
838 skillCount: 0,
839 securitySchemes: { count: 0, issues: [] },
840 securitySchemeCount: 0,
841 signatures: [],
842 signatureCount: 0,
843 iconUrl: null,
844 documentationUrl: null,
845 defaultInputModes: [],
846 defaultOutputModes: [],
847 issues: [],
848 score: 0,
849 grade: 'F',
850 checkedAt: new Date().toISOString(),
851 recommendations: ['The request to /.well-known/agent-card.json failed. Verify the domain is reachable and the well-known path exists.'],
852 error: fetchResult.error,
853 };
854 }
855
856 const contentTypeRaw = fetchResult.headers['content-type'] || '';
857 const contentTypeInfo = analyzeContentType(contentTypeRaw);
858
859
860 if (!fetchResult.ok) {
861 const score = 0;
862 const grade = 'F';
863 const recommendations = buildRecommendations(
864 { name: null, issues: [], skills: [], capabilities: null, provider: null, securitySchemeCount: 0, signatureCount: 0, documentationUrl: null },
865 score,
866 fetchResult.https,
867 fetchResult.status,
868 contentTypeInfo,
869 );
870 return {
871 inputUrl: input.startUrl,
872 wellKnownUrl,
873 finalUrl: fetchResult.finalUrl,
874 https: fetchResult.https,
875 httpStatus: fetchResult.status,
876 contentType: contentTypeRaw,
877 cardFound: false,
878 jsonValid: false,
879 parseError: null,
880 name: null,
881 description: null,
882 version: null,
883 interfaces: [],
884 interfaceCount: 0,
885 capabilities: null,
886 provider: null,
887 skills: [],
888 skillCount: 0,
889 securitySchemes: { count: 0, issues: [] },
890 securitySchemeCount: 0,
891 signatures: [],
892 signatureCount: 0,
893 iconUrl: null,
894 documentationUrl: null,
895 defaultInputModes: [],
896 defaultOutputModes: [],
897 issues: [`/.well-known/agent-card.json returned HTTP ${fetchResult.status}`],
898 score,
899 grade,
900 checkedAt: new Date().toISOString(),
901 recommendations,
902 error: null,
903 };
904 }
905
906
907 const { card, error: parseError } = parseCardJson(fetchResult.body);
908 const cardFound = card !== null;
909
910 if (!cardFound) {
911 const issues = [parseError, ...contentTypeInfo.issues];
912 const score = 0;
913 const grade = 'F';
914 const recommendations = buildRecommendations(
915 { name: null, issues, skills: [], capabilities: null, provider: null, securitySchemeCount: 0, signatureCount: 0, documentationUrl: null },
916 score,
917 fetchResult.https,
918 fetchResult.status,
919 contentTypeInfo,
920 );
921 return {
922 inputUrl: input.startUrl,
923 wellKnownUrl,
924 finalUrl: fetchResult.finalUrl,
925 https: fetchResult.https,
926 httpStatus: fetchResult.status,
927 contentType: contentTypeRaw,
928 cardFound: false,
929 jsonValid: false,
930 parseError,
931 name: null,
932 description: null,
933 version: null,
934 interfaces: [],
935 interfaceCount: 0,
936 capabilities: null,
937 provider: null,
938 skills: [],
939 skillCount: 0,
940 securitySchemes: { count: 0, issues: [] },
941 securitySchemeCount: 0,
942 signatures: [],
943 signatureCount: 0,
944 iconUrl: null,
945 documentationUrl: null,
946 defaultInputModes: [],
947 defaultOutputModes: [],
948 issues,
949 score,
950 grade,
951 checkedAt: new Date().toISOString(),
952 recommendations,
953 error: null,
954 };
955 }
956
957
958 const summary = analyzeAgentCard(card);
959
960 for (const iss of contentTypeInfo.issues) {
961 if (!summary.issues.includes(iss)) summary.issues.push(iss);
962 }
963
964 const score = scoreAgentCard(summary, fetchResult.https);
965 const grade = gradeFromScore(score);
966 const recommendations = buildRecommendations(summary, score, fetchResult.https, fetchResult.status, contentTypeInfo);
967
968 return {
969 inputUrl: input.startUrl,
970 wellKnownUrl,
971 finalUrl: fetchResult.finalUrl,
972 https: fetchResult.https,
973 httpStatus: fetchResult.status,
974 contentType: contentTypeRaw,
975 cardFound: true,
976 jsonValid: true,
977 parseError: null,
978 name: summary.name,
979 description: summary.description,
980 version: summary.version,
981 interfaces: summary.interfaces,
982 interfaceCount: summary.interfaceCount,
983 capabilities: summary.capabilities,
984 provider: summary.provider,
985 skills: summary.skills,
986 skillCount: summary.skillCount,
987 securitySchemes: summary.securitySchemes,
988 securitySchemeCount: summary.securitySchemeCount,
989 signatures: summary.signatures,
990 signatureCount: summary.signatureCount,
991 iconUrl: summary.iconUrl,
992 documentationUrl: summary.documentationUrl,
993 defaultInputModes: summary.defaultInputModes,
994 defaultOutputModes: summary.defaultOutputModes,
995 issues: summary.issues,
996 score,
997 grade,
998 checkedAt: new Date().toISOString(),
999 recommendations,
1000 error: null,
1001 };
1002}
1003
1004
1005
1006
1007
1008const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
1009
1010if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
1011 await Actor.init();
1012 try {
1013 const input = await Actor.getInput();
1014 const result = await auditAgentCard(input || {});
1015 await Actor.pushData(result);
1016 await Actor.setValue('OUTPUT', result);
1017 Actor.log.info('A2A Agent Card audit complete', { finalUrl: result.finalUrl, cardFound: result.cardFound, score: result.score, grade: result.grade });
1018 } finally {
1019 await Actor.exit();
1020 }
1021}