From af98e46db1fd3c6251f7e987459bb086f9ef2565 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 11:48:31 -0400 Subject: [PATCH 1/2] Add: per-sub-rule threshold tuning in the build-time override file (ADR-0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens the ADR-0016 leaf-type invariant from boolean-only to `boolean | finite number`. Each `encoded-payload-redact` sub-rule may now be a boolean (existing on/off behaviour) or an object carrying `enabled` plus named tuning thresholds (`minLength`, `minWords`, `validRatio`, `minCommonWords`, etc.). Bare boolean at a sub-rule is shorthand for `{ enabled: }`; omitted threshold fields keep their committed defaults. The `MIN_*` constants that used to live in `encoded-payload-redact.ts` relocate to `RULE_OPTION_DEFAULTS` in `rule-metadata.ts` alongside the sub-rule on/off shape, so a reader of one file sees both the binary and numeric configuration for each sub-rule. The rule reads its merged thresholds via `getRuleOptions(...)` at module init and rebuilds the threshold-derived regex candidates from those values. Validation: leaf type must match the declared default (boolean → boolean, number → finite number). No range checks — operators tuning thresholds are reading the rule source by definition (the sophisticated-user policy recorded in this ADR). Stacks on PR #232; both PRs together expose the full per-sub-rule configuration surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0017-numeric-thresholds-as-rule-options.md | 173 ++++++++++++++++++ decisions/README.md | 1 + docs/src/content/docs/install.md | 35 +++- .../data/defaults-overrides.example.json | 2 +- .../__tests__/load-default-overrides.test.ts | 133 +++++++++++++- extension/scripts/load-default-overrides.ts | 48 +++-- extension/src/lib/rule-options.ts | 59 +++--- extension/src/rules/__tests__/catalog.test.ts | 11 +- .../__tests__/encoded-payload-redact.test.ts | 71 +++++-- extension/src/rules/encoded-payload-redact.ts | 157 ++++++++-------- extension/src/rules/rule-metadata.ts | 114 +++++++++--- skills/agent-browser-shield-install/SKILL.md | 30 ++- specs/0011-build-time-customization.md | 20 +- 13 files changed, 674 insertions(+), 180 deletions(-) create mode 100644 decisions/0017-numeric-thresholds-as-rule-options.md diff --git a/decisions/0017-numeric-thresholds-as-rule-options.md b/decisions/0017-numeric-thresholds-as-rule-options.md new file mode 100644 index 0000000..cb98905 --- /dev/null +++ b/decisions/0017-numeric-thresholds-as-rule-options.md @@ -0,0 +1,173 @@ +--- +status: accepted +date: 2026-06-09 +--- + +# Numeric thresholds exposed as per-sub-rule options + +## Context and Problem Statement + +ADR-0016 introduced the ESLint-style object form for the build-time override +file, with the leaf-type invariant that every override value in the sub-rule +tree is a **boolean** (sub-rule on / off). That covers the case where a +sub-rule's heuristic misfires badly enough to disable it, but not the in-between +case: an operator who wants `encoded-payload-redact`'s NATO detector turned on +but with a stricter token floor than the shipping default, or Morse with a +higher valid-ratio cutoff. Today those tuning knobs are file-scope constants in +`extension/src/rules/encoded-payload-redact.ts` (`MIN_NATO_WORDS = 10`, +`MIN_MORSE_VALID_RATIO = 0.8`, `MIN_LEET_SUBSTITUTIONS = 4`, twelve more) and +changing them requires forking the rule source. + +The sub-rule on/off layer (ADR-0016) already requires sophisticated operators to +read the rule's source and the spec to understand which sub-rules are worth +disabling. Threshold tuning sits one click further down that same path: "a +custom build by someone reading the source." The question is whether to expose +the knobs in the same override-file shape, or keep them as source-level +constants. + +## Decision Drivers + +- Tuning the false-positive band per sub-rule is the natural follow-on to the + on/off control ADR-0016 ships (PR #TBD §"Summary"). The same operators who + disable NATO/Morse outright are the most likely to also want to keep them on + at a stricter setting. +- Threshold values already live in pure data — the existing file-level `MIN_*` + consts have no behaviour, only inline comments explaining the trade-off + (`extension/src/rules/encoded-payload-redact.ts` constants block). Moving them + into `RULE_OPTION_DEFAULTS` is a relocation, not new state. +- The override-file shape should stay declarative and uniform with the ADR-0016 + design: an operator who already knows the boolean form should recognize the + threshold form as a strict extension of it. No new top-level keys, no parallel + mechanism. +- Sophisticated-user policy: per the user directive recorded in PR #TBD + §"Scope", knob meanings stay in the rule source — no in-repo "knobs reference" + doc — so the validation layer doesn't carry the maintenance burden of + explaining what each number means. + +## Considered Options + +- **Numeric leaves in the same tree, sub-rule value is + `boolean | { enabled?, ...numbers }`** — each sub-rule may stay a bare boolean + (back-compat with ADR-0016) or be upgraded to an object with optional + `enabled` and named numeric thresholds matching the sub-rule's section of + `RULE_OPTION_DEFAULTS`. +- **Separate `ruleThresholds` namespace** — keep the ADR-0016 tree boolean-only; + thresholds go under a sibling top-level key keyed by rule id and threshold + name. +- **Sensitivity enum (low / medium / high)** — each sub-rule takes a discrete + band; the rule maps it to internal threshold sets. +- **Don't expose; keep thresholds as source-level constants** — status quo. + +## Decision Outcome + +Chosen option: **numeric leaves in the same tree, with each sub-rule value being +`boolean | { enabled?: boolean, ...numericThresholds }`**. + +- `RULE_OPTION_DEFAULTS` in `extension/src/rules/rule-metadata.ts` becomes the + source-of-truth for the threshold values themselves. The `MIN_*` constants + currently inline in `encoded-payload-redact.ts` move into the per-sub-rule + defaults; the rule reads its merged thresholds via `getRuleOptions(...)` at + module init (PR #TBD §"Implementation"). +- A sub-rule's value in the override file is either a boolean (existing + behaviour from ADR-0016) or an object whose keys match the sub-rule's declared + thresholds plus an optional `enabled`. A bare boolean is equivalent to + `{ "enabled": }`, mirroring the rule-level shape ADR-0016 set up. +- Validation widens to accept `boolean | finite number` at leaves, gated on the + leaf type in `RULE_OPTION_DEFAULTS`: positions whose default is a boolean + accept booleans only; positions whose default is a number accept finite + numbers only. No range checks — operators who pass `minLength: -1` or + `validRatio: 5` get the surprising-but-deterministic behaviour their number + produces. They are reading the source by definition (PR #TBD §"Scope": + "sophisticated users"). +- `enabled` at the sub-rule level remains optional; absent fields fall back to + `RULE_OPTION_DEFAULTS`. This generalizes ADR-0016 FR-2a's "omitted sub-rules + keep defaults" to sub-fields within a sub-rule. + +### Consequences + +- Good, because the override file gains expressiveness without a new schema + namespace: every operator-facing knob lives under + `.subRules..`. The ADR-0016 mental model ("rule entry + can be boolean or object") propagates down one level cleanly. +- Good, because thresholds become discoverable via the same declarative source + the on/off shape lives in (`RULE_OPTION_DEFAULTS`). A reader of + `rule-metadata.ts` sees both the binary and the numeric configuration for each + sub-rule in one place (PR #TBD §"Implementation"). +- Good, because the rule code stops carrying duplicated default state — the + `MIN_*` constants relocate rather than fork (PR #TBD §"Refactor"). +- Neutral, because the catalog-test invariant from ADR-0016 ("every leaf is a + boolean") is replaced with "every leaf is `boolean | finite number`, and + override-type matches default-type at each position" (PR #TBD §"Test plan"). +- Bad, because the override file's failure modes grow: in addition to ADR-0016's + path-qualified type errors, operators can now silently produce a non-matching + rule by setting a threshold to a value that no realistic input would clear. + The mitigation is the sophisticated-user policy (PR #TBD §"Scope") — no range + checks, knob meanings live in the source. +- Bad, because the regex candidates that interpolate threshold constants + (`BASE64_CANDIDATE`, `HEX_CANDIDATE`, `TEXT_CIPHER_CANDIDATE`, + `LEET_CANDIDATE`, `MORSE_CANDIDATE`) must be rebuilt at module init from the + merged options rather than at file-evaluation time. Cheap (build runs once per + content-script load), but worth noting. + +### Confirmation + +- `extension/scripts/__tests__/load-default-overrides.test.ts` extends the + ADR-0016 cases with numeric leaves: valid numeric override; non-number for a + numeric leaf; boolean for a numeric leaf and vice-versa; partial threshold + object merges over defaults (PR #TBD §"Test plan"). +- `extension/src/rules/__tests__/catalog.test.ts` invariant is widened: every + leaf in `RULE_OPTION_DEFAULTS` is `boolean | finite number`, and no leaf is a + string or `null` (PR #TBD §"Test plan"). +- `extension/src/rules/__tests__/encoded-payload-redact.test.ts` adds + threshold-tuning cases: lowering `nato.minWords` matches a previously + too-short candidate; raising `morse.validRatio` rejects a previously matching + payload (PR #TBD §"Test plan"). + +## Pros and Cons of the Options + +### Numeric leaves in the same tree (chosen) + +- Good, because the validation and merge logic are a strict extension of the + ADR-0016 boolean-tree walk: same recursion, wider leaf type check. +- Good, because the override file stays single-tree; an operator's threshold + tweak sits visually adjacent to the on/off toggle it modifies. +- Bad, because the leaf type is no longer uniform — readers can no longer assume + every position is a boolean. + +### Separate `ruleThresholds` namespace + +- Good, because the ADR-0016 boolean-only invariant stays clean. +- Bad, because per-sub-rule state splits across two top-level namespaces. An + operator looking at `encoded-payload-redact.nato` has to also consult + `ruleThresholds.encoded-payload-redact.nato`. + +### Sensitivity enum + +- Good, because tiny vocabulary (`low | medium | high`); easy to validate; no + escape-hatch maintenance burden. +- Bad, because not expressive enough — an operator who needs + `nato.minWords = 14` cannot get it without a custom build, defeating the + purpose of exposing the knob in the override layer. + +### Status quo (source-level constants) + +- Good, because zero schema or validator change. +- Bad, because forking the rule source is the only path to a tuned threshold, + which is the gap this ADR closes. + +## 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) + — the predecessor PR that introduced the boolean-only leaf invariant this ADR + widens +- PR #TBD — the implementation PR for this ADR; updates the catalog invariant, + validator, merge, and rule reads +- [ADR-0016](./0016-eslint-style-per-rule-options-shape.md) — the parent + decision that established the ESLint-style object shape; this ADR extends its + leaf-type invariant +- [ADR-0009](./0009-rule-defaults-and-build-time-overrides.md) — the root + decision establishing `rule-metadata.ts` as the build-time configuration + source-of-truth +- Spec [0011](../specs/0011-build-time-customization.md) — FR-2a / FR-4 / + NFR-S-2 reworded to cover the wider leaf type diff --git a/decisions/README.md b/decisions/README.md index 11bd3a9..df9c1ab 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -30,6 +30,7 @@ that is not supported by one of those citations. | [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 | +| [0017](./0017-numeric-thresholds-as-rule-options.md) | Numeric thresholds exposed as per-sub-rule options | Accepted | ## Conventions diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index fd58c45..da3e12d 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -151,10 +151,37 @@ under `RULE_OPTION_DEFAULTS`. Today the only rule that takes options is — 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 (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. +Each sub-rule may also be an object carrying tuning thresholds — length floors, +common-word counts, printable-byte ratios — that override the file-scope +defaults in `rule-metadata.ts`: + +```json +{ + "encoded-payload-redact": { + "subRules": { + "leetspeak": false, + "nato": { "enabled": true, "minWords": 14 }, + "morse": { "enabled": true, "validRatio": 0.9 } + } + } +} +``` + +A bare boolean at a sub-rule is shorthand for `{ "enabled": }`; +omitted threshold fields keep their committed default. Threshold meanings (and +the rationale for each shipping value) live in the rule source — +`extension/src/rules/encoded-payload-redact.ts` and +`extension/src/rules/rule-metadata.ts`. Threshold values are not range-checked +at the validator; operators tuning them are reading the rule source by +definition. + +The file may be partial; rules not listed keep the committed default. A bare +boolean still works for any rule, including those with sub-rule options +(`"encoded-payload-redact": false` disables the entire rule). Unknown keys (rule +ids, reserved keys, sub-rule names, or threshold field names), object values for +rules without declared sub-rule options, and leaf values whose type doesn't +match the declared default (boolean → non-boolean, number → non-finite or +non-number) 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/data/defaults-overrides.example.json b/extension/data/defaults-overrides.example.json index 6f8bd88..5951da9 100644 --- a/extension/data/defaults-overrides.example.json +++ b/extension/data/defaults-overrides.example.json @@ -7,7 +7,7 @@ "enabled": true, "subRules": { "leetspeak": false, - "nato": false, + "nato": { "enabled": true, "minWords": 14 }, "morse": false } }, diff --git a/extension/scripts/__tests__/load-default-overrides.test.ts b/extension/scripts/__tests__/load-default-overrides.test.ts index 76f1a1b..b5a7b89 100644 --- a/extension/scripts/__tests__/load-default-overrides.test.ts +++ b/extension/scripts/__tests__/load-default-overrides.test.ts @@ -237,12 +237,22 @@ describe("loadDefaultOverrides", () => { }); describe("per-rule options (ESLint-style object value)", () => { + // Fixture covers both leaf shapes the validator now supports: bare + // boolean (`base64`) and `{ enabled, ...thresholds }` per-sub-rule + // (`hex`, `leetspeak`). const RULE_OPTION_DEFAULTS = { "ads-hide": { subRules: { base64: true, - hex: true, - leetspeak: true, + hex: { + enabled: true, + minLength: 160, + printableRatio: 0.85, + }, + leetspeak: { + enabled: true, + minSubstitutions: 4, + }, }, }, } as const; @@ -254,7 +264,7 @@ describe("loadDefaultOverrides", () => { "pii-redact": true, "ads-hide": { enabled: false, - subRules: { leetspeak: false }, + subRules: { base64: false }, }, }), ); @@ -267,7 +277,61 @@ describe("loadDefaultOverrides", () => { ).toEqual({ rules: { "pii-redact": true, "ads-hide": false }, ruleOptions: { - "ads-hide": { subRules: { leetspeak: false } }, + "ads-hide": { subRules: { base64: false } }, + }, + }); + }); + + it("accepts numeric threshold overrides at number-typed leaves", () => { + const file = writeFile( + "numeric-thresholds.json", + JSON.stringify({ + "ads-hide": { + subRules: { + hex: { minLength: 240, printableRatio: 0.9 }, + leetspeak: { minSubstitutions: 6 }, + }, + }, + }), + ); + expect( + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toEqual({ + rules: {}, + ruleOptions: { + "ads-hide": { + subRules: { + hex: { minLength: 240, printableRatio: 0.9 }, + leetspeak: { minSubstitutions: 6 }, + }, + }, + }, + }); + }); + + it("accepts a bare-boolean shorthand at a `{ enabled, ... }` sub-rule", () => { + const file = writeFile( + "boolean-shorthand.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: { enabled: false } } }, }, }); }); @@ -276,7 +340,7 @@ describe("loadDefaultOverrides", () => { const file = writeFile( "no-enabled.json", JSON.stringify({ - "ads-hide": { subRules: { hex: false } }, + "ads-hide": { subRules: { base64: false } }, }), ); expect( @@ -288,7 +352,7 @@ describe("loadDefaultOverrides", () => { ).toEqual({ rules: {}, ruleOptions: { - "ads-hide": { subRules: { hex: false } }, + "ads-hide": { subRules: { base64: false } }, }, }); }); @@ -323,6 +387,22 @@ describe("loadDefaultOverrides", () => { ).toThrow(/unknown option keys: ads-hide\.subRules\.bogus/); }); + it("rejects unknown sub-fields under a `{ enabled, ... }` sub-rule", () => { + const file = writeFile( + "unknown-subfield.json", + JSON.stringify({ + "ads-hide": { subRules: { hex: { bogus: 1 } } }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow(/unknown option keys: ads-hide\.subRules\.hex\.bogus/); + }); + it("rejects unknown top-level keys under a rule object", () => { const file = writeFile( "unknown-group.json", @@ -339,11 +419,11 @@ describe("loadDefaultOverrides", () => { ).toThrow(/unknown option keys: ads-hide\.unknownGroup/); }); - it("rejects non-boolean sub-rule leaves with a path-qualified name", () => { + it("rejects non-boolean leaves at boolean-typed positions", () => { const file = writeFile( "nonbool-subrule.json", JSON.stringify({ - "ads-hide": { subRules: { leetspeak: "off" } }, + "ads-hide": { subRules: { base64: "off" } }, }), ); expect(() => @@ -352,7 +432,42 @@ describe("loadDefaultOverrides", () => { knownRuleIds: KNOWN_IDS, ruleOptionDefaults: RULE_OPTION_DEFAULTS, }), - ).toThrow(/non-boolean option values for: ads-hide\.subRules\.leetspeak/); + ).toThrow(/mistyped option values for: ads-hide\.subRules\.base64/); + }); + + it("rejects non-number leaves at number-typed positions", () => { + const file = writeFile( + "nonnumber-threshold.json", + JSON.stringify({ + "ads-hide": { subRules: { hex: { minLength: "240" } } }, + }), + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow( + /mistyped option values for: ads-hide\.subRules\.hex\.minLength/, + ); + }); + + it("rejects non-finite numbers (NaN / Infinity) at number-typed positions", () => { + const file = writeFile( + "infinity-threshold.json", + // JSON.stringify drops Infinity to null; build the literal directly. + '{"ads-hide": {"subRules": {"hex": {"minLength": 1e500 }}}}', + ); + expect(() => + loadDefaultOverrides({ + path: file, + knownRuleIds: KNOWN_IDS, + ruleOptionDefaults: RULE_OPTION_DEFAULTS, + }), + ).toThrow( + /mistyped option values for: ads-hide\.subRules\.hex\.minLength/, + ); }); it("rejects a non-boolean `enabled` field", () => { diff --git a/extension/scripts/load-default-overrides.ts b/extension/scripts/load-default-overrides.ts index 6d0405d..f0e8c3f 100644 --- a/extension/scripts/load-default-overrides.ts +++ b/extension/scripts/load-default-overrides.ts @@ -48,15 +48,18 @@ const RESERVED_KEYS = new Set([ ]); // 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. +// tree. Accepts boolean leaves at boolean default positions, finite-number +// leaves at number default positions, object overrides at object positions +// (recursed), and the bare-boolean shorthand `{ enabled: ... }` at object +// positions whose `enabled` field is a boolean. Collects unknown-key and +// mistyped-leaf paths into `unknownPaths` / `mistypedPaths` 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[], + mistypedPaths: string[], ): Record { const validated: Record = {}; for (const [key, value] of Object.entries(override)) { @@ -65,25 +68,44 @@ function validateRuleOptions( continue; } const defaultValue = defaultTree[key]; + const path = `${prefix}.${key}`; if (typeof defaultValue === "boolean") { if (typeof value !== "boolean") { - nonBooleanPaths.push(`${prefix}.${key}`); + mistypedPaths.push(path); + continue; + } + validated[key] = value; + continue; + } + if (typeof defaultValue === "number") { + if (typeof value !== "number" || !Number.isFinite(value)) { + mistypedPaths.push(path); continue; } validated[key] = value; continue; } if (defaultValue && typeof defaultValue === "object") { + // Bare-boolean shorthand at a nested object position whose `enabled` + // default is a boolean — interpret as `{ enabled: }`. + const defaultObject = defaultValue as Readonly>; + if ( + typeof value === "boolean" && + typeof defaultObject.enabled === "boolean" + ) { + validated[key] = { enabled: value }; + continue; + } if (value === null || typeof value !== "object" || Array.isArray(value)) { - nonBooleanPaths.push(`${prefix}.${key}`); + mistypedPaths.push(path); continue; } validated[key] = validateRuleOptions( - `${prefix}.${key}`, - defaultValue as Readonly>, + path, + defaultObject, value as Record, unknownPaths, - nonBooleanPaths, + mistypedPaths, ); } } @@ -126,7 +148,7 @@ export function loadDefaultOverrides( const nonBooleanIds: string[] = []; const objectsForRulesWithoutOptions: string[] = []; const unknownOptionPaths: string[] = []; - const nonBooleanOptionPaths: string[] = []; + const mistypedOptionPaths: string[] = []; const rules: Record = {}; const ruleOptions: Record = {}; const result: DefaultOverrides = { rules, ruleOptions }; @@ -197,7 +219,7 @@ export function loadDefaultOverrides( defaultsForRule as Readonly>, optionsOnly, unknownOptionPaths, - nonBooleanOptionPaths, + mistypedOptionPaths, ); if (Object.keys(validated).length > 0) { ruleOptions[key] = validated; @@ -222,9 +244,9 @@ export function loadDefaultOverrides( if (nonBooleanIds.length > 0) { issues.push(`non-boolean values for: ${nonBooleanIds.join(", ")}`); } - if (nonBooleanOptionPaths.length > 0) { + if (mistypedOptionPaths.length > 0) { issues.push( - `non-boolean option values for: ${nonBooleanOptionPaths.join(", ")}`, + `mistyped option values for: ${mistypedOptionPaths.join(", ")}`, ); } if (issues.length > 0) { diff --git a/extension/src/lib/rule-options.ts b/extension/src/lib/rule-options.ts index d9f6d24..6ba4821 100644 --- a/extension/src/lib/rule-options.ts +++ b/extension/src/lib/rule-options.ts @@ -31,11 +31,39 @@ function parseRuleOptionsEnv(): Record { 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 +// Recursively merges a validated override tree over a default tree. Accepts: +// - boolean override at a boolean default position +// - finite-number override at a number default position +// - object override at an object default position (recurse) +// - boolean override at an object default position whose `enabled` field is +// a boolean (the bare-boolean shorthand for `{ enabled: }`, +// used at sub-rule positions; see ADR-0017) +// 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 { +function mergeOptionTree(defaults: T, overrides: unknown): T { + if (overrides === undefined) { + return defaults; + } + if (typeof defaults === "boolean") { + return (typeof overrides === "boolean" ? overrides : defaults) as T; + } + if (typeof defaults === "number") { + return ( + typeof overrides === "number" && Number.isFinite(overrides) + ? overrides + : defaults + ) as T; + } + if (defaults === null || typeof defaults !== "object") { + return defaults; + } + const defaultsObject = defaults as Record; + // Bare-boolean shorthand at a sub-rule position: `{ enabled: false }` is + // equivalent to `false`. Generalizes the rule-level shorthand the loader + // applies at the top level. + if (typeof overrides === "boolean" && "enabled" in defaultsObject) { + return { ...defaultsObject, enabled: overrides } as T; + } if ( overrides === null || typeof overrides !== "object" || @@ -43,25 +71,14 @@ function mergeBooleanTree(defaults: T, overrides: unknown): T { ) { return defaults; } - const result: Record = { - ...(defaults as Record), - }; - for (const [key, defaultValue] of Object.entries( - defaults as Record, - )) { - const candidate = (overrides as Record)[key]; + const overridesObject = overrides as Record; + const result: Record = { ...defaultsObject }; + for (const [key, defaultValue] of Object.entries(defaultsObject)) { + const candidate = overridesObject[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); - } + result[key] = mergeOptionTree(defaultValue, candidate); } return result as T; } @@ -71,7 +88,7 @@ const ENV_OVERRIDES = parseRuleOptionsEnv(); const RESOLVED_OPTIONS: RuleOptions = Object.fromEntries( Object.entries(RULE_OPTION_DEFAULTS).map(([id, defaults]) => [ id, - mergeBooleanTree(defaults, ENV_OVERRIDES[id]), + mergeOptionTree(defaults, ENV_OVERRIDES[id]), ]), ) as RuleOptions; diff --git a/extension/src/rules/__tests__/catalog.test.ts b/extension/src/rules/__tests__/catalog.test.ts index 81a9536..8741abf 100644 --- a/extension/src/rules/__tests__/catalog.test.ts +++ b/extension/src/rules/__tests__/catalog.test.ts @@ -147,11 +147,14 @@ describe("rule catalog invariants", () => { expect(missing).toEqual([]); }); - it("RULE_OPTION_DEFAULTS sub-rule trees only contain boolean leaves", () => { - function findNonBooleanLeaves(node: unknown, prefix: string): string[] { + it("RULE_OPTION_DEFAULTS leaves are booleans or finite numbers", () => { + function findMistypedLeaves(node: unknown, prefix: string): string[] { if (typeof node === "boolean") { return []; } + if (typeof node === "number") { + return Number.isFinite(node) ? [] : [prefix]; + } if (node === null || typeof node !== "object" || Array.isArray(node)) { return [prefix]; } @@ -159,13 +162,13 @@ describe("rule catalog invariants", () => { for (const [key, value] of Object.entries( node as Record, )) { - issues.push(...findNonBooleanLeaves(value, `${prefix}.${key}`)); + issues.push(...findMistypedLeaves(value, `${prefix}.${key}`)); } return issues; } const offenders: string[] = []; for (const [id, options] of Object.entries(RULE_OPTION_DEFAULTS)) { - offenders.push(...findNonBooleanLeaves(options, id)); + offenders.push(...findMistypedLeaves(options, id)); } expect(offenders).toEqual([]); }); diff --git a/extension/src/rules/__tests__/encoded-payload-redact.test.ts b/extension/src/rules/__tests__/encoded-payload-redact.test.ts index 6659809..766d05a 100644 --- a/extension/src/rules/__tests__/encoded-payload-redact.test.ts +++ b/extension/src/rules/__tests__/encoded-payload-redact.test.ts @@ -620,18 +620,13 @@ 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. +// module init via `getRuleOptions`, so testing the toggles or threshold +// tuning requires a fresh module graph per override. Sub-rule values may be +// a boolean (bare-boolean shorthand for `{ enabled }`) or an object with +// `enabled` and/or named threshold overrides — same shape as the +// build-time override file (ADR-0016, ADR-0017). async function loadRuleWithSubRuleOverrides( - subRules: Partial<{ - base64: boolean; - hex: boolean; - percent: boolean; - substitutionCipher: boolean; - leetspeak: boolean; - nato: boolean; - morse: boolean; - }>, + subRules: Record>, ): Promise { const previous = process.env.EXTENSION_RULE_OPTIONS; process.env.EXTENSION_RULE_OPTIONS = JSON.stringify({ @@ -735,3 +730,57 @@ describe("encoded-payload-redact sub-rule toggles", () => { rule.teardown(); }); }); + +describe("encoded-payload-redact threshold tuning", () => { + it("lowering nato.minWords matches a previously too-short candidate", async () => { + // Six NATO tokens — under the default minWords=10, so the default build + // leaves it alone. With minWords=6 the same run becomes a payload. + const shortNato = natoEncode("FOXBAT"); + document.body.innerHTML = `

${shortNato}

`; + + const defaultRule = await loadRuleWithSubRuleOverrides({}); + defaultRule.apply(document.body); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + defaultRule.teardown(); + + document.body.innerHTML = `

${shortNato}

`; + const tunedRule = await loadRuleWithSubRuleOverrides({ + nato: { minWords: 6 }, + }); + tunedRule.apply(document.body); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)?.textContent).toBe( + "[encoded payload hidden]", + ); + tunedRule.teardown(); + }); + + it("raising base64.minLength leaves a previously-matching payload visible", async () => { + const payload = base64Encode(LONG_PROSE); + document.body.innerHTML = `

${payload}

`; + + const tunedRule = await loadRuleWithSubRuleOverrides({ + base64: { minLength: payload.length + 1 }, + }); + tunedRule.apply(document.body); + + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + expect(document.body.textContent).toContain(payload); + tunedRule.teardown(); + }); + + it("raising leetspeak.minCommonWords rejects a previously-matching payload", async () => { + const ciphertext = leetEncode(CIPHER_CLEARTEXT); + document.body.innerHTML = `

${ciphertext}

`; + + // CIPHER_CLEARTEXT carries ~12 distinct common-word hits after deleet; + // raising the floor above that count rejects the payload. + const tunedRule = await loadRuleWithSubRuleOverrides({ + leetspeak: { minCommonWords: 50 }, + }); + tunedRule.apply(document.body); + + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + expect(document.body.textContent).toContain(ciphertext); + tunedRule.teardown(); + }); +}); diff --git a/extension/src/rules/encoded-payload-redact.ts b/extension/src/rules/encoded-payload-redact.ts index d52655a..9bb09bd 100644 --- a/extension/src/rules/encoded-payload-redact.ts +++ b/extension/src/rules/encoded-payload-redact.ts @@ -38,64 +38,20 @@ import { defineInlineTextRedactRule } from "../lib/inline-text-redact"; import type { InlineMatch } from "../lib/placeholder"; import { getRuleOptions } from "../lib/rule-options"; +// Sub-rule on/off flags + tuning thresholds. Defaults and rationale for each +// number live alongside the on/off shape in +// `extension/src/rules/rule-metadata.ts` `RULE_OPTION_DEFAULTS`; operators +// override them via the `--defaults` file (ADR-0017). 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 -// instruction-payload sizes seen in indirect-injection samples. -const MIN_BASE64_LENGTH = 120; -const MIN_HEX_LENGTH = 160; -const MIN_PERCENT_TRIPLETS = 20; - -// Text-cipher candidate floor. Substitution ciphers (ROT13, Atbash, -// leetspeak) and reverse need enough characters to carry a meaningful -// instruction; under 80 chars the candidate is too short to clear the -// common-word qualifier even when the decode is real. -const MIN_TEXT_CIPHER_LENGTH = 80; - -// Leetspeak candidate floor. Smaller than the other ciphers because a -// leet payload is denser (digit substitutions concentrate intent in -// fewer chars). Combined with the digit-substitution count below, the -// floor avoids matching ordinary text that happens to contain digits. -const MIN_LEET_LENGTH = 40; -const MIN_LEET_SUBSTITUTIONS = 4; - -// Distinct common-English-word hits required for the decoded output of -// a text cipher to qualify as a payload. Three hits across a 40-char -// decode is rare for random letter noise but routine for any English -// sentence carrying a directive. -const MIN_COMMON_WORDS = 3; - -// NATO and Morse minima — both encodings spell one letter per token, so -// the token count IS the decoded length. Ten letters is the smallest -// payload that can fit a single English directive verb plus its object. -const MIN_NATO_WORDS = 10; -const MIN_MORSE_TOKENS = 10; - -// Morse decoders that resolve to a known letter must clear this share -// of the decoded tokens; below it the run is likely incidental dots and -// dashes (ASCII art, bullets, repeated `---` separators) rather than a -// payload. -const MIN_MORSE_VALID_RATIO = 0.8; - // Reject text nodes shorter than the smallest candidate window — cheap // per-node early-out. The smallest cipher floor (Morse: 10 tokens of // 1+ symbol each, separated by single whitespace) is ~19 chars; we use // 20 so the dispatcher sees every plausible cipher payload while still -// skipping short text nodes (UI labels, tab text, badges). +// skipping short text nodes (UI labels, tab text, badges). Not per-sub-rule +// configurable because it gates the dispatcher itself. const MIN_TEXT_LENGTH = 20; -// Decoded byte stream must be this fraction printable ASCII (space..~, -// plus \t \n \r) to count as "decodes to readable text". Hashes and -// binary blobs sit well below; UTF-8 prose (even with curly quotes / -// em-dashes whose continuation bytes are non-ASCII) clears it because -// the bulk of the bytes are still printable ASCII. -const PRINTABLE_RATIO_THRESHOLD = 0.85; - -// After decoding, require at least this many bytes of mostly-printable -// output. Filters short hashes that happen to score well on the ratio. -const MIN_DECODED_LENGTH = 40; - // JWT shape — `secrets-redact` redacts these with a `[jwt hidden]` // label. Skip so we don't double-process or override the more specific // label. @@ -106,11 +62,11 @@ const JWT_RE = // enough to clear the per-encoding floor; the printable-ratio filter // decides whether the run is a payload or random noise. const BASE64_CANDIDATE = new RegExp( - `[A-Za-z0-9+/=_-]{${MIN_BASE64_LENGTH},}`, + `[A-Za-z0-9+/=_-]{${SUB_RULES.base64.minLength},}`, "g", ); const HEX_CANDIDATE = new RegExp( - String.raw`\b[0-9a-fA-F]{${MIN_HEX_LENGTH},}\b`, + String.raw`\b[0-9a-fA-F]{${SUB_RULES.hex.minLength},}\b`, "g", ); // Percent-encoded: a run of `%XX` triplets (possibly interleaved with @@ -474,15 +430,15 @@ const MORSE_MAP: Record = { // anchored on alphanumerics so trailing punctuation doesn't drift the // match boundary into surrounding prose. const TEXT_CIPHER_CANDIDATE = new RegExp( - String.raw`[A-Za-z][A-Za-z\s.,'"!?:;\-]{${MIN_TEXT_CIPHER_LENGTH - 2},}[A-Za-z]`, + String.raw`[A-Za-z][A-Za-z\s.,'"!?:;\-]{${SUB_RULES.substitutionCipher.minLength - 2},}[A-Za-z]`, "g", ); const LEET_CANDIDATE = new RegExp( - String.raw`[A-Za-z0-9@$!][A-Za-z0-9@$!\s.,'"?:;\-]{${MIN_LEET_LENGTH - 2},}[A-Za-z0-9@$!]`, + String.raw`[A-Za-z0-9@$!][A-Za-z0-9@$!\s.,'"?:;\-]{${SUB_RULES.leetspeak.minLength - 2},}[A-Za-z0-9@$!]`, "g", ); const MORSE_CANDIDATE = new RegExp( - String.raw`(?:[.\-]{1,7}[ \t/]+){${MIN_MORSE_TOKENS - 1},}[.\-]{1,7}`, + String.raw`(?:[.\-]{1,7}[ \t/]+){${SUB_RULES.morse.minTokens - 1},}[.\-]{1,7}`, "g", ); @@ -498,10 +454,11 @@ interface CipherDecodeResult { function tryCipherDecode( candidate: string, decoder: (text: string) => string, + minCommonWords: number, ): CipherDecodeResult | null { const decoded = decoder(candidate); const commonWords = countDistinctCommonWords(decoded); - if (commonWords < MIN_COMMON_WORDS) { + if (commonWords < minCommonWords) { return null; } return { decoded, commonWords }; @@ -511,8 +468,8 @@ function tryCipherDecode( // already English — applying ROT13/Atbash/reverse to English prose // would produce gibberish (zero common-word hits), so this is only a // performance gate, not a correctness one. -function alreadyEnglish(candidate: string): boolean { - return countDistinctCommonWords(candidate) >= MIN_COMMON_WORDS; +function alreadyEnglish(candidate: string, minCommonWords: number): boolean { + return countDistinctCommonWords(candidate) >= minCommonWords; } interface NatoRun { @@ -560,7 +517,10 @@ function findNatoRuns(text: string): NatoRun[] { current.letters += letter; } } else { - if (current !== null && current.letters.length >= MIN_NATO_WORDS) { + if ( + current !== null && + current.letters.length >= SUB_RULES.nato.minWords + ) { runs.push({ start: current.start, end: current.end, @@ -571,7 +531,7 @@ function findNatoRuns(text: string): NatoRun[] { } lastTokenEnd = end; } - if (current !== null && current.letters.length >= MIN_NATO_WORDS) { + if (current !== null && current.letters.length >= SUB_RULES.nato.minWords) { runs.push({ start: current.start, end: current.end, @@ -614,14 +574,18 @@ function decodeMorse(candidate: string): MorseDecodeResult { }; } -function qualifies(decoded: Uint8Array | null): boolean { +function qualifies( + decoded: Uint8Array | null, + minDecodedLength: number, + printableRatioThreshold: number, +): boolean { if (decoded === null) { return false; } - if (decoded.length < MIN_DECODED_LENGTH) { + if (decoded.length < minDecodedLength) { return false; } - return printableRatio(decoded) >= PRINTABLE_RATIO_THRESHOLD; + return printableRatio(decoded) >= printableRatioThreshold; } function collectJwtRanges(text: string): Array<[number, number]> { @@ -651,7 +615,13 @@ function collectBase64( if (overlapsAny(start, end, jwtRanges)) { continue; } - if (qualifies(decodeBase64(m[0]))) { + if ( + qualifies( + decodeBase64(m[0]), + SUB_RULES.base64.minDecodedLength, + SUB_RULES.base64.printableRatio, + ) + ) { matches.push({ start, end, label: "[encoded payload hidden]" }); } } @@ -659,7 +629,13 @@ function collectBase64( function collectHex(text: string, matches: InlineMatch[]): void { for (const m of text.matchAll(HEX_CANDIDATE)) { - if (qualifies(decodeHex(m[0]))) { + if ( + qualifies( + decodeHex(m[0]), + SUB_RULES.hex.minDecodedLength, + SUB_RULES.hex.printableRatio, + ) + ) { matches.push({ start: m.index, end: m.index + m[0].length, @@ -699,14 +675,20 @@ function collectPercentRuns( runs.push(current); } return runs - .filter((run) => run.count >= MIN_PERCENT_TRIPLETS) + .filter((run) => run.count >= SUB_RULES.percent.minTriplets) .map(({ start, end }) => ({ start, end })); } function collectPercent(text: string, matches: InlineMatch[]): void { for (const run of collectPercentRuns(text)) { const slice = text.slice(run.start, run.end); - if (qualifies(decodePercent(slice))) { + if ( + qualifies( + decodePercent(slice), + SUB_RULES.percent.minDecodedLength, + SUB_RULES.percent.printableRatio, + ) + ) { matches.push({ start: run.start, end: run.end, @@ -733,11 +715,19 @@ function collectSubstitutionCiphers( ): void { for (const m of text.matchAll(TEXT_CIPHER_CANDIDATE)) { const candidate = m[0]; - if (alreadyEnglish(candidate)) { + if ( + alreadyEnglish(candidate, SUB_RULES.substitutionCipher.minCommonWords) + ) { continue; } for (const decoder of SUBSTITUTION_DECODERS) { - if (tryCipherDecode(candidate, decoder) !== null) { + if ( + tryCipherDecode( + candidate, + decoder, + SUB_RULES.substitutionCipher.minCommonWords, + ) !== null + ) { matches.push({ start: m.index, end: m.index + candidate.length, @@ -752,10 +742,15 @@ function collectSubstitutionCiphers( function collectLeet(text: string, matches: InlineMatch[]): void { for (const m of text.matchAll(LEET_CANDIDATE)) { const candidate = m[0]; - if (countLeetSubstitutions(candidate) < MIN_LEET_SUBSTITUTIONS) { + if ( + countLeetSubstitutions(candidate) < SUB_RULES.leetspeak.minSubstitutions + ) { continue; } - if (tryCipherDecode(candidate, deleet) !== null) { + if ( + tryCipherDecode(candidate, deleet, SUB_RULES.leetspeak.minCommonWords) !== + null + ) { matches.push({ start: m.index, end: m.index + candidate.length, @@ -782,10 +777,10 @@ function collectMorse(text: string, matches: InlineMatch[]): void { for (const m of text.matchAll(MORSE_CANDIDATE)) { const candidate = m[0]; const { decoded, validRatio } = decodeMorse(candidate); - if (validRatio < MIN_MORSE_VALID_RATIO) { + if (validRatio < SUB_RULES.morse.validRatio) { continue; } - if (countDistinctCommonWords(decoded) < MIN_COMMON_WORDS) { + if (countDistinctCommonWords(decoded) < SUB_RULES.morse.minCommonWords) { continue; } matches.push({ @@ -800,26 +795,26 @@ function collectMatches(text: string): InlineMatch[] { const matches: InlineMatch[] = []; // 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) { + const jwtRanges = SUB_RULES.base64.enabled ? collectJwtRanges(text) : []; + if (SUB_RULES.base64.enabled) { collectBase64(text, jwtRanges, matches); } - if (SUB_RULES.hex) { + if (SUB_RULES.hex.enabled) { collectHex(text, matches); } - if (SUB_RULES.percent) { + if (SUB_RULES.percent.enabled) { collectPercent(text, matches); } - if (SUB_RULES.substitutionCipher) { + if (SUB_RULES.substitutionCipher.enabled) { collectSubstitutionCiphers(text, matches); } - if (SUB_RULES.leetspeak) { + if (SUB_RULES.leetspeak.enabled) { collectLeet(text, matches); } - if (SUB_RULES.nato) { + if (SUB_RULES.nato.enabled) { collectNato(text, matches); } - if (SUB_RULES.morse) { + if (SUB_RULES.morse.enabled) { collectMorse(text, matches); } diff --git a/extension/src/rules/rule-metadata.ts b/extension/src/rules/rule-metadata.ts index 5aa705d..8590e34 100644 --- a/extension/src/rules/rule-metadata.ts +++ b/extension/src/rules/rule-metadata.ts @@ -63,43 +63,115 @@ export const RULE_IDS = Object.keys(RULE_DEFAULTS) as readonly RuleId[]; // 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). +// A sub-rule's value may be a boolean (sub-rule on/off, equivalent to +// `{ enabled: }`) or an object with `enabled?: boolean` plus +// finite-number tuning thresholds. Leaf types are validated positionally — +// override boolean→boolean, number→finite number. 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 + // sub-rule because they share the candidate window and first-match-wins // resolution; users disable them together or not at all. + // + // Numeric thresholds below replace the file-scope `MIN_*` constants the + // rule used to carry. Operators tuning them are reading the rule source + // by definition (ADR-0017) — no range checks, knob meanings live in the + // rule's inline rationale. subRules: { - base64: true, - hex: true, - percent: true, - substitutionCipher: true, - leetspeak: true, - nato: true, - morse: true, + // Length floor for base64 candidate window. Sized above SHA-512 hex + // and below typical instruction-payload sizes. Combined with the + // printable-ratio filter on the decoded bytes. + base64: { + enabled: true, + minLength: 120, + printableRatio: 0.85, + minDecodedLength: 40, + }, + // Length floor for hex candidate window. Sized above SHA-512 hex + // (128 chars) so common hashes don't match. Same printable-ratio / + // decoded-length filter as base64. + hex: { + enabled: true, + minLength: 160, + printableRatio: 0.85, + minDecodedLength: 40, + }, + // Minimum count of `%XX` triplets in a percent-encoded run. Runs are + // merged across short gaps before the count check. + percent: { + enabled: true, + minTriplets: 20, + printableRatio: 0.85, + minDecodedLength: 40, + }, + // Substitution-cipher (ROT13 / Atbash / reverse) candidate floor. The + // decoded common-word qualifier separates real payloads from random + // letter noise. + substitutionCipher: { + enabled: true, + minLength: 80, + minCommonWords: 3, + }, + // Leetspeak candidate floor. Smaller than the substitution-cipher + // floor because leet payloads are denser; combined with a minimum + // count of digit substitutions and the decoded common-word qualifier. + leetspeak: { + enabled: true, + minLength: 40, + minSubstitutions: 4, + minCommonWords: 3, + }, + // NATO phonetic minimum token count. Token count = decoded letter + // count, so 10 lets a single English directive verb plus object fit. + nato: { + enabled: true, + minWords: 10, + }, + // Morse minimum dot/dash token count, the share of decoded tokens + // that must resolve to a known letter (rejects ASCII art and sparse + // separator runs), and the decoded common-word qualifier. + morse: { + enabled: true, + minTokens: 10, + validRatio: 0.8, + minCommonWords: 3, + }, }, }, } as const satisfies Readonly< Partial< - Record>>>> + Record< + RuleId, + Readonly< + Record< + string, + Readonly< + 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 = { +// `as const` narrows every leaf to its literal type (`true`, `120`, `0.85`), +// which would force `no-unnecessary-condition` to flag every sub-rule gate +// and turn every threshold read into a comparison against a literal. The +// resolved option values are intentionally widened to `boolean` / `number` +// so the override file can flip booleans and replace thresholds. +type WidenLeaves = { [K in keyof T]: T[K] extends boolean ? boolean - : T[K] extends object - ? WidenBooleanLeaves - : T[K]; + : T[K] extends number + ? number + : T[K] extends object + ? WidenLeaves + : T[K]; }; -export type RuleOptions = WidenBooleanLeaves; +export type RuleOptions = WidenLeaves; export type RuleWithOptionsId = keyof RuleOptions; diff --git a/skills/agent-browser-shield-install/SKILL.md b/skills/agent-browser-shield-install/SKILL.md index 6ebcd71..b157909 100644 --- a/skills/agent-browser-shield-install/SKILL.md +++ b/skills/agent-browser-shield-install/SKILL.md @@ -225,25 +225,37 @@ JSON override file instead of using the hosted ZIP. ``` 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: + to a plain boolean (a bare `"encoded-payload-redact": false` still works and + disables the entire rule). Today only `encoded-payload-redact` takes options + (one sub-rule per encoding family: `base64`, `hex`, `percent`, + `substitutionCipher`, `leetspeak`, `nato`, `morse`). Each sub-rule can be + `false` (off), `true` (on), or an object carrying `enabled` plus tuning + thresholds — length floors, common-word counts, printable-byte ratios — that + override the committed defaults from `rule-metadata.ts`: ```json { "encoded-payload-redact": { "enabled": true, - "subRules": { "leetspeak": false, "nato": false, "morse": false } + "subRules": { + "leetspeak": false, + "nato": { "enabled": true, "minWords": 14 }, + "morse": { "enabled": true, "validRatio": 0.9 } + } } } ``` - `enabled` is optional; omitted sub-rules keep their committed default. + `enabled` is optional; omitted sub-rules and omitted threshold fields keep + their committed defaults. Threshold meanings and shipping values live in + `extension/src/rules/rule-metadata.ts` and the rule source — operators tuning + thresholds are expected to read the source. -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. +4. Unknown keys (rule ids, reserved keys, sub-rule names, or threshold field + names), object values for rules without declared sub-rule options, and leaf + values whose type doesn't match the declared default (boolean → non-boolean, + number → non-finite or non-number) 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. diff --git a/specs/0011-build-time-customization.md b/specs/0011-build-time-customization.md index 36dc7f4..91d0eb3 100644 --- a/specs/0011-build-time-customization.md +++ b/specs/0011-build-time-customization.md @@ -56,9 +56,13 @@ shield release. - **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 + flat-boolean). Sub-rule values are either a boolean (sub-rule on/off, + equivalent to `{ "enabled": }`) or an object whose fields match the + sub-rule's declared shape — `enabled?: boolean` plus any number of + finite-number tuning thresholds. Leaf types in the override file must match + the leaf type declared in `RULE_OPTION_DEFAULTS` at the same position (boolean + → boolean, number → finite number). Partial sub-rule objects merge over the + committed defaults so omitted fields 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 @@ -76,9 +80,12 @@ shield release. 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), - 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. + unknown sub-rule or sub-field keys under a rule object, object values for + rules without declared options, and leaf values whose type does not match the + declared default (boolean → non-boolean, number → non-finite or non-number) + fail the build with a message naming the offending paths. The validator does + not range-check numeric thresholds (FR-2a) — operators tuning thresholds are + reading the rule source by definition. - **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 @@ -147,6 +154,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-0017](../decisions/0017-numeric-thresholds-as-rule-options.md), [ADR-0013](../decisions/0013-background-worker-purity-canary.md). - Docs: [`docs/src/content/docs/install.md`](../docs/src/content/docs/install.md) From 84ee30b80d192aed426c924208e9b11df4815032 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 11:52:11 -0400 Subject: [PATCH 2/2] Docs: backfill PR #233 references in ADR-0017 The ADR was drafted before the PR was opened, with `PR #TBD` placeholders. Resolve to the actual PR number now that the implementation PR is live. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0017-numeric-thresholds-as-rule-options.md | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/decisions/0017-numeric-thresholds-as-rule-options.md b/decisions/0017-numeric-thresholds-as-rule-options.md index cb98905..aca8c07 100644 --- a/decisions/0017-numeric-thresholds-as-rule-options.md +++ b/decisions/0017-numeric-thresholds-as-rule-options.md @@ -28,7 +28,7 @@ constants. ## Decision Drivers - Tuning the false-positive band per sub-rule is the natural follow-on to the - on/off control ADR-0016 ships (PR #TBD §"Summary"). The same operators who + on/off control ADR-0016 ships (PR #233 §"Summary"). The same operators who disable NATO/Morse outright are the most likely to also want to keep them on at a stricter setting. - Threshold values already live in pure data — the existing file-level `MIN_*` @@ -39,7 +39,7 @@ constants. design: an operator who already knows the boolean form should recognize the threshold form as a strict extension of it. No new top-level keys, no parallel mechanism. -- Sophisticated-user policy: per the user directive recorded in PR #TBD +- Sophisticated-user policy: per the user directive recorded in PR #233 §"Scope", knob meanings stay in the rule source — no in-repo "knobs reference" doc — so the validation layer doesn't carry the maintenance burden of explaining what each number means. @@ -67,7 +67,7 @@ Chosen option: **numeric leaves in the same tree, with each sub-rule value being source-of-truth for the threshold values themselves. The `MIN_*` constants currently inline in `encoded-payload-redact.ts` move into the per-sub-rule defaults; the rule reads its merged thresholds via `getRuleOptions(...)` at - module init (PR #TBD §"Implementation"). + module init (PR #233 §"Implementation"). - A sub-rule's value in the override file is either a boolean (existing behaviour from ADR-0016) or an object whose keys match the sub-rule's declared thresholds plus an optional `enabled`. A bare boolean is equivalent to @@ -77,7 +77,7 @@ Chosen option: **numeric leaves in the same tree, with each sub-rule value being accept booleans only; positions whose default is a number accept finite numbers only. No range checks — operators who pass `minLength: -1` or `validRatio: 5` get the surprising-but-deterministic behaviour their number - produces. They are reading the source by definition (PR #TBD §"Scope": + produces. They are reading the source by definition (PR #233 §"Scope": "sophisticated users"). - `enabled` at the sub-rule level remains optional; absent fields fall back to `RULE_OPTION_DEFAULTS`. This generalizes ADR-0016 FR-2a's "omitted sub-rules @@ -92,16 +92,16 @@ Chosen option: **numeric leaves in the same tree, with each sub-rule value being - Good, because thresholds become discoverable via the same declarative source the on/off shape lives in (`RULE_OPTION_DEFAULTS`). A reader of `rule-metadata.ts` sees both the binary and the numeric configuration for each - sub-rule in one place (PR #TBD §"Implementation"). + sub-rule in one place (PR #233 §"Implementation"). - Good, because the rule code stops carrying duplicated default state — the - `MIN_*` constants relocate rather than fork (PR #TBD §"Refactor"). + `MIN_*` constants relocate rather than fork (PR #233 §"Refactor"). - Neutral, because the catalog-test invariant from ADR-0016 ("every leaf is a boolean") is replaced with "every leaf is `boolean | finite number`, and - override-type matches default-type at each position" (PR #TBD §"Test plan"). + override-type matches default-type at each position" (PR #233 §"Test plan"). - Bad, because the override file's failure modes grow: in addition to ADR-0016's path-qualified type errors, operators can now silently produce a non-matching rule by setting a threshold to a value that no realistic input would clear. - The mitigation is the sophisticated-user policy (PR #TBD §"Scope") — no range + The mitigation is the sophisticated-user policy (PR #233 §"Scope") — no range checks, knob meanings live in the source. - Bad, because the regex candidates that interpolate threshold constants (`BASE64_CANDIDATE`, `HEX_CANDIDATE`, `TEXT_CIPHER_CANDIDATE`, @@ -114,14 +114,14 @@ Chosen option: **numeric leaves in the same tree, with each sub-rule value being - `extension/scripts/__tests__/load-default-overrides.test.ts` extends the ADR-0016 cases with numeric leaves: valid numeric override; non-number for a numeric leaf; boolean for a numeric leaf and vice-versa; partial threshold - object merges over defaults (PR #TBD §"Test plan"). + object merges over defaults (PR #233 §"Test plan"). - `extension/src/rules/__tests__/catalog.test.ts` invariant is widened: every leaf in `RULE_OPTION_DEFAULTS` is `boolean | finite number`, and no leaf is a - string or `null` (PR #TBD §"Test plan"). + string or `null` (PR #233 §"Test plan"). - `extension/src/rules/__tests__/encoded-payload-redact.test.ts` adds threshold-tuning cases: lowering `nato.minWords` matches a previously too-short candidate; raising `morse.validRatio` rejects a previously matching - payload (PR #TBD §"Test plan"). + payload (PR #233 §"Test plan"). ## Pros and Cons of the Options @@ -161,7 +161,9 @@ Chosen option: **numeric leaves in the same tree, with each sub-rule value being [#232 — Add: ESLint-style per-rule build-time options (encoded-payload sub-rules)](https://github.com/pixiebrix/agent-browser-shield/pull/232) — the predecessor PR that introduced the boolean-only leaf invariant this ADR widens -- PR #TBD — the implementation PR for this ADR; updates the catalog invariant, +- PR + [#233 — Add: per-sub-rule threshold tuning in build-time override file](https://github.com/pixiebrix/agent-browser-shield/pull/233) + — the implementation PR for this ADR; updates the catalog invariant, validator, merge, and rule reads - [ADR-0016](./0016-eslint-style-per-rule-options-shape.md) — the parent decision that established the ESLint-style object shape; this ADR extends its