# Sallyport [![CI](https://github.com/ginkida/sallyport/actions/workflows/ci.yml/badge.svg)](https://github.com/ginkida/sallyport/actions/workflows/ci.yml) [![CodeQL](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml/badge.svg)](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Release](https://img.shields.io/github/v/release/ginkida/sallyport)](https://github.com/ginkida/sallyport/releases/latest) A secure browser-automation bridge between Claude Code (or any MCP client) and your Chrome. An alternative to Kimi WebBridge with explicit security boundaries instead of implicit ones. ``` Claude Code ── MCP/stdio ──▶ daemon ── WS+HMAC ──▶ extension ── CDP ──▶ Chrome ``` | Status | Number | |---|---| | Daemon tests (pytest) | 539 | | Extension tests (vitest) | 1206 | | Lint / typecheck (ruff, mypy, eslint, prettier, tsc) | all green | ## What's in the box | Path | What it is | |---|---| | `extension/` | MV3 Chrome extension (TypeScript, esbuild, vitest). Loads as an unpacked extension. | | `daemon/` | Python MCP server. Speaks MCP on stdio to Claude Code, hosts a WS server on `127.0.0.1:10086` for the extension. | | `fixtures/` | Cross-language canonical-JSON / HMAC vectors shared by both test suites. | | `.pre-commit-config.yaml` | Fast lint/format checks before commit. | | `.github/workflows/ci.yml` | Same checks plus full tests on push/PR. | ## Security model A deeper threat model + known limitations lives in [`SECURITY.md`](SECURITY.md). The short version: the original Kimi extension trusts any process that can reach `127.0.0.1:10086`, which on a shared/compromised machine means everything. Sallyport changes the default in five places: 1. **HMAC-SHA256 on every frame.** A 32-byte random secret lives in `~/.config/sallyport/secret` (chmod 600) and is generated on first run. Both sides sign every WS frame and verify timestamp drift (≤ 30 s) and nonce freshness (rolling cache of 4096 nonces — replay-protected and persisted across extension service-worker eviction). A cross-language test pin in pytest + vitest guarantees the canonical-JSON and MAC bytes stay byte-for-byte compatible. 2. **Domain allowlist enforced in the extension.** Tools refuse to run on any URL whose host isn't in `chrome.storage.local.sallyport_allowlist`. Patterns are `example.com`, `*.example.com`, or `https://x.com/path/*`. Bare `*` is rejected by the validator. 3. **`evaluate` is opt-in per domain.** Even on an allow-listed domain, arbitrary JS is refused unless that entry has `allowEvaluate: true`. Other tools (`click`, `fill`, `read_text`, …) use structured CDP calls only. 4. **Defense-in-depth on inputs.** `fill` refuses `` unless `allowPassword=true`. The daemon refuses to bind to anything that isn't a loopback address. WS frames over 16 MiB are dropped (1009). 5. **Operational visibility.** Every tool call (and its outcome — `ok` or `error`) is appended to `chrome.storage.local.sallyport_audit` (last 500 entries), browsable and JSON-exportable from the popup. One-click **Pause** in the popup stops the WS connection and rejects all tool calls. Other deliberate choices: - No content-script injection, no `` content scripts. Permissions are only what the debugger API (and a popup-pin context-menu entry) need (`tabs`, `debugger`, `storage`, `alarms`, `contextMenus`). - Per-tab accessibility refs (`@e1`, `@e2`). Snapshotting tab A cannot invalidate refs for tab B, and a ref scoped to A cannot resolve to a node in B. A ref is also bound to the document it came from: after any cross-document navigation, including one the page starts itself (a link, a form submit, a redirect), it fails as `bad_ref` instead of resolving to an element of the new page, and ids are never reissued on a live tab. - MCP-side tool calls are serialised per client, with up to 8 clients running concurrently. The extension also serialises calls per tab to protect shared browser state. - The daemon shuts down cleanly on stdin EOF (Claude Code closing) or SIGINT/SIGTERM: pending calls fail with `ExtensionNotConnected`, the client gets a 1001 close, no orphan tasks. What the extension still trusts: anyone with read access to `~/.config/sallyport/secret`. The browser debugger is, ultimately, the browser debugger — this bridge limits *which* domains it operates on and *who* can drive it. ## Setup ### 1. Build the extension The extension is **not** on PyPI — `pip install sallyport` (step 2) gives you only the daemon. The extension lives in this repo's `extension/` directory, so you need a checkout to build it: ```sh git clone https://github.com/ginkida/sallyport cd sallyport/extension npm install npm run build ``` The output lands in `extension/dist/`. Load it as an unpacked extension: 1. `chrome://extensions` 2. Enable **Developer mode** 3. **Load unpacked** → pick `extension/dist` Pin the toolbar icon. ### 2. Install the daemon Sallyport needs **Python ≥ 3.10** (it uses match statements and `X | Y` type syntax). Check with `python --version` first. ```sh pip install --user sallyport ``` Or from source (for development): `cd daemon && pip install --user -e .` This installs the `sallyport-daemon` command on your `PATH`. Verify it landed there: ```sh which sallyport-daemon # should print a path; if not, add your Python # user-scripts dir (e.g. ~/.local/bin) to PATH ``` The first time something runs it, the daemon will: - Generate a 32-byte secret in `~/.config/sallyport/secret` (chmod 600). - Print the base64 secret to stderr — paste it into the extension popup. - Start listening on `127.0.0.1:10086` and speak MCP on stdio. Then run the built-in setup check, which validates the install and prints the exact block to paste into the popup: ```sh sallyport-daemon doctor ``` It checks your Python version, the secret file and its permissions, and that the port is free — then prints the pairing secret and the remaining steps. Run it any time a connection won't come up. To just re-print the secret: ```sh sallyport-daemon --show-secret ``` ### 3. Register with Claude Code Add an MCP server entry — either edit `~/.claude/mcp.json` directly, or: ```sh # Use the ABSOLUTE path. A GUI-launched Claude Code often doesn't inherit your # shell PATH, so a bare "sallyport-daemon" command silently fails to spawn. claude mcp add sallyport "$(which sallyport-daemon)" ``` `sallyport-daemon doctor` prints this exact line with the path already resolved (and falls back to `python -m sallyport_daemon` if the console script isn't on PATH) — copy it from there if you're unsure. The result: ```json { "mcpServers": { "sallyport": { "command": "/Users/you/.local/bin/sallyport-daemon", "args": [] } } } ``` Restart Claude Code. The tools appear as `mcp__sallyport__navigate`, `mcp__sallyport__click`, etc. ### 4. Pair the extension 1. Open the popup. It will show a one-card **Pair** onboarding view. 2. Get the secret. Run `sallyport-daemon doctor` (or `--show-secret`) in a terminal and copy the printed block. **Note:** when Claude Code spawns the daemon for you (step 3), the first-run banner goes to the daemon's stderr, which Claude Code does not display — so `doctor`/`--show-secret` is the reliable way to see it. 3. Paste into the popup textarea — you can paste the whole banner; the popup auto-extracts the base64 secret and shows "✓ secret detected (32 bytes)". 4. Click **Pair**. Status flips to **connected** with a pulsing green indicator and an at-a-glance summary of allowed sites / recent calls. ### 5. Add the first domain to the allowlist The allowlist starts empty — every tool rejects every URL by default. In the popup's **Allowlist** tab, add an entry like `example.com` or `*.github.com`. Tick **allow evaluate()** only if you actually need arbitrary JS on that host. ## Several sessions, one browser (automatic) Sallyport is built for the case where *you* are using Chrome while one or more Claude Code sessions drive tabs in it. That works out of the box: the first `sallyport-daemon` starts a small **broker** in the background and attaches to it, and every session after that attaches to the same one. Register Sallyport as in step 3 from **as many project folders as you like** — nothing else to set up. ```sh # Nothing. Just start a second Claude Code session. ``` Under the hood the broker owns the extension connection and the port; each session relays its MCP over a `0600` Unix-domain socket next to the secret (`broker-10086.sock`). Before this, the second session simply failed to start — the first one held the port — and had no browser tools at all. What you get: - **Sessions don't block each other.** Calls from different sessions run concurrently (a session's own calls stay in order, and a tab is only ever touched by one call at a time). One agent's 30-second wait no longer freezes everyone else's next click. - **Each agent stays in its own tabs.** An agent can only see and act on tabs it created (`navigate` with no tab opens a fresh one); it cannot touch — or even list — your tabs or another agent's. `list_tabs` shows a session only its own. - **It won't steal your focus.** Each session's tabs live in its own un-focused, muted window, and the window you were using is re-focused if Chrome tries to raise the new one (only if the new window really took focus — it never pulls a backgrounded Chrome to the front). Screenshots work there too, without foregrounding anything. An agent closing or navigating away from its own tab never raises a "Leave site?" / "Close site?" prompt — Chrome would bring the agent window to the front to show it and wait for your click — while your own tabs, and an agent tab you used or have in front of you, keep it. - **You can see who did what.** The popup's Audit tab tags every row with the session (its folder name by default), and its **Agent tabs** section lists what each session left open, with a one-click sweep. - **You're logged in.** These are ordinary tabs in your real Chrome profile, so agents operate on the sites you're already signed into — scoped, as always, to the allowlist. There is no incognito or separate profile involved: the separation is about *who may drive which tab*, not about identity. Useful knobs: ```sh sallyport-daemon --no-broker # single session, own the port directly # (or SALLYPORT_NO_BROKER=1) sallyport-daemon --session-label review # name this session in the audit log sallyport-daemon broker --idle-exit 3600 # start one explicitly, exit when idle sallyport-daemon doctor --stop-broker # stop it (needed after an upgrade: # a broker outlives `pip install -U` # and serves its build to everyone) ``` The broker is long-lived by design, so `doctor` treats it as the intended setup rather than a stale daemon: the port check reports **OK**, and `doctor --kill-stale` leaves it running. This is a *software partition* of one shared profile, not an OS-level sandbox: the security floor is still "any process running as you" (it can read the secret and drive the browser within the allowlist). See [`SECURITY.md`](SECURITY.md) for the full model, including the tab-ownership and MCP-client-auth invariants. ## Tools | Name | Notes | |---|---| | `list_tabs` | No allowlist check — listing is free. | | `navigate` | Checks the *destination* URL against allowlist. `waitFor={selector?,text?,absent?,timeoutMs?}` polls after the load until the page is actually usable (SPAs render long after "loaded"). | | `reload` | Hard reload via `bypassCache=true`. Allowlist-gated; refs invalidate. | | `history_go` | Back/forward through the tab's session history — return to the previous page without knowing its URL. `direction='back'\|'forward'`, `steps` hops several entries in one jump (intermediate pages never load). The **landing** entry is allowlist-checked *before* anything moves (`Page.getNavigationHistory` → `Page.navigateToHistoryEntry`, no `evaluate`); too far → `no_history` with how far it does reach — neither error names the blocked page's host, to avoid an oracle over non-allowlisted browsing history. On a `timeout`, don't assume it failed: the hop can land before the load watchdog fires, so check with `read_text`/`snapshot` rather than blindly retrying. Conversely, if a `beforeunload` prompt cancels the hop (dismissed by default), that's caught too — `navigation_cancelled` instead of silently reporting success on a page the tab never actually reached (a hop not served from the back/forward cache is a live navigation, so the check tolerates a legitimate mid-hop redirect and reports wherever the tab actually landed, not just the requested entry's URL). Refs invalidate; optional `waitFor`. | | `close_tab` | `tabId` required — no implicit fallback (closing the wrong tab loses work). | | `snapshot` | Accessibility tree with stable `@eN` refs (per-tab), pruned of layout noise. Cross-checks against a DOM walk (same refs) when the a11y tree looks suspiciously sparse — Telegram Web K and similar SPAs. `mode=auto\|a11y\|dom`; `compact=true` → flat list of actionable elements only; `selector` scopes to one subtree. | | _(embedded)_ `observe` | Optional on every tool that takes `waitFor`. Returns the page as it is once the action settled — `observe:{snapshot:'compact'}` or `{text:true}` → `observed:{source, elements\|tree, text?}` — so act→look is one call instead of two. Matters most after `navigate`/`reload`/`history_go`, which invalidate every `@eN` and thus made a follow-up snapshot unavoidable. Re-checks the allowlist against the page it is about to read, so a redirect off the allowlist comes back `skipped` with nothing read; a failure inside it never fails the action. | | `read_text` | Whole-page or by ref. No raw JS. Capped at 20 000 chars by default (`maxChars` overrides, ceiling 200 000; cut results carry `truncated`/`totalChars`/`nextOffset`). Pass `offset=` to continue past a cut instead of re-reading from zero — and slices never split a surrogate pair, so an emoji on the boundary can't make the result unsignable. | | `get_state` | Cheap one-element probe (CSS or `@eN`) — `{exists, visible, tag, text, box?, inViewport?}` without a full snapshot. Verify an action's effect or re-check a ref in one round-trip. Never errors on a missing node: returns `{exists:false, reason}` (`not_found`/`unknown_ref`/`detached`), so it is safe to poll; `{exists:null, reason:'unknown'}` means an `@eN` could not be placed in the current page — not gone. Does **not** read input `.value` (no password readback). Structured CDP only. | | `console_tail` | Recent page console errors/warnings + uncaught exceptions for a tab (`{enabled, entries:[{ts,level,text,origin}]}`) — tell "the handler threw and the page is wedged" from "merely slow". **Opt-in** (popup setting, off by default; returns `{enabled:false}` when off). Capture starts at first attach (no replay); entries are **origin-filtered to the allowlist**. Pure CDP event capture, no `evaluate`. | | `network_tail` | Recent XHR/fetch responses for a tab (`{enabled, entries:[{ts,method,url,status,type,contentType,size,body?}]}`) — **the data behind canvas dashboards** (Metrika, Chart.js, WebGL) that have no readable DOM. Pull exact JSON instead of screenshot + vision. **Opt-in** (popup "capture API responses", off by default). Bodies only for textual content-types, capped; no auth headers captured; entries **origin-filtered to the allowlist**; `filter` narrows by URL substring. Pure CDP event capture, no `evaluate`. | | `handle_dialog` | See and steer native JS dialogs (`alert`/`confirm`/`prompt`/`beforeunload`) — an open dialog **freezes the page's JS** and is browser UI no other tool can click. With handling on, every dialog is answered the moment it opens (alert → OK, everything else → cancel) and recorded (`{enabled, armed, recent:[{ts,type,message,origin,response,armed}], truncated?}`); `action='accept'\|'dismiss'` (+`promptText`) arms a **one-shot** answer for the tab's *next* dialog — arm, then click the button that opens `confirm()`. The arm is **origin-bound** to the page it was armed on (a cross-origin iframe can't hijack it) and is dropped the instant the tab's top-level frame navigates anywhere — link clicks, redirects, `navigate`/`reload`/`history_go` — so it can never fire on an unrelated later dialog. **Opt-in** (popup "auto-handle JS dialogs", off by default; returns `{enabled:false}` when off — and unchecking it mid-session actually stops auto-answering, not just future recording). Entries **origin-filtered to the allowlist**. Structured CDP only, no `evaluate`. | | `click` | DOM `.click()`. CSS selector or `@eN` ref. Optional `waitFor` polls for the click's effect in the same call. | | `mouse_click` | Real `Input.dispatchMouseEvent` as a full hover→press→release sequence. Auto-aims around partial overlays; a fully covered target reports `covered`/`hitTarget`/`hitTargetRef`. Explicit `x`/`y` (viewport CSS px) as manual aim. `button` left/middle/right, `clickCount` 1–3, optional `waitFor`. | | `hover` | Hover the pointer over an element/point without clicking (the `mouseMoved` preamble only). For CSS `:hover`-only menus, tooltips, row-action UIs. `selector`/`@eN` (auto-aimed, reports `covered`/`hitTargetRef`) or viewport `x`/`y`; optional `waitFor` to hover→wait-for-menu. Strictly weaker than `mouse_click`; the `:hover` state is transient. | | `fill` | Blocks password fields without `allowPassword=true` — the gate checks the resolved node **and** the node the keystrokes actually reach, following focus through open shadow roots and same-origin frames. `method=insertText` clears the field and types via CDP with real input events (for SPA editors that ignore programmatic values), then reads back and reports `applied` (`yes`/`no`/`unclear`) plus `len` — the field's length after the write, never its contents. Optional `waitFor`. | | `select_option` | Choose an option in a native ``. `wrong_element` for non-`` via `DOM.setFileInputFiles`. Paths must be absolute, `..`-free, **and resolve under `~/Downloads/sallyport/`** (override via `SALLYPORT_DOWNLOAD_DIR`) — same sandbox as `save_to_file`, with symlink escapes blocked by `Path.resolve()`. Target must really be a file input. Allowlist-gated. | | `save_to_file` | **Daemon-local** — writes base64 to `~/Downloads/sallyport/` (override via `SALLYPORT_DOWNLOAD_DIR`). Sandboxed: no path separators or `..`. | | `status` | **Daemon-answered** health check: `{connected, mode, version, port, pendingCalls, uptimeS, lastCalls, lastError, lastHandshakeError}`. `mode` is `broker` (explicit owned `tabId` required per call) or `standalone` (active-tab fallback). `lastCalls` is a ring of recent tool **outcomes** (`{tool, ok, ms, code?}` — never the args) and `lastError` the latest failure, so a loop can attribute a stall to a specific tool/code; when `connected` is false, `lastHandshakeError` says why the extension leg failed to attach (wrong secret, clock skew, no hello). No browser round-trip and never queues behind a running call — use it as preflight before browser work. | In broker mode, target an owned tab with an explicit `tabId`; `navigate` without one creates an agent tab. Standalone mode supports an active-tab fallback for browser tools, except `close_tab`, which always requires `tabId`. There is no implicit "last touched tab" memo. Console/network capture starts on the next tool call after enabling it. Switching either setting off immediately clears that capture's buffers on all tabs and switches the capture off in the browser as well. For `network_tail`, `bodyPending: true` means a body read is queued or still running; read again shortly. At most 4 body reads per tab and 16 globally are in flight at once; the rest wait in a per-tab queue bounded by the 100-entry ring, so a burst of simultaneous responses still yields every body. Only when that queue overflows does a response keep its metadata but carry `bodyOmitted: true` and `bodyOmissionReason: "capture_busy"`, with the result marked `truncated`; filtering cannot recover those bodies. Retained body payloads are bounded across tabs — 10 MiB per tab and 40 MiB total, measured in the same wire bytes as the per-result budget — so older bodies within the same tab may be discarded; their metadata remains with `bodyOmissionReason: "cache_limit"`, and a tab cannot evict another tab's bodies. Chrome's own capture buffer is capped at 48 MB per tab and 10 MB per response, counted decoded — a body with any non-Latin-1 character is stored as UTF-16, so for it the per-response cap is ~5 MB raw (Chrome's defaults are 200 MB / 20 MB). A body Chrome does not hold — over the per-response cap, pushed out by newer traffic, or never stored at all (typically an empty response) — keeps its metadata with `bodyOmissionReason: "unavailable"`; re-fetch it with `fetch_in_page` only when the request is safe to repeat (an idempotent GET), never a POST or a signed one-shot call. These limits cover body payloads, not transient decoding, metadata or total process memory. For agents running on a schedule, the cheap iteration shape is: `status` (skip everything if the extension is detached) → scoped reads (`snapshot selector=… compact=true`, `read_text ref=…`) → actions with embedded `waitFor` instead of separate `wait_for` calls → verify with `get_state ref=…` (one element) instead of re-snapshotting the whole page. Driven tabs are kept awake automatically, so the loop keeps working while the browser window sits in the background (see Troubleshooting for the trade-offs). ## Compared to Kimi WebBridge Sallyport implements the everyday Kimi tools (`navigate`, `click`, `fill`, `snapshot`, `screenshot`, `evaluate`, `mouse_click`, `upload`, …) and adds a few of its own (`fetch_in_page`, `save_to_file`, `reload`). Three Kimi features are deliberately *not* here: | Kimi feature | Why Sallyport omits it | If you need the behaviour | |---|---|---| | `network` (start/stop/list/detail HTTP capture via `Network.enable`) | Kimi's version captures auth headers, cookies, and every request body with no per-domain gate — that ungated shape is what Sallyport omits. | Use `network_tail`: a gated subset — opt-in per popup, **response bodies only** (no headers/cookies), origin-filtered to the allowlist. Or `fetch_in_page` against a specific URL. | | `_session` (per-agent Chrome tab groups, coloured) | Cosmetic flair that complicates tab handling without solving a real problem at current scale. | Use `list_tabs` to find what you opened. | `find_tab` is also intentionally absent: Sallyport's `list_tabs` returns the full set and the agent filters client-side — one round trip instead of two. ## Testing it locally Three layers, from fastest to most realistic: ### A. Wire only — no MCP, no Claude Code > A running broker owns the port, so stop it first (`sallyport-daemon doctor > --stop-broker`) or pass a different `--port` and update the popup's daemon URL. Confirms HMAC pairing, allowlist, audit log without any tools firing. ```sh # terminal: run the daemon in WS-only mode. Stays up until Ctrl-C — # no stdin / no MCP client needed. sallyport-daemon serve ``` Default `sallyport-daemon` (no subcommand) expects an MCP client on stdin and exits immediately on EOF — fine for Claude Code, awkward for local testing. Use `serve` whenever you want a stable long-running daemon. Open the popup → **Pair** → paste secret → status flips to **connected**. That alone proves: WS reach, HMAC handshake, perms. ### B. Fire individual tools from the shell — no Claude Code `sallyport-daemon exec key=value...` calls one tool and exits, printing the result. Values are JSON when parseable, otherwise strings. ```sh # Catalogue of tools (works offline, no extension needed): sallyport-daemon list-tools # Open a tab (the host must already be in the popup's allowlist): sallyport-daemon exec navigate url=https://example.com newTab=true # Read the page: sallyport-daemon exec read_text # Get the accessibility tree + refs: sallyport-daemon exec snapshot # Click something from snapshot (use a ref): sallyport-daemon exec click selector=@e3 # Targeted screenshot: sallyport-daemon exec screenshot format=jpeg quality=70 ``` Notes: - `exec` works **alongside** running Claude Code sessions: when a broker is up (the normal case) it goes through it as a one-shot client rather than fighting for the port. With no broker it owns the port itself, and is then mutually exclusive with a running session — stop it, or pass a different `--port` here and update the popup's daemon URL. - Without a broker, the first `exec` waits up to 10s (`--wait 30` to bump it) for the popup to connect. Once paired, the extension reconnects on its own. - Screenshot blobs are truncated in the printout — they're still passed in full to a real MCP client. ### C. End-to-end with Claude Code Once A and B work, register the MCP server (see Setup → step 3), restart Claude Code, and ask it to do anything web-shaped. Watch the popup's **Audit** tab — every call lands there with `ok`/`error` and target URL. ## Troubleshooting | Symptom | Fix | |---|---| | Anything in setup feels off | Run `sallyport-daemon doctor` — it checks Python version, secret + perms, and port, and reprints the pairing block. | | Popup stuck on **"Connecting to daemon…"** | The daemon isn't reachable at the popup's URL. Confirm it's running (`sallyport-daemon serve` in another shell), that the **Advanced → daemon URL** matches (default `ws://127.0.0.1:10086/ws`), and that the port is free (`sallyport-daemon doctor`). The single-client rule means a live Claude Code session already holds the port — that's expected; the extension connects to *it*. | | Popup says **"extension is not connected"** | Daemon isn't running, or the popup says **paused** — open and hit **Resume**/**Reconnect**. | | `domain_not_allowed` | Add the host to the **Allowlist** tab. | | `evaluate_not_allowed` | Edit the allowlist entry and re-add with **allow evaluate()** checked. | | `password_field` (from `fill`) | `fill` refuses `` by default. Pass `allowPassword=true` if you really mean it. | | `wrong_element` (from `upload`) | Selector resolved to something other than ``. Re-`snapshot` and pick a real file-input ref. | | `wrong_element` (from `select_option`) | Target isn't a native `