Plugins: run your own audit rules
Write a rule pack in TypeScript, name it in squirrel.toml, approve it in your user settings, and run it with squirrel audit
A plugin is a TypeScript or JavaScript file that registers audit rules. squirrel audit runs them in the same pass as the built-in rules, so [rules], --rule-include, --rule-exclude, [[ignore]], scoring and the report treat them the same way.
A plugin is code, and it runs with your permissions. Read the security model before you turn one on.
A plugin file must be self-contained: bundle its dependencies into the one file. The file is loaded from a verified copy in ~/.squirrel/plugin-cache/ (see the security model), so a relative import inside it does not resolve, and a package import fails the load unless the package is bundled in.
Write a plugin
A plugin file has one default export with a register function. Call registerRule once per rule.
export default {
name: "Acme pack",
version: "1.0.0",
capabilities: ["rules"],
register(ctx) {
ctx.registerRule({
meta: {
id: "acme/no-lorem-ipsum",
name: "No placeholder text",
description: "Flags pages that still contain lorem ipsum",
solution: "Replace the placeholder text with real copy.",
category: "content",
scope: "page",
verdictScope: "page",
severity: "warning",
weight: 3,
},
run(ctx) {
const text = ctx.parsed.document?.body?.textContent ?? "";
const found = /lorem ipsum/i.test(text);
return {
checks: [
{
name: "no-lorem-ipsum",
status: found ? "warn" : "pass",
message: found ? "Placeholder text found" : "No placeholder text",
},
],
};
},
});
},
};Rule ids and namespaces
A rule id is <namespace>/<name>: lowercase letters, digits and hyphens. Pick a namespace of your own, and use it for every rule in the pack.
- A plugin rule can never replace or shadow a built-in rule. An id that equals a built-in id fails the load with an error that names the id, and no built-in rule changes.
- A built-in namespace (
core,content,securityand the rest) and a rule category code (such asother) are not available. - Registering the same id twice fails the load.
Page rules and site rules
Set scope to "page" or "site". Always set verdictScope: "page" on a page rule. That keeps the engine from copying one page’s verdict to other pages that share a template.
Page rules get the page’s parsed document in ctx.parsed, its response in ctx.page, and the rule’s options in ctx.options.
Both kinds of rule get the same context as a built-in rule, including ctx.cloudResults when the audit fetched cloud results, and ctx.siteMetadata.
Run a plugin
Name the plugin in squirrel.toml. A relative entry is resolved from the directory of the config file.
[plugins]
enabled = true
[[plugins.items]]
id = "acme-pack"
entry = "./acme-pack.ts"
allow = ["rules"]Then approve it in your own ~/.squirrel/settings.json. An approval names the plugin id, the absolute path of the file, and the SHA-256 of the file’s contents:
{
"plugin_allowlist": [
{
"id": "acme-pack",
"entry": "/home/you/projects/site/acme-pack.ts",
"sha256": "3f2a...64 hex characters...9c1e"
}
]
}You do not have to work the line out by hand. Run the audit once: it skips the plugin and prints the line to add, with the real path of the file it found and the command that gives its hash. Read the file, then run shasum -a 256 /home/you/projects/site/acme-pack.ts (or sha256sum on Linux) and paste the line in with that hash. squirrel does not open or hash a file you have not approved, so the hash is for you to compute.
Run the audit as usual. The plugin’s issues appear in the report under its namespace.
squirrel audit https://example.comSelect and ignore plugin rules
--rule-exclude acmeskips every rule in theacmenamespace.--rule-exclude acme/no-lorem-ipsumskips one.--rule-includereplaces the[rules] enablelist, so name the namespace there too:--rule-include core,acme.[[ignore]]entries work as they do for built-in rules. See Ignore.[rule_options."acme/no-lorem-ipsum"]passes options to a rule that declares anoptionsSchema.
Security model
The project’s squirrel.toml can only ask for a plugin. It can never make squirrel audit run one, and it can only select among files you have approved.
- Your
~/.squirrel/settings.jsonpins each approved plugin to one file: theid, the absoluteentrypath and thesha256of the file. That file is the only place the decision is read. A project’s.squirrel/settings.json, an environment variable andsquirrel.tomlitself cannot approve a plugin. - A plugin loads only when the
entryinsquirrel.tomlis the pinned file and the file’s hash equals the pin. Both paths have symlinks resolved before they are compared, so a symlink cannot stand in for the pinned file. A config that names the sameidwith another file is refused, and so is a pinned file whose content has changed. - A bare id in
plugin_allowlist(such as"acme-pack") approves nothing. An entry whoseentryis not an absolute path, or that has no validsha256, approves nothing either. The audit prints why, and the line to add. - Without an approval the plugin file is not imported, so none of its code runs. The audit prints one line per plugin it skipped, with the reason.
- If
settings.jsoncannot be read, nothing is allowed. - An approved plugin that cannot be loaded (a bad id, an id that clashes with a built-in rule, a rule the entry did not allow, a module or
registerfunction that throws) stops the audit before the crawl, with an error that names the plugin. Nothing is dropped silently.
squirrel decides from the id and the path alone whether a file is approved, before it opens anything. A file named by squirrel.toml that no pin covers is never opened, so a FIFO, a device or a huge file there cannot hang or exhaust the audit. A pinned file must be a regular file of at most 4 MiB, or the plugin is refused with a message that says so. squirrel then reads it once, checks the hash, and imports exactly the bytes it hashed. It writes them to a private copy under ~/.squirrel/plugin-cache/, never the shared system temp directory. Every directory squirrel creates there is mode 0700, each run gets its own subdirectory, and the subdirectory is deleted as soon as the plugin is loaded, even when the load fails. squirrel refuses to load any plugin if ~/.squirrel/plugin-cache/ is a symlink, is owned by another user, or can be written by other users. A change to the original file between the check and the import therefore cannot run different code.
Module resolution from the copy walks up the directories above it looking for node_modules. If any directory above ~/.squirrel/plugin-cache/ (the directory itself included, so also your home directory and /) holds a node_modules, squirrel refuses to load plugins and names it, because a package import in a plugin could then pull in code the hash does not cover. That is why a plugin must be self-contained.
The residual risk: while the copy exists, another process running as your user could modify it. That user can already edit your settings.json, so it is not a new boundary.
Capabilities
The allow list in each [[plugins.items]] entry says what the plugin may register: "rules" and "listeners". Registering something the entry does not allow fails the load. This is a check on honest plugins, which catches a mistake or a surprise in a pack you trust. It is not a security boundary, because plugin code runs fully trusted.
register may be async. squirrel waits for it before the audit starts, and a register that throws or rejects fails the load with an error that names the plugin.
A listener subscribes to an event name. The rules phase emits audit:rules-phase with { phase, boundary }. A listener that throws is logged and ignored.
What a plugin rule does not change
- Public rule counts, the rule reference pages and the rule-count checks count built-in rules only.
- An audit with plugin rules does not reuse cached per-page results, because the cache key cannot see a plugin’s code.
squirrel config validatereports[rule_options]and[[ignore]]entries for a plugin rule as unknown, because validation does not load plugins.squirrel reporton an older audit shows a plugin rule’s issues under a generic category, because the plugin is not loaded then.
Embed the engine
A host that runs the audit engine in its own process, and keeps externalPlugins off, can still add a rule pack. It passes an in-process snapshot to runStreamingRules as the plugins option. No file is loaded, and the same id checks apply.
await Effect.runPromise(
runStreamingRules(storage, crawlId, config, assets, undefined, {
plugins: { rules: [myRule], listeners: new Map() },
}),
);