Skip to content

Repository files navigation

Moiré

Moiré

Security and context integrity for MCP-controlled browser agents.

Browser agents don't see the page the way you do. They see a text serialization built from the DOM, accessibility metadata, and browser tooling. Anything in it that you can't see on screen is context the page controls and you can't supervise.

Moiré compares what the agent would receive against what is actually visible, quarantines unexplained differences, and keeps authorization decisions outside the model. It mediates both directions: context in, actions out.

flowchart LR
    W["Website"] --> M1["Moiré<br/>what the agent<br/>is allowed to see"]
    M1 --> L["LLM"]
    L --> M2["Moiré<br/>what the agent<br/>is allowed to do"]
    M2 --> B["Browser"]
Loading

A moiré pattern appears when two overlaid grids don't quite align. Neither grid looks wrong on its own; the artifact only shows up in the disagreement. That's how detection works here.

The side panel shows it directly: aligned rings when the page and the agent's view agree, interference when they don't.

Side panel on a clean page: nothing hidden, what you see is what the agent gets

Side panel on a page with hidden instructions: the payload is quarantined and withheld from the agent

What this is and is not

The limits go first - they are what make the rest believable.

  • Mediation is opt-in. Moiré only protects the agent that reaches the browser through its tools. Another extension, or a browser MCP attached over CDP, still reads the raw page, and Moiré can't see those reads. Watchdog mode (below) is the smoke alarm for that gap; proxy mode (roadmap) is the fix.
  • Visible injection is out of scope. A payload in plainly readable body text looks the same in every projection - no divergence, nothing to detect. Moiré closes one indirect injection channel; it does not solve prompt injection in general.
  • Detection is probabilistic; enforcement is deterministic. Detectors report typed evidence with confidence. The policy engine alone maps evidence to allow / approve / deny. No LLM sits inside the authorization boundary.
  • Invisible doesn't mean untrusted. An aria-label="Delete customer" on an icon button is hidden content the agent should receive. What matters is whether hidden content is bound to a real, visible control.
  • Time-of-check/time-of-use is narrowed, not closed. Serialization is tied to a scan, and a MutationObserver invalidates stale scans. A mutation between scan and action is still possible, but auditable.
  • Opaque regions are reported, not silently skipped. Cross-origin frames and canvas can't be scanned, so they surface as opaqueRegions. Closed shadow roots can't even be discovered in v0.
  • Accessibility-tree and OCR projections are roadmap. Until they land, accessibility-tree spoofing and image-embedded payloads get through.

How it works

The context an agent receives contains only three things: text that passes the visibility checks, hidden metadata bound to visible controls, and content you explicitly restored. Everything else is quarantined.

Read path. get_page runs scan(document): project the DOM text and the visible text, diff them, classify what diverges, serialize. Trusted segments go through; the rest is quarantined. The agent gets sanitized context plus metadata (quarantinedCount, budgetExhausted, opaqueRegions), so it knows the scan was incomplete without ever seeing the hidden text.

Write path. fill_fields goes through the policy engine. Fields are classified deterministically, and writing a sensitive field on a new origin requires your approval. The approval card shows the exact rule that fired. The verdict is bound to the page revision and per-field digests, rechecked right before the write, so the page can't swap a field after approval.

ARCHITECTURE.md covers the trust model, both flows, and the package map.

Watchdog mode

A smoke alarm, not a firewall. Containment only covers context served through Moiré's own tools, but Moiré can still tell you when the page carries content that unmediated tools won't filter.

The content script scans automatically - at idle and after bursts of mutations - and alerts on a fixed tier mapping:

  • muted badge (): quarantine-grade hidden content, none of it attack-shaped - the normal state on heavy apps;
  • numeric badge: counts attention-grade groups - hidden content that is attack-shaped (imperative prose, charset payloads, prose where an accessible name belongs), one concealed container counted once;
  • notify (opt-in, off by default): a desktop notification, only for hidden imperative payloads.

The side panel updates live and carries the full numbers: every quarantined segment stays restorable, grouped by concealed container. Admitted accessibility content bound to visible controls remains the exception, as ever.

This blocks nothing and attributes nothing. An alert means "this page contains content your other tools won't filter," never "an agent just read this." Alerts carry origins and counts only; the hidden text stays in the panel.

The precision problem

Legitimate hidden content is everywhere: accessibility helpers, clipped application state, .visually-hidden utilities. A hidden-text detector that ignores this is unusable on the real web. That's why bound-semantics assessment runs before any perceptual check, and why measurement is the point:

  • Recall is measured against authored fixtures and reconstructed third-party attack shapes (evals/authored, evals/third-party).
  • Precision is a real crawl (evals/precision), reporting findings per 1,000 pages. Preliminary, not benchmark-quality.
pnpm test                      # recall + adversarial suites
node evals/precision/crawl.mjs # add URLs to evals/precision/urls.txt first

Documented bypasses

