Sallyport
About
# Sallyport [](https://github.com/ginkida/sallyport/actions/workflows/ci.yml) [](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml)…
Explore
- HMAC-SHA256 signed frames with replay protection (nonce cache, timestamp drift ≤ 30 s)
- Domain allowlist in chrome.storage.local, patterns like example.com or *.example.com
- evaluate is opt-in per domain; other tools use structured CDP only
- Defense-in-depth: fill refuses <input type=password> unless allowPassword=true
- Operational visibility: every tool call is audited (last 500 entries) with one-click pause
- No content scripts or <all_urls> permissions; uses debugger API exclusively
- Per-tab accessibility refs (@e1, @e2), serialized tool calls via daemon-side lock
Setting up with Highlight
This MCP is not yet compatible with Highlight’s one-click setup. However, you can still use it with Highlight by following these steps:
- Download and install Highlight from highlightai.com/download
- Navigate to the plugins tab and select "Add Custom Plugin"
-
Configure the plugin with the settings below
Plugin Name
SallyportCommand (node, npx, python, etc.)Please refer to the README for specific instructions on how to obtain API keys or other required environment variables.
- Enable "Start Automatically" if you want the plugin to start when Highlight launches
From the repository
Sallyport needs Python ≥ 3.10 (it uses match statements and X | Y type
syntax). Check with python --version first.
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:
which sallyport-daemon # should print a path; if not, add your Python
By default each Claude Code session spawns its own daemon, and only one can own
the browser at a time (they'd fight over 127.0.0.1:10086). Broker mode lets
several sessions — and you, working in the same browser — share one extension:
shlist_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.
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\
read_text
Whole-page or by ref. No raw JS. Capped at 20 000 chars by default (`maxChars` overrides; cut results carry `truncated`/`totalChars`).
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. 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 `eva
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`. `method=insertText` clears the field and types via CDP with real input events (for SPA editors that ignore programmatic values). Optional `waitFor`.
select_option
Choose an option in a native `<select>` (the OS popup can't be driven via CDP). Sets the value in the DOM and fires `input`/`change` instead of opening the menu. One of `value`/`label`/`index`; array for `<select multiple>`. `wrong_element` for non-`<select>` targets — custom JS comboboxes (react-select, MUI) stay on `click`/`find`/`reveal`. Optional `waitFor`.
key_type
Raw text input via CDP. Blocks when focus is on a password field without `allowPassword=true`.
send_keys
`Mod+A`, `Shift+Tab`, etc. `Mod` = `Cmd` on macOS, `Ctrl` elsewhere. Same password-field gate as `key_type`.
screenshot
PNG/JPEG as a native MCP image block. `maxWidth` downscales, `region={x,y,width,height}` crops (viewport-relative CSS px). Hidden tabs fail fast with `tab_not_visible`; `bringToFront=true` activates the tab first (steals focus).
wait_for
Poll (250 ms) until a selector/`@eN` ref is visible and/or page text contains a substring; `absent=true` waits until it is GONE. `timeoutMs` ≤ 30 s; timeout returns `{found:false}`, not an error. Replaces blind sleeps. Prefer the embedded `waitFor` on the preceding action when there is one.
scroll
Deterministic scrolling — the predicate-less companion to `reveal`. `selector` → `scrollIntoView`; or scroll the page (or a `selector` container) by `dx`/`dy` (negatives = up/left) or `to='top'\
evaluate
Per-domain opt-in. Returns `{type, value}`.
fetch_in_page
`fetch()` with page cookies/auth. Returns `{status, contentType, headers, mode, data}`. Allowlist-gated.
upload
Attach local files to `<input type=file>` 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/<filename>` (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 fai
| 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. |
| 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. |
| read_text | Whole-page or by ref. No raw JS. Capped at 20 000 chars by default (maxChars overrides; cut results carry truncated/totalChars). |
| 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. 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. |
| 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. method=insertText clears the field and types via CDP with real input events (for SPA editors that ignore programmatic values). Optional waitFor. |
| select_option | Choose an option in a native <select> (the OS popup can't be driven via CDP). Sets the value in the DOM and fires input/change instead of opening the menu. One of value/label/index; array for <select multiple>. wrong_element for non-<select> targets — custom JS comboboxes (react-select, MUI) stay on click/find/reveal. Optional waitFor. |
| key_type | Raw text input via CDP. Blocks when focus is on a password field without allowPassword=true. |
| send_keys | Mod+A, Shift+Tab, etc. Mod = Cmd on macOS, Ctrl elsewhere. Same password-field gate as key_type. |
| screenshot | PNG/JPEG as a native MCP image block. maxWidth downscales, region={x,y,width,height} crops (viewport-relative CSS px). Hidden tabs fail fast with tab_not_visible; bringToFront=true activates the tab first (steals focus). |
| wait_for | Poll (250 ms) until a selector/@eN ref is visible and/or page text contains a substring; absent=true waits until it is GONE. timeoutMs ≤ 30 s; timeout returns {found:false}, not an error. Replaces blind sleeps. Prefer the embedded waitFor on the preceding action when there is one. |
| scroll | Deterministic scrolling — the predicate-less companion to reveal. selector → scrollIntoView; or scroll the page (or a selector container) by dx/dy (negatives = up/left) or to='top'\|'bottom'. Returns {x, y, scrollHeight, atBottom} so a lazy-load loop knows when to stop. Fixed scroll probe, no evaluate. |
| evaluate | Per-domain opt-in. Returns {type, value}. |
| fetch_in_page | fetch() with page cookies/auth. Returns {status, contentType, headers, mode, data}. Allowlist-gated. |
| upload | Attach local files to <input type=file> 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/<filename> (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. |
All tools accept tabId to target a specific tab; otherwise they use the
active tab in the current window. There is no implicit "last touched tab"
memo — explicit IDs win, the active tab is the only fallback.
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).
Claude Desktop / Cursor
Paste into your MCP client config file to install this server.
{
"mcpServers": {
"sallyport": {
"sallyport": {
"command": "sallyport-daemon"
}
}
}
}
McpServers
{
"sallyport": {
"command": "sallyport-daemon"
}
}
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) | 439 |
| Extension tests (vitest) | 594 |
| 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.
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). 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 <input type=password>
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 <all_urls> content scripts. Permissions
are only what the debugger API needs (tabs, activeTab, debugger,
storage, alarms).
- 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.
- MCP-side tool calls are serialised by a daemon-side lock so Claude can't
accidentally race state on the extension.
- 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:
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.
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:
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:
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:
sallyport-daemon --show-secret
3. Register with Claude Code
Add an MCP server entry — either edit ~/.claude/mcp.json directly, or:
```sh
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.



