Skip to main content

Releases

Release history and changelogs

All releases up to v0.1 will be in the stable channel.

Release channel

Release History

v0.0.118Oct 19, 2026Latest

Release Notes

A release about reading pages more the way a browser does. The accessibility checks that skip hidden controls now read the page's own <style> blocks, so a control a <style> rule hides, such as a closed modal, is skipped as a browser skips it, and accessibility scores move with it. A local audit detects technologies from more of its response headers, a charset sent only in the Content-Type header counts, a redirected page's canonical is judged at its target, four more schema rules follow @id references, a CAPTCHA widget rendered client-side is recognised, and the link checks never request a link whose fragment carries a sign-in token.

Thanks to @WilliamK112, whose pull requests are behind the content/hidden-text and npm install fixes below, and who also hardened the plan lookup, tidied the crawler's page events, kept the squirrel analyze tests away from a developer's real project databases and made CI check that the compiled CLI carries the rules version it was built from. Thanks also to @deniskern, whose reports are behind the hidden-control, security/form-captcha and security/csp-blocks-own-resources fixes.

Pricing

  • squirrel keys create now says how an audit signed in with the new key is billed: no audit or page charge, links checked on the runner, cloud renders 2 credits each (not printed with --json). That billing is not new; the CI guide now spells it out.
  • A dead-link check called directly through the API, outside an audit (POST /v1/services/dead-links with no run), costs 1 credit for each distinct link it verifies, and nothing for a link it cannot verify or does not answer. Links that differ only in their query string or fragment count as one. It used to be free for any caller until 2026-11-12. That free period now applies only to a squirrel CLI from before v0.0.114 signed in with squirrel auth login; from 2026-11-12 those calls are refused and the CLI checks its links itself. See Credits and pricing.
  • A completed cloud audit is refunded for the external links it was charged for that its report lists as not checked because the link check ran out of time.

