Skip to main content
Glama

Aperture

An AI-native, privacy-first browser. The primary user is an agent; the human is the one who stays in charge.

Status: early but real. The browser runs, the MCP server works, and Claude Code can drive it end to end — snapshot, act, diff, autofill, capture. The diff engine has been measured against a real competitor over two scored, preregistered head-to-head cohorts on a byte-identical apparatus: it won the cost primary both times, lost the precision primary in the first, and passed it in the second after an engine fix that was specified, preregistered and independently adjudicated before its numbers existed. The vault has a working UI, tested crypto, PSL-backed origin binding, and a wired MCP fill path gated by a native consent dialog the agent cannot reach. See What has been measured for the numbers and their scope, and Honest status for what is not done — nothing below is claimed as working unless it is marked working.


Why build this

Every "AI browser" shipping today — Dia, Comet, Neon, Edge Copilot Mode, Brave Leo — is a chat sidebar bolted onto Chromium, driving a model the vendor chose. Aperture inverts that: it is a browser whose control surface is an MCP server, so you bring the agent. Claude Code attaches to a browser you are already using, with your sessions and your logins, and you watch it work.

Opera Neon shipped an in-browser MCP endpoint in March 2026, so the idea is validated. Two things are still unoccupied: nobody has built this on Electron, and nobody has solved incremental page observation, which is where almost all the token cost in agentic browsing actually goes.

Related MCP server: ecobrowser MCP server

The core idea: diffs, not re-dumps

Every browser-MCP today (playwright-mcp, browser-use, chrome-devtools-mcp) re-serializes the entire page after every single action. Aperture treats the loop as act → observe delta: browser_act returns what changed, against a page state the model already holds.

For scale, from npm run bench:live on four real sites: an Aperture full snapshot of Hacker News is 9,519 tokens (233 refs), a GitHub repo page 5,269 (100), a Wikipedia article 6,197 (209), an MDN reference 7,102 (163). An observation that reports nothing changed costs ~112–115 tokens on any of them. That gap is the whole design.

