URL: /configuration/plugins

---
title: "Plugins: run your own audit rules"
sidebarTitle: "Plugins"
icon: "puzzle"
description: "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](#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](#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.

```ts acme-pack.ts
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`, `security` and the rest) and a rule category code (such as `other`) 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`.

<Warning>
Site rules get no DOM. In `squirrel audit` the pages of `ctx.site.pages` carry no document and `ctx.page.html` is an empty string. A site rule reads page facts (URL, status, headers), `ctx.siteQuery` and the signals the engine collected. To look at markup, write a page rule.
</Warning>

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.

```toml squirrel.toml
[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:

```json ~/.squirrel/settings.json
{
  "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.

```bash
squirrel audit https://example.com
```

### Select and ignore plugin rules

- `--rule-exclude acme` skips every rule in the `acme` namespace. `--rule-exclude acme/no-lorem-ipsum` skips one.
- `--rule-include` replaces the `[rules] enable` list, so name the namespace there too: `--rule-include core,acme`.
- `[[ignore]]` entries work as they do for built-in rules. See [Ignore](/configuration/ignore).
- `[rule_options."acme/no-lorem-ipsum"]` passes options to a rule that declares an `optionsSchema`.

## 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.json` pins each approved plugin to one file: the `id`, the absolute `entry` path and the `sha256` of the file. That file is the only place the decision is read. A project's `.squirrel/settings.json`, an environment variable and `squirrel.toml` itself cannot approve a plugin.
- A plugin loads only when the `entry` in `squirrel.toml` is 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 same `id` with 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 whose `entry` is not an absolute path, or that has no valid `sha256`, 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.json` cannot 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 `register` function 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.

<Warning>
Only approve plugin files you wrote or have read. Approval is trust, not containment: once a plugin is approved it runs fully trusted, in the `squirrel` process, with your permissions. It can read your environment variables and files, use the network and run commands. The `allow` list below is not a sandbox. It restricts what an honest plugin may register, and it does nothing to stop a malicious one, because a malicious plugin does not need the registration API to do harm. The hash pin makes sure the code you read is the code that runs, nothing more.
</Warning>

### 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 validate` reports `[rule_options]` and `[[ignore]]` entries for a plugin rule as unknown, because validation does not load plugins.
- `squirrel report` on 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.

```ts
await Effect.runPromise(
  runStreamingRules(storage, crawlId, config, assets, undefined, {
    plugins: { rules: [myRule], listeners: new Map() },
  }),
);
```