Three attacks defeat the current detector. They're kept as expected-failing tests in evals/adversarial/ and never "fixed" to pass:

  1. Visible semantic injection - all projections agree, so there is no signal. Closing it needs semantic defense, a documented non-goal.
  2. Just-above-threshold contrast - ink just over the contrast floor is technically legible but goes unread. Numeric floors invite gaming.
  3. Opaque-region payload - text painted into a canvas isn't in the DOM. Reported as an opaqueRegion; closing it needs OCR (roadmap).

Layout

packages/
  core/            # pure logic, no browser APIs: projections, divergence, serializer, classification, policy, wire auth
  extension/
    platform/      # the only files that differ per browser, behind the Platform interface
    background/    # session state, bridge routing, approvals, audit, watchdog alerting (badge / notifications)
    content/       # thin: owns the revision counter, calls core, executes approved writes, schedules watchdog scans
    panel/         # scan status, findings, quarantine + restore, approval card, audit stream
  mcp/             # MCP server + authenticated loopback bridge + tools
  shared/          # protocol types only (type-only import of core)
manifests/         # base.json + per-browser overlays, merged at build
fixtures/          # static HTML pages core is unit-tested against
evals/             # authored / third-party / adversarial / precision - imports core directly

packages/core uses standard DOM types only, never chrome.* / browser.* - that's what lets evals/ and the fixture tests run without a browser. Per-browser code lives only in packages/extension/platform/, behind the Platform interface; the build aliases @platform per target and merges manifests/base.json with the per-browser overlay.

Commands

pnpm install
pnpm build            # dist/chrome, dist/firefox, and the moire-mcp CLI bundle
pnpm build:chrome
pnpm build:firefox
pnpm build:mcp
pnpm typecheck
pnpm test

Running it

  1. pnpm build, then load dist/chrome via chrome://extensions (Developer mode → "Load unpacked"). Firefox: about:debugging → "Load Temporary Add-on" → dist/firefox/manifest.json.

  2. Click the Moiré toolbar action to open the side panel and expand Connect an agent for the pairing secret. The secret is generated once per install and persists across browser restarts, so the registration below keeps working session to session. Regenerate secret in the same drawer rotates it; after rotating, re-register the agent with the new value.

  3. Register the server with your MCP client (the client spawns the server process, so don't run it manually).

    Claude Code - the panel's Connect an agent drawer provides this command with the secret filled in:

    claude mcp add moire -e MOIRE_BRIDGE_SECRET=<secret from the panel> \
      -- node packages/mcp/dist/moire-mcp.cjs

    Then start a new session - MCP servers load at startup.

    Claude Desktop and other clients, in their MCP config:

    {
      "mcpServers": {
        "moire": {
          "command": "node",
          "args": ["<repo path>/packages/mcp/dist/moire-mcp.cjs"],
          "env": { "MOIRE_BRIDGE_SECRET": "<secret from the panel>" }
        }
      }
    }
  4. The panel header shows "Agent connected" once paired. get_page returns sanitized context; fill_fields on a sensitive field raises the approval card.

To smoke-test pairing without an MCP client, run the server bare:

MOIRE_BRIDGE_SECRET=<secret from the panel> node packages/mcp/dist/moire-mcp.cjs

The panel pairs, but no tools can be called - nothing is attached to the server's stdio. Stop it before starting your MCP client, or the client's instance can't bind the bridge port.

v0 is tested and demoed on Chrome; the Firefox build compiles but is not exercised.

Trying it on the fixtures

The pages in fixtures/ are the demo targets. Serve them over HTTP so the content script injects without extra browser flags:

npx serve fixtures
# or: python3 -m http.server 8000 --directory fixtures

The index page catalogues every fixture and what the detector should say about it.

  • normal.html - baseline: no findings, nothing quarantined, no badge.
  • display-none.html, clipped.html, offscreen.html - the muted badge () appears within a second; Scan again shows the finding under Withheld from the agent, and the payload is absent from get_page.
  • busy-benign-app.html - noise reduction: a webmail-shaped page with many quarantined segments, a muted badge, and zero attention-grade findings.
  • bound-semantics.html - precision: hidden accessibility content bound to a visible control is admitted, no badge.
  • bound-abuse.html, charset-anomaly.html - attack-shaped: the numeric badge counts their groups; with notifications enabled, bound-abuse.html also raises a desktop notification.
  • sensitive-form.html - write path: fill_fields on the SSN or card field raises the approval card.

file:// URLs also work after enabling "Allow access to file URLs" for Moiré in chrome://extensions (reload the tab afterwards).

Roadmap: proxy mode

The most broadly useful form of Moiré wraps an existing browser MCP server rather than replacing it:

# conceptual
npx moire --upstream chrome-devtools-mcp

Keep your existing browser MCP. Add Moiré to control what your agent sees and does. Tool requests to the upstream cross the same policy engine, and page content the upstream returns is scanned by the same core (bundled into an upstream evaluate_script call - core is pure and dependency-free). The trust model is weaker and stated as such: the trusted computing base moves into the proxy process, and the upstream MCP is trusted to execute faithfully. Browser MCPs are not insecure - they are capability layers whose security model expects the client to provide this validation, and that's the seam Moiré fills.

License

MIT

About

Divergence detection for AI browser agents: catch hidden prompt injection, quarantine it, enforce policy before actions execute

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages