From 53b65e45916d69ede4a76ce0015127de5483fac1 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 08:41:34 +0000 Subject: [PATCH 01/36] fix(core): decline index coverage on a partial sitemap read The sitemap walk caps entries at 500 and child sitemaps at 10. A page missing from the part it read may be listed in the part it skipped, so discovery-index-coverage now returns notApplicable instead of failing. Found on a large retail site whose 2455-URL product sitemap filled the cap before the one-URL sitemap that lists the homepage. --- .changeset/partial-sitemap-coverage.md | 5 ++ .../discovery-index-coverage.md | 26 +++++++--- .../discovery-index-coverage.test.ts | 49 +++++++++++++++++++ .../discovery-index-coverage.ts | 30 ++++++++++-- 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 .changeset/partial-sitemap-coverage.md diff --git a/.changeset/partial-sitemap-coverage.md b/.changeset/partial-sitemap-coverage.md new file mode 100644 index 00000000..200011a3 --- /dev/null +++ b/.changeset/partial-sitemap-coverage.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Stop failing `machine-discovery/discovery-index-coverage` when the sitemap is larger than the scan reads. A page missing from a partial sitemap read now returns not applicable instead of "Not indexed". diff --git a/docs/evidence/audits/machine-discovery/discovery-index-coverage.md b/docs/evidence/audits/machine-discovery/discovery-index-coverage.md index f0700cd3..c0c55973 100644 --- a/docs/evidence/audits/machine-discovery/discovery-index-coverage.md +++ b/docs/evidence/audits/machine-discovery/discovery-index-coverage.md @@ -32,13 +32,14 @@ sources: Every scanned page must appear in at least one _discovery index_: the sitemap (including the sub-sitemaps a `` points at) or the llms.txt link list. A page in neither is reachable only through the link graph, which the non-JS-executing AI crawlers may not traverse. -| State | Result | -| :------------------------------------------- | :------------------------ | -| every page found in an index | `pass` | -| >50% of pages found in no index | `fail`, priority `medium` | -| ≤50% of pages found in no index | `warn`, priority `low` | -| no sitemap URLs and no llms.txt links at all | `warn`, priority `medium` | -| no pages scanned | `na` | +| State | Result | +| :----------------------------------------------------------- | :------------------------ | +| every page found in an index | `pass` | +| >50% of pages found in no index | `fail`, priority `medium` | +| ≤50% of pages found in no index | `warn`, priority `low` | +| no sitemap URLs and no llms.txt links at all | `warn`, priority `medium` | +| a page is missing, but the sitemap walk stopped at its limit | `na` | +| no pages scanned | `na` | Both sides of the comparison go through one key — host without `www.`, lower-cased decoded path, no trailing slash, no scheme, query or fragment — and a page also matches on its declared ``. @@ -115,3 +116,14 @@ Both source reviews' required fixes shipped with the fold: The shared sitemap gatherer uses the scanned homepage directory as the site root when it is a detected or declared subpath homepage. It reads declarations from the origin's robots.txt, then tries the conventional sitemap names under that directory before the origin fallbacks. Shared sitemap files contribute only absolute URLs on the same origin and under that directory. Filtering precedes the entry cap and sampling. Relative or malformed loc values associated with this site remain available to content audits. A sibling-only sitemap does not prove that this site has a sitemap. The child-fetch cap remains in force even when all children cover siblings. Content-page scans do not infer a mount. Origin files, origin probes, feed discovery, explicit page overrides, and redirect handling keep their existing scope. This change limits sitemap-derived evidence; it does not make every scan request subpath-only. + +### Partial sitemap reads decline the verdict (2026-10-06) + +The shared sitemap walk keeps at most 500 entries and reads at most 10 child sitemaps. When it stops early, a page missing from the entries it kept may still be listed in the part it skipped. The audit now returns `na` for that case instead of `fail` or `warn`. A page found in the partial read still passes, and a page missing from a complete read still fails. + +The trigger was a large retail site. Its index lists a 2455-URL product sitemap before a one-URL sitemap that holds the homepage. The product file filled the cap, and the audit failed the homepage as unindexed. + +## Deferred + +- The fetcher cuts a sitemap body at 5 MB and does not flag the cut. A `` lost to that cut is still read as absent. The walk needs a body-truncation flag before this audit can decline that case too. +- After the entry cap is reached, the walk still downloads the remaining child sitemaps and discards their entries. diff --git a/packages/core/src/audits/machine-discovery/discovery-index-coverage.test.ts b/packages/core/src/audits/machine-discovery/discovery-index-coverage.test.ts index 86b0e100..f4abeb3d 100644 --- a/packages/core/src/audits/machine-discovery/discovery-index-coverage.test.ts +++ b/packages/core/src/audits/machine-discovery/discovery-index-coverage.test.ts @@ -271,6 +271,55 @@ describe("DiscoveryIndexCoverageAudit", () => { expect(fetch).toHaveBeenCalledTimes(11); }); + // A large product sitemap fills the walk's entry cap before the one-URL + // sitemap that lists the homepage. The page is missing only from the part + // the scan read, which proves nothing about the sitemap as a whole. + it("declines the verdict when the sitemap walk stopped at its limit", async () => { + const products = Array.from( + { length: 600 }, + (_, i) => `https://example.com/product-${i}.html`, + ); + const rootSpec = mockFetchResult( + sitemapIndex([ + "https://example.com/sitemap_0-product.xml", + "https://example.com/sitemap_4.xml", + ]), + 200, + "application/xml", + ); + const fetch = vi.fn(async ({ url }) => { + if (url === "https://example.com/sitemap.xml") return rootSpec; + if (url === "https://example.com/sitemap_0-product.xml") + return mockFetchResult(sitemap(products), 200, "application/xml"); + return mockFetchResult( + sitemap(["https://example.com/"]), + 200, + "application/xml", + ); + }); + const ctx: CheckContext = { + ...mockCheckContext([page("https://example.com/")], { + "/sitemap.xml": rootSpec, + }), + fetch, + }; + const result = await audit.audit(ctx); + expect(result.status).toBe("na"); + expect(result.message).toContain("read only part of the sitemap"); + }); + + it("still fails an unlisted page when the walk read the whole sitemap", async () => { + const ctx = mockCheckContext([page("https://example.com/")], { + "/sitemap.xml": mockFetchResult( + sitemap(["https://example.com/product-1.html"]), + 200, + "application/xml", + ), + }); + const result = await audit.audit(ctx); + expect(result.status).toBe("fail"); + }); + // Review finding (1.8 + 1.22): raw string equality over trailing-slash // variants only, so protocol/host/case/encoding differences produced phantom // "missing" pages. diff --git a/packages/core/src/audits/machine-discovery/discovery-index-coverage.ts b/packages/core/src/audits/machine-discovery/discovery-index-coverage.ts index a40c220c..8ff8e8bb 100644 --- a/packages/core/src/audits/machine-discovery/discovery-index-coverage.ts +++ b/packages/core/src/audits/machine-discovery/discovery-index-coverage.ts @@ -89,8 +89,13 @@ export class DiscoveryIndexCoverageAudit extends Audit { }, }; - /** Every URL any discovery index lists, as comparison keys. */ - private async indexKeys(ctx: CheckContext): Promise> { + /** + * Every URL any discovery index lists, as comparison keys, and whether the + * sitemap walk stopped before it read the whole tree. + */ + private async indexKeys( + ctx: CheckContext, + ): Promise<{ keys: Set; incomplete: boolean }> { const keys = new Set(); const add = (url: string, base?: string) => { const key = coverageKey(url, base); @@ -99,13 +104,14 @@ export class DiscoveryIndexCoverageAudit extends Audit { const sitemapTree = await siteSitemapTree(ctx); for (const entry of sitemapTree.entries) add(entry.loc); + const incomplete = sitemapTree.truncated || !!sitemapTree.scopeIncomplete; const llmsResult = ctx.rootFiles["/llms.txt"]; if (llmsResult && isOk(llmsResult)) { for (const link of llmsTxtLinks(llmsResult.body)) add(link, ctx.baseUrl); } - return keys; + return { keys, incomplete }; } /** A page's own keys: its URL plus its declared canonical, if any. */ @@ -129,7 +135,7 @@ export class DiscoveryIndexCoverageAudit extends Audit { ); } - const indexKeys = await this.indexKeys(ctx); + const { keys: indexKeys, incomplete } = await this.indexKeys(ctx); // The missing sitemap is sitemap-exists' (1.7) finding. Charging for it here // too levied two penalties for one missing file. @@ -150,9 +156,23 @@ export class DiscoveryIndexCoverageAudit extends Audit { const uncovered = ctx.pages .filter((page) => !this.pageKeys(page).some((key) => indexKeys.has(key))) .map((page) => page.url); + const listed = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? ` (+${uncovered.length - 5} more)` : ""}`; + + // The walk caps entries and child sitemaps. A page missing from the part + // it read may sit in the part it skipped: a large retailer whose product + // sitemap fills the cap before the one-URL homepage sitemap was failed + // for an unindexed homepage it lists. Absence from a partial read proves + // nothing, so the verdict is declined rather than guessed. + if (uncovered.length > 0 && incomplete) { + return this.notApplicable( + `${uncovered.length}/${ctx.pages.length} scanned page(s) were not found in the part of the sitemap this scan read. The scan read only part of the sitemap — it hit its read limit or could not read a child sitemap — so the rest may list them.`, + expected, + `Not found in the partial sitemap read: ${listed}`, + ); + } if (uncovered.length > 0) { - const shown = `Not indexed: ${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? ` (+${uncovered.length - 5} more)` : ""}`; + const shown = `Not indexed: ${listed}`; const message = `${uncovered.length}/${ctx.pages.length} scanned page(s) are in no discovery index.`; if (uncovered.length / ctx.pages.length > 0.5) { From 8b2e2c38a56e777cc986a8a8ff7161213ac81ba0 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 09:00:50 +0000 Subject: [PATCH 02/36] fix(core): read only page dates in lastmod verifiability Nested Review, Comment, Answer and Question dates record when someone else wrote, not when the page changed. Reading them as page signals reported product pages that publish no page date as divergent instead of unverifiable. Count lastmods within one hour as one build stamp. A generator that writes the clock per URL spreads one run over seconds, and exact string matching missed it. --- .changeset/lastmod-page-signals.md | 5 + .../sitemap-lastmod-verifiability.md | 10 ++ .../sitemap-lastmod-verifiability.test.ts | 88 ++++++++++++++++- .../sitemap-lastmod-verifiability.ts | 95 ++++++++++++++++--- 4 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 .changeset/lastmod-page-signals.md diff --git a/.changeset/lastmod-page-signals.md b/.changeset/lastmod-page-signals.md new file mode 100644 index 00000000..ab706413 --- /dev/null +++ b/.changeset/lastmod-page-signals.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Fix two false readings in `machine-discovery/sitemap-lastmod-verifiability`. Dates on nested reviews, comments, questions and answers no longer count as the page's modification time, so a product page with customer reviews and no page date is unverifiable, not divergent. Lastmod values written seconds apart in one generator run now count as one build stamp. diff --git a/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md b/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md index 942414bc..d5edbf59 100644 --- a/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md +++ b/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md @@ -97,6 +97,16 @@ Content-page scans do not infer a mount. Origin files, origin probes, feed disco - **One hour of clock skew is tolerated** before a value counts as future-dated, so a server a few minutes ahead of the scanner is not reported. +### Page signals exclude contributed dates (2026-10-06) + +The sketch says to parse all JSON-LD blocks for `dateModified` and `datePublished`. The audit reads those dates only from nodes that describe the page: top-level nodes, `@graph` members and what they nest. It skips a nested `Review`, `Comment`, `Answer` or `Question`. Those dates record when someone else wrote, not when the page changed. A top-level `Review` is the page itself and keeps its dates. + +The trigger was a large retail site. Its product pages publish no modification time, but each one nests customer reviews. The review dates were read as page signals, and five pages with no page date were reported as divergent. They are now unverifiable. + +### A build stamp is one run, not one string (2026-10-06) + +The claim is "one identical lastmod equal to the last deploy date". The audit compared lastmod strings exactly. A generator that writes the clock per URL spreads one run over seconds, and exact matching saw every value as different. One large retail site stamped 2451 product URLs between 09:57:16 and 09:57:33 on one day. The audit now counts the largest group of sampled values inside a one-hour window. The 90% share and the 3-day recency rules are unchanged. Values hours apart still count as separate dates. + ## Deferred - **Only the first level of a `` is walked**, per the shared diff --git a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts index b3f0545d..e95a906a 100644 --- a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts +++ b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts @@ -36,6 +36,8 @@ interface PageSpec { lastModified?: string; /** , if the page carries one. */ metaModified?: string; + /** A raw JSON-LD block, served beside any dateModified block. */ + jsonLd?: object; } function html(spec: PageSpec): string { @@ -47,10 +49,13 @@ function html(spec: PageSpec): string { dateModified: spec.dateModified, })}` : ""; + const raw = spec.jsonLd + ? `` + : ""; const meta = spec.metaModified ? `` : ""; - return `${jsonLd}${meta}

Copy.

`; + return `${jsonLd}${raw}${meta}

Copy.

