Summary
The rule engine and every rule scan the light DOM only. Content rendered inside an open shadow root is invisible to nearly every rule; closed shadow roots are opaque to everything. This is a systemic blind spot, not a per-rule bug — the asymmetry the injection-defense rules exist to close (DOM is readable to agents but invisible to humans) is exactly what an attacker gets for free by mounting payloads inside a shadow root.
Why it happens
Three structural facts make this true across the catalog:
rule.apply() is only ever called with document.body (extension/src/lib/rule-engine.ts:171, :221). No rule is ever handed a shadow root as its scan root.
- The shared subtree watcher attaches
MutationObserver to document.body / document.head (extension/src/lib/subtree-watcher.ts:163, extension/src/lib/selector-token-index.ts:163). MutationObserver does not cross shadow boundaries even with `subtree:true` — mutations inside a shadow root never fire on a host-level observer. So the dispatcher won't fire for any DOM that arrives inside a shadow root.
querySelectorAll / closest / TreeWalker stop at shadow boundaries. Every rule uses one of these on the light tree.
Only one code path traverses open shadow roots: extension/src/lib/page-tree.ts:251-255, which compresses the page for the LLM. Closed shadow roots (`mode: "closed"`) are opaque even there — `element.shadowRoot` is `null`.
What's affected, grouped by blast radius
High-asymmetry — rules whose entire point is defeated by a trivial bypass
An attacker that wants to plant injection text can wrap it in `
.attachShadow({mode:"open"})` and these rules walk past it:
- `prompt-injection-redact`
- `hidden-text-strip`
- `attribute-injection-sanitize`
- `encoded-payload-redact`
- `unicode-invisibles-strip`
- `svg-text-strip`, `svg-sprite-strip`
- `html-comment-strip` — comments inside shadow trees survive
- `meta-injection-strip` (unlikely in practice; `` rarely has shadow roots, but worth noting)
Wholesale silent miss for legitimate content rendered inside web components
Any rule that uses `createSelectorHideRule`, `createSubtreeWatcher`, `walkTextNodes(Chunked)`, or `findInnermostMatches` is blind to shadow-tree content. The full list:
- Selector-hide family: `ads-hide` (curated selectors), `chat-widget-hide`, `cookie-banner-hide`, `newsletter-modal-hide`, `footer-redact`, `reviews-redact`, `comments-redact`, `social-embed-redact`
- Text-walk family: `pii-redact`, `secrets-redact`, `prompt-injection-redact`, `encoded-payload-redact`, `unicode-invisibles-strip`
- Per-element scans: `hidden-text-strip`, `html-comment-strip`, `noscript-strip`, `attribute-injection-sanitize`, `json-ld-sanitize`, `schema-trust-sanitize`, `svg-text-strip`, `svg-sprite-strip`, `confirmshame-sanitize`, `checkout-checkbox-sanitize`, `countdown-timer-redact`, `scarcity-redact`, `cart-addon-annotate`, `link-spoof-annotate`, `trust-badge-annotate`, `roach-motel-annotate`, `disguised-ad-flag`, `cross-origin-frame-redact`
Practical blast radius is smaller than the list suggests for the widget-hide rules. Intercom, Zendesk, HubSpot, OneTrust, etc. mount a wrapper in the light tree (`#intercom-frame`, `#onetrust-banner-sdk`, etc.) and put their UI inside its shadow root. The wrapper still matches our selector and gets hidden, which is what we want. Where it breaks: a vendor that renders the launcher node itself inside a parent's shadow root (rare today; some newer React-native chat embeds do this).
CSS-path also blind
`ads-hide`'s EasyList path (`extension/src/rules/ads-hide.ts:40-54`) injects a 13k-selector `display:none !important` stylesheet at document scope. Document stylesheets do not apply inside shadow trees (style scoping). So shadow-rendered ads aren't hidden even by CSS.
The placeholder stylesheet (`extension/src/lib/rule-engine.ts:39-107`) has the same property. If a placeholder is ever placed inside an open shadow root (only `irrelevant-sections-redact` can, via ref resolution through `page-tree`), it renders unstyled. Reveal click handlers are attached imperatively, so they'd still function.
Closed shadow roots — opaque to everything
`mode: "closed"` is uncommon outside browser UA shadows and some hardened widgets, but no fix above reaches them. Likely a documented limitation rather than a fix.
Ideas, ranked by impact × effort
Tier 1 — covers most rules with one change
- Shadow-aware subtree watcher. Patch `Element.prototype.attachShadow` at content-script entry to keep a `Set`. `subtree-watcher.ts` recursively `observe()`s each open shadow root with its own `MutationObserver`, fanning into the same subscriber set (subscribers don't know or care which tree a mutation came from). `selector-token-index`'s `findTriggeredRules` walks `shadowRoot.querySelectorAll("*")` for any host element seen during dispatch. Covers every `createSelectorHideRule` / `createSubtreeWatcher` rule with no rule-code change.
- Initial-apply walk into existing shadow roots. On `rule.apply(document.body)`, walk the existing tree once and call each rule's scan again rooted at every open shadow root found. Without this, rules miss shadow content that existed before the rule was enabled.
Tier 2 — text-walk rules
- Shadow-piercing text walker. `document.createTreeWalker` does not descend into shadow roots. Replace `walkTextNodes(Chunked)`'s walker with a custom iterator that, on each element with `shadowRoot`, recursively walks the shadow tree. Covers `pii-redact`, `secrets-redact`, `prompt-injection-redact`, `encoded-payload-redact`, `unicode-invisibles-strip`. Worth prioritizing the injection-defense rules over PII.
Tier 3 — CSS-path
- Shadow-aware ads-hide stylesheet. For each open shadow root, inject a `<style>` containing the EasyList rules. Either intercept `attachShadow` (same hook as Tier 1) or use `adoptedStyleSheets` (one `CSSStyleSheet`, adopted into every shadow root). Adopted stylesheets are the right primitive — single source of truth, cheap to attach. Same approach would fix placeholder styling inside shadow roots.
Tier 4 — explicit limitation
- Document closed shadow roots as unreachable. Add to README / docs so users running into a sealed widget understand why it's not redacted.
Non-goals
- Trying to read closed shadow roots. There's no supported way; even devtools shadow inspection is gated.
- Changing every rule's scan signature. The watcher/walker upgrades push shadow-awareness into the shared plumbing so per-rule code stays as-is.
Recommended sequencing
- PR 1 (Tier 1): shadow-aware subtree watcher + token-index + initial walk. Covers the majority of the catalog with one architectural change.
- PR 2 (Tier 2): shadow-piercing text walker. Targets the injection-defense rules specifically.
- PR 3 (Tier 3): `adoptedStyleSheets` for EasyList + placeholder styles.
- Docs: note the closed-shadow-root limitation.
Worth landing a test fixture first: a demo page that mounts injection text, a chat widget wrapper, and a cookie banner each inside an open shadow root, so each PR can assert before/after coverage.
Summary
The rule engine and every rule scan the light DOM only. Content rendered inside an open shadow root is invisible to nearly every rule; closed shadow roots are opaque to everything. This is a systemic blind spot, not a per-rule bug — the asymmetry the injection-defense rules exist to close (DOM is readable to agents but invisible to humans) is exactly what an attacker gets for free by mounting payloads inside a shadow root.
Why it happens
Three structural facts make this true across the catalog:
rule.apply()is only ever called withdocument.body(extension/src/lib/rule-engine.ts:171,:221). No rule is ever handed a shadow root as its scan root.MutationObservertodocument.body/document.head(extension/src/lib/subtree-watcher.ts:163,extension/src/lib/selector-token-index.ts:163). MutationObserver does not cross shadow boundaries even with `subtree:true` — mutations inside a shadow root never fire on a host-level observer. So the dispatcher won't fire for any DOM that arrives inside a shadow root.querySelectorAll/closest/TreeWalkerstop at shadow boundaries. Every rule uses one of these on the light tree.Only one code path traverses open shadow roots:
extension/src/lib/page-tree.ts:251-255, which compresses the page for the LLM. Closed shadow roots (`mode: "closed"`) are opaque even there — `element.shadowRoot` is `null`.What's affected, grouped by blast radius
High-asymmetry — rules whose entire point is defeated by a trivial bypass
An attacker that wants to plant injection text can wrap it in `
Wholesale silent miss for legitimate content rendered inside web components
Any rule that uses `createSelectorHideRule`, `createSubtreeWatcher`, `walkTextNodes(Chunked)`, or `findInnermostMatches` is blind to shadow-tree content. The full list:
Practical blast radius is smaller than the list suggests for the widget-hide rules. Intercom, Zendesk, HubSpot, OneTrust, etc. mount a wrapper in the light tree (`#intercom-frame`, `#onetrust-banner-sdk`, etc.) and put their UI inside its shadow root. The wrapper still matches our selector and gets hidden, which is what we want. Where it breaks: a vendor that renders the launcher node itself inside a parent's shadow root (rare today; some newer React-native chat embeds do this).
CSS-path also blind
`ads-hide`'s EasyList path (`extension/src/rules/ads-hide.ts:40-54`) injects a 13k-selector `display:none !important` stylesheet at document scope. Document stylesheets do not apply inside shadow trees (style scoping). So shadow-rendered ads aren't hidden even by CSS.
The placeholder stylesheet (`extension/src/lib/rule-engine.ts:39-107`) has the same property. If a placeholder is ever placed inside an open shadow root (only `irrelevant-sections-redact` can, via ref resolution through `page-tree`), it renders unstyled. Reveal click handlers are attached imperatively, so they'd still function.
Closed shadow roots — opaque to everything
`mode: "closed"` is uncommon outside browser UA shadows and some hardened widgets, but no fix above reaches them. Likely a documented limitation rather than a fix.
Ideas, ranked by impact × effort
Tier 1 — covers most rules with one change
Tier 2 — text-walk rules
Tier 3 — CSS-path
Tier 4 — explicit limitation
Non-goals
Recommended sequencing
Worth landing a test fixture first: a demo page that mounts injection text, a chat widget wrapper, and a cookie banner each inside an open shadow root, so each PR can assert before/after coverage.