Changed

  • A link whose fragment carries a credential, written as key=value pairs such as an OAuth sign-in redirect's #access_token=…&token_type=bearer, is now treated like any other token link. The external and internal link checks never request it, and a signed-in audit never sends it to the cloud's link check. The report lists it as not requested, with the fragment left off. On your own site, the crawl can still load the page such a link points to, since a fragment is never sent with a request. A plain anchor (#pricing), a hash route (#/settings) and ordinary state in the fragment (#q=shoes) are checked as before. See What the internal link check never requests.
  • The squirrelscan agent skill that ships with the CLI, which squirrel skills, squirrel setup and squirrel mcp hand out, is now version 1.15. Its access-gateway advice now says that cloud browser renders send a website's custom request headers, so a gated site can keep rendering on. A signed-in CLI audit's cloud renders still never carry -H headers.
  • A published report now carries linkTargetSets: for links/broken-links and links/broken-external-links, hashes of the targets each one reported broken and of those it could not verify or did not request, taken before the report is sampled. Each set holds up to 2,000 hashes and says whether it is complete. A later comparison can then tell a link that broke since the previous audit from one that was already broken, even on a site with more broken links than the report lists. No format renders them, and scores do not change. See Reports.

Fixed

  • The accessibility checks that skip hidden controls (a11y/aria-hidden-focus, a11y/button-name, a11y/form-labels, a11y/aria-labels, a11y/aria-command-name, a11y/aria-dialog-name and a11y/aria-input-field-name) now read the page's <style> blocks, not only inline styles. A control or dialog that a <style> rule hides with display: none or visibility: hidden, such as a closed modal, is skipped as a browser skips it, instead of being reported.
    • squirrel settles a rule itself only when it sits outside any at-rule, in a <style> with no media (other than all or screen) and no title, its declarations are plainly written, and each of its selectors is one compound of a tag, an id, classes and plain attribute tests (.modal, div#menu.closed, [data-state=closed]). It applies those as a browser does: !important first, then an inline style over a rule, then specificity, then source order.
    • Any other <style> rule that sets display or visibility, whatever the value, can make the unnamed controls it could apply to, and those inside them, unconfirmed: a warning rather than a failure. That covers a selector with a combinator (.nav .item) or a pseudo-class (:hover), a rule inside @media, @supports or @layer, and a rule whose declarations squirrel cannot read. A rule using :is(), :where() or :has() can reach any element, so it can make any unnamed control on the page unconfirmed. Hiding that comes from a <style> block on a page that also loads an external stylesheet, which may undo it, is unconfirmed too.
    • On a page whose styles squirrel reads in full (for example, no external stylesheet or @import), a control inside a container whose inline style sets visibility: hidden now counts as hidden instead of unconfirmed.
    • Some controls that were skipped or unconfirmed are now checked, because a <style> rule shows them again, as a browser does: a display value on an element with the hidden attribute, a closed <dialog> or a popover, an !important rule that beats an inline display: none, or visibility: visible on a control inside a hidden container. A control hidden only by its inline style is unconfirmed where an undecided !important rule, or an animation that sets visibility, may show it.
    • Accessibility scores change in both directions. Unconfirmed rows now read may be hidden by a style (unconfirmed) instead of may be hidden by an inline style (unconfirmed), and their items say a style on it or an ancestor sets display or visibility in a way squirrel cannot settle.
    • Known limits, tracked in #938: a :root block of custom properties whose names or values mention display, such as WordPress global styles with a "Display" typography preset, can make every unnamed control on the page unconfirmed. A <style> rule that shows a control is applied even when an external stylesheet may hide it again.
    • See a11y/aria-hidden-focus.
  • An audit no longer keeps an earlier audit's accessibility failure open, as a carried finding, on a page where the same check now reports the control as unconfirmed. This affected the seven hidden-control checks above once they began reading <style> blocks: the page showed both the stale failure and the new unconfirmed warning. An unconfirmed row now counts as the check having run on that page, so the earlier failure is closed unless the page still fails that check, and an earlier unconfirmed warning is closed once the check passes there. A page a rule did not check at all keeps its earlier findings. The fix text of those seven rules now describes how <style> blocks are read.
  • Technology detection in a local audit sees more of the response headers a signature reads. The crawler kept only a fixed set of headers, so a header-only signature such as X-CDN: Imperva, X-Sucuri-ID or X-Varnish never matched. The crawler now stores those headers with each page, each kept value cut to 512 characters, and the CLI passes them to the local scan. A signed-in audit also sends them to the cloud detection, together with X-Powered-By, X-Generator, X-AspNet-Version and the names of the cookies a page sets (up to 50). A header a signature only checks for presence, such as a request id or a bot protection token, is kept as a marker, not its value, and cookie values are never sent. A page an earlier version stored, and the crawl cache reuses, is read with only the headers it kept until it is fetched again. See Technology Detection.
  • core/charset now sees the charset of the Content-Type response header in a real audit. The crawler kept only the media type, so a page declared UTF-8 by its header alone failed with No charset declaration found, and a header that disagreed with the page's meta about UTF-8 was never reported. It now warns, for example Conflicting charset declarations: header iso-8859-1, page utf-8. A page an earlier version stored and the crawl cache reuses is read as before until it is fetched again. See core/charset.
  • core/canonical and crawl/pagination judge a URL that answers with an HTTP redirect by the URL it ended on, as i18n/hreflang already does. /en/old-slug redirecting to /en/new-slug, whose canonical is https://example.com/en/new-slug, no longer reads "Canonical points to different URL", and a paginated URL that redirects is tested, and its relative canonical resolved, at its target. See core/canonical.
  • schema/breadcrumb, schema/local-business, schema/website-search and schema/rating-scope follow JSON-LD node references ({"@id": …}) to the node they name on the same page, as schema/faq, schema/product and schema/review already do. A breadcrumb whose items are references no longer reports items missing their position and name, a business whose address is a reference no longer reads as an incomplete address, a referenced SearchAction is found, and a referenced rating is read for the entity that carries it. A reference to a node the page does not have is judged as written. See schema/breadcrumb.
  • security/form-captcha recognises a CAPTCHA widget rendered client-side. @nuxtjs/turnstile and React reCAPTCHA components mount the widget after hydration, so the static HTML holds only the site key, for example Nuxt's turnstile:{siteKey:"…"} runtime config. A Turnstile, reCAPTCHA, hCaptcha or Friendly Captcha site key, under the provider's name and in that provider's key format, in an inline or JSON data script now protects the page's single public form, like a provider script does, unless another form on the page has a CAPTCHA of its own. A script that only mentions a provider, or switches it off, still does not. See security/form-captcha.
  • security/csp-blocks-own-resources no longer warns about, or fails on, a host that only the page's same-site scripts or preloaded chunks name as a script to load. SDKs ship fallback URLs that load only behind a feature flag, so the page may never request them. That includes a known vendor's host (analytics, ads, payments, CAPTCHA, forms or booking) that only a chunk names, which used to fail the check. Such hosts are now listed in an informational csp-chunk-only-hosts note. A host the page's HTML loads, or that a vendor it uses needs, is reported as before. See security/csp-blocks-own-resources.
  • content/hidden-text reads CSS comments the way a browser does. A comment before a property or its colon (.promo { /* hide */ display: none }) used to stay in the property name, so the declaration was never recognised, in <style> rules and inline style attributes alike. A <style> rule with a comment before or inside its selector, such as a /* Section */ comment between rules, was not applied either, here or in mobile/input-zoom. Text and links hidden either way now count, as other hidden text does. See content/hidden-text.
  • WAF detection stops treating text as quoted at the end of the block an unclosed <code>, <kbd> or <samp> sits in. Since 0.0.117, an unclosed <code> could hide a block page's wording, such as an Imperva incident line, for the rest of the page, so the block page went unrecognised. A block nested inside a code block, as Expressive Code and Starlight write each line, still reads as quoted.
  • The console, llm and XML reports say how many items a check left out, as the HTML, markdown and text reports do. The console's ... +N more after the first 5 items now counts the items the audit left out too, and a check that lists none reads 1,500 items not listed. The llm format no longer drops the count for a rule whose checks list no items (Items (0/1500): none listed), and the XML gives it as a not-listed attribute on the check's <items> element. Scores do not change. See Reports.
  • A signed-in audit checks on your machine a link the cloud link checker refused to request because its name resolves to a private address, when the checker says so, instead of listing it as status unverifiable. An audit of a site on a private network gets the link's real status, and a public site's audit still never requests it and lists it as refused. The cloud's refusal is never itself reported as a broken link. See External links.
  • The npm package's postinstall download follows at most three redirects, refuses a download or redirect URL that is not HTTPS, and resolves a relative Location against the URL that sent it.

Docs

  • The CI guide says what an API-key audit costs, and Credits and pricing gives the price of a dead-link check outside an audit.
  • The README and the docs home count 8 Mobile rules.

Downloads

Windows
Linux
v0.0.117Oct 17, 2026

Release Notes

A release about reading pages the way browsers and search engines do. The new mobile/input-zoom rule finds the form fields that make iOS Safari zoom in, so squirrelscan now ships 309 rules. A strict CSP built on a nonce or 'strict-dynamic' is no longer called weak, the rules that ask whether robots.txt blocks a page read it the way Google does, a sitemap with a mismatched tag or a raw & gets caught, and the FAQ, product and review checks follow JSON-LD @id references to the nodes they name. A site missing compression or caching headers is scored for each once instead of twice, squirrel analyze stops stacking a second copy of every result, and a signed-in audit no longer pays for a link the cloud could not verify.

Thanks to @deniskern, whose reports are behind the schema/faq, crawl/pagination and i18n/hreflang fixes below.

Added

  • New rule mobile/input-zoom: on an iPhone, focusing a text field, select or textarea under 16px makes Safari zoom the page in. The rule reads field sizes from inline styles and <style> blocks and lists up to 20 small fields, each with its size and where it came from, such as 14px (inline style). A field nothing on the page sizes, on a page with no external stylesheet, gets iOS's own 11px (iOS default). A field it cannot read plainly (its size may come from an external stylesheet, a media query, conflicting rules or a relative unit) is counted as not read rather than guessed at, so on many sites built with a CSS framework most fields are not read yet. A page that sets maximum-scale=1 or user-scalable=no in its viewport tag to stop the zoom still gets the warning, with a note that the workaround blocks zoom for everyone. See mobile/input-zoom.
  • perf/total-byte-weight also reports two site figures, for information only: the site total (every scored page's HTML plus each measured external resource once, however many pages share it) and the average page weight. They are at the end of the message (Not scored: site total 80KB across 19 pages, shared files counted once; average page 4KB) and in the details as siteTotalKb and pageAverageKb. They never change the pass, warn or fail result, which is still the heaviest page's, and an audit whose page weights are only estimated reports neither. See perf/total-byte-weight.
  • crawl/sitemap-valid warns about the <lastmod> values of sitemap URLs that are outside the W3C Datetime format the sitemap protocol requires, such as 2024/09/10, 2024-9 or Tue, 10 Sep 2024 12:00:00 GMT (sitemap-lastmod-format), with a sample of URLs and values. A datetime with no zone (2024-09-10T12:00:00) is named as missing its zone (sitemap-lastmod-zone). A search engine may ignore a lastmod it cannot read. See crawl/sitemap-valid.
  • [rules] time_budget_ms in squirrel.toml sets the rule time budget for squirrel audit: how long one rule may spend on one page (its synchronous work) before it is abandoned for that page and the audit moves on (default 1000 ms). It is a backstop against a page built to make a pattern match slowly. Raise it if rules time out on ordinary pages on a slow or busy machine, such as a shared CI runner. 0 turns the budget off. A value below 1000 is refused for now, with time_budget_ms must be 0 (off) or at least 1000. Cloud audits keep the default. See Rule time budget.

Pricing

  • A signed-in audit, cloud or CLI, is no longer charged for an external link squirrelscan's cloud link checker could not verify: one behind a bot wall, a throttled one, one the run's limit on deeper checks for its site left unverified, or a 404 waiting for its confirming check. The report lists those as unverified, as before. A link the checker did not answer in time is not charged either. Only links that work, redirect or are broken cost a credit (at most 1 per page): each batch of links is charged before it is checked, then settled down to the links the checker verified. The settling is done by squirrelscan's cloud, so it applies to every CLI version that pays for cloud link checks. A link to another of your own domains is external and is charged the same way. See Credits and pricing.
  • The CLI's pricing text says the same: 1 credit per external link the cloud verifies, not per link it checks. That covers squirrel credits, the low-balance and credit wall lines, the one-time link pricing notice, the audit estimate and the billing note of the MCP audit_website tool.
  • squirrel credits no longer lists issue_enrich (3 per issue). AI issue enrichment is switched off in squirrelscan's cloud, so nothing is sold or charged under it today. The keyword and content gap add-ons are listed as before.

Changed

  • links/broken-external-links says why a link could not be verified. A link that answered 404 once and waits for its confirming check reads (unverified: awaiting confirmation) on the unverifiable row: it is likely broken, not blocked. Other items keep (unverified) or (unverified: {vendor}). Each item also carries meta.unverifiedReason when the checker knew why: blocked for a wall or challenge, unreachable for a timeout or failed connection, or awaiting-confirmation. An unverifiable link squirrel checks from your machine gets blocked, or unreachable when its check timed out. The cloud link checker sends all three. The reason is stored with the audit, so squirrel report and squirrel analyze keep it. See broken external links.
  • squirrel audit and squirrel report say how many items a check left out in their HTML, markdown and text output, not only how many they list. A check that lists 20 of its 26 items now reads 20 items, 6 more not listed in HTML and 20 item(s), 6 more not listed in markdown, and the text format prints ... and 6 more items not listed after the list. A check that lists no items but left some out says so, such as 1,500 items not listed, where it used to say nothing. The count comes from the check's details.additional, which the JSON and LLM formats already reported. A check with nothing left out reads as before.
  • A report published from the CLI carries the exact number of pages a rule fails or warns on when its issues reach different pages, in a new top-level rulePageCounts field of the JSON. Each issue lists a sample of its pages beside its own true count, but the pages two issues of one rule leave out cannot be told apart, so a rule with one issue on 46 pages and another on 4 different pages could only be counted as at least 46. The count is taken before the report is sampled, over this run's checks (findings carried over from an earlier audit are left out, as in the check tallies), and it is listed only for the rules that need it. See Output formats via URL.

Fixed

  • squirrel analyze now replaces a crawl's stored rule results. It used to add a second copy of every check beside the ones the audit, or an earlier analyze, had stored, and squirrel report then showed the earlier run's items, so a rule change looked like it did nothing.
  • crawl/sitemap-valid no longer passes a sitemap with a mismatched closing tag (</lox> for </loc>), an element left open or a raw & in a <loc> or other element text. It now fails its sitemap-syntax check with an error that names the problem and where it is, such as XML is not well formed: char '&' is not expected. (line 6, column 49). The URLs squirrel can still read are kept. Whitespace before the XML declaration is tolerated, and a sitemap that declares its own entities in a DOCTYPE is not checked. See crawl/sitemap-valid.
  • crawl/robots-meta-conflict reports a page that robots.txt blocks and that carries noindex only in an X-Robots-Tag header, and lists the pages on its finding. It decides "blocked by robots.txt" the way Google does: the longest matching Allow or Disallow wins, an Allow wins a tie, and * and $ in a rule are honoured. Before, Disallow: /dir/ plus Allow: /dir/ok counted /dir/ok as blocked, and Disallow: /*.php matched nothing. The same decision serves crawl/indexability-conflicts, crawl/all-noindex-pages, crawl/schema-noindex-conflict and the GraphQL probe's robots.txt check. The crawler's own robots.txt handling, used when respect_robots is on, is unchanged. See crawl/robots-meta-conflict.
  • security/csp no longer reports a strict CSP as weak. In a script-src (or default-src) that also lists a nonce or a hash, or 'strict-dynamic', 'unsafe-inline' no longer warns csp-unsafe-scripts, and next to 'strict-dynamic' a wildcard host such as * no longer warns csp-wildcard: browsers ignore those sources (CSP Level 3). So a strict policy of the shape Google recommends, script-src 'nonce-…' 'strict-dynamic' https: 'unsafe-inline', gets no warning. 'unsafe-eval' still warns. See security/csp.
  • security/sri no longer reports the scripts and stylesheets Shopify injects into every store from cdn.shopify.com/shopifycloud/ and cdn.shopify.com/storefront/, which Shopify updates in place so no store can pin a hash. Theme and app assets elsewhere on cdn.shopify.com are still checked. A new exempt_hosts option exempts other hosts whose files change at the same URL, by exact hostname: [rule_options."security/sri"] exempt_hosts = ["static.klaviyo.com"]. See security/sri.
  • A site with no compression or no caching headers is no longer scored twice for the same server setting. perf/compression and perf/cache-headers score it page by page, and the bad-caching-compression and bad-caching-freshness checks of perf/bad-caching now report the site's share at info status, which does not move the score. A site missing both now scores a little higher than before: a clean five-page test site served with neither went up 2 points in our smoke test. bad-caching-validators is unchanged. See perf/bad-caching.
  • A page that quotes a WAF's signature is no longer attributed to that WAF or read as its challenge page. A tutorial printing <script src="https://js.datadome.co/tags.js"> in a code block, a support page showing Incapsula incident ID: 1234-5678 in one, or a troubleshooting page that mentions /cdn-cgi/challenge-platform/ could name the provider in the report's technologies, or be set aside as a challenge page and left out of page-level scoring. Script and markup signatures now count only inside a tag (not a link) or an inline script, and block-page wording only outside <code>, <kbd> and <samp> elements, comments and scripts. Real block and challenge pages are recognised as before. See Bot protection or WAF challenge.
  • content/placeholder-text now reads the page's <title> too. A title where undefined, null or NaN fills a whole segment between spaced separators (undefined | Acme) warns, and one holding [object Object] fails, in a new placeholder-title check. A page about the word, whose first <h1> or one of its <code> elements is exactly that word, such as MDN's undefined - JavaScript | MDN, is not reported. See content/placeholder-text.
  • content/mojibake reads the same rendered prose as content/placeholder-text. A sequence that appears only inside a <template> or a code-editor or syntax-highlighter container (CodeMirror, Monaco, highlight.js, Shiki, Prism) is no longer reported, and adjacent blocks such as table cells no longer fuse into a false sequence. See content/mojibake.
  • perf/dom-size counts elements and depth from <body>, as its widest-element check already did and as Lighthouse's DOM size audit does. <head> metadata such as hreflang alternates and preload hints no longer adds to a page's count or depth, so a small page with a large head reports the body's numbers. The total is the elements inside <body>, and <body> is depth 1, so a page's depth usually reads one level less than before. Thresholds are unchanged. See perf/dom-size.
  • i18n/hreflang no longer reports "No self-referencing hreflang found" on a URL that answers with an HTTP redirect to a page that references itself. The crawl reads the redirect target's tags, so the self reference is now compared with the URL the redirect ended on. A redirected page's other hreflang findings, such as a missing x-default, are about the target's tags and stay. See i18n/hreflang.
  • crawl/pagination no longer warns that /blog?page=1 canonicalizes to /blog. Page 1 of a list is the same content as the unpaginated URL, so that canonical is correct. Page 2 and later still warn when their canonical points at a URL with no page marker. See crawl/pagination.
  • schema/faq no longer reports every question of a Yoast-style FAQ as missing its name and answer. When mainEntity lists node references ({"@id": "…#q1"}) to Question nodes elsewhere in the page's JSON-LD, the rule now reads the nodes they name, and a null entry counts as an invalid question instead of breaking the check. schema/product does the same for its first offer, and schema/review for review entries and aggregateRating, judging a review both referenced and present as its own node once. A reference to a node the page does not have is still reported. See schema/faq.
  • The sitemap lastmod checks (crawl/sitemap-lastmod-drift, crawl/sitemap-lastmod-churn) and the cloaking probe read dates outside the W3C format more carefully. A time range such as Sep 10 2024 12:00-14:00 used to be read as 12:00 at offset -14:00, and a value with a time range like that is now not read. An overnight range such as 22:00-02:00 cannot be told from a time and its offset, and still reads as one. A local time that daylight saving skips, such as 2024/03/10 02:30, now reads as written on every machine. It used to come out an hour off on a machine in a zone that skips it.
  • The XML report squirrel audit writes now states the scan scope, as the other formats do. Inside <site>, <scan-scope origin="cli" crawled="42" max-pages="100" capped="false"/> says where the audit ran, how many pages it crawled, the page limit in force and whether that limit stopped the crawl. requested-max-pages is added when the requested limit was lowered. A report from before scan scope existed renders as before. See Reports.
  • An entity map published from the CLI that has more edges than the publish cap (1,500) now keeps the edges between its best-ranked entities. It used to keep the first 1,500 in alphabetical order, which left late-alphabet entities unconnected. A dangling reference ranks with the entity that makes it, so a problem entity's dangling edges are kept with it.

Docs

  • The README and the docs home page carry one feature list, in the same order, each item linked to its page. The rules configuration page lists all 22 rule categories, the content rules page drops the retired content quality score, and the dashboard audit settings page no longer says only owners and admins can change them.
  • The cloud firewall, crawler and crawl settings pages say that cloud browser renders send a website's custom request headers, pinned to the rendered page's own origin, so a gated site no longer needs rendering turned off. A signed-in CLI audit's cloud renders still never carry -H headers.
  • The XML examples in the docs match what the renderer writes.
  • The docs check now fails on dead internal links, dead #anchors, missing anchors that other pages link to, and rule Options rows with an unknown type or undefined default, and the dead anchors it found are fixed.
v0.0.116Oct 16, 2026

Release Notes

A release about link results and local sites. Cloudflare email-protection links no longer count as broken links, a public page's leftover link to a local-only name is flagged again, a .test or .local site typed without a scheme is audited over http, and the external link check shows its progress as it runs.

Changed

  • The external link check now reports its progress while it runs, so the CLI's "Checking external links" progress counts up as links are answered, where before it showed no count until the check ended. The check can also be given a time budget, which squirrelscan's cloud audits use. Links still unanswered when the budget runs out, or when one request takes too long, are reported as Not checked with reason external-links-out-of-time, never as broken, and the audit completes. See links/broken-external-links.
  • The notice a CLI audit prints when some external links could not be verified from your machine now says what gets them checked by squirrelscan's cloud link checker: a signed-in Surface or Full audit, at 1 credit per link it checks (at most 1 per page). Signing in alone is not enough, since a Quick audit checks links from your machine either way. A signed-in Quick audit now gets the notice too, naming --level surface. The --external-links help no longer suggests that some level skips external links. See Link checking.

Fixed

  • links/broken-links no longer reports Cloudflare email-protection links as broken. With Cloudflare's Email Address Obfuscation on, every mailto: link and every email address in a page's text becomes a link to /cdn-cgi/l/email-protection, which answers an error when requested, so a site with an address in its footer had one broken link per address, sourced from every page. These links are email addresses, not pages: the crawl, the internal link check and the external link check never request them, and they are read as the mailto: links they stand for. The broken-links row's details.emailProtectionLinks says how many were left out. Other /cdn-cgi/ links, such as image resizing URLs, are still checked. See links/broken-links.
  • A public page's link to a local-only name, such as a leftover http://myapp.test/ from a CMS preview, stands out again. Since 0.0.114 the external link check does not request a .test, .example or .invalid name, or one under .localdomain or .home, and lists it as info where it used to count as a broken link. content/dev-leakage now warns about them as a new local-name kind: names under .test, .example, .invalid, .local, .lan, .internal, .localdomain, .home and the other local-only zones, and single-label hosts such as intranet. .onion and .alt names are not included. An audit of a local site such as myapp.test skips the rule, so its links to other local names are not reported. reliability/broken-forms likewise reports a form that posts to one of these names as non-production-form-action, without sending it, unless the audited site is itself local. See content/dev-leakage.
  • squirrel audit myapp.test, typed without a scheme, now audits http://myapp.test/ instead of https://myapp.test/. Laravel Valet and Herd serve .test sites over plain http unless the site is secured, so the audit used to fail to connect. Every host the CLI treats as local or private now defaults to http:// the way localhost and private addresses already did: box.local, nas.lan, db.internal and the other local-only names. A public host still defaults to https://, and a scheme you type is kept. See Local audits.
  • a11y/aria-dialog-name, a11y/aria-command-name, a11y/aria-input-field-name, a11y/aria-labels and a11y/form-labels no longer report controls that are out of the accessibility tree, as a11y/button-name already does. That covers a control with the hidden attribute or aria-hidden="true", an inline display: none on it or an ancestor, an inline visibility: hidden on it, or a control inside a closed <dialog> or an unopened popover. A closed modal marked aria-hidden="true" no longer counts as an unnamed dialog. A control an inline style may hide in a form squirrel does not read exactly is reported as unconfirmed, a warning, not a failure. A closed native <dialog> is still checked, since pages open it with script. Hiding by <style> blocks and stylesheets is not read yet, so a control hidden only by a class rule is still reported. See a11y/aria-dialog-name.
v0.0.115Oct 15, 2026

Release Notes

Quick audits in the CLI now check the links that leave your site, as Surface and Full already do. At Quick they are checked from your machine and the link checks cost no credits. squirrel audit --offline stops checking anything on another site, and the inbound-link rules stop flagging the page a crawl starts on. Cloud audit defaults are unchanged in this release.

Changed

  • Quick audits in the CLI check external links too. Every audit level now checks the links that leave your site, Quick included.
    • A Quick audit checks them from your machine, signed in or not, and the link checks cost no credits.
    • A Quick audit now also requests the pages your site links to on other sites, signed out included, so it can take a little longer.
    • Skip them with --no-external-links or [external_links] enabled = false. --offline still skips them.
    • Cloud audits keep their current link check settings for now. Any change to the cloud defaults will be announced separately.
    • See Link checking defaults.
  • links/broken-external-links no longer calls every link it could not verify "blocked by WAF". Many are not a firewall: a timeout, a CDN error page or a 404 waiting for its confirming check lands in the same list. The row now reads {count} external link(s) could not be verified - status unverifiable, and each item reads (unverified), with a vendor added when the checker named one. The check name, waf-blocked-external-links, is unchanged. See broken external links.

Fixed

  • squirrel audit --offline no longer status-checks anything on another site: the third-party scripts, stylesheets and fonts the pages load, and sitemap URLs on another site, are left alone. reliability/broken-resources reports the third-party resources as not measured on an offline run, and crawl/sitemap-4xx says how many sitemap URLs on another site it skipped. Requests to the audited site can still end up on another host when the site sends them there: a page or asset redirect, or a sitemap that robots.txt lists on another host. See audit.
  • links/orphan-pages, links/weak-internal-links and links/no-contextual-inbound no longer report the page the crawl started on. When a site's root redirects to a deeper page (/en/, or a sign-in page), the redirect leads there, and often no link does, or only the logo in every header does, so the site's front door read as a page with too few incoming links. integrity/orphan-page already exempted it. See links/orphan-pages.
v0.0.114Oct 15, 2026

Release Notes

A pricing correction, and a release about trusting your link results. Version 0.0.113 said a signed-in audit's link checks were free. For the links the cloud checks, that was wrong, and this release puts it right: a signed-in audit now pays for its cloud link checks the same way a cloud audit does. Links squirrel checks on your machine stay free, and so do internal links. Beyond pricing, a link that meets a sign-in or bot wall is listed as unverifiable instead of broken, broken internal links say exactly what each audit covered, an audit of a site on your own network keeps what it finds there on your machine, and squirrel analyze fetches what its rules read the same guarded way squirrel audit does.

Pricing

  • A signed-in audit pays for its cloud link checks: 1 credit for each distinct external link the cloud's link checker answers, an unverifiable answer included, at most 1 per page of the audit's page limit, and only at the levels that send external links to the cloud (surface and full, or a custom level with cloud checks and external links on). Version 0.0.113 said these checks were free; they are not.
    • Still free: a link the cloud can't answer in time, which squirrel checks on your machine; internal links; and every link in a signed-out audit. A quick audit with --external-links checks its links on your machine and pays nothing for them.
    • Each page of the audit's page limit now reserves its link check, so the same max_credits_per_audit covers fewer pages, and starting a signed-in surface or full audit needs 1 more credit than before. The pre-audit estimate and the page cap fitted to your balance include the link checks. squirrel credits and the low-balance messages state the price, and the first signed-in audit that checks external links after you update prints a one-time notice. Skip the checks with --no-external-links.
    • An audit that can't register a run, such as one signed in with an organization API key, checks its links on your machine and pays nothing for them.
    • Older versions keep free cloud link checks until 2026-11-12. From then, they check their links on your machine until you run squirrel self update.
    • See Credits and pricing.

Security

  • An audit of a site on a private network (localhost, a private address, a .local name) no longer reads or writes the external link cache that every audit on your machine shares. A later audit of a public site could otherwise be answered from that cache about a public name that resolves to a private address. Results cached by earlier versions are not reused, so each link is checked again once after you update.
  • No audit sends the cloud's link checker a link whose URL names a private or local host (a private IP address, localhost, a .local or single-label name). squirrel checks those itself. When the cloud declines a link because it, or a redirect it leads to, is on a private address, that is never reported as a broken link.
  • squirrel analyze fetches the assets its rules read the way squirrel audit does. Its own copy of that step followed a script's redirects without checking where they led, so a script that redirected to a cloud metadata address led it to request that address. Every asset and link check, and every redirect those follow, is now held to the audit's rules. analyze also checks the internal links the crawl did not fetch, uses the crawl's own scope and page limit rather than the squirrel.toml in your current directory, and sends none of that file's custom request headers. It now also fetches fonts, preloaded scripts and web app manifests, status-checks third-party scripts, stylesheets and fonts while reliability/broken-resources is on, and no longer caches scripts between runs. See analyze.

Changed

  • A link checked from your own machine that meets a wall is listed as unverifiable, not broken. That covers a sign-in or bot wall (401, 403, 405, 406, or LinkedIn's 999), a challenge page served as a 503, and a site that never answers in time. Most of these used to be counted as broken, so a signed-out audit's broken link list was mostly links that work in a browser. They are now listed under waf-blocked-external-links in links/broken-external-links, and links/dead-links no longer counts them. A real 404 or 410, a plain 503, a domain that does not exist and a refused connection are still broken, and so are a storage bucket's 403 for a missing file, an HTTP sign-in prompt and a 405 that allows no GET. Walls and timeouts are no longer cached, so the next audit asks again, and one an earlier version cached as broken is checked again. A signed-out audit with unverifiable links says so and suggests squirrel auth login, since the cloud's link checker can usually get a real answer. See links/broken-external-links.
  • links/broken-links says what it covered on every row, the failing one included: how many internal link targets the pages link, how many the internal link check got an answer for, how many nothing fetched, and why (past the internal_links.max_targets limit, skipped by design, not trusted, or no answer). Comparing two audits can then tell a link that was fixed from one the later audit did not check. squirrel report --diff leaves these counts out of each broken link, so a change in coverage alone no longer marks every broken link as changed. A link that only a rate-limited page carries is no longer requested, or counted as skipped or past the limit. See links/broken-links.
  • Names under the reserved .test, .example and .invalid zones, and under .localdomain, .home, .onion and .alt, count as local hosts, like .local. An audit of a Laravel Valet or Herd site such as myapp.test is treated as an audit of your own machine: no publish, no cloud render, the delivery rules (such as HTTPS and caching) skipped, and its links to other local names checked from your machine. On a public site's audit, a link to one of these names is not requested and is listed as refused-external-links instead of broken. See Local audits.

Fixed

  • links/broken-links keeps the crawl's answer for a linked page the crawl fetched but does not grade as a page. A PDF that loaded no longer counts as an unchecked target, so a site that links its own PDFs can read as fully checked, and one that answered 404 is still broken. A linked page that answered the crawl with rate limiting is listed in rate-limited-links, and one that answered with a bot-wall challenge in refused-links. See links/broken-links.
  • integrity/orphan-page no longer reports the page the crawl started on as a hidden page. A site whose root redirects to a deeper page, such as /en/ or a sign-in page, had that landing page read as hidden, and as a likely injected page when it also carried a page-level signal. The opt-in cloaking probe no longer picks it either. See integrity/orphan-page.
  • crawl/sitemap-valid reads a sitemap whose root uses a prefix bound to the sitemap namespace, such as the ns0:urlset that Python's ElementTree writes by default. It used to report an unknown sitemap format and read none of its URLs. A prefixed sitemap index reads too. See crawl/sitemap-valid.
  • The sitemap date checks read every date as the period it names, the same way on every machine. A date with no time is the whole day, a datetime with no zone is read in UTC, and a date that is not a real date is left out instead of guessed at. crawl/sitemap-lastmod-drift no longer warns that a lastmod of 2024-09-10 is 253 days ahead of a page dated 2024, crawl/sitemap-lastmod-churn counts the day a lastmod was written in its own zone and says when most of a sitemap's lastmods can't be read, and the cloaking probe stops treating pages dated only with this year as old two weeks into the year. See crawl/sitemap-lastmod-drift.
v0.0.113Oct 15, 2026

Release Notes

A safety and accuracy release. The external link check no longer requests links that are actions or carry a credential, such as one-click unsubscribe links, magic sign-in links and signed downloads, and every request it sends, redirect hops included, goes only to a public address (or a private one when the audited site is private), never to a link-local or cloud metadata address. squirrel audit's asset checks can no longer be pointed at a cloud metadata service, the GraphQL probes no longer let a compressed answer inflate past their read limit, and an audit now closes each connection once it has read what it needs, so an endless body no longer keeps downloading. Sitemap dates, bot-protection names and a dozen rules report more accurately too.

Security

  • The external link check no longer requests third-party links that look like actions or carry a token. Examples are a newsletter's one-click unsubscribe link, a magic sign-in link or a signed download URL, where the URL itself is the credential and a request can use it up.
    • It uses the internal link check's rules (action words, token-shaped values, keys such as token or sig) and skips links whose text is plainly an action ("Log out", "Unsubscribe").
    • A signed-in audit does not send these links to the cloud's dead-link check either.
    • links/broken-external-links lists them in a new info check, not-requested-external-links, with tokens masked. They are never reported as working or broken, and they do not count against max_links.
    • When squirrel checks a link itself it now follows redirects by hand, so a short link that redirects into one stops at the redirect.
    • Every request and redirect hop goes only to a public address (private ones too for an audit of a private site), never to a link-local or cloud metadata address, and connects to the address it checked.
    • See What the external link check never requests.
  • The internal and external link checks also skip three kinds of link they used to request.
    • A server-script extension (such as .aspx, .php, .jsp or .do) no longer counts as a word, so /MyAccountLogOut.aspx reads as the four-word sign-out it is. Any other action word is read the same way (/delete-your-account.php, like /delete-your-account).
    • Log out, log off and sign out now count in a path segment of five words (/my-account-log-out-now). A longer slug that only mentions signing out (/blog/how-to-log-out-of-every-device) is still checked.
    • A link with a credential in its query string is never requested: a key such as api_key, x-api-key, pwd, session_id, accesstoken, authorization, jwt, x-amz-credential or x-goog-credential, in any case, with -, _ and . ignored, or in brackets (config[api_key], pwd[0]).
    • A not-requested link listed in links/broken-external-links now has a path segment holding an email address masked, where /unsubscribe/user@example.com/… used to show the address. An asset name such as logo@2x.png is kept.
  • External links refused for their address are now listed in links/broken-external-links as refused-external-links instead of silently left out, and never counted as working or broken. That covers a link-local or cloud metadata address on any audit, and a private or loopback one on a public site's audit.
    • A link refused on its URL alone is not counted as checked: it takes no slot under max_links, is not answered from the link cache (where an audit of a private site may have stored a verdict for the same URL), and is not sent to the cloud's dead-link check.
    • A re-audit no longer reports an old verdict for an external link this audit did not check.
  • squirrel audit's asset checks can no longer be pointed at a cloud metadata service.
    • A stylesheet, script, image, font, PDF or sitemap URL on the audited site is refused at the hop, before anything is sent, when it redirects to a link-local address (169.254.0.0/16, fe80::/10) or a metadata hostname, or sits on a subdomain outside the hosts the crawl itself requests whose name points at one.
    • The hosts the crawl itself requests are requested as before, so an audit of localhost, a LAN address or a .local name still loads its own assets.
    • A sitemap URL on another host is checked without your custom request headers. They used to go to whatever host a sitemap listed, Web Bot Auth signatures and Authorization included.
  • The GraphQL probes behind security/graphql-introspection and security/graphql-get-mutations no longer let a site answer with a compression bomb.
    • Their requests went out with the runtime's own decompression on, and Bun inflates a gzip or brotli body in full as it arrives, before the probe's read limit applied. A probed endpoint could cost an audit gigabytes of memory.
    • They now fetch with that decompression off, like every other request an audit sends, and decompress the answer no further than the probe's limit (1 MB for introspection, 64 KB for the mutation probe).
    • Each probe also closes its connection once its answer is read, where an endless answer used to keep downloading until the 10-second timeout.
    • Findings are unchanged. See security/graphql-introspection.
  • integrity/brand-impersonation and integrity/fake-auth-overlay no longer lose a signal to how a page's markup is spaced.
    • An off-brand "Sign in with Google" link right after <h1>Welcome</h1> used to read as welcomesign in with google, so the sign-in cue was missed and a correlated overlay dropped from fail to info.
    • The cues are now also read from element-separated text, with invisible characters removed and whitespace collapsed, so a newline, &nbsp;, a <br> or a zero-width space inside a link's label of up to 300 characters no longer hides it either. A longer label is read as before, lowercased only.
    • For integrity/brand-impersonation, a link whose own label says sign in or log in now counts as a credential affordance, like a password field.
    • See integrity/brand-impersonation.

Changed

  • A signed-in audit's link checks are free, a firewall-blocked link included: Credits and pricing no longer lists a 2-credit charge for one. At the surface and full levels, a signed-in audit's external links go to the cloud's dead-link check, and any link it leaves unanswered is checked on your machine.
  • A signed-in audit no longer waits out a busy dead-link service batch by batch. squirrel sends the service up to 200 external links at a time, and any link left out of a reply is checked locally. When a reply answers none of its links, squirrel now stops asking and checks the rest locally straight away. Before, it sent every remaining batch and waited on each, about 100 seconds on a site with 1,000 external links. See links/dead-links.

Fixed

  • An audit no longer goes on downloading a response it is done with.
    • Bun keeps receiving the rest of a body it has stopped reading until the request is aborted, and most of squirrel's requests only cleared their timeout. A site answering with an endless body kept the connection open, and kept sending, after squirrel had moved on, in most cases until the audit ended.
    • That covered the reads with a size limit (robots.txt, sitemaps, llms.txt and the other root files, scripts, the external link check's look at a 403 page, the soft-404 and cloaking re-fetches, plain-HTTP page fetches in an audit that uses cloud rendering) and the status checks that drop a body unread (external link checks, asset checks, link target checks, the reliability/broken-forms probes, the reachability check of a localhost site).
    • squirrel now closes each connection as soon as it is done with it. What an audit reads is unchanged.
  • A sitemap with a bare-year <lastmod>2024</lastmod>, which the W3C Datetime format allows, is now read. crawl/sitemap-valid used to report the whole file as an error starting Parse error: entry.lastmod?.trim is not a function, and none of its URLs were read.
    • Sitemap values are now kept as the text the file wrote, and a bad entry costs only itself, except that a raw < reads as an unclosed tag and can hide the entries after it.
    • An entry whose <loc> holds markup, a raw <, or appears twice is skipped and counted (N <url> entries skipped: <loc> is not a single text value), and the text around markup is never read as the URL.
    • A <lastmod>, <changefreq> or <priority> like that is dropped and its URL kept (N <lastmod> values ignored: not a single text value).
    • See crawl/sitemap-valid.
  • crawl/sitemap-lastmod-drift and crawl/sitemap-lastmod-churn read a lastmod of just a year or a month (2024, 2024-09, both valid W3C Datetime values) as that whole year or month, not its first day.
    • A year-stamped sitemap used to look months behind every page dated later that year (lastmod 2024 vs dateModified 2024-09-10 (253 day(s))), and churn reported a day the sitemap never wrote.
    • Drift is now measured from the nearer end of the period, and churn leaves such values out of its sample.
    • Churn's "too small to judge" message counts them: a sitemap with only a few lastmods, all years or months, used to read Only 0 sitemap URL(s) carry a lastmod.
    • See crawl/sitemap-lastmod-drift.
  • links/broken-links names the real reason in its Not checked line.
    • When the internal link check answered some targets and skipped others as actions, token links, out of the crawl scope or disallowed by robots.txt, the line said it status-checked only the N most-linked targets and told you to raise internal_links.max_targets, even with nothing over the limit.
    • The line now names the cause that applies, gives a count for each when there are several (past the limit, skipped, not trusted, no answer), and suggests raising internal_links.max_targets only when the limit left targets out. The check's details.notFetched carries the counts.
    • See links/broken-links.
  • With cloud link checking (signed in), a link the checker could not verify is listed with the firewall-blocked links as status unverifiable, never as broken. That covers a timeout or failed connection from the checker's network, a firewall refusal, and a 404 waiting for its confirming second check. Before, a timeout was counted as a broken link and a refused link was left out of the report. See links/broken-external-links.
  • Bot protection is named more accurately.
    • The vendor's own cookie names now count (Cloudflare's __cf_bm, DataDome's datadome, HUMAN's _px3, Kasada's KP_UIDz, Akamai Bot Manager's _abck, Imperva's incap_ses_*, AWS WAF's aws-waf-token), names only, never values.
    • A vendor behind a CDN is named over the CDN, so a DataDome block served through Cloudflare reads as DataDome.
    • A site on CloudFront, API Gateway or Lambda is no longer reported as behind AWS WAF from those services' own headers: only AWS WAF's action header, cookie or challenge script, or CloudFront's "Request blocked" error page, count.
  • eeat/privacy-policy, eeat/about-page and eeat/contact-page no longer tell you to create a page your pages already link to.
    • When the crawl did not fetch the policy, because its path is excluded (/policies/** on a Shopify storefront) or the page limit came first, eeat/privacy-policy now reports Privacy Policy linked but not crawled as info. It warns Privacy Policy link returns N when the internal link probe saw that link answer a 4xx or 5xx other than 401, 403 or a rate limit, and it still warns No Privacy Policy page found when nothing links to a policy.
    • The About and Contact rules report About page linked but not crawled and Contact page linked but not crawled (or About page link returns N, Contact page link returns N), recognising the link by its path only.
    • All three count only links to the same host, so a footer link to another site's /about, or to reCAPTCHA's policies.google.com/privacy, never stands in for a missing page.
    • No extra requests are made. See eeat/privacy-policy.
  • perf/critical-request-chains and perf/font-delivery count only the CSS @imports a browser fetches. An @import inside a comment (/* @import url(a.css); */), a string or a url() no longer counts as a network request, and neither does one after another rule or inside @media, which a browser ignores.
  • content/placeholder-media no longer reports a 1x1 inline src as a blank pixel when the image sits in a <picture> whose <source srcset> names a real image: the browser shows the source, so the src is a stub.
  • local/nap-consistency no longer reads a button label as part of a phone number's format. A tel: link showing +49 (0)6061 630 980 0 with Jetzt anrufen on German pages and Call now on English ones was reported as one number written two ways. Only the number's own characters (digits, +, parentheses, ., /, - and spaces) are compared now, and a genuinely different rendering such as 060616309800 still warns. See local/nap-consistency.
  • schema/rating-scope no longer warns that a rating is not visible when the page prints it in elements with no whitespace between them. <h3>Customer rating</h3><p>5/5</p> used to read as Customer rating5/5, where no rating phrase matched. The rule now also searches the page text with a space at every element boundary, so glued and spaced markup get the same verdict. See schema/rating-scope.
v0.0.112Oct 13, 2026

Release Notes

Seven new rules, and a new category to hold two of them. squirrelscan now ships 308 rules across 22 categories. The new Reliability category catches the things that look fine until a visitor needs them: a deleted script bundle behind a page that still answers 200, or a form that quietly loses every submission. Also new: broken in-page anchors, security.txt and version-leaking headers, the web app manifest, and a misspelling check with its own MIT-licensed word list. The new checks that request other hosts go through a new egress guard, so a page cannot point squirrel at your own network.

Added

  • New Reliability category, in the Performance score group, with its first rule, reliability/broken-resources. A page can answer 200 while the script or stylesheet it depends on returns 404 or 500, usually because a deploy deleted the hashed bundle that cached HTML still references.
    • A first-party script or stylesheet that answers 4xx or 5xx is an error. First-party means the same registrable domain as the site, so cdn.example.com counts for www.example.com.
    • A third-party file, a font or a network failure is a warning.
    • A 429, 430, 503 with Retry-After or a WAF challenge is recorded as unverified, never as broken.
    • One finding per URL lists every page that references it.
    • A fetch the time budget skipped or a cap dropped is noted as a partial check, and the pass says how many resources it checked.
    • Third-party files get a status-only check each: HEAD first, a GET only when the HEAD fails or answers 400 or above, and no body read. That is at most 50 per kind per audit, and only while the rule is enabled. These requests carry none of your custom request headers and go through the egress guard (see Security below).
    • See reliability/broken-resources.
  • New rule reliability/broken-forms: a form can look perfect and still lose every submission.
    • A field with no name is never sent (warning).
    • A first-party action the crawl found 404 or 410 is an error.
    • An action on a staging, preview or localhost host, or a mailto: action, warns. On a localhost or private-network audit those hosts are the site's own and are not reported; on a preview deployment only localhost and private-IP actions are.
    • An action the crawl did not fetch is probed only at the active or aggressive probe level, at most once per endpoint (25 per audit, 10 per site, the site's own first). The probe sends HEAD, and a GET only when the HEAD is refused (405 or 501) or fails, never POST. It carries no cookies or custom headers and follows no redirects. A third-party action is probed only when the audit checks external links and only on public addresses; a same-site action only on the site's own hosts.
    • A 404 to that probe warns that the endpoint may accept POST only.
    • See reliability/broken-forms.
  • New rule links/broken-anchors: a link to #pricing, or to /docs/setup#install on another crawled page, whose target id is gone still loads, and the reader lands at the top of the page instead of the section.
    • The rule warns once per broken target and fragment, lists every page that links to it, and labels a broken skip link.
    • It matches the way the browser does: percent-decoded, case-sensitive, id or <a name>.
    • It leaves out #, #top, purely numeric fragments such as #0, hash routes and text fragments.
    • A link into an unrendered client-side page is recorded as unconfirmed rather than broken.
    • See links/broken-anchors.
  • Two new security rules.
    • security/security-txt checks for an RFC 9116 security.txt at /.well-known/security.txt, and the legacy /security.txt. It warns when the file is missing (404 or 410), when the path answers with the site's HTML catch-all page, when Contact or Expires is missing, and when Expires is not an RFC 3339 date-time, has passed, or appears twice. A 401, 403, 429 or 5xx is recorded as not checked, never as missing.
    • security/version-disclosure warns when Server, X-Powered-By, X-AspNet-Version, X-AspNetMvc-Version or X-Generator names a software version (Apache/2.4.41, PHP/7.4.3), one finding per value listing every page it was seen on. A product name alone (Server: nginx) is fine. On pages stored by an earlier version only Server is checked; re-run with --refresh for the rest.
    • See security/security-txt and security/version-disclosure.
  • New rule mobile/web-app-manifest: the manifest a page links (<link rel="manifest">) sets the home-screen icon, name and splash colours, and nothing shows on a desktop browser when it rots.
    • The rule fetches each same-site manifest once, however many pages link it, and its same-origin icons.
    • A manifest that answers 4xx or 5xx, or is not valid JSON, is an error.
    • These are warnings: a wrong content type; a missing name (and short_name), start_url or display; a missing 192px or 512px PNG icon; a broken icon, or one whose real size differs from its declared sizes; a hex theme_color that differs from the pages' hex theme-color.
    • Redirects off the site, rate limits and WAF challenges are recorded as not checked.
    • See mobile/web-app-manifest.
  • New rule content/common-misspellings: every word on English pages (<html lang="en">) is looked up in a bundled list of known misspellings (recieve for receive), and doubled function words such as "the the" are flagged.
    • A misspelling in a title, meta description or heading is a warning. One in body copy, and a doubled word, is info-level advice, which reports do not list yet.
    • Each finding names the correction, the page count and example pages with context.
    • Code, URLs, emails, ALL CAPS, other languages, British or American variants, and names (a word the page capitalises mid-sentence) are left alone. ignore_words takes your brand spellings.
    • The list of about 520 misspellings was written for squirrelscan and is MIT licensed.
    • See content/common-misspellings.

Security

  • The new checks that send requests beyond the crawl go through an egress guard: the third-party status checks of reliability/broken-resources, the form action probes of reliability/broken-forms, and the manifest and icon fetches of mobile/web-app-manifest, which are held to the site's host and its subdomains on every redirect hop.
    • The guard checks the address before every hop, so a public URL that redirects to a private one is refused before the private hop is sent.
    • On a public site only public addresses are allowed, except the audited origin itself, which the crawl already requests. A private, loopback, link-local, CGNAT or reserved address, a name that is not public by itself (localhost, *.internal, a dotless host), and a name that resolves to such an address are all refused.
    • A site addressed by a private IP or a local name (localhost:3000, 192.168.1.10, nas.local) may reach private hosts, never link-local or cloud metadata addresses.
    • Each guarded request connects to the address that was vetted, so DNS rebinding cannot swap it between the check and the request.
    • The third-party status checks and the form probes carry no cookies and none of your custom request headers or Web Bot Auth headers. Manifest and icon fetches stay on the site's host and its subdomains and carry your custom request headers, like the other same-site asset checks.
  • Known limit: your custom request headers still go to subdomains of the audited host, not only the audited host itself (auditing example.com sends them to cdn.example.com too). Limiting them to the audited origin is planned.

Docs

  • The docs and README count 22 categories, and their per-category rule counts add up to 308. The dashboard crawl settings and the MCP pages describe the internal link checks setting and the internal_links and external_links switches.
v0.0.111Oct 12, 2026

Release Notes

Broken links have nowhere left to hide. Every audit, Quick included, now status-checks the internal link targets its crawl did not fetch, so a linked 404 shows up even when it is not in your sitemap or sits past the page budget. External links stay opt-in at Quick, and there are new flags for both.

Behaviour changes:

  • squirrel sends requests it did not send before. After the crawl, every audit sends a status-only HEAD to each internal link target the crawl did not fetch: up to 200 per audit, the links on the most pages first. Before those, it sends one HEAD to the audited URL, to see whether the server answers HEAD properly. These requests send no cookies, credentials or custom request headers, and never go to a link that looks like an action (log out, unsubscribe, delete, add to cart) or carries a token. Switch them off with --no-internal-links or [internal_links] enabled = false.
  • Expect new links/broken-links findings, and Links scores that move with them: Quick audits now find linked 404s their sitemap does not list, and Surface and Full audits find broken pages past their page budget.
  • "Not checked" is narrower. In 0.0.110 a Quick audit reported every internal link target it did not fetch as Not checked. Now that only covers the targets the status check did not answer: over the limit, outside the crawl scope or robots.txt, an action or token link, no answer, or a site whose HEAD answers cannot be trusted.
  • External links: which levels check them by default does not change. Surface and Full do, and Quick does not unless you pass --external-links. An audit that checks them now requests at most its page budget or 200 distinct external links, whichever is more.

Changed

  • Internal link checks run at every audit level, Quick included. Each internal link target the crawl did not fetch gets a status-only HEAD request, or a one-byte GET when the server answers HEAD with 405 or 501.
    • The check covers up to 200 targets per audit, the links on the most pages first, inside the crawl scope and, when the crawl respects it, robots.txt. Change the limit with [internal_links] max_targets.
    • It is free: no page is added, rendered or charged.
    • A target that answers with 401 or 403 is recorded as unverifiable in an info-level refused-links check, never as broken. Reports do not list info checks yet.
    • See Link checking defaults.
  • The internal link check is built not to change anything on the site.
    • It never requests a link that looks like an action (log out, unsubscribe, delete, confirm, add to cart) or carries a token. It re-checks every redirect hop against the same rules, and it sends no cookies, credentials or custom request headers.
    • On a site that answers HEAD with an error on the audited URL, the targets' errors are not trusted, so they are never reported as broken.
    • These rules are a careful default, not a guarantee. A site that changes state on GET requests to links that read like pages should switch the check off with --no-internal-links.
    • See What the internal link check never requests.
  • An audit that checks external links now requests at most its page budget or 200 distinct external links, whichever is more, the links on the most pages first. The rest are listed as Not checked (reason external-links-over-cap). Only sites that link out to more than that see a difference. Change the limit with [external_links] max_links.

Added

  • --external-links checks external links for one run at any level, Quick included, and --no-external-links skips them. --no-internal-links switches the internal link check off.
    • --external-links cannot be combined with --offline or --no-external-links, and it overrides [external_links] enabled = false.
    • At Quick it makes the audit custom, custom (quick + external_links on).
    • All three are in the shell completions.
  • The local MCP server's audit_website tool takes externalLinks and internalLinks switches. offline: true with externalLinks: true is refused, as on the command line.
v0.0.110Oct 12, 2026

Release Notes

A small release with two fixes worth having. A site that answers with compression bombs no longer eats gigabytes of your memory while squirrel crawls and checks it: squirrel now decodes compressed responses itself, as it reads them, and stops at each read's size limit. And a Quick audit stops telling you your links are fine when it never checked them: those checks now say Not checked, with what to run instead.

Behaviour changes:

  • links/broken-external-links and links/broken-links report Not checked instead of a pass when they did not run. A Not checked result is left out of the score, and the JSON report gains a notChecked list when there is one. A Quick audit's Links score can drop a few points where the empty passes used to pad it.
  • squirrel now decodes compressed pages and the other responses listed below itself (sitemaps already had their own reader). A response sent as x-gzip or an upper-case GZIP is now decoded, where it used to be read as raw bytes. A gzip body made of several members is read to the end of its first member, as browsers read it, where the previous release read every member. A page body that does not decode is still recorded as a decode failure, and the message now gives the decoder's reason.

Fixed

  • A compressed page, root file or checked resource that inflates to gigabytes no longer exhausts memory. The runtime could inflate a Content-Encoding body in full before squirrel read it, even when squirrel read it under its size limit, cancelled it unread or discarded it with a redirect. In our test, an audit of a four-page site whose page, redirect, robots.txt and llms.txt were each a 1 GiB bomb (sent as about 1 MB of compressed data) peaked at 1.8 to 2.8 GB of memory on the previous releases.
    • These requests now turn the runtime's decoding off, and squirrel decodes the body as it reads it, stopping at that read's limit: pages and redirects, robots.txt, llms.txt, the well-known and agent files, RSL licences, Markdown versions of pages, the reachability check, scripts, external links, and the soft-404 and cloaking re-checks. An asset's size is counted as its body streams by, without holding it. Sitemaps already turned the runtime's decoding off and capped what they read.
    • The same audit, run with this release's build, now peaks at about 275 MB, against about 150 MB for the same site without the bombs, and reports the same findings. The bomb page is cut at 10 MB, as before, which is where an uncompressed page over the limit is cut too.
    • These requests send the same Accept-Encoding as before, squirrel's decoder handles gzip, deflate, br and zstd, and bytes a server appends after a complete compressed body are still ignored. The decoding changes are listed under Behaviour changes above.
    • The CLI's script check now stops reading one byte past its size limit instead of downloading the whole script first, and an external link's 403 page is read up to 1 MB for bot-wall detection.
    • Per-request figures are in benchmarks/2026-10-content-encoding-bombs.md. See Compressed responses.
  • A Quick audit no longer says your links are fine when it never checked them. Quick, --offline and [external_links] enabled = false do not request external links. Quick also does not follow internal links, unless the sitemaps give it nothing to crawl. Yet links/broken-external-links passed with "No external links found to check", and links/broken-links passed with "No broken internal links detected" when it had seen only the pages the crawl fetched.
    • links/broken-external-links now reports Not checked when external links were not checked. links/broken-links reports Not checked when the audit did not follow links and left internal link targets unfetched. Each comes with the reason and what to run instead.
    • The result appears in the console, text, Markdown and HTML reports, as <not-checked> in the LLM and XML reports, and as notChecked in the JSON report.
    • Not checked results are not scored. A broken target a Quick crawl does fetch, such as a 404 listed in the sitemap, is still reported. A Quick audit that fetched every internal link target and found none broken still passes links/broken-links.
    • See Which audits can find a broken link.
v0.0.109Oct 12, 2026

Release Notes

Published reports went on a diet. Whatever the size of the crawl, the report squirrel publishes is now a summary capped at 1 MiB: issues carry a sample of their pages and items plus the true counts, the score still comes from every check the audit ran, and the full detail stays on your machine. Heads up if a script or CI job runs it: squirrel config validate now checks [rule_options] and exits 1 on mistakes it used to wave through. Also in this release: squirrel mcp hands your agent the squirrelscan skills directly, Cloudflare pages with JavaScript Detections are no longer mistaken for bot challenges, gzip sitemaps get read, and a pile of rule fixes.

Thanks to @deniskern, whose reports are behind the gzip sitemap, core/meta-title, core/charset, links/tel-mailto, a11y/aria-hidden-focus and a11y/button-name fixes below.

Added

  • squirrel mcp serves the squirrelscan agent skills (squirrelscan 1.13 and audit-website 2.5) over MCP through the Skills extension (io.modelcontextprotocol/skills): skills/list, skills/get and every skill file through resources/read, each listed with its sha256 digest and size. A client that supports the extension gets the skills that match the server it connected to, with no separate install. See Skills.

Changed

  • Behaviour change: squirrel config validate now checks [rule_options] and exits 1 when it finds a problem. It reports a table whose rule ID matches no rule, an option the rule does not declare, a value of the wrong type and an enabled that is not true or false, one Invalid config: line each, naming the table and the option. It used to print Config valid and exit 0 for all of these, so a squirrel config validate && ... step can now fail on a config that used to pass. squirrel audit runs the same check before it crawls: a wrong-typed option on a rule the audit runs stops it with Error: [rule_options."core/meta-title"] min_length: ... instead of failing after the crawl with a bare validation error, and the problems that change nothing are printed as warnings. The config subcommands now read and write the file --config-file names, the file an audit uses, where they always used the nearest squirrel.toml; config set still never creates a missing file. See rule options.
  • A published report is now a summary capped at 1 MiB, however many pages the audit crawled. Each issue lists a sample of its affected pages and items (usually 10 pages and 5 items) with the true counts, and the score still comes from every check the audit ran, not just the sampled ones. On the largest reports the samples shrink, and lower-priority issues can be cut down to their counts or left out. The report says it is a summary and where the full list is: the full per-page detail stays in the local project, and squirrel report --format json exports it. Large audits used to publish bodies of several MB. squirrel audit prints a warning when a report needs more than the usual sampling to fit, and a report that cannot fit at all is refused with PAYLOAD_TOO_LARGE before it is sent. squirrel report --input <file> --publish now refuses a file that has page checks but no crawled page list (the --format json export, or a published report) with PUBLISH_PREPARE_FAILED, where it used to publish a score built from its failures alone: publish the audit from its project with squirrel report <audit-id> --publish. See What a published report holds.
  • squirrel channel now runs on the same MCP SDK (v2) as squirrel mcp, and the CLI no longer ships the v1 SDK or the HTTP server packages it pulled in. The channel still connects with the 2025-era initialize handshake, which Claude Code needs to register it as a channel, and the docs now say so.
  • core/charset reports a UTF-8 page whose charset meta is not first in <head> as placement advice at info status instead of the warning it shared with real encoding problems. Info checks do not count toward the score and are not listed in the report yet. On a page fetched without browser rendering, a charset <meta> that does not end within the first 1,024 bytes of the HTML is now a warning. The rule also warns when the Content-Type header and the page disagree about UTF-8, and reads a quoted header value such as charset="utf-8" without its quotes, but audits do not pass the header's charset to rules yet, so those two take effect once they do. See core/charset.

Fixed

  • Gzip-compressed sitemap files, such as a sitemap.xml.gz child served as application/gzip without Content-Encoding, are now decompressed and parsed. crawl/sitemap-valid used to report each one as Unknown sitemap format and the crawl got none of its URLs. Sitemap requests now ask for gzip (Accept-Encoding: gzip, unless custom headers set another value) and squirrel decodes the response itself, because the runtime's own decoding inflated a whole response before it could be stopped: in our test, a 1 MB gzip bomb sent as Content-Encoding: gzip cost about 1.4 GB of memory. Reading and decompressing now stop at the sitemap protocol's 50 MB uncompressed limit, so a gzip bomb, or a response that keeps sending past 50 MB, is reported as Sitemap exceeds the 50 MB uncompressed size limit, and the same bomb now costs under 70 MB. The limit applies to every sitemap body, so an uncompressed sitemap over 50 MB, which search engines also reject, is now reported the same way instead of parsed. A body that does not decompress is reported as Corrupt gzip body: <reason> (Corrupt br body or Corrupt deflate body for those codings), and a coding squirrel does not decode as Unsupported Content-Encoding: <coding>.
  • On a server that answers every path with a 200 page, the common sitemap locations squirrel guesses (/sitemap.xml, /sitemap_index.xml and six more) are no longer reported as eight invalid sitemaps. A guessed location that answers with an HTML page, or with a body that is not XML, now means no sitemap is there, so crawl/sitemap-exists reports No XML sitemap found where it used to pass on those pages. A sitemap that robots.txt or a sitemap index names and that answers with a page is still a crawl/sitemap-valid failure, now labelled Not a sitemap: the URL returned an HTML page.
  • An ordinary page on a Cloudflare site with JavaScript Detections (which Bot Fight Mode turns on) is no longer classified as a bot-challenge page and set aside from scoring, and ax/agent-blocking no longer reports GPTBot and Claude-User as blocked on such a homepage. Cloudflare's JavaScript Detections script loads from /cdn-cgi/challenge-platform/, which was read as a challenge whenever it fell in the first 10 KB of the page, as it does on a small one. A challenge is now recognised by the challenge's own script (/cdn-cgi/challenge-platform/h/…/orchestrate/), _cf_chl_opt or __cf_chl_, and challenge wording such as "Just a moment" counts only on a 401, 403, 429 or 503, or, for ax/agent-blocking, in the page <title>. A Cloudflare challenge page is still detected and named; a 200 page whose only sign is challenge wording is now scored as a page. Scores on affected sites change, because their pages are now scored.
  • A page that mentions Imperva, or any other bot-protection provider, is no longer attributed to it. Body signatures are now the provider's own markup, script hosts and reference formats, not its name: Imperva's Incapsula incident ID line and /_Incapsula_Resource, DataDome's tag and captcha hosts, PerimeterX's _pxAppId, script host and captcha, Kasada's window.KPSDK and script path, Sucuri's block-page title and heading, Akamai's reference id and error host, and Cloudflare's ray id with its value. The ips.js, cd.js and dd.js attribution patterns, which matched any script whose name ends that way (tips.js, abcd.js, add.js), are gone or anchored to Kasada's own script path. A block page that carries one of these signatures, or the provider's headers, is still detected with its provider. One that carried only the provider's name no longer is, so a headerless 403 like that is reported as an ordinary refusal.
  • A site behind Imperva that answers with x-cdn: Imperva is now recognised as Imperva bot protection, the same as x-cdn: Incapsula, so a blocked crawl names the right provider even when the response carries no x-iinfo header.
  • a11y/aria-hidden-focus and a11y/button-name no longer report controls the markup shows are hidden. Both rules now share one check. It reads the hidden attribute, a closed <dialog> or unopened popover, and an inline style that plainly sets display: none or visibility: hidden. a11y/button-name also skips buttons under aria-hidden="true". An inline style that sets display or visibility in a form the check cannot read exactly (a comment, var(), an unlisted keyword and the like) is reported as an unconfirmed warning (aria-hidden-focus-unconfirmed, button-name-unconfirmed), not a failure. <style> blocks and stylesheets are not read, so a control hidden only by a class or selector rule is still reported. Off-screen and transparent controls are still checked.
  • core/meta-title measures the title as a browser shows it: runs of spaces, tabs and line breaks count as one space and the ends are trimmed. Themes that indent <title> across several lines (Shopware, Shopify) were reported Title too long because of their indentation. The finding's value and item now show the collapsed title, and a title holding only whitespace is reported missing, like an empty one.
  • links/tel-mailto no longer reports a mismatch for a link such as <a href="tel:+4960616309800">+49 (0)6061 630 980 0</a>. The (0) is the optional trunk prefix, which is dropped after the country code, so the link dials the number shown. That display form is now formatting advice in a new tel-trunk-prefix check at info status, which does not count toward the score and is not listed in the report yet. The reading applies only to the link text and only to calling codes whose trunk prefix is 0 (+31, +32, +33, +41, +43, +44, +49, +61, +64 and +353); a display number that really differs from the href goes through the mismatch check as before. See links/tel-mailto.
  • perf/total-byte-weight no longer counts a page's inline CSS and JavaScript twice. The HTML size already includes them, and the rule added them again, so a page with a 1.8 MB inline stylesheet read as about 3,600 KB. The breakdown's HTML total holds the inline code, and its CSS and JavaScript totals now hold external files only. See perf/total-byte-weight.
  • content/hidden-text reads <style> sheets and inline styles the way a browser tokenizes them. A {, } or ; inside an unquoted url(...), inside a ( or [ block, or after a backslash no longer ends a rule or a declaration, so a sheet such as .a{background:url(}.b{display:none}.c{)}, which a browser reads as one rule, is no longer reported as hiding .b. See content/hidden-text.
  • Template fan-out no longer shares a verdict between pages that differ in the <html> language or aria-hidden, the <body> aria-hidden, or a favicon link. Those are inputs three template rules read.
  • squirrel audit no longer seems to hang after printing the report url on a machine with many local projects. Marking the report as published opened every project database under ~/.squirrel/projects until it found the audit (5.5 s for 2,000 projects, and far longer on a large store); it now opens only the audited project's database. squirrel report --publish marks the database the report was loaded from.
  • A release download that stalls no longer hangs squirrel self update or the background updater. The download used to be bounded only until the server sent its headers, so a body that stopped arriving left the detached updater waiting forever, holding the update lock until the lock went stale after 30 minutes. A download now fails when no data arrives for 60 seconds or it has not finished after 20 minutes, the lock is released, and a later run tries again. The new bounds apply to updates made from this version on: the update to this version still runs on the previous release's downloader.
  • Docs: custom request headers are on every plan, Free included, where several pages said Pro only, and the pages that still called local audits free in general now say a local audit costs nothing only when it runs signed out or with --offline. The firewall page now covers letting an audit through Cloudflare Access, Vercel deployment protection and HTTP basic auth, the failed audits guide walks through each failure cause, and the hosted MCP page lists every tool, get_website_settings and update_website_settings included. The docs, README and npm package now give one rule count: 300+.
v0.0.108Oct 10, 2026

Release Notes

Audits now come in three sizes, called levels: quick, surface and full. A level is one name for a whole set of settings (pages, crawl strategy, cloud checks, rendering, external link checks and probing), defined once and shared by the CLI, the local MCP server and the dashboard, so a surface audit means the same thing wherever you start it. A signed-in cloud audit costs credits at every level, quick included: 50 for the audit plus 2 per audited page. Signed out or with --offline, the audit runs on your machine and costs nothing. The Audit levels guide has what each level reads, checks and costs. Also in this release: the local MCP audit_website tool now bills a signed-in audit the way squirrel audit does, a dev server on localhost stops getting marked down for having no HTTPS, squirrel self uninstall is safe for package managers to call, and a pile of rule fixes.

Added

  • Audit levels: --level quick|surface|full. A level is a named set of audit settings (pages, crawl strategy, cloud checks, rendering, external link checks and probing), defined once and read by squirrel audit, squirrel crawl, the local MCP tools, the config and the shell completions. Quick reads up to 25 pages from the URL and its sitemaps (it follows links only when the sitemaps give it nothing to crawl), surface up to 100 pages with one page per URL pattern first, and full every page it can reach, up to 500. Without --level or [crawler] coverage, squirrel audit runs surface when you are signed in and quick when you are signed out or your session has expired, and squirrel crawl runs quick. --coverage/-C and [crawler] coverage keep working as the older names and fast still means quick. An unknown level, a repeated --level or -C, or two flags naming different levels stop with an error before the crawl starts. Picking a level fills in every setting; the flags and config values you already use (--max-pages, --render-mode, --http, --probe, [external_links] enabled = false and the rest) change single settings, and an audit whose settings differ from its level becomes custom. The run banner says which: Level surface · max 100 pages, or Level custom (surface + max_pages 200). See Audit levels.
  • Reports say which level they ran at. The JSON report has meta.auditLevel (level, basedOn, changes, settings), the LLM report an <audit-level> element, and the text, Markdown and HTML reports an Audit level: line. A JSON report loaded back with squirrel report keeps it. No existing field changes.
  • The local MCP audit_website tool takes level (coverage still works) and defaults to surface. It applies the project config's page limit, rendering, external link and probing settings the way squirrel audit does, including [crawler] max_pages.
  • After the crawl, the audit lists the pages whose response could not be decoded, with each URL and its declared content-encoding: the first five, then a count of the rest.

Changed

  • The local MCP audit_website tool bills a signed-in audit the way squirrel audit does, at every level: it registers the run (50 credits) and settles it on its audited pages (2 each), renders pages in the cloud browser as the level says (quick only the pages that need JavaScript, surface and full every page), and runs the cloud checks at surface and full. Before, it could run the cloud checks without registering an audit. A tool call cannot prompt, so when the estimate is above [cloud] confirm_threshold (50 by default, as in the CLI) the call returns it as confirmation_required, and the agent calls again with confirm: true. The page budget is fitted to the balance and to [cloud] max_credits_per_audit; an audit that cannot pay for even one page is refused with the cost and the balance, and so is one the server will not register. An audit that errors asks the server to mark its run failed, and stopping the server with Ctrl-C or a termination signal asks it to mark the run cancelled, so both settle like a failed or cancelled squirrel audit. Signed out, with offline: true, against a local or private host, and in quick_check, the tools still run on your machine with no cloud call.
  • A level's settings are now the defaults. Quick no longer checks external links and probes passively, while surface and full check external links and probe actively, signed in or not. Before, external links were checked at every level and probing followed sign-in instead of the level. A signed-out audit with no flags runs quick, so it now skips external link checks. Explicit flags and config values win as before. max_pages = 100, which squirrel init writes into every config, still reads as unset, and so does [external_links] enabled = true, which it also writes, so a generated config no longer turns external link checks on at quick. Set --level surface or full to get them back.
  • Hints and locked-check messages point at --level instead of -C, and the quick-level message no longer says "coverage".
  • security/leaked-secrets reports a server-only secret found in a browser-served script (database connection strings, service-role and sk_live_ keys, AWS secret keys and similar) in a new leaked-secrets-critical check, "shipped to every visitor". Publishable keys, DigitalOcean Spaces key ids, tutorial values (AWS's documented example keys, placeholder or localhost database URLs) and hits only in page HTML are unchanged.
  • perf/bad-caching explains a missing ETag behind Cloudflare. Cloudflare drops the ETag from HTML it rewrites (Email Address Obfuscation, Automatic HTTPS Rewrites, Replace insecure JavaScript libraries, and JavaScript Detections, which Bot Fight Mode turns on), sometimes only for requests that ask for a page, so a plain curl -I can show a header browsers never get. When the validators check warns and at least half of the pages missing a validator came through Cloudflare, the finding now says the CDN may be the cause and lists the fixes: no-transform in the HTML Cache-Control, turning those features off, or also sending Last-Modified. The validators check now warns instead of failing, because a page with no lifetime and no validator already counts against the freshness check. A weak ETag (W/"...") still counts as a validator for every content encoding. The rule page shows how to check which side removes it.
  • crawl/pdf-size describes its 60MB failure threshold as squirrel's default, not as Google's limit. Google documents 64MB for PDFs; the rule's messages, description and option text now say so.
  • security/sri, content/meta-in-body and schema/rating-scope use one message wording whatever the count, and schema/rating-scope no longer puts the rated entity's name in its check message, so one defect keeps one finding identity across pages.
  • squirrel self uninstall only deletes what a squirrelscan install created, and is ready for package managers to call. It removes the squirrel link (in the default bin directory and the one recorded by self install --bin-dir) and ~/.squirrel/releases, and keeps settings, credentials and audit data unless you pass --purge, which now also removes the cache directory. Each link is checked against the managed install before anything is removed: a link pointing anywhere else, an unrelated file at the link path, or a symlinked ~/.squirrel is reported and left alone, and a symlink inside the releases is never followed. It will not delete a managed install when run from some other squirrel binary. It asks before deleting, and without a terminal it refuses unless you pass --yes (the new name for --force, which still works). With nothing left to remove, it exits 0. See self uninstall.
  • The install docs cover Homebrew: brew install --cask squirrelscan/tap/squirrel installs a launcher that runs the regular installer on first use, after which squirrel self update owns updates. brew uninstall --cask squirrel runs squirrel self uninstall --yes, and --zap also removes ~/.squirrel. See the quickstart.

Fixed

  • A clean site audited on localhost or a private-network host no longer scores a C or D for being a dev server. The transport and delivery rules (security/https, security/hsts, security/http-to-https, perf/http2, perf/compression, perf/asset-compression, perf/cache-headers, perf/bad-caching) are skipped there and left out of the score, so the same cause (no HTTPS, no compression, no caching policy) is no longer charged by several rules at once. The report says so in one line under the score, and the JSON report as notApplicable. The host is classified from the audit URL by the same check the cloud handoff already used, now shared in @squirrelscan/utils; public hosts score exactly as before. See Transport and delivery rules do not apply.
  • The docs said local audits were free. The agent setup pages, the cloud overview, the setup guides and every rule page now say it plainly: signed-out local audits need no account and cost nothing, and signed-in audits spend credits.
  • squirrel crawl -C fast crawled with no page limit at all: the crawl cast the flag to a level without checking it, the page lookup for fast came back empty and the cap became NaN. The crawl now reads its level through the same parser as audit, so fast is quick (25 pages) and an unknown level stops before the crawl starts. A non-numeric --max-pages is refused too, instead of the same unbounded crawl.
  • The local MCP quick_check tool described itself as a single-page check. It reads the URL and its sitemaps, 25 pages by default, and now says so. It also follows the project's [crawler] max_pages and [security] probe settings.
  • The console report footer counts warnings the same way as its "Total:" line. Before, the footer counted advisory warnings from severity-"info" rules and the score line did not, so one report could show two warning totals.
  • squirrel audit -f json -o <file> prints the "JSON report saved to" line on stderr instead of stdout.
  • A malformed squirrel.toml prints the "TOML syntax error:" line with the parser detail again. The check compared the error name, which the parser never sets, so the labelled message never showed.
  • content/heading-hierarchy does not count an empty heading the markup hides (hidden, inline display:none or visibility:hidden, or a hidden or aria-hidden ancestor). The parser marks those headings, so a cached parse counts the same as a fresh one.
  • A signed-in audit of a localhost, private-network or other non-public host no longer sends the archive-indexing lookup to the cloud, where it could only come back empty.
  • Template fan-out, which lets the pages of one template share a rule's verdict, now groups pages only when the markup those rules read matches too: the same scripts and stylesheets (and whether each carries integrity), the same <meta> tags with the same viewport, geo and refresh values, and the same number of <main> landmarks. Before, a page missing its viewport meta, carrying a second <main> or loading a different per-route bundle could inherit a sibling's result. Fan-out runs when the rule cache is off: in cloud audits, and in CLI audits with SQUIRREL_RULE_CACHE=0. On a recorded 4000-page gymshark.com crawl, 3931 of 3983 pages still group with their template (3946 before).
  • Results replayed from an earlier run ignore an items field that is not a list, so a damaged stored row can no longer crash a smart audit or a publish while it merges results.
  • crawl/all-noindex-pages validates its warnOnPatterns and errorOnPatterns options. A value that is not a list of strings now fails option validation, instead of reaching the matcher and breaking the rule.
  • perf/source-maps ignores <script> and <style> inside <noscript>. A browser with scripting enabled never runs that markup, so a source map referenced there is no longer reported.
  • content/article-toc finds a table of contents ItemList nested in a JSON-LD @graph wrapper, as Yoast, Rank Math and Slim SEO emit. Before, the rule reported a missing TOC schema on those sites.
v0.0.107Oct 9, 2026

Release Notes

squirrelscan can now probe, politely: a new probing intensity setting decides how much an audit may send, and the first two probing rules (GraphQL introspection and GraphQL mutations over GET) use it. There is also a rule for auth tokens kept in web storage, a rule for a CSP that blocks the page's own resources, quieter secret scanning, and the entity page filter now behaves the same in the CLI, the MCP tools and the hosted API.

Added

  • security/token-storage flags JWTs and session or access tokens written to localStorage or sessionStorage. Anything in web storage can be read by any script on the page, so one XSS bug turns a stored token into an account takeover, where an httpOnly cookie would not. It is passive: it reads the inline scripts and the external scripts the crawl already fetched and sends no extra request. A key is split into words, and whole words decide, so access_token, jwt and idToken flag while theme, dismissed and csrf do not; a value shaped like a JWT flags under any key. The report names the script and the key and never prints the value.
  • Two GraphQL probing rules, the first rules that use probing intensity. They run only when probing is active or aggressive, probe only the audited origin, and on a passive run send nothing. Both probe the GraphQL endpoints the audit found in your pages and scripts, plus /graphql and /api/graphql, at most five per rule, and never a path that names a state-changing action such as logout or delete. Below aggressive they skip paths robots.txt disallows. security/graphql-introspection sends one GET with the read-only query {__schema{types{name}}} and warns when an endpoint returns its schema, naming the endpoint and the type count; on an aggressive run it may follow up with one POST of the same query when the GET answer is GraphQL without a schema. security/graphql-get-mutations sends one GET with the no-op mutation mutation{__typename}, which cannot write, and fails when the server executes it, because a mutation reachable by GET can be fired cross-site with your visitors' cookies (CSRF). Disabled introspection, errors, a 405 and HTML or catch-all answers are not flagged. See Security configuration. That makes 301 rules, 20 of them in Security.
  • security/csp-blocks-own-resources flags a Content-Security-Policy that blocks what the page itself loads. It matches each page's own policy (the header and any <meta http-equiv> policy) against the page's scripts, preloaded modules, stylesheets, frames and images using real source matching, so *.example.com allows cdn.example.com but not example.com. It also looks inside the same-site chunks a page loads for third-party script URLs, and checks the hosts a detected vendor needs at runtime (Google Analytics 4 and Google Ads endpoints, Google Tag Manager, PostHog, Stripe, Cloudflare Turnstile, reCAPTCHA), which the HTML never names. A blocked analytics, ads, payments, captcha, form or booking host fails and any other host warns, naming the directive and the host to add. A page with no enforced policy passes, because a missing CSP stays with security/csp. See the rule page. That makes 298 rules, 17 of them in Security.
  • Probing intensity, a third audit setting next to coverage and render mode. --probe (-P) takes passive, active or aggressive, with --passive and --aggressive as shortcuts and --pentest for --coverage full --probe aggressive. --probe-budget caps the wall-clock time of all probing together (default 30s active, 2m aggressive). [security] probe and budget set both in config. A logged-in audit defaults to active and a logged-out one to passive; aggressive is never a default. --disable-discovery-probes keeps probing passive. No probing rules ship yet, so no audit sends anything new: this adds the setting and the shared budget future rules will use. See Security configuration.

Changed

  • Publishing from a large cloud audit no longer slows down as a site's page history grows. The merge indexes the prior pages once instead of copying them several times, builds the full page list only when something reads it, and writes back only the pages the run changed. At 25,000 pages this removes tens of milliseconds of cold-start work per publish.
  • The send_feedback tool description in the local MCP server is neutral about when to use it, and a test now fails when the MCP docs drift from the tools the server lists.
  • squirrel entities and the MCP list_entities tool now apply the same page filter rule as the hosted API, and a filtered result keeps summary site-wide. --page with an absolute http(s) URL is a prefix of the page URL, a value starting with / is a prefix of the path and query (so --page /blog no longer matches https://example.com/tag/blog), and anything else is a substring. Use --page blog for the old substring behavior. A filtered squirrel entities -f json and list_entities used to return a summary recomputed over the matching entities. They now return the site-wide summary and a new filtered: { nodes, edges } block with what the filters kept, so scripts that read summary.nodeCount after a filter should read filtered.nodes instead. --diff still compares recomputed summaries.
  • The audit also fetches the same-site script chunks a page preloads with <link rel="modulepreload"> or <link rel="preload" as="script">, so security/csp-blocks-own-resources can read the third-party script URLs inside them. They are kept apart from the scripts every other rule reads, so no other rule's result changes, and they are fetched only when a crawled page enforces a CSP, capped at 20 extra fetches per audit. The rule cache format changed, so the first audit of each site after the upgrade runs every rule fresh, once.

Fixed

  • security/leaked-secrets raises fewer false alarms. A label that ends in a credential word, such as a translated Password: heading in compiled JSX, is no longer read as a key whose value is the markup after it, template expressions and placeholder connection strings are not credentials, and a key that runs past a provider's format is reported as a secret of unknown provider instead of being named as that provider's. Public vendor identifiers in a script's query string (Mida, Klaviyo and reCAPTCHA site keys) are skipped. A Statsig secret- key is reported as a server secret and a client- key as the public SDK key it is.
  • A site that sends response headers and then stalls its body can no longer hang the CLI. The shared request helper used by the reachability check, the robots.txt fetch and the update check disarmed its timeout as soon as the headers arrived, so the body read had no time limit. The timeout now covers the body too, and a stalled body fails like any other timeout.
  • The audit engine can keep the cloud results it has already paid for when its caller stops the cloud analysis step at a deadline, instead of discarding all of them. A caller turns this on with the new deadlineMs option on the container prefetch (or by passing its own accumulator and calling abandon()). Results that came back are used by the rules, rules still waiting report skipped with reason service-unavailable, and no new cloud call starts after the deadline. The spend lists each call known to be charged, once: every call that came back, plus a render batch the cloud accepted but had not finished. A call still running at the deadline is not listed and may yet be charged, so the estimated balance after the run is marked approximate (balanceAfterApproximate). Nothing in the CLI sets a deadline, so CLI audits are unchanged, and a prefetch that finishes in time returns exactly what it did before. See credits.
v0.0.106Oct 8, 2026

Release Notes

squirrel mcp speaks the new MCP 2026-07-28 protocol, squirrel channel gives your Claude Code session a heads-up the moment a cloud audit lands, every rule now runs against the clock to keep booby-trapped pages from stalling an audit, and two new content rules catch the info@example.com and placehold.co your theme shipped with. Plus a big pile of false positives, swept out of the nest.

Added

  • squirrel mcp supports MCP protocol revision 2026-07-28 and the 2025-era protocol from one process. It moved to version 2 of the MCP SDK and picks the protocol from the client's first message: a 2026-07-28 client connects with no initialize handshake and reads the server's capabilities from server/discover, and an older client keeps the initialize handshake. There is nothing to configure. Tool names, descriptions and input schemas are the same in both, and the schemas are now published as JSON Schema 2020-12 rather than draft-07. The MCP docs have the details.
  • squirrel channel pushes cloud audit events into a running Claude Code session. Add it to your MCP config as squirrel channel, and when a cloud audit finishes, fails or finds new issues (audit_complete, audit_failed, issues_detected), your agent gets a one-line event with the ids it needs to pull the report and start on the fixes, without being asked. It polls your organization's notification feed every 30 seconds (--interval, from 5 to 86400), --categories picks the events, and it backs off on 429 and 5xx responses. Event text is built from validated ids, hostnames, counts and fixed strings, never from notification titles or text on your site, so a crawled page can't talk to your agent through it. It needs a login session (squirrel auth login), because API keys can't read the feed; without one it sends a single login_required event and keeps running. It remembers where it stopped, per organization, and catches up once on the next start (until the cloud feed supports cursors, that catch-up reads back only the latest 50 notifications). Channels are a research preview in Claude Code, so for now you start Claude Code with the development flag. The channel guide has the setup.
  • Every rule's synchronous work runs under a time budget: one second per page for a page rule, and one second per page of the site for a site rule, capped at 60 seconds. A rule that waits on the network is timed up to its first wait, and its requests keep their own timeouts. A rule that runs past its budget is stopped and the audit carries on with the next rule. The report records it once per audit, as a failed <rule>-error check (Rule error: exceeded its 1000 ms time budget and was abandoned, naming the larger budget for a site rule) against the first page it happened on (or the whole site, for a site rule), and a page rule also gets an info <rule>-timeouts check with the number of pages it did not check. Ordinary pages come nowhere near it: measured over 500 pages, the slowest page rule's 99th percentile was about 6 ms. A page where a rule timed out is never stored in the rule cache, so the next audit runs it again. The rules reference has the details.
  • content/placeholder-contact flags contact details a template shipped with and nobody replaced: email addresses at example.com, test.com or yourdomain.com, the fictional 555-01xx and UK 07700 900xxx phone ranges, 123 Main St, and handles such as @yourhandle. It reads the visible text and every link's href, so a mailto:info@example.com behind an "Email us" link counts. It fails on a contact or checkout page, skips other pages under documentation paths such as /docs and /api, warns everywhere else, and never judges a code sample. See the rule page.
  • content/placeholder-media flags images that were never finished: a placeholder service source (via.placeholder.com, placehold.co, dummyimage.com and friends, or placeholder as a word in the image's filename), a blank 1x1 inline image used as content, and camera-default alt text such as DSC_0001 or IMG_1234. A placeholder source or a blank stand-in fails and a camera-default alt warns. A lazy-loaded image is judged on its real data-src, not its stub. See the rule page. That makes 297 rules, 23 of them in Content.
  • The JSON report lists checks a rule skipped because it hit its scan limit, in a new top-level skippedChecks list with the rule, the check, the reason and the pages (with the full count when a long list is clipped), and summary.skipped adds up those skipped page checks. Before, the JSON dropped them, so a page that content/hidden-text or content/dev-leakage could not finish scanning read as clean. A report with no such skips has the same keys as before. The output docs now describe the JSON shape the CLI really writes.

Changed

  • perf/total-byte-weight scores each page on its own weight, as its 1600 KB and 5000 KB thresholds intend, instead of adding up the whole crawl. A page's weight is its HTML, its inline CSS and JS, and the stylesheets, scripts, images and fonts it loads, and a file several pages share counts toward each of them. The HTML and fonts (from a preload link or an inline @font-face) are counted for the first time. The message names the heaviest page and how many pages are over each threshold, and it says how many resources it could not size instead of quietly counting them as zero bytes. A site with one heavy page can now warn or fail where it didn't, and a large site of light pages no longer does. Same-site fonts are sized like images, normally with one HEAD request per distinct font, inside the existing resource check limit. See the rule page.
  • The rule cache is keyed on the rule code as well as the CLI version, so running from a source checkout no longer replays findings from before a rule change. The cache format changed, so the first audit of each site after the upgrade runs every rule fresh, once.
  • crawler.include patterns narrow the crawl within its scope instead of replacing it. A path-only pattern such as /blog/* no longer admits /blog/ URLs on other hosts (they could arrive through a sitemap), while an absolute-URL pattern still reaches the host it names. An explicit [project] domains list is a hard allowlist: an absolute include for a host it does not list is refused, with one warning naming both. A config that relied on a path-only include to reach another host needs an absolute pattern, or that host in [project] domains. The crawler docs have the details.
  • Published reports from a large audit count every page the crawl fetched as audited. Before, a page left out of the published sample, or one no page check looked at, was missing from the audited count and from the page charge. On the next publish of such an audit, pages that were left out of the sample and passed every check count toward the score, so its score can rise once, toward what a complete cloud audit gives it.
  • A rate-limited crawl says how much it never reached. The partial status reads like 437 pages rate limited by host; 1355 more discovered but not fetched, and the text, markdown, llm and JSON reports carry the count of URLs it found but never fetched.
  • Reports can say a cloud crawl stopped because it ran out of time rather than at its page limit: the scan scope line adds stopped early on time, the partial-scan note says the site may have more pages than the score covers (without suggesting a higher page limit, which wouldn't help), and the JSON report carries stopReason: "time" next to maxPages, the llm output stop-reason="time".
  • The render limit warning says how many pages your plan renders at once, per audit: 1 on Free, 5 on Pro and 10 on Team.
  • The credit wall links to the plan comparison on the pricing page next to the upgrade link, and an account already on a paid plan is pointed at a top-up instead of being pitched the plan it already pays for.
  • Installer error reports include the version being installed and, on Linux, whether the system uses glibc or musl, so a failed install can be matched to its release.
  • The local project store (~/.squirrel/projects/<domain>/project.db) upgrades itself to schema version 33 the first time this version opens it, to store each page's hreflang alternates. It only adds a column, so v0.0.105 can still open the upgraded store.

Fixed

Thanks to @deniskern, whose reports are behind the hreflang, noindex, dom-size, autocomplete, empty heading, meta-in-body, video caption and LCP fixes below, and the new skippedChecks list.

  • core/title-unique, content/duplicate-title and content/duplicate-description no longer report region variants of one page, such as /en-gb/shirt and /en-us/shirt, as duplicates when each lists the other as an hreflang alternate in the same language and neither canonicalises elsewhere. A de and en pair with one title is still reported. A page reused from the crawl cache picks this up once it is fetched again, or with --refresh.
  • crawl/indexability-conflicts no longer reports robots.txt Allow plus noindex as a conflict, since that is the supported way to deindex a page. Its remaining info check, a page robots.txt blocks without a noindex, now lists the pages.
  • crawl/sitemap-coverage no longer lists a noindex page as missing from the sitemap, and content/title-pattern-outlier leaves noindex pages out of its template vote, when the site itself is indexable.
  • The dom-max-children check of perf/dom-size counts <body> only, so a <head> full of hreflang alternates or preload hints no longer trips its 60-children limit.
  • a11y/autocomplete-tokens no longer asks for an autocomplete token on a country select that is the only personal-data field in its form, such as a locale or market switcher. An explicit token is still checked.
  • a11y/empty-heading skips headings hidden in the markup (hidden, inline display:none or visibility:hidden, or an aria-hidden or hidden ancestor), and each item shows the heading's tag and its position on the page, so two empty <h2>s can be told apart.
  • content/meta-in-body skips schema.org microdata <meta itemprop> tags, which HTML allows in <body>.
  • a11y/video-captions and video/video-accessible skip decorative video, one that is aria-hidden or muted with no controls and autoplays or loops, and both rules now agree on what counts as decorative.
  • perf/lcp-hints uses the same LCP candidate finder as perf/lcp-fetchpriority, so header and nav images, small logos, icons and tracking pixels no longer count as likely LCP images, and a preload matches its image across relative and absolute URLs, query strings and srcset.
  • content/date-agreement reads dates in French, German, Spanish, Italian, Portuguese and Dutch, year-month-day dates with Japanese, Chinese and Korean markers, and numeric dates, reading an ambiguous 08/01/2026 both ways. It stays quiet about a date it can't read instead of warning that the page shows none, and a year in a slug like /reviews/2024-honda-civic-review no longer counts as a publication date.
  • security/new-tab no longer warns on target="_blank" without noopener, because browsers imply it, and noreferrer implies it too. It warns only on an explicit rel="opener", reads rel as a list of tokens rather than a substring, and a missing noreferrer is an info-level privacy note.
  • security/mixed-content reports only http:// URLs in markup that loads a resource, so canonical and alternate <link>s, which are never fetched, no longer count. Loopback URLs such as localhost and 127.0.0.1 are not mixed content and get their own local-development-url warning, and each finding names its tag and whether a browser would likely block it or upgrade it, and says it comes from the markup rather than an observed load.
  • links/no-contextual-inbound skips when the crawl stopped at its page limit, because a page it never reached could hold the link. legal/subprocessor-disclosure warns only when the crawl shows a commercial offer (a pricing, plans, enterprise or trust page, or a sales or demo link), so a pre-launch waitlist page is skipped. content/keyword-stuffing no longer counts the site's own brand name, taken from its domain.
  • An audit of a site that refuses the crawler (401, 403, 429 or a bot challenge) no longer reports its robots.txt, sitemap, llms.txt or Markdown response as missing. crawl/robots-txt, crawl/sitemap-exists, ax/llms-txt and ax/markdown-response say "not checked" and name who refused, a failed or blocked audit scores no categories, and the text, markdown, llm and JSON reports list the refused requests and, when the response names it, the bot protection behind them.
  • A response whose body doesn't match its declared content-encoding is recorded as a failed page, with an error naming the URL and the encoding, and the audit is marked partial with a count such as 2 pages could not be decoded (bad content-encoding). Before, the page vanished and the audit claimed to be complete.
  • A meta refresh inside <noscript> no longer moves the audit to the no-JavaScript page. A browser with scripting on never follows it.
  • A cached page reused by an incremental crawl follows the same link discovery settings as a freshly fetched one. With link discovery turned off, one reused homepage used to put the whole site back in the queue.
  • A JSON-LD node with its own @type and a @graph keeps its own type, so a WebPage wrapping an Organization is checked as both. A page reused from the crawl cache picks this up once it is fetched again, or with --refresh.
  • Soft-404 detection picks up framework error pages served with a 200, such as a Next.js __next_error__ shell, as candidates in every audit. The engine the CLI uses read that marker from the page's DOM after it had already released it, so it missed them.
  • The entity map published with a report sets aside about a fifth of its node budget (more on a small map, so each kind of problem still gets one) for nodes that show the entity problems the rules report (conflicting properties, dangling references, a missing or malformed @id, a split identity and the rest), taking turns so no one kind crowds out the rest. A finding's evidence is no longer sampled out of the map just because the entity is rarely linked. The map in the HTML report is chosen the same way.
  • Merged report messages no longer turn HTTP statuses into ranges: redirect chains ending in a 404 and a 500 stay separate rows instead of merging into a (404 to 500) status that doesn't exist. Redirect messages now write the status as (HTTP 404).
  • A signed-in audit whose publish gets a 5xx after the report was already saved now returns the report URL instead of failing. The CLI checks whether the run gained a newly linked report, and does not post the report again after a 5xx.

Security

  • Six regular expression scans that a crafted page could make backtrack for seconds now run in linear time and match exactly what they matched before: four in security/leaked-secrets (JSON Web Tokens, Google OAuth client ids, the <script> tag scan and the key-name look-back), the CSS custom property scan behind the template fingerprint, and the title separator in content/title-pattern-outlier. The time budget above is the backstop for anything we haven't found yet.
v0.0.105Oct 7, 2026

Release Notes

images/responsive-size measures thumbnails in pixels, schema/json-ld-valid follows schema.org and Google more closely and now checks the fields of each breadcrumb and FAQ question, and squirrel entities shows the page each dangling reference comes from.

Changed

  • images/responsive-size compares a thumbnail's real pixel size with the size its width and height attributes give it, allowing for 2x screens, the way Lighthouse's responsive images audit does. It still looks only at images 100x100 or smaller by those attributes (the default max_thumbnail_dimension), with no srcset and outside <picture>. A large image shown small is now reported even when its file is light, such as a 1200x1200 JPEG of 20 KB shown at 64x64, once the pixels a 2x screen never draws come to 4 KiB or more of the file, so this rule can report more than before. With the pixel size known, an image no more than twice the size it is shown at is not reported, animated or not, so an animated WebP shown at its own size no longer is. When the pixel size can't be read, such as a JPEG whose dimensions sit past the bytes read, the rule falls back to the file size check from v0.0.104, which still skips SVG, ICO, GIF and animated images. The rule page has the details.
  • To read that pixel size, the image check now requests the first 32 KiB of each image on the host you audit and its subdomains, normally with one ranged GET in place of the HEAD it sent before. At the default 500-image limit that is about 16 MB of image data, less when files are small or reused from the cache. A server that rejects the range, or answers without a length, can cost extra requests. Raster images an earlier version measured are requested again on the next audit so their size can be read, even when their cache headers would have let the audit reuse them.
  • schema/json-ld-valid now checks the fields of each entry in a BreadcrumbList and a FAQPage. Those per-entry checks never ran before, so the rule can report findings it did not: a crumb with no position, no item (the last crumb may leave it out, as Google allows) or no name on the crumb or its item, and a question with no name or acceptedAnswer.
  • a11y/label-content-name-mismatch and the tabindex-very-high check of a11y/tabindex list up to ten elements, and now count the elements past those ten toward the score as well, up to 20 per check and page, the same cap every failing check has. A page with more than ten of them can score lower. Warning checks that cut their item lists short now also record how many items they left out, as details.additional in the JSON report: tabindex-positive, and checks in a11y/identical-links-same-purpose, a11y/link-in-text-block, perf/animated-content, perf/source-maps and schema/rating-scope. Warnings count once per check and page, so their scores don't change.
  • The local project store (~/.squirrel/projects/<domain>/project.db) upgrades itself to schema version 32 the first time this version opens it. Version 31 stores the pages behind each entity map reference and version 32 stores each image's pixel size and whether it is animated. Both only add columns, so v0.0.104 can still open the upgraded store.

Fixed

  • schema/json-ld-valid no longer flags valid markup: a single recipe ingredient, recipe instructions given as text or as a single step, a single FAQ question or breadcrumb not wrapped in a list, a breadcrumb named on its item node, a Product offer that is an AggregateOffer (it needs lowPrice and priceCurrency, not price and availability), an Article with no publisher or a publisher with no logo, and fields given on a top-level node that has an @id but no @type, which a reference to that @id now finds. A page reused from the crawl cache picks up that last fix once it is fetched again, or with --refresh.
  • squirrel entities and the entity tools in squirrel mcp show the page each dangling reference comes from. The "Example page" column was always empty, because the store kept no pages for a reference. The edges and site they read back now match the audit report's entity map, and site is the URL you audited rather than the site's origin (older audits that never recorded that URL still show the origin). New audits store the pages, and squirrel analyze fills them in for an audit stored by an earlier version.
  • A finding in your dashboard for an element you fixed, or for one a rule update renamed, now resolves when a published audit checks that page again and lists every item of that check, even while the page still fails the check for another element. Before, it stayed open for as long as the page failed the check at all. A check that lists only some of its items, or could not finish its scan, keeps the rest open, because the audit can't tell they were fixed.
  • content/hidden-text and content/dev-leakage skip a page that hits their scan limits when the part they read was clean, instead of passing it. The pass told your dashboard the page was clean, so a finding from an earlier audit that sat past the limit was marked fixed while it was still there. It now stays open.
  • The configuration docs use the rule ids and option names the rules read. Entries such as [rule_options."performance/ttfb"], exclude_patterns and min_external_links matched no rule or option, and an audit ignores an unknown rule id or option without a warning (squirrel config validate doesn't catch it either), so copying them changed nothing. content/keyword-stuffing's density_threshold is a percentage (3, not 0.03), and the URL patterns these rule options take are a substring, a /segment/ or a regex:, never a * glob. The rule options reference is corrected and points to the Options table on each rule's page, and the squirrel init page shows the config file the command really writes.

Security

  • The MCP SDK is updated to 1.32 for a published advisory in its OAuth client (GHSA-6qxp-vccf-f47h). squirrel mcp runs only the SDK's stdio server and the OAuth client is not part of the squirrel binary, so the CLI was not exposed.
v0.0.104Oct 6, 2026

Release Notes

Pages kept out of search stop counting against title, description and heading checks, and four fixes from community bug reports cut false positives in structured data, image and accessibility checks.

Changed

  • Pages kept out of search are no longer graded on how they would look in search results. On a page whose robots meta tag or X-Robots-Tag header says noindex, core/meta-title, core/meta-description and core/h1 record a skipped check instead of a finding, and core/title-unique, content/duplicate-title, content/duplicate-description and links/orphan-pages leave the page out. Scores can rise for sites with noindex pages such as thank-you pages, internal search or account pages, because those findings no longer count. Accessibility, security, performance and broken-link rules still check every page. When the homepage itself is noindex, as on a staging site or a Vercel, Netlify or Cloudflare Pages preview deployment, nothing is skipped, so a pre-launch audit still checks titles, descriptions and headings everywhere. The rules reference has the details. Thanks to @deniskern for the report (#457).

Fixed

  • schema/json-ld-valid accepts an ImageObject (or an {"@id"} reference) for image and logo, and resolves @id references across the page's JSON-LD, so @unhead/schema-org (Nuxt) pages validate clean. Validation errors are no longer repeated under "Invalid JSON-LD syntax", and a page reused from the crawl cache is validated again instead of replaying the previous release's results. Thanks to @deniskern for the report (#463).
  • images/responsive-size reports a small image only when its measured file is heavier than its displayed size needs, instead of flagging every thumbnail without srcset. Thanks to @deniskern for the report (#464).
  • a11y/color-contrast reports only contrast it measured: an inline text and background color pair under 4.5:1. Guesses from class names and stylesheets, such as text-gray-300, are no longer findings. Thanks to @deniskern for the report (#465).
  • a11y/aria-dialog-name identifies an unnamed dialog with no id by its classes and start tag (dialog.newsletter-popup) instead of a bare dialog. Thanks to @deniskern for the report (#462).
  • Entity map findings link to the entity map guide at its new home, https://docs.squirrelscan.com/guides/entity-map/fixing.

Security

  • Dependencies are updated for published security advisories, including smol-toml, which reads squirrel.toml.

Audit your site in one command

SEO, performance, security, accessibility and agent experience issues, with exact fixes for your coding agent.

Install
$

No account needed for the CLI. Cloud audits include free monthly credits.