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

Trust Zones as Code

~15 min · permissions, origins, secure-contexts, honest-gaps

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

Three zones on paper

Invariant 5 is the most ambitious sentence in Voyager's architecture: trust is a zone, and a zone is verified. It names three. This Mac and the home network's family origins. Dad's tailnet. The outside world. A tailnet origin is one whose host the local tailscaled names as a peer. A home origin is an exact origin the catalog lists for a fleet Mac, never "any address on the LAN", because guests share the Wi-Fi. Everything else is the outside world, and so is anything that cannot be verified.

Invariant 6 says what a zone buys. Family zones use the microphone and camera without a prompt, every further grant is added by name with its reason, and the outside world is asked as Safari asks. The friction it removes is the one Track 1 opened with: a permission prompt for Dad's own microphone, on his own apps.

One function decides

The first block is the whole policy. A TrustPolicy wraps a function from an origin (scheme, host, port) to a zone, handed in by whoever can check. With nothing handed in, every origin is the outside world. decision(for:) then looks at two origins, the frame that asks and the page it sits in, and grants only when neither is the outside world. A family frame inside an outside page is asked. So is an outside frame inside a family page. Content cannot raise its own zone by being embedded, or by embedding.

Two things are missing on purpose. The policy never answers .deny: the outside world gets WebKit's own prompt and Dad decides, so nothing is refused on his behalf, and a test named nothingIsRefusedOnDadsBehalf walks every kind to prove it. And the list of grantable things is closed. PermissionKind holds microphone, camera and both, and a new kind is added there by name, with its reason. Track 3 showed that enum, and the engine child that turns WebKit's capture question into this request. Trust also decides one quieter thing: Track 4's sleep policy lets only a tab of the outside world fall asleep.

The verifier Voyager installs

The second block is the only verifier the code ever builds. TrustPolicy.family(origins:) makes home of two things: this Mac by any loopback spelling (localhost, ::1, any 127. address), and the exact origins handed in. Everything else is the outside world. A default port is normalised first, because WebKit names HTTPS on 443 with no port while a catalog may write the port out.

The third block hands those origins in: the hub and Pippa that Settings reach, plus every web target in the current catalog projection. The shell calls it whenever Settings or the catalog move. On the Mac that happens in the same place that fences extensions (next lesson), and on the phone in its own family hook. So trust follows the catalog. An app the catalog lists today is home today, and one it drops is the outside world as soon as the projection changes.

Exact means exact. The family trust tests check that a neighbour is not family: another port on the same tailnet host is outside, and so is the same host over plain HTTP. One listed origin vouches for itself and for nothing near it.

Nineteen days with no verifier

The zone type, the decision and their tests existed from the first brick on 2026-10-03. What did not exist was a caller. BrowserServices kept the default TrustPolicy(), the one with no verifier, so every origin was the outside world and every family app got WebKit's prompt like a stranger. Every trust test passed the whole time, because each test built its policy by hand. The fix on 2026-10-22 (3638f97, todo #603) added the verifier in the second block and, just as important, the third block and the shell's calls to it: the code that installs a policy.

Principle: A policy with passing tests is not a policy in force. Test the place that installs it, or test the behaviour through the assembled app. A type nobody installs is a promise kept in a drawer.

The zone that exists only as a type

Now the honest gap. TrustZone declares .tailnet, and its comment says "A host the local tailscaled names as a peer." The architecture, the house sign and the README all describe Voyager as trusting Dad's tailnet without asking. The code never asks tailscaled anything. family(origins:) returns only .home or .outsideWorld, and outside its declaration the app's sources mention .tailnet only in Site Information's text and symbol, for a zone no verifier produces. (One test builds a policy by hand that answers .tailnet, to check that the decision grants it.)

So, today: a family app on the tailnet is trusted because Firelink's catalog, or Settings, names its exact origin. A tailnet host the catalog does not list is asked, exactly like any site on the web. The tailnet zone is not built yet. Because unverifiable already means outside world, the gap fails toward asking, which is the safe direction: what is missing costs Dad a prompt, never a grant he did not mean to give.

A smaller drift sits in the same panel. Site Information describes the home zone as "Family app on this Mac", though a catalog-listed app served by another Mac is home too. That wording was written on the first day, nineteen days before any verifier existed.

Secure contexts first

A grant only matters where a page can ask. WebKit offers capture only to secure contexts and cannot be told to bless plain HTTP. The engine gate measured it (case W6): a family app at its Tailscale HTTPS name can ask, the same app over plain HTTP at its tailnet name has no navigator.mediaDevices at all, and loopback over plain HTTP counts as secure. That is why a family app should open at its HTTPS twin or at loopback, and why Firelink's catalog prefers the HTTPS twin when it answers for the tailnet. Under the hardened runtime the app must also claim the microphone and camera in its entitlements, and macOS's own permission for Voyager still applies. A zone settles the question between Voyager and the page. It never overrides the system.

Code

cwkVoyager, Sources/VoyagerCore/TrustPolicy.swift: three zones, no verifier by default, and the frame-and-page decision (verbatim, lines 1-41)·swift
// Trust zones (architecture invariants 5 and 6). A zone is verified, never
// claimed: the verifier is handed in by whoever can check (the launch
// catalog for a home origin, the local tailscaled for a tailnet peer). Until
// one is handed in, every origin is the outside world.

import CwkKitCore
import Foundation

public enum TrustZone: String, Sendable, Codable {
    /// This Mac, and the exact origins the catalog lists for a fleet Mac.
    case home
    /// A host the local tailscaled names as a peer.
    case tailnet
    /// Everything else, and anything that cannot be verified.
    case outsideWorld
}

public struct TrustPolicy: Sendable {
    private let zoneOf: @Sendable (PageOrigin) -> TrustZone

    /// With no verifier, every origin is the outside world.
    public init(zoneOf: @escaping @Sendable (PageOrigin) -> TrustZone = { _ in .outsideWorld }) {
        self.zoneOf = zoneOf
    }

    public func zone(of origin: PageOrigin) -> TrustZone {
        zoneOf(origin)
    }

    /// Family zones use the microphone and camera without a prompt; the
    /// outside world is asked, as Safari asks. Nothing is ever granted to the
    /// outside world here, and nothing is refused on Dad's behalf.
    ///
    /// Both the frame that asks and the page it sits in must be family. An
    /// outside page cannot borrow a family frame's grant, and a family page
    /// cannot lend its own to a frame from outside.
    public func decision(for request: PermissionRequest) -> PermissionDecision {
        let zones = [zone(of: request.origin), zone(of: request.topOrigin)]
        return zones.contains(.outsideWorld) ? .ask : .grant
    }
}
cwkVoyager, Sources/VoyagerCore/TrustPolicy.swift: the one verifier built today, loopback plus exact listed origins (verbatim, lines 43-66)·swift
extension TrustPolicy {
    /// The family's own origins, as Voyager verifies them today: this Mac
    /// (loopback, a secure context to WebKit) and the exact origins the family
    /// names, Firelink's catalog and the hub and Pippa that Settings reach. A
    /// tailnet host the catalog does not list stays the outside world: the
    /// local tailscaled is not asked yet. Before 2026-10-22 no verifier was
    /// installed at all, so every family app was asked like a stranger.
    public static func family(origins: Set<PageOrigin>) -> TrustPolicy {
        let listed = Set(origins.map(\.withoutDefaultPort))
        return TrustPolicy { origin in
            if ConnectionHint.isLoopback(origin.host) { return .home }
            return listed.contains(origin.withoutDefaultPort) ? .home : .outsideWorld
        }
    }
}

extension PageOrigin {
    /// The same origin with a port its scheme implies written as none: WebKit
    /// names https on 443 with no port, a catalog may write it out.
    public var withoutDefaultPort: PageOrigin {
        let implied = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
        return implied ? PageOrigin(scheme: scheme, host: host) : self
    }
}
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: the installation that was missing for nineteen days (verbatim, lines 85-95)·swift
    /// The microphone and camera without a prompt for the family's own
    /// origins (invariant 6): the catalog's web targets, the hub, Pippa. The
    /// shell calls it whenever the settings or the catalog move.
    public func applyFamilyTrust() {
        let settings = settings.value
        var origins = Set([settings.familyHub, settings.pippaBase].compactMap(PageOrigin.init(url:)))
        for item in familyCatalog.projection?.items ?? [] {
            if case .web(let url) = item.destination, let origin = PageOrigin(url: url) { origins.insert(origin) }
        }
        trust = .family(origins: origins)
    }

External links

Exercise

Write a permission policy for an embedded web view in your own app: a function from origin to zone, a decision that grants only when both the requesting frame and the top page are trusted, a closed enum of grantable kinds, and no deny answer. Then write the test Voyager lacked for nineteen days: build the app's real service object the way the app does at launch, and assert that a listed origin is granted without a prompt. Finish with neighbour tests: another port and plain HTTP on a trusted host must both be asked.
Hint
The installation test must not construct a policy itself; it should go through the same composition code the app uses, so it fails if that code forgets the policy. Normalise default ports (443 for https, 80 for http) on both sides of every comparison.

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.