Skip to content
On this page

Architecture: genkan ​

This page is the component structure, the data flows, the protocols, the security model, and the constraints of genkan, with one diagram per trust boundary. The "why" behind the security decisions is security/rationale.md.

Every diagram box that names a file names one that exists and, for TypeScript, symbols it exports; a caption box names an outside actor. moon run check-architecture proves existence only, and each diagram's Demonstrated by: links resolve to the tests that cover it.

1. Architecture overview ​

MCP client A --stdio--> +--------------------------------------------------+
MCP client B --stdio--> | genkan (MCP server instances)           |
                        |                                                  |
                        |  first instance = BROKER                         |
                        |   - owns the bridge socket + lock file           |
                        |   - admits each harness against the              |
                        |     trusted-client allowlist (attested)          |
                        |   - holds session state, dispatches tools        |
                        |  later instances = relays, attach as clients     |
                        +------------------------+-------------------------+
                                                 | bridge socket: NDJSON over a
                                                 | 0600 Unix-domain socket in a
                                                 | 0700 runtime dir (a user-only
                                                 | named pipe on Windows); same-user
                                                 | check + attestation + HMAC handshake
                                                 v
                        +--------------------------------------------------+
                        | genkan --native-host  (one per browser, |
                        | spawned by that browser, label e.g. "chrome")    |
                        +------------------------+-------------------------+
                                                 | stdin/stdout, Chrome native
                                                 | messaging (4B LE len + JSON)
                                                 v
                        +--------------------------------------------------+
                        | Genkan extension (MV3, WXT)             |
                        |  service worker: dispatch, allowlist, masking,   |
                        |    kill-switch mirror, enrollment pin            |
                        |  content script + CDP backend: one shared DOM    |
                        |    implementation (snapshot/click/fill/...)      |
                        |  confirm.html: extension-owned confirmation      |
                        |    window, off the page-reachable DOM            |
                        +------------------------+-------------------------+
                                                 |
                                                 v
                                       the user's real page (logged in)

  Management surface (over the core, never a trust root):
    - the CLI: doctor --fix / uninstall / pair / pair-client / kill / unkill / policy / audit

2. The processes ​

ProcessWho starts itResponsibilityLifetime
MCP server (broker)The first MCP client to spawn oneOwns the socket and the lock, admits harnesses, holds session state, dispatches toolsUntil the last attached harness detaches
MCP server (relay)Each further MCP clientAttests itself to the broker and forwards its harness's callsFollows its client session
native hostEach browser (via the host manifest)Thin bridge between stdin/stdout NM frames and socket NDJSON; answers control frames (enrollment, kill, client admin, registration repair, policy restriction) itselfFollows the browser extension's Port
extension (SW + content)The browserPage operations, allowlist, confirmations, maskingThe SW restarts about every 5 minutes; the extension follows the browser

Why separate server and host processes: the browser spawns the native host itself (via the manifest) and the MCP client spawns the MCP server itself. The two are not parent and child, cannot share stdin/stdout, and so need an IPC channel between them.

Why the native host is so thin: all logic lives in the MCP server, so neither an SW restart nor a host restart loses session state. The host is a protocol translator with one addition: it terminates the control plane (enrollment ceremony frames, kill-switch frames, client-admin frames, audit-event forwarding) so those work even when the bridge is down or killed.

Why a broker instead of one server per client: several MCP clients may be configured at once, and the old newest-wins takeover (SIGTERM the previous server) made them fight over the browsers. The first instance now owns the socket; later attested instances attach as relays and share one session, ref-counted so the broker exits when the last harness detaches.

3. Protocol layers ​

3.1 Native Messaging (extension <-> native host) ​

