theseus/bundled-addons/claude-connector/lib/policy.cjs
Dev c06557e957 Theseus: Claude connector, an opt-in MCP server for the signed-in profile
Claude sessions could not work inside the user's own profile: the desktop
app's browser lost synthetic pastes and typing in Medium's editor, and a
browser-wide --remote-debugging-port would hand every session to any local
process. The connector is a built-in extension, off until turned on in
Settings > Extensions, that serves MCP on 127.0.0.1 behind a per-install
token and drives one tab at a time through that tab's webContents.debugger.

- Host: a "tab-control" capability (built-in add-ons only) for listing,
  opening, navigating, closing and focusing tabs, handing out web tabs'
  webContents (never Settings, extension or home tabs), and a toolbar chip
  plus tab outline while a client is connected; the chip's X stops it.
  Built-in add-ons can ship "defaultEnabled": false.
- Tools: list_tabs, open_tab, navigate, activate_tab, close_tab, screenshot,
  read_page_text, snapshot (accessibility tree with refs), click, type
  (insertText + real Enter), press_key, paste_html / paste_text (system
  clipboard + webContents.paste, clipboard restored), upload_file
  (DOM.setFileInputFiles), wait_for, and evaluate behind its own switch.
- Safety: first use of each site asks the user; password and one-time-code
  fields are refused; uploads only from folders the user added; requests
  from web pages or foreign Host headers are refused; every action is
  logged with a timestamp in the panel and actions.log.
- Claude Desktop runs stdio-shim.cjs through the Theseus executable in Node
  mode.
- Tests: unit (protocol, auth, policy, shim) and an end-to-end run on a
  throwaway profile that types, pastes HTML with a heading and a link, and
  uploads a PNG on a local page.
2026-10-23 17:45:02 +03:00

144 lines
6.6 KiB
JavaScript