`; } function run( @@ -116,6 +121,65 @@ describe("SitemapLastmodVerifiabilityAudit", () => { expect(result.status).toBe("na"); }); + // A product page's customer reviews carry their own datePublished. Those + // dates say when a shopper wrote, not when the page changed; read as page + // signals they turned unverifiable lastmods into "divergent" ones. + describe("contributed dates", () => { + const product = (reviewDaysAgo: number) => ({ + "@context": "https://schema.org", + "@type": "Product", + name: "Hoodie", + review: [ + { + "@type": "Review", + author: { "@type": "Person", name: "A shopper" }, + datePublished: iso(reviewDaysAgo), + }, + ], + }); + + it("does not read a nested Review date as a page signal", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/p-${i}`, + lastmod: iso(5 + i * 9), + jsonLd: product(60 + i * 9), + })), + ); + expect(result.status).toBe("warn"); + expect(result.found).toContain("0 divergent, 5 unverifiable"); + }); + + it("does not read a nested Review inside an @graph member either", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/p-${i}`, + lastmod: iso(5 + i * 9), + jsonLd: { + "@context": "https://schema.org", + "@graph": [product(60 + i * 9)], + }, + })), + ); + expect(result.found).toContain("0 divergent, 5 unverifiable"); + }); + + it("keeps the dates of a page that is itself a Review", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/r-${i}`, + lastmod: iso(10 + i * 7), + jsonLd: { + "@context": "https://schema.org", + "@type": "Review", + datePublished: iso(10 + i * 7), + }, + })), + ); + expect(result.status).toBe("pass"); + }); + }); + it("passes when every lastmod matches the page dateModified", async () => { const result = await run(consistent(5)); expect(result.status).toBe("pass"); @@ -173,6 +237,28 @@ describe("SitemapLastmodVerifiabilityAudit", () => { expect(result.message).toContain("build stamp"); }); + // A generator that writes the clock per URL spreads one run over seconds. + // Exact-string matching saw every value as distinct and missed the stamp. + it("fails when recent lastmods spread over seconds cover over 90% of sampled URLs", async () => { + const base = Date.now() - DAY; + const urls = Array.from({ length: 10 }, (_v, i) => ({ + loc: `https://example.com/s-${i}`, + lastmod: new Date(base + i * 2_000).toISOString(), + })); + const result = await run(urls); + expect(result.status).toBe("fail"); + expect(result.message).toContain("one lastmod run"); + }); + + it("does not call lastmods hours apart a build stamp", async () => { + const urls = Array.from({ length: 10 }, (_v, i) => ({ + loc: `https://example.com/h-${i}`, + lastmod: new Date(Date.now() - DAY - i * 2 * 3_600_000).toISOString(), + })); + const result = await run(urls); + expect(result.message).not.toContain("build stamp"); + }); + it("fails when over 20% of sampled URLs diverge from every page signal by more than 7 days", async () => { const urls = Array.from({ length: 5 }, (_v, i) => ({ loc: `https://example.com/x-${i}`, diff --git a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts index 18cc43de..b051f529 100644 --- a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts +++ b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts @@ -15,7 +15,7 @@ import { parseHtml, extractJsonLd, extractMetaTags, - allJsonLdNodes, + topLevelJsonLd, } from "../../parser"; import { siteSitemapTree, @@ -31,6 +31,13 @@ const SAMPLE_SIZE = 6; const FUTURE_SKEW_MS = 60 * 60 * 1000; /** One value on this share of the sample is a stamp, not a set of content dates. */ const MODAL_SHARE = 0.9; +/** + * Values this close together are one stamp. A generator that writes the + * clock per URL spreads one run over seconds: one retail site stamped 2451 + * product URLs between 09:57:16 and 09:57:33, and exact-string matching saw + * 2451 different dates. + */ +const STAMP_WINDOW_MS = 60 * 60 * 1000; /** ...but only when that value is this recent, which is what makes it a deploy date. */ const MODAL_RECENCY_DAYS = 3; /** How far a lastmod may sit from every page signal before it is unsupported. */ @@ -53,6 +60,56 @@ function parseTime(value: unknown): number | undefined { return Number.isNaN(parsed) ? undefined : parsed; } +/** + * Types whose dates belong to someone else's contribution, not to the page. + * + * A product page carries its customers' reviews as `review: [{ "@type": + * "Review", datePublished }]`. Those dates say when a shopper wrote, not + * when the page changed, and on one retail site they turned five pages with + * no modification time at all into "divergent" lastmods. + */ +const CONTRIBUTED_TYPES = new Set([ + "Review", + "CriticReview", + "UserReview", + "EmployerReview", + "Comment", + "Answer", + "Question", +]); + +function typeNames(node: Record): string[] { + const raw = node["@type"]; + const list = Array.isArray(raw) ? raw : [raw]; + return list + .filter((t): t is string => typeof t === "string") + .map((t) => t.replace(/^.*[/:#]/, "")); +} + +/** + * The JSON-LD nodes whose dates describe the page: every top-level node and + * `@graph` member, and what they nest, minus nested contributions. A top-level + * Review is the page itself and keeps its dates. + */ +function pageDateNodes(jsonLd: object[]): Record[] { + const out: Record[] = []; + const visit = (node: unknown, nested: boolean): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item, nested); + return; + } + if (!isObject(node)) return; + if (nested && typeNames(node).some((t) => CONTRIBUTED_TYPES.has(t))) return; + out.push(node); + for (const [key, value] of Object.entries(node)) { + if (key === "@context") continue; + if (value && typeof value === "object") visit(value, true); + } + }; + for (const top of topLevelJsonLd(jsonLd)) visit(top, false); + return out; +} + /** Every modification time the page itself publishes, in no particular order. */ function pageSignals( headers: Record, @@ -66,8 +123,7 @@ function pageSignals( }; add(headers["last-modified"]); - for (const node of allJsonLdNodes(jsonLd)) { - if (!isObject(node)) continue; + for (const node of pageDateNodes(jsonLd)) { add(node["dateModified"]); add(node["datePublished"]); } @@ -218,16 +274,29 @@ export class SitemapLastmodVerifiabilityAudit extends Audit { } } - // The modal test only looks at URLs we could compare, so a site whose pages - // publish nothing is never accused of stamping builds. - const counts = new Map(); - for (const entry of sample) - counts.set(entry.lastmod, (counts.get(entry.lastmod) ?? 0) + 1); - const [modalValue, modalCount] = [...counts.entries()].sort( - (a, b) => b[1] - a[1], - )[0] ?? ["", 0]; + // The modal test counts the whole sample, compared or not: a stamp is a + // property of the sitemap, and needs no page signal to be seen. It takes + // the largest group of values inside one STAMP_WINDOW_MS window. + const ordered = sample + .map((entry) => ({ + lastmod: entry.lastmod, + time: Date.parse(entry.lastmod), + })) + .sort((a, b) => a.time - b.time); + let modalCount = 0; + let modalFirst = ""; + let modalLast = ""; + for (let start = 0, end = 0; end < ordered.length; end++) { + while (ordered[end]!.time - ordered[start]!.time > STAMP_WINDOW_MS) + start += 1; + if (end - start + 1 > modalCount) { + modalCount = end - start + 1; + modalFirst = ordered[start]!.lastmod; + modalLast = ordered[end]!.lastmod; + } + } const modalRecent = - (now - Date.parse(modalValue)) / DAY_MS <= MODAL_RECENCY_DAYS; + (now - Date.parse(modalLast)) / DAY_MS <= MODAL_RECENCY_DAYS; const buildStamp = sample.length > 1 && modalCount / sample.length > MODAL_SHARE && @@ -243,7 +312,7 @@ export class SitemapLastmodVerifiabilityAudit extends Audit { } if (buildStamp) { problems.push( - `${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share the single lastmod ${modalValue}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`, + `${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share ${modalFirst === modalLast ? `the single lastmod ${modalFirst}` : `one lastmod run, ${modalFirst} to ${modalLast}`}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`, ); } if (compared > 0 && divergent / compared > DIVERGENT_SHARE) { From bc0a865ef96f8105920e92f7f47aae75e4bf67b4 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 09:53:44 +0000 Subject: [PATCH 03/36] fix(core): pass crawler bots allowed through the catch-all group The 14 per-bot robots.txt audits warned at 0.5 when a bot was allowed only through User-agent: *. Under RFC 9309 section 2.2.1 that is the same access a named group grants, and no vendor documents a difference. They now pass and name the group that applied. A blocked bot still fails. A missing or unreadable robots.txt is not applicable. Control-token copy no longer says Google-Extended or Applebot-Extended crawl. --- .changeset/crawler-inherited-access-passes.md | 5 + .../audits/access-crawl-control/amazonbot.md | 45 +- .../access-crawl-control/applebot-extended.md | 51 +- .../audits/access-crawl-control/bravebot.md | 45 +- .../audits/access-crawl-control/ccbot.md | 45 +- .../access-crawl-control/chatgpt-user.md | 45 +- .../access-crawl-control/claude-searchbot.md | 45 +- .../access-crawl-control/claude-user.md | 45 +- .../access-crawl-control/duckassistbot.md | 45 +- .../access-crawl-control/google-extended.md | 51 +- .../audits/access-crawl-control/gptbot.md | 48 +- .../meta-external-fetcher.md | 45 +- .../access-crawl-control/mistralai-user.md | 45 +- .../access-crawl-control/oai-searchbot.md | 45 +- .../access-crawl-control/perplexitybot.md | 45 +- .../_crawler-bot-audit.ts | 105 +- .../_robots-consumers.differential.test.ts | 121 +- .../_robots-txt-helpers.ts | 5 + .../access-crawl-control/amazonbot.test.ts | 99 +- .../audits/access-crawl-control/amazonbot.ts | 4 +- .../access-crawl-control/anthropic-ai.ts | 4 +- .../applebot-extended.test.ts | 115 +- .../access-crawl-control/applebot-extended.ts | 7 +- .../access-crawl-control/bravebot.test.ts | 99 +- .../audits/access-crawl-control/bravebot.ts | 4 +- .../audits/access-crawl-control/ccbot.test.ts | 97 +- .../src/audits/access-crawl-control/ccbot.ts | 4 +- .../access-crawl-control/chatgpt-user.test.ts | 103 +- .../access-crawl-control/chatgpt-user.ts | 4 +- .../claude-searchbot.test.ts | 107 +- .../access-crawl-control/claude-searchbot.ts | 4 +- .../access-crawl-control/claude-user.test.ts | 99 +- .../access-crawl-control/claude-user.ts | 4 +- .../duckassistbot.test.ts | 105 +- .../access-crawl-control/duckassistbot.ts | 4 +- .../google-extended.test.ts | 113 +- .../access-crawl-control/google-extended.ts | 7 +- .../access-crawl-control/gptbot.test.ts | 97 +- .../src/audits/access-crawl-control/gptbot.ts | 4 +- .../meta-external-agent.ts | 5 +- .../meta-external-fetcher.test.ts | 107 +- .../meta-external-fetcher.ts | 4 +- .../mistralai-user.test.ts | 105 +- .../access-crawl-control/mistralai-user.ts | 4 +- .../oai-searchbot.test.ts | 105 +- .../access-crawl-control/oai-searchbot.ts | 4 +- .../perplexitybot.test.ts | 105 +- .../access-crawl-control/perplexitybot.ts | 4 +- .../bare-site.snapshot.test.ts.snap | 28 +- .../real-page-corpus.test.ts.snap | 1148 ++++++++--------- 50 files changed, 2398 insertions(+), 1186 deletions(-) create mode 100644 .changeset/crawler-inherited-access-passes.md diff --git a/.changeset/crawler-inherited-access-passes.md b/.changeset/crawler-inherited-access-passes.md new file mode 100644 index 00000000..5073cba1 --- /dev/null +++ b/.changeset/crawler-inherited-access-passes.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +The per-bot robots.txt checks, such as `access-crawl-control/gptbot` and `google-extended`, now pass a bot allowed through `User-agent: *` instead of warning, and say which group applied. A missing or unreadable robots.txt is not applicable, and the fix text now warns that a named `Allow: /` group drops the catch-all rules. diff --git a/docs/evidence/audits/access-crawl-control/amazonbot.md b/docs/evidence/audits/access-crawl-control/amazonbot.md index 66467642..fe2b1285 100644 --- a/docs/evidence/audits/access-crawl-control/amazonbot.md +++ b/docs/evidence/audits/access-crawl-control/amazonbot.md @@ -15,6 +15,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - amazonbot-docs - cloudflare-ai-crawler-purpose-industry --- @@ -25,7 +26,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Amazonbot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to Amazonbot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -63,3 +64,45 @@ Amazonbot is live (Alexa+ and Rufus grounding) and for commerce sites it is a ge - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Amazonbot. It +warned at 0.5 when Amazonbot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Amazonbot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Amazonbot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/applebot-extended.md b/docs/evidence/audits/access-crawl-control/applebot-extended.md index 47254c97..bbff06ff 100644 --- a/docs/evidence/audits/access-crawl-control/applebot-extended.md +++ b/docs/evidence/audits/access-crawl-control/applebot-extended.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - applebot-doc - apple-applebot-training-privacy --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Applebot-Extended may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Applebot-Extended is a robots.txt usage-control token, not a crawler: no request carries it. This check reads the rules that apply to it — its own group if it has one, otherwise the catch-all — and reports whether they disallow the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same permission. ## Code review findings (2026-08-20, 11-agent pass) @@ -62,3 +63,51 @@ Live signal, correct-in-kind guidance, but the same base-class defects and one s - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Applebot-Extended. It +warned at 0.5 when Applebot-Extended was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Applebot-Extended permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Applebot-Extended` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +The `impact` text is corrected in the same change, as the 2026-08-20 +required fix asks. Applebot-Extended governs foundation-model training only. +Siri, Spotlight and Safari presence come from base Applebot. The description +and the result messages no longer say the token crawls: no request carries +it. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/bravebot.md b/docs/evidence/audits/access-crawl-control/bravebot.md index 82b77d7d..a5874a02 100644 --- a/docs/evidence/audits/access-crawl-control/bravebot.md +++ b/docs/evidence/audits/access-crawl-control/bravebot.md @@ -14,6 +14,7 @@ signals: grade: C domain: robots-ai-crawlers sources: + - rfc-9309 - brave-search-crawler - knownagents-bravebot --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Bravebot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to Bravebot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -60,3 +61,45 @@ Token is real (Brave Search's crawler) but the value is thin: Brave Search build - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Bravebot. It +warned at 0.5 when Bravebot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Bravebot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Bravebot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/ccbot.md b/docs/evidence/audits/access-crawl-control/ccbot.md index 7e9e07dd..9c6aa921 100644 --- a/docs/evidence/audits/access-crawl-control/ccbot.md +++ b/docs/evidence/audits/access-crawl-control/ccbot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - commoncrawl-ccbot - consent-in-crisis-arxiv --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, CCBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to CCBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -61,3 +62,45 @@ CCBot is still active and Common Crawl still feeds many downstream training sets - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named CCBot. It +warned at 0.5 when CCBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to CCBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: CCBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/chatgpt-user.md b/docs/evidence/audits/access-crawl-control/chatgpt-user.md index 37e76cce..ee4508e4 100644 --- a/docs/evidence/audits/access-crawl-control/chatgpt-user.md +++ b/docs/evidence/audits/access-crawl-control/chatgpt-user.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - s18 - cloudflare-ai-crawler-purpose-industry - tollbit-robots-noncompliance @@ -26,7 +27,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, ChatGPT-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to ChatGPT-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -64,3 +65,45 @@ One of the four checks in this category that genuinely matter — ChatGPT-User i - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named ChatGPT-User. It +warned at 0.5 when ChatGPT-User was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to ChatGPT-User permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: ChatGPT-User` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/claude-searchbot.md b/docs/evidence/audits/access-crawl-control/claude-searchbot.md index 0f758d05..b6aea011 100644 --- a/docs/evidence/audits/access-crawl-control/claude-searchbot.md +++ b/docs/evidence/audits/access-crawl-control/claude-searchbot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - anthropic-crawlers --- @@ -23,7 +24,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Claude-SearchBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to Claude-SearchBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -72,3 +73,45 @@ vendor-level audit was not done and remains open. - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Claude-SearchBot. It +warned at 0.5 when Claude-SearchBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Claude-SearchBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Claude-SearchBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/claude-user.md b/docs/evidence/audits/access-crawl-control/claude-user.md index 2e4ecf96..dc001d4d 100644 --- a/docs/evidence/audits/access-crawl-control/claude-user.md +++ b/docs/evidence/audits/access-crawl-control/claude-user.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - anthropic-crawlers - tollbit-robots-noncompliance --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Claude-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to Claude-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -64,3 +65,45 @@ Genuinely valuable signal — Claude-User is the live fetcher behind Claude's we - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Claude-User. It +warned at 0.5 when Claude-User was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Claude-User permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Claude-User` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/duckassistbot.md b/docs/evidence/audits/access-crawl-control/duckassistbot.md index d9bb2795..f4d31920 100644 --- a/docs/evidence/audits/access-crawl-control/duckassistbot.md +++ b/docs/evidence/audits/access-crawl-control/duckassistbot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - duckduckgo-duckassistbot - knownagents-directory --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, DuckAssistBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to DuckAssistBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -62,3 +63,45 @@ Real, active token behind DuckDuckGo's DuckAssist answers — a legitimate keep, - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named DuckAssistBot. It +warned at 0.5 when DuckAssistBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to DuckAssistBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: DuckAssistBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/google-extended.md b/docs/evidence/audits/access-crawl-control/google-extended.md index c0a348bb..9ece8833 100644 --- a/docs/evidence/audits/access-crawl-control/google-extended.md +++ b/docs/evidence/audits/access-crawl-control/google-extended.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - google-common-crawlers - google-ai-features-trust - pebblous-blocking-citation-gap @@ -25,7 +26,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Google-Extended may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Google-Extended is a robots.txt usage-control token, not a crawler: no request carries it. This check reads the rules that apply to it — its own group if it has one, otherwise the catch-all — and reports whether they disallow the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same permission. ## Code review findings (2026-08-20, 11-agent pass) @@ -63,3 +64,51 @@ Inherits every base-class defect, and its guidance text is factually wrong in a - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Google-Extended. It +warned at 0.5 when Google-Extended was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Google-Extended permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Google-Extended` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +The `impact` text is corrected in the same change, as the 2026-08-20 +required fix asks. Google-Extended governs Gemini training and grounding +only. It does not affect Search, AI Overviews or AI Mode. The description and +the result messages no longer say the token crawls: Google documents it as a +standalone product token, and no request carries it. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/gptbot.md b/docs/evidence/audits/access-crawl-control/gptbot.md index 36451f1d..9be9952e 100644 --- a/docs/evidence/audits/access-crawl-control/gptbot.md +++ b/docs/evidence/audits/access-crawl-control/gptbot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - s18 - cloudflare-ai-crawler-purpose-industry - pebblous-blocking-citation-gap @@ -26,7 +27,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, GPTBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to GPTBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -69,3 +70,48 @@ GPTBot is the single most valuable token in the category and detecting a real Di - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named GPTBot. It +warned at 0.5 when GPTBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to GPTBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: GPTBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +This closes items 3 and 4 of the 2026-08-20 required fix. Item 5, the live +user-agent probe, is out of scope here. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/meta-external-fetcher.md b/docs/evidence/audits/access-crawl-control/meta-external-fetcher.md index 7ca43eac..65913c8e 100644 --- a/docs/evidence/audits/access-crawl-control/meta-external-fetcher.md +++ b/docs/evidence/audits/access-crawl-control/meta-external-fetcher.md @@ -18,6 +18,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - meta-web-crawlers-docs - cloudflare-ai-crawler-purpose-industry --- @@ -28,7 +29,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, Meta-ExternalFetcher may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to Meta-ExternalFetcher — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -67,3 +68,45 @@ Live token backing Meta AI's link fetching; a reasonable check to keep, though i - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named Meta-ExternalFetcher. It +warned at 0.5 when Meta-ExternalFetcher was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to Meta-ExternalFetcher permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: Meta-ExternalFetcher` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/mistralai-user.md b/docs/evidence/audits/access-crawl-control/mistralai-user.md index 4e7cf7dd..00cdc018 100644 --- a/docs/evidence/audits/access-crawl-control/mistralai-user.md +++ b/docs/evidence/audits/access-crawl-control/mistralai-user.md @@ -16,6 +16,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - mistral-robots-docs - cloudflare-ai-crawler-purpose-industry --- @@ -26,7 +27,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, MistralAI-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to MistralAI-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -71,3 +72,45 @@ Unusually, Mistral asserts no user-initiated robots.txt exemption. - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named MistralAI-User. It +warned at 0.5 when MistralAI-User was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to MistralAI-User permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: MistralAI-User` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/oai-searchbot.md b/docs/evidence/audits/access-crawl-control/oai-searchbot.md index 1329891c..d8239bf1 100644 --- a/docs/evidence/audits/access-crawl-control/oai-searchbot.md +++ b/docs/evidence/audits/access-crawl-control/oai-searchbot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - s18 - pebblous-blocking-citation-gap --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, OAI-SearchBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to OAI-SearchBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -63,3 +64,45 @@ High-value and correctly scoped — OAI-SearchBot builds the ChatGPT Search inde - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named OAI-SearchBot. It +warned at 0.5 when OAI-SearchBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to OAI-SearchBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: OAI-SearchBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/docs/evidence/audits/access-crawl-control/perplexitybot.md b/docs/evidence/audits/access-crawl-control/perplexitybot.md index eb98357d..425b2a4a 100644 --- a/docs/evidence/audits/access-crawl-control/perplexitybot.md +++ b/docs/evidence/audits/access-crawl-control/perplexitybot.md @@ -14,6 +14,7 @@ signals: grade: A domain: robots-ai-crawlers sources: + - rfc-9309 - perplexity-bots-docs - cloudflare-ai-crawler-purpose-industry --- @@ -24,7 +25,7 @@ sources: ## What it checks -Without an explicit robots.txt rule, PerplexityBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior. +Reads the robots.txt rules that apply to PerplexityBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access. ## Code review findings (2026-08-20, 11-agent pass) @@ -63,3 +64,45 @@ Perplexity is a genuinely high-value citation surface, so the signal is worth ke - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources). - 2026-08-21 — dossier generated; disposition pending final taxonomy design. + +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit scored the shape of the file, not the access it grants. The shared +base class `_crawler-bot-audit.ts` passed only when a group named PerplexityBot. It +warned at 0.5 when PerplexityBot was allowed through `User-agent: *` or by no rule +at all. + +Nothing in the evidence supports that split. The grade rests on what a +disallow does, which is a fact about the block state. RFC 9309 §2.2.1 makes a +crawler obey the group that names its product token and fall back to `*` +only when none does. An open catch-all therefore grants the same access a +named group would. + +The rule now asks one question: do the rules that apply to PerplexityBot permit `/`? +Allowed by its own group, through the catch-all, or by no applicable group +all pass. The message names which one applied. A disallow that reaches the +token still fails. + +An unreadable robots.txt is not applicable rather than a warn: missing, +non-200, an empty body, or a 200 that parses to no groups and no directives. +RFC 9309 §2.3.1.3 lets a crawler access everything when the file is +unavailable, and no source here documents a cost for its absence. + +The old fix, add `User-agent: PerplexityBot` / `Allow: /`, is withdrawn as advice for +an allowed site. A named group replaces the catch-all for that bot, so it +drops every catch-all `Disallow` the site still meant to apply. The fail +remedy now says so. + +`access-crawl-control/meta-external-agent` and +`access-crawl-control/anthropic-ai` made the same change on 2026-08-24. The +base class now matches them. + +Grade, tier and weight are unchanged. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. diff --git a/packages/core/src/audits/access-crawl-control/_crawler-bot-audit.ts b/packages/core/src/audits/access-crawl-control/_crawler-bot-audit.ts index cc5aeb28..eb8025b7 100644 --- a/packages/core/src/audits/access-crawl-control/_crawler-bot-audit.ts +++ b/packages/core/src/audits/access-crawl-control/_crawler-bot-audit.ts @@ -3,14 +3,23 @@ import { Audit } from "../../audit"; import type { CheckContext } from "../../check-context"; import type { CrawlerBot } from "./_robots-txt-helpers"; import { - parseRobotsTxt, - isAllowed, - isAnthropicAllowed, -} from "./_robots-txt-helpers"; + parseRobotsFile, + hasNamedGroup, + isPathAllowed, +} from "../../gatherers/robots"; /** - * Base audit class for crawler bot permission checks (2.1-2.21). - * Each bot audit extends this with the appropriate bot configuration. + * Base audit class for the per-bot robots.txt permission checks. + * + * Scores the access state, not the shape of the file. Under RFC 9309 §2.2.1 a + * crawler obeys the group that names its product token and falls back to the + * `*` group only when none does, so an open catch-all grants exactly the + * access a named group would. The rule used to pass only on a named group and + * warn at 0.5 on inherited access. No vendor documents a difference, and the + * fix it prescribed, a named `Allow: /` group, silently drops every catch-all + * Disallow for that bot. `meta-external-agent` and `anthropic-ai` made the + * same change first; see "Inherited access passes (2026-10-06)" in each bot's + * dossier. */ export abstract class CrawlerBotAudit extends Audit { protected abstract bot: CrawlerBot; @@ -18,59 +27,67 @@ export abstract class CrawlerBotAudit extends Audit { audit(ctx: CheckContext): AuditResult { const robotsFile = ctx.rootFiles["/robots.txt"]; const { bot } = this; + const token = bot.botName; + const expected = `No robots.txt rule that disallows / for ${token}`; - // If robots.txt is missing or errored, treat as warn + // Absent artifact, absent verdict. RFC 9309 §2.3.1.3 lets a crawler fetch + // anything when robots.txt is unavailable, so there is no rule to grade. if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) { - return this.warn( - `robots.txt not found — ${bot.displayName} is allowed by default but not explicitly.`, - `Explicit User-agent: ${bot.botName} with Allow: / in robots.txt`, + return this.notApplicable( + `No robots.txt to read, so there are no crawl rules to evaluate for ${bot.displayName}.`, + expected, "No robots.txt found", - { - priority: "medium", - description: `Without an explicit robots.txt rule, ${bot.displayName} may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.`, - code: `User-agent: ${bot.botName}\nAllow: /`, - }, ); } - const groups = parseRobotsTxt(robotsFile.body); + const { groups, sitemaps } = parseRobotsFile(robotsFile.body); - // Special handling for anthropic-ai / ClaudeBot (check 2.3) - const result = - bot.aliases && bot.aliases.length > 0 - ? isAnthropicAllowed(groups) - : isAllowed(groups, bot.botName); - - if (result.allowed && result.explicitly) { - return this.pass( - `${bot.displayName} is explicitly allowed in robots.txt.`, - `No Disallow: / for User-agent: ${bot.botName}; explicit Allow: / preferred`, - `Explicit rules found for ${bot.botName} — access allowed`, + // A 200 that carries no groups, no sitemaps and no directives is a soft 404 + // — an HTML error page served at /robots.txt — not a permissive rules file. + if (groups.length === 0 && sitemaps.length === 0) { + return this.notApplicable( + `The response at /robots.txt carries no crawl rules, so there is nothing to evaluate for ${bot.displayName}.`, + expected, + "robots.txt contains no user-agent groups and no directives", ); } - if (result.allowed && !result.explicitly) { - return this.warn( - `${bot.displayName} is allowed by default (no specific rules), but not explicitly allowed.`, - `No Disallow: / for User-agent: ${bot.botName}; explicit Allow: / preferred`, - `No explicit rules for ${bot.botName} — allowed via wildcard or default`, - { - priority: "medium", - description: `${bot.displayName} is currently allowed only through the wildcard rule, which could change if you later add a blanket block. An explicit User-agent entry for ${bot.botName} ensures stable access and signals to the crawler that your site is AI-friendly.`, - code: `User-agent: ${bot.botName}\nAllow: /`, - }, - ); + const named = hasNamedGroup(groups, token); + const hasCatchAll = groups.some((group) => group.userAgent.trim() === "*"); + // A usage-control token is read by its operator's other crawlers. No + // request carries it, so the copy must not say it crawls. + const tokenNote = bot.controlToken + ? ` ${bot.displayName} is a usage-control token, not a crawler: no request carries it, and the rule governs how content its operator's crawlers fetch may be used.` + : ""; + + if (isPathAllowed(groups, token, "/")) { + const [message, found] = named + ? [ + `${bot.displayName} is allowed by its own robots.txt group.`, + `User-agent: ${token} group permits /`, + ] + : hasCatchAll + ? [ + `${bot.displayName} is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.`, + "Allowed through the catch-all group", + ] + : [ + `${bot.displayName} is allowed. No group in robots.txt applies to it, so nothing restricts it.`, + `No group applies to ${token}`, + ]; + return this.pass(`${message}${tokenNote}`, expected, found); } - // Blocked return this.fail( - `${bot.displayName} is blocked by robots.txt.`, - `No Disallow: / for User-agent: ${bot.botName}; explicit Allow: / preferred`, - `${bot.botName} is disallowed (Disallow: /)`, + `${bot.displayName} is blocked by robots.txt.${tokenNote}`, + expected, + named + ? `Its own group (User-agent: ${token}) disallows /` + : `The catch-all group disallows / and no group names ${token}`, { priority: "high", - description: `Blocking ${bot.displayName} prevents your content from appearing in AI-powered search results and answers. If you want AI assistants to reference your site, remove the Disallow rule and add an explicit Allow.`, - code: `User-agent: ${bot.botName}\nAllow: /`, + description: `If the block was not intended, remove the Disallow rule that applies to ${token}. A named User-agent: ${token} group with Allow: / also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line ${token} should still obey.`, + code: `User-agent: ${token}\nAllow: /`, }, ); } diff --git a/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts b/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts index 8216052f..d91861ae 100644 --- a/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts +++ b/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts @@ -231,6 +231,13 @@ describe("robots.txt consumers — shared-gatherer differential", () => { // three that disallow it stay `fail`/0 with new text at priority `medium`. No // other audit's rows moved. +// Regenerated a fourth time, deliberately (2026-10-06): the per-bot base class +// `_crawler-bot-audit.ts` now scores access, not the shape of the file. +// gptbot's rows that leave GPTBot able to fetch `/` through the catch-all, or +// through no applicable group, move from `warn`/0.5 to `pass`/1; the four +// fixtures with no readable robots.txt move from `warn` to `na`; the blocked +// rows stay `fail`/0 with a `found` that names the group that applied. No +// other audit's rows moved. const BASELINE: Record> = { "no-blanket-block": { missing: { @@ -948,47 +955,47 @@ const BASELINE: Record> = { }, gptbot: { missing: { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "robots.txt not found — GPTBot is allowed by default but not explicitly.", + "No robots.txt to read, so there are no crawl rules to evaluate for GPTBot.", found: "No robots.txt found", priority: "medium", details: "{}", }, "non-200": { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "robots.txt not found — GPTBot is allowed by default but not explicitly.", + "No robots.txt to read, so there are no crawl rules to evaluate for GPTBot.", found: "No robots.txt found", priority: "medium", details: "{}", }, empty: { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "robots.txt not found — GPTBot is allowed by default but not explicitly.", + "No robots.txt to read, so there are no crawl rules to evaluate for GPTBot.", found: "No robots.txt found", priority: "medium", details: "{}", }, "html-error-page": { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "The response at /robots.txt carries no crawl rules, so there is nothing to evaluate for GPTBot.", + found: "robots.txt contains no user-agent groups and no directives", priority: "medium", details: "{}", }, "wildcard-allow": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, @@ -996,16 +1003,16 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "The catch-all group disallows / and no group names GPTBot", priority: "high", details: "{}", }, "blanket-block-countered": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, @@ -1013,7 +1020,7 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "The catch-all group disallows / and no group names GPTBot", priority: "high", details: "{}", }, @@ -1021,7 +1028,7 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "Its own group (User-agent: GPTBot) disallows /", priority: "high", details: "{}", }, @@ -1029,7 +1036,7 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "Its own group (User-agent: GPTBot) disallows /", priority: "high", details: "{}", }, @@ -1037,51 +1044,51 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "Its own group (User-agent: GPTBot) disallows /", priority: "high", details: "{}", }, "anthropic-alias-only": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "comments-and-crlf": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "bom-prefixed": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "crawl-delay-reasonable": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "crawl-delay-excessive": { status: "pass", score: 1, - message: "GPTBot is explicitly allowed in robots.txt.", - found: "Explicit rules found for GPTBot — access allowed", + message: "GPTBot is allowed by its own robots.txt group.", + found: "User-agent: GPTBot group permits /", priority: "medium", details: "{}", }, @@ -1089,34 +1096,34 @@ const BASELINE: Record> = { status: "fail", score: 0, message: "GPTBot is blocked by robots.txt.", - found: "GPTBot is disallowed (Disallow: /)", + found: "Its own group (User-agent: GPTBot) disallows /", priority: "high", details: "{}", }, "sensitive-paths-disallowed": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "youbot-and-ai2bot-explicit": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group names it, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", + found: "Allowed through the catch-all group", priority: "medium", details: "{}", }, "youbot-blocked": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "GPTBot is allowed by default (no specific rules), but not explicitly allowed.", - found: "No explicit rules for GPTBot — allowed via wildcard or default", + "GPTBot is allowed. No group in robots.txt applies to it, so nothing restricts it.", + found: "No group applies to GPTBot", priority: "medium", details: "{}", }, diff --git a/packages/core/src/audits/access-crawl-control/_robots-txt-helpers.ts b/packages/core/src/audits/access-crawl-control/_robots-txt-helpers.ts index a591519f..5593235c 100644 --- a/packages/core/src/audits/access-crawl-control/_robots-txt-helpers.ts +++ b/packages/core/src/audits/access-crawl-control/_robots-txt-helpers.ts @@ -112,6 +112,11 @@ export interface CrawlerBot { category: "training" | "realtime"; /** Optional alias bot names to also check (e.g. ClaudeBot for anthropic-ai) */ aliases?: string[]; + /** + * True for a usage-control token such as Google-Extended: robots.txt rules + * name it, but no request carries it, so report copy must not say it crawls. + */ + controlToken?: boolean; } export const TRAINING_CRAWLERS: CrawlerBot[] = [ diff --git a/packages/core/src/audits/access-crawl-control/amazonbot.test.ts b/packages/core/src/audits/access-crawl-control/amazonbot.test.ts index 017184dc..a14d5f63 100644 --- a/packages/core/src/audits/access-crawl-control/amazonbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/amazonbot.test.ts @@ -2,52 +2,87 @@ import { describe, it, expect } from "vitest"; import { AmazonbotAudit } from "./amazonbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("AmazonbotAudit", () => { const audit = new AmazonbotAudit(); - it("passes when Amazonbot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Amazonbot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Amazonbot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: Amazonbot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Amazonbot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to Amazonbot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when Amazonbot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots("User-agent: Amazonbot\nAllow: /\n\nUser-agent: *\nDisallow: /"), + ); + expect(result.status).toBe("pass"); }); - it("fails when Amazonbot is blocked via Disallow: /", () => { - const robots = "User-agent: Amazonbot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when Amazonbot is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: Amazonbot\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Amazonbot under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: Amazonbot\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Amazonbot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(AmazonbotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(AmazonbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/amazonbot.ts b/packages/core/src/audits/access-crawl-control/amazonbot.ts index 0a432740..148851b5 100644 --- a/packages/core/src/audits/access-crawl-control/amazonbot.ts +++ b/packages/core/src/audits/access-crawl-control/amazonbot.ts @@ -10,7 +10,7 @@ export class AmazonbotAudit extends CrawlerBotAudit { title: "Amazonbot allowed", failureTitle: "Amazonbot allowed", description: - "Without an explicit robots.txt rule, Amazonbot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to Amazonbot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class AmazonbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking Amazonbot prevents your content from appearing in Alexa AI answers and Amazon's AI-powered search features. Allowing it gives your content visibility in Amazon's voice and commerce AI ecosystem.", - fix: "Add an explicit User-agent: Amazonbot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to Amazonbot. A named `User-agent: Amazonbot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Amazonbot should still obey.", code: "User-agent: Amazonbot\nAllow: /", effort: "trivial", docsUrl: "https://developer.amazon.com/amazonbot", diff --git a/packages/core/src/audits/access-crawl-control/anthropic-ai.ts b/packages/core/src/audits/access-crawl-control/anthropic-ai.ts index 074e9e7c..f5d693c9 100644 --- a/packages/core/src/audits/access-crawl-control/anthropic-ai.ts +++ b/packages/core/src/audits/access-crawl-control/anthropic-ai.ts @@ -79,8 +79,8 @@ export class AnthropicAudit extends CrawlerBotAudit { * about whether a group names the token — and under RFC 9309 §2.2.1 the * catch-all and named cases grant identical access. * - * The override is confined to this class. Twenty sibling bot audits inherit - * the base rule; changing it there would move every one of them. + * The base class adopted the same pass rule on 2026-10-06. This override + * stays for the legacy-token note and the ClaudeBot-specific failure text. */ override audit(ctx: CheckContext): AuditResult { const robotsFile = ctx.rootFiles["/robots.txt"]; diff --git a/packages/core/src/audits/access-crawl-control/applebot-extended.test.ts b/packages/core/src/audits/access-crawl-control/applebot-extended.test.ts index 121973d4..56f141be 100644 --- a/packages/core/src/audits/access-crawl-control/applebot-extended.test.ts +++ b/packages/core/src/audits/access-crawl-control/applebot-extended.test.ts @@ -2,52 +2,103 @@ import { describe, it, expect } from "vitest"; import { ApplebotExtendedAudit } from "./applebot-extended"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("ApplebotExtendedAudit", () => { const audit = new ApplebotExtendedAudit(); - it("passes when Applebot-Extended is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Applebot-Extended\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Applebot-Extended has its own group that permits /", () => { + const result = audit.audit( + robots("User-agent: Applebot-Extended\nAllow: /"), + ); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Applebot-Extended is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); }); - it("warns when Applebot-Extended is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("passes when no group applies to Applebot-Extended", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("fails when Applebot-Extended is blocked via Disallow: /", () => { - const robots = "User-agent: Applebot-Extended\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Applebot-Extended\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); + }); + + it("fails when Applebot-Extended is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: Applebot-Extended\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Applebot-Extended under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Applebot-Extended\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Applebot-Extended", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(ApplebotExtendedAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(ApplebotExtendedAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); + + it("does not describe Applebot-Extended as a crawler", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.message).toContain("not a crawler"); + expect(ApplebotExtendedAudit.meta.description).not.toContain( + "crawl your site", + ); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/applebot-extended.ts b/packages/core/src/audits/access-crawl-control/applebot-extended.ts index ecc3d471..ce01596a 100644 --- a/packages/core/src/audits/access-crawl-control/applebot-extended.ts +++ b/packages/core/src/audits/access-crawl-control/applebot-extended.ts @@ -10,7 +10,7 @@ export class ApplebotExtendedAudit extends CrawlerBotAudit { title: "Applebot-Extended allowed", failureTitle: "Applebot-Extended allowed", description: - "Without an explicit robots.txt rule, Applebot-Extended may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Applebot-Extended is a robots.txt usage-control token, not a crawler: no request carries it. This check reads the rules that apply to it — its own group if it has one, otherwise the catch-all — and reports whether they disallow the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same permission.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -21,8 +21,8 @@ export class ApplebotExtendedAudit extends CrawlerBotAudit { defaultPriority: "medium", guidance: { impact: - "Blocking Applebot-Extended prevents your content from being used in Apple Intelligence features, Siri AI answers, and Safari Highlights. Allowing it ensures visibility across Apple's AI ecosystem.", - fix: "Add an explicit User-agent: Applebot-Extended with Allow: / rule in your robots.txt file.", + "Disallowing Applebot-Extended opts content Applebot already crawls out of training Apple's foundation models. It does not remove the site from Siri, Spotlight or Safari search results: base Applebot governs those.", + fix: "If the block was not intended, remove the Disallow rule that applies to Applebot-Extended. A named `User-agent: Applebot-Extended` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Applebot-Extended should still obey.", code: "User-agent: Applebot-Extended\nAllow: /", effort: "trivial", docsUrl: "https://support.apple.com/en-us/111042", @@ -34,5 +34,6 @@ export class ApplebotExtendedAudit extends CrawlerBotAudit { botName: "Applebot-Extended", displayName: "Applebot-Extended", category: "training", + controlToken: true, }; } diff --git a/packages/core/src/audits/access-crawl-control/bravebot.test.ts b/packages/core/src/audits/access-crawl-control/bravebot.test.ts index e9b05c7f..c10a065a 100644 --- a/packages/core/src/audits/access-crawl-control/bravebot.test.ts +++ b/packages/core/src/audits/access-crawl-control/bravebot.test.ts @@ -2,52 +2,87 @@ import { describe, it, expect } from "vitest"; import { BravebotAudit } from "./bravebot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("BravebotAudit", () => { const audit = new BravebotAudit(); - it("passes when Bravebot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Bravebot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Bravebot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: Bravebot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Bravebot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to Bravebot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when Bravebot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots("User-agent: Bravebot\nAllow: /\n\nUser-agent: *\nDisallow: /"), + ); + expect(result.status).toBe("pass"); }); - it("fails when Bravebot is blocked via Disallow: /", () => { - const robots = "User-agent: Bravebot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when Bravebot is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: Bravebot\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Bravebot under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: Bravebot\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Bravebot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(BravebotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(BravebotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/bravebot.ts b/packages/core/src/audits/access-crawl-control/bravebot.ts index 3e4a345b..a0624176 100644 --- a/packages/core/src/audits/access-crawl-control/bravebot.ts +++ b/packages/core/src/audits/access-crawl-control/bravebot.ts @@ -10,7 +10,7 @@ export class BravebotAudit extends CrawlerBotAudit { title: "Bravebot allowed", failureTitle: "Bravebot allowed", description: - "Without an explicit robots.txt rule, Bravebot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to Bravebot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "informative", weight: weightForGrade("C", "informative"), evidenceGrade: "C", @@ -22,7 +22,7 @@ export class BravebotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking Bravebot prevents your content from appearing in Brave Search AI answers and Brave Leo AI assistant responses. Allowing it gives your content visibility in the privacy-focused Brave browser ecosystem.", - fix: "Add an explicit User-agent: Bravebot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to Bravebot. A named `User-agent: Bravebot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Bravebot should still obey.", code: "User-agent: Bravebot\nAllow: /", effort: "trivial", tags: ["robots-txt", "brave", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/ccbot.test.ts b/packages/core/src/audits/access-crawl-control/ccbot.test.ts index d0cbf9f5..88779965 100644 --- a/packages/core/src/audits/access-crawl-control/ccbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/ccbot.test.ts @@ -2,52 +2,85 @@ import { describe, it, expect } from "vitest"; import { CcbotAudit } from "./ccbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("CcbotAudit", () => { const audit = new CcbotAudit(); - it("passes when CCBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: CCBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when CCBot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: CCBot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when CCBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to CCBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when CCBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots("User-agent: CCBot\nAllow: /\n\nUser-agent: *\nDisallow: /"), + ); + expect(result.status).toBe("pass"); }); - it("fails when CCBot is blocked via Disallow: /", () => { - const robots = "User-agent: CCBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when CCBot is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: CCBot\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks CCBot under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: CCBot\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches CCBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(CcbotAudit.meta.guidance?.fix).toContain("replaces the catch-all"); + expect(CcbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/ccbot.ts b/packages/core/src/audits/access-crawl-control/ccbot.ts index 27feca9d..0db19e98 100644 --- a/packages/core/src/audits/access-crawl-control/ccbot.ts +++ b/packages/core/src/audits/access-crawl-control/ccbot.ts @@ -10,7 +10,7 @@ export class CcbotAudit extends CrawlerBotAudit { title: "CCBot allowed", failureTitle: "CCBot allowed", description: - "Without an explicit robots.txt rule, CCBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to CCBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class CcbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking CCBot prevents your content from being included in the Common Crawl dataset, which is a foundational training data source for many AI models. Allowing it broadens your content's reach across multiple AI systems.", - fix: "Add an explicit User-agent: CCBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to CCBot. A named `User-agent: CCBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line CCBot should still obey.", code: "User-agent: CCBot\nAllow: /", effort: "trivial", docsUrl: "https://commoncrawl.org/ccbot", diff --git a/packages/core/src/audits/access-crawl-control/chatgpt-user.test.ts b/packages/core/src/audits/access-crawl-control/chatgpt-user.test.ts index d32d50b4..26f76452 100644 --- a/packages/core/src/audits/access-crawl-control/chatgpt-user.test.ts +++ b/packages/core/src/audits/access-crawl-control/chatgpt-user.test.ts @@ -2,52 +2,91 @@ import { describe, it, expect } from "vitest"; import { ChatgptUserAudit } from "./chatgpt-user"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("ChatgptUserAudit", () => { const audit = new ChatgptUserAudit(); - it("passes when ChatGPT-User is explicitly allowed in robots.txt", () => { - const robots = "User-agent: ChatGPT-User\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when ChatGPT-User has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: ChatGPT-User\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when ChatGPT-User is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to ChatGPT-User", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when ChatGPT-User is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: ChatGPT-User\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when ChatGPT-User is blocked via Disallow: /", () => { - const robots = "User-agent: ChatGPT-User\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when ChatGPT-User is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: ChatGPT-User\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks ChatGPT-User under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: ChatGPT-User\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches ChatGPT-User", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(ChatgptUserAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(ChatgptUserAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/chatgpt-user.ts b/packages/core/src/audits/access-crawl-control/chatgpt-user.ts index 92a08759..ae59eb49 100644 --- a/packages/core/src/audits/access-crawl-control/chatgpt-user.ts +++ b/packages/core/src/audits/access-crawl-control/chatgpt-user.ts @@ -10,7 +10,7 @@ export class ChatgptUserAudit extends CrawlerBotAudit { title: "ChatGPT-User allowed", failureTitle: "ChatGPT-User allowed", description: - "Without an explicit robots.txt rule, ChatGPT-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to ChatGPT-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "informative", weight: weightForGrade("C", "informative"), evidenceGrade: "C", @@ -22,7 +22,7 @@ export class ChatgptUserAudit extends CrawlerBotAudit { guidance: { impact: "Blocking ChatGPT-User prevents ChatGPT from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in ChatGPT Browse conversations, losing a significant source of AI-driven traffic.", - fix: "Add an explicit User-agent: ChatGPT-User with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to ChatGPT-User. A named `User-agent: ChatGPT-User` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line ChatGPT-User should still obey.", code: "User-agent: ChatGPT-User\nAllow: /", effort: "trivial", docsUrl: "https://platform.openai.com/docs/bots/overview", diff --git a/packages/core/src/audits/access-crawl-control/claude-searchbot.test.ts b/packages/core/src/audits/access-crawl-control/claude-searchbot.test.ts index 0c0b8fe9..b0f237ae 100644 --- a/packages/core/src/audits/access-crawl-control/claude-searchbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/claude-searchbot.test.ts @@ -2,52 +2,95 @@ import { describe, it, expect } from "vitest"; import { ClaudeSearchbotAudit } from "./claude-searchbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("ClaudeSearchbotAudit", () => { const audit = new ClaudeSearchbotAudit(); - it("passes when Claude-SearchBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Claude-SearchBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Claude-SearchBot has its own group that permits /", () => { + const result = audit.audit( + robots("User-agent: Claude-SearchBot\nAllow: /"), + ); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Claude-SearchBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to Claude-SearchBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when Claude-SearchBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Claude-SearchBot\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when Claude-SearchBot is blocked via Disallow: /", () => { - const robots = "User-agent: Claude-SearchBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when Claude-SearchBot is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: Claude-SearchBot\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Claude-SearchBot under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Claude-SearchBot\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Claude-SearchBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(ClaudeSearchbotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(ClaudeSearchbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/claude-searchbot.ts b/packages/core/src/audits/access-crawl-control/claude-searchbot.ts index 35d5069d..a75b3404 100644 --- a/packages/core/src/audits/access-crawl-control/claude-searchbot.ts +++ b/packages/core/src/audits/access-crawl-control/claude-searchbot.ts @@ -10,7 +10,7 @@ export class ClaudeSearchbotAudit extends CrawlerBotAudit { title: "Claude-SearchBot allowed", failureTitle: "Claude-SearchBot allowed", description: - "Without an explicit robots.txt rule, Claude-SearchBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to Claude-SearchBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class ClaudeSearchbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking Claude-SearchBot prevents your content from appearing in Claude's web search results. Allowing it ensures your site is included when Claude searches the web to answer user questions.", - fix: "Add an explicit User-agent: Claude-SearchBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to Claude-SearchBot. A named `User-agent: Claude-SearchBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Claude-SearchBot should still obey.", code: "User-agent: Claude-SearchBot\nAllow: /", effort: "trivial", tags: ["robots-txt", "anthropic", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/claude-user.test.ts b/packages/core/src/audits/access-crawl-control/claude-user.test.ts index 49c3e9a5..fe2b618f 100644 --- a/packages/core/src/audits/access-crawl-control/claude-user.test.ts +++ b/packages/core/src/audits/access-crawl-control/claude-user.test.ts @@ -2,52 +2,87 @@ import { describe, it, expect } from "vitest"; import { ClaudeUserAudit } from "./claude-user"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("ClaudeUserAudit", () => { const audit = new ClaudeUserAudit(); - it("passes when Claude-User is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Claude-User\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Claude-User has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: Claude-User\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Claude-User is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to Claude-User", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when Claude-User is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots("User-agent: Claude-User\nAllow: /\n\nUser-agent: *\nDisallow: /"), + ); + expect(result.status).toBe("pass"); }); - it("fails when Claude-User is blocked via Disallow: /", () => { - const robots = "User-agent: Claude-User\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when Claude-User is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: Claude-User\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Claude-User under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: Claude-User\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Claude-User", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(ClaudeUserAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(ClaudeUserAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/claude-user.ts b/packages/core/src/audits/access-crawl-control/claude-user.ts index 1115000b..5e3dad07 100644 --- a/packages/core/src/audits/access-crawl-control/claude-user.ts +++ b/packages/core/src/audits/access-crawl-control/claude-user.ts @@ -10,7 +10,7 @@ export class ClaudeUserAudit extends CrawlerBotAudit { title: "Claude-User allowed", failureTitle: "Claude-User allowed", description: - "Without an explicit robots.txt rule, Claude-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to Claude-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class ClaudeUserAudit extends CrawlerBotAudit { guidance: { impact: "Blocking Claude-User prevents Claude from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in Claude conversations with web access enabled.", - fix: "Add an explicit User-agent: Claude-User with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to Claude-User. A named `User-agent: Claude-User` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Claude-User should still obey.", code: "User-agent: Claude-User\nAllow: /", effort: "trivial", tags: ["robots-txt", "anthropic", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/duckassistbot.test.ts b/packages/core/src/audits/access-crawl-control/duckassistbot.test.ts index 930db6eb..969a6f15 100644 --- a/packages/core/src/audits/access-crawl-control/duckassistbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/duckassistbot.test.ts @@ -2,52 +2,93 @@ import { describe, it, expect } from "vitest"; import { DuckassistbotAudit } from "./duckassistbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("DuckassistbotAudit", () => { const audit = new DuckassistbotAudit(); - it("passes when DuckAssistBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: DuckAssistBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when DuckAssistBot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: DuckAssistBot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when DuckAssistBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to DuckAssistBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when DuckAssistBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: DuckAssistBot\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when DuckAssistBot is blocked via Disallow: /", () => { - const robots = "User-agent: DuckAssistBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when DuckAssistBot is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: DuckAssistBot\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks DuckAssistBot under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: DuckAssistBot\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches DuckAssistBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(DuckassistbotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(DuckassistbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/duckassistbot.ts b/packages/core/src/audits/access-crawl-control/duckassistbot.ts index 49fa771e..97f356f3 100644 --- a/packages/core/src/audits/access-crawl-control/duckassistbot.ts +++ b/packages/core/src/audits/access-crawl-control/duckassistbot.ts @@ -10,7 +10,7 @@ export class DuckassistbotAudit extends CrawlerBotAudit { title: "DuckAssistBot allowed", failureTitle: "DuckAssistBot allowed", description: - "Without an explicit robots.txt rule, DuckAssistBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to DuckAssistBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class DuckassistbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking DuckAssistBot prevents your content from appearing in DuckDuckGo's AI-powered DuckAssist feature, which generates instant answers from crawled web pages. Allowing it ensures visibility in this privacy-first AI search experience.", - fix: "Add an explicit User-agent: DuckAssistBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to DuckAssistBot. A named `User-agent: DuckAssistBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line DuckAssistBot should still obey.", code: "User-agent: DuckAssistBot\nAllow: /", effort: "trivial", tags: ["robots-txt", "duckduckgo", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/google-extended.test.ts b/packages/core/src/audits/access-crawl-control/google-extended.test.ts index e1a0139f..03120af2 100644 --- a/packages/core/src/audits/access-crawl-control/google-extended.test.ts +++ b/packages/core/src/audits/access-crawl-control/google-extended.test.ts @@ -2,52 +2,101 @@ import { describe, it, expect } from "vitest"; import { GoogleExtendedAudit } from "./google-extended"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("GoogleExtendedAudit", () => { const audit = new GoogleExtendedAudit(); - it("passes when Google-Extended is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Google-Extended\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Google-Extended has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: Google-Extended\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Google-Extended is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); }); - it("warns when Google-Extended is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("passes when no group applies to Google-Extended", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("fails when Google-Extended is blocked via Disallow: /", () => { - const robots = "User-agent: Google-Extended\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Google-Extended\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); + }); + + it("fails when Google-Extended is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: Google-Extended\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Google-Extended under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Google-Extended\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Google-Extended", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(GoogleExtendedAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(GoogleExtendedAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); + + it("does not describe Google-Extended as a crawler", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.message).toContain("not a crawler"); + expect(GoogleExtendedAudit.meta.description).not.toContain( + "crawl your site", + ); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/google-extended.ts b/packages/core/src/audits/access-crawl-control/google-extended.ts index 54b1f767..c6a004a5 100644 --- a/packages/core/src/audits/access-crawl-control/google-extended.ts +++ b/packages/core/src/audits/access-crawl-control/google-extended.ts @@ -10,7 +10,7 @@ export class GoogleExtendedAudit extends CrawlerBotAudit { title: "Google-Extended allowed", failureTitle: "Google-Extended allowed", description: - "Without an explicit robots.txt rule, Google-Extended may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Google-Extended is a robots.txt usage-control token, not a crawler: no request carries it. This check reads the rules that apply to it — its own group if it has one, otherwise the catch-all — and reports whether they disallow the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same permission.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -21,8 +21,8 @@ export class GoogleExtendedAudit extends CrawlerBotAudit { defaultPriority: "medium", guidance: { impact: - "Blocking Google-Extended prevents your content from being used in Google's AI features like Gemini and AI Overviews. Allowing it ensures your site appears in Google's AI-powered search experiences alongside traditional results.", - fix: "Add an explicit User-agent: Google-Extended with Allow: / rule in your robots.txt file.", + "Disallowing Google-Extended stops content Google already crawls from being used to train Gemini models and to ground answers in Gemini Apps and Vertex AI. It does not affect inclusion in Google Search, AI Overviews or AI Mode: Googlebot's own rules govern those.", + fix: "If the block was not intended, remove the Disallow rule that applies to Google-Extended. A named `User-agent: Google-Extended` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Google-Extended should still obey.", code: "User-agent: Google-Extended\nAllow: /", effort: "trivial", docsUrl: @@ -35,5 +35,6 @@ export class GoogleExtendedAudit extends CrawlerBotAudit { botName: "Google-Extended", displayName: "Google-Extended", category: "training", + controlToken: true, }; } diff --git a/packages/core/src/audits/access-crawl-control/gptbot.test.ts b/packages/core/src/audits/access-crawl-control/gptbot.test.ts index 428fe65d..fe08384b 100644 --- a/packages/core/src/audits/access-crawl-control/gptbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/gptbot.test.ts @@ -2,52 +2,85 @@ import { describe, it, expect } from "vitest"; import { GptbotAudit } from "./gptbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("GptbotAudit", () => { const audit = new GptbotAudit(); - it("passes when GPTBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: GPTBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when GPTBot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: GPTBot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when GPTBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to GPTBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when GPTBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots("User-agent: GPTBot\nAllow: /\n\nUser-agent: *\nDisallow: /"), + ); + expect(result.status).toBe("pass"); }); - it("fails when GPTBot is blocked via Disallow: /", () => { - const robots = "User-agent: GPTBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when GPTBot is blocked by its own group", () => { + const result = audit.audit(robots("User-agent: GPTBot\nDisallow: /")); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks GPTBot under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches GPTBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(GptbotAudit.meta.guidance?.fix).toContain("replaces the catch-all"); + expect(GptbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/gptbot.ts b/packages/core/src/audits/access-crawl-control/gptbot.ts index 09a91c69..4650e6ea 100644 --- a/packages/core/src/audits/access-crawl-control/gptbot.ts +++ b/packages/core/src/audits/access-crawl-control/gptbot.ts @@ -10,7 +10,7 @@ export class GptbotAudit extends CrawlerBotAudit { title: "GPTBot allowed", failureTitle: "GPTBot allowed", description: - "Without an explicit robots.txt rule, GPTBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to GPTBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class GptbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking GPTBot prevents your content from being used by OpenAI's models and appearing in ChatGPT responses. Explicitly allowing it signals that your site welcomes AI indexing for the largest AI platform by user base.", - fix: "Add an explicit User-agent: GPTBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to GPTBot. A named `User-agent: GPTBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line GPTBot should still obey.", code: "User-agent: GPTBot\nAllow: /", effort: "trivial", docsUrl: "https://platform.openai.com/docs/bots/overview", diff --git a/packages/core/src/audits/access-crawl-control/meta-external-agent.ts b/packages/core/src/audits/access-crawl-control/meta-external-agent.ts index 129120c1..1b3ba77a 100644 --- a/packages/core/src/audits/access-crawl-control/meta-external-agent.ts +++ b/packages/core/src/audits/access-crawl-control/meta-external-agent.ts @@ -57,9 +57,8 @@ export class MetaExternalAgentAudit extends CrawlerBotAudit { * falls back to `*` only when no such group exists, so an open catch-all * grants exactly the access a named group would. * - * The override is confined to this class. Twenty sibling bot audits inherit - * the base rule and two of them are pinned in the robots differential - * baseline; only this audit has a recorded pass-rule finding. + * The base class adopted the same pass rule on 2026-10-06. This override + * stays for the Meta-specific failure text and its `details`. */ override audit(ctx: CheckContext): AuditResult { const robotsFile = ctx.rootFiles["/robots.txt"]; diff --git a/packages/core/src/audits/access-crawl-control/meta-external-fetcher.test.ts b/packages/core/src/audits/access-crawl-control/meta-external-fetcher.test.ts index f8d18900..b45de051 100644 --- a/packages/core/src/audits/access-crawl-control/meta-external-fetcher.test.ts +++ b/packages/core/src/audits/access-crawl-control/meta-external-fetcher.test.ts @@ -2,52 +2,95 @@ import { describe, it, expect } from "vitest"; import { MetaExternalFetcherAudit } from "./meta-external-fetcher"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("MetaExternalFetcherAudit", () => { const audit = new MetaExternalFetcherAudit(); - it("passes when Meta-ExternalFetcher is explicitly allowed in robots.txt", () => { - const robots = "User-agent: Meta-ExternalFetcher\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when Meta-ExternalFetcher has its own group that permits /", () => { + const result = audit.audit( + robots("User-agent: Meta-ExternalFetcher\nAllow: /"), + ); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when Meta-ExternalFetcher is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to Meta-ExternalFetcher", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when Meta-ExternalFetcher is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Meta-ExternalFetcher\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when Meta-ExternalFetcher is blocked via Disallow: /", () => { - const robots = "User-agent: Meta-ExternalFetcher\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when Meta-ExternalFetcher is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: Meta-ExternalFetcher\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks Meta-ExternalFetcher under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: Meta-ExternalFetcher\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches Meta-ExternalFetcher", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(MetaExternalFetcherAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(MetaExternalFetcherAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/meta-external-fetcher.ts b/packages/core/src/audits/access-crawl-control/meta-external-fetcher.ts index f058e327..2bf6d096 100644 --- a/packages/core/src/audits/access-crawl-control/meta-external-fetcher.ts +++ b/packages/core/src/audits/access-crawl-control/meta-external-fetcher.ts @@ -10,7 +10,7 @@ export class MetaExternalFetcherAudit extends CrawlerBotAudit { title: "Meta-ExternalFetcher allowed", failureTitle: "Meta-ExternalFetcher allowed", description: - "Without an explicit robots.txt rule, Meta-ExternalFetcher may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to Meta-ExternalFetcher — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -23,7 +23,7 @@ export class MetaExternalFetcherAudit extends CrawlerBotAudit { guidance: { impact: "Blocking Meta-ExternalFetcher prevents Meta's AI from fetching your content in real-time for AI-powered features across Facebook, Instagram, and WhatsApp. Allowing it ensures your content can be surfaced in Meta's real-time AI experiences.", - fix: "Add an explicit User-agent: Meta-ExternalFetcher with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to Meta-ExternalFetcher. A named `User-agent: Meta-ExternalFetcher` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line Meta-ExternalFetcher should still obey.", code: "User-agent: Meta-ExternalFetcher\nAllow: /", effort: "trivial", tags: ["robots-txt", "meta", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/mistralai-user.test.ts b/packages/core/src/audits/access-crawl-control/mistralai-user.test.ts index e8a44066..21fa0ca3 100644 --- a/packages/core/src/audits/access-crawl-control/mistralai-user.test.ts +++ b/packages/core/src/audits/access-crawl-control/mistralai-user.test.ts @@ -2,52 +2,93 @@ import { describe, it, expect } from "vitest"; import { MistralaiUserAudit } from "./mistralai-user"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("MistralaiUserAudit", () => { const audit = new MistralaiUserAudit(); - it("passes when MistralAI-User is explicitly allowed in robots.txt", () => { - const robots = "User-agent: MistralAI-User\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when MistralAI-User has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: MistralAI-User\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when MistralAI-User is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to MistralAI-User", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when MistralAI-User is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: MistralAI-User\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when MistralAI-User is blocked via Disallow: /", () => { - const robots = "User-agent: MistralAI-User\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when MistralAI-User is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: MistralAI-User\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks MistralAI-User under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: MistralAI-User\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches MistralAI-User", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(MistralaiUserAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(MistralaiUserAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/mistralai-user.ts b/packages/core/src/audits/access-crawl-control/mistralai-user.ts index 2d4cb153..312260ba 100644 --- a/packages/core/src/audits/access-crawl-control/mistralai-user.ts +++ b/packages/core/src/audits/access-crawl-control/mistralai-user.ts @@ -10,7 +10,7 @@ export class MistralaiUserAudit extends CrawlerBotAudit { title: "MistralAI-User allowed", failureTitle: "MistralAI-User allowed", description: - "Without an explicit robots.txt rule, MistralAI-User may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to MistralAI-User — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class MistralaiUserAudit extends CrawlerBotAudit { guidance: { impact: "Blocking MistralAI-User prevents Mistral AI's Le Chat from browsing your site in real-time when users ask it to visit your pages. Allowing it ensures your content can be cited in Mistral-powered AI conversations.", - fix: "Add an explicit User-agent: MistralAI-User with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to MistralAI-User. A named `User-agent: MistralAI-User` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line MistralAI-User should still obey.", code: "User-agent: MistralAI-User\nAllow: /", effort: "trivial", tags: ["robots-txt", "mistral", "realtime", "crawler-permissions"], diff --git a/packages/core/src/audits/access-crawl-control/oai-searchbot.test.ts b/packages/core/src/audits/access-crawl-control/oai-searchbot.test.ts index d3a25308..7196e1c8 100644 --- a/packages/core/src/audits/access-crawl-control/oai-searchbot.test.ts +++ b/packages/core/src/audits/access-crawl-control/oai-searchbot.test.ts @@ -2,52 +2,93 @@ import { describe, it, expect } from "vitest"; import { OaiSearchbotAudit } from "./oai-searchbot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("OaiSearchbotAudit", () => { const audit = new OaiSearchbotAudit(); - it("passes when OAI-SearchBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: OAI-SearchBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when OAI-SearchBot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: OAI-SearchBot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when OAI-SearchBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to OAI-SearchBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when OAI-SearchBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: OAI-SearchBot\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when OAI-SearchBot is blocked via Disallow: /", () => { - const robots = "User-agent: OAI-SearchBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when OAI-SearchBot is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: OAI-SearchBot\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks OAI-SearchBot under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: OAI-SearchBot\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches OAI-SearchBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(OaiSearchbotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(OaiSearchbotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/oai-searchbot.ts b/packages/core/src/audits/access-crawl-control/oai-searchbot.ts index 11e55df1..e7361aa9 100644 --- a/packages/core/src/audits/access-crawl-control/oai-searchbot.ts +++ b/packages/core/src/audits/access-crawl-control/oai-searchbot.ts @@ -10,7 +10,7 @@ export class OaiSearchbotAudit extends CrawlerBotAudit { title: "OAI-SearchBot allowed", failureTitle: "OAI-SearchBot allowed", description: - "Without an explicit robots.txt rule, OAI-SearchBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to OAI-SearchBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class OaiSearchbotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking OAI-SearchBot prevents your content from appearing in OpenAI's SearchGPT and ChatGPT web search results. Allowing it ensures your site is discoverable through OpenAI's real-time search features.", - fix: "Add an explicit User-agent: OAI-SearchBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to OAI-SearchBot. A named `User-agent: OAI-SearchBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line OAI-SearchBot should still obey.", code: "User-agent: OAI-SearchBot\nAllow: /", effort: "trivial", docsUrl: "https://platform.openai.com/docs/bots/overview", diff --git a/packages/core/src/audits/access-crawl-control/perplexitybot.test.ts b/packages/core/src/audits/access-crawl-control/perplexitybot.test.ts index 3a446786..50c06841 100644 --- a/packages/core/src/audits/access-crawl-control/perplexitybot.test.ts +++ b/packages/core/src/audits/access-crawl-control/perplexitybot.test.ts @@ -2,52 +2,93 @@ import { describe, it, expect } from "vitest"; import { PerplexitybotAudit } from "./perplexitybot"; import { mockCheckContext, mockFetchResult } from "../../__tests__/test-utils"; +const robots = (body: string) => + mockCheckContext([], { "/robots.txt": mockFetchResult(body, 200) }); + describe("PerplexitybotAudit", () => { const audit = new PerplexitybotAudit(); - it("passes when PerplexityBot is explicitly allowed in robots.txt", () => { - const robots = "User-agent: PerplexityBot\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("passes when PerplexityBot has its own group that permits /", () => { + const result = audit.audit(robots("User-agent: PerplexityBot\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.message).toContain("its own robots.txt group"); + }); + + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when PerplexityBot is allowed through the catch-all group", () => { + const result = audit.audit(robots("User-agent: *\nAllow: /")); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); + }); + + it("passes when no group applies to PerplexityBot", () => { + const result = audit.audit(robots("User-agent: SomeOtherBot\nDisallow: /")); expect(result.status).toBe("pass"); - expect(result.message).toContain("explicitly allowed"); + expect(result.message).toContain("No group in robots.txt applies"); }); - it("warns when PerplexityBot is allowed only via wildcard (not explicit)", () => { - const robots = "User-agent: *\nAllow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("allowed by default"); + it("follows its own group over a blocking catch-all", () => { + const result = audit.audit( + robots( + "User-agent: PerplexityBot\nAllow: /\n\nUser-agent: *\nDisallow: /", + ), + ); + expect(result.status).toBe("pass"); }); - it("fails when PerplexityBot is blocked via Disallow: /", () => { - const robots = "User-agent: PerplexityBot\nDisallow: /"; - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult(robots, 200), - }); - const result = audit.audit(ctx); + it("fails when PerplexityBot is blocked by its own group", () => { + const result = audit.audit( + robots("User-agent: PerplexityBot\nDisallow: /"), + ); expect(result.status).toBe("fail"); expect(result.message).toContain("blocked by robots.txt"); + expect(result.found).toContain("Its own group"); }); - it("warns when robots.txt is missing", () => { - const ctx = mockCheckContext([], {}); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("robots.txt not found"); + it("fails when its own group blocks PerplexityBot under an open catch-all", () => { + const result = audit.audit( + robots( + "User-agent: PerplexityBot\nDisallow: /\n\nUser-agent: *\nAllow: /", + ), + ); + expect(result.status).toBe("fail"); }); - it("warns when robots.txt returns non-200", () => { - const ctx = mockCheckContext([], { - "/robots.txt": mockFetchResult("", 404), - }); - const result = audit.audit(ctx); - expect(result.status).toBe("warn"); + it("fails when a catch-all block reaches PerplexityBot", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.status).toBe("fail"); + expect(result.found).toContain("catch-all group disallows /"); + }); + + it("warns in the fail remedy that a named group drops catch-all rules", () => { + const result = audit.audit(robots("User-agent: *\nDisallow: /")); + expect(result.remediation).toContain("replaces the catch-all"); + expect(PerplexitybotAudit.meta.guidance?.fix).toContain( + "replaces the catch-all", + ); + expect(PerplexitybotAudit.meta.description).toContain( + "A named group is not required", + ); + }); + + it("is not applicable when robots.txt is missing", () => { + const result = audit.audit(mockCheckContext([], {})); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); + + it("is not applicable when robots.txt returns non-200", () => { + const result = audit.audit( + mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), + ); + expect(result.status).toBe("na"); + expect(result.found).toContain("No robots.txt found"); + }); + + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); }); diff --git a/packages/core/src/audits/access-crawl-control/perplexitybot.ts b/packages/core/src/audits/access-crawl-control/perplexitybot.ts index f8dfa551..dbf52f77 100644 --- a/packages/core/src/audits/access-crawl-control/perplexitybot.ts +++ b/packages/core/src/audits/access-crawl-control/perplexitybot.ts @@ -10,7 +10,7 @@ export class PerplexitybotAudit extends CrawlerBotAudit { title: "PerplexityBot allowed", failureTitle: "PerplexityBot allowed", description: - "Without an explicit robots.txt rule, PerplexityBot may still crawl your site but has no signal that it is welcome. Adding an explicit allow rule improves your visibility in AI-powered search and ensures consistent crawler behavior.", + "Reads the robots.txt rules that apply to PerplexityBot — its own group if it has one, otherwise the catch-all — and reports whether they let it fetch the site root. A named group is not required: under RFC 9309 §2.2.1 an open catch-all grants the same access.", scoreDisplayMode: "ternary", weight: weightForGrade("A", "scored"), evidenceGrade: "A", @@ -22,7 +22,7 @@ export class PerplexitybotAudit extends CrawlerBotAudit { guidance: { impact: "Blocking PerplexityBot prevents your content from appearing in Perplexity AI search results, one of the fastest-growing AI answer engines. Allowing it gives your content visibility in AI-native search.", - fix: "Add an explicit User-agent: PerplexityBot with Allow: / rule in your robots.txt file.", + fix: "If the block was not intended, remove the Disallow rule that applies to PerplexityBot. A named `User-agent: PerplexityBot` group with `Allow: /` also lifts it, but under RFC 9309 §2.2.1 a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line PerplexityBot should still obey.", code: "User-agent: PerplexityBot\nAllow: /", effort: "trivial", docsUrl: "https://docs.perplexity.ai/guides/bots", diff --git a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap index e6cb1e9c..57360deb 100644 --- a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap @@ -48,6 +48,8 @@ fail 1 structured-data/json-ld-present fail 1 structured-data/organization-schema fail 1 structured-data/schema-validation na 0 access-crawl-control/ai-content-declaration +na 0 access-crawl-control/bravebot +na 0 access-crawl-control/chatgpt-user na 0 access-crawl-control/tdm-rep na 0 agent-interfaces/agents-json na 0 agent-interfaces/cors-api-routes @@ -114,9 +116,21 @@ na 0.6 operability-safety/url-addressable-state-and-pagination-fallback na 0.6 operability-safety/wikidata-round-trip-verification na 1 access-crawl-control/agent-governance na 1 access-crawl-control/ai-crawler-edge-parity +na 1 access-crawl-control/amazonbot na 1 access-crawl-control/anthropic-ai +na 1 access-crawl-control/applebot-extended na 1 access-crawl-control/bot-content-delta-declared +na 1 access-crawl-control/ccbot +na 1 access-crawl-control/claude-searchbot +na 1 access-crawl-control/claude-user +na 1 access-crawl-control/duckassistbot +na 1 access-crawl-control/google-extended +na 1 access-crawl-control/gptbot na 1 access-crawl-control/meta-external-agent +na 1 access-crawl-control/meta-external-fetcher +na 1 access-crawl-control/mistralai-user +na 1 access-crawl-control/oai-searchbot +na 1 access-crawl-control/perplexitybot na 1 access-crawl-control/robots-ai-group-shadowing na 1 access-crawl-control/sensitive-paths na 1 agent-interfaces/mcp-modern-era-reachability @@ -185,8 +199,6 @@ pass 1 content-extraction/language-attribute pass 1 operability-safety/aria-layer-injection-scan pass 1 operability-safety/invisible-instruction-scan pass 1 operability-safety/no-blocking-captcha -warn 0 access-crawl-control/bravebot -warn 0 access-crawl-control/chatgpt-user warn 0 access-crawl-control/crawl-delay warn 0 answer-readiness/brand-name warn 0 answer-readiness/og-image-alt @@ -200,19 +212,7 @@ warn 0.6 agentic-commerce/offer-truth-consistency warn 0.6 content-extraction/section-headings warn 0.6 content-extraction/sequential-headings warn 0.6 machine-discovery/discovery-index-coverage -warn 1 access-crawl-control/amazonbot -warn 1 access-crawl-control/applebot-extended warn 1 access-crawl-control/canonical -warn 1 access-crawl-control/ccbot -warn 1 access-crawl-control/claude-searchbot -warn 1 access-crawl-control/claude-user -warn 1 access-crawl-control/duckassistbot -warn 1 access-crawl-control/google-extended -warn 1 access-crawl-control/gptbot -warn 1 access-crawl-control/meta-external-fetcher -warn 1 access-crawl-control/mistralai-user -warn 1 access-crawl-control/oai-searchbot -warn 1 access-crawl-control/perplexitybot warn 1 machine-discovery/no-broken-ai-endpoints warn 1 machine-discovery/no-broken-links warn 1 structured-data/breadcrumb-schema" diff --git a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap index b1fad6d8..267c76c9 100644 --- a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap @@ -8,31 +8,31 @@ exports[`real-page corpus > aljazeera-com-article: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -228,31 +228,31 @@ exports[`real-page corpus > allbirds-com-collection: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -448,31 +448,31 @@ exports[`real-page corpus > atlassian-com-pricing-shell: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -668,31 +668,31 @@ exports[`real-page corpus > barclays-co-uk-current-accounts: verdicts hold 1`] = "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -888,31 +888,31 @@ exports[`real-page corpus > bbc-co-uk-article: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "warn", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -1108,31 +1108,31 @@ exports[`real-page corpus > canada-ca-income-tax: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -1328,31 +1328,31 @@ exports[`real-page corpus > capitalone-com-savings: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -1548,31 +1548,31 @@ exports[`real-page corpus > cdc-gov-flu-about: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -1768,31 +1768,31 @@ exports[`real-page corpus > chase-com: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -1988,31 +1988,31 @@ exports[`real-page corpus > cloudflare-com-plans: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -2208,31 +2208,31 @@ exports[`real-page corpus > discourse-meta-topic: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -2428,31 +2428,31 @@ exports[`real-page corpus > ebay-com-category-wall: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "warn", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "fail", "access-crawl-control/no-nofollow": "fail", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "fail", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -2648,31 +2648,31 @@ exports[`real-page corpus > gov-uk-vehicle-tax: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -2868,31 +2868,31 @@ exports[`real-page corpus > gymshark-com-shell: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "fail", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -3088,31 +3088,31 @@ exports[`real-page corpus > hackernews-thread: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -3308,31 +3308,31 @@ exports[`real-page corpus > hiutdenim-co-uk: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -3528,31 +3528,31 @@ exports[`real-page corpus > irs-gov-form-1040: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -3748,31 +3748,31 @@ exports[`real-page corpus > kubernetes-docs-pods: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -3968,31 +3968,31 @@ exports[`real-page corpus > lobsters-front-page: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "pass", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -4188,31 +4188,31 @@ exports[`real-page corpus > lobsters-login-threshold: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "pass", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -4408,31 +4408,31 @@ exports[`real-page corpus > magicspoon-com-product: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -4628,31 +4628,31 @@ exports[`real-page corpus > mdn-cache-control: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -4848,31 +4848,31 @@ exports[`real-page corpus > myfritz-net: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -5068,31 +5068,31 @@ exports[`real-page corpus > nodejs-docs-fs: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -5288,31 +5288,31 @@ exports[`real-page corpus > npr-org-article: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "warn", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -5508,31 +5508,31 @@ exports[`real-page corpus > otto-de-category: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -5728,31 +5728,31 @@ exports[`real-page corpus > python-docs-json: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -5948,31 +5948,31 @@ exports[`real-page corpus > quitenice-co-shell: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -6168,31 +6168,31 @@ exports[`real-page corpus > rakuten-co-jp-home: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -6388,31 +6388,31 @@ exports[`real-page corpus > react-dev-usestate: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -6608,31 +6608,31 @@ exports[`real-page corpus > reddit-r-programming-shell: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -6828,31 +6828,31 @@ exports[`real-page corpus > reuters-com: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "warn", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "fail", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -7048,31 +7048,31 @@ exports[`real-page corpus > stackoverflow-thread-wall: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "warn", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "fail", "access-crawl-control/no-nofollow": "fail", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "fail", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -7268,31 +7268,31 @@ exports[`real-page corpus > stripe-com-pricing: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -7488,31 +7488,31 @@ exports[`real-page corpus > tattly-com-shell: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -7708,31 +7708,31 @@ exports[`real-page corpus > theguardian-com-article: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "warn", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -7928,31 +7928,31 @@ exports[`real-page corpus > tirerack-com-soft-block-200: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "fail", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -8148,31 +8148,31 @@ exports[`real-page corpus > velasca-com: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "fail", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -8368,31 +8368,31 @@ exports[`real-page corpus > vercel-com-wall-200: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "warn", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -8588,31 +8588,31 @@ exports[`real-page corpus > walmart-com-wall-200: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "warn", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", @@ -8808,31 +8808,31 @@ exports[`real-page corpus > wellsfargo-com-checking: verdicts hold 1`] = ` "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", "access-crawl-control/aipref-content-usage-declaration-validity": "na", - "access-crawl-control/amazonbot": "warn", + "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", - "access-crawl-control/applebot-extended": "warn", + "access-crawl-control/applebot-extended": "na", "access-crawl-control/bot-content-delta-declared": "na", - "access-crawl-control/bravebot": "warn", + "access-crawl-control/bravebot": "na", "access-crawl-control/canonical": "pass", - "access-crawl-control/ccbot": "warn", - "access-crawl-control/chatgpt-user": "warn", - "access-crawl-control/claude-searchbot": "warn", - "access-crawl-control/claude-user": "warn", + "access-crawl-control/ccbot": "na", + "access-crawl-control/chatgpt-user": "na", + "access-crawl-control/claude-searchbot": "na", + "access-crawl-control/claude-user": "na", "access-crawl-control/crawl-delay": "warn", - "access-crawl-control/duckassistbot": "warn", - "access-crawl-control/google-extended": "warn", - "access-crawl-control/gptbot": "warn", + "access-crawl-control/duckassistbot": "na", + "access-crawl-control/google-extended": "na", + "access-crawl-control/gptbot": "na", "access-crawl-control/https-enabled": "pass", "access-crawl-control/machine-actionable-402-paid-access": "na", "access-crawl-control/meta-external-agent": "na", - "access-crawl-control/meta-external-fetcher": "warn", - "access-crawl-control/mistralai-user": "warn", + "access-crawl-control/meta-external-fetcher": "na", + "access-crawl-control/mistralai-user": "na", "access-crawl-control/no-blanket-block": "warn", "access-crawl-control/no-bot-detection": "pass", "access-crawl-control/no-nofollow": "pass", "access-crawl-control/no-redirect-chains": "pass", - "access-crawl-control/oai-searchbot": "warn", - "access-crawl-control/perplexitybot": "warn", + "access-crawl-control/oai-searchbot": "na", + "access-crawl-control/perplexitybot": "na", "access-crawl-control/robots-ai-group-shadowing": "na", "access-crawl-control/robots-directives": "pass", "access-crawl-control/rsl-licensing-terms-conformance": "na", From ed5af31f0215fbf15b36b8dad103d76c6781caee Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 09:57:56 +0000 Subject: [PATCH 04/36] fix(core): pass ai-bot-directives when bots inherit the catch-all The audit warned when YouBot or AI2Bot was allowed through User-agent: *. Its only reason was that the policy could flip under a later blanket block; no source documents a difference, and RFC 9309 section 2.2.1 grants the same access. The dossier is corrected first. Inherited access now passes and names the rule that applied; a block still fails. A missing or soft-404 robots.txt is not applicable. --- .../ai-bot-directives-inherited-allow.md | 5 + docs/evidence/audit-map.json | 2 +- .../access-crawl-control/ai-bot-directives.md | 67 +++++++++-- .../_robots-consumers.differential.test.ts | 107 +++++++++--------- .../ai-bot-directives.test.ts | 60 ++++++---- .../access-crawl-control/ai-bot-directives.ts | 79 +++++++------ .../bare-site.snapshot.test.ts.snap | 2 +- .../real-page-corpus.test.ts.snap | 82 +++++++------- 8 files changed, 244 insertions(+), 160 deletions(-) create mode 100644 .changeset/ai-bot-directives-inherited-allow.md diff --git a/.changeset/ai-bot-directives-inherited-allow.md b/.changeset/ai-bot-directives-inherited-allow.md new file mode 100644 index 00000000..8ad36ed6 --- /dev/null +++ b/.changeset/ai-bot-directives-inherited-allow.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +`access-crawl-control/ai-bot-directives` now passes when YouBot and AI2Bot are allowed through `User-agent: *` or by no applicable rule, instead of warning; a block still fails. A missing or unreadable robots.txt is not applicable, and the check is retitled "Documented AI bots allowed by robots.txt". diff --git a/docs/evidence/audit-map.json b/docs/evidence/audit-map.json index 211badb2..ad04dc23 100644 --- a/docs/evidence/audit-map.json +++ b/docs/evidence/audit-map.json @@ -49,7 +49,7 @@ "id": "access-crawl-control/ai-bot-directives", "category": "access-crawl-control", "slug": "ai-bot-directives", - "title": "AI bot directives are explicit", + "title": "Documented AI bots allowed by robots.txt", "tier": "scored", "evidenceGrade": "B", "weight": 0.6, diff --git a/docs/evidence/audits/access-crawl-control/ai-bot-directives.md b/docs/evidence/audits/access-crawl-control/ai-bot-directives.md index 8a411c1e..3e77c7ce 100644 --- a/docs/evidence/audits/access-crawl-control/ai-bot-directives.md +++ b/docs/evidence/audits/access-crawl-control/ai-bot-directives.md @@ -17,6 +17,7 @@ sources: - cloudflare-ai-crawler-purpose-industry - knownagents-cohere-training-crawler - knownagents-diffbot + - rfc-9309 --- # ai-bot-directives (`2.9`–`2.13`) @@ -31,12 +32,15 @@ Institute) — because only those two have an operator who publishes crawler documentation naming the token, which is what makes the directive readable by anyone. -For those two, an explicit `User-agent` group with `Allow: /` passes: it keeps a -documented consumer path open and pins the policy against a later blanket block. -Leaving them to the catch-all `*` warns, because the policy is unstated and will -flip the day a blanket block is added. An explicit `Disallow: /` is reported as a -failure — a legitimate publisher decision, but one that closes a documented -consumer path, and this check records that cost rather than hiding it. +For those two, the check asks one question: do the rules that apply to the +bot permit `/`? Allowed through its own group, through the catch-all `*`, or by +no applicable group all pass. Under RFC 9309 §2.2.1 a crawler obeys the group +that names it and falls back to `*` only when none does, so an open catch-all +grants the same access a named group would. A `Disallow: /` that reaches either +bot, whether its own group or a catch-all block, is reported as a failure. That +is a legitimate publisher decision, but it closes a documented consumer path, +and this check records that cost rather than hiding it. A site with no +readable robots.txt is not applicable. **Bytespider**, **cohere-ai** and **Diffbot** are listed for information only and never change the result. Blocking them is a reasonable operational choice that @@ -44,7 +48,7 @@ costs no AI-answer visibility. ## Claimed mechanism (falsifiable) -**Falsifiable claim:** Two AI bots have operators who publish crawler documentation naming the product token: YouBot (You.com) and AI2Bot (Allen Institute). For those, a `User-agent:` group in `robots.txt` is read by the operator. It determines whether the site enters that operator's corpus or index. A `Disallow: /` for those tokens therefore closes a documented consumer path; an explicit `Allow: /` keeps it open and pins the policy against a later blanket block. +**Falsifiable claim:** Two AI bots have operators who publish crawler documentation naming the product token: YouBot (You.com) and AI2Bot (Allen Institute). For those, a `User-agent:` group in `robots.txt` is read by the operator. It determines whether the site enters that operator's corpus or index. A `Disallow: /` that reaches those tokens therefore closes a documented consumer path. An open path needs no named group: under RFC 9309 §2.2.1 a crawler with no group of its own obeys the catch-all `*`. **Falsifiable the other way:** for the three remaining tokens (Bytespider, cohere-ai, Diffbot) no such documented reader could be located, and for Bytespider the directive is measured being ignored. Those rows are therefore reported but never scored — blocking them is a legitimate operational choice with no measurable AI-answer cost. @@ -58,7 +62,7 @@ The five source dossiers graded: bytespider **C**, cohere-ai **C**, youbot **A** Note the internal consistency this restores — and note that the two cases are _not_ symmetric. The _same_ TollBit finding capped Bytespider at C — "the consumer exists but the mechanism is empirically unreliable" — while YouBot kept an A. One of the two stamps had to move. Only YouBot's needed to, and only by one step. Bytespider's C rests on **two** negatives: no vendor documentation of any kind **plus** measured non-compliance. YouBot's demotion rests on **one**: measured non-compliance _despite_ vendor documentation that is the most detailed of the five. Documented-but-unreliable is a strictly stronger evidence position than undocumented-and-unreliable, which is exactly why YouBot lands at B rather than being dragged down to Bytespider's C. -That is also the disposition of YouBot inside this audit. **YouBot's own contribution is held at B, not C, and it stays in the scored set.** An unreliable mechanism is still a documented mechanism. A YouBot directive therefore continues to move the score: a block fails, and a wildcard-only allow warns. The cap is therefore a live judgement, with a re-review trigger in both directions. If a later measurement round finds YouBot honouring `robots.txt` after all, the A case reopens and this section should be re-litigated. If sustained non-compliance is confirmed instead, YouBot leaves the scored set, and the merged grade rests on AI2Bot alone (see counter-evidence below). +That is also the disposition of YouBot inside this audit. **YouBot's own contribution is held at B, not C, and it stays in the scored set.** An unreliable mechanism is still a documented mechanism. A YouBot directive therefore continues to move the score: a block fails. The cap is therefore a live judgement, with a re-review trigger in both directions. If a later measurement round finds YouBot honouring `robots.txt` after all, the A case reopens and this section should be re-litigated. If sustained non-compliance is confirmed instead, YouBot leaves the scored set, and the merged grade rests on AI2Bot alone (see counter-evidence below). ## Per-bot evidence @@ -106,11 +110,56 @@ That is also the disposition of YouBot inside this audit. **YouBot's own contrib - **The scored pair is low-volume.** Neither YouBot nor AI2Bot appears in a Cloudflare Radar top-five breakdown. The audit scores the _documentedness_ of the directive, not traffic, and its weight (0.6, grade B) is priced accordingly — it cannot dominate a category the way five weight-1.0 checks did. - **YouBot's compliance claim is disputed** (TollBit). If a future re-review confirms sustained non-compliance, YouBot drops out of the scored set and the merged grade falls to B on AI2Bot alone (weight unchanged) — or to C/informative if AI2Bot's evidence also degrades. -- **"Allow" is not universally correct — but the score is not neutral about it either.** Blocking either scored bot is a defensible publisher choice, and the audit says so at medium, never high, priority. The three states mean this. An explicit `Allow: /` for YouBot and AI2Bot is the **pass**: the documented consumer path is open and pinned. A bot left to `User-agent: *` is a **warn**: the policy is unstated, and it flips the day a blanket block lands. An explicit `Disallow: /` for either bot is a **fail**: a documented compliant consumer path is closed, and the audit records that cost rather than hiding it. Stating a block deliberately is more honest than inheriting one, but it does not neutralise the fail. The fail is the recorded price of the choice, not an accusation of a mistake. The fix text is written so that following it never scores worse than doing nothing. +- **"Allow" is not universally correct, and the score does not reward stating it.** Blocking either scored bot is a defensible publisher choice, and the audit says so at medium, never high, priority. Two states remain. A bot whose applicable rules permit `/` passes, whether the rule is its own group, the catch-all `*`, or no group at all: under RFC 9309 §2.2.1 the three grant identical access. A `Disallow: /` that reaches either bot is a **fail**: a documented compliant consumer path is closed, and the audit records that cost rather than hiding it. The fail is the recorded price of the choice, not an accusation of a mistake. The fix text is written so that following it never scores worse than doing nothing. - **Overlaps** with `access-crawl-control/no-blanket-block` (2.22) and `access-crawl-control/agent-governance` (2.28), both of which read the same file. All three now consume the shared RFC 9309 gatherer, so parsing behaviour (BOM, exact-match, wildcard paths) is fixed in one place. +## Implementation deviations + +### Inherited access passes (2026-10-06) + +The audit warned at 0.5 when YouBot or AI2Bot was allowed through +`User-agent: *` or by no rule at all. The stated reason was that the policy +"flips the day a blanket block is added". + +No source supports that warn. Neither You.com nor the Allen Institute documents +treating a named `Allow: /` group differently from inherited access. RFC 9309 +§2.2.1 makes a crawler obey the group that names its product token and fall +back to `*` only when none exists, so the two states grant identical access. +A later blanket block is a future edit, and this audit fails it when it lands. +Scoring a risk the site has not taken is speculation the evidence policy does +not allow. + +The named-group advice also had a cost. A `User-agent: YouBot` / `Allow: /` +group replaces the catch-all for YouBot, so it drops every catch-all +`Disallow` the site meant to apply. + +The rule now asks one question per scored bot: do the rules that apply to it +permit `/`? A block that reaches either bot still fails. An unreadable +robots.txt is not applicable, as in `access-crawl-control/meta-external-agent`: +missing, non-200, empty, or a 200 that parses to no groups and no directives. +A site with no robots.txt had received the same warn as a site with an open +catch-all. Neither is penalised now. + +The title moved from "AI bot directives are explicit" to "Documented AI bots +allowed by robots.txt". The failure title moved to "A documented AI bot is +blocked in robots.txt", because a block is now the only failing state. The +per-bot table keeps its stance labels: "allowed by default" is still an +accurate description of a row. `guidance.code` still shows the named +`Allow: /` snippet, and the fix text carries the caveat. + +Grade, tier and weight are unchanged at B, scored, 0.6. + +## Deferred + +- **A 5xx robots.txt.** RFC 9309 §2.3.1.4 tells a crawler to assume a complete + disallow when robots.txt is unreachable. This audit reports any non-200 as + not applicable and does not grade that case. + +``` + ## Review history - 2026-08-20 — code review (11-agent workflow) + evidence research (12-domain workflow, 400 sources) on the five source audits. - 2026-08-21 — five dossiers generated; REWORK-TODO records the consolidation requirement. - 2026-08-22 — consolidated into this audit (Plan 4, Task 2). Source dossiers preserved at [`docs/evidence/merged/access-crawl-control/`](../../merged/access-crawl-control/). +``` diff --git a/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts b/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts index d91861ae..bd62ba3c 100644 --- a/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts +++ b/packages/core/src/audits/access-crawl-control/_robots-consumers.differential.test.ts @@ -238,6 +238,12 @@ describe("robots.txt consumers — shared-gatherer differential", () => { // fixtures with no readable robots.txt move from `warn` to `na`; the blocked // rows stay `fail`/0 with a `found` that names the group that applied. No // other audit's rows moved. +// Regenerated deliberately (2026-10-06): ai-bot-directives stopped warning +// when YouBot or AI2Bot is allowed through the catch-all or by no applicable +// group. RFC 9309 §2.2.1 grants the same access either way. Those fixtures +// move from `warn` to `pass`; the four with no readable robots.txt move from +// `warn` to `na`; the three that block a scored bot stay `fail`. No other +// audit's rows moved. const BASELINE: Record> = { "no-blanket-block": { missing: { @@ -757,47 +763,46 @@ const BASELINE: Record> = { }, "ai-bot-directives": { missing: { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "No robots.txt found — the documented AI bots are allowed by default, but no directive names them.", + "No robots.txt to read, so there are no directives to evaluate for YouBot or AI2Bot.", found: "No robots.txt found", priority: "medium", details: "{}", }, "non-200": { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "No robots.txt found — the documented AI bots are allowed by default, but no directive names them.", + "No robots.txt to read, so there are no directives to evaluate for YouBot or AI2Bot.", found: "No robots.txt found", priority: "medium", details: "{}", }, empty: { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "No robots.txt found — the documented AI bots are allowed by default, but no directive names them.", + "No robots.txt to read, so there are no directives to evaluate for YouBot or AI2Bot.", found: "No robots.txt found", priority: "medium", details: "{}", }, "html-error-page": { - status: "warn", - score: 0.5, + status: "na", + score: 0, message: - "YouBot, AI2Bot are allowed by default: robots.txt has no directive for them and no wildcard rule either.", - found: - "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", + "The response at /robots.txt carries no crawl rules, so there is nothing to evaluate for YouBot or AI2Bot.", + found: "robots.txt contains no user-agent groups and no directives", priority: "medium", details: "{}", }, "wildcard-allow": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", @@ -814,10 +819,10 @@ const BASELINE: Record> = { details: "{}", }, "blanket-block-countered": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", @@ -834,100 +839,100 @@ const BASELINE: Record> = { details: "{}", }, "both-categories": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "versioned-product-token": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "mixed-case-tokens": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed by default: robots.txt has no directive for them and no wildcard rule either.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group and robots.txt has no catch-all group, so nothing restricts them.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "anthropic-alias-only": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "comments-and-crlf": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "bom-prefixed": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "crawl-delay-reasonable": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "crawl-delay-excessive": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "grouped-agents": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", details: "{}", }, "sensitive-paths-disallowed": { - status: "warn", - score: 0.5, + status: "pass", + score: 1, message: - "YouBot, AI2Bot are allowed only through the wildcard rule — no explicit directive.", + "YouBot and AI2Bot are allowed. YouBot, AI2Bot are named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.", found: "YouBot: allowed by default (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: allowed by default (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", @@ -936,7 +941,7 @@ const BASELINE: Record> = { "youbot-and-ai2bot-explicit": { status: "pass", score: 1, - message: "YouBot and AI2Bot are explicitly allowed in robots.txt.", + message: "YouBot and AI2Bot are allowed by their own robots.txt groups.", found: "YouBot: explicitly allowed (scored — You.com publishes a crawler page and a robots.txt compliance claim (field measurement disputes it; see dossier))\nAI2Bot: explicitly allowed (scored — the Allen Institute publishes the user-agent so operators can filter it; feeds the open Dolma corpora)\nBytespider: allowed by default (informational — no English vendor documentation, and measured fetching disallowed URLs; enforce at the edge, not in robots.txt)\ncohere-ai: allowed by default (informational — undocumented legacy token with no verified consumer (Cohere's observed crawler is cohere-training-data-crawler))\nDiffbot: allowed by default (informational — commercial extraction vendor with no published compliance statement; blocking it costs no AI-answer visibility)", priority: "medium", diff --git a/packages/core/src/audits/access-crawl-control/ai-bot-directives.test.ts b/packages/core/src/audits/access-crawl-control/ai-bot-directives.test.ts index 221cea75..1f9b254e 100644 --- a/packages/core/src/audits/access-crawl-control/ai-bot-directives.test.ts +++ b/packages/core/src/audits/access-crawl-control/ai-bot-directives.test.ts @@ -32,30 +32,48 @@ describe("AiBotDirectivesAudit — scoring (documented-active bots only)", () => expect(result.score).toBe(1); }); - it("warns when documented-active bots are only allowed by default", () => { + // RFC 9309 §2.2.1: a bot with no group of its own obeys the catch-all, so + // an open catch-all grants the same access a named group would. + it("passes when documented-active bots are allowed through the catch-all group", () => { const result = audit.audit(robots("User-agent: *\nAllow: /")); - expect(result.status).toBe("warn"); + expect(result.status).toBe("pass"); + expect(result.score).toBe(1); + expect(result.message).toContain("catch-all group applies"); }); - it("warns when robots.txt is missing", () => { + it("is not applicable when robots.txt is missing", () => { const result = audit.audit(mockCheckContext([], {})); - expect(result.status).toBe("warn"); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); - it("warns when robots.txt returns non-200", () => { + it("is not applicable when robots.txt returns non-200", () => { const result = audit.audit( mockCheckContext([], { "/robots.txt": mockFetchResult("", 404) }), ); - expect(result.status).toBe("warn"); + expect(result.status).toBe("na"); expect(result.found).toContain("No robots.txt found"); }); - it("warns when only one documented-active bot is explicitly allowed", () => { + it("is not applicable when /robots.txt serves an HTML error page", () => { + const result = audit.audit(robots("Not found")); + expect(result.status).toBe("na"); + }); + + it("passes when one documented-active bot is named and the other inherits the catch-all", () => { const result = audit.audit( robots("User-agent: YouBot\nAllow: /\n\nUser-agent: *\nAllow: /"), ); - expect(result.status).toBe("warn"); + expect(result.status).toBe("pass"); + expect(result.message).toContain("AI2Bot is named by no group"); + }); + + it("still fails when its own group blocks YouBot under an open catch-all", () => { + const result = audit.audit( + robots("User-agent: YouBot\nDisallow: /\n\nUser-agent: *\nAllow: /"), + ); + expect(result.status).toBe("fail"); + expect(result.message).toContain("YouBot"); }); it("fails when a documented-active bot is blocked", () => { @@ -169,28 +187,25 @@ describe("AiBotDirectivesAudit — informational per-bot table", () => { describe("AiBotDirectivesAudit — message accuracy", () => { const audit = new AiBotDirectivesAudit(); - it("does not claim wildcard-only access when robots.txt has no wildcard group", () => { + it("does not claim catch-all access when robots.txt has no catch-all group", () => { const result = audit.audit(robots("User-agent: Googlebot\nAllow: /\n")); - expect(result.status).toBe("warn"); - expect(result.message).not.toContain( - "allowed only through the wildcard rule", - ); - expect(result.message).toContain("no directive"); + expect(result.status).toBe("pass"); + expect(result.message).not.toContain("catch-all group applies"); + expect(result.message).toContain("no catch-all group"); }); - it("still names the wildcard rule when there is one", () => { + it("names the catch-all group when it is the rule that applies", () => { const result = audit.audit(robots("User-agent: *\nAllow: /\n")); - expect(result.status).toBe("warn"); - expect(result.message).toContain("wildcard rule"); + expect(result.status).toBe("pass"); + expect(result.message).toContain("RFC 9309"); }); - it("does not headline a warn as a blocked bot", () => { + it("does not headline an allowed site as needing attention", () => { const check = audit.toCheckResult( audit.audit(robots("User-agent: *\nAllow: /\n")), ); - expect(check.title).not.toBe( - "A documented AI bot is blocked in robots.txt", - ); + expect(check.title).toBe(AiBotDirectivesAudit.meta.title); + expect(check.title).not.toContain("explicit"); }); it("keeps the blocked-bot headline informative on the fail path", () => { @@ -198,5 +213,8 @@ describe("AiBotDirectivesAudit — message accuracy", () => { expect(result.status).toBe("fail"); expect(result.priority).toBe("medium"); expect(result.message).toContain("blocked by robots.txt"); + expect(audit.toCheckResult(result).title).toBe( + "A documented AI bot is blocked in robots.txt", + ); }); }); diff --git a/packages/core/src/audits/access-crawl-control/ai-bot-directives.ts b/packages/core/src/audits/access-crawl-control/ai-bot-directives.ts index b22cd07d..841207a9 100644 --- a/packages/core/src/audits/access-crawl-control/ai-bot-directives.ts +++ b/packages/core/src/audits/access-crawl-control/ai-bot-directives.ts @@ -1,7 +1,8 @@ import type { AuditMeta, AuditResult } from "../../types"; import { Audit } from "../../audit"; import type { CheckContext } from "../../check-context"; -import { parseRobotsTxt, isAllowed } from "./_robots-txt-helpers"; +import { parseRobotsFile, type RobotsGroup } from "../../gatherers/robots"; +import { isAllowed } from "./_robots-txt-helpers"; import { weightForGrade } from "../../scorer"; /** @@ -83,13 +84,10 @@ const DIRECTIVE_BOTS: DirectiveBot[] = [ const SCORED_BOTS = DIRECTIVE_BOTS.filter((b) => b.documentedActive); const EXPECTED = - "An explicit User-agent group for each documented AI bot (YouBot, AI2Bot) stating the access policy you intend"; + "No robots.txt rule that disallows / for a documented AI bot (YouBot, AI2Bot)"; /** Resolve one bot's stance against the parsed robots.txt groups. */ -function stanceFor( - groups: ReturnType, - bot: DirectiveBot, -): Stance { +function stanceFor(groups: RobotsGroup[], bot: DirectiveBot): Stance { const { explicitly, allowed } = isAllowed(groups, bot.botName); if (!allowed) return "blocked"; return explicitly ? "explicitly allowed" : "allowed by default"; @@ -106,10 +104,9 @@ export class AiBotDirectivesAudit extends Audit { static override meta: AuditMeta = { id: "access-crawl-control/ai-bot-directives", category: "access-crawl-control", - title: "AI bot directives are explicit", - // Both non-pass paths render under this headline, and one of them is a warn - // about an unstated policy, not a block. It has to be true of both. - failureTitle: "AI bot directives need attention", + title: "Documented AI bots allowed by robots.txt", + // A block is the only failing state, so the failure headline names it. + failureTitle: "A documented AI bot is blocked in robots.txt", description: "Reports your robots.txt stance on five long-tail AI bot tokens in one place. Only the bots whose operator publishes crawler documentation — YouBot (You.com) and AI2Bot (Allen Institute) — affect the score, because only those directives have a documented reader. Bytespider, cohere-ai and Diffbot are listed for information: blocking them is a legitimate operational choice that costs no AI-answer visibility.", scoreDisplayMode: "ternary", @@ -122,8 +119,8 @@ export class AiBotDirectivesAudit extends Audit { defaultPriority: "medium", guidance: { impact: - "Blocking YouBot removes the site from You.com's live search index; blocking AI2Bot removes it from the Allen Institute's open training corpora while leaving closed commercial crawlers untouched. Leaving either to the wildcard rule means the policy silently flips the day a blanket block is added. The other three tokens carry no comparable consumer, so this audit never penalises blocking them.", - fix: "Give YouBot and AI2Bot their own User-agent groups with Allow: / — that is the state this audit passes, because it keeps the documented consumer path open and pins it against a later blanket block. Leaving them to User-agent: * is a warning: the policy is unstated and flips the day a blanket block is added. An explicit Disallow: / for either bot is reported as a failure — it is a legitimate publisher decision, but it does close a documented consumer path, and this audit records that cost rather than hiding it; if that is what you intend, enforce it at the edge too, since robots.txt alone is not a reliable block. Bytespider, cohere-ai and Diffbot never affect the score, whatever you do with them.", + "Blocking YouBot removes the site from You.com's live search index; blocking AI2Bot removes it from the Allen Institute's open training corpora while leaving closed commercial crawlers untouched. The other three tokens carry no comparable consumer, so this audit never penalises blocking them.", + fix: "Nothing to do while YouBot and AI2Bot are allowed, whether through their own groups or through User-agent: *, which under RFC 9309 §2.2.1 grants the same access. A Disallow: / that reaches either bot is reported as a failure: a legitimate publisher decision, but one that closes a documented consumer path. If the block is unintended, remove it. A named group with Allow: / also lifts it, but a named group replaces the catch-all for that bot, so copy into it every catch-all Disallow line it should still obey. If the block is intended, enforce it at the edge too, since robots.txt alone is not a reliable block. Bytespider, cohere-ai and Diffbot never affect the score, whatever you do with them.", code: "User-agent: YouBot\nAllow: /\n\nUser-agent: AI2Bot\nAllow: /", effort: "trivial", tags: ["robots-txt", "crawler-permissions", "ai-bots"], @@ -133,20 +130,30 @@ export class AiBotDirectivesAudit extends Audit { audit(ctx: CheckContext): AuditResult { const robotsFile = ctx.rootFiles["/robots.txt"]; - // No robots.txt at all: every bot is allowed by default, so nothing is - // blocked — but no directive is explicit either, which is exactly the - // "one blanket block away from silently flipping" state. + // Absent artifact, absent verdict. With no robots.txt every bot may fetch + // everything (RFC 9309 §2.3.1.3), the same access an open catch-all + // grants, so there is no rule to grade and no cost to report. if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) { - return this.warn( - "No robots.txt found — the documented AI bots are allowed by default, but no directive names them.", + return this.notApplicable( + "No robots.txt to read, so there are no directives to evaluate for YouBot or AI2Bot.", EXPECTED, "No robots.txt found", - { priority: "medium" }, ); } // Parsed once for all five bots; the v1 audits re-parsed per bot. - const groups = parseRobotsTxt(robotsFile.body); + const { groups, sitemaps } = parseRobotsFile(robotsFile.body); + + // A 200 that carries no groups, no sitemaps and no directives is a soft 404 + // — an HTML error page served at /robots.txt — not a permissive rules file. + if (groups.length === 0 && sitemaps.length === 0) { + return this.notApplicable( + "The response at /robots.txt carries no crawl rules, so there is nothing to evaluate for YouBot or AI2Bot.", + EXPECTED, + "robots.txt contains no user-agent groups and no directives", + ); + } + const rows = DIRECTIVE_BOTS.map((bot) => ({ bot, stance: stanceFor(groups, bot), @@ -155,9 +162,6 @@ export class AiBotDirectivesAudit extends Audit { const scoredRows = rows.filter((r) => r.bot.documentedActive); const blocked = scoredRows.filter((r) => r.stance === "blocked"); - const implicit = scoredRows.filter( - (r) => r.stance === "allowed by default", - ); if (blocked.length > 0) { const names = blocked.map((r) => r.bot.displayName).join(", "); @@ -169,26 +173,29 @@ export class AiBotDirectivesAudit extends Audit { ); } - if (implicit.length > 0) { - const names = implicit.map((r) => r.bot.displayName).join(", "); - const verb = implicit.length === 1 ? "is" : "are"; - // "Allowed only through the wildcard rule" is false when there is no - // wildcard group: those bots are allowed because nothing in robots.txt - // mentions them at all. Both states warn, for the same reason — the - // policy is unstated — but they are not the same state. - const hasWildcard = groups.some((g) => g.userAgent === "*"); - return this.warn( - hasWildcard - ? `${names} ${verb} allowed only through the wildcard rule — no explicit directive.` - : `${names} ${verb} allowed by default: robots.txt has no directive for them and no wildcard rule either.`, + const allNames = SCORED_BOTS.map((b) => b.displayName).join(" and "); + const inherited = scoredRows.filter( + (r) => r.stance === "allowed by default", + ); + if (inherited.length === 0) { + return this.pass( + `${allNames} are allowed by their own robots.txt groups.`, EXPECTED, table, - { priority: "medium" }, ); } + // Under RFC 9309 §2.2.1 a bot with no group of its own obeys the + // catch-all, so inherited access is the same access a named group grants. + // The message still says which rule applied, and does not claim a + // catch-all where the file has none. + const names = inherited.map((r) => r.bot.displayName).join(", "); + const verb = inherited.length === 1 ? "is" : "are"; + const hasCatchAll = groups.some((g) => g.userAgent.trim() === "*"); return this.pass( - `${SCORED_BOTS.map((b) => b.displayName).join(" and ")} are explicitly allowed in robots.txt.`, + hasCatchAll + ? `${allNames} are allowed. ${names} ${verb} named by no group, so under RFC 9309 §2.2.1 the catch-all group applies, and it permits /.` + : `${allNames} are allowed. ${names} ${verb} named by no group and robots.txt has no catch-all group, so nothing restricts ${inherited.length === 1 ? "it" : "them"}.`, EXPECTED, table, ); diff --git a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap index 57360deb..1dad9245 100644 --- a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap @@ -71,6 +71,7 @@ na 0 operability-safety/security-header-hygiene na 0 operability-safety/tabindex na 0 operability-safety/trust-txt-reciprocity-coherence na 0 structured-data/claimreview-advisory +na 0.6 access-crawl-control/ai-bot-directives na 0.6 access-crawl-control/aipref-content-usage-declaration-validity na 0.6 access-crawl-control/machine-actionable-402-paid-access na 0.6 access-crawl-control/rsl-licensing-terms-conformance @@ -205,7 +206,6 @@ warn 0 answer-readiness/og-image-alt warn 0 content-extraction/code-language warn 0 structured-data/faqpage-schema warn 0 structured-data/howto-schema -warn 0.6 access-crawl-control/ai-bot-directives warn 0.6 access-crawl-control/ai-usage-signal-coherence-across-channels warn 0.6 access-crawl-control/no-blanket-block warn 0.6 agentic-commerce/offer-truth-consistency diff --git a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap index 267c76c9..9cbc0fdd 100644 --- a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap @@ -3,7 +3,7 @@ exports[`real-page corpus > aljazeera-com-article: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -223,7 +223,7 @@ exports[`real-page corpus > aljazeera-com-article: verdicts hold 1`] = ` exports[`real-page corpus > allbirds-com-collection: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -443,7 +443,7 @@ exports[`real-page corpus > allbirds-com-collection: verdicts hold 1`] = ` exports[`real-page corpus > atlassian-com-pricing-shell: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -663,7 +663,7 @@ exports[`real-page corpus > atlassian-com-pricing-shell: verdicts hold 1`] = ` exports[`real-page corpus > barclays-co-uk-current-accounts: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -883,7 +883,7 @@ exports[`real-page corpus > barclays-co-uk-current-accounts: verdicts hold 1`] = exports[`real-page corpus > bbc-co-uk-article: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -1103,7 +1103,7 @@ exports[`real-page corpus > bbc-co-uk-article: verdicts hold 1`] = ` exports[`real-page corpus > canada-ca-income-tax: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -1323,7 +1323,7 @@ exports[`real-page corpus > canada-ca-income-tax: verdicts hold 1`] = ` exports[`real-page corpus > capitalone-com-savings: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -1543,7 +1543,7 @@ exports[`real-page corpus > capitalone-com-savings: verdicts hold 1`] = ` exports[`real-page corpus > cdc-gov-flu-about: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -1763,7 +1763,7 @@ exports[`real-page corpus > cdc-gov-flu-about: verdicts hold 1`] = ` exports[`real-page corpus > chase-com: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -1983,7 +1983,7 @@ exports[`real-page corpus > chase-com: verdicts hold 1`] = ` exports[`real-page corpus > cloudflare-com-plans: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -2203,7 +2203,7 @@ exports[`real-page corpus > cloudflare-com-plans: verdicts hold 1`] = ` exports[`real-page corpus > discourse-meta-topic: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -2423,7 +2423,7 @@ exports[`real-page corpus > discourse-meta-topic: verdicts hold 1`] = ` exports[`real-page corpus > ebay-com-category-wall: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -2643,7 +2643,7 @@ exports[`real-page corpus > ebay-com-category-wall: verdicts hold 1`] = ` exports[`real-page corpus > gov-uk-vehicle-tax: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -2863,7 +2863,7 @@ exports[`real-page corpus > gov-uk-vehicle-tax: verdicts hold 1`] = ` exports[`real-page corpus > gymshark-com-shell: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -3083,7 +3083,7 @@ exports[`real-page corpus > gymshark-com-shell: verdicts hold 1`] = ` exports[`real-page corpus > hackernews-thread: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -3303,7 +3303,7 @@ exports[`real-page corpus > hackernews-thread: verdicts hold 1`] = ` exports[`real-page corpus > hiutdenim-co-uk: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -3523,7 +3523,7 @@ exports[`real-page corpus > hiutdenim-co-uk: verdicts hold 1`] = ` exports[`real-page corpus > irs-gov-form-1040: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -3743,7 +3743,7 @@ exports[`real-page corpus > irs-gov-form-1040: verdicts hold 1`] = ` exports[`real-page corpus > kubernetes-docs-pods: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -3963,7 +3963,7 @@ exports[`real-page corpus > kubernetes-docs-pods: verdicts hold 1`] = ` exports[`real-page corpus > lobsters-front-page: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "warn", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "pass", @@ -4183,7 +4183,7 @@ exports[`real-page corpus > lobsters-front-page: verdicts hold 1`] = ` exports[`real-page corpus > lobsters-login-threshold: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "warn", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "pass", @@ -4403,7 +4403,7 @@ exports[`real-page corpus > lobsters-login-threshold: verdicts hold 1`] = ` exports[`real-page corpus > magicspoon-com-product: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -4623,7 +4623,7 @@ exports[`real-page corpus > magicspoon-com-product: verdicts hold 1`] = ` exports[`real-page corpus > mdn-cache-control: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -4843,7 +4843,7 @@ exports[`real-page corpus > mdn-cache-control: verdicts hold 1`] = ` exports[`real-page corpus > myfritz-net: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -5063,7 +5063,7 @@ exports[`real-page corpus > myfritz-net: verdicts hold 1`] = ` exports[`real-page corpus > nodejs-docs-fs: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -5283,7 +5283,7 @@ exports[`real-page corpus > nodejs-docs-fs: verdicts hold 1`] = ` exports[`real-page corpus > npr-org-article: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -5503,7 +5503,7 @@ exports[`real-page corpus > npr-org-article: verdicts hold 1`] = ` exports[`real-page corpus > otto-de-category: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -5723,7 +5723,7 @@ exports[`real-page corpus > otto-de-category: verdicts hold 1`] = ` exports[`real-page corpus > python-docs-json: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -5943,7 +5943,7 @@ exports[`real-page corpus > python-docs-json: verdicts hold 1`] = ` exports[`real-page corpus > quitenice-co-shell: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -6163,7 +6163,7 @@ exports[`real-page corpus > quitenice-co-shell: verdicts hold 1`] = ` exports[`real-page corpus > rakuten-co-jp-home: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -6383,7 +6383,7 @@ exports[`real-page corpus > rakuten-co-jp-home: verdicts hold 1`] = ` exports[`real-page corpus > react-dev-usestate: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -6603,7 +6603,7 @@ exports[`real-page corpus > react-dev-usestate: verdicts hold 1`] = ` exports[`real-page corpus > reddit-r-programming-shell: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -6823,7 +6823,7 @@ exports[`real-page corpus > reddit-r-programming-shell: verdicts hold 1`] = ` exports[`real-page corpus > reuters-com: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -7043,7 +7043,7 @@ exports[`real-page corpus > reuters-com: verdicts hold 1`] = ` exports[`real-page corpus > stackoverflow-thread-wall: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -7263,7 +7263,7 @@ exports[`real-page corpus > stackoverflow-thread-wall: verdicts hold 1`] = ` exports[`real-page corpus > stripe-com-pricing: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -7483,7 +7483,7 @@ exports[`real-page corpus > stripe-com-pricing: verdicts hold 1`] = ` exports[`real-page corpus > tattly-com-shell: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -7703,7 +7703,7 @@ exports[`real-page corpus > tattly-com-shell: verdicts hold 1`] = ` exports[`real-page corpus > theguardian-com-article: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -7923,7 +7923,7 @@ exports[`real-page corpus > theguardian-com-article: verdicts hold 1`] = ` exports[`real-page corpus > tirerack-com-soft-block-200: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -8143,7 +8143,7 @@ exports[`real-page corpus > tirerack-com-soft-block-200: verdicts hold 1`] = ` exports[`real-page corpus > velasca-com: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -8363,7 +8363,7 @@ exports[`real-page corpus > velasca-com: verdicts hold 1`] = ` exports[`real-page corpus > vercel-com-wall-200: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -8583,7 +8583,7 @@ exports[`real-page corpus > vercel-com-wall-200: verdicts hold 1`] = ` exports[`real-page corpus > walmart-com-wall-200: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", @@ -8803,7 +8803,7 @@ exports[`real-page corpus > walmart-com-wall-200: verdicts hold 1`] = ` exports[`real-page corpus > wellsfargo-com-checking: verdicts hold 1`] = ` { "access-crawl-control/agent-governance": "na", - "access-crawl-control/ai-bot-directives": "warn", + "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", From 22b59520595417368e968acbdfbc4baa32f2d357 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 10:02:19 +0000 Subject: [PATCH 05/36] fix(core): treat AI-usage silence as not applicable in coherence audit The audit reports contradictions between channels; a site with no signal in any channel has nothing to contradict. The dossier called silence a real cost with no source, while its own sources read absence as unknown or no preference. Dossier corrected first; notes stay in details. --- .changeset/ai-usage-silence-not-applicable.md | 5 ++ ...-usage-signal-coherence-across-channels.md | 23 +++++- ...e-signal-coherence-across-channels.test.ts | 37 ++++++++- ...-usage-signal-coherence-across-channels.ts | 9 ++- .../bare-site.snapshot.test.ts.snap | 2 +- .../real-page-corpus.test.ts.snap | 78 +++++++++---------- 6 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 .changeset/ai-usage-silence-not-applicable.md diff --git a/.changeset/ai-usage-silence-not-applicable.md b/.changeset/ai-usage-silence-not-applicable.md new file mode 100644 index 00000000..d25408c6 --- /dev/null +++ b/.changeset/ai-usage-silence-not-applicable.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +`access-crawl-control/ai-usage-signal-coherence-across-channels` no longer warns a site that declares no AI-usage preference in any channel. With nothing declared there is nothing to contradict, so the check is not applicable. diff --git a/docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md b/docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md index 1f70d88b..5e046c61 100644 --- a/docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md +++ b/docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md @@ -67,6 +67,8 @@ A publisher adds `` site-wide to claim Tier per evidence policy: **scored** — grade B meets the A/B bar required for scored audits. +A site that publishes no AI-usage signal in any channel is not applicable: with nothing declared there is nothing to contradict. The AIPREF vocabulary reads an absent preference as unknown and Content Signals reads omission as no preference, so neither documents a cost for silence. + ## Review history - 2026-08-20 — proposed by the novel-checks research pass (10-agent evidence workflow); sources URL-verified at research time. @@ -114,7 +116,7 @@ cross-channel disagreement is reported here. prefixes. Two declarations are compared when either prefix contains the other, which is the same test in all three vocabularies. -**Silence warns, it does not fail.** A site that declares nothing is not +**Silence warns, it does not fail.** _Superseded 2026-10-06; see "Silence is not applicable" below._ A site that declares nothing is not misconfigured — it has left every crawler to its own default, which is a real cost but not a contradiction. A scan that read neither a page nor a robots.txt is `notApplicable`: nothing could have carried a signal. @@ -129,6 +131,25 @@ is `notApplicable`: nothing could have carried a signal. to here on a text-rich HTTP 200 wall: pass → na, at weight 0.6. Found by `packages/core/src/tests/hostile-state-contract.test.ts`. +### Silence is not applicable (2026-10-06) + +The audit reports whether channels agree. With no signal in any channel there +is nothing to compare, so the audit returns `notApplicable` instead of a warn. + +The earlier paragraph called silence "a real cost". No source here documents +one. The AIPREF vocabulary draft (s10) reads an absent preference as unknown. +Cloudflare's Content Signals policy (s8) reads omission as no preference. A +crawler that finds no signal applies its own default either way, and nothing +says the site loses anything by it. + +Step 5 of the implementation sketch, a distinct "no signal in any channel" +warning, is withdrawn for the same reason. Notes gathered on the way still +ride in `details.notes`, such as a `/.well-known/tdmrep.json` that is not an +array. `access-crawl-control/tdm-rep` owns that file's validity. + +A site with one signal, or several that agree, still passes. A contradiction +still fails. Grade, tier and weight are unchanged. + ## Deferred - **EPUB TDM-Rep.** The report defines a fourth transport, `tdm:reservation` in diff --git a/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.test.ts b/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.test.ts index 50f140a6..4edc0f5d 100644 --- a/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.test.ts +++ b/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.test.ts @@ -133,13 +133,42 @@ describe("AiUsageSignalCoherenceAcrossChannelsAudit", () => { ); }); - it("warns on total silence, with a different remedy from a contradiction", async () => { + // Coherence needs at least one declaration. Silence has nothing to + // contradict, and no source documents a cost for it. + it("is not applicable on total silence, and keeps the notes it gathered", async () => { const result = await audit.audit( - site({ robots: "User-agent: *\nAllow: /\n" }), + site({ + robots: "User-agent: *\nAllow: /\n", + tdmrep: '{"tdm-reservation": 1}', + }), ); - expect(result.status).toBe("warn"); + expect(result.status).toBe("na"); + expect(result.score).toBe(0); expect(result.details?.["signals"]).toBe(0); - expect(result.remediation).toContain("Content-Usage"); + expect((result.details!["notes"] as string[])[0]).toContain( + "array of rules", + ); + expect(result.remediation).toBeUndefined(); + }); + + it("still fails a robots.txt block contradicted by a response header", async () => { + const result = await audit.audit( + site({ + robots: "User-agent: GPTBot\nDisallow: /\n", + headers: { "content-usage": "train-ai=y" }, + }), + ); + expect(result.status).toBe("fail"); + const contradictions = result.details?.["contradictions"] as string[]; + expect(contradictions[0]).toContain("robots.txt Disallow"); + expect(contradictions[0]).toContain("Content-Usage response header"); + }); + + it("still passes a single declaration with nothing to contradict it", async () => { + const result = await audit.audit( + site({ robots: "User-agent: *\nContent-Usage: train-ai=n\n" }), + ); + expect(result.status).toBe("pass"); }); it("reads an inline RSL document without fetching anything", async () => { diff --git a/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.ts b/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.ts index ec901699..a83bb96c 100644 --- a/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.ts +++ b/packages/core/src/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.ts @@ -457,13 +457,16 @@ export class AiUsageSignalCoherenceAcrossChannelsAudit extends Audit { }; } + // Coherence needs at least one declaration. A site that publishes none has + // nothing to contradict, and no source documents a cost for silence: + // AIPREF reads an absent preference as unknown, and Content Signals reads + // omission as no preference. Notes gathered on the way still ride along. if (signals.length === 0) { return { - ...this.warn( - "This site declares no AI-usage preference in any channel, so every crawler applies its own default.", + ...this.notApplicable( + "This site declares no AI-usage preference in any channel, so there is nothing to compare across channels.", expected, found, - "Publish one preference — an AIPREF `Content-Usage` line in robots.txt is the smallest complete declaration — rather than leaving every operator to guess.", ), displayValue, details, diff --git a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap index 1dad9245..64b09f4d 100644 --- a/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/bare-site.snapshot.test.ts.snap @@ -72,6 +72,7 @@ na 0 operability-safety/tabindex na 0 operability-safety/trust-txt-reciprocity-coherence na 0 structured-data/claimreview-advisory na 0.6 access-crawl-control/ai-bot-directives +na 0.6 access-crawl-control/ai-usage-signal-coherence-across-channels na 0.6 access-crawl-control/aipref-content-usage-declaration-validity na 0.6 access-crawl-control/machine-actionable-402-paid-access na 0.6 access-crawl-control/rsl-licensing-terms-conformance @@ -206,7 +207,6 @@ warn 0 answer-readiness/og-image-alt warn 0 content-extraction/code-language warn 0 structured-data/faqpage-schema warn 0 structured-data/howto-schema -warn 0.6 access-crawl-control/ai-usage-signal-coherence-across-channels warn 0.6 access-crawl-control/no-blanket-block warn 0.6 agentic-commerce/offer-truth-consistency warn 0.6 content-extraction/section-headings diff --git a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap index 9cbc0fdd..6d5d346c 100644 --- a/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap +++ b/packages/core/src/tests/__snapshots__/real-page-corpus.test.ts.snap @@ -6,7 +6,7 @@ exports[`real-page corpus > aljazeera-com-article: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -226,7 +226,7 @@ exports[`real-page corpus > allbirds-com-collection: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -446,7 +446,7 @@ exports[`real-page corpus > atlassian-com-pricing-shell: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -666,7 +666,7 @@ exports[`real-page corpus > barclays-co-uk-current-accounts: verdicts hold 1`] = "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -886,7 +886,7 @@ exports[`real-page corpus > bbc-co-uk-article: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -1106,7 +1106,7 @@ exports[`real-page corpus > canada-ca-income-tax: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -1326,7 +1326,7 @@ exports[`real-page corpus > capitalone-com-savings: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -1546,7 +1546,7 @@ exports[`real-page corpus > cdc-gov-flu-about: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -1766,7 +1766,7 @@ exports[`real-page corpus > chase-com: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -1986,7 +1986,7 @@ exports[`real-page corpus > cloudflare-com-plans: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -2206,7 +2206,7 @@ exports[`real-page corpus > discourse-meta-topic: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -2426,7 +2426,7 @@ exports[`real-page corpus > ebay-com-category-wall: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -2646,7 +2646,7 @@ exports[`real-page corpus > gov-uk-vehicle-tax: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -2866,7 +2866,7 @@ exports[`real-page corpus > gymshark-com-shell: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -3086,7 +3086,7 @@ exports[`real-page corpus > hackernews-thread: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -3306,7 +3306,7 @@ exports[`real-page corpus > hiutdenim-co-uk: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -3526,7 +3526,7 @@ exports[`real-page corpus > irs-gov-form-1040: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -3746,7 +3746,7 @@ exports[`real-page corpus > kubernetes-docs-pods: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -4406,7 +4406,7 @@ exports[`real-page corpus > magicspoon-com-product: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -4626,7 +4626,7 @@ exports[`real-page corpus > mdn-cache-control: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -4846,7 +4846,7 @@ exports[`real-page corpus > myfritz-net: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -5066,7 +5066,7 @@ exports[`real-page corpus > nodejs-docs-fs: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -5286,7 +5286,7 @@ exports[`real-page corpus > npr-org-article: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -5506,7 +5506,7 @@ exports[`real-page corpus > otto-de-category: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -5726,7 +5726,7 @@ exports[`real-page corpus > python-docs-json: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -5946,7 +5946,7 @@ exports[`real-page corpus > quitenice-co-shell: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -6166,7 +6166,7 @@ exports[`real-page corpus > rakuten-co-jp-home: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -6386,7 +6386,7 @@ exports[`real-page corpus > react-dev-usestate: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -6606,7 +6606,7 @@ exports[`real-page corpus > reddit-r-programming-shell: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -6826,7 +6826,7 @@ exports[`real-page corpus > reuters-com: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -7046,7 +7046,7 @@ exports[`real-page corpus > stackoverflow-thread-wall: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -7266,7 +7266,7 @@ exports[`real-page corpus > stripe-com-pricing: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -7486,7 +7486,7 @@ exports[`real-page corpus > tattly-com-shell: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -7706,7 +7706,7 @@ exports[`real-page corpus > theguardian-com-article: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -7926,7 +7926,7 @@ exports[`real-page corpus > tirerack-com-soft-block-200: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -8146,7 +8146,7 @@ exports[`real-page corpus > velasca-com: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -8366,7 +8366,7 @@ exports[`real-page corpus > vercel-com-wall-200: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -8586,7 +8586,7 @@ exports[`real-page corpus > walmart-com-wall-200: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", @@ -8806,7 +8806,7 @@ exports[`real-page corpus > wellsfargo-com-checking: verdicts hold 1`] = ` "access-crawl-control/ai-bot-directives": "na", "access-crawl-control/ai-content-declaration": "na", "access-crawl-control/ai-crawler-edge-parity": "na", - "access-crawl-control/ai-usage-signal-coherence-across-channels": "warn", + "access-crawl-control/ai-usage-signal-coherence-across-channels": "na", "access-crawl-control/aipref-content-usage-declaration-validity": "na", "access-crawl-control/amazonbot": "na", "access-crawl-control/anthropic-ai": "na", From 6f3ce5bfe08fd2cc22ac0314b9f46cdc6f97d6b3 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 10:05:13 +0000 Subject: [PATCH 06/36] fix(core): ignore RSL path guesses that return no RSL document When nothing advertises an RSL licence, the audit guesses /license.xml and /rsl.xml. A site that redirects both to its homepage got four conformance errors about the homepage HTML. A guessed path is now kept only when its body has an element; otherwise the audit returns notApplicable. --- .changeset/rsl-probe-homepage-absent.md | 5 ++ .../rsl-licensing-terms-conformance.md | 15 ++++ .../rsl-licensing-terms-conformance.test.ts | 76 +++++++++++++++++++ .../rsl-licensing-terms-conformance.ts | 16 ++++ 4 files changed, 112 insertions(+) create mode 100644 .changeset/rsl-probe-homepage-absent.md diff --git a/.changeset/rsl-probe-homepage-absent.md b/.changeset/rsl-probe-homepage-absent.md new file mode 100644 index 00000000..2fc06e27 --- /dev/null +++ b/.changeset/rsl-probe-homepage-absent.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Stop failing `access-crawl-control/rsl-licensing-terms-conformance` when `/license.xml` or `/rsl.xml` redirects to the homepage. A guessed path whose body is not an RSL document now counts as no licence, and the audit returns not applicable. diff --git a/docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md b/docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md index 0b89b6c0..ebecfb63 100644 --- a/docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md +++ b/docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md @@ -82,6 +82,21 @@ The 402 cross-check the sketch's step 6 describes lives in 402 responses are: that audit requires a `` when a 402 is observed, rather than this one guessing whether any 402 exists. +### A guessed path must return an RSL document (2026-10-06) + +The conventional-path probe keeps a candidate only when its body has an +`` element. A large retail site redirected `/license.xml` and `/rsl.xml` +to its homepage. The fetcher followed the redirect, and the audit graded the +homepage HTML as a licence: four conformance errors about a document the site +never published. A guess that lands on HTML is now absent, and the audit +returns not applicable. + +The test is the body, not the redirect. A conventional path that redirects to +a real RSL document still counts and still warns as not discoverable. The +content type is not the test either: an RSL document served as `text/xml` is +still reported. Advertised candidates are not filtered, so a `License:` line +that points at HTML still fails. + ## Deferred - **`max-age` and ``.** Both are spec elements this audit does not diff --git a/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.test.ts b/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.test.ts index 5a50d3b4..274bbab7 100644 --- a/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.test.ts +++ b/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.test.ts @@ -128,6 +128,82 @@ describe("RslLicensingTermsConformanceAudit", () => { expect(strings(result, "notes")[0]).toContain("no crawler is obliged"); }); + // A guessed path that redirects to the homepage carries no licence. The + // fetcher follows the redirect, so the body is the storefront's HTML. + const homepageVia = (finalUrl: string): FetchResult => { + const result = mockFetchResult( + "Shop

New arrivals

", + 200, + "text/html", + ); + result.finalUrl = finalUrl; + result.redirectChain = [ + { status: 301, from: "https://example.com/license.xml", to: finalUrl }, + ]; + return result; + }; + + it("is notApplicable when the conventional paths redirect to the homepage", async () => { + const { ctx } = site({ + robots: "User-agent: *\nAllow: /\n", + files: { + "https://example.com/license.xml": homepageVia("https://example.com/"), + "https://example.com/rsl.xml": homepageVia("https://example.com/"), + }, + }); + const result = await audit.audit(ctx); + expect(result.status).toBe("na"); + expect(result.details?.["conformanceErrors"]).toBeUndefined(); + }); + + it("is notApplicable when a conventional path answers 200 with HTML", async () => { + const { ctx } = site({ + robots: "User-agent: *\nAllow: /\n", + files: { + "https://example.com/rsl.xml": mockFetchResult( + "

Page not found

", + 200, + "text/html", + ), + }, + }); + expect((await audit.audit(ctx)).status).toBe("na"); + }); + + // True positive: a guessed path that redirects to a real RSL document is + // still found, and still reported as not discoverable. + it("still reports an RSL document a conventional path redirects to", async () => { + const moved = rslFile(); + moved.finalUrl = "https://example.com/legal/rsl.xml"; + const { ctx } = site({ + robots: "User-agent: *\nAllow: /\n", + files: { "https://example.com/license.xml": moved }, + }); + const result = await audit.audit(ctx); + expect(result.status).toBe("warn"); + expect(strings(result, "notes")[0]).toContain("no crawler is obliged"); + }); + + // True positive: an advertised licence is never filtered by the probe rule. + // A robots.txt License: line that points at HTML still fails. + it("still fails an advertised licence that is not an RSL document", async () => { + const { ctx } = site({ + robots: "License: https://example.com/a.xml\n", + files: { + "https://example.com/a.xml": mockFetchResult( + "Terms", + 200, + "text/html", + ), + }, + }); + const result = await audit.audit(ctx); + expect(result.status).toBe("fail"); + const errors = strings(result, "conformanceErrors").join(" "); + expect(errors).toContain("root element is not "); + expect(errors).toContain("application/rsl+xml"); + }); + it("fails a wrong namespace", async () => { const { ctx } = site({ robots: "License: https://example.com/a.xml\n", diff --git a/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.ts b/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.ts index 7ab5976a..7be33e70 100644 --- a/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.ts +++ b/packages/core/src/audits/access-crawl-control/rsl-licensing-terms-conformance.ts @@ -32,6 +32,19 @@ const CONVENTIONAL_PATHS = ["/license.xml", "/rsl.xml"]; /** Documents fetched. A site with more licence pointers than this has a different problem. */ const MAX_CANDIDATES = 3; +/** + * Whether a probed body is an RSL document at all. + * + * Only the conventional-path probe uses this. A guessed path that redirects + * to the homepage, or answers a soft 404 with HTML, carries no licence, and + * grading that page against the RSL schema reports a defect in a document the + * site never published. An advertised candidate skips this check: the site + * said a licence is there, so a body that is not one is a finding. + */ +function carriesRsl(body: string): boolean { + return cheerio.load(body, { xmlMode: true })("rsl").length > 0; +} + interface Candidate { url: string; /** How it was found, named the way the finding quotes it. */ @@ -167,6 +180,9 @@ export class RslLicensingTermsConformanceAudit extends Audit { const result = await probeRsl(ctx, url, { followRedirects: true }); if (!result || result.status !== 200 || result.body.trim() === "") continue; + // A guess is evidence only of what it found. A redirect to the + // homepage or an HTML soft 404 is no licence, so it is absent. + if (!carriesRsl(result.body)) continue; candidates.push({ url, channel: `probe of ${path}`, From 89783bf6452d3a5373eaf1899cdbb8ee3f6a8227 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 10:08:04 +0000 Subject: [PATCH 07/36] fix(core): read a redirected root .txt probe as absent Random root .txt paths that 301 to the homepage were reported as an SPA catch-all, because the audit counted the homepage's final 200. A probe whose final path differs from the requested one now counts as absent. An HTML 200 at the requested path still fails. --- .changeset/root-text-probe-redirects.md | 5 ++ .../root-text-file-resolution-integrity.md | 21 ++++++ ...oot-text-file-resolution-integrity.test.ts | 66 +++++++++++++++++++ .../root-text-file-resolution-integrity.ts | 41 ++++++++++-- 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 .changeset/root-text-probe-redirects.md diff --git a/.changeset/root-text-probe-redirects.md b/.changeset/root-text-probe-redirects.md new file mode 100644 index 00000000..f646b1da --- /dev/null +++ b/.changeset/root-text-probe-redirects.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Stop failing `machine-discovery/root-text-file-resolution-integrity` when random `.txt` paths redirect to the homepage. A probe that is redirected to a different path now counts as a missing file, the same as a 404. diff --git a/docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md b/docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md index 33ca7633..23ff72b1 100644 --- a/docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md +++ b/docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md @@ -84,6 +84,19 @@ the key-file mechanism. The AI-consumer link in the FAQ is a vendor claim, as the dossier itself records; this audit rests on the byte-comparison behaviour, which is documented, not on that claim. +### A probe redirected to another path reads as absent (2026-10-06) + +The sketch follows redirects and asserts the final status. A large retail site +redirected every unknown root `.txt` path to its homepage, so both probes ended +on a 200 HTML page and the audit reported an SPA catch-all. The 200 belonged to +the homepage, not to the `.txt` path. The mechanism this audit rests on is a +catch-all that answers 200 at the requested path, so that a real key file and +a missing one look the same. A redirect to another path does not do that: a +real IndexNow key file answers 200 at its own path, and a missing one answers +a redirect. A probe whose final URL has a different path from the one +requested now counts as absent, like a 404. A redirect that keeps the path, such as +a scheme or host change, is still followed and judged on its final answer. + ## Deferred - **Consuming `details.discoveryProbeReliable` from other audits.** The flag is @@ -97,3 +110,11 @@ which is documented, not on that claim. - **WAF challenge-page detection.** A challenge page answering 200 is caught by the HTML classification arm; telling a challenge apart from an app shell needs signals this audit does not collect. +- **Scoring only sites that publish a root `.txt` artifact.** The sources + document the cost of a catch-all for IndexNow key verification. They do not + document a cost for a site that uses no IndexNow key and no other root `.txt` + file. Narrowing the verdict to those sites would need a way to observe that a + site relies on a key file. The key is not public and the audit never guesses + one (see above), and `llms.txt` and `security.txt` reliance has its own + audits. Until such a signal exists the audit judges the origin's behaviour on + every site, as the "What it checks" section states. diff --git a/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.test.ts b/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.test.ts index 3cc2c13b..92bbfd02 100644 --- a/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.test.ts +++ b/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.test.ts @@ -120,6 +120,72 @@ describe("RootTextFileResolutionIntegrityAudit", () => { expect(r.details?.["discoveryProbeReliable"]).toBe(false); }); + // A random .txt that 301s to the homepage is answered by a redirect, not by + // a catch-all. The fetcher follows it, so the final answer is the + // homepage's 200 HTML, but the .txt path itself does not serve a file. + const toHomepage = (url: string): FetchResult => { + const result = mockFetchResult( + "Home", + 200, + "text/html", + ); + result.url = url; + result.finalUrl = "https://example.com/"; + result.redirectChain = [ + { status: 301, from: url, to: "https://example.com/" }, + ]; + return result; + }; + + it("reads a probe redirected to another path as absent", async () => { + const { result } = run({ probe: (url) => toHomepage(url) }); + const r = await result; + expect(r.status).toBe("pass"); + expect(r.details?.["discoveryProbeReliable"]).toBe(true); + expect(r.found).toContain("redirected away"); + }); + + // True positive: a 200 HTML answer at the requested path is still a + // catch-all, even when the fetcher reports the final URL. + it("still fails an HTML 200 served at the requested path", async () => { + const { result } = run({ + probe: (url) => { + const r = mockFetchResult( + "App", + 200, + "text/html", + ); + r.url = url; + r.finalUrl = url; + return r; + }, + }); + const r = await result; + expect(r.status).toBe("fail"); + expect(strings(r, "failures").join(" ")).toContain("SPA or HTML catch-all"); + }); + + // A redirect that keeps the path (http to https, apex to www) is not a + // redirect away. Its final answer is still judged. + it("judges a same-path redirect on its final answer", async () => { + const { result } = run({ + probe: (url) => { + const r = mockFetchResult( + "App", + 200, + "text/html", + ); + r.url = url; + r.finalUrl = url.replace( + "https://example.com", + "https://www.example.com", + ); + return r; + }, + }); + expect((await result).status).toBe("fail"); + }); + it("classifies a text body served as text/html as a wrong content type", async () => { const { result } = run({ probe: (_url, index) => diff --git a/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.ts b/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.ts index ec6fa4f9..0ad12ea0 100644 --- a/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.ts +++ b/packages/core/src/audits/machine-discovery/root-text-file-resolution-integrity.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import type { AuditMeta, AuditResult } from "../../types"; import { Audit } from "../../audit"; import type { CheckContext } from "../../check-context"; +import type { FetchResult } from "../../fetcher"; import { weightForGrade } from "../../scorer"; import { isSafeUrl } from "../../url-utils"; import { sharedProbeUrl } from "../../gatherers/discovery"; @@ -17,6 +18,24 @@ function randomProbeName(): string { return `${randomBytes(16).toString("hex")}.txt`; } +/** + * True when the fetcher followed the probe to a different path. + * + * A redirect away answers "not here" at the requested path, which a client + * can tell apart from a file served there. Counting the destination's 200 + * reported a homepage redirect as a catch-all. A redirect that keeps the + * path (scheme or host change) is not "away" and is judged on its final + * answer. An empty `finalUrl` means the fetcher reported none. + */ +function redirectedAway(url: string, result: FetchResult): boolean { + if (!result.finalUrl) return false; + try { + return new URL(result.finalUrl).pathname !== new URL(url).pathname; + } catch { + return false; + } +} + export class RootTextFileResolutionIntegrityAudit extends Audit { static override meta: AuditMeta = { id: "machine-discovery/root-text-file-resolution-integrity", @@ -104,10 +123,18 @@ export class RootTextFileResolutionIntegrityAudit extends Audit { ); const failures: string[] = []; + const redirected = probes.filter((probe) => + redirectedAway(probe.url, probe.result), + ); const answering = probes.filter( - (probe) => probe.result.status >= 200 && probe.result.status < 300, + (probe) => + !redirected.includes(probe) && + probe.result.status >= 200 && + probe.result.status < 300, + ); + const absent = probes.filter( + (probe) => redirected.includes(probe) || ABSENT.has(probe.result.status), ); - const absent = probes.filter((probe) => ABSENT.has(probe.result.status)); if (answering.length > 0) { // Classify what the catch-all is, because the three kinds have three @@ -139,7 +166,7 @@ export class RootTextFileResolutionIntegrityAudit extends Audit { `${answering.length} of 2 random .txt probes answered ${answering.map((probe) => probe.result.status).join("/")} instead of 404 — ${kind}`, ); } else if (absent.length < probes.length) { - const other = probes.filter((probe) => !ABSENT.has(probe.result.status)); + const other = probes.filter((probe) => !absent.includes(probe)); failures.push( `A random .txt probe answered ${other.map((probe) => probe.result.status).join("/")}, which is neither 404/410 nor a catch-all — the origin's answer for a missing file cannot be read`, ); @@ -176,7 +203,11 @@ export class RootTextFileResolutionIntegrityAudit extends Audit { }; const expected = "Two random root .txt paths answer 404 or 410, and /robots.txt answers 200 as text/plain"; - const found = `Probes answered ${probes.map((probe) => probe.result.status).join(" and ")}; /robots.txt answered ${robots.status} as "${robotsType || "no content type"}".`; + const redirectNote = + redirected.length > 0 + ? ` (${redirected.length} redirected away from the requested path, read as absent)` + : ""; + const found = `Probes answered ${probes.map((probe) => probe.result.status).join(" and ")}${redirectNote}; /robots.txt answered ${robots.status} as "${robotsType || "no content type"}".`; const displayValue = discoveryProbeReliable ? "Probes reliable" : "Probes unreliable"; @@ -209,7 +240,7 @@ export class RootTextFileResolutionIntegrityAudit extends Audit { return { ...this.pass( - "Both random .txt probes 404ed and /robots.txt is text/plain, so a root .txt file that answers 200 is really there.", + "Both random .txt probes resolved as absent and /robots.txt is text/plain, so a root .txt file that answers 200 is really there.", expected, found, ), From c57d0ed3969fcb47abdd48a187138c1dae2137eb Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 10:17:09 +0000 Subject: [PATCH 08/36] fix(core): stop reading a form Turnstile loader as a bot wall A readable storefront that loads the Turnstile api.js for its forms still drew three findings. cart-handoff-reachability now fails only a cart that is a challenge page or only a widget. no-bot-detection passes a loader on a page the scan could read. no-blocking-captcha matches vendor script src and widget elements instead of the vendor name, so inline config, CSP lists and prose no longer match. --- .changeset/turnstile-form-scope.md | 5 +++ .../access-crawl-control/no-bot-detection.md | 12 ++++++ .../cart-handoff-reachability.md | 16 ++++++++ .../operability-safety/no-blocking-captcha.md | 20 ++++++++++ .../no-bot-detection.test.ts | 17 ++++++++ .../access-crawl-control/no-bot-detection.ts | 34 +++++++++++++--- .../cart-handoff-reachability.test.ts | 40 ++++++++++++++++++- .../cart-handoff-reachability.ts | 22 +++++++++- .../no-blocking-captcha.test.ts | 31 ++++++++++++++ .../operability-safety/no-blocking-captcha.ts | 27 ++++++++----- .../real-page-corpus.test.ts.snap | 30 +++++++------- 11 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 .changeset/turnstile-form-scope.md diff --git a/.changeset/turnstile-form-scope.md b/.changeset/turnstile-form-scope.md new file mode 100644 index 00000000..8e3387bc --- /dev/null +++ b/.changeset/turnstile-form-scope.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Stop treating a Cloudflare Turnstile loader on a readable page as a bot wall. `agentic-commerce/cart-handoff-reachability` fails only a cart that is a challenge page or only a widget, `access-crawl-control/no-bot-detection` passes a loader on a page the scan could read, and `operability-safety/no-blocking-captcha` matches widget scripts and markup instead of any mention of the vendor name. diff --git a/docs/evidence/audits/access-crawl-control/no-bot-detection.md b/docs/evidence/audits/access-crawl-control/no-bot-detection.md index b234412f..db24f139 100644 --- a/docs/evidence/audits/access-crawl-control/no-bot-detection.md +++ b/docs/evidence/audits/access-crawl-control/no-bot-detection.md @@ -82,6 +82,18 @@ a pause is the only way to get a verdict from a rate-limited origin. ## Implementation deviations +### A loader on a readable page passes (2026-10-06) + +The page-script branch warned at weight 1 on any page whose HTML contained a +challenge host, including `challenges.cloudflare.com`. The counter-evidence +grades that sub-signal D, and the required fix says to warn only when the +widget gates the main content. A loader is now reported only on a page that +served no readable text, using the per-page `renderedByPage` verdict from the +scan evidence (`pageRendersText` when it is absent). A loader on a page the +scan read is named in a pass, because no wall was observed and the page was +read. The `wafProtection` branch, which carries the A grade, is unchanged. +Script-`src` scoping and a UA probe are still open items from the review above. + - 2026-10-06 — the shared WAF detector no longer treats the `challenges.cloudflare.com` hostname as proof of an access wall. A live fetch of a storefront returned HTTP 200 with the storefront HTML and a diff --git a/docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md b/docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md index 085e108c..55250001 100644 --- a/docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md +++ b/docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md @@ -73,6 +73,22 @@ Tier per evidence policy: **scored** — grade B meets the A/B bar required for - **Without a platform fingerprint the audit is not applicable, not failing.** It still probes `/cart` and `/checkout`, but when neither answers it reports `notApplicable`: the paths were a guess. With a fingerprint, an unanswered cart path is a failure, because the platform's own convention says where it should be. - **The JS-only arm needs both halves.** A document warns only when its visible text is under 200 characters _and_ it carries no `