Verifies Thunderbird WebExtensions against the annotated WebExtension API schemas and ATN review policies. The tool will report API, manifest, permission, and bundled-code issues. It does not modify the reviewed sources.
The schema verification works like
addons-linter - parsing the
JavaScript and matching browser.* / messenger.* / chrome.* calls against
the API surface - but uses Thunderbird's annotated schema files.
Beyond the deterministic checks, a few review judgments that resist static analysis can optionally be delegated to an LLM (Claude or ChatGPT) when an API key is supplied. See the LLM checks under Review checks below.
npm installRun via npm (the -- separator stops npm from eating the parameters):
npm run help
npm run verify -- <xpi|folder> [options]Run the node script directly:
node verify.js <xpi|folder> [options]The schema review always reads manifest_version and uses the matching
schema (MV2 → <channel>-mv2, MV3 → <channel>-mv3). An add-on that omits
manifest_version (or has a missing/invalid manifest) is treated as MV2.
| Option | Description |
|---|---|
--schema-cache <dir> |
Where downloaded schema zips are cached (default .schema-cache). |
--schema-channel <name> |
Schema channel (default release). One of release, beta, esr. |
--schema-force-refresh |
Re-download the schema even if a cached copy exists. |
--schema-zip <path> |
Use a local schema zip (or directory) instead of downloading. |
--checks-only <ids> |
Only run these checks (comma-separated). See the check list below. |
--checks-skip <ids> |
Skip these checks (comma-separated). See the check list below. |
--report-format <text|json> |
Report output format (default text). |
--report-out <file> |
Write the report to a file in addition to stdout. |
--llm-enabled |
Enable the LLM checks. The key is read from the LLM_API_KEY environment variable (see LLM configuration). |
--llm-list-models |
List the models your token can use, then exit. |
--allow-experiments |
Accept add-ons that use Experiment APIs, instead of rejecting them as unsupported. Off by default. |
--eslint |
Run the ESLint code-sanity check on authored JS. Off by default. |
--diff-to <xpi|folder> |
Previously published version, to diff against. |
--diff-summary |
Add an AI assisted "Summary of changes" section: how the add-on changed since the --diff-to baseline. Needs --diff-to and --llm-enabled. |
--full-summary |
Add an AI "Summary of add-on" section after the report - what the add-on does, with security/privacy notes and a permission review (which declared permissions appear unused) - from its (almost) full current source (vendored and unused files excluded). Advisory, not a finding. Needs --llm-enabled. |
--verbose |
Verbose logging. |
Exit codes: 0 no errors · 1 one or more error-severity findings · 2
tool failure.
The LLM checks are configured from the environment, and enabled via the --llm-enabled flag:
| Variable | Description |
|---|---|
LLM_API_TYPE |
Provider: claude (default), chatgpt, or ollama (local). |
LLM_API_KEY |
The provider API key. Required for claude/chatgpt, not used by ollama. |
LLM_API_MODEL |
Model for the LLM checks (default: the provider's default). |
LLM_API_URL |
Override the provider's API base URL (e.g. a proxy, or a remote Ollama host). |
# Claude (the default provider)
export LLM_API_KEY=sk-ant-...
node verify.js <xpi|folder> --llm-enabled
# ChatGPT
export LLM_API_TYPE=chatgpt
export LLM_API_KEY=sk-...
node verify.js <xpi|folder> --llm-enabled
# local Ollama - no API key
export LLM_API_TYPE=ollama
node verify.js <xpi|folder> --llm-enabledEach provider has a default model (claude-sonnet-4-6 for claude, gpt-4.1
for chatgpt, llama3.1 for ollama). Override it by setting LLM_API_MODEL,
or list the available models with --llm-list-models.
Local model (Ollama). With Ollama running, the checks
talk to its OpenAI-compatible endpoint at http://localhost:11434/v1 — no API
key. Pull a tool-capable model first (the structured checks require tool calling),
e.g. ollama pull llama3.1. When the LLM is enabled, a Setup-step pre-flight
shows the chosen type and model and fails hard if Ollama is unreachable or the
model is not pulled. Point LLM_API_URL at a remote host to use a non-local Ollama.
A review has three kinds of check, all declared in assets/registry.yaml:
- Deterministic - decided entirely in code, no LLM, offline apart from the
one-time vendor source fetch. A few are gated by review mode
(
diff: true/false). - LLM - a deterministic pre-flight always runs offline, only the ambiguous residue is delegated to an LLM (when an API key is supplied) or routed to manual review.
- Manual - checks the tool can't make itself, surfaced as a todo list.
Each deterministic-checks entry links to a module in
src/checks/rules/ and supplies the severity for its
findings. A deterministic check either decides each case as a finding or
escalates it straight to manual review (e.g. vendor-unverified,
native-messaging, fork-check). The LLM checks escalate only their ambiguous
residue.
| Check | What it flags |
|---|---|
api-coverage |
Dynamic/aliased API access static analysis can't resolve (info). Files that fail to parse are unparsable-file. |
async-onmessage |
An async listener passed to runtime.onMessage.addListener(). |
background-module |
A background script (background.scripts/service_worker) that uses static ES module syntax (import/export) while the manifest's background is not declared "type": "module" - it won't load as a module (error). Background pages and content scripts are out of scope. |
bundled-files |
Referenced files that aren't packaged: content_scripts/background/popup/options manifest entries, and packaged-file paths passed to file-loading API calls (script registration, setIcon, executeScript/insertCSS, getURL, ...) - the same schema-derived loader set that fuels the reference graph, not a hardcoded list. |
cleartext-transmission |
Data transmitted to a remote host over an unencrypted scheme (http:///ws:///ftp://) by an overt API (fetch, XHR, WebSocket, sendBeacon) - any cleartext send, regardless of payload (error). Covert disguised channels are the disguised-* checks. |
code-sanity |
Opt-in (only runs with --eslint). ESLint-based code errors: no-redeclare, no-shadow, dupe/unreachable/self-* rules, empty blocks (no-empty, e.g. an error-swallowing empty catch) (info). Style/fixable rules (e.g. prefer-const) are excluded - the tool is read-only, so "rewrite this" is not a review concern. No no-undef (WebExtension scripts share a global scope). |
csp-unsafe-eval |
A content_security_policy that allows 'unsafe-eval' - permits dynamic code execution (error). |
csp-unsafe-inline |
A content_security_policy that allows 'unsafe-inline' - permits dynamic code execution via inline scripts (error). |
debugger-statement |
Unconditional debugger statements. |
default-locale-missing |
A packaged _locales/ directory but no default_locale manifest key - Thunderbird refuses to load the add-on (error). |
default-locale-unused |
A default_locale manifest key but no packaged _locales/ directory - Thunderbird refuses to load the add-on (error). |
deprecated-api |
Deprecated APIs (member or namespace level), and APIs whose version_added is newer than the target Thunderbird. |
disguised-navigation |
Data smuggled out through a page navigation (location.assign/replace) built with appended runtime data (error, regardless of consent). |
disguised-resource |
Data smuggled out through a resource-load URL (image/iframe/media src, setAttribute) built with appended runtime data (error, regardless of consent). |
disguised-stylesheet |
Data smuggled out through a stylesheet or CSS url() built with appended runtime data (error, regardless of consent). |
disguised-window |
Data smuggled out through a window.open() to a remote URL built with appended runtime data (error, regardless of consent). |
eval-call |
An eval() call in authored JS - dynamic code execution (error). |
experiment-missing-strict-max-version |
An accepted Experiment (--allow-experiments) that sets no strict_max_version (error). Silent when experiments are disallowed, since experiment-not-allowed already rejects it. |
experiment-not-allowed |
An Experiment (declares experiment_apis) when experiments are not enabled via --allow-experiments (error, on by default). |
fork-check |
New-submission manual prompt (diff: false - the inverse of a diff check, so it is skipped when reviewing an update against a --diff-to baseline): confirm a forked add-on is clearly distinguished from the original and offers a significant difference in functionality and/or code - routed to manual review. |
function-constructor |
A new Function(...) (the Function constructor) in authored JS - dynamic code execution (error). |
manifest-invalid-json |
manifest.json is present but is not valid JSON (error). |
manifest-missing |
No manifest.json at the add-on root (error). |
manifest-missing-key |
A required top-level manifest key (manifest_version/name/version) is absent (error). |
manifest-unknown-permission |
A declared permission value that is neither a known permission, a data-collection permission, nor a match pattern (error). |
manifest-version-mismatch |
manifest_version disagrees with the schema set being reviewed (error). |
minimize-host-permissions |
Broad (<all_urls> / * host) permissions requested as required (info). |
missing-library |
A JS file (not in the VENDOR file) that looks like a bundled third-party library - by a /*! … */ banner, UMD wrapper, .min.js name, or known library filename (warning). Heuristic, no hash DB: it can't say which library or verify the version. |
missing-manifest-key |
A called API needs a manifest key (e.g. action) that is not declared (error). The manifest-key counterpart of missing-permission. |
missing-permission |
A permission used by a called API but not declared (error). An API needing a manifest key is missing-manifest-key. |
missing-vendor-file |
A VENDOR entry (file + source URL) naming a file not present in the submission (warning). |
mistyped-manifest-value |
A known manifest key whose value has the wrong type, validated with ajv against a JSON Schema derived from the annotated schema (warning). Thunderbird misreads such values. |
native-messaging |
The nativeMessaging permission (in permissions or optional_permissions), which lets the add-on exchange messages with a native application outside Thunderbird - routed to manual review to confirm disclosure (No Surprises). |
non-experiment-strict-max-version |
A non-Experiment that pins strict_max_version (warning - it only blocks installs on newer Thunderbird). |
obfuscated-code |
A JS file (not a recognized library) shipped minified or obfuscated - by minified line geometry, _0x… obfuscator identifiers, or eval/Function-of-decoded-string packers (error). High precision, partial recall - some obfuscators evade it. |
privacy-policy |
Data transmitted to a hardcoded remote host by an overt API - routed to manual review to confirm the listing carries a privacy policy disclosing the collection (the policy text is not part of the package). Complements data-exfiltration (which judges consent). |
strict-max-version-bump-only |
Diff check (needs --diff-to): fires (info) when a submission changes only the version and the gecko strict_max_version vs. the prior version - the developer could raise the max on ATN instead of resubmitting. Runs only with --diff-to. |
string-timer |
A code string passed to setTimeout/setInterval (it is eval'd) - dynamic code execution (error). |
sync-xhr |
Synchronous XMLHttpRequest (open(..., false)). |
trademark-violation |
Add-on name (resolved from _locales for a __MSG__ name) using a Mozilla trademark - Firefox/Mozilla/MZLA anywhere, or Thunderbird other than as a trailing "for Thunderbird" (error, case-insensitive). The icon is a separate manual check. |
unknown-api |
Unknown namespaces, unknown members (incl. methods on property types like storage.local.x), and APIs marked unsupported. |
unparsable-file |
A JS file that failed to parse, so its API checks were skipped (info). |
unpinned-dependency |
A package.json dependency declared as a version range with no lock file, so it can't be pinned to one release and verified (error). |
unpinned-vendor-source |
A VENDOR-declared file whose (trusted-host) source is not pinned to an immutable version/tag/commit, so its bytes can't be verified (error). |
unrecognized-manifest-key |
A top-level manifest key the schema does not define - Thunderbird ignores it (warning). |
unsafe-html |
Dynamic content written to innerHTML/outerHTML/srcdoc/insertAdjacentHTML (warning). |
vendor-modified |
A declared third-party file whose bytes don't match its pinned source (EOL-tolerant compare) - it appears modified from upstream (error). |
vendor-unverified |
Declarations that can't be settled automatically - an untrusted-host source, a library not confirmed widely used, an unfetchable source, or an unparsable VENDOR file - routed to manual review. |
Each LLM check always runs its deterministic pre-flight, regardless if LLM support is enabled or not.
Cases the pre-flight can settle become findings directly, and only the
genuinely-ambiguous residue is escalated, per case. When LLM support is not enabled, unsure findings are added to the manual review queue. When LLM support is enabled
(--llm-enabled with an LLM_API_KEY), each escalated case is sent to the model
with the check's rubric and that case's evidence (e.g. the offending file's
source). The model returns a three-way verdict - fail / pass /
unsure - so a confident result is final. Any unsure finding is routed to manual review.
Check id (check:) |
Pre-flight (always) + what the LLM judges |
|---|---|
remote-eval |
Pre-flight: the statically-undecidable fetch()->eval pattern → the LLM judges (given the offending file) whether the executed code is fetched remotely. The definite dynamic-execution cases are the deterministic eval-call/function-constructor/string-timer/csp-unsafe-eval/csp-unsafe-inline checks. |
remote-script |
Pre-flight: remote <script>/<link>/@import/url()/media/imports/importScripts/runtime injection/WASM, and a CSP permitting a remote script source → a finding. Statically-undecidable cases (non-literal URLs, inline data:/blob: script sources) → the LLM judges whether the source is remote. |
data-exfiltration |
Pre-flight: a normal transmission (fetch/XHR/WebSocket/EventSource/sendBeacon) to a remote/dynamic host → the LLM judges, given the file and the options page, whether user data is sent without an explicit opt-in. Covert channels are the separate disguised-* errors. |
missing-english-localization |
Pre-flight: A _locales English directory (en, en-US, …) present → pass. A _locales directory but no English → a finding. No _locales directory → the LLM judges whether user-facing strings are hardcoded in a non-English language. |
minimize-web-accessible-resources |
Pre-flight: over-broad exposure (a resource pattern like *, or MV3 matches of <all_urls>/*://*/*) and concrete resources no content script/page loads → a finding. An ambiguous exposed resource (dynamic loaders, or name mentioned) → the LLM judges whether it is needlessly exposed. |
unused-files |
Pre-flight: hidden/junk by name, and files reachable from no manifest entry point (a reference graph over imports/getURL/HTML/CSS plus schema-derived file-loading APIs) - a clearly-unreferenced file is a finding. An ambiguous file (string-mentioned, or the add-on uses dynamic loaders) → the LLM judges whether it is unused. License/README/VENDOR/_locales are exempt. |
Some review steps can't be automated - they need hands-on testing or a human's
judgment over content the tool can't see (the store listing, screenshots, the
icon). These live under manual-checks in the yaml and are surfaced in the
report's Standard manual review to-do list.
| Check | What the reviewer verifies |
|---|---|
| Check the submission for spam | The listing and add-on for spam or inappropriate, misleading, or low-effort content. |
| Test the add-on and request testing information if needed | Functionality in a test profile, fail if credentials or other info are needed to continue. |
| Check for "No Surprises" policy violations | The code diff for behavior not documented on the ATN listing that could surprise the user. |
| Check for a missing payment disclosure | Whether the add-on requires payment but the "needs payment" flag is not set on ATN. |
| Check suitability for listing | Whether the add-on targets a limited or non-public audience (better self-hosted than listed). |
| Acceptable Use Policy | The name, summary, description, and screenshots against Mozilla's Acceptable Use Policy. |
| Check the icon for trademark or logo imitation | The icon for imitation of the Thunderbird or Mozilla logo (an image the automated checks can't inspect). |
# Review a submitted xpi against the matching schema (read-only - report only)
node verify.js ./submission.xpi
# Review an unpacked source folder
node verify.js ./my-addon
# esr channel, machine-readable, offline schema
node verify.js ./submission.xpi --schema-channel esr --report-format json --schema-zip ./schemas.zip
# Review with the LLM checks enabled, plus an AI summary of the add-on
export LLM_API_KEY=sk-…
node verify.js ./submission.xpi --llm-enabled --full-summary
# Same, but use ChatGPT instead of the default (Claude)
export LLM_API_KEY=sk-… LLM_API_TYPE=chatgpt
node verify.js ./submission.xpi --llm-enabled
# List the models your token can use, then exit (needs a token)
export LLM_API_KEY=sk-…
node verify.js --llm-list-modelsRequires Node >=20 and npm install once. Before sending a change, run:
npm run lint # ESLint over src/ and the root entry files
npm run format:check # Prettier (run `npx prettier --write <glob>` to fix)
npm test # add-on golden snapshots + the unit suiteConventions:
- Prettier-formatted and ESLint-clean - double quotes, semicolons,
printWidth80. - The registry owns every model-facing string - check rubrics, the LLM
system intro, and prompts live in
assets/registry.yaml, never insrc/. - Each source file opens with a header comment stating what belongs in it, keep it accurate when you edit.
- Golden tests are byte-exact - regenerate intended report changes with
UPDATE_GOLDEN=1 npm testand review the diff.
(See tests/README.md for the test suite.)