// 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 } 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 };