diff --git a/decisions/0016-eslint-style-per-rule-options-shape.md b/decisions/0016-eslint-style-per-rule-options-shape.md new file mode 100644 index 0000000..3947118 --- /dev/null +++ b/decisions/0016-eslint-style-per-rule-options-shape.md @@ -0,0 +1,174 @@ +--- +status: accepted +date: 2026-06-09 +--- + +# ESLint-style per-rule build-time options shape + +## Context and Problem Statement + +The build-time override file shipped with ADR-0009 maps each registered rule id +to a single boolean. That shape covers "rule on / rule off" but not "selectively +disable one of the rule's internal detectors." `encoded-payload-redact` runs +seven detectors inside one rule (base64, hex, percent, substitution ciphers, +leetspeak, NATO phonetic, Morse), three of which — leetspeak, NATO, and Morse — +have higher false-positive rates by construction because their qualifiers rest +on common-word counts rather than printable-ratio (PR #232 §"Summary"). +Operators today have only two choices: accept the misfires or disable the entire +rule and lose the byte-encoding coverage they actually want (PR #232 +§"Summary"). + +Spec 0011 §"Future work" had already noted "Per-host default overrides — today +overrides are flat and global" as a related gap; sub-rule control is the +adjacent shape question — *how* to add structure to the per-rule value without +breaking the flat-boolean contract the Options-page export depends on (spec 0011 +FR-7: "a JSON exported from a tuned extension can be fed straight back into the +next build"). + +## Decision Drivers + +- The flat boolean shape and the Options-page export round-trip (spec 0011 FR-7) + must keep working — a user who exports their toggles and feeds the JSON back + into `--defaults` must still get a valid build. +- Validation must fail loudly with path-qualified messages (spec 0011 FR-4): + "Infra operators want loud failures, not silent drift if a rule was renamed" + (`extension/scripts/load-default-overrides.ts` header). +- The same source-of-truth invariants ADR-0009 set up must keep holding: the + service-worker bundle can't depend on rule-file DOM access + (`extension/src/rules/rule-metadata.ts` header; ADR-0013), and + `catalog.test.ts` must continue to enforce parity (ADR-0009 §"Confirmation"). +- Build-time only for this iteration. PR #232 §"Implementation" scopes the + change to build-time configuration; runtime sub-rule toggling via the Options + page is explicitly out of scope. + +## Considered Options + +- **ESLint-style object value** — a rule's value is either a boolean (existing + behaviour) or `{ "enabled"?: boolean, ...subRuleOptions }`. Mirrors the ESLint + convention where a rule entry can be `"rule-name": "error"` or + `"rule-name": ["error", { options }]`. +- **Top-level `ruleOptions` sibling key** — the rule entry stays boolean; + per-rule options live under a separate `ruleOptions: { "rule-id": { ... } }` + field on the same flat object. +- **Dot-notation flat keys** — stay flat at one level: keys like + `encoded-payload-redact.subRules.leetspeak` mapped directly to booleans. + +## Decision Outcome + +Chosen option: **ESLint-style object value**, gated on the rule declaring a +sub-rule shape in `extension/src/rules/rule-metadata.ts`'s +`RULE_OPTION_DEFAULTS`. + +- A rule's value in the override file may be a boolean (existing behaviour) *or* + an object `{ "enabled"?: boolean, ...subRuleOptions }`. Object values for + rules without a declared shape fail the build (spec 0011 FR-4, reworded by PR + #232 to include the new failure mode). +- `enabled` in the object form is optional; absence keeps the rule's committed + `RULE_DEFAULTS` state. Partial sub-rule objects merge over + `RULE_OPTION_DEFAULTS` so omitted sub-rules keep their defaults (spec 0011 + FR-2a; FR-5 generalized). +- The validator (`extension/scripts/load-default-overrides.ts`) walks the + declared option-shape tree recursively rather than hard-coding `subRules`, so + future rules can declare other option groups without touching the loader (PR + #232 §"Implementation"). +- Build-time injection: `extension/build.ts` adds a new + `process.env.EXTENSION_RULE_OPTIONS` define alongside the existing + `EXTENSION_DEFAULT_OVERRIDES`; rules consume it through + `extension/src/lib/rule-options.ts`'s typed `getRuleOptions(id)` accessor. + Malformed JSON silently degrades to defaults, mirroring `lib/storage.ts` (spec + 0011 NFR-S-2 generalized by PR #232). +- First (and only) consumer: `extension/src/rules/encoded-payload-redact.ts`, + which exposes one sub-rule per encoding family — the three substitution + decoders (ROT13 / Atbash / reverse) share one `substitutionCipher` toggle + because they share the candidate window and first-match-wins resolution (PR + #232 §"Summary"). + +### Consequences + +- Good, because the Options-page export shape stays flat-boolean and the + round-trip into the next build still works: object values are purely additive + (PR #232 §"Summary"; spec 0011 FR-2a). +- Good, because the rule entry remains the single keyed identity for a rule in + the override file — enable/disable and sub-rule configuration co-locate rather + than splitting into a sibling namespace. +- Good, because the recursive validator means new rules that take options don't + need new loader code, only a new `RULE_OPTION_DEFAULTS` entry; the catalog + test enforces every entry has corresponding metadata (PR #232 + §"Implementation"; `extension/src/rules/__tests__/catalog.test.ts` new + invariants). +- Neutral, because the `Rule` interface (`extension/src/rules/types.ts`) did not + change. Rules read their options via `getRuleOptions` at module init rather + than receiving them through `apply()`. This keeps the engine API stable but + means the lookup is implicit at the rule's call site rather than injected by + the engine (PR #232 §"Implementation": "the rule reads its options at module + init rather than receiving them through `apply()`"). +- Bad, because the `enabled` field overlaps with the flat-boolean shape — a + reader can write `"encoded-payload-redact": false` or + `"encoded-payload-redact": { "enabled": false }` and get the same result. The + loader maps both to the same `rules[key] = enabled` write + (`extension/scripts/load-default-overrides.ts` — boolean branch and the + `enabled` extraction inside the object branch). +- Bad / future work: per-rule options remain build-time only. Runtime sub-rule + toggling via the Options page is not in this iteration (PR #232 + §"Implementation" / §"Summary"). + +### Confirmation + +- `extension/scripts/__tests__/load-default-overrides.test.ts` adds the + validator cases for the object form: missing `enabled`, unknown sub-rule key, + unknown top-level group under a rule object, non-boolean leaf, non-boolean + `enabled`, partial sub-rule object, and object value for a rule without + declared options (PR #232 §"Test plan"). +- `extension/src/rules/__tests__/catalog.test.ts` adds two invariants: every key + in `RULE_OPTION_DEFAULTS` must also appear in `RULE_DEFAULTS`, and every leaf + in the option-shape tree must be a boolean (PR #232 §"Test plan"). +- `extension/src/rules/__tests__/encoded-payload-redact.test.ts` exercises each + sub-rule toggle by reloading the rule module under a freshly-set + `EXTENSION_RULE_OPTIONS` env via `jest.isolateModulesAsync` (PR #232 §"Test + plan"). +- End-to-end build smoke (PR #232 §"Test plan"): a valid override builds with + the new "Applying N override(s)" count incremented; four malformed inputs + (object value for a rule without options, unknown sub-rule key, non-boolean + leaf, unknown top-level group) each fail with a clear path-qualified message. + +## Pros and Cons of the Options + +### ESLint-style object value + +- Good, because each rule's state stays under a single top-level key — the + enable/disable flag and the sub-rule configuration co-locate. Mirrors a shape + contributors already know from ESLint and similar lint tooling. +- Good, because the existing flat-boolean shape stays valid (PR #232 §"Summary": + "Plain booleans still work"). +- Bad, because object and boolean values for the same key co-exist, so the + override-file schema has two shapes per rule (the loader's boolean and object + branches in `extension/scripts/load-default-overrides.ts` both produce a + boolean write at the storage layer). + +### Top-level `ruleOptions` sibling key + +- Good, because the enable/disable namespace stays purely flat-boolean (mirrors + today's Options-page export 1:1). +- Bad, because per-rule state splits across two top-level keys for any rule with + options. A reader looking at `encoded-payload-redact`'s state has to consult + two places. + +### Dot-notation flat keys + +- Good, because the file stays a single flat map of string-to-boolean. +- Bad, because keys are stringly typed + (`encoded-payload-redact.subRules.leetspeak`) rather than structurally typed; + harder for tooling and for humans to scan at a glance. + +## More Information + +- PR + [#232 — Add: ESLint-style per-rule build-time options (encoded-payload sub-rules)](https://github.com/pixiebrix/agent-browser-shield/pull/232) +- Spec [0011](../specs/0011-build-time-customization.md) — Build-time + customization (FR-2a added by PR #232) +- [ADR-0009](./0009-rule-defaults-and-build-time-overrides.md) — the parent + decision establishing `rule-metadata.ts` and the `--defaults` flag +- [ADR-0013](./0013-background-worker-purity-canary.md) — the service-worker + purity invariant `rule-metadata.ts` must respect +- `extension/data/defaults-overrides.example.json` — starting template, updated + with the object-form example diff --git a/decisions/README.md b/decisions/README.md index cc6a173..11bd3a9 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -29,6 +29,7 @@ that is not supported by one of those citations. | [0013](./0013-background-worker-purity-canary.md) | Keep rule files out of the background service worker; enforce with a build-time purity canary | Accepted | | [0014](./0014-css-first-hide-for-selector-only-rules.md) | CSS-first hide for selector-only `removeEntirely` rules | Accepted | | [0015](./0015-calver-workflow-driven-release.md) | CalVer + `workflow_dispatch`-driven extension release | Accepted | +| [0016](./0016-eslint-style-per-rule-options-shape.md) | ESLint-style per-rule build-time options shape | Accepted | ## Conventions diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index ba27e73..fd58c45 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -123,9 +123,38 @@ set of reserved keys is also accepted for non-rule build-time toggles: display* section so humans can flip it without rebuilding. Enable for deployments on consistently dark UIs. +A handful of rules expose sub-rule options in addition to the plain on/off +toggle. For those, a rule's value may be an ESLint-style object instead of a +boolean: + +```json +{ + "encoded-payload-redact": { + "enabled": true, + "subRules": { + "leetspeak": false, + "nato": false, + "morse": false + } + } +} +``` + +`enabled` is optional — when absent, the rule's committed default state is used. +Sub-rule fields are merged over the committed sub-rule defaults; omitted +sub-rules keep their default state. The rules that take sub-rule options and the +fields each accepts are declared in +[`extension/src/rules/rule-metadata.ts`](https://github.com/pixiebrix/agent-browser-shield/blob/main/extension/src/rules/rule-metadata.ts) +under `RULE_OPTION_DEFAULTS`. Today the only rule that takes options is +`encoded-payload-redact`, which exposes one sub-rule per encoding family +(`base64`, `hex`, `percent`, `substitutionCipher`, `leetspeak`, `nato`, `morse`) +— useful for turning off the higher-false-positive text ciphers without losing +coverage of the byte encodings. + The file may be partial; rules not listed keep the committed default. Unknown -keys (neither a registered rule id nor a reserved key) and non-boolean values -fail the build with a message naming them. +keys (rule ids, reserved keys, or sub-rule fields), object values for rules +without declared sub-rule options, and non-boolean values fail the build with a +message naming the offending paths. Build-time overrides only affect **fresh** `chrome.storage` — users who already toggled rules in the Options UI keep their preferences. The typical target is diff --git a/extension/build.ts b/extension/build.ts index 713563d..36988e3 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -90,8 +90,11 @@ async function build(): Promise { // Resolve the optional --defaults / EXTENSION_DEFAULTS_FILE override // against the hand-edited RULE_DEFAULTS so the validator knows the current - // rule registry. - const { RULE_DEFAULTS } = await import("./src/rules/rule-metadata"); + // rule registry. RULE_OPTION_DEFAULTS supplies the per-rule option shapes + // for any rule that takes ESLint-style sub-rule configuration. + const { RULE_DEFAULTS, RULE_OPTION_DEFAULTS } = await import( + "./src/rules/rule-metadata" + ); const knownRuleIds = Object.keys(RULE_DEFAULTS); const overrides = defaultsPath ? loadDefaultOverrides({ @@ -99,11 +102,13 @@ async function build(): Promise { ? defaultsPath : resolve(process.cwd(), defaultsPath), knownRuleIds, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, }) - : { rules: {} }; + : { rules: {}, ruleOptions: {} }; if (defaultsPath) { const changed = Object.keys(overrides.rules).length + + Object.keys(overrides.ruleOptions).length + (overrides.optionsButton === undefined ? 0 : 1) + (overrides.runOnInactiveTabs === undefined ? 0 : 1) + (overrides.debugTrace === undefined ? 0 : 1) + @@ -172,6 +177,9 @@ async function build(): Promise { "process.env.EXTENSION_DEFAULT_OVERRIDES": JSON.stringify( JSON.stringify(overrides.rules), ), + "process.env.EXTENSION_RULE_OPTIONS": JSON.stringify( + JSON.stringify(overrides.ruleOptions), + ), "process.env.EXTENSION_OPTIONS_BUTTON_DEFAULT": JSON.stringify( overrides.optionsButton === undefined ? "" diff --git a/extension/data/defaults-overrides.example.json b/extension/data/defaults-overrides.example.json index 28d637d..6f8bd88 100644 --- a/extension/data/defaults-overrides.example.json +++ b/extension/data/defaults-overrides.example.json @@ -3,6 +3,15 @@ "ads-hide": false, "irrelevant-sections-redact": true, + "encoded-payload-redact": { + "enabled": true, + "subRules": { + "leetspeak": false, + "nato": false, + "morse": false + } + }, + "optionsButton": true, "runOnInactiveTabs": false, "debugTrace": false, diff --git a/extension/scripts/__tests__/load-default-overrides.test.ts b/extension/scripts/__tests__/load-default-overrides.test.ts index 24736de..76f1a1b 100644 --- a/extension/scripts/__tests__/load-default-overrides.test.ts +++ b/extension/scripts/__tests__/load-default-overrides.test.ts @@ -34,6 +34,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: { "pii-redact": true, "reviews-redact": false }, + ruleOptions: {}, }); }); @@ -41,7 +42,7 @@ describe("loadDefaultOverrides", () => { const file = writeFile("empty.json", "{}"); expect( loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), - ).toEqual({ rules: {} }); + ).toEqual({ rules: {}, ruleOptions: {} }); }); it("extracts the optionsButton reserved key alongside rules", () => { @@ -53,6 +54,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: { "pii-redact": true }, + ruleOptions: {}, optionsButton: false, }); }); @@ -66,6 +68,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: {}, + ruleOptions: {}, optionsButton: true, }); }); @@ -89,6 +92,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: { "pii-redact": true }, + ruleOptions: {}, runOnInactiveTabs: true, }); }); @@ -112,6 +116,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: { "pii-redact": true }, + ruleOptions: {}, debugTrace: true, }); }); @@ -125,6 +130,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: {}, + ruleOptions: {}, debugTrace: false, }); }); @@ -148,6 +154,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: { "pii-redact": true }, + ruleOptions: {}, placeholderAdaptivePalette: true, }); }); @@ -161,6 +168,7 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toEqual({ rules: {}, + ruleOptions: {}, placeholderAdaptivePalette: false, }); }); @@ -227,4 +235,157 @@ describe("loadDefaultOverrides", () => { loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), ).toThrow(/unknown keys: bogus-rule.*non-boolean values for: pii-redact/); }); + + describe("per-rule options (ESLint-style object value)", () => { + const RULE_OPTION_DEFAULTS = { + "ads-hide": { + subRules: { + base64: true, + hex: true, + leetspeak: true, + }, + }, + } as const; + + it("accepts an object value with enabled and a partial sub-rule object", () => { + const file = writeFile( + "with-options.json", + JSON.stringify({ + "pii-redact": true, + "ads-hide": { + enabled: false, + subRules: { leetspeak: false }, + }, + }), + ); + expect( + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toEqual({ + rules: { "pii-redact": true, "ads-hide": false }, + ruleOptions: { + "ads-hide": { subRules: { leetspeak: false } }, + }, + }); + }); + + it("treats a missing `enabled` field as unset (keeps committed default)", () => { + const file = writeFile( + "no-enabled.json", + JSON.stringify({ + "ads-hide": { subRules: { hex: false } }, + }), + ); + expect( + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toEqual({ + rules: {}, + ruleOptions: { + "ads-hide": { subRules: { hex: false } }, + }, + }); + }); + + it("rejects an object value for a rule without declared options", () => { + const file = writeFile( + "wrong-rule.json", + JSON.stringify({ "pii-redact": { enabled: true } }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/object value for rules without declared options: pii-redact/); + }); + + it("rejects unknown sub-rule keys with a path-qualified name", () => { + const file = writeFile( + "unknown-subrule.json", + JSON.stringify({ + "ads-hide": { subRules: { bogus: false } }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/unknown option keys: ads-hide\.subRules\.bogus/); + }); + + it("rejects unknown top-level keys under a rule object", () => { + const file = writeFile( + "unknown-group.json", + JSON.stringify({ + "ads-hide": { unknownGroup: { leetspeak: false } }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/unknown option keys: ads-hide\.unknownGroup/); + }); + + it("rejects non-boolean sub-rule leaves with a path-qualified name", () => { + const file = writeFile( + "nonbool-subrule.json", + JSON.stringify({ + "ads-hide": { subRules: { leetspeak: "off" } }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/non-boolean option values for: ads-hide\.subRules\.leetspeak/); + }); + + it("rejects a non-boolean `enabled` field", () => { + const file = writeFile( + "nonbool-enabled.json", + JSON.stringify({ + "ads-hide": { enabled: "yes" }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/non-boolean values for: ads-hide\.enabled/); + }); + + it("omits the rule from ruleOptions when no sub-rule overrides are provided", () => { + const file = writeFile( + "enabled-only.json", + JSON.stringify({ "ads-hide": { enabled: true } }), + ); + expect( + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toEqual({ + rules: { "ads-hide": true }, + ruleOptions: {}, + }); + }); + }); }); diff --git a/extension/scripts/load-default-overrides.ts b/extension/scripts/load-default-overrides.ts index 07179cb..6d0405d 100644 --- a/extension/scripts/load-default-overrides.ts +++ b/extension/scripts/load-default-overrides.ts @@ -5,13 +5,15 @@ // build.ts when the operator passes `--defaults ` or // `EXTENSION_DEFAULTS_FILE=`. // -// The file is a flat JSON object. Most keys are rule ids mapped to booleans — -// the same shape the in-extension Options page exports/imports. A small set -// of reserved keys is also accepted for non-rule build-time toggles (e.g. -// `optionsButton`, which controls the floating on-page options button). +// The file is a flat JSON object. Most keys are rule ids — usually mapped to +// booleans (same shape as the in-extension Options page export). Rules that +// declare a sub-rule option shape in `RULE_OPTION_DEFAULTS` may instead take +// an ESLint-style object value `{ enabled?: boolean, ...optionShape }`. A +// small set of reserved keys is also accepted for non-rule build-time toggles +// (e.g. `optionsButton`). // // Validation is strict: unknown keys (neither a registered rule id nor a -// reserved key) and non-boolean values fail the build. Infra operators want +// reserved key) and ill-typed values fail the build. Infra operators want // loud failures, not silent drift if a rule was renamed. import { readFileSync } from "node:fs"; @@ -19,19 +21,25 @@ import { readFileSync } from "node:fs"; export interface LoadOverridesOptions { path: string; knownRuleIds: readonly string[]; + // Map of rule ids to their option-shape default tree. Rules absent from + // this map only accept a plain boolean value in the override file. Walking + // this tree drives sub-rule validation (unknown keys / non-boolean leaves + // are reported with a path like `encoded-payload-redact.subRules.leetspeak`). + ruleOptionDefaults?: Readonly>; } export interface DefaultOverrides { rules: Record; + // Validated per-rule option values, keyed by rule id. Values are + // structurally-validated subsets of the corresponding `ruleOptionDefaults` + // entry (only override-present leaves are included). + ruleOptions: Record; optionsButton?: boolean; runOnInactiveTabs?: boolean; debugTrace?: boolean; placeholderAdaptivePalette?: boolean; } -// Reserved top-level keys are not rule ids; the loader maps each one to a -// typed field on `DefaultOverrides`. Add new build-time toggles here as they -// appear. const RESERVED_KEYS = new Set([ "optionsButton", "runOnInactiveTabs", @@ -39,10 +47,53 @@ const RESERVED_KEYS = new Set([ "placeholderAdaptivePalette", ]); +// Walks the per-rule override object against the rule's option-shape default +// tree. Collects unknown-key and non-boolean-leaf paths into `unknownPaths` / +// `nonBooleanPaths` so the loader can report them alongside any top-level +// issues in a single error message. +function validateRuleOptions( + prefix: string, + defaultTree: Readonly>, + override: Record, + unknownPaths: string[], + nonBooleanPaths: string[], +): Record { + const validated: Record = {}; + for (const [key, value] of Object.entries(override)) { + if (!(key in defaultTree)) { + unknownPaths.push(`${prefix}.${key}`); + continue; + } + const defaultValue = defaultTree[key]; + if (typeof defaultValue === "boolean") { + if (typeof value !== "boolean") { + nonBooleanPaths.push(`${prefix}.${key}`); + continue; + } + validated[key] = value; + continue; + } + if (defaultValue && typeof defaultValue === "object") { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + nonBooleanPaths.push(`${prefix}.${key}`); + continue; + } + validated[key] = validateRuleOptions( + `${prefix}.${key}`, + defaultValue as Readonly>, + value as Record, + unknownPaths, + nonBooleanPaths, + ); + } + } + return validated; +} + export function loadDefaultOverrides( options: LoadOverridesOptions, ): DefaultOverrides { - const { path, knownRuleIds } = options; + const { path, knownRuleIds, ruleOptionDefaults = {} } = options; let raw: string; try { @@ -73,8 +124,12 @@ export function loadDefaultOverrides( const known = new Set(knownRuleIds); const unknownIds: string[] = []; const nonBooleanIds: string[] = []; + const objectsForRulesWithoutOptions: string[] = []; + const unknownOptionPaths: string[] = []; + const nonBooleanOptionPaths: string[] = []; const rules: Record = {}; - const result: DefaultOverrides = { rules }; + const ruleOptions: Record = {}; + const result: DefaultOverrides = { rules, ruleOptions }; for (const [key, value] of Object.entries( parsed as Record, @@ -108,20 +163,70 @@ export function loadDefaultOverrides( unknownIds.push(key); continue; } - if (typeof value !== "boolean") { - nonBooleanIds.push(key); + if (typeof value === "boolean") { + rules[key] = value; continue; } - rules[key] = value; + // Object value — only allowed for rules with declared options. + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const defaultsForRule = ruleOptionDefaults[key]; + if (!defaultsForRule || typeof defaultsForRule !== "object") { + objectsForRulesWithoutOptions.push(key); + continue; + } + const valueObject = value as Record; + // `enabled` is reserved at the rule-object root and projects back onto + // the flat boolean storage shape. It does not appear in the + // option-shape default tree, so peel it off before recursing. + if ("enabled" in valueObject) { + const enabled = valueObject.enabled; + if (typeof enabled === "boolean") { + rules[key] = enabled; + } else { + nonBooleanIds.push(`${key}.enabled`); + } + } + const optionsOnly: Record = {}; + for (const [k, v] of Object.entries(valueObject)) { + if (k !== "enabled") { + optionsOnly[k] = v; + } + } + const validated = validateRuleOptions( + key, + defaultsForRule as Readonly>, + optionsOnly, + unknownOptionPaths, + nonBooleanOptionPaths, + ); + if (Object.keys(validated).length > 0) { + ruleOptions[key] = validated; + } + continue; + } + nonBooleanIds.push(key); } const issues: string[] = []; if (unknownIds.length > 0) { issues.push(`unknown keys: ${unknownIds.join(", ")}`); } + if (objectsForRulesWithoutOptions.length > 0) { + issues.push( + `object value for rules without declared options: ${objectsForRulesWithoutOptions.join(", ")}`, + ); + } + if (unknownOptionPaths.length > 0) { + issues.push(`unknown option keys: ${unknownOptionPaths.join(", ")}`); + } if (nonBooleanIds.length > 0) { issues.push(`non-boolean values for: ${nonBooleanIds.join(", ")}`); } + if (nonBooleanOptionPaths.length > 0) { + issues.push( + `non-boolean option values for: ${nonBooleanOptionPaths.join(", ")}`, + ); + } if (issues.length > 0) { throw new Error( `Defaults file ${path} failed validation — ${issues.join("; ")}`, diff --git a/extension/src/lib/rule-options.ts b/extension/src/lib/rule-options.ts new file mode 100644 index 0000000..d9f6d24 --- /dev/null +++ b/extension/src/lib/rule-options.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Build-time per-rule options. The `--defaults ` / `EXTENSION_DEFAULTS_FILE` +// loader emits an object value for any rule listed in `RULE_OPTION_DEFAULTS`; +// `extension/build.ts` serializes that object into the bundle via the +// `process.env.EXTENSION_RULE_OPTIONS` define substitution. Rules with sub-rule +// configuration read their merged options from this module at module init. +// +// A malformed `EXTENSION_RULE_OPTIONS` payload silently degrades to the +// committed defaults rather than crashing the engine — mirrors the +// `parseOverrides` behaviour in `lib/storage.ts` (spec 0011 NFR-S-2). + +import type { RuleOptions, RuleWithOptionsId } from "../rules/rule-metadata"; +import { RULE_OPTION_DEFAULTS } from "../rules/rule-metadata"; + +function parseRuleOptionsEnv(): Record { + const raw = process.env.EXTENSION_RULE_OPTIONS; + if (!raw) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return parsed as Record; +} + +// Recursively merges a validated override tree over a default tree. Only +// boolean leaves at positions that exist in the default tree are accepted; +// anything else falls back to the default — defence-in-depth against a +// malformed bundle slipping past the build-time loader. +function mergeBooleanTree(defaults: T, overrides: unknown): T { + if ( + overrides === null || + typeof overrides !== "object" || + Array.isArray(overrides) + ) { + return defaults; + } + const result: Record = { + ...(defaults as Record), + }; + for (const [key, defaultValue] of Object.entries( + defaults as Record, + )) { + const candidate = (overrides as Record)[key]; + if (candidate === undefined) { + continue; + } + if (typeof defaultValue === "boolean") { + if (typeof candidate === "boolean") { + result[key] = candidate; + } + continue; + } + if (defaultValue && typeof defaultValue === "object") { + result[key] = mergeBooleanTree(defaultValue, candidate); + } + } + return result as T; +} + +const ENV_OVERRIDES = parseRuleOptionsEnv(); + +const RESOLVED_OPTIONS: RuleOptions = Object.fromEntries( + Object.entries(RULE_OPTION_DEFAULTS).map(([id, defaults]) => [ + id, + mergeBooleanTree(defaults, ENV_OVERRIDES[id]), + ]), +) as RuleOptions; + +export function getRuleOptions( + id: Id, +): RuleOptions[Id] { + return RESOLVED_OPTIONS[id]; +} diff --git a/extension/src/rules/__tests__/catalog.test.ts b/extension/src/rules/__tests__/catalog.test.ts index 58ede1d..81a9536 100644 --- a/extension/src/rules/__tests__/catalog.test.ts +++ b/extension/src/rules/__tests__/catalog.test.ts @@ -27,7 +27,7 @@ jest.mock("abort-utils", () => ({ import { RULE_GROUPS } from "../../lib/rule-groups"; import { RULE_LABELS } from "../../popup/rule-labels"; import { RULE_IDS, RULES } from ".."; -import { RULE_DEFAULTS } from "../rule-metadata"; +import { RULE_DEFAULTS, RULE_OPTION_DEFAULTS } from "../rule-metadata"; describe("rule catalog invariants", () => { it("ships at least one rule", () => { @@ -135,6 +135,41 @@ describe("rule catalog invariants", () => { expect(mismatches).toEqual([]); }); + // RULE_OPTION_DEFAULTS declares the ESLint-style sub-rule shape consumed by + // the build-time defaults loader. Every rule with options must also be in + // RULE_DEFAULTS (so the loader doesn't dangle); every leaf of the option + // tree must be a boolean (the loader and runtime accessor both assume that + // shape). + it("RULE_OPTION_DEFAULTS keys all appear in RULE_DEFAULTS", () => { + const missing = Object.keys(RULE_OPTION_DEFAULTS).filter( + (id) => !(id in RULE_DEFAULTS), + ); + expect(missing).toEqual([]); + }); + + it("RULE_OPTION_DEFAULTS sub-rule trees only contain boolean leaves", () => { + function findNonBooleanLeaves(node: unknown, prefix: string): string[] { + if (typeof node === "boolean") { + return []; + } + if (node === null || typeof node !== "object" || Array.isArray(node)) { + return [prefix]; + } + const issues: string[] = []; + for (const [key, value] of Object.entries( + node as Record, + )) { + issues.push(...findNonBooleanLeaves(value, `${prefix}.${key}`)); + } + return issues; + } + const offenders: string[] = []; + for (const [id, options] of Object.entries(RULE_OPTION_DEFAULTS)) { + offenders.push(...findNonBooleanLeaves(options, id)); + } + expect(offenders).toEqual([]); + }); + // Reactive availability accessors must expose both `get` and `subscribe` // — the rule engine and the UI both depend on the pair. it("reactive `available` accessors expose get + subscribe", () => { diff --git a/extension/src/rules/__tests__/encoded-payload-redact.test.ts b/extension/src/rules/__tests__/encoded-payload-redact.test.ts index f2381d8..6659809 100644 --- a/extension/src/rules/__tests__/encoded-payload-redact.test.ts +++ b/extension/src/rules/__tests__/encoded-payload-redact.test.ts @@ -617,3 +617,121 @@ describe("encoded-payload-redact teardown", () => { ); }); }); + +// Loads a fresh copy of the rule with `process.env.EXTENSION_RULE_OPTIONS` +// set to the given sub-rule overrides. The rule reads its options once at +// module init via `getRuleOptions`, so testing the toggles requires a fresh +// module graph per override. +async function loadRuleWithSubRuleOverrides( + subRules: Partial<{ + base64: boolean; + hex: boolean; + percent: boolean; + substitutionCipher: boolean; + leetspeak: boolean; + nato: boolean; + morse: boolean; + }>, +): Promise { + const previous = process.env.EXTENSION_RULE_OPTIONS; + process.env.EXTENSION_RULE_OPTIONS = JSON.stringify({ + "encoded-payload-redact": { subRules }, + }); + let reloaded: typeof encodedPayloadRedactRule | undefined; + await jest.isolateModulesAsync(async () => { + // The rule module's top-level `getRuleOptions` call reads + // EXTENSION_RULE_OPTIONS at evaluation time, so each override needs a + // fresh module graph. + const ruleModule = await import("../encoded-payload-redact"); + reloaded = ruleModule.encodedPayloadRedactRule; + }); + if (previous === undefined) { + delete process.env.EXTENSION_RULE_OPTIONS; + } else { + process.env.EXTENSION_RULE_OPTIONS = previous; + } + if (!reloaded) { + throw new Error("Failed to reload encoded-payload-redact rule"); + } + return reloaded; +} + +describe("encoded-payload-redact sub-rule toggles", () => { + it("with leetspeak disabled, leet payloads pass through unchanged", async () => { + const rule = await loadRuleWithSubRuleOverrides({ leetspeak: false }); + const ciphertext = leetEncode(CIPHER_CLEARTEXT); + document.body.innerHTML = `

