Feat: shadow-piercing text walkers (#164 Tier 2)#166
Merged
Conversation
Tier 1 (#165) made the subtree watcher and selector dispatch reach into open shadow roots. Tier 2 does the same for the text-walk path the injection-defense rules depend on: - prompt-injection-redact - pii-redact - secrets-redact - encoded-payload-redact - unicode-invisibles-strip The walkers all bottomed out in `document.createTreeWalker`, which stops at shadow boundaries. An attacker could plant an injection payload inside a `<div>.attachShadow({mode:"open"})` and every one of these rules would walk right past it. Approach: replace the TreeWalker in `yielding-text-walk.ts` and in `walkTextNodes` / `visibleTextContent` (dom-utils) with a shared recursive collector that descends through `element.shadowRoot` for open hosts. Closed shadows stay opaque by design. Pre-collection instead of an interleaved TreeWalker also drops the walker.currentNode resume dance the chunked walker had to maintain across consumer detachment — the array holds direct refs. Demo: extends the existing ShadowDomEmbed on the home page with a prompt-injection paragraph (reused fixture from product detail, already base64 in source) so reviewers can confirm the text walker reaches into the shadow and the placeholder lands. Property tests for three structural invariants: - walkTextNodes / collectTextNodesShadowPiercing return exactly the text nodes reachable through light + open-shadow boundaries for any random tree, with parent filtering applied. - visibleTextContent concatenates the same reachable set in pre-order across nested shadows. - The chunked walker's union of processed chunks matches the eager walker — equivalence holds across the shadow extension in both sync and async paths. Plus a non-vacuous exclusion property: text living inside a closed shadow root never surfaces in any walker output, even when the builder explicitly places content there. The earlier hand-written tests for chunked-walker equivalence (in yielding-text-walk.property.test.ts) continue to pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
prompt-injection-redact,pii-redact,secrets-redact,encoded-payload-redact,unicode-invisibles-strip.<div>.attachShadow({mode:"open"})and every one of these rules would walk right past it — the entire point the rules exist (asymmetry between what the user sees and what an agent reads) was bypassable with one line of page JS.Part of #164 Tier 2.
What changed
extension/src/lib/dom-utils.tscollectTextNodesShadowPiercingshared collector — recursive walk that descends throughelement.shadowRootfor open hosts.walkTextNodesnow delegates to it (same public contract; same filter; just sees shadow content too).visibleTextContentrewritten as a recursive concat that descends into open shadows; closed shadows stay opaque.extension/src/lib/yielding-text-walk.ts— the chunked walker now uses the shared collector for phase 1 and iterates by index in phase 2. Drops thewalker.currentNoderesume dance the original needed because the consumer detaches nodes mid-chunk; pre-collection makes that problem disappear (the array holds direct refs) and unlocks shadow piercing in one move. Chunking + scheduler-yield + abort semantics are unchanged.extension/src/lib/__tests__/text-walk-shadow.property.test.ts(new) — fast-check property tests for:walkTextNodesreturns exactly the text nodes reachable through light + open-shadow boundaries for any random tree.walkTextNodesChunkedchunks equalswalkTextNodesacross nested shadows, in both sync and async paths.visibleTextContentconcatenates the same reachable set in pre-order across nested shadows.demo-site/src/components/ShadowDomEmbed.tsx— adds an injection-shaped paragraph inside the existing demo shadow root. Fixture reused from product detail (already base64 in source — no new plaintext payload shipped).Closed shadow roots stay opaque by design. Tier 3 (
adoptedStyleSheetsfor EasyList + placeholder styles inside shadows) is the remaining piece tracked in #164.Test plan
bun jest— 1322 / 1322 pass (6 new property tests; existingyielding-text-walk.property.test.tsequivalence tests continue to pass unchanged across the rewrite).bun run check— biome + eslint clean (extension + demo-site).bun run build— both bundles build clean.🤖 Generated with Claude Code