// What the connector lets a client do: which sites (asked once per origin),
// which files it may upload, and what counts as a password field.
const fs = require("node:fs");
const path = require("node:path");
// "https://medium.com" for any page on it. BCNR pages load as bns://name.bch
// inside Theseus and show as https:// in the address bar; they are the same
// site, so both forms map to the https origin. Anything else has no origin
// the connector will act on.
function originOf(url) {
let u;
try { u = new URL(String(url || "")); } catch { return null; }
if (u.protocol === "bns:") return `https://${u.host.toLowerCase()}`;
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return `${u.protocol}//${u.host.toLowerCase()}`;
}
function hostOf(url) {
try { return new URL(String(url || "")).host.toLowerCase(); } catch { return ""; }
}
const DENY_MS = 10 * 60 * 1000;
const ASK_TIMEOUT_MS = 3 * 60 * 1000;
// Approved origins live in the add-on store (remembered) or in memory (this
// session only). A refusal is remembered for ten minutes, so a client cannot
// keep the prompt on screen. Concurrent calls for one origin share a prompt.
class SitePolicy {
constructor({ getAllowed, setAllowed, ask }) {
this.getAllowed = getAllowed;
this.setAllowed = setAllowed;
this.ask = ask;
this.session = new Set();
this.denied = new Map(); // origin -> until (ms)
this.pending = new Map(); // origin -> Promise<boolean>
}
isAllowed(origin) {
return !!origin && (this.session.has(origin) || this.getAllowed().includes(origin));
}
async ensure(origin, context = {}) {
if (!origin) throw new Error("this page has no web origin the connector can act on");
if (this.isAllowed(origin)) return true;
const until = this.denied.get(origin);
if (until && until > Date.now()) throw new Error(`the user did not allow control of ${origin}; ask them to approve it in the Claude connector panel`);
if (!this.pending.has(origin)) {
const p = (async () => {
let answer = "cancel";
let timer;
try {
answer = await Promise.race([
this.ask(origin, context),
new Promise((r) => { timer = setTimeout(() => r("timeout"), ASK_TIMEOUT_MS); }),
]);
} finally { clearTimeout(timer); }
const pick = String(answer || "").split("+")[0];
if (pick === "allow") {
const list = this.getAllowed();
if (!list.includes(origin)) this.setAllowed([...list, origin]);
return true;
}
if (pick === "session") { this.session.add(origin); return true; }
if (pick === "timeout") throw new Error(`no answer from the user about ${origin}; try again when they are at the computer`);
this.denied.set(origin, Date.now() + DENY_MS);
throw new Error(`the user did not allow control of ${origin}`);
})().finally(() => this.pending.delete(origin));
this.pending.set(origin, p);
}
return this.pending.get(origin);
}
forget(origin) {
this.session.delete(origin);
this.denied.delete(origin);
this.setAllowed(this.getAllowed().filter((o) => o !== origin));
}
reset() { this.session.clear(); this.denied.clear(); }
}
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
// The real file behind `p` if it sits inside one of the allowed folders.
// Both sides are resolved with realpath, so a symlink or junction inside an
// allowed folder that points elsewhere is refused.
function resolveUpload(p, allowedDirs) {
if (!p || typeof p !== "string") throw new Error("upload needs a file path");
if (!path.isAbsolute(p)) throw new Error("upload needs an absolute file path");
if (!Array.isArray(allowedDirs) || !allowedDirs.length) throw new Error("no upload folder is set; the user can add one in the Claude connector panel");
let real;
try { real = fs.realpathSync.native(p); } catch { throw new Error(`file not found: ${p}`); }
const st = fs.statSync(real);
if (!st.isFile()) throw new Error(`not a regular file: ${p}`);
if (st.size > MAX_UPLOAD_BYTES) throw new Error(`file is larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
const norm = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
for (const d of allowedDirs) {
let rd;
try { rd = fs.realpathSync.native(d); } catch { continue; }
const base = norm(rd.endsWith(path.sep) ? rd : rd + path.sep);
if (norm(real).startsWith(base)) return real;
}
throw new Error(`${p} is outside the upload folders the user allowed`);
}
const SECRET_AUTOCOMPLETE = /\b(?:current-password|new-password|one-time-code)\b/i;
// From DOM.describeNode: is this element a password (or one-time code) field?
function isSecretField(node) {
if (!node) return false;
const attrs = {};
const a = node.attributes || [];
for (let i = 0; i + 1 < a.length; i += 2) attrs[String(a[i]).toLowerCase()] = String(a[i + 1]);
if (String(node.localName || node.nodeName || "").toLowerCase() === "input" && String(attrs.type || "").toLowerCase() === "password") return true;
return SECRET_AUTOCOMPLETE.test(attrs.autocomplete || "");
}
// Runs in the connector's isolated world (page scripts cannot reach or
// patch it): follows focus through open shadow roots and same-origin frames
// and says whether typing there is allowed.
const FOCUS_GUARD = `(() => {
let el = document.activeElement;
for (let i = 0; el && i < 25; i++) {
if (el.shadowRoot && el.shadowRoot.activeElement) { el = el.shadowRoot.activeElement; continue; }
if (el.tagName === "IFRAME" || el.tagName === "FRAME") {
let doc = null;
try { doc = el.contentDocument; } catch {}
if (!doc) return { ok: false, reason: "the focused element is inside a frame from another site, which the connector cannot check" };
el = doc.activeElement;
continue;
}
break;
}
if (!el || el.tagName === "BODY" || el.tagName === "HTML") return { ok: true, editable: false, tag: el ? el.tagName.toLowerCase() : null };
const tag = el.tagName.toLowerCase();
const ac = String((el.getAttribute && el.getAttribute("autocomplete")) || "");
if (tag === "input" && String(el.type).toLowerCase() === "password") return { ok: false, reason: "the focused field is a password field" };
if (${SECRET_AUTOCOMPLETE}.test(ac)) return { ok: false, reason: "the focused field is marked for a password or one-time code" };
const editable = !!el.isContentEditable || tag === "textarea" || tag === "select" || (tag === "input" && !/^(?:button|submit|reset|checkbox|radio|file|image|range|color|hidden)$/i.test(el.type));
return { ok: true, editable, tag };
})()`;
module.exports = { originOf, hostOf, SitePolicy, resolveUpload, isSecretField, FOCUS_GUARD, MAX_UPLOAD_BYTES };