Making the gap safe rather than merely small is the actual engineering:

  • Stable refs. e42 names a logical element, not a DOM node, so it survives a React re-render that replaced every node. Identity is data-testidname → non-generated id → role + accessible name + nearest named ancestor + semantic path. Framework-generated ids (:r1:, hash suffixes) are rejected, because keying on them would make every ref unstable.

  • Keyed reconciliation, not tree-edit-distance. Identity keys turn matching into a hash lookup, so diffing is O(n), with a longest-increasing-subsequence pass so "one row jumped to the top" is one op, not twenty.

  • Explicit resets. Diffs name the state they apply to (diff from #7.3). On navigation, on >30% change, or after 12 diffs, the engine emits a full snapshot headed FULL SNAPSHOT — replaces all prior state, which tells a model whose context was compacted to discard its mental model. The fallback thresholds are reasoned choices, not tuned values — nothing has measured them against alternatives.

  • Explicit retirement. A replace op carries a gone: list naming every ref it destroyed, and a - gone: op reports deaths that have no addressable root to hang off. Restating a subtree without saying what died is how an agent ends up believing in elements that no longer exist.

  • Noise suppression. A clock is recognized by shape and suppressed — even mid-task, when every observation follows an action (this specifically did not work until it was benchmarked: in an act-observe loop the statistical demotion path can never fire, and the shape path was gated behind it). Anything changing repeatedly on its own is demoted; the element the agent acts on or reads is promoted straight back. Without this, one ticking timestamp defeats the entire diff argument.

  • Positional fallback — the known weak point, and it cost us a benchmark before it was fixed. Elements distinguishable only by position (ten identical "Add to cart" buttons, a queue of identical rows) get a document-order ordinal appended to their key, and reordering is exactly what breaks positional identity. In the first head-to-head this was measured in the field: under row removal those ordinals re-keyed to positions, a plan captured before the removal executed one row off, and the click landed — the one preregistered primary Aperture failed. A positional family's refs now retire on any membership change, so a stale ref is refused rather than silently rebound. The hazard is still reached constantly and now lands never: see Correctness.

  • Ref discipline. Only actionable elements get refs. On the four real sites above, Aperture's full snapshots are 1.25×–2.77× smaller than @playwright/mcp's of the same URLs, averaging ~1.9×. That is a per-snapshot byte comparison and nothing more — per-observation is not per-dollar, which the campaigns below spent three waves learning.

What has been measured

Six scored campaigns with preregistered rules, plus the engine's own fidelity benches. Every claim here is quoted at the scope its verdict grants. The measurement detail, the corrections, and the failed analyses live in bench/RESULTS.md and the adjudications in docs/design/{wave3,sweep,h2h,h2h-post-tier5}-evaluation.md.

Cost, against the real incumbent

On an 11-task benchmark against Playwright MCP 0.0.78, both products sealed to an identical three-tool surface, driven by claude-sonnet-5 (385 episodes, build 0916e30f…; design preregistered in docs/design/headtohead.md, the fix and its re-measurement in docs/design/tier5.md §9 and docs/design/tier5-ruling.md §7, adjudicated in docs/design/h2h-post-tier5-evaluation.md):

  • On realistic-weight pages — the preregistered neutral fixtures, ~6k Aperture tokens against ~22k in Playwright's dialect — end-to-end agent cost was 0.390× sealed Playwright MCP's [0.338, 0.455]. That is worse than the pre-fix engine's 0.313×, by exactly the amount the fix makes honest: one extra full snapshot per episode on one fixture, in 10 of 10 episodes, +$0.066/ep, where the old engine skipped it by reviving dead positional refs.

  • On the small adversarial home fixtures the 1.30× premium is gone: 0.823× [0.693, 0.975] CHEAPER, and said thinly — the CI upper is 0.975, and about a quarter of the movement is the incumbent drifting 7.8% dearer for no attributable reason. The premium died with the defect: Aperture's output tokens fell 6,285 → 2,131 per episode, confined to the three queue tasks, because agents no longer re-derive ordinal plans and repair after silent wrong landings.

  • On small neutral pages the difference is null (0.977× [0.937, 1.019]), unchanged across both cohorts.

  • Disclosure pinned by the preregistration: both cohorts run a shared-tab-per-run protocol, so Aperture's engine carries warm ref state across a run's episodes while the Playwright arms have none. The asymmetry is real, favours neither side uniformly, and is in the numbers rather than hidden from them — the warm-revisit cost above is precisely it being paid.

Where the crossover sits, from a page-size sweep on one task (54 episodes, full-snapshot weights 1,116–38,081 chars, measured on the pre-tier5 engine and not re-run since): the diff arm is never significantly dearer at any measured size, and is significantly cheaper from ≈10k chars (≈2.5k tokens) up — 19% / 39% / 43% cheaper per episode at the top three rungs, growing monotonically with page weight. At the two smallest rungs the intervals include zero, so the sweep neither confirms nor refutes the +4–6% small-page premium the earlier waves measured; it caps any such premium at +20% of episode cost. One task, one model, synthetic inert padding.

Correctness: the primary we lost, and then fixed

  • Precision (head-to-head primary): the +0.2/run wrong-element bound HOLDS, and the sign reversed. Pooled delta −0.109 [−0.200, −0.036]; the pre-fix cohort measured +0.173 [0.018, 0.345] against the same bound and FAILED. Zero landed wrong-element actions across all 220 Aperture episodes — the home rate went 0.540 → 0.000 — against 0.240/run for sealed Playwright on the same fixtures. Two caveats travel with the number: the negative sign is partly the incumbent's own sampled wrong clicks rising 8 → 12 between cohorts, so the durable claim is Aperture's zero, not Playwright's 0.24; and zero-landed-wrong is a measured result on fixtures purpose-built to stress re-rendering identical-row lists, not a structural guarantee.

  • The hazard is still reached — it now fails loudly instead of landing. Agents acted on stale refs 90 times in 50 home episodes (the pre-fix cohort: 27 landed wrong plus 8 refused), and every one was refused with a restatement naming what died, followed by a re-observation and a correct act. Hard-queue success rose with it: 40% → 70% and 50% → 90%. This is the designed currency conversion, not a hazard that stopped occurring — a distinction the store can make because the retirement events are on the wire.

  • Reliability (head-to-head primary): the −10pp non-inferiority bound HOLDS, and now holds without the ruled cell. +10.0pp [−0.3, +20.2] pooled; the CI touches zero, so this is non-inferiority and no superiority claim. The delta is carried by one task (catalog-order, where sealed Playwright scores 0/10 and both Aperture arms 10/10); excluding it, +1.0pp [−9.2, +11.2] — parity, with the bound still holding, which the pre-fix cohort could not say. On the disclosed-adversarial home set Aperture now leads 90% to 88%, retiring by measurement the earlier cohort's "the incumbent led on our own hard set".

  • None of that task-success gap belongs to the diff mechanism. diff − re-dump is +0.0pp [−9.2, +9.2]; re-dump − sealed carries the entire +10.0pp. The product gap is engine and dialect — compact rendering of a page Playwright's dialect renders at ~22k tokens — and no share of it may be attributed to diffs. What diffs measurably win is economics, and nothing else; what they measurably cost reliability is zero, which after the retirement surgery is itself a result.

  • Sealing the incumbent cost it capability, and that matters more than our win. On this cohort stock Playwright MCP scored 81.8% against its own sealed configuration's 76.4% — inside this cohort's noise, but the direction repeats the archived cohort's +15.5pp finding, and on catalog-order the stock surface converts what the sealed one cannot (100% vs 0%). The sealed comparison understates the incumbent, and stock Playwright remains the stronger choice where its full surface is acceptable. Every stock number carries "with code-execution, network-inspection and screenshot tools disabled".

  • The mechanism guard printed CONFIRMED and is adjudicated as task-mix arithmetic. Its pooled 62.7% observation-byte share crosses the 50% bar only because catalog-order is in the pool — the same single cell that held the share under the bar last cohort, from the other side (33.3% without it). Where both arms solve the page, observation bytes are about half the cost delta and the turn term runs modestly against Aperture, because refusal recoveries and the warm-revisit expand buy real round-trips. The clean isolation of the observation channel is diff versus re-dump on the same engine and dialect: 0.46× on realistic-weight pages.

Scope, and it is narrow. One model (claude-sonnet-5, an undated alias — cross-cohort comparisons of agent behaviour, including "the incumbent got slightly worse", are observations and never claims). Our fixtures — 5 disclosed-adversarial plus 6 preregistered-neutral, synthetic, static, logged-out, no anti-bot, no iframes, no auth; the "13-task" label of the older docs was always 11 prompts, hash-unchanged across both cohorts. MCP mode only: Playwright's own recommended CLI/skills mode, which its README concedes is the token-efficient path, is unmeasured, and if that mode wins the economics then MCP-vs-MCP was the wrong fight. Every Playwright episode ran branded Chrome 150.0.7871.187, not the pinned chromium build the spec named — that build cannot spawn on this machine, so the pinned browser never ran (Aperture ran its own Electron-bundled Chromium, as always). Sealed Playwright ran with codegen off, which makes key/scroll/type-without-submit return zero bytes — its shipped conduct, disclosed in the shared tool description. Sonnet has trained on Playwright's dialect and never on Aperture's; that asymmetry is unclaimable in either direction. account-prefs scores near-zero in every arm on a case-sensitive predicate defect, deliberately left unfixed so the task set stayed byte-identical between cohorts; it is in the reliability pool and excluded from every cost claim. The measured build is the fix plus the 2026-08-05 security hardening, so every number above is stamped 0916e30f… rather than to the fix's own landing. The suite's own report still exits non-zero on this store (the catalog-order tripwire, re-investigated and ruled the same genuine product difference), so the verdict was computed out of band with the suite's own stats code. The full disclosure block is in bench/RESULTS.md.

Speed

Aperture finished faster than Playwright MCP in every task class, in both head-to-head cohorts — median wall-clock per episode, 385 episodes each:

task class

Aperture

Playwright MCP (sealed)

(stock)

adversarial home

36.7s

96.3s

84.4s

neutral, small pages

24.7s

41.5s

46.1s

neutral, real-page weight

24.7s

61.7s

58.5s

The load-bearing half of that is browser time — the seconds spent inside the tool call, not waiting on the model — measured directly per call:

task class

Aperture

Playwright MCP

adversarial home

1.1s

42.4s

neutral, small pages

0.7s

18.2s

neutral, real-page weight

0.7s

12.2s

That is ~0.075s per action against ~3s, and it is the same mechanism the cost result measures: a full aria snapshot generated and shipped across a process boundary on every action, versus a diff computed in-process.

Stated at the scope it was measured, which is narrower than the table looks. Wall-clock was preregistered as reported, never verdicted — there is no confidence interval and no bound it passed, because end-to-end time at these episode lengths is dominated by API queueing noise (pw-sealed's neutral-large episodes range [34.4s, 141.2s]). One machine, serial runs, one model. What survives that caveat: the direction is consistent across all six class-cohort cells, and the browser-time component is not queueing noise — it is time the browser spent, recorded per call.

Two things this does not claim. It says nothing about raw Playwright: a script with no model in the loop runs at machine speed and wins outright on any task it already knows how to do. And the wall-clock gap is not the diff mechanism's alone — the same engine in re-dump mode posts 33.8s/24.3s/27.6s, so most of the browser-time advantage is Aperture's in-process engine rather than diffs specifically.

A side effect worth recording: Aperture's home median was 70.5s before the positional-rebind fix and 36.7s after. Wrong actions were burning turns; not making them made the browser faster.

The mechanism, on our own bench

  • Diff fidelity: six scenarios GREEN (npm run bench:fidelity) — typing with a mid-run resync, full DOM teardowns, clicks and state flips through a shadow root with a ticking clock suppressed, mass ref death and revival through the size-cap resync, native <select>s plus a custom ARIA combobox, and a same-walker pierce that asserts table-cell text, an href rewrite and a label morph against a model built from the stream alone. Vacuity guards mean a run that measured nothing exits without printing a verdict at all. What a green licenses: the diff stream is complete and unambiguous for a mechanical rule-following reader — if a real agent's model drifts, the fault is its bookkeeping, not missing information in the stream. It does not prove an LLM does that bookkeeping correctly, and it does not check containment or position: a stream that reordered the world would still pass.

  • Refusals and retractions: 11/11 — the oldest block of npm run bench:guards, judged against the fixture's own change-event log rather than Aperture's own report, because an error: reply is not evidence that nothing was written. The same probe scored 1/11 on the build immediately before the fixes. The suite has since grown to cover redaction, egress and request signing; its last full run was 72/72 green against artifact 4115dd9f…, and it refuses to start at all against a build older than src/.

  • Ref stability across a re-snapshot: 100% on four real sites, including one whose content changes underneath. Through a full re-render refs survive when the element has a distinguishing name and cannot be re-identified when siblings are identical — the positional hazard above, measured in the lab before the head-to-head measured it in the field. Since the retirement fix that case is refused rather than rebound.

  • The synthetic token model (npm run bench) puts observation cost at 6.6×–10.2× lower in diff mode over a 20-action sequence, rising with page size. It is a model of one fixture family, not a measurement of anything an agent did, and the campaigns above supersede it for any deployment claim.

An earlier version of this file claimed ~50×, then ~40×, from design targets nobody had measured. Both were wrong, in the same way: neither accounted for fixed per-response overhead, and neither counted the turns an agent spends deciding. The envelope overhead has since been cut from 420 to 104 chars — 79 tokens per response, reconciled to the byte — and the honest headline is the one above, which is smaller than the first draft and better evidenced.

What none of this settles

  • Live websites. Every scored fixture is synthetic. No anti-bot, no A/B drift, no iframes, no auth.

  • Other models. One model throughout. A model that reads 22k-token dumps reliably, or one that cannot read 6k, moves every headline number.

  • The truncation regime — an agent on a page bigger than its budget. That is the default product experience, and the sweep priced the enabler world instead.

  • Hard tasks on big pages. The one unmeasured quadrant of the cost picture, and the one place the sweep's flat voluntary-observation residue could plausibly break.

  • Long horizons. Everything is ≤16 actions with budgets that always fit.

  • Structure, containment, position, iframes — outside what the fidelity bench calls faithful.

Small tool surface, deliberately — with a measured counterweight

Every registered MCP tool costs schema tokens before it does anything, and playwright-mcp (~50 tools) and chrome-devtools-mcp (51) levy that tax on every session. Aperture ships 14, kept down by putting related operations behind an action discriminator rather than splitting them into a tool each:

browser_tabs      browser_navigate   browser_snapshot   browser_read
browser_act       browser_fill_form  browser_profile    browser_attach
browser_capture   browser_container  browser_theme      browser_console
vault_entries_for_origin             vault_request_fill

browser_act covers click, type, clear, hover, scroll, key and select.

The counterweight, because it was measured against us: in the head-to-head, giving the incumbent more tools made it better, not worse. Stock Playwright beat its own three-tool sealed configuration by 15.5 points on the first cohort and by 5.5 on the second (inside that cohort's noise, same direction), and both times the dividend concentrated exactly where a scoping affordance was missing — on the one task the sealed surface fails outright, stock converts every run. A small surface is a real token saving and a real capability cost, and this project has now paid the second half twice.

Note what is absent: there is no vault_reveal, no vault_unlock, no vault_export, and no tool that reads the filesystem. Those capabilities do not exist on this surface at all — see below for why that is the enforcement mechanism rather than a policy.

Autofill: the thing that makes this useful daily

A job application asks for the same twelve facts the last one did. Aperture stores them once, and the agent proposes filling them:

profile "Demo" · 11 of 12 fields ready

e3  "First name"             → givenName: Brad
e5  "Email address"          → email ~85%: brad@example.com
e8  "Apartment, suite, etc." → addressLine2: —  SKIP: no-value
e16 "Date of birth"          → dateOfBirth ~85%: (from profile — value not shown)

Calling apply then raises a native OS dialog naming the origin and the fields. The agent cannot render it, see it, click it, or pass a parameter that skips it, and sensitive fields never ride on a prior grant.

That gate used to be a sentence in the tool description asking the agent to check with the human — which made the approval the agent's judgement, and the agent is exactly the component we assume a hostile page can steer. The vault got origin binding with no override while profile autofill got a polite suggestion. That inconsistency was the worst thing in this codebase and is now fixed.

Note what is not in the plan above: "Why do you want this role?" is a prose question, not an identity field, and matching a stray keyword inside one is how you end up submitting "Director" as an essay answer. Free-text prompts are excluded by shape.

Matching uses the HTML autocomplete attribute first — it is a standardized declaration of exactly what a field wants, and a surprising share of real forms set it correctly — then label heuristics. Low-confidence matches are reported but not filled: silently putting a wrong value in a field the human then submits is worse than leaving it blank.

Attachments (CV, cover letter, portfolio) work the same way, with one hard constraint: the agent picks files by id from a library the human curated, and cannot pass a path. "Attach my CV" and "read any file on this machine and upload it somewhere" are the same primitive if the agent controls the path — so it doesn't.

This is also the honest answer to CAPTCHAs (see below): the human proves they're human, the agent does the typing. No evasion involved, which is exactly why it keeps working.

Dark mode

Three layers, because no single one covers every case:

  1. Tell the site the truthprefers-color-scheme reports the real preference, so sites shipping their own dark theme use it. Always better than anything synthesized.

  2. Chromium force-dark for sites without one. It works on the rendered layout tree and runs in the compositor, so it costs nothing per frame. Applied per-tab via CDP Emulation.setAutoDarkModeOverrideverified working on Electron 43, which is what makes per-site control possible at all (the Blink setting alone is process-wide).

  3. Filter inversion as fallback and for brightness/contrast, which force-dark doesn't expose at runtime.

Per-site policy is Auto / On / Off, where Auto measures the page's background luminance and skips sites that are already dark.

The filter fallback and the control model are adapted from Nightfall — specifically its counter-inversion set with the nesting guard, which stops a photo inside an already-counter-inverted container coming out net-inverted. An extension can't reach layer 2; owning the browser is the only way to get it.

Privacy

  • Identity containers — isolated cookie jar, cache, and storage per container, with a per-container fingerprint seed. Containers cannot be merged and sites cannot be moved between them from the agent surface: a page that could talk the agent into merging two containers defeats the isolation in one move, so that stays a human decision.

  • Fingerprint consistency over randomness. Randomizing canvas noise per load makes you more identifiable — real browsers are boringly stable, so a machine whose fingerprint changes every load is wearing a sign. One seed per container, every surface derived from it, frozen while the container holds state. (The seed exists and is stable; the per-surface derivation is not yet applied — see Honest status.)

  • Tracker blocking via Ghostery's compiled engine (EasyList/EasyPrivacy scale), because an agent makes far more requests per minute than a human and per-request matching cost is on the hot path.

  • Chromium's phone-home features (variations, domain reliability, autofill server) are switched off at launch, and WebRTC is prevented from leaking the local IP.

The password vault is agent-blind by construction

We are building a password manager into a browser an agent can drive. That creates an unusual requirement: the agent must be able to cause a login without ever being able to read the credential. Agent context goes to a model API, gets logged, and may be summarized or persisted — treat a credential entering it as a full compromise.

Two properties do the work, and both are structural rather than procedural:

  1. No getter exists. Not disabled, not flagged — absent. vault_request_fill performs the insertion and reports which fields it touched. A getter that exists can be called, and anything the agent can call, a hostile page can talk it into calling.

  2. Unnameability. vault_entries_for_origin returns only entries matching the page's own origin. On evil.com the agent is never told a google.com entry exists, so a prompt injection has no identifier to weaponize. Removing the vocabulary beats blocking the action.

Origin mismatch is terminal — no force flag anywhere in the surface. The agent is precisely the component we assume is manipulable; giving it an override reduces the design to the agent's judgment under adversarial input.

Crypto: Argon2id (moderate limits, calibrated) → XChaCha20-Poly1305. The 192-bit nonce means random nonces are safe without counter state.

Taint redaction is best-effort, not a guarantee. Mirrored values are caught by exact substring match, so a reformatted date (fill 1990-01-05, page echoes January 5, 1990), a case change, or a value split across text nodes all defeat it — and it over-redacts, turning every "Anna" on the page into a marker if that is your first name. It raises the cost of an accidental echo; it is not a boundary. The boundaries are origin binding and the process split.

What this does not claim: a page can read back its own DOM, so a password delivered to an origin is a password that origin has. A password manager's real guarantee is correct routing, not secrecy from the site. Passkeys are the actual fix; every password in a vault is technical debt.

The vault window

The password manager is a separate window with three properties that are structural rather than policy:

  1. The agent cannot address it. TabManager never learns about it, so it has no tab id — and every agent action routes through TabManager by id. There is no argument the agent could pass to reach it. Not a blocked one, an unrepresentable one. Verified: with the vault window open, browser_tabs lists only the browser tab.

  2. It is excluded from capture. setContentProtection(true) maps to WDA_EXCLUDEFROMCAPTURE on Windows, so screenshots and screen shares see a blank region — including the agent's own capture tool. This is asserted by the code and not yet verified by a probe on the deployment OS; it is queued.

  3. Its IPC is separate and sender-checked. The vaultui: channels verify the caller is the vault window on every call, so knowing a channel name is not enough to use it.

revealForHuman is the one function in the codebase that returns a plaintext password. It is safe only because of where it can be called from, and it must never grow a caller outside that window.

Honest limitation: the security design calls for this to run in its own OS process (hardened: its own low-privilege account). It currently runs in the browser process. That is sound against the threat that actually matters — a hostile page steering the agent — and not sound against local code execution as the same user. That is the T-Base tier in docs/design/security.md.

Two-factor codes

The vault stores TOTP seeds and shows live codes with a countdown. No registration, no accounts, no network calls — TOTP (RFC 6238) is an offline standard, and "Google Authenticator" is just one client for it. Paste what the site prints next to its QR code. Verified against the RFC's own test vectors.

On using 2FA to unlock the vault itself: don't bother, and here's why. The vault is encrypted with a key derived from your passphrase. A TOTP check would be an if statement in the UI — and an attacker with the vault file ignores the UI entirely and attacks the passphrase. Worse, verifying codes requires storing the seed in the vault, so it is available to exactly the attacker it claims to stop. A second factor only means something at rest if it contributes key material, which is what a TPM or Windows Hello does and what TOTP structurally cannot. Storing codes for other sites is genuinely valuable; gating the vault with one is theatre.

Crash reporting (off by default)

Reports go to your own uh-oh instance, and only if you turn it on.

A browser is the worst app to wire crash reporting into carelessly, because the crash context is the sensitive data: URLs are browsing history, titles are page content, stack traces can carry form values, and this process holds the MCP token and your profile. So:

  • Allowlist, not denylist. The event is rebuilt from an empty object with only named fields copied in — a delete pass would leak whatever the SDK adds in a future version.

  • URLs become a salted hash of the origin. You get "these crashes cluster on one site" without recording which site. The salt is per-install, so hashes can't be compared across users or matched against a list of popular domains.

  • Your home directory is stripped from file paths, since it usually contains your real name.

  • The vault reports nothing. Not scrubbed — excluded, by tag and by any vault module appearing in any stack frame. False positives cost a diagnostic; a false negative costs a credential.

  • Fail closed. uh-oh's client documents that a beforeSend which throws results in the event being sent unmodified — backwards for a scrubber — so ours never throws and returns null on any failure.

  • A final independent gate re-scans the serialized payload and drops the whole event if a secret survived, precisely because it doesn't depend on the structural pass being correct.

A dedicated envelope suite covers this, including "an unknown top-level field is dropped" and "a malformed event fails closed."

Verify it end-to-end, not just in unit tests. npx electron . --test-crash sends a probe error deliberately containing a URL, an email, a bearer token and your home path, so you can inspect what actually landed on the server. That flag exists because the unit tests were green while the pipeline was completely broken — three separate ways:

  • The scrubber rebuilt events against a shape I had assumed rather than the real EventEnvelopeSchema (stacktrace is a flat array, not {frames}; mechanism is required; breadcrumbs use ts, not timestamp). The server 400'd every event. The tests passed because they used the same invented shape as the code did.

  • That same wrong shape meant originatesInVault found no frames — so the vault exclusion, the strongest claim in this section, silently excluded nothing.

  • With events finally arriving, the leak audit found the username in every stack frame: real frames are file:///C:/Users/name/… with forward slashes, while the stripper matched os.homedir()'s backslash form.

All three are fixed and now covered, including an assertion that scrubbed output still satisfies the wire contract. The lesson is worth keeping: a test that shares the code's assumptions validates the assumption, not the behaviour.

Capture → Notion

One button, on a fallback chain that matches what you actually mean:

  1. A Notion page is open in a tab → append the capture there.

  2. Notion configured but no page open → append to today's dated page.

  3. Otherwise → write a PNG to Pictures/Aperture.

Every step falls through on failure, and step 3 cannot fail short of a full disk. Losing a screenshot because an API call returned 400 would be the worst outcome for a button whose entire job is "keep this".

The capture is filed, never returned to the agent's context — invisible text in a screenshot is a known prompt-injection vector against vision models.

The Notion API path is unverified. Two pages of Notion's own docs disagree on the field names for file uploads, and I had no workspace token to test against. It is written to fail loudly and fall back to disk. Validate it before trusting it; the token goes in the vault window's Notion tab, never through the agent or a chat message.

Prompt injection is the defining threat

Brave demonstrated hidden text in a Reddit post making Comet exfiltrate a user's one-time passcode, and invisible text in screenshots steering a vision model. This is not patchable at the model layer.

Aperture's response is structural: every tool result carrying page text is wrapped in an <untrusted-page-content> envelope whose delimiters carry a fresh per-call random nonce that is stripped from the body — so the closing tag cannot occur inside the content no matter what the page writes, by construction rather than by the nonce staying secret. What the envelope means is stated in the tool descriptions, not repeated in every response: clients re-send tool descriptions on every request, so that explanation survives context compaction. Harness speech never appears inside an envelope, and page bytes never appear outside one — including browser_tabs' list of page-authored tab titles and browser_fill_form's page-authored field labels, both of which reached the agent bare until an audit caught them. The snapshot format reinforces it: all page-authored text is quoted and control/bidi characters are stripped, so a page cannot emit text that parses as snapshot structure or forge a FULL SNAPSHOT header.

The other half: a value Aperture filled must not come back out

The envelope marks page bytes as untrusted. It does nothing about the case where the untrusted bytes are your own secret, copied out of the field Aperture just filled and into somewhere the agent reads. Four review rounds went after that, and the useful output is not the number of holes closed — it is the shape they had. Sorted by mechanism rather than by surface, fifteen findings collapse to seven classes, and docs/design/security.md carries the table:

mechanism

the failure it names

A

enumeration

a field nobody listed carries the value out

B

scope

the redactor's reach does not follow the value — to the origin the tab carried it to, or through the opener chain

C

alphabet

redactor and renderer read different bytes (an invisible separator, whitespace, URL percent-encoding)

D

parity

one helper, several call sites, and only some of them got the treatment

E

egress

Aperture acts outside the page on a string the page chose

F

coverage

a whole data class the machinery was never wired to — a filled date of birth came back verbatim for three gates

G

lifetime

when the redactor holds the value; the navigation that delivered it was the one that disarmed the redactor

The point is the stopping criterion, and it is one you can fail. "No more findings" is unfalsifiable and had been wrong four times. What replaced it: every mechanism has a guard that fails when that mechanism regresses; each guard has been shown to fail by sabotage; and the sabotage row is an instance of the class its author did not have in hand — someone other than the guard's author picks the row. That third clause is the whole difference. An independent gate applied it to the two newest guards, re-ran their recorded rows (both red, as claimed), then wrote its own row for each — and both went green. A guard that fails on the example it was written from has been shown to recognise its author's example, not to catch the mechanism. Twice, satisfying the clause changed a guard rather than confirming it.

Stated rather than buried: two of the seven rows, C and D, have no author-independent row yet. What is known about them is exactly the standard that was just shown to be insufficient, and they are the first thing a fifth reviewer should point at. And one residual is deliberate: a short all-digit sensitive value — a one-time code, a six-to-eight-digit account number — is matched only on the origin it was filled into, so a copy of it on an origin the tab merely carried is not scrubbed there. On a carried origin nothing distinguishes those digits from the page's own order number, and a marker that is sometimes a lie is worse than coverage that is sometimes absent. Two guards hold that bound from both sides: one fails on over-redaction, one on under-redaction. Page-side transformation — base64, reversal, one character per element — defeats substring matching and always will.

About CAPTCHAs — read this before expecting Camoufox behavior

The original goal here was "avoids CAPTCHAs like Camoufox." Having researched the 2026 state of the art, that premise does not hold, and building toward it would be building the wrong thing.

CAPTCHA decisions are made across five layers:

Layer

Weight

Where Aperture stands

IP / ASN reputation

hard gate

Your proxy choice; nothing a browser fixes

TLS (JA3/JA4) + HTTP/2

hard gate

Free — a real Chromium gives a real Chrome fingerprint

Automation-protocol tells

high

Avoid CDP Runtime.enable; use isolated worlds

Browser fingerprint

consistency tax

Containers (above) get you to unremarkable, not good

Behavior

the decision variable

The hard part, and not a browser problem

Camoufox is excellent at the fingerprint layer and its own docs call the behavioral layer work-in-progress. But Cloudflare shipped Precursor on 2026-07-13: continuous, session-scoped behavioral scoring explicitly built to detect agentic behavior, which survives page refresh. Cloudflare names "mathematically perfect Bézier curves" as a bot signal — indicting every humanization library by technique.

And an LLM agent has a signature no browser fork can hide: bursts of perfect action separated by multi-second silences while the model thinks.

The strategically correct answer in late 2026 is the opposite of hiding. Web Bot Auth (RFC 9421 HTTP message signatures) lets an agent cryptographically identify itself and be allowed. From 2026-09-15 Cloudflare blocks Agent-class traffic by default on ad-monetized pages for newly onboarded domains, with bot operators enrolling as signed agents through its dashboard.

That date is not Aperture's deadline, and chasing it would be a mistake. Enrollment as deployed assumes a hosted agent whose requests egress from operator infrastructure holding the operator's private key. Aperture is a local personal browser: a project-level key would ship inside every install, i.e. be public, i.e. be worthless — anyone could sign as "Aperture", and the first abuser burns the key's reputation for everyone. Checked against Cloudflare's published policy rather than inferred: signed-agent enrollment requires widespread use of zones, which one install per human fails per key, and there is no per-user enrollment path in the policy at all.

So what shipped is the capability, not the registration — and it is built and live-verified, not planned (docs/design/webbotauth.md):

  • Ed25519 signing per identity container, not per install. One key across containers would hand every allowlisted origin a cross-container correlator, which is the exact thing the container work exists to prevent.

  • Structurally off, not defaulted off. Nothing signs unless a human writes userData/botauth.json naming a directory URL where the public key is published. With no directory URL there is nothing to verify a keyid against, and an unverifiable keyid is a supercookie.

  • Main-frame document requests in agent-owned tabs, to origins the human listed — and nothing else. Subresources are unsignable, which closes the oracle where page script on an allowlisted origin could mint signatures with a chosen method and path via fetch(). Human tabs are never signed, even on allowlisted domains: a bot assertion over a human's browsing is the anti-detect lie inverted.

  • The agent surface is zero. No tool reads, writes or reports any of it, and the allowlist is withheld from the agent because it is a targeting map.

  • Verified against a build, both ways. The guard suite runs 72/72 green with the feature present and goes red on six named legs with the request mux uninstalled — including the three absence guards, which are wired to hard-fail rather than pass trivially when nothing signs anywhere. Both artifact hashes are recorded. Interop was measured against Cloudflare's own signer and verifier before any of that.

Still owed, and named as owed: two live-only sabotage rows, and the header-order measurement of what an installed request listener does to Chromium's wire.

Aperture is built for your browsing: your accounts, your sessions, your automation. It is not a mass-evasion tool and will not be pointed in that direction.

Honest status

Area

State

Electron shell, tab model (WebContentsView), browser UI

Working — launches and browses

MCP server over Streamable HTTP, 14 tools

Working — verified against a live browser

Bearer auth + DNS-rebinding guards

Working — verified (401 / 403)

Untrusted-content envelope

Working — verified in both directions by bench:live on every site: page bytes always inside, Aperture's own ok … always outside

Snapshot engine end-to-end (walker → refs → diff → render)

Working — verified on real sites and six fidelity fixtures

browser_act (click/type/clear/hover/scroll/key/select)

Working — trusted CDP input for pointer/keyboard, isolated-world setter for select; returns a diff

Input witness (act acknowledged ⇒ input actually reached the page)

Working — covers targeted acts plus scroll and key; unknown never fails an act, and a page that self-navigates mid-settle is invisible to it

Diff fidelity — typing, re-renders, clicks/state flips, shadow DOM, both resync fallbacks, native + ARIA selects, table/href/label blind spots

GREEN across six scenarios (npm run bench:fidelity), with vacuity guards so an empty run cannot score

Refusals and retractions (disabled, obstructed, blank-query, bounded error text, option-list retraction)

GREEN — 11/11, judged against the page's own event log; the wider guard suite's last full run was 72/72 (npm run bench:guards)

Ref stability across a re-snapshot

Measured: 100% on four real sites

Ref survival through a full re-render

Measured: survives for named elements; identical siblings cannot be re-identified — since the retirement fix they are refused rather than rebound

Positional refs under row removal

Fixed and re-measured — the family retires on membership change; zero landed wrong-element actions in 220 episodes, 90 stale acts refused in 50

Positional refs under row insertion

Fixed — a positional family that gains a member escalates to a full replace

Equal-size same-walk family churn (one member out, one in)

Undetectable in principle at the key level — recorded, not pretended at

Cost vs Playwright MCP

Measured — 0.390× on realistic-weight pages, 0.823× on the adversarial small ones, null on small neutral ones; see above for scope

Task success attributable to the diff mechanism

Measured: none — diff − re-dump +0.0pp [−9.2, +9.2]; the product gap is engine and dialect

Page-size cost crossover

Measured on the pre-tier5 engine — diffs never significantly dearer, significantly cheaper from ≈10k chars up; lower edge unresolved, not re-run since

Autofill: profile matching, plan/apply, sensitive-field redaction

Working — verified end-to-end

Autofill consent gate

Working — native OS dialog the agent cannot render, see, click, or bypass

Dark mode (per-tab force-dark + per-site policy)

Working — CDP override verified on Electron 43

Password manager UI

Working — content-protected window, entry CRUD, reveal with auto-hide, generator, identity + attachment + Notion editors

Vault MCP fill path

Working — crypto, origin binding (bundled PSL), native consent dialog, and the insertion itself; the password is never returned to the agent

Redaction of filled values in everything the agent reads

Working — seven mechanism classes, each with a guard, five of seven sabotaged by someone other than the guard's author. Residual: a short all-digit value on a carried origin

Web Bot Auth (RFC 9421 request signing)

Working — per-container key, agent-owned main-frame documents only, off unless a human configures it. Two live sabotage rows and the header-order measurement still owed

2FA (TOTP)

Working — verified against the test vectors in RFC 6238

Capture → Notion

Working; disk fallback verified. The Notion API path is unverified

Crash reporting to uh-oh

Working — verified end-to-end against a live server, payload audited for leaks. Off by default

Attachments (CV upload via DOM.setFileInputFiles)

Built; library is human-curated. Multi-upload forms need the ref→node bridge

Tracker blocking

Wired; not yet measured

Identity containers

Sessions, partitions and a stable per-container seed working; per-surface fingerprint derivation not applied

inert / pointer-events: none / small modal dialogs

Known gap — only :disabled and the covering-overlay hit-test are enforced

Structure/containment/position fidelity, iframes, model-side budget truncation

Not measured by any benchmark

Live-web behaviour

Not measured — every scored fixture is synthetic

Extensions

Not started — see below

A bug worth recording, because testing caught it and the design predicted it. The first end-to-end autofill run filled the date of birth through the agent-blind path, and then leaked it straight back out in the next snapshot — because the walker reads input values from the DOM. Filling correctly is only half the job: the value now lives in the page, and the agent has tools that read the page. Fixed with a per-tab taint set that redacts those fields before anything downstream sees them, cleared on navigation. The redaction is a fixed marker rather than a length-accurate mask, because length is real information about a secret.

Known risks, stated rather than buried:

  • The positional-ref hazard is closed in one currency, not abolished. On a re-rendering list of identical rows, a stale ref is now refused rather than silently rebound — but the agent still reaches for it, 90 times in 50 measured episodes, and every refusal costs a round-trip. What was measured is zero landed wrong actions on fixtures built to force them; that is a result on those fixtures, not a structural guarantee.

  • A positional family that loses one member and gains another in the same walk is undetectable in principle at the key level — membership size is unchanged and no key looks added, so no fixture can produce it and no diff-side rule can catch it. Closing it would need walker-side identity rebinding, which was ruled out on its own false-positive risk.

  • Two of the seven redaction guard classes have never faced an author-independent sabotage row (C, alphabet; D, parity). They catch the instances they were written from, which is exactly the standard shown to be insufficient.

  • Electron has no declarativeNetRequest and no chrome.action, so modern MV3 content blockers and extension toolbar UI do not work out of the box. The community shim (electron-chrome-extensions) is ~13 months stale, GPL-3, and untested against Electron 43. Aperture uses Ghostery for blocking instead and treats Chrome-extension compatibility as an open question, not a promise.

  • Several Electron API behaviors the design depends on still need verification — chiefly whether Electron's WebAuthn support can host a platform authenticator (if not, passkeys become a Chromium-patch project and the vault roadmap stays password-primary), and whether setContentProtection actually excludes the vault window from capture on Windows 11. docs/design/security.md and docs/design/tier2.md §6 carry the ranked queue. One item already resolved unfavourably: overriding the UA does not keep Sec-CH-UA client hints coherent, which cost a claim this file used to make.

Getting started

npm install && npm run build && npx electron .

On launch it prints a ready-to-paste command and writes the same config to %APPDATA%/aperture/mcp.json. The bearer token is regenerated every launch.

claude mcp add --transport http aperture http://127.0.0.1:8817/mcp -H "Authorization: Bearer <token>"
npm test         # full suite — snapshot engine, security, vault, bench readers
npm run typecheck

Benchmarks, and what each one answers, are in docs/HANDOFF.md; the results and their adjudications are in bench/RESULTS.md.

Layout

src/
  main/       Electron main: window, tab manager, IPC
  preload/    shell.ts (trusted chrome UI) · page.ts (hostile territory)
  renderer/   the browser chrome UI
  core/       snapshot engine — walker, registry, diff, render, volatility
  mcp/        MCP server + tool surface
  privacy/    containers, tracker blocking
  net/        the one webRequest mux · Web Bot Auth signing
  vault/      agent-blind password vault
bench/        tokens · live · fidelity · guards · task · size · headtohead · probes
docs/design/  snapshot.md · security.md · webbotauth.md · the tier specs · the adjudications

Maintenance

ActivitySlowing
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers