# Sallyport
[](https://github.com/ginkida/sallyport/actions/workflows/ci.yml)
[](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml)
[](LICENSE)
[](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 `