Skip to content

feat(discover): inventory the browser WebMCP surface - #340

Open
Ar9av wants to merge 4 commits into
mainfrom
feat/discover-webmcp
Open

Ar9av wants to merge 4 commits into
mainfrom
feat/discover-webmcp

Conversation

@Ar9av

@Ar9av Ar9av commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #339.

What

Adds a fourth inventory to prismor discover: the browser.

WebMCP (Chrome 150+, behind a flag) lets a page register tools on itself with document.modelContext.registerTool(); an agent in the same tab finds them with getTools() and runs them with executeTool(). The exchange never leaves the browser — no child process, no transport, no config file naming a server — so a page offering a transfer-funds tool to an in-tab agent appeared in none of the three existing sections, and coverage still read 100%.

  BROWSER (WebMCP)
    no coverage            Model Context Tool Inspector  [chrome]  Default
      …/Chrome/Default/Extensions/gbpdfapgefenggkahomfgkhfehlcenpd/1.2.0
      · Known WebMCP tool inspector.
    no coverage            Shopping Copilot  [chrome]  Default
      …/Chrome/Default/Extensions/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/3.1.4
      · Extension code references the WebMCP API.
    no coverage            webmcp-for-testing  [chrome]
      ~/Library/Application Support/Google/Chrome/Local State
      · Pages in this browser can register tools for an in-tab agent to call.

  ──────────────────────────────────────────────────────────
  Coverage:  60%  of discovered AI surface is governed
  Shadow:    3 agent(s), 1 MCP server(s)
  Browser:   3 WebMCP surface(s)  — not covered by any surface, not scored

What it does and does not claim

Which tools a page exposes is decided at runtime and is not observable from disk. This does not pretend otherwise. What is on disk is the precondition, and that is what gets reported, per Chromium-family browser (Chrome and its channels, Edge, Brave, Chromium, Arc):

  • The experiment is enabledLocal Statebrowser.enabled_labs_experiments. Matched on substrings, because the flag gets renamed between milestones while the capability it gates stays the same; a pinned exact string would silently stop matching.
  • An extension speaks the API — the bundle's own JavaScript references modelContext. The known-id map (currently just the Model Context Tool Inspector) only supplies a friendly name; the source scan is the actual signal, since an allowlist cannot see an extension nobody has catalogued yet.

Extensions that cannot reach page context are skipped. That is correct — WebMCP is exposed to the page, so an extension that never gets there is not calling it — and it is also what keeps the scan off almost every bundle in a profile. On my machine 30 extensions across 9 profiles narrow to 15 worth scanning, and the whole sweep runs in 0.2s.

Locations and names only. No history, no extension storage, no page contents — the same construction as CredentialRecord, whose fields cannot hold a secret value.

Why these findings stay out of the coverage score

Prismor has no interception point inside a browser tab. An enabled WebMCP profile is therefore not governable surface that was skipped; it is surface nothing can cover yet. Folding it into the ratio would drive the number down with findings no --fix can clear.

That is the distinction AgentRecord.coverable already draws for agents Prismor has no hook for: reported, badged distinctly, excluded from the score. This section gets the same treatment — badged no coverage rather than SHADOW, reported as coverable: false in the wire payload, and given no --fix path, because offering one would promise a remediation that does not exist.

Notes for review

  • os.walk ordering was a real bug, not just a test bug (second commit). The scan has file/byte ceilings, so the walk can stop early — and os.walk returns filesystem order, which is arbitrary on ext4. Unsorted, which files got read varied by platform: the bounded-scan test passed on macOS and failed on Linux, and the same bundle could report a finding on one machine and not another. Found by running the suite on a real Linux box; fixed by sorting.
  • The control plane drops this kind for now. normalizeFinding in lib/shadow-inventory.ts enum-checks against agent|mcp|credential and returns null otherwise, so these rows report cleanly (no 4xx) and land in the console once that set is extended — a one-line follow-up on the web side, not a blocker here.
  • discover_webmcp(workspace) ignores workspace; the browser surface is a property of the machine, not of a checkout. The parameter is accepted so it matches every other discover_* entry point.

Testing

  • 20 new tests in tests/test_discover_webmcp.py, against a fake $HOME laid out for whichever platform the suite is running on, so the path enumeration is under test rather than stubbed. Covers both signals, the page-context filter, localised (__MSG_) names, the newest-version and multi-profile dedupes, malformed/missing Local State, the scan ceilings, and that none of it moves coverage or the shadow counts.
  • Full suite before/after: 21 failed both times, identical FAILED sets (pre-existing, see Test suite leaks a deny-everything PolicyEngine across modules; tests/ is red on main #308); 2375 → 2395 passed, the +20 being these tests.
  • Verified on macOS against 8 real Chrome profiles plus Chromium/Edge/Brave/Arc, and on a headless Ubuntu box (no browsers: empty section, no crash) where all 20 tests also pass.
  • scripts/check_oss_safe.py and tests/test_oss_guard.py pass.

WebMCP (Chrome 150+, behind a flag) lets a page register tools on itself with
document.modelContext.registerTool(); an agent in the same tab finds them with
getTools() and runs them with executeTool(). The exchange never leaves the
browser -- no child process, no transport, no config file naming a server.

Every surface `prismor discover` knew about is a file on disk: an agent by its
hook config, an MCP server by its declaration, a key by an env var. The browser
offers none of those, so a page exposing a transfer-funds tool to an in-tab
agent appeared in none of the three sections and coverage still read 100%.

The tool calls themselves are decided at runtime and are not observable from
disk; this does not pretend otherwise. What is on disk is the precondition, and
that is what gets reported: profiles with the experiment enabled (Local State,
matched on substrings because the flag is renamed between milestones), and
extensions that name the API in their own code. The known-id map only supplies
a friendly name -- the source scan is the signal, since an allowlist cannot see
an extension nobody has catalogued yet. Extensions that cannot reach page
context are skipped, which is both correct (WebMCP is exposed to the page) and
what keeps the scan off almost every bundle in a profile.

These findings stay out of the coverage ratio. Prismor has no interception
point inside a tab, so an enabled profile is not governable surface that was
skipped -- it is surface nothing can cover yet, and scoring it would drive the
number down with findings no --fix can clear. That is the distinction
AgentRecord.coverable already draws for agents Prismor has no hook for, and
report_payload reports these as coverable: false for the same reason. There is
no --fix path for the section.

Locations and names only. No history, no extension storage, no page contents.

The control plane drops findings whose kind is outside agent|mcp|credential
(normalizeFinding enum-checks it), so these rows report cleanly today and land
in the console once that set is extended -- a one-line follow-up on the web
side, not a blocker here.
os.walk returns filesystem order, arbitrary on ext4. With the scan ceilings
the walk can stop early, so which files got read varied by platform -- the
bounded-scan test passed on macOS and failed on Linux, and the same bundle
could report a finding on one machine and not another. Sorting makes the
early stop reproducible.
Adds the section to the host-discovery deep dive and the CLI reference,
including why it is reported but never scored and why it takes no --fix.
Verified against Chrome 152: it ships two matching flags,
enable-webmcp-testing and devtools-webmcp-support, and only the first takes
document.modelContext from undefined to a live object. The finding text
asserted page tool registration for both. Now states that the experiment is
enabled and leaves it there, and the real flag names are pinned in a test.
@Ar9av

Ar9av commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Validated against a real browser (Chrome 152.0.7977.65), which the original implementation had not been.

The flag names are real. enable-webmcp-testing and devtools-webmcp-support are both present in the Chrome 152 framework binary, and both are caught by the webmcp substring hint. Pinned in a parametrised test so a future rename trips a canary rather than silently disabling detection.

The API name is confirmed. In a throwaway profile, document.modelContext is undefined without the feature and an object with --enable-features=WebMCP; registerTool() followed by getTools() returns the registered tool. So modelContext is the correct source marker for the extension scan, and it is document.modelContext — not navigator.modelContext, which some write-ups still say.

It caught a wording bug (last commit). Only enable-webmcp-testing exposes the API to pages; devtools-webmcp-support is DevTools tooling. The finding text asserted page tool registration for both, so it now states that the experiment is enabled and stops there.

Still not covered, and honestly can't be from a disk sweep: a page actually registering tools against a live in-tab agent. That is the runtime half this PR explicitly does not claim to see.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(discover): inventory the browser WebMCP surface

1 participant