safari-mcp
Version:
Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.
242 lines (222 loc) • 12.8 kB
JavaScript
// Tab-ownership state + tracking — the security-critical core that prevents the MCP
// session from operating on the USER's tabs. Extracted verbatim from index.js so the
// state and its helpers live in one reviewable, test-locked module (see
// tests/ownership-state.test.mjs). The pure matching/pruning semantics live next door in
// ownership-match.js; this module owns the *stateful* layer on top of them: the in-memory
// sets, the on-disk persistence (so ownership survives MCP restarts), and the TTL.
//
// Every symbol here was previously a module-level binding in index.js. All exported state
// is `const` (Map/Set) — mutated, never reassigned — so ESM live bindings keep index.js and
// any future src/tools/* module pointing at the exact same objects.
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { findOwnedMatch, pruneExpired, hasDurableReceipt } from "./ownership-match.js";
// A restart empties the in-memory sets. Persist the owned URLs to a JSON file with a TTL so, for
// up to OWNERSHIP_TTL_MS, the page of a tab an MCP session opened stays recognizable: a session
// that re-anchors to that tab after a restart (by its receipt) still passes the current-tab URL
// check instead of having to reopen it. The file is shared by every process on the machine, so it
// says which pages some session opened, never that the caller has a tab — index.js asks that of
// the session's own state (_assertTabOwnership).
export const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
export const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
export const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes — plain URLs (can collide with a user tab)
// Receipt-bearing tabs (#mcp-tab=<12+ opaque chars>) get a much longer window. The marker is
// unguessable, so ageing never makes it match someone else's page; the short TTL only ever
// broke long-running jobs mid-way (GBP publish, 2026-08-23: a tab opened at the start became
// un-switchable ~35min in, while still carrying our own marker).
export const RECEIPT_OWNERSHIP_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
export function _loadOwnershipFile() {
try {
if (!existsSync(OWNERSHIP_FILE)) return [];
const raw = readFileSync(OWNERSHIP_FILE, "utf8");
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
const now = Date.now();
const cutoff = now - OWNERSHIP_TTL_MS;
const receiptCutoff = now - RECEIPT_OWNERSHIP_TTL_MS;
// Same tiering as _pruneExpiredOwnership — otherwise a daemon restart (which reloads from
// disk) silently drops receipt-bearing tabs after 30min even though the in-memory path
// would have kept them, and every write tool on that tab starts failing.
return data.filter(
(e) => e && typeof e.url === "string" && typeof e.ts === "number" &&
e.ts > (hasDurableReceipt(e.url) ? receiptCutoff : cutoff)
);
} catch {
return [];
}
}
export function _saveOwnershipFile(urls, removed = []) {
try {
if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
const now = Date.now();
// Merge with disk before writing (#82): this file is shared by every instance on the
// machine, and a snapshot of only THIS process's Set silently drops entries concurrent
// instances added after we hydrated. Union disk+local (newest ts wins), then apply this
// write's explicit removals so deletions propagate instead of being resurrected by the
// merge. _loadOwnershipFile() TTL-filters, so expired disk entries fall away here too.
// Residual race: two writers between read and rename can still drop one entry — that
// window is sub-millisecond (was: entire process lifetime) and losing an entry is
// fail-safe: ownership is lost, so a tool refuses; it never gains a user's tab.
const mergedTs = new Map();
for (const e of _loadOwnershipFile()) mergedTs.set(e.url, e.ts);
for (const url of urls) {
const localTs = _ownedTabTimestamps.get(url) ?? now;
mergedTs.set(url, Math.max(localTs, mergedTs.get(url) ?? 0));
}
for (const url of removed) mergedTs.delete(url);
const entries = Array.from(mergedTs, ([url, ts]) => ({ url, ts }));
// Atomic write (tmp + rename) — a partial write from one instance must never corrupt
// the JSON another instance reads.
const tmp = OWNERSHIP_FILE + ".tmp." + process.pid;
writeFileSync(tmp, JSON.stringify(entries), { mode: 0o600 });
renameSync(tmp, OWNERSHIP_FILE);
} catch {
/* best-effort */
}
}
// Tabs opened by the MCP sessions of this process, each recorded with its sessionId (see
// _trackTab); _sessionTabs() gives one session's.
export const _openedTabs = new Map();
// ========== TAB OWNERSHIP: prevent operating on user's tabs ==========
// URLs of tabs opened by the MCP sessions of this process, plus every process's from
// ~/.safari-mcp/owned-tabs.json, so ownership survives MCP restarts. A tool that modifies a
// tab (navigate, click, fill, etc.) is refused unless the session's current tab is on one.
// Being everyone's, the set cannot say whether the caller has a tab at all.
export const _ownedTabURLs = new Set();
// Preserve each entry's ORIGINAL timestamp so _saveOwnershipFile doesn't reset it to `now` on
// every write — otherwise the 30-min TTL never expires anything while a session is active, and
// stale ownership leaks onto the user's tabs across sessions.
export const _ownedTabTimestamps = new Map();
for (const e of _loadOwnershipFile()) {
_ownedTabURLs.add(e.url);
_ownedTabTimestamps.set(e.url, e.ts);
}
// Touch-on-use + live TTL enforcement. The TTL exists so ownership doesn't outlive the
// session's actual use of a tab: entries the session keeps asserting against stay fresh;
// abandoned entries expire after OWNERSHIP_TTL_MS and can no longer match a user's tab.
// (Previously the TTL was only applied when loading the file at startup, so a long-lived
// session accumulated ownership forever.)
export function _touchOwned(ownedKey) {
_ownedTabTimestamps.set(ownedKey, Date.now());
return true;
}
export function _pruneExpiredOwnership() {
const before = new Set(_ownedTabURLs);
if (pruneExpired(_ownedTabURLs, _ownedTabTimestamps, OWNERSHIP_TTL_MS, Date.now(), RECEIPT_OWNERSHIP_TTL_MS)) {
// Pass the pruned URLs as explicit removals so the merge-on-write in
// _saveOwnershipFile doesn't resurrect them from the disk copy.
const removed = [...before].filter((u) => !_ownedTabURLs.has(u));
_saveOwnershipFile(_ownedTabURLs, removed);
}
}
// Matching semantics (exact / normalized / same-origin path-prefix with a segment
// boundary) live in ownership-match.js, where test/ownership-match.test.mjs locks
// them — including that owning /org never owns /org-evil, and that the broad
// "own the whole origin" rule stays dead (it defeated tab-safety entirely).
export function _isURLOwned(url) {
if (!url) return false;
_pruneExpiredOwnership();
const match = findOwnedMatch(url, _ownedTabURLs);
return match !== null ? _touchOwned(match) : false;
}
// Destructive recovery must never inherit the broader same-path matching used by
// normal navigation. It needs the exact marked URL that new_tab persisted.
export function _isExactURLOwned(url) {
if (!url) return false;
_pruneExpiredOwnership();
return _ownedTabURLs.has(url) ? _touchOwned(url) : false;
}
// Sentinel persisted when a blank tab (about:blank) is opened by this session.
// A blank tab has no unique URL to own, but ownership must still survive an MCP
// process restart (_openedTabs is in-memory only) — otherwise safari_switch_tab's
// URL check refuses a blank tab that _openedTabs has no entry for. The sentinel is never a
// real tab URL, so it cannot falsely match a user's page in _isURLOwned().
export const BLANK_TAB_SENTINEL = "__mcp-blank-tab__";
export function _markBlankTabOpened() {
if (!_ownedTabURLs.has(BLANK_TAB_SENTINEL)) {
_ownedTabTimestamps.set(BLANK_TAB_SENTINEL, Date.now());
_ownedTabURLs.add(BLANK_TAB_SENTINEL);
_saveOwnershipFile(_ownedTabURLs);
}
}
// ========== OPT-IN TAB ADOPTION (#92) ==========
// With SAFARI_MCP_ALLOW_USER_TABS set, an EXPLICIT safari_switch_tab may adopt a tab the
// user already had open, and the session then acts on it like one of its own. The flag
// deliberately unlocks adoption rather than blanket-skipping the guards: "the server acts
// on the tab you pointed it at, not the one you wandered to" is the property that makes the
// opt-in safe, and an ambient op landing on whatever tab is frontmost is the exact failure
// the guards were built against. Read from the environment on each call so a host can flip
// it without a restart and so tests need no module reload.
//
// Nothing here records an adoption. A URL cannot tell the adopted tab from another tab on
// the same page, and the adopted tab can navigate to a URL the session owns. safari.js marks
// the tab itself instead, with the session's adoption marker (isActiveTabAdopted()), in the
// per-session state: nothing reaches the shared ownership file or another session.
export function allowUserTabs() {
const v = String(process.env.SAFARI_MCP_ALLOW_USER_TABS || "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
export function _addOwnedURL(url) {
if (url && url !== "about:blank" && url !== "favorites://") {
if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
_ownedTabURLs.add(url);
_saveOwnershipFile(_ownedTabURLs);
}
}
export function _removeOwnedURL(url) {
if (url) {
_ownedTabURLs.delete(url);
_ownedTabTimestamps.delete(url);
_saveOwnershipFile(_ownedTabURLs, [url]);
}
}
export function _trackTab(tabIndex, url, sessionId = "", marker = "", receipt = "") {
// sessionId is what keeps the tab cap per-session. In HTTP daemon mode one process
// serves many Claude sessions, and this map is process-wide: without it, the cap is
// the SUM of every session's tabs, and the "close the oldest" eviction happily closed
// a tab another session was still working in.
// The index and the URL both go stale — indices shift whenever a tab of the window
// closes or moves, and the tab navigates — so the entry carries the tab's identity: the
// receipt the extension minted for it, or the marker (window.name / __mcpTabMarker)
// stamped on a tab AppleScript opened. Every path that later CLOSES this tab resolves it
// through that identity (#112), and the entry is keyed by it too: keyed by index, a new
// tab that landed on a shifted position overwrote a live tab's entry.
// A tab recorded again under its identity keeps one record, and the old one goes first, with the
// URL it claimed: safari_wait_for_new_tab can take one of the session's tabs that navigated for the
// new one, and a switch keeps that tab's marker, so no close would release the URL it had left.
const key = receipt || marker || tabIndex;
if (_openedTabs.has(key)) _untrackTab(key);
_openedTabs.set(key, {
index: tabIndex, url: url || "", openedAt: Date.now(), sessionId,
marker: marker || "", receipt: receipt || "",
});
_addOwnedURL(url);
}
// This session's tracked tabs as [key, info], oldest first.
export function _sessionTabs(sessionId) {
return [..._openedTabs]
.filter(([, info]) => (info.sessionId || "") === sessionId)
.sort(([, a], [, b]) => a.openedAt - b.openedAt);
}
// The newest tab recorded at `index` when it was opened. A hint, never proof: Safari
// renumbers indices on every close, so a caller must pair it with other evidence.
export function _trackedAtIndex(index) {
let hit;
for (const info of _openedTabs.values()) if (info.index === index) hit = info;
return hit;
}
// What a tab's claim covers: the URL it was opened on and, when that URL has no scheme, its
// https:// form, which index.js claims along with it (_claimURL) because that is what loads.
function _claimedForms(url) {
if (!url) return [];
return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? [url] : [url, "https://" + url];
}
export function _untrackTab(tabIndex) {
const info = _openedTabs.get(tabIndex);
_openedTabs.delete(tabIndex);
// Release the tab's claim, except what another tracked tab still claims: a close released the URL
// two tabs shared, and the next write in the other one, this session's or another's, was refused.
const kept = new Set([..._openedTabs.values()].flatMap((other) => _claimedForms(other.url)));
for (const url of _claimedForms(info?.url)) if (!kept.has(url)) _removeOwnedURL(url);
}