Hyperbolic Gateway

Shadow Walk β€” sealed-content plan

Status: approved 2026-10-25. Each phase ships on its own tag; nothing here changes a default behaviour that the page can detect.

Closed shadow roots and iframes are the last two places where the page can keep content from the agent. Iframes are solved (see SHADOW_DOM_AND_FRAMES.md). This is the plan for what a closed root hides, and for the levers that are allowed to touch it.


Principles

  1. Read-only first. The accessibility tree and the DOM domain see closed roots without changing the page. They are the default path. Anything that mutates page semantics comes last and is opt-in.
  2. Safe by default, loud when not. Every capability has a tier: SAFE, VISIBLE, RISKY. The risky one is red, confirmed, and shows a persistent chrome chip while it is active.
  3. Per-call opt-in. A lever is permission, not behaviour: nothing runs unless a tool call asks for it (find(sealed:true), focus_walk, frames).
  4. Never guess. A component fingerprint narrows the search; the accessibility tree verifies it. A control is acted on only after its role/name/rect has been read.
  5. Refuse, don't fall back. A disabled capability returns a structured error with the path to enable it. It never silently does something weaker.
  6. One master switch. enabled:false disables every path below it, regardless of sub-levers.
  7. Read the minimum. Sealed results carry role, name, state and rect β€” never input values and never text bodies. A password field's name is a label; its value is not ours to collect. The accessibility tree exposes values, so this is stripped deliberately, not by accident.

Quiet by default (the activity budget)

The walk only happens when a call asks for it. Navigating, observe, find without sealed:true, screenshot, bridge_health, tab switching and idle time perform zero accessibility or DOM-domain enumeration. The page cannot see CDP, but a browser that walks every document still does fingerprint-shaped work: repeated full-tree reads, CPU spikes, and a pattern no human session produces. "The page can't tell" is not the same as "it looks normal".

Lifecycle of one sealed read

  1. Ask. find(sealed:true) or focus_walk. Nothing runs before this.
  2. One enumeration per document revision. Accessibility.enable β†’ getFullAXTree for the main frame and each same-site child (frameId), and on the session for each cross-site child β†’ one DOM.getDocument({depth:-1, pierce:true}) to learn which nodes live under a closed root. The result is cached by (tab, loaderId, frame set) and by the page revision.
  3. Per call, no full walk. Filter the cached identity set, then DOM.getBoxModel only for the nodes that actually matched β€” capped at 40.
  4. Per click, one node. Re-measure only the clicked control; rects go stale, identities do not. Never re-enumerate to click.
  5. Release. Accessibility.disable after ~1.5 s without a sealed call, unless the domain was already on (a real screen reader attached) β€” then leave it alone. The AX domain is the only new global state we introduce; OOPIF sessions are detached after the scan.

Cache and budget

rule value why
full pierce + AX tree ≀ 1 per document revision, per frame set one question, not a sweep
measured cost (9k-node page) AX 976 ms / 2.8 MB, pierce 191 ms / 841 KB fine once, unacceptable per navigation
cache invalidation navigation, 5 s TTL, or an action that bumps the revision a dialog can create a new root; a page load silently changes everything
getBoxModel only matched nodes, ≀ 40 per call geometry is the expensive part
repeat find(sealed) within 2 s cached identities, fresh geometry only for the requested subset (cached:true) an agent loop must not become a scanner
rate limit 6 enumerations / minute / tab β†’ 429 SEALED_RATE_LIMITED with retry_after bounds pathological loops
background scans none β€” no timers, no observers, no preload scan the plan's hard rule

Timing and shape

focus_walk is a keyboard user by construction: one Tab every ~120 ms, stop at the limit, restore focus. It is not a burst of synthetic keys, and it does not start until the call names a host.

The force-open patch is the one page-load-time activity, because it must run before the page's first script. That is exactly why it is RISKY, off by default, and shows a chrome chip while active. Everything else is demand-driven.

Verification

The invariant is testable, not aspirational β€” the metrics registry already counts CDP work:


Capability map

surface open shadow closed shadow iframe
observe(markdown) / extract βœ… today ❌ ❌
find βœ… today βœ… today (AX, no mark id) βœ… today (same-site and OOPIF)
act(x, y) βœ… today βœ… today βœ… today
act(mark_id) βœ… today ❌ (AX results carry coordinates, not ids) βœ… today (frame ids 100+)
type / press βœ… today βœ… today (coordinate click focuses inside) βœ… today
focus_walk (Tab) β€” βœ… today β€”
component signature βœ… today βœ… today (host surface) βœ… today (host surface)
observe text from closed roots β€” ❌ (AX has names, not bodies) ❌

act(mark_id) inside a closed root is deliberately not planned: an id implies a DOM handle we do not have, and handing out an id that resolves to a cached coordinate would be a lie the first time the page scrolls.

Same-site vs cross-site is not a detail. Chromium keeps a same-site iframe (two ports on the same host, or the same registrable domain) in the page's process, so the parent's Page.getFrameTree lists it and getFullAXTree({frameId}) can read it. A cross-site iframe runs out-of-process (OOPIF): the parent's frame tree omits it, and it appears only as an iframe target. The 1.4.89 frames tests used two ports on 127.0.0.1 β€” cross-origin, same-site β€” so they never exercised the OOPIF path. P0 closed that gap in 1.4.90, before P1 builds on the frame list.


Settings β€” Settings β†’ Shadow Walk

A top-level Settings tab with a vertical rail on the left:

Overview | Shadow DOM | Iframes | Clone Lab

Overview β€” master switch, live status for the active tab (sealed controls found, frames present, patch active), and a detectability test button that runs the closed-root probe in the current page and reports what a fingerprinting script would see.

Each lever shows a tier badge and one line of plain language. Risky levers are red, need a confirm, and take effect on the next navigation (the patch is injected at document start).

Schema and defaults

{
  "enabled": true,
  "shadow": {
    "deepOpenRoots": true,        // SAFE   β€” existing deep scan, open roots only
    "axSealed": true,             // SAFE   β€” closed roots via Accessibility + box model
    "componentSurface": true,     // SAFE   β€” host part/exportparts/css vars β†’ signature
    "tabWalk": true,              // VISIBLEβ€” trusted Tab stepping; moves focus
    "tabWalkMaxSteps": 8,
    "tabWalkRestoreFocus": true,
    "cdpStyleMatch": false,       // COST   β€” CSS.getMatchedStylesForNode per sealed control
    "partHighlight": false,       // VISIBLEβ€” injects ::part outlines (page-visible CSS)
    "forceOpen": false            // RISKY  β€” attachShadow({mode:'open'}) at document start
  },
  "frames": {
    "geometry": true,             // SAFE   β€” frame tree + rects
    "controls": true,             // SAFE   β€” in-frame control facts
    "screenshots": true,          // SAFE   β€” crops on request; costs one capture
    "maxFrames": 6,
    "maxControls": 40,
    "screenshotMaxSide": 1024
  },
  "cloneLab": { "enabled": false } // proposal only; see docs/TODO.md
}

Environment kill-switches remain for CI and headless runs: KUANG_SHADOW_WALK=off, KUANG_FORCE_OPEN_SHADOW=1 (never in a preset).

Settings are written over IPC only (no HTTP route). The bridge reads the same store and enforces it. bridge_health reports a compact shadowWalk block so an agent can see what is allowed before it asks.

Shipped in 1.4.93:

Force-open is the only RISKY lever. It is off by default, needs a confirm in the panel, shows a red SHADOW PATCH chip while armed, and is injected at document start with Page.addScriptToEvaluateOnNewDocument. No toString forgery is attempted: the page can detect it structurally and by reading the function source. The patch applies to the next navigation; turning it off removes it for future documents, while the current document stays patched until it reloads. KUANG_FORCE_OPEN_SHADOW=1 arms it for a one-off run; KUANG_SHADOW_WALK=off still wins.

Tiers

tier meaning levers
SAFE read-only; the page cannot tell deepOpenRoots, axSealed, componentSurface, all frames.*
VISIBLE the page or the human can see the effect tabWalk (focus ring moves), partHighlight (CSSOM)
COST safe but expensive enough to be opt-in cdpStyleMatch
RISKY changes page semantics and is detectable forceOpen

Phases

P0 β€” 1.4.90 Cross-site frames β€” shipped

P1 β€” 1.4.91 AX sealed discovery β€” shipped

P2 β€” 1.4.92 Tab walk (focus_walk) β€” shipped

P3 β€” 1.4.93 Settings surface + enforcement β€” shipped

P4 β€” 1.4.94 Component fingerprints + recipe anchors β€” shipped

P5 β€” 1.4.95 Force-open patch (RISKY, last) β€” shipped

P6 β€” Clone Lab

Proposal only, tracked in docs/TODO.md item 18. Read-only analysis in an ephemeral partition; not an actuation path.


Architecture

src/main/shadow-walk/
  settings.ts            defaults, validation, IPC-backed store
  ax-controls.ts         AX tree β†’ control facts + live rects (P1)
  tab-walk.ts            trusted Tab stepping + AX focus confirmation (P2)
  component-signature.ts host exported surface β†’ stable signature (P4)
  force-open.ts          document-start patch, per-tab (P5)
  README.md              subsystem reference

All of it composes with what exists: control-facts.ts (description shape), frames.ts (P0: cross-site discovery + viewport rects, DPR-aware cropping), primitives.ts (find/act), recipes (signature anchors), metrics.ts (cdp.ax, cdp.pierce, sealed.scan, sealed.cacheHit, tabwalk.steps).


Detection model (what the page sees)

lever page-visible evidence
AX tree / DOM pierce none β€” passive CDP, no JS API exposed
OOPIF target attach none β€” no pause (we never use waitForDebuggerOnStart), no page code
find(sealed) coordinate click a normal trusted pointer event
focus_walk trusted focus/key events, i.e. a keyboard user
partHighlight a new rule in document.styleSheets, getComputedStyle outlines
forceOpen shadowRoot !== null on a closed root; patched attachShadow descriptor
Clone Lab a second navigation, cookies/timing β€” which is why it uses an ephemeral partition

Rollout

Each phase: npx tsc --noEmit, npm run test:mcp:contract, npm run check:no-local-paths, its own live suite, changelog entry, tag. The settings tab ships in P3, after both read paths exist, so no lever is a dead switch for a release.


Probe results (answered 2026-10-25, before P0/P1)

Five throwaway Electron probes with closed-root fixtures: nested roots, same-site and cross-site frames, hidden variants, and a 9,025-node page.

question answer
Does AX see inside a closed root? Yes β€” button, textbox and a button inside a nested closed root all appear with names and roles. ignored:false.
Does AX expose field values? Yes β€” a textbox returned value:"hunter2". Stripped at the boundary; sealed results never carry values.
Does AX include same-process iframe content? Not in the parent tree, but getFullAXTree({frameId}) on the child frame returns it, including a closed root inside.
Does AX include cross-site iframe content? Only through the OOPIF target's own session. The parent's frame tree omits the frame entirely (see P0).
Does DOM.getBoxModel accept a closed backendNodeId? Yes, no resolveNode needed. This Electron build wraps the result as {model:{…}}; handle both shapes. Coordinates are CSS px relative to that frame's viewport.
Nested closed roots: can focus_walk use the AX parent chain? The chain exists (button < generic < … < RootWebArea), but custom elements collapse to generic, so it cannot name the host reliably. Use the pierce-derived backendNodeId set for "still inside this component".
How is hidden content reported? display:none, visibility:hidden and aria-hidden are absent from AX. Zero-size and offscreen controls are present β€” filter zero-area, flag offscreen, scroll before clicking.
What does a full scan cost? 9,025 nodes: AX 976 ms / 2.8 MB, pierce 191 ms / 841 KB. Acceptable once per explicit call; unacceptable on navigation or per find.

Two consequences are already folded into P0 and P1: cross-site frames need target sessions, and the quiet-by-default cache is not an optimization β€” it is what keeps a normal scan from looking like a crawler.