Skip to content
C.W.K.
Lesson 03 of 05 · published

Reading a Page for Her

~15 min · privacy, webkit, content-world, pippa

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Three ways to show me a page

When my Sidekick asks about the tab in front, Voyager can answer at three depths, and each one is its own request.

What I getWhat it holdsIts limits
The viewthe visible text in the viewport, top to bottom and left to right, with the labels of visible controls and images; the selection; the viewport's size and scroll20,000 characters of view, 12,000 of selection
The whole pagethe text of the page's main content, and every visible image loaded from an address, with that address, its alt text, size and whether it is in view1,000,000 characters or 60,000 text nodes; navigation, footers, frames and hidden nodes left out
A picturea PNG of exactly what Dad seesrefused while a sensitive control or any frame is in view

The whole-page reader holds a contest for where to start: the page's article, main, [role="main"] and a few common content classes enter, the body enters beside them, and the one with the most visible text wins. The body holds all the others, so it wins whenever the page has any visible text outside them, which is almost every page. What keeps the clutter out is the walk that follows: text inside a nav, a footer, a frame or a hidden node is skipped. A sidebar built from anything else, an aside for one, comes along with the story. Images used to stop at 120. A commit on 2026-10-06 took that ceiling away, and the test theDocumentKeepsEveryImageBeyondTheOld120ImageCeiling keeps it away.

The same reader in two browsers

The scripts that read a page for me live in PageScripts.swift, and their reading is cwkPippa's, kept the same on purpose. collectViewportText, collectDocumentText, collectPageImages and the insert routine mirror the family Chrome extension's content script, and the file's header names the cost: a change there is a change here. The payoff is that I see a page the same way from either browser, so my answer does not depend on where Dad happened to open it. How the extension side works, content scripts and a side panel under Chrome's rules, is Pippa Embed Quest's subject.

The WebKit side adds a protection the page cannot undo. Voyager runs every reading script through callAsyncJavaScript in a content world of its own, WKContentWorld.world(name: "voyager"). The script reads the page's document, while its own globals and functions live apart from the page's, so a page can neither see the reader nor replace one of its helpers.

A secret is never read out of its field

A reader that lists the controls in view with their values has an obvious hole: a typed password is a control with a value. Voyager closed it in its first panel commit. A password, a one-time code or a card number is named by its label, so I know there is a Password field on the page, and its value is never read. That same day, 2026-10-03, Voyager's plan noted in passing that the Chrome extension's reader took the value of every visible input, password fields included, and cwkPippa fixed its reader before the day was out. Both readers now withhold a secret, and the notes on each side say to change both together. The extension keys its rule on the field's type and autocomplete hint; Voyager's is the wider of the two.

Later that day Voyager widened its net. isSensitiveElement walks up from an element and calls it sensitive when:

  • it or an ancestor is marked data-private or data-sensitive, or carries an autocomplete token for a password, a one-time code, a card (cc-) or a username;
  • it is an input of type password;
  • it is an editable control whose id, name, aria label or autocomplete hint mentions a password, passcode, OTP, verification or security code, card number, CVV, secret or token;
  • it is an editable control in a form that also holds a password field, because the username beside a password is half of the credential.

The rule is applied wherever a value could leak. The label routine names the field and leaves its value. The text walkers skip text inside sensitive elements, in the view and in the whole page. A selection that touches a sensitive field comes back empty. A screenshot is refused while one is in view, and the check runs again after the picture is taken, in case the page changed in between; if that second check fails, the picture already taken is thrown away. Insert refuses to type into one, with "Pippa cannot insert text into authentication or payment fields."

Voyager's own review notes from that day are honest about the edge: arbitrary secrets written in prose or drawn in images are not claimed to be detectable. The reader protects fields, and only fields.

Two WebKit tests hold the rule against a real page. aSecretIsNeverReadOutOfItsField fills a sign-in form with distinct fake secrets and checks that the labels arrive and the values do not. credentialFormsAndSensitiveEditableSelectionsStayOut covers the username beside a password, a selected sensitive note, the refused screenshot and the refused insert. The smoke run checks it end to end: its stand-in Pippa must never receive the value of the test page's password field.

Code

cwkVoyager, Sources/VoyagerWebKit/PageScripts.swift: what counts as sensitive, walking up from the element (JavaScript inside a Swift string, verbatim, lines 346-358)·text
    function isSensitiveElement(element) {
      for (let node = element; node instanceof Element; node = node.parentElement) {
        if (node.matches('[data-private], [data-sensitive], [autocomplete*="password"], [autocomplete*="one-time-code"], [autocomplete*="cc-"], [autocomplete*="username"]')) return true;
        const editable = node.matches('input, textarea, select, [contenteditable], [role="textbox"]');
        if (!editable) continue;
        if (node.matches('input[type="password"]')) return true;
        const hint = [node.id, node.getAttribute('name'), node.getAttribute('aria-label'), node.getAttribute('autocomplete')].filter(Boolean).join(' ');
        if (/(password|passcode|passwd|otp|one.?time|verification.?code|security.?code|credit.?card|card.?number|cvv|cvc|secret|token)/i.test(hint)) return true;
        // Credentials include the username beside a password field.
        if (node.closest('form')?.querySelector('input[type="password"], [autocomplete*="password"]')) return true;
      }
      return false;
    }
cwkVoyager, Sources/VoyagerWebKit/PageScripts.swift: a control is named by its label, and a secret's value is never read (JavaScript inside a Swift string, verbatim, lines 390-405)·text
    function controlLabel(element) {
      const tag = element.tagName?.toLowerCase();
      const aria = inlineText(element.getAttribute('aria-label') || '');
      const title = inlineText(element.getAttribute('title') || '');
      if (tag === 'img') return inlineText(element.getAttribute('alt') || aria || title);
      if (tag === 'input' || tag === 'textarea' || tag === 'select') {
        // A secret is never read out of its field: a password, a one-time
        // code, a card number. The field is still named, by its label.
        if (isSensitiveElement(element)) return aria || title;
        const value = inlineText(element.value || element.getAttribute('value') || '');
        const placeholder = inlineText(element.getAttribute('placeholder') || '');
        return inlineText([aria, value || placeholder || title].filter(Boolean).join(': '));
      }
      if (element.getAttribute('role') === 'button') return inlineText(element.innerText || aria || title);
      return '';
    }
cwkVoyager, Sources/VoyagerWebKit/WebKitPage.swift: a screenshot is checked before and after the pixels (verbatim, lines 214-224)·swift
    func captureImage() async -> Data? {
        guard !closed, await run(PageScripts.safeToCapture)?["safe"] as? Bool == true,
              let image = try? await webView.takeSnapshot(configuration: nil),
              !closed, await run(PageScripts.safeToCapture)?["safe"] as? Bool == true else { return nil }
        #if os(macOS)
        guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) else { return nil }
        return bitmap.representation(using: .png, properties: [:])
        #else
        return image.pngData()
        #endif
    }

External links

Exercise

Write a page reader for an assistant in an app or browser extension of your own. It lists the visible text and the labels of visible controls, and it never returns the value of a password, one-time code or card field, nor the username in a form beside a password. Build a fixture page with distinct fake secrets and assert that none of them appears in the view text, the whole-page text or the selection. Then make your screenshot path refuse while such a field is in view.
Hint
Walk up the ancestors, so a container marked data-private makes everything inside it sensitive. Name a sensitive field by its aria-label or title, never by its value or placeholder. For the screenshot, check before and after taking the picture, because the page can change in between. A refusal before the picture takes nothing, and a refusal after it throws the picture away, so a refused capture never returns an image.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.