Do not open public GitHub issues for security vulnerabilities.
Report vulnerabilities through:
- GitHub Security Advisories (preferred): Create a private security advisory
- Description of the vulnerability and potential impact
- Steps to reproduce or a minimal proof of concept
- The version(s) affected
- Acknowledgment within 48 hours
- Status update within 7 days
- Credit in the release notes (unless you prefer to stay anonymous)
| Version | Supported |
|---|---|
| Latest | Yes |
browser_safety is off by default and stores its settings only for the current MCP server session. When enabled, the extension inspects the target before a click, type or fill action and labels recognized submits, file uploads and common delete/remove controls. The exact hostname rule overrides defaultAction. block refuses the dispatch; ask requests a user decision through MCP elicitation and refuses when the connected client cannot provide that UI. The target classification and element fingerprint are checked again after approval. The extension also compares the fingerprint (element ref, tag and input type) of each element it resolves for a click, type or fill with the one that was classified, immediately before any input is sent, and refuses with SAFETY_TARGET_CHANGED when a different element is found; the same element changed in place (for example its text becoming "Delete") keeps its fingerprint and is not caught, and a point or drag target has no fingerprint to compare.
This policy is a guardrail, not a complete authorization boundary. Site-specific JavaScript controls can perform destructive work without a native submit control or a recognizable label, and the heuristic may miss them. Leave sensitive sites set to block when an incomplete page classifier is not acceptable. File paths and field values are not sent to the classifier.
A session is trusted and writable by default: the common case is an agent that has to work in the user's own signed-in tabs. Two fences can be raised per session with browser_tabs {action:"session"}:
access: "read-only"refuses every call that changes something — click, type, fill,browser_evaluate, a dialog, a mock, an emulation — withREAD_ONLY, before anything is dispatched. Navigation, scrolling and every read stay allowed: opening a page changes no data, and a read-only session that could not navigate could not read anything.isolation: "isolated"restricts the session to the tabs it opened itself. A call that names any other tab failsTAB_NOT_OWNEDbefore it is dispatched, and the tab listing shows the session's own tabs only: the user's other tabs are counted, not shown. A tab the session opens withbrowser_navigateis its own, so a session can still work.
Both are session settings: they last until the session ends, and the default stays trusted and writable, so a client that never asks for them keeps today's behaviour. The fences are enforced in the MCP server, before the call reaches the extension, and a batch's steps are checked one by one — onError: "continue" cannot walk around them.
They are a guardrail for the agent's own behaviour, not an authorization boundary: a paired client that asks for access: "full" gets writing back, and browser_evaluate remains arbitrary page-context JavaScript for a trusted session.
- Local-only communication: the daemon's HTTP/WebSocket control plane is hard-bound to IPv4 loopback (
127.0.0.1), not configurable throughWS_HOST. WebSocket traffic between the extension and daemon remains local. MCP clients use stdio and then an authenticated local IPC socket; they do not get a TCP listener. - Remote callers and n8n: Browser Controller has no supported remote or n8n ingress protocol. Do not publish port 7225 or change the listener to a wildcard address. A remote integration should run on the same host and reach the MCP client through its approved local process boundary, or be given a separately authenticated transport; browser-control credentials must not be reused as a network API key.
- Origin validation (exact-match on a pinned extension ID): a request whose
Originis notchrome-extension://…is refused. The daemon pins the first extension origin that passes authenticated HTTP or WebSocket proof, then refuses every later request whose Origin is not an exact match. A web page and a co-installed hostile extension carry their ownOrigin; requests without an Origin are allowed through this gate for local clients but must still prove identity. Origin and loopback address are not treated as identity. - Mutual WebSocket authentication: the extension sends a fresh random nonce in
authInit. The daemon proves its identity with an HMAC under the enrollment secret, bound to the nonce, daemon instance, protocol version, and capabilities. The extension verifies it before adopting daemon state, enabling vault messages, or accepting control frames. The daemon's hello also carries a fresh nonce of its own for that socket. The extension then proves the auth token inhelloAck, bound to both nonces, so a captured acknowledgement cannot authenticate another connection; the daemon marks it ready only after verifying the proof. Neither secret nor token appears in the URL, WebSocket upgrade, subprotocol, or handshake frame. - Authenticated HTTP operations:
/pair,/status, and/killrequire a fresh challenge, client proof, and signed response./auth/challengereturns only a daemon proof over a client nonce; callers verify it before sending a request proof. Challenges are single-use and expire. Port-conflict checks and lifecycle probes use the same verification, so an unrelated listener never receives the enrollment secret and cannot be mistaken for the daemon. - Versioned capability handshake (fail closed): transport authentication is necessary but not sufficient to mark a peer ready. Each side sends a
protocolVersionand its capabilities. A peer must send an explicit protocol version and thetools-v3capability before any tool call is routed to it; otherwise it is refused. This holds for the extension WebSocket and for the MCP-client IPC socket. A different protocol major is refused. Application-version differences alone are diagnostic. A tool reply is accepted only from the connection the call was sent on. - No data exfiltration: Nothing leaves your machine. No cloud, no telemetry, no analytics.
- Broad-but-purpose-scoped permissions: the extension requests
debugger,scripting,webRequest, and<all_urls>— among the most powerful Chrome-extension permissions available. They are required for the tool's purpose:debuggerruns DevTools Protocol operations for trusted input (clicks, typing, keys, wheel, drags, file inputs), screenshots of background tabs,browser_evaluate, console and network reads, per-tab response mocks, and element inspection;scriptingruns page functions;webRequestrecords request metadata forbrowser_logs; and<all_urls>lets the selected tools operate on normal web tabs across origins. This is not "minimal" in the Chrome-Web-Store sense; it is the smallest practical set for "control your real browser." Chrome-protected pages remain outside the extension's reach.nativeMessagingis used only to ask the local pairing helper for the enrollment secret (see below). - Nothing runs in the pages you browse yourself: the extension declares no content scripts. Code reaches a page only while a tool acts on that tab (page functions, the agent's frame and cursor).
browser_logsreads what Chrome already keeps for the page through the debugger, so the page'sconsoleis never wrapped and a site's own errors are never reported as the extension's. - Trusted-agent boundary: a successfully paired MCP client is authorized to act with those browser permissions. In particular,
browser_evaluatecan execute arbitrary page-context JavaScript, while CDP-backed tools may access data visible to DevTools. Authentication protects the control channel from unpaired callers; it does not sandbox or second-guess an authorized agent. Only connect clients and prompts you trust, especially while banking, email, admin, or company SSO tabs are open. - Local daemon log privacy: vault calls may write the action, outcome, site origin and request ID to
daemon.log; those origins can reveal which sites were used. On POSIX systems, the daemon opens regular owner-owned files without following final-component symbolic links, rejects hard links, and tightens the current and previous log to mode0600when starting. This does not revoke already-open file descriptors or remove platform-specific ACLs, and does not establish Windows ACLs. Logs rotate by size at startup, not on a time-based schedule, so both copies may persist. Review contents before sharing them; logs are not a secret-safe channel. - Per-tab request changes:
browser_mockanswers or blocks the requests of one tab through the debugger's Fetch domain. It has no effect on other tabs, and it stops when the debugger detaches. - Sticky tab state ends with its owner:
browser_mockrules,browser_emulateoverrides andbrowser_logsresponse-body capture stay on a tab until they are cleared. The session that set them clears them when that session disconnects, and a closed tab forgets them. A disconnect never clears another session's settings. - Private windows (incognito):
browser_navigatewithincognito: trueopens the session's new tab in a private window, and only after the user turns on "Allow in Incognito" for the extension. That switch also lets agents see and drive the user's own private tabs:browser_tabslists them, markedincognito. Vault fills, logs and screenshots work there as in a normal tab. The private window is not closed when the session ends; the tab lock is released as usual. - File uploads: an authorized agent can upload any readable local file to a page with
browser_fill, except the paths the server refuses before anything is sent (FILE_NOT_ALLOWED). Refused are hidden files (a last path segment that starts with.) and any path that contains one of these names:.ssh,.aws,.gnupg,.kube,.docker,.azure,.config,.browser-controller,.password-store,.netrc,.git-credentials,Keychains. A symlink is resolved before the check. - Co-installed extension hardening: an untrusted extension cannot obtain the auth token from
/pairwithout a fresh request proof under the out-of-band enrollment secret. It also cannot impersonate the daemon to the paired extension without signing its nonce-bound WebSocket hello. Origin pinning remains an additional post-authentication restriction. - Enrollment secret (pairing): the daemon generates a random secret at
~/.browser-controller/enrollment.json(mode 0600) and prints it to stderr on first run of the MCP server. The extension receives it from the local pairing helper below or the user pastes it once into the popup's "Enrollment Secret" field; it is stored inchrome.storage.localand used locally to verify HMAC proofs. The extension never sends it to the HTTP listener. To rotate, runnpm run pairing:rotate, thennpm run daemon:restart. It atomically replacesenrollment.jsonand the daemon auth tokentoken.json(both mode 0600), prints neither value, and does not touch any process, so the old values stay valid until the daemon restarts. Running MCP clients re-readtoken.jsonon each reconnect, so they pick up the new token after the restart without being restarted themselves. With the helper installed the extension re-pairs by itself (its next authentication failure triggers a fresh request to the helper); otherwise re-paste the new secret in the popup. Results ofbrowser_evaluateand daemon logs are not a secret-safe channel: do not use them to carry or inspect the enrollment secret or token. - Credential files: on POSIX, credential reads check that the state directory and regular credential files belong to the current user, refuse final-component symlinks, and tighten their mode to
0700and0600respectively. An invalid credential file is refused instead of silently replacing it; preserve it for diagnosis and use the documented pairing rotation when recovery is needed. POSIX mode checks do not establish Windows ACLs or remove platform-specific ACLs. - Pairing helper (Chrome native messaging,
npm run setup:pairing): a per-user native messaging host (mcp-server/dist/native-host.js) answers one request with the enrollment secret and daemon port, then exits; it opens no port and writes nothing. Chrome starts it only for extension IDs in its host manifest'sallowed_origins. The secret moves over the helper's stdio, never over the daemon's HTTP channel. The helper reads a file the same user can already read, so it grants no new access. - Strict TypeScript: compiled with strict mode to reduce runtime errors.
- The layers are enforced, not agreed. The server's layering is checked by the build:
npm run arch:checkreads the real import graph and fails when a boundary is crossed (the neutralcore/layer stays neutral, the credential vault does not depend on the memory layer, the wire contract does not know the tools layer), andnpm run lintenforces the same boundaries asno-restricted-importsrules. A dependency that would put the vault on the memory layer, or the transport on the tools layer, cannot land unnoticed. - A failure code is registered, or it is not a code. Every
CODE: messagefailure comes from one registry (mcp-server/src/core/error-codes.ts). A line that only looks like a coded message (TAB_OWNED: …) is not one: it is rewritten asINTERNAL_ERRORinstead of reaching the agent as a code nothing in the product produces, so a mistyped code becomes a visible generic failure instead of a silent one. - The tool inventory is derived from the registry. The tool names the wire accepts are generated from the tool registry rather than written by hand, and the AI-facing inventories (
llms.txt,.cursor/rules/project.mdc) are checked against the same registry. A stale name, or a name nobody registered, cannot slip into the surfaces an agent reads, and the drift fails the build.
browser_vault accepts opaque account handles; only the authenticated daemon resolves an encrypted local account. Password entry is confined to the extension-owned masked page. Fill is bound to the approved browser connection, live session, exact origin, current top document, same-origin target frame, and one-use inspection nonce. The extension uses a bounded isolated-world chrome.scripting call targeted by document ID, and never clicks Submit. A timeout after dispatch has an unknown outcome and is never retried automatically.
The vault directory is treated as credential material for local file uploads. browser_fill refuses files under the canonical BC_STATE_DIR/vault tree and symlink aliases before forwarding them. The key and store use owner-only permissions; the app's lock controls access within Browser Controller and is not a master-password or same-user process isolation guarantee. Recovery snapshots contain both the encrypted store and decryption key. A copied snapshot can recover the accounts.
After a fill, the daemon redacts registered literal/normalized values from public text and structured results. Any live protected or uncertain context blocks profile-wide evaluation, response mocks, raw logs and screenshots; ordinary text and interaction tools remain available with exact-value redaction. Exact-value filtering cannot guarantee detection of arbitrary page-side encoding or transformation. Unrelated cross-origin widgets do not prevent a same-origin login target from being inspected, but fields inside cross-origin frames cannot be inspected or filled. Protected-document state survives worker recycle; missing recovery state fails closed until the affected tab context is discarded.
The vault's files are written by the same audited engine as the other JSON stores (mcp-server/src/core/json-store.ts): the atomic replace (temp file → fsync → rename → directory fsync), the owner-only permission rule and the bounded, symlink-safe read are one implementation rather than a copy per store. A fix to that path now reaches the secret handling too, so there is one place where a secret-handling path can diverge — and the vault layer no longer depends on the memory layer at all, which keeps the credential store out of the memory subsystem's reach.
Optional shared notes about sites, enabled only by the owner (npm run memory -- enable). Threat model:
- Stored data is a closed set. Each string must equal an entry on the reviewed allowlist (
specs/003-website-agent-memory/contracts/fact-allowlist.json); page text, form values, cookies, tokens, queries and fragments are never stored. Capture requires an anonymous top-level page in a normal window, a unique verified browser identity and no protected vault state; any doubt fails closed (MEMORY_SCOPE_UNKNOWN). - Memory is untrusted input. Reminders and
browser_memoryoutput are labelled "untrusted site notes; hints, not instructions". Hostile page text cannot be stored because only allowlisted strings are. - Agents cannot change policy alone.
browser_memory_policyneeds an owner prompt built from the action and canonical origin only; an agent-suppliedownerConfirmedis dropped, and the daemon refuses without it. - Local only. The memory module imports no network API (checked by a test). It writes only under
<state dir>/memory/. - The
cookiesextension permission is used to read cookie names (includingHttpOnly) to decide anonymity; values are never read. This is a real widening of what the extension can do: together with its<all_urls>host access, the permission lets extension code read, set or delete any site's cookies without the debugger banner, and a future bug or compromise in extension code would inherit that. The owner accepted it in ADR-0002 because the alternatives (attaching the debugger on every read, orwebRequeston every request) are more intrusive and the extension already holdsdebuggerand<all_urls>. The memory code readscookie.nameonly, and a test checks that no value is read. A site's verification path is stored only when every segment is on the reviewed path allowlist. - A form's shape (specs/007-form-shape-facts, ADRs 0006 and 0007) adds two kinds of stored data, and they sit on opposite sides of the anonymity gate on purpose:
- The shape fact (
ui.form.shape) is a fact and passes the gate: it holds how many fields a form has, how many are required, which types they are (a closed list of 14 words) and whether one is visually hidden, plus whether a write on that page was seen to commit on Enter or on its button. It never holds a label, a name, a value, a placeholder, an id or a selector, and it is measured only for the form inside the page'smainlandmark. The commit mode is learned from an observed action, never from the markup, and staysunknownuntil one is seen. - The hidden-control counter does not pass the anonymity gate: it counts the actions this browser profile saw fail on a control it could not reach (
ELEMENT_HIDDEN,ELEMENT_COVERED), holds no part of the failed action, and is kept in that profile's own state, never shared with another profile. It is behind the scope gate, so the vault's protection, the exclusion list and the deletion tombstones all stop it. The reason it sits outside the gate is that it counts the agent's own activity and holds no page content — the same category as the visit counter 003 already stores on those sites. - The origin of a refused action comes from the last observed read of its tab, kept in memory only, for at most 120 seconds. A tab that was never read has no known origin, and nothing is counted.
- The shape fact (
- Residual risks:
- Sites that identify users without cookies or storage (HTTP authentication, client certificates, IP-based personalization, tokens in a URL path) can look anonymous; such a page may be treated as public.
- Approval fatigue: an agent can request a policy change often, and a distracted owner may approve one. The MCP process stops prompting after 3 unconfirmed requests in 10 minutes per connection; it cannot tell the owner why a request was made.
- Anyone who can read the IPC token (
token.json, same OS user) can sendownerConfirmedor anymemory-admincommand to the daemon directly, with no prompt. That is the owner's authority by design, and an agent with shell access already has it.memory-admin exporthas no size or rate bound (up to about 32 MiB in one reply). - The anonymity check is best effort.
chrome.cookies.getAllis asked by URL and by domain (host and, for hosts with three or more labels, the parent), names only. The parent is always asked: no public suffix list ships, so skipping it on a guess (a two-label parent such asac.meis a registered domain, not a suffix) could hide a login cookie shared across subdomains and let a signed-in page pass as anonymous, while a scope Chrome itself forbids for domain cookies — a public suffix such asco.uk, or a hosting one such asgithub.io— holds none and answers empty. Not examined: Cache Storage, service workers, the origin-private file system, HTTP basic authentication, client certificates, and network-level identity (VPN, IP allowlists). Partitioned (CHIPS) cookies may be missed depending on the Chrome version. A signed-in intranet page can therefore pass as anonymous; the facts stored from it are still only allowlisted UI names. The analytics cookie names_ga_<6–12 upper-case letters or digits>and_gat_<letters or digits>count as anonymous; any other name does not. Allowlist v2 (2026-10-28) adds further consent, CDN and analytics names (for example__cf_bm,AWSALB, MediaWiki's<wiki>mwuser-sessionId) that sites set for signed-out visitors; a name it lists cannot make a signed-in page read as anonymous unless the site uses only listed names for its login state, and login cookies such ascentralauth_Sessionstay outside the list. - Anyone with shell access as the same OS user can run the owner CLI or read or edit the store directly. Memory does not defend against that user.
Optional per-profile map of a site's structure, enabled only by the owner for one Chrome profile at a time (npm run memory -- map enable --browser <id>, or an owner-confirmed map_enable). Website memory must be on as well. Design: ADR-0004 and ADR-0005. Threat model:
- It records signed-in pages. Unlike website memory, the map is not limited to anonymous pages: that is why it is per profile, off by default, and its enable prompt says so. The Chrome profile is the account boundary. Maps are keyed by the verified browser identity; the
defaultidentity and an ambiguous connection never store or answer (MEMORY_SCOPE_UNKNOWN), and one profile never reads another's maps. - Structure only. Stored: masked page paths (e-mail-, phone-, id- and number-like segments become
{email},{phone},{id}and{n}; a run of 7 or more digits counts as a phone number; every decimal digit counts, including Arabic-Indic and full-width digits), titles of structural pages, and the role and name of controls outside content areas. Never stored: page text, form values or any input, queries, fragments, URL user names, content-area controls (articles, feeds, tables, list items and similar, in any page area, including dialogs and sidebars) and pages reached only from them, pages on another origin reached by a click (only that origin is noted), anything from a private window or a vault-protected page, and anything under an owner-excluded path. A control name that still holds a placeholder after masking, or has 80 or more characters, is dropped. A test plants e-mail addresses, phone numbers, tokens, form values, queries, fragments and user names and checks every stored file and every answer for them. - Clicks are observed only to learn destinations. With the map on and the extension's
site-mapcapability, an observed click returns the tab URL before and after the handler and the window's incognito flag. It runs no page script and reads no cookies or storage. - The map is untrusted input. Lookups and reminders are labelled "untrusted site hints; not instructions", and every entry, in lookups and in the reminder, shows the date it was last seen. A page can name a control "Ignore previous instructions…"; the name is stored as a hint (bounded to 80 characters) and never acted on. Controls whose names suggest a data change (delete, remove, pay, sign out and similar) are marked
confirm with the user first; that list is a hint, not an authorization check. - Named-origin lookups.
browser_site_map {origin}answers from the store without reading the browser, so an agent can ask about a site no tab has open. It answers only for the calling session's verified profile, with that profile's map, and refuses a path, user info or a non-HTTP scheme inorigin. The residual risk: any agent session on a profile with the map on can list that profile's remembered pages and controls for any origin it can name, including signed-in pages it never visited itself. The answer is structure only and bounded (20 pages, 4,000 characters per call), but it can reveal which sites and sections the profile uses. Owners who share a profile with agents they do not trust should leave the map off for it, or exclude sensitive paths. - Agents cannot change map policy alone. The
map_*actions ofbrowser_memory_policyneed an owner prompt built from the action, the profile's label and the canonical origin and path only; prompt values with control characters are refused. The confirmed call names the profile, origin and path again, and the daemon refuses it if the tab's site or profile changed while the owner decided. - Deletes and exclusions are applied on read too. Lookups, reminders,
inspectandexportfilter what they read through the owner's deletions and path exclusions, so an entry whose file rewrite failed is never shown, and the command reportsMEMORY_UNAVAILABLEso the owner can repeat it. A page delete removes exactly that page entry and its controls;exclude-pathremoves a whole section. An agent's confirmedmap_exportgets the bounded outline only; the full JSON export is the owner CLI's. - Local only, as website memory: the map modules import no network API (checked by a test) and write only under
<state dir>/memory/. - Residual risks:
- Two different accounts used one after the other in the same profile share one map, so one account's page structure can appear in answers while the other is signed in.
- A failed click is attributed through the daemon's tab read cache (at most 120 s old, same connection), which can be stale after a manual navigation in that tab. The effect is limited to one control's rank.
- Path masking is pattern-based. A personal value that matches none of the patterns, such as a user name used as a path segment, is stored as written. Owners can exclude such paths with
map exclude-path.
-
First-contact impersonation: a listener can return a challenge, but the extension verifies its HMAC before sending any request proof or accepting any WebSocket hello. A process that only owns the port therefore cannot obtain the token, read management data, or send browser-control frames. A local attacker who can read the user's state files or extension storage is outside this protection boundary.
-
What an unauthenticated peer cannot do: obtain the token, read signed HTTP results, authenticate a WebSocket, or drive browser tools. Challenge responses intentionally expose only fresh public nonces and HMAC proofs.
-
What remains (residual, low-severity): a hostile extension that wins the pin and somehow learns the secret (e.g. the user pasted it into the wrong extension, or a separate compromise reads
chrome.storage.local) could act. This requires the attacker to already have a foothold on the user's browser and the user to have mishandled the secret — it is not a network-reachable attack. If you suspect this, runnpm run pairing:rotate. -
The enrollment secret is printed to stderr on the first run of the MCP server. Whatever captures that output sees it once.
-
The control shield is hidden while a screenshot is taken, and shown again afterwards.
-
Emulation overrides on a shared tab: when two sessions set emulation on the same tab, the last session that set an override wins.
-
Sensitive form values are masked by heuristic.
browser_snapshotmasks password, one-time-code, and payment-card fields identified from their type,autocomplete, names, labels, or a Luhn-valid card number.browser_textandbrowser_inspectredact matching values when page text echoes them; input verification keeps only booleans for detected sensitive fields. Detection can miss custom labels, and short values may remain if echoed in page prose.browser_evaluatecan return arbitrary page data, so callers can still explicitly retrieve such values.browser_logsredacts JSON bodies, text bodies and URL query values with secret-looking keys. -
browser_navigateon a frozen tab replaces the tab: the old tab is closed and the result names the new tab id. -
A JavaScript dialog opened before the debugger attaches cannot be answered. Chrome answers only the debugger session that saw the dialog open (
NO_DIALOG). -
Breaking change for existing installs: the enrollment secret is now mandatory. Users who upgrade will see the popup show "disconnected" until they run
npm run setup:pairing(the extension then pairs itself) or paste the secret (printed on first run of the MCP server) into the "Enrollment Secret" field. This is a deliberate one-time UX cost to close the first-contact race; see CHANGELOG.md. -
Same-origin iframe boundary: DOM snapshot, text, and target-based interaction paths can pierce same-origin iframes and open shadow roots. They cannot traverse a cross-origin iframe's DOM because the browser enforces the same-origin policy. The content may still be visible in a screenshot, but that does not make its elements safely addressable by a ref from the top document.
-
Top-frame control shield: the visible shield and its input interception are installed in the top document. They are a user-safety affordance, not an authorization boundary, and are not guaranteed to suppress input already focused inside an independent or cross-origin child frame. Avoid manual input anywhere in a tab while an agent controls or locks it.
-
Evaluation and CSP:
browser_evaluateruns over CDP by default, so a page Content Security Policy does not block it, and Chrome shows its debugging banner while the debugger is attached. When the debugger cannot attach, it falls back tochrome.scriptingin the page's MAIN world (reported asvia: scripting), which a strict CSP can reject. Both paths exercise the broaddebuggerpermission. -
Upgrade compatibility (no legacy window): 3.0 refuses every pre-3.0 peer. Update the MCP server, the daemon and the extension together. After you pull the update, stop the old daemon, restart your MCP clients so the 3.0 daemon starts, then reload the extension at
chrome://extensions. The refusal texts are:- A 3.0 MCP client meets a running 2.x daemon:
The running Browser Controller daemon is older than 3.0: stop it (npm run daemon:stop, or end the pid in its daemon.lock), then restart your MCP client so the 3.0 daemon starts. - A 2.x MCP client meets a 3.0 daemon:
This MCP client is older than 3.0: restart your MCP client so it starts the 3.0 client. - A 3.0 extension meets a 2.x daemon:
The Browser Controller daemon is older than 3.0: restart your MCP client so it starts the 3.0 daemon. - A 2.x extension meets a 3.0 daemon:
The Browser Controller extension is older than 3.0: reload it at chrome://extensions.
A protocol-major mismatch is the actual compatibility failure. A build-version mismatch is diagnostic only.
- A 3.0 MCP client meets a running 2.x daemon:
Before release, maintainers run the same local gates enforced by CI:
npm ci
npm audit --omit=dev
npm run context:lint
npm run context:measure
npm run lint
npm run arch:check
npm run typecheck
npm run build
npm run test:coverageProduction dependencies must have no known audit findings at release time, lint runs with zero warnings, the architectural layering checks pass, and coverage is held to the configured thresholds over the whole production surface. Chrome lifecycle entry points, popup rendering, and native debugger plumbing need a real extension runtime, so no unit test reaches them: the only checks that exercise them are the local smoke scripts (npm run smoke:real-chrome, smoke:vault-isolated, smoke:vault-card), which the pipeline does not run. A green pipeline therefore does not prove those paths. Audit data changes as advisories are published, so a previously clean lockfile must be checked again rather than assumed safe.
Dependency gate — production and development. The production gate (npm run security:audit, which is npm audit --omit=dev --audit-level=high) reports no findings, and so does the full tree: npm audit over every dependency reports no findings either.
The development tree needed one override to get there. The lint toolchain carried GHSA-g3hv-75wg-m249 (minimatch before 11.0.1, denial of service through glob-parts amplification) through eslint → @eslint/config-array → minimatch. The automatic route, npm audit fix --force, was rejected: npm states it "will install @eslint/js@9.39.5, which is a breaking change" — a downgrade of the installed 10.0.1, trading a linter version for an advisory no product path reaches. Instead package.json pins "overrides": { "minimatch": "^11.0.1" }, which resolves to 11.0.3 and clears the advisory without changing any tool. The override was measured before it was applied: eslint 10.14.0 running on minimatch 11.0.3 against this repository's own eslint.config.js and its own lint targets reports no errors, and npm run lint stays at zero after it lands. Two things to remember about it. The override pushes @eslint/config-array outside the range it declares (minimatch: ^10.2.4), so the combination is one the package author has not vouched for — revisit the override when ESLint is next upgraded, and drop it if the toolchain ever declares minimatch 11 itself. And npm audit alone would not have surfaced this: the production gate omits dev dependencies by design, so the development tree needs its own check.
A finding in the production tree is not a finding in the code. A dependency advisory names a package and a version range, not a reachable path. Before a fix changes a lockfile, check whether the affected function is called at all (for example, a package reached only transitively through the MCP SDK, with no call into the vulnerable API). That check is recorded in docs/audit/15-git-hygiene-and-dependency-audit.md, which covers both the exposure assessment and the fix.
In-scope:
- WebSocket security issues (authentication bypass, injection)
- Chrome extension permission escalation
- Data leakage through the MCP protocol
- Dependency vulnerabilities with a realistic exploit path
Out of scope:
- Issues in Chrome itself or the MCP SDK
- Denial of service via local WebSocket flooding
- Social engineering attacks