${ciphertext}

`; + rule.apply(document.body); + + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + expect(document.body.textContent).toContain(ciphertext); + rule.teardown(); + }); + + it("with leetspeak disabled, base64 payloads still redact (sanity)", async () => { + const rule = await loadRuleWithSubRuleOverrides({ leetspeak: false }); + const payload = base64Encode(LONG_PROSE); + document.body.innerHTML = `

${payload}

`; + rule.apply(document.body); + + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)?.textContent).toBe( + "[encoded payload hidden]", + ); + rule.teardown(); + }); + + it("with nato and morse disabled, those candidates pass through", async () => { + const rule = await loadRuleWithSubRuleOverrides({ + nato: false, + morse: false, + }); + const natoText = natoEncode("thequickbrownfox"); + const morseText = morseEncode( + "you can see this from above and you know what", + ); + document.body.innerHTML = `

${natoText}

${morseText}

`; + rule.apply(document.body); + + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + expect(document.body.textContent).toContain(natoText); + rule.teardown(); + }); + + it("with substitutionCipher disabled, ROT13 / Atbash / reverse all pass through", async () => { + const rule = await loadRuleWithSubRuleOverrides({ + substitutionCipher: false, + }); + const rot = rot13(CIPHER_CLEARTEXT); + const ats = atbash(CIPHER_CLEARTEXT); + const rev = reverseText(CIPHER_CLEARTEXT); + document.body.innerHTML = `

${rot}

${ats}

${rev}

`; + rule.apply(document.body); + + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`).length).toBe(0); + rule.teardown(); + }); + + it("with all sub-rules disabled, the rule produces no matches", async () => { + const rule = await loadRuleWithSubRuleOverrides({ + base64: false, + hex: false, + percent: false, + substitutionCipher: false, + leetspeak: false, + nato: false, + morse: false, + }); + document.body.innerHTML = ` +

${base64Encode(LONG_PROSE)}

+

${hexEncode(LONG_HEX_PROSE)}

+

${percentEncode(LONG_PERCENT_PROSE)}

+

${rot13(CIPHER_CLEARTEXT)}

+

${leetEncode(CIPHER_CLEARTEXT)}

+

${natoEncode("thequickbrownfox")}

+ `; + rule.apply(document.body); + + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`).length).toBe(0); + rule.teardown(); + }); +}); diff --git a/extension/src/rules/encoded-payload-redact.ts b/extension/src/rules/encoded-payload-redact.ts index 6b39297..d52655a 100644 --- a/extension/src/rules/encoded-payload-redact.ts +++ b/extension/src/rules/encoded-payload-redact.ts @@ -36,6 +36,9 @@ import { defineInlineTextRedactRule } from "../lib/inline-text-redact"; import type { InlineMatch } from "../lib/placeholder"; +import { getRuleOptions } from "../lib/rule-options"; + +const SUB_RULES = getRuleOptions("encoded-payload-redact").subRules; // Length floors per encoding. Tuned to sit above common hash/fingerprint // sizes (SHA-512 hex = 128, so 160 leaves headroom) and below typical @@ -795,14 +798,30 @@ function collectMorse(text: string, matches: InlineMatch[]): void { function collectMatches(text: string): InlineMatch[] { const matches: InlineMatch[] = []; - const jwtRanges = collectJwtRanges(text); - collectBase64(text, jwtRanges, matches); - collectHex(text, matches); - collectPercent(text, matches); - collectSubstitutionCiphers(text, matches); - collectLeet(text, matches); - collectNato(text, matches); - collectMorse(text, matches); + // JWT ranges are needed only to suppress overlapping base64 matches; skip + // the scan when base64 is disabled. + const jwtRanges = SUB_RULES.base64 ? collectJwtRanges(text) : []; + if (SUB_RULES.base64) { + collectBase64(text, jwtRanges, matches); + } + if (SUB_RULES.hex) { + collectHex(text, matches); + } + if (SUB_RULES.percent) { + collectPercent(text, matches); + } + if (SUB_RULES.substitutionCipher) { + collectSubstitutionCiphers(text, matches); + } + if (SUB_RULES.leetspeak) { + collectLeet(text, matches); + } + if (SUB_RULES.nato) { + collectNato(text, matches); + } + if (SUB_RULES.morse) { + collectMorse(text, matches); + } // Sort by start, then prefer the longest on ties so a base64 candidate // wins over a hex prefix of the same span. Merge by dropping any match diff --git a/extension/src/rules/rule-metadata.ts b/extension/src/rules/rule-metadata.ts index f3b7205..5aa705d 100644 --- a/extension/src/rules/rule-metadata.ts +++ b/extension/src/rules/rule-metadata.ts @@ -56,3 +56,50 @@ export const RULE_DEFAULTS = { export type RuleId = keyof typeof RULE_DEFAULTS; export const RULE_IDS = Object.keys(RULE_DEFAULTS) as readonly RuleId[]; + +// Per-rule build-time options. Rules whose behaviour is governed by more than +// a single on/off toggle declare their option shape here. The override file +// loader (`scripts/load-default-overrides.ts`) accepts an object value for any +// rule listed below and validates it against this shape; rules absent from +// this map only accept a plain boolean (existing behaviour). +// +// Sub-rule keys map to booleans only — option groups are nested objects of +// booleans. The structure stays pure data so this module is safe to import +// from the service worker (see file header). +export const RULE_OPTION_DEFAULTS = { + "encoded-payload-redact": { + // Each sub-rule corresponds to one of the encoded-content detectors in + // `rules/encoded-payload-redact.ts` (`collectMatches`). The three + // substitution-cipher decoders (ROT13 / Atbash / reverse) share a single + // toggle because they share the candidate window and first-match-wins + // resolution; users disable them together or not at all. + subRules: { + base64: true, + hex: true, + percent: true, + substitutionCipher: true, + leetspeak: true, + nato: true, + morse: true, + }, + }, +} as const satisfies Readonly< + Partial< + Record>>>> + > +>; + +// `as const` narrows every leaf to the literal `true`, which would force +// `no-unnecessary-condition` to flag every sub-rule gate at the call site +// (the resolved option is intentionally `boolean` so the override file can +// flip it to `false`). Widen booleans back to `boolean` at the type level. +type WidenBooleanLeaves = { + [K in keyof T]: T[K] extends boolean + ? boolean + : T[K] extends object + ? WidenBooleanLeaves + : T[K]; +}; + +export type RuleOptions = WidenBooleanLeaves; +export type RuleWithOptionsId = keyof RuleOptions; diff --git a/skills/agent-browser-shield-install/SKILL.md b/skills/agent-browser-shield-install/SKILL.md index 68febb4..6ebcd71 100644 --- a/skills/agent-browser-shield-install/SKILL.md +++ b/skills/agent-browser-shield-install/SKILL.md @@ -224,11 +224,28 @@ JSON override file instead of using the hosted ZIP. EXTENSION_DEFAULTS_FILE=/abs/path/to/defaults.json bun run build ``` -3. Unknown keys (neither a registered rule id nor a reserved key) and - non-boolean values fail the build with a clear error — catch typos before - shipping. +3. Rules with sub-rule options accept an ESLint-style object value in addition + to a plain boolean. Today only `encoded-payload-redact` takes options (one + sub-rule per encoding family: `base64`, `hex`, `percent`, + `substitutionCipher`, `leetspeak`, `nato`, `morse`); turn off the + higher-false-positive text ciphers without losing the byte-encoding coverage: -4. Package and deploy as usual (`bun run package` then upload via Path A / B / C + ```json + { + "encoded-payload-redact": { + "enabled": true, + "subRules": { "leetspeak": false, "nato": false, "morse": false } + } + } + ``` + + `enabled` is optional; omitted sub-rules keep their committed default. + +4. Unknown keys (rule ids, reserved keys, or sub-rule fields), object values for + rules without declared sub-rule options, and non-boolean values fail the + build with a clear error — catch typos before shipping. + +5. Package and deploy as usual (`bun run package` then upload via Path A / B / C above). The overrides are baked into the bundle. Build-time overrides apply only when `chrome.storage` is empty (fresh session). diff --git a/specs/0011-build-time-customization.md b/specs/0011-build-time-customization.md index 7fcec23..36dc7f4 100644 --- a/specs/0011-build-time-customization.md +++ b/specs/0011-build-time-customization.md @@ -48,8 +48,18 @@ shield release. substitution. - **FR-2.** The override file is a **flat JSON object**. Keys are either: - a registered rule ID mapped to a boolean (same shape as the Options-page - export), or + export), + - a registered rule ID mapped to an ESLint-style object + `{ "enabled"?: boolean, ...subRuleOptions }` for rules whose behaviour is + governed by sub-rule options (FR-2a), or - one of the reserved non-rule keys (FR-3). +- **FR-2a.** Rules listed in `extension/src/rules/rule-metadata.ts`'s + `RULE_OPTION_DEFAULTS` may take an object value. `enabled` is optional and + projects back onto the flat boolean storage shape (Options-page export stays + flat-boolean). Sub-rule keys map to booleans only and are validated against + the rule's declared option-shape tree; the partial object merges over the + committed defaults so omitted sub-rules keep their defaults. Object values for + rules without declared options fail the build (FR-4). - **FR-3.** Reserved non-rule keys: - `optionsButton` (boolean, default **off**) — start with the floating on-page options button enabled. @@ -65,8 +75,10 @@ shield release. the light default. Default off while the visual heuristic is still being tuned; the same toggle is exposed in the Options page under *Placeholder display* (spec [0010](./0010-extension-ui-and-controls.md) FR-10). -- **FR-4.** Unknown keys (neither a registered rule ID nor a reserved key) and - non-boolean values fail the build with a message naming them. +- **FR-4.** Unknown keys (neither a registered rule ID nor a reserved key), + unknown sub-rule keys under a rule object, object values for rules without + declared options, and non-boolean values at any leaf position fail the build + with a message naming the offending paths. - **FR-5.** The override file may be partial; rules not listed keep the committed default from `extension/src/rules/rule-metadata.ts`. - **FR-6.** Build-time overrides only affect **fresh** `chrome.storage`. Users @@ -86,9 +98,11 @@ shield release. literals at build time so the shipped JS contains no runtime `atob` of obfuscated strings. See [ADR-0011](../decisions/0011-build-time-decoded-injection-patterns.md). -- **NFR-S-2.** The build-time `EXTENSION_DEFAULT_OVERRIDES` value is parsed at - content-script startup via `JSON.parse` and validated per-rule-ID; a malformed - value silently degrades to "no overrides" rather than crashing the engine. +- **NFR-S-2.** The build-time `EXTENSION_DEFAULT_OVERRIDES` and + `EXTENSION_RULE_OPTIONS` values are parsed at content-script startup via + `JSON.parse` and validated against the rule registry / option-shape tree + respectively; a malformed value silently degrades to "no overrides" rather + than crashing the engine. - **NFR-M-1.** Rule defaults and IDs live in a single hand-edited file (`extension/src/rules/rule-metadata.ts`). The `catalog.test.ts` invariant enforces parity with `rules/index.ts`. See @@ -109,6 +123,11 @@ shield release. `extension/src/lib/run-on-inactive-tabs.ts`, `extension/src/lib/debug-trace.ts`, `extension/src/lib/placeholder-adaptive-palette.ts`. +- FR-2a: `extension/src/rules/rule-metadata.ts` (`RULE_OPTION_DEFAULTS`), + `extension/scripts/load-default-overrides.ts` (sub-rule validation), + `extension/src/lib/rule-options.ts` (`getRuleOptions`, parses + `EXTENSION_RULE_OPTIONS`). First consumer: + `extension/src/rules/encoded-payload-redact.ts`. - FR-5, FR-6: `extension/src/lib/storage.ts` (`parseOverrides`, `DEFAULT_STATES`). - Default source-of-truth: `extension/src/rules/rule-metadata.ts`, validated by @@ -127,6 +146,7 @@ shield release. - ADRs: [ADR-0009](../decisions/0009-rule-defaults-and-build-time-overrides.md), [ADR-0011](../decisions/0011-build-time-decoded-injection-patterns.md), + [ADR-0016](../decisions/0016-eslint-style-per-rule-options-shape.md), [ADR-0013](../decisions/0013-background-worker-purity-canary.md). - Docs: [`docs/src/content/docs/install.md`](../docs/src/content/docs/install.md)