Chrome's official protocol, defined at developer.chrome.com/native-messaging.

  • Frame format: 4-byte little-endian u32 length + UTF-8 JSON
  • Length counts only the JSON bytes, excluding the 4-byte prefix
  • Outbound (host -> Chrome) hard limit: 1 MB (exceeding it makes Chrome drop the Port immediately)
  • Inbound (Chrome -> host): 64 MB
  • Shutdown signal: stdin EOF (not SIGTERM); the host exits gracefully on EOF
  • stderr: not shown to the user, but usable for logging (recorded in Chrome's internal logs)
  • argv: Chrome appends the caller origin (e.g. chrome-extension://<id>/)

Key traps (all handled in the implementation):

  • All stdout writes must be single-threaded with a flush per frame (concurrent writes interleave in the pipe buffer and corrupt frames)
  • A panic prints to stdout by default and pollutes the stream, so a stderr panic hook is mandatory
  • panic = "abort" (Cargo profile) + the stderr hook, as a double safety net

3.2 MCP JSON-RPC (MCP server <-> MCP client) ​

Based on JSON-RPC 2.0 over NDJSON, defined at modelcontextprotocol.io.

  • Transport: stdin/stdout, NDJSON (one message per line, LF-terminated)
  • Protocol version 2026-07-28, the stateless revision
  • The layer is built on the official Rust SDK (rmcp; the trust-surface decision is in security/rationale.md), with a small shared tokio runtime confined to the MCP serve path.
  • Our invariants wrap the SDK: harness attestation and admission pass before serving begins, the kill switch and audit trail run on every tool call, diagnostics stay on stderr (stdout is protocol), and the broker/relay legs are unchanged
  • No handshake and no session state: a modern request carries the version and the client capabilities in params._meta (io.modelcontextprotocol/protocolVersion and .../clientCapabilities; rmcp requires both, an empty capabilities object suffices) and is gated per request
RequestAnswer
an unsupported version stringJSON-RPC error -32022 (UnsupportedProtocolVersionError) with data.supported (the full rmcp set) and data.requested
incomplete or malformed metadata-32602 naming the field(s)
a connection's FIRST request that is neither a legacy initialize nor a well-formed stateless requestthe connection drops without a reply, fail closed
a bare pinganswered without opening the connection
  • server/discover replaces the handshake. Its result declares supportedVersions (rmcp's full supported set, newest pinned to 2026-07-28 by a unit test) and capabilities: {"tools": {}}.
  • Cacheable results (server/discover, tools/list) carry ttlMs: 3600000 / cacheScope: "private" for peers >= 2026-07-28: the catalogue is static per binary, so one hour bounds staleness across upgrades, and "private" is the conservative scope, since a local single-user server has no shared caches to feed.
  • There is no bare-probe form: a server/discover without its _meta is refused (or, as an opener, dropped); the planned leniency was cut to match the SDK
  • Results carry resultType; the serverInfo _meta rides only the server/discover result; tool errors still use isError: true inside the result, not a JSON-RPC error, so the model sees the error text and can react
  • Handled methods: server/discover, tools/list, tools/call, plus ping for legacy peers (answered {} pre-open and in initialize-opened sessions; refused after a stateless opener); unknown methods return -32601 (initialize is served in every era as the legacy negotiation - rmcp echoes a supported requested revision and answers an unknown one with the newest)
  • Temporary legacy era: on a connection opened with initialize, a request with no _meta version key is served the previous revision's behavior (initialize / notifications/initialized / ping and legacy-shaped tool results) through rmcp's built-in earlier-revision support while Claude Code's 2026-07-28 support rolls out. Once the harness interop smoke shows our harnesses opening with server/discover, legacy support is disabled and a missing version key fails closed
  • Before any tool call is served, the harness that spawned the server must pass admission against the trusted-client allowlist (section 6 and trust-boundaries.md)

3.3 Internal bridge protocol (broker <-> native hosts and relays) ​

Custom, NDJSON over the bridge socket: a 0600 Unix-domain socket inside the 0700 per-user runtime directory on macOS/Linux, a named pipe only the current user can open on Windows (see SECURITY.md).

Connection setup, in order, each step fail-closed:

  1. Kernel checks (Unix): the accepting end verifies the peer's UID equals its own and takes a kernel-attested identity of the peer's running executable, which must match its own image (mutual).
  2. HMAC handshake: the server sends a fresh nonce; the peer answers with HMAC-SHA256(secret, nonce || 0x00 || label) over the per-run secret from the lock file, the label present when the peer claims a browser. The secret never crosses the wire; the nonce defeats replay, and a label the MAC does not cover fails verification.
  3. Attach frame: one mandatory role-declaring frame. A browser's native host attaches as a browser, under the label its handshake response carried (chrome, brave, ...); a relay attaches with its attested harness identity, which the broker checks against the trusted-client allowlist.
flowchart LR
  peer["a connecting peer: a browser's native host, or a relay"]
  lock["src/packages/core/src/ipc/lockfile.rs"]
  socket["src/packages/core/src/ipc/socket.rs"]
  cred["src/packages/core/src/ipc/peercred.rs"]
  attest["src/packages/core/src/ipc/attest.rs"]
  hs["src/packages/core/src/ipc/handshake.rs"]
  broker["src/packages/core/src/broker.rs"]
  session["src/packages/core/src/session.rs"]
  lock -->|endpoint and per-run secret| peer
  peer -->|connect| socket
  socket -->|same user| cred
  cred -->|same executable image, both ways| attest
  attest -->|HMAC over a fresh nonce and the claimed browser label| hs
  hs -->|attach frame| broker
  broker -->|a browser attach, registered under its label| session

The secret from the lock file never crosses the wire, and the nonce is fresh per connection, so a captured reply cannot replay. A relay's attach carries the identity its own attest_parent measured; the broker trusts that measurement because the relay passed attest_peer first and is therefore this binary. A relay never enters the session: the broker keeps its admission guard and serves its harness against the shared session itself.

Demonstrated by: session/tests.rs, broker/tests.rs, handshake_verify.rs, adversarial.py.

After attach, tool traffic is the BridgeReq/BridgeResp envelope pair (src/packages/core/src/protocol.rs; the Rust types are the wire contract, see section 11):

typescript
interface BridgeReq {
  id: number;        // monotonically increasing, pairs responses
  op: string;        // operation name, e.g. "tab_list", "page_click"
  browser?: string;  // target browser label (required when several attached)
  args: unknown;     // operation arguments
}

interface BridgeResp {
  id: number;
  ok: boolean;
  data?: unknown;
  error?: string;
}

Control frames (enrollment, revocation, kill switch, audit events, policy, language, WebAuthn) ride the native-messaging leg between the extension and its host and are terminated at the host; the same frame kinds arriving from the socket leg are dropped as injections (see trust-boundaries.md).

3.4 The native host's frame router ​

flowchart LR
  port["src/apps/extension/src/lib/background/port.ts<br>connectNative() collaborators"]
  conn["src/apps/extension/src/lib/background/connection.ts<br>Connection"]
  host["src/packages/core/src/native_host.rs"]
  control["src/packages/core/src/protocol/control.rs"]
  broker["src/packages/core/src/broker.rs"]
  port -->|one live Port at a time, reconnected on disconnect| conn
  conn -->|native-messaging frames| host
  host -->|classify_nm_frame on every inbound frame| control
  control -->|Forward, as an NDJSON line| broker
  control -->|Handle or Malformed, handled by the host, some without a reply| host
  broker -->|a control tag from the socket leg is dropped| host

The router is a pure function of the frame, so the handled-versus-forwarded decision is unit-tested without a socket. Bridge requests carry op and no type, and the socket handshake frames never traverse the pump, so nothing legitimate collides with a control tag.

Demonstrated by: control/tests.rs, native_host/tests.rs, port-routing.test.ts, cancel_test.ts.

4. Components in detail ​

4.1 The Rust core (src/packages/core) and the binary (src/apps/host) ​

The binary is a thin argv dispatch (src/apps/host/src/main.rs) over the genkan-core library:

ModuleResponsibility
protocol.rsMessage types and read/write for the three protocols; the wire-envelope contract; stderr panic hook; SIGPIPE ignore
protocol/control.rsThe host-handled control frames (enclave, admin, kill switch, audit, browser registration status and repair, policy and its restriction lane, language, WebAuthn) and classify_nm_frame, the router that answers them locally and forwards everything else
ipc/The bridge socket: platform socket + lockfile + peer credentials + attestation + HMAC handshake, split per concern with platform impls
broker.rsBroker ownership, relay attach/detach ref-counting, DoS caps, the kill-switch watcher
session.rsConnection registry keyed by browser label; request/response pairing by id; per-connection generation guard; an in-flight guard that cancels an abandoned request at the tool call's deadline
mcp_server.rsDefault mode: harness admission, JSON-RPC loop, dispatch into the shared session
native_host.rs--native-host mode: NM frames <-> socket NDJSON, control-plane frame handling, graceful exit on EOF
tools/The tool catalogue (26 tools; the cross-process contract source): one catalogue! row per tool emits the BridgeCommand enum, the ToolId index, and the Tool record (metadata, grants, dispatch, typed args schema); capabilities are read off the records
runtime_record.rsThe one loader and writer for every JSON record in the runtime directory: capped read, version envelope, strict parse, atomic 0600 write under the runtime lock
migrations/One migration ladder per record, a floor plus its rungs (the rule under the runtime-state table below); the ladders are the only home for compatibility code
allowlist.rsThe trusted-client allowlist: the entry types, the pairing and revocation writes, and pair-client / revoke-client / list-clients
trust.rsThe trust record (trust.json): the kill latch, the paired clients, the change epoch, and the admission decision every enforcement point takes from one read
kill.rsKill-switch engage/release; release demands a PresenceAttestation
presence/Proof of user presence for a capability-granting act: a WebAuthn assertion from a credential the act's rule admits (this browser's for a kill release or a grant, any enrolled one for enrolling another browser), the extension's confirmation window only where that rule admits none, or the typed phrase on the CLI's terminal; which path vouched is audited per act
webauthn/The host as WebAuthn relying party: the statement a tap signs, the registration and assertion parsers, the verifier, and the enrollment store kept in trust.json
enclave/The host identity key: a P-256 key pair mints into the OS credential store (or a 0600 file with --file-store), which the extension pins and verifies the signed policy baseline against
audit.rsThe durable audit trail: bounded 0600 audit.log, strict-parsed JSON records, audit subcommand reader
registration/ + browsers.rsThe registration engine and browser-path resolver behind doctor --fix and uninstall
doctor.rsRead-only health report (doctor / status / doctor --list)
error.rsTyped CallError at the tool-call boundary and the stable ERROR_SPECS taxonomy
log.rsLeveled stderr logger (GENKAN_LOG) and the log_*! macros
identity.rsThe native-messaging host id and the pinned extension key: the single definition site

4.2 The extension (src/apps/extension) ​

Built on WXT (which generates the manifest, including the pinned key) with React UI, TypeScript strict, Vitest + fakeBrowser tests. The load-unpacked target is the build output build/extension/chrome-mv3, not the source directory.

WhereResponsibility
src/apps/extension/src/entrypoints/background.tsService-worker entry: native port + reconnect, message router
src/apps/extension/src/entrypoints/content.tsContent-script entry: injection guard, op dispatch into the shared DOM layer
src/apps/extension/src/entrypoints/confirm/The confirmation window: an extension-owned chrome-extension:// document the page cannot read, overlay, or click
src/apps/extension/src/entrypoints/options/, src/apps/extension/src/entrypoints/popup/Settings (Zod-validated, versioned, migrated), the host-admin panels, and the authorization/status popup
src/apps/extension/src/lib/background/Dispatch, allowlist store, tabs/CDP backends, cookies, egress masking, kill mirror, enrollment, policy sync, and exchange.ts: the one single-flight host round trip (claim, deadline, post-undo, reply route, fail on detach) that the client-admin, kill, host-admin and WebAuthn exchanges ride
src/apps/extension/src/lib/webauthn/The background half of the presence exchange: the frame exchange with the host (the ceremony itself is under lib/shared/)
src/apps/extension/src/lib/dom/The one shared DOM implementation (snapshot/refs/actions); the CDP backend ships its stringified source so the two page backends cannot diverge
src/apps/extension/src/lib/shared/Settings schema, message protocol types, allowlist matching, and the page half of the presence exchange: the WebAuthn ceremony against the browser's authenticator in webauthn-ceremony.ts
src/apps/extension/src/locales/The i18n bundles, one *.yml per locale (en, zh_CN, zh_TW); CI enforces key parity

Trust-state isolation: the enrollment pin, kill mirror, allowlist, and audit ring live in storage confined to extension contexts (setAccessLevel(TRUSTED_CONTEXTS)), and the message router refuses security-relevant messages from anything but the extension's own pages.

4.3 On-disk artifacts ​

Registration (written by doctor --fix through registration/):

macOS   ~/.genkan/run-host-<browser>.sh      # wrapper: exec <host> --native-host --label <browser>
        ~/.genkan/run-host.sh                # unlabeled, for a manifest several browsers read
        ~/Library/Application Support/<Vendor>/NativeMessagingHosts/
          com.vivswan.genkan.host.json       # manifest -> that browser's wrapper

Linux   ${XDG_DATA_HOME:-~/.local/share}/genkan/run-host-<browser>.sh
        ${XDG_CONFIG_HOME:-~/.config}/<vendor>/NativeMessagingHosts/
          com.vivswan.genkan.host.json

Windows %LOCALAPPDATA%\genkan\com.vivswan.genkan.host.json
        HKCU\Software\<Vendor>\NativeMessagingHosts\com.vivswan.genkan.host
          (Default) = absolute path of the manifest; manifest points at the exe

The manifest's path points at the registering binary in place (through the wrapper on Unix, because the manifest format has no args field); nothing is built, downloaded, or copied. The wrapper carries --label <browser> only when one browser alone launches that manifest; one several browsers read gets the unlabeled run-host.sh, per registration/mod.rs. On Windows, Chrome appends the extension origin to the command line, which selects native-host mode.

Runtime state, in the 0700 per-user runtime directory (macOS: $XDG_RUNTIME_DIR/genkan or ~/Library/Application Support/genkan; Linux: $XDG_RUNTIME_DIR/genkan with XDG-cache fallback; Windows: %LOCALAPPDATA%\genkan):

FileContents
run.lock (0600)The broker's pid and the per-run HMAC secret; the socket rendezvous
the bridge socket (0600)Unix only; no listening port exists
trust.json (0600)The trust record: the kill latch, the trusted-client allowlist, the WebAuthn enrollments, and the change epoch
policy.json (0600)The host-owned policy: the signed baseline and the unsigned restriction overlay (section 11.3)
policy-history.json (0600)Superseded policy revisions, a bounded ring; data for rollback, never authority
lang.json (0600)The shared uiLanguage preference and its echo-suppression sequence
audit.log (0600)The durable audit trail, size-capped
host_key.json (0600)The host identity key's scalar, only when the user ran pair --file-store; otherwise the key lives in the OS credential store

Every record loaded through runtime_record.rs (trust.json, policy.json, policy-history.json, lang.json, and host_key.json) carries a version envelope and climbs a migration ladder in src/packages/core/src/migrations/. The extension's settings store climbs one of the same shape in src/apps/extension/src/lib/shared/settings-migration.ts. A ladder is an explicit floor plus an array of rungs, and the current version is derived from both:

text
FIRST_VERSION = 0                        the version the first rung lifts from
MIGRATIONS    = [rungA, rungB]           the array is the ladder; no rung carries a version, no file name does
CURRENT       = FIRST_VERSION + MIGRATIONS.length

append a rung                           -> CURRENT rises by one
retire rung 0, raise FIRST_VERSION      -> CURRENT unchanged; every stored version keeps its meaning
stored < FIRST_VERSION                  -> too old to climb: a host record is refused, the settings
                                           store is stamped current and salvaged per field

The floor is what makes retiring the oldest rung safe. Derived from the rung count alone, every stored version would silently renumber the moment the first rung is deleted, and the wrong rung would run on every existing file. Today every ladder is empty or one no-op rung: this is the shape, not data.

The host identity key lives in the OS credential store (the Keychain, the Credential Manager, or the Secret Service) as the com.vivswan.genkan.enclave.signing.v1 entry, qualified by the runtime directory so two directories never share one; pair --file-store puts it in host_key.json instead.

5. Key data flows ​

5.1 One complete tool-call round trip (page_click(ref="e3")) ​

1. MCP client -> MCP server (stdin NDJSON):
   {"jsonrpc":"2.0","id":2,"method":"tools/call",
    "params":{"name":"page_click","arguments":{"ref":"e3"},
     "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
      "io.modelcontextprotocol/clientCapabilities":{}}}}

2. dispatch checks: harness admitted, epoch fresh, kill switch clear
   -> session.call assigns BridgeReq.id=1, writes to the socket
   (a relay's call reaches the same dispatcher through the broker)

3. native host reads socket NDJSON -> NM frame -> stdout

4. extension SW receives {op:"page_click",args:{ref:"e3"}}
   -> resolve target tab
   -> ensureAllowed(tab.url)   // allowlist; prompts if not authorized
   -> inject content script if needed
   -> content: resolveTarget({ref:"e3"}) -> element
   -> high-risk? (submit/link) -> confirmation window (confirm.html);
      deny/timeout/close all reject
   -> click

5. The result returns along the same path, masked at the SW egress,
   and session pairs it back to the pending call by id.

5.2 Native host reconnect ​

Browser closes the Port -> host gets stdin EOF -> host exits
Extension onDisconnect -> scheduleReconnect(2s)
connectNative() -> browser re-spawns the host -> host reads the lock file
  -> connects to the socket -> kernel checks + HMAC + attach(label)
Broker accepts -> session re-attaches that label (generation-guarded:
  the superseded connection is severed, so a host still alive on it exits
  and its worker life redials; pending calls of the old connection drain
  as Disconnected)

A same-label attach always wins and closes the connection it supersedes. While a browser keeps two worker lives of the extension alive at once, the two lives trade the slot about every 2 s, each redial closing the other, until one life ends.

5.3 A second MCP client attaches ​

Client B spawns its own genkan process
  -> it finds a live broker via the lock file
  -> attests itself over the socket (kernel checks + HMAC + attach frame
     carrying its harness's attested identity)
  -> broker checks the identity against the trust record's paired clients; unmatched fails closed
  -> B's tool calls multiplex through the shared session
Broker exits when the last attached harness detaches.

6. Security model ​

The full treatment is in docs/security.md; this is the map.

BoundaryMechanismRationale
Harness admission (stdio)Kernel-attested parent identity checked against the trusted-client allowlist; fail-closed once enrolledharness admission
Bridge socket0600 Unix-domain socket in a 0700 dir; peer-UID check; mutual executable attestation; HMAC challenge-response; role-declaring attachhost identity
Any-side revocationEvery host-side enforcement point re-reads trust.json before it decides (the extension's kill gate reads its mirror); both credential halves deleted on unpairrevocation
Host identity (host <-> extension)A P-256 host key minted by pair, pinned by the extension after a fingerprint comparison; every signed policy baseline verifies against the pinenrollment
User presence (host <-> extension)A capability-granting act needs a WebAuthn assertion: releasing the kill switch, from a credential enrolled under this browser; enrolling another browser, from any credential already enrolled on the machine. The confirmation window stands in only where that rule admits no credential, so an enrolled browser is never demoteduser presence
Site allowlistPer-origin approval + chrome.permissions.request; page cannot self-approvetrust boundaries
High-risk confirmationExtension-owned window off the page-reachable DOM; deny on timeout/closetrust boundaries
Crown-jewel confirmationpage_eval / page_upload confirm per call in the extension-owned window; only page_eval's prompt can be waived, by the host policy's confirmPageEval opt-out (signed, on a pinned extension); a WebAuthn route for them is not builttool risk matrix
Kill switch + auditFail-closed latch enforced at four layers; presence-gated release; log-after-decide trailkill switch
MaskingCookie/storage/eval/page-text egress masked in the SW, once for both page backendstool risk matrix
Protocol safetyNM 1 MB outbound limit; single-writer + flush; stderr panic hook; fuzzed parsers(section 3.1)

6.1 Harness admission ​

flowchart LR
  harness["the MCP client that spawned this server"]
  server["src/packages/core/src/mcp_server.rs"]
  attest["src/packages/core/src/ipc/attest.rs"]
  trust["src/packages/core/src/trust.rs"]
  allow["src/packages/core/src/allowlist.rs"]
  broker["src/packages/core/src/broker.rs"]
  handler["src/packages/core/src/mcp/handler.rs"]
  harness -->|spawns, stdio| server
  server -->|attest_parent, once at start| attest
  attest -->|the image hash or the signer| broker
  allow -->|pair-client and revoke-client write the entries| trust
  trust -->|one snapshot, decide| broker
  broker -->|admitted, before every request is handled| handler

Authorization keys on the attested anchor, never on the self-asserted client name, which is a log label. Until a trusted-client allowlist exists every harness is admitted and the open posture is logged at ERROR; once it exists an unmatched harness is refused before any tool call.

Demonstrated by: broker/tests.rs, adversarial.py.

6.2 User presence over WebAuthn ​

flowchart LR
  host["src/packages/core/src/native_host.rs"]
  exchange["src/apps/extension/src/lib/webauthn/exchange.ts<br>handleWebAuthnFrame() pendingPresenceRequest() assertPresence()"]
  page["the options page, the RP page the user taps on"]
  ceremony["src/apps/extension/src/lib/shared/webauthn-ceremony.ts<br>requestOptions() assert() browserClient"]
  authenticator["the browser's WebAuthn client and the platform authenticator"]
  verify["src/packages/core/src/webauthn/verify.rs"]
  store["src/packages/core/src/webauthn/store.rs"]
  trust["src/packages/core/src/trust.rs"]
  host -->|presence_request, pushed for a granting act| exchange
  exchange -->|holds the request and opens the page| page
  page -->|the challenge and the allowed credentials| ceremony
  ceremony -->|navigator.credentials.get| authenticator
  authenticator -->|the assertion| ceremony
  ceremony -->|the assertion fields| page
  page -->|the answer| exchange
  exchange -->|presence_assert| host
  host -->|verify against the enrolled key| verify
  verify -->|sign counter advanced| store
  store -->|enrollments| trust

The host is the relying party and the extension is the WebAuthn client, with the extension id as the RP ID. Enrollment runs the same exchange with enroll_begin, enroll_options, enroll_finish, and enroll_result; the first enrollment on a machine is trust on first use, every later one needs a tap from a credential already enrolled on the machine, under any browser.

The options page runs the ceremony and answers: it enrolls this browser's authenticator, releases the kill switch, and signs the grants of section 11.3 (a policy set, a rollback that relaxes, a client pairing). A release or a grant is a tap on this browser's enrolled credential; when the host's request names no credential, the page asks for a confirmation instead.

Its Forget this browser action sends browser_revoke, and the host forgets the credentials enrolled under that connection's own label, the same act as genkan revoke <browser>.

A presence_confirm from the confirmation window is accepted only when no enrolled credential could have answered, so an enrolled browser is never demoted to a click.

Every refusal the exchange answers with, on presence_result or enroll_result, carries a code from the RefusalCode roster in src/packages/core/src/webauthn/refusal.rs, the one list of presence refusal reasons. moon run gen emits it as refusals.ts, and src/apps/extension/src/lib/refusals.ts keys one sentence per code on it, so a code the host adds is a type error there until its sentence lands.

Demonstrated by: webauthn/tests.rs, exchange.test.ts, ceremony.test.ts, webauthn_test.ts.

6.3 The kill switch ​

flowchart LR
  cli["src/packages/core/src/cli.rs"]
  panel["src/apps/extension/src/entrypoints/options/KillSwitchPanel.tsx<br>KillSwitchPanel()"]
  mirror["src/apps/extension/src/lib/background/kill.ts<br>killGate() engageKill() handleKillFrame()"]
  host["src/packages/core/src/native_host.rs"]
  kill["src/packages/core/src/kill.rs"]
  trust["src/packages/core/src/trust.rs"]
  handler["src/packages/core/src/mcp/handler.rs"]
  broker["src/packages/core/src/broker.rs"]
  cli -->|kill, and unkill behind presence| kill
  panel -->|engage| mirror
  mirror -->|kill_engage| host
  host --> kill
  kill -->|the killed flag, one atomic write| trust
  trust -->|BRIDGE_KILLED on every tool call| handler
  trust -->|browser legs severed within one tick| broker
  trust -->|kill_status_result pushed when the state moves| host
  host -->|the mirror in trusted storage| mirror

Nothing clears the latch on its own: no timeout, restart, or reconnect. Release is genkan unkill on a terminal, or the extension's kill_release, answered by a WebAuthn tap from a credential enrolled under that browser, or by the confirmation window where that browser enrolled none. A corrupt record refuses both directions, since an unkill from an unknown state would be a fail-open.

Demonstrated by: kill.test.ts, deny-kill.test.ts, broker/tests.rs, native_host/tests.rs.

7. Key constraints (pitfalls hit and handled) ​

7.1 MV3 Service Worker 5-minute restart (Chromium #40733525) ​

Chrome force-restarts the SW about every 5 minutes, losing in-memory state; the Port closes and the native host exits on stdin EOF. Mitigation: durable state lives in chrome.storage (confined to trusted contexts) or in the MCP server process; the SW reconnects on startup; ref markers are stamped onto DOM attributes so the content script rebuilds its map after a restart; pending calls are generation-guarded.

7.2 chrome.debugger forces an infobar ​

Any chrome.debugger.attach shows a "Started debugging this browser" banner on every tab while attached. Mitigation: the default snapshot uses a content script and never touches the debugger; page_snapshot_precise attaches, reads the a11y tree, and detaches in one handler (detach on the finally path), so the banner flashes for about a second.

7.3 The Native Messaging manifest has no args field ​

The manifest's path must be a bare executable. Mitigation: a wrapper script bakes in --native-host, with --label <browser> (run-host-<browser>.sh) when one browser alone launches that manifest and none (run-host.sh) when several read it, per registration/mod.rs; the label keys the broker's connection registry.

7.4 chrome.permissions.request requires a user gesture ​

Host permissions can only be requested from a user-gesture context. Mitigation: the allowlist authorization flow goes through the popup; Allow requests the permission and records the entry together.

7.5 Static content_scripts conflict with optional permissions ​

With empty initial host permissions, manifest-declared content scripts never inject. Mitigation: no manifest content_scripts; everything injects at runtime via chrome.scripting.executeScript, following the granted optional permissions.

7.6 Rust panics pollute stdout ​

Panic messages default to stdout, which corrupts NM frames and MCP NDJSON. Mitigation: panic = "abort" in the release profile plus a stderr panic hook, as a double safety net.

7.7 page_eval uses the Function constructor, not eval() ​

page_eval must run code in the page's global scope, but the content script runs in a strict-mode closure where eval sees the wrong scope. Mitigation: new Function('"use strict"; return (async () => { <code> })()')(), which executes in the global scope and supports return/await. Under CDP mode the same code runs through Runtime.evaluate in the page's MAIN world instead.

A reliable execution timeout is impossible in single-threaded JS; the backstop is the tool call's 120 s budget (CALL_BUDGET in src/packages/core/src/tools/mod.rs). The session has no reply timeout of its own; it only caps the wait for a browser connection at 12 s (CONNECT_WAIT in src/packages/core/src/session.rs). Results pass through safe serialization (cycles/DOM/exotic types) and masking before leaving the extension.

7.8 chrome.debugger restrictions (page_snapshot_precise, CDP mode) ​

The chrome.debugger API is SW-only, cannot attach to chrome:// or Web Store pages, and allows one debugger per tab (DevTools counts). Mitigation: CDP work happens in the SW; a URL-scheme check filters non-debuggable pages; precise-snapshot refs use a p prefix to stay clear of content-script refs; detach is on the finally path.

7.9 Cookies are host-bound; storage is same-origin; httpOnly is readable ​

chrome.cookies is bound by host permissions and lives in the SW (it can read httpOnly, its core value); page localStorage/sessionStorage is readable only from a content script on the same origin. Hence cookie_get in the SW, storage_get in content, both read-only and always masked.

8. Technology choices ​

DimensionChoiceRationale
Backend languageRust, single binary + subcommandsSingle-file distribution; the host manifest takes an absolute path; one codebase for server, host, and CLI
IPCUnix-domain socket + lock file (a user-only named pipe on Windows)No listening port; the kernel's peer credentials (the pipe peer's pid on Windows) enable attestation
Crypto and parsingRustCrypto hmac/sha2, subtle, serdeWell-adopted libraries over homegrown code; bespoke code only where no library exists
Extension platformMV3 on WXT, React UI, VitestGenerated manifest with the pinned key; unified browser.*; testable SW
ContractsThe Rust core generates the TS sideOne source of truth; CI fails on drift. See section 11
Engineering gatesmoon + proto + GitHub Actions, bun workspace, Biome, cargo-nextest, typos/machete, cargo-deny + the fleet's Trivy and dependency reviewOne moon run ci runs the local cross-platform gate; CI layers additional jobs on top (the repo's jobs live in .github/workflows/checks.yml, called inside the managed ci.yml's all-green gate)
MCP version2026-07-28 (stateless)The current spec revision, served on the official rmcp SDK: per-request version gate, server/discover; rmcp's built-in legacy-era support serves older harnesses during the rollout

9. Known limitations ​

  1. Snapshot accuracy: the content-script a11y tree is an approximation (shadow DOM, complex ARIA); page_snapshot_precise is the authoritative fallback.
  2. Cross-origin iframes: the content script cannot read them.
  3. Windows image measurement by path: the pipe peer's image is hashed from its file path, a residual the trust boundaries ledger owns; the gates themselves (user-only pipe, mutual attestation, HMAC, harness admission) hold there as on Unix. See SECURITY.md.
  4. Same-user attacker running our own binary: kernel attestation distinguishes binaries, not intentions; see the trust boundaries residuals.
  5. Revocation latency to the extension: the socket leg is immediate; the extension's reflection of a host-key revoke is bounded to the next service-worker wake.

10. Extension points ​

  • Adding a tool: one catalogue entry + handler in the core, moon run gen, an op home in the extension, a risk-matrix row, and tests; the drift guards fail until every surface is covered. The step-by-step list is in CONTRIBUTING.md.
  • Adding a browser: one row in the resolver (browsers.rs); doctor, --fix, and uninstall pick it up from there.
  • Skill layer: no architecture change; additive skill files that teach an agent to combine existing tools.

11. Protocol boundary contracts: error taxonomy and handshake ​

The cross-process contracts live in the Rust core, the single source of truth; the TypeScript side is generated from it into src/packages/shared/generated (a build output, never tracked), and runtime behavior is validated against it. The canonical modules and their derived artifacts:

  • Tool catalogue (src/packages/core/src/tools/catalogue.rs): each tool's name, English model-facing description, JSON-Schema inputSchema, and policy metadata (risk / scope / permission / confirmation). moon run gen runs the core's emit_contract example and scripts/gen-ops.ts to produce ops.ts: op names, policy metadata, and a Zod arg validator per tool.
  • The BridgeCommand request union is inferred from those validators, so the compile-time types and the runtime checks are the same artifact. Every task that reads the generated TS rebuilds it from the Rust source first, so no checked-in copy exists to drift. UI labels are deliberately NOT part of the contract; they are extension UI copy (tools.<op> keys in the extension's *.yml locale bundles).
  • Error taxonomy (ERROR_SPECS in src/packages/core/src/error.rs): the stable cross-process codes with category, retryable, and the user/model-facing message. CallError::code() maps the Rust tool-call errors into a subset of the table (cargo test enforces membership), and errors.ts gives TS consumers the same code constants (currently unconsumed; see section 11.1).
  • Capabilities (src/packages/core/src/tools/capabilities.rs): the negotiable groupings over the catalogue, emitted into protocol.ts. cargo test enforces that every bridge-routed tool is covered by exactly one capability and each capability's permissions equal the union of its tools' permissions.
  • Protocol versions (src/packages/core/src/protocol.rs): the internal bridge protocol integer (BRIDGE_PROTOCOL_VERSION) and the MCP JSON-RPC revision the server speaks (MCP_PROTOCOL_VERSION, gated per request, declared by server/discover, and asserted by the protocol e2e suites), both emitted into protocol.ts.
  • Audit forwarding whitelist (EXTENSION_AUDIT_KINDS in src/packages/core/src/audit.rs): the extension-owned audit kinds the host accepts over the audit_event control frame (extension_kind derives from the same list), emitted into audit.ts. The extension's forwarding set and the forwarded prefix of its audit-ring vocabulary build on the generated constant, so the two sides of the forwarding boundary cannot drift apart.
  • Identity (src/packages/core/src/identity.rs): the native-messaging host id and the pinned extension manifest key, emitted into identity.ts. The extension imports NATIVE_HOST_ID for connectNative, EXTENSION_MANIFEST_KEY for the built manifest, and PINNED_EXTENSION_ID, derived from the key, for its startup self-check. The registration engine consumes the constants directly, so no installer copy exists to drift.
  • Identity gates: generating the TS re-derives the id from the key (scripts/gen-ops.ts fails on a mismatch), and scripts/check-extension-id.ts (moon run check-extension-id, part of moon run ci) verifies the built manifest and the single-definition-site rule.
  • Refusal roster (RefusalCode in src/packages/core/src/webauthn/refusal.rs): emitted into refusals.ts; section 6.2 owns what it enumerates and who consumes it.
  • Host constants (the key label, the lock filename, the client-name and log environment variables, the audit default limit, and the browser keys, each a constant in the Rust module that owns it): emitted into host.ts, which scripts/check-docs-literals.ts holds the docs to, so a renamed constant fails the docs gate instead of leaving a page quietly wrong.
  • Host-key signing contract (src/packages/core/src/enclave/: challenge.rs for the domain strings and field bounds, pubkey.rs and mod.rs for the key and signature byte lengths and the enclave_error reason codes): emitted by the core's emit_enclave_contract example into enclave.ts (constants plus the EnclaveReasonCode union the extension's enrollment state machine classifies exhaustively) and enclave-fixture.ts.
  • The fixture file holds golden vectors: Rust-built message bytes with deterministic software-P256 proofs, replayed through the extension's WebCrypto verifier by src/apps/extension/tests/background/enclave-golden.test.ts, so the signed-message encoding itself is pinned across languages. The fixture's signing key is public test data and deny-listed as a host identity on both sides (ensure_not_fixture_key in the core, ENCLAVE_FIXTURE_KEY_ID in the extension's pairing verifier and stored-pin validators).
  • Policy document and directions (src/packages/core/src/policy/): the host-owned PolicyDoc, the fifteen policy fields (the four capability grants, the confirmation policy, disabledTools, the confirmation timeouts), their deny defaults, the per-field permissive-direction table, and the relaxes/restricts comparisons, plus the signed store and the set_signed/restrict write seams.
  • moon run gen emits policy.ts: the signing domain constant, the field list with its directions, the defaults, and strict Zod validators for the document, the values, and the restriction overlay. The extension recomputes every direction comparison from the emitted table itself; it never trusts a host's claim about which way a change points.
  • A grant is signed by the host key over UTF8("genkan-policy-v1") || 0x00 || doc_bytes, a NUL-separated signing domain beside the host-key challenge domain, injective against it, so no artifact of one ceremony replays as the other.
  • There is no canonicalization step anywhere: the host signs and stores the exact document bytes, and the extension verifies the exact bytes it received against its pinned key before strict-parsing those same bytes. Section 11.3 covers the frames that carry all of this.
  • Wire envelopes and control frames (BridgeReq / BridgeResp in src/packages/core/src/protocol.rs; EnclaveControl, AdminControl, which embeds allowlist::ClientEntry, PolicyControl and WebAuthnControl in src/packages/core/src/protocol/control.rs): the Rust types ARE the contract, and moon run gen generates the extension's validators from them into envelope.ts. The table below names each layer and its owner.
LayerOwnerWhat it holds
Faithful base, per envelope and host->extension framescripts/gen-envelope.ts (rules G1-G7, A1-A3 over the Rust input; generation aborts rather than emit anything weaker than the Rust parser) and scripts/gen-schema.ts (every validator is the Zod source json-schema-to-zod writes from its schema, every type json-schema-to-typescript's reading of the same schema, the emitted validator held to rules R1-R4 before the file is written)strict objects, required fields required, no invented defaults
Enforced validator, the one the extension runssrc/packages/shared/src/envelope-asymmetries.tsthe base plus exactly the table's entries, each with its direction and reason; a frame built from one typed verdict (policy_current, enroll_result, presence_result) declares its ok-split here and is emitted as a union of its arms, so a mixture of them fails the reader; the request's args and policy_current's overlay are the schemas ops.ts and policy.ts export, imported
Writer schemas for extension->host framesscripts/gen-envelope.tstypes the constructor sites satisfies; the enforcing reader is the Rust serde parser
The gate (moon run check-envelope)scripts/check-envelope.tsproves every entry's probes against both validators, holds the inbound classifiers to the reader plan, refuses a hand-written refinement on any reader
Behavioral tests of the generated basessrc/packages/shared/tests/wire-validators.test.tsunknown fields, missing required fields, type confusion, nested extras

11.1 Error taxonomy (ERROR_SPECS) ​

At the tool-call boundary, Rust's typed error CallError maps to the stable codes in ERROR_SPECS (src/packages/core/src/error.rs); cargo test validates the mapping. The code is for programmatic decisions (it carries category and retryable); what the model and the user see is the message.

CodeWho assigns it today
EXECUTION_FAILEDthe MCP server, for every free-form failure string the extension reports
TOOL_DISABLEDthe MCP server's policy gate (section 11.3): dispatch refuses a tool whose capability grant is off or that the effective policy disables, before any bridge traffic
NOT_CONNECTED, EXTENSION_NOT_READY, CONNECTION_LOST, the admission and revocation refusals, BRIDGE_KILLEDthe MCP server, with one shared meaning across every process
PROTOCOL_MISMATCHnobody yet: it awaits the version/capability handshake wiring (section 11.2)
SITE_NOT_ALLOWED, USER_DENIED, TAB_NOT_FOUND, ...nobody yet: they would need structured error reporting from the extension in place of the free-form strings

The MCP server (CallError::code() in src/packages/core/src/error.rs) is the only assigner, covering a subset of the table; the TS constants generated into errors.ts exist for future consumers.

11.2 Capability / version handshake ​

Beyond the authentication of section 3.3, connection setup carries a capability and version dimension: the extension side advertises its supported BRIDGE_PROTOCOL_VERSION and available capability set (see src/packages/core/src/tools/capabilities.rs). The intended behaviour, not yet wired (next paragraph): an incompatible version fails fast with PROTOCOL_MISMATCH rather than blowing up later on an unknown op, and a tool whose capability is not advertised is rejected up front.

Honest status: the negotiation is defined in the contract modules and not yet wired, deferred until the binary and the extension can be upgraded independently; the first stage, generation-guarded reconnect (section 5.2), has landed. When it lands, the advertised capability set should derive from the effective policy, which host-owned policy makes possible but does not wire.

Note the three distinct "versions": the MCP JSON-RPC version 2026-07-28 (section 3.2), the internal bridge protocol version (an integer), and the release version (Cargo-sourced). They are all different.

11.3 Host-owned policy and language sync ​

The host owns the security policy: the four capability grants, the confirmation policy, disabledTools, and the confirmation timeouts.

The host persists at most one signed baseline plus an unsigned restriction overlay in policy.json under the runtime directory, which RuntimeDir in src/packages/core/src/ipc/runtime_dir.rs resolves.

The state travels over the PolicyControl frames in protocol/control.rs (the policy lanes get, restrict, set, rollback, and history, and the language frames), each classified and terminated exactly like the host-key and admin frames: answered by the host, never forwarded to the MCP server, and dropped when the server leg tries to inject one.

flowchart LR
  cli["src/packages/core/src/policy/cli.rs"]
  store["src/packages/core/src/policy/store.rs"]
  presence["src/packages/core/src/presence/mod.rs"]
  gating["src/packages/core/src/policy/gating.rs"]
  host["src/packages/core/src/native_host.rs"]
  sync["src/apps/extension/src/lib/background/policy-sync.ts<br>handlePolicyFrame() policyDispatchGate() getPolicyPosture()"]
  effective["src/apps/extension/src/lib/background/effective-policy.ts<br>getEffectivePolicy()"]
  editor["src/apps/extension/src/entrypoints/options/PolicyEditor.tsx<br>PolicyEditor()"]
  admin["src/apps/extension/src/lib/background/host-admin.ts<br>restrictPolicy()"]
  cli -->|policy set, a grant| store
  presence -->|the attestation a grant consumes| store
  store -->|policy.json, the signed baseline and the overlay| host
  store -->|TOOL_DISABLED before any bridge traffic| gating
  host -->|policy_current, pushed at connect and on change| sync
  sync -->|verified bytes, the overlay direction-checked| effective
  editor -->|a tightening| admin
  admin -->|policy_restrict| host
  host -->|restrict, the free lane| store
  editor -->|a grant, behind this browser's tap| host
  host -->|policy_set, a relaxing policy_rollback: the signed lane| store

The attestation a grant consumes is this browser's presence proof when the editor asks (the exchange of section 6.2), or the typed phrase on the CLI's terminal; the host key signs the exact document bytes either way. The security page owns who answers each act.

The restriction seam refuses an overlay that would relax anything, so a tightening of an existing baseline travels free from either surface; a loosening, or the first baseline, takes the grant lane.

Demonstrated by: store_tests.rs, presence/tests.rs, policy-sync.test.ts, policy-swap.test.ts, policy-editor.test.tsx.

  • policy_get {} (extension -> host): on-demand refresh. Like every extension-originated frame in this family, it is sent only on a connection where the host has already pushed a frame on the same lane (policy_current for the policy frames, lang_current for the language ones).
  • The never-speak-first rule behind that: an old host would classify an unknown frame as forwardable and the MCP server's strict parse would tear the browser leg down, so against an old host the new frames simply never flow.
  • policy_current { ok, baseline?, sig?, overlay?, error? } (host -> extension): the policy state, pushed unsolicited at every connect and on every observed store change, and the reply to policy_get. The host builds it only through a typed intermediate, so the mixtures the extension must never see cannot be constructed:
    • ok: true carries the exact signed baseline bytes (base64, so the signed artifact survives the JSON hop byte-for-byte), the optional signature, and the optional overlay.
    • ok: false carries error (naming the absent, damaged, or unreadable store, or a malformed policy_get) and never a baseline, so the extension fails closed rather than trusting bytes nobody vouched for.
  • policy_restrict { overlay } (extension -> host) and policy_restrict_result { ok, error? } (host -> extension): the options page's policy editor tightening the effective policy through the unsigned restriction seam, which refuses anything that relaxes it; an applied restriction is followed by a policy_current carrying the written state, so the result carries the verdict alone. Loosening takes the grant lane below.
  • policy_set { overlay }, policy_rollback { revision, entry? }, and policy_history {} (extension -> host), each with a result frame: the grant lane and the superseded-revision ring. A set or a relaxing rollback opens a presence_request (section 6.2), whose approval carries the verdict and a policy_current; a rollback that only tightens applies free. A keyless host or an invalid change is refused before any request exists, in the CLI's words.
  • lang_get {} / lang_set { value } (extension -> host) and lang_current { value, seq } (host -> extension): the shared uiLanguage preference (lang.json in the same runtime directory), deliberately outside the signed policy document - not signed, not ratcheted, unable to affect any security decision - with echo suppression by sequence number.

The enforcement contract is asymmetric by design: policy that grants capability carries the host key's signature over the exact bytes and consumed a presence attestation when it was written, while policy that only removes capability travels free as the unsigned overlay. What a same-user process can do to the host key is a residual the trust boundaries ledger names.

A machine with no host key has no grant surface: policy set and the page's editor refuse up front, in the same words, until pair has minted one.

The extension verifies the signature against its own pinned key (never a frame-supplied identity), strict-parses the verified bytes, direction-checks the overlay locally, and keeps a value ratchet in trusted storage: a pinned extension never applies a relaxation without a fresh signature whose signed touched set names the relaxed field.

Post-cutover a per-connection dispatch barrier refuses bridge ops until the connection's first policy push has verified and applied, so an op cannot race ahead of a tightening.

On the wire-validation side these frames ride the same generated machinery as every other control frame (section 11 above); policy_current declares its ok-split in the asymmetry table and is emitted as a discriminated union, proved by the moon run check-envelope gate.

The host also enforces its own policy at dispatch (policy/gating.rs): a tool whose capability grant is off or that is in disabledTools is refused with the stable TOOL_DISABLED code before any bridge traffic, with an absent store allowing (pre-cutover) and an unreadable one denying all.

That check is defense in depth for the honest-host path; the extension's gate stays authoritative at its boundary precisely because the host may not be ours.

To troubleshoot these links at runtime (whether the connection is reachable; whether the lock file, socket, and manifests are in place), use the read-only genkan doctor; see cli.md.

12. The TypeScript module map ​

Each node is one layer, labelled with the paths it owns; an arrow means the layer imports the other. Rendered from architecture.yml by scripts/render-architecture-map.ts; scripts/arch-lint.ts keeps that declaration equal to the import graph in both directions.

graph TD
  shared["src/packages/shared/src/<br>src/packages/shared/generated/"]
  extension_background["src/apps/extension/src/entrypoints/background.ts<br>src/apps/extension/src/lib/background/<br>src/apps/extension/src/lib/webauthn/"]
  extension_content["src/apps/extension/src/entrypoints/content.ts<br>src/apps/extension/src/lib/content/"]
  extension_confirm["src/apps/extension/src/entrypoints/confirm/"]
  extension_options["src/apps/extension/src/entrypoints/options/"]
  extension_popup["src/apps/extension/src/entrypoints/popup/"]
  extension_ui["src/apps/extension/src/components/<br>src/apps/extension/src/hooks/<br>src/apps/extension/src/lib/cn.ts<br>src/apps/extension/src/lib/theme.ts<br>src/apps/extension/src/lib/i18n.ts<br>src/apps/extension/src/lib/native-language-names.ts<br>src/apps/extension/src/lib/refusals.ts<br>src/apps/extension/src/lib/local-time.ts"]
  extension_lib["src/apps/extension/src/lib/shared/<br>src/apps/extension/src/lib/dom/<br>src/apps/extension/src/lib/messages.ts"]
  scripts["scripts/"]
  extension_background --> shared
  extension_background --> extension_lib
  extension_background --> extension_ui
  extension_content --> shared
  extension_content --> extension_lib
  extension_confirm --> shared
  extension_confirm --> extension_lib
  extension_confirm --> extension_ui
  extension_options --> shared
  extension_options --> extension_lib
  extension_options --> extension_ui
  extension_popup --> shared
  extension_popup --> extension_lib
  extension_popup --> extension_ui
  extension_ui --> shared
  extension_ui --> extension_lib
  extension_lib --> shared
  scripts --> shared
  scripts --> extension_lib
Built from main at 25ae5f4Source: docs/architecture.md