// GoGoPortal's cleaner: the cleaning that runs on the member's side before // anything reaches GoGoPortal, and the stamp that says it ran // (docs/plans/remote-connector.md, sections 1 and 4; owner, 2026-10-12: "we // want their agent to do the work to strip the data before its sent to // GoGoPortal"). // // One file, the single source for both sides: the skill's script beside it // cleans and stamps a batch with it on the member's computer (or in Claude's // own sandbox on claude.ai), and the site imports the same file to check a // batch it receives (src/lib/cleaning, src/lib/marketplace/lessons-batch.ts). // Pure and dependency-free: plain ESM for Node 18 and later or any sandbox, // with no file system, no network and nothing read from the environment. // // The rules are the local connector's (packages/connector/src/connector.ts: // SECRET_PATTERNS, EMAIL, PHONE, termPattern, findInText, cleanText, // ABSOLUTE_PATHS, IDS, cleanLessonText, lessonTopicProblem), with two // changes; src/lib/cleaning/cleaner.test.ts holds both to the same outputs // on shared vectors and notes each change: // - The site's sign-in tokens (gpo_ and gpr_, then 43 base64url characters) // are secrets here; the connector does not know them. // - The email and token patterns take the same text out in linear time. As // the connector wrote them, a long run of letters, digits, dots or dashes // was read again from each place in it a match could start (a crafted // 200 KB took seconds). Here, once a match fails at the first place in a // run it could start, every later place in that run fails for the same // reason (the run's end decides it), so the pattern passes over the rest // of the run (its "skip" group: neither counted nor taken out). // // What the stamp proves, and what it does not: it stops an agent skipping the // cleaning by mistake. It cannot stop one that fakes a stamp; the site runs // this cleaner over the batch it receives (checkClean) and refuses one the // cleaning would still change, which catches what the site can see: secrets, // personal data, paths, IDs and the terms the batch names. The member's // client names are known only on their computer. /** The cleaner's name in a stamp; the site's package names it as the program that prepared it. */ export const CLEANER_NAME = "gogoportal-skill"; /** * The cleaner's version, in every stamp. It changes whenever what the * cleaning removes changes: the site accepts a list of versions * (ACCEPTED_CLEANER_VERSIONS in src/lib/marketplace/lessons-batch.ts) and * checks every batch with this file's rules, so a version whose rules differ * from these leaves that list. */ export const CLEANER_VERSION = "1.0.0"; /** The batch the skill prints and the remote tools take. */ export const BATCH_FORMAT = "gogoportal.lessons"; /** What the cleaning puts where it took something out. */ const REMOVED = "[removed]"; /** A topic's length (ITEM_TOPIC_MAX_CHARS on the site). */ const TOPIC_MAX_CHARS = 80; /** Passes cleanBatch makes over one text before it stops: a removal can leave text a rule matches on the next pass. */ const CLEAN_PASSES_MAX = 4; // ─── The cleaning (moved from the connector) ──────────────────── const SECRET_FILE_NAME = /(?:^|[\\/])(?:\.env(?:\.[\w.-]+)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)|credentials(?:\.json)?|service[-_]account[\w-]*\.json|[\w.-]+\.(?:pem|key|p12|pfx|keystore|jks|ppk|kdbx|keychain))$/i; /** A file whose name says it holds secrets (.env, a private key, a keystore). */ export function isSecretFileName(p) { return SECRET_FILE_NAME.test(p); } // The same kinds as the site's scanner (src/lib/marketplace/secrets.ts). A // pattern may end with a "skip" group: what it passes over without a match // (see the top of this file), never counted and never taken out. const SECRET_PATTERNS = [ /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/g, /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA)[A-Z0-9]{16}\b/g, /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/g, /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g, /\bAIza[0-9A-Za-z_-]{35}\b/g, /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}\b/g, /\b(?:agk_|gpc_)[A-Za-z0-9_-]{20,}/g, /\bgp[or]_[A-Za-z0-9_-]{43,}/g, /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|(?\beyJ[A-Za-z0-9_-]*)/g, /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/@]{1,100}:[^\s@/]{3,200}@[^\s/]{1,200}/gi, /\b(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)\b["']?\s*[:=]\s*["']?[^\s"',;]{8,}/gi, ]; const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b|(?\b[A-Za-z0-9._%+-]+)/g; const PHONE = /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?)?\d{2,4}[\s.-]\d{3,4}[\s.-]?\d{3,4}\b/g; /** A match a pattern only passes over (its skip group). */ const passedOver = (groups) => groups !== null && typeof groups === "object" && groups.skip !== undefined; function countMatches(text, re, keep = () => true) { re.lastIndex = 0; let n = 0; for (const m of text.matchAll(re)) if (!passedOver(m.groups) && keep(m[0])) n++; return n; } /** String.replace's function for a pattern: "[removed]" for each match keep() takes; the match as it was for one it turns down or one the pattern passes over. */ const removing = (keep = () => true) => (m, ...rest) => passedOver(rest.at(-1)) || !keep(m) ? m : REMOVED; const isPhone = (m) => { const digits = m.replace(/\D/g, "").length; return digits >= 9 && digits <= 15; }; function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** A term to leave out (a client's name, a term that identifies the project), matched whole and without regard to case; null for one under 2 characters. */ export function termPattern(term) { const t = term.trim(); if (t.length < 2) return null; return new RegExp(`(?|]*[^\s"'`<>|.,;:!?)\]}])?/g, /\\\\[A-Za-z0-9._$-]+\\(?:[^\s"'`<>|]*[^\s"'`<>|.,;:!?)\]}])?/g, /(?|]*[^\s"'`<>|.,;:!?)\]}])?/g, /(?|]*[^\s"'`<>|.,;:!?)\]}])?/g, ]; const IDS = [ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, /\b\d{3}-\d{2}-\d{4}\b/g, /\b\d(?:[ -]?\d){12,18}\b/g, /\b(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{20,}\b/g, ]; /** * A lesson's text with what cleanText removes (secrets, emails, phone * numbers, the member's terms) and full paths and IDs replaced by * "[removed]", and the counts: a path counts as personal data, an ID as a * secret. */ export function cleanLessonText(text, terms) { const base = cleanText(text, terms); let out = base.text; let paths = 0; let ids = 0; for (const re of ABSOLUTE_PATHS) out = out.replace(re, () => (paths++, REMOVED)); for (const re of IDS) out = out.replace(re, () => (ids++, REMOVED)); return { text: out, found: { secrets: base.found.secrets + ids, personalData: base.found.personalData + paths, clientNames: base.found.clientNames } }; } /** What is wrong with a lesson's topic, in the site's words (the outline's rule: outlineTopicProblem there); null when it reads as a topic. */ export function lessonTopicProblem(topic) { const t = topic.trim(); if (!t) return "Say what the lesson is about."; if (t.length > TOPIC_MAX_CHARS) return `Keep the topic under ${TOPIC_MAX_CHARS} characters.`; if (/\p{N}/u.test(t)) return "Leave numbers out of the topic. Say what it is about, not a value or a version."; if (/https?:|www\.|@|\.(com|org|net|io)\b/i.test(t)) return "Leave links and addresses out of the topic."; if (/[`{}<>;=$\\|]/.test(t)) return "Leave code out of the topic."; if (t.split(/\s+/).length > 8) return "Keep the topic to 8 words or fewer."; return null; } // ─── Every text a batch holds ─────────────────────────────────── const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); /** * The batch with each of its texts passed through `edit(text, field, file)`: * every free-text field (the description, the kinds of project, each item's * topic, body, circumstances, versions and the topics it builds on, each * question's words and its options', a correction's "since" version) and, * with `file` true, each file's text. Not texts: ids, keys, dates, kinds, * counts, the paths files are listed under, a correction's source address, * and the identifying terms (they are what the cleaning looks for). Keys the * batch does not have are not added. The one walk cleanBatch and checkClean * share, so the skill cleans exactly what the site checks. */ function mapTexts(batch, edit) { if (!isObject(batch)) return batch; const text =(v, field) => (typeof v === "string" ? edit(v, field, false) : v); const texts = (v, field) => (Array.isArray(v) ? v.map((x, i) => text(x, `${field}[${i}]`)) : v); const set = (from, to, key, value) => { if (Object.prototype.hasOwnProperty.call(from, key)) to[key] = value; }; const out = { ...batch }; set(batch, out, "description", text(batch.description, "description")); set(batch, out, "kindsOfProject", texts(batch.kindsOfProject, "kindsOfProject")); if (Array.isArray(batch.items)) { out.items = batch.items.map((item, i) => { if (!isObject(item)) return item; const at = `items[${i}]`; const next = { ...item }; for (const key of ["topic", "body", "circumstances"]) set(item, next, key, text(item[key], `${at}.${key}`)); for (const key of ["appliesTo", "buildsOn"]) set(item, next, key, texts(item[key], `${at}.${key}`)); return next; }); } if (isObject(batch.questions) && Array.isArray(batch.questions.questions)) { out.questions = { ...batch.questions, questions: batch.questions.questions.map((q, i) => { if (!isObject(q)) return q; const at = `questions.questions[${i}]`; const next = { ...q }; for (const key of ["ask", "why", "detail", "recommendedWhy"]) set(q, next, key, text(q[key], `${at}.${key}`)); if (Array.isArray(q.options)) { next.options = q.options.map((o, j) => { if (!isObject(o)) return o; const option = { ...o }; for (const key of ["label", "description"]) set(o, option, key, text(o[key], `${at}.options[${j}].${key}`)); return option; }); } return next; }), }; } if (isObject(batch.correction) && isObject(batch.correction.since)) { const since = { ...batch.correction.since }; set(batch.correction.since, since, "version", text(batch.correction.since.version, "correction.since.version")); out.correction = { ...batch.correction, since }; } if (Array.isArray(batch.files)) { out.files = batch.files.map((f, i) => { if (!isObject(f) || typeof f.text !== "string") return f; return { ...f, text: edit(f.text, `files[${i}].text`, true) }; }); } return out; } const noneFound = () => ({ secrets: 0, personalData: 0, clientNames: 0 }); function addFound(sum, found) { sum.secrets += found.secrets; sum.personalData += found.personalData; sum.clientNames += found.clientNames; } /** * What the cleaning would still change in a batch: one entry per text it * would change, by field, with the counts (never the text). Empty when the * batch is clean. Free-text fields go through cleanLessonText (secrets, * personal data, paths, IDs and the terms); a file's text through cleanText * (secrets, personal data and the terms, the connector's rule for a named * file: a file is refused, never edited, and code is full of paths and long * names). The site passes the batch's identifying terms (identifyingTermsOf); * the skill adds the member's client names. */ export function checkClean(batch, terms = []) { const left = []; mapTexts(batch, (value, field, file) => { const c = file ? cleanText(value, terms) : cleanLessonText(value, terms); if (c.text !== value) left.push({ field, found: c.found }); return value; }); return left; } /** * The batch with every free-text field cleaned, each until another pass of * cleanLessonText changes nothing (so the site's checkClean finds nothing * left), and the counts of what went. Files' text is left as it is: a file * the cleaning would change is refused by the skill's check, never edited. */ export function cleanBatch(batch, terms) { const found = noneFound(); const cleaned = mapTexts(batch, (value, _field, file) => { if (file) return value; let out = value; for (let pass = 0; pass < CLEAN_PASSES_MAX; pass++) { const c = cleanLessonText(out, terms); addFound(found, c.found); if (c.text === out) break; out = c.text; } return out; }); return { batch: cleaned, found }; } // ─── The stamp ────────────────────────────────────────────────── /** * JSON with every object's keys sorted and every string in Unicode NFC, no * whitespace: the same batch always gives the same text, whichever side * writes it and however its keys were ordered. Keys whose value is * undefined are left out, as JSON.stringify leaves them out. */ export function canonicalJson(value) { if (typeof value === "string") return JSON.stringify(value.normalize("NFC")); if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`; const entries = Object.entries(value) .filter(([, v]) => v !== undefined) .map(([k, v]) => [k.normalize("NFC"), v]) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; } // SHA-256 (FIPS 180-4), written out in plain JavaScript rather than taken // from node:crypto: the fingerprint is then the same code on the member's // side (Node 18 and later, or a sandbox with no Node modules) and on the // site's, with nothing to import and no runtime to detect. It is fast // enough for any batch (about 25 ms for 5 MB on Node 24), and a test holds // it to node:crypto's digests. const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]); const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; const rotr = (x, n) => (x >>> n) | (x << (32 - n)); /** The SHA-256 of a text's UTF-8 bytes, in lower-case hex. */ export function sha256Hex(text) { const bytes = new TextEncoder().encode(text); const total = Math.ceil((bytes.length + 9) / 64) * 64; const block = new Uint8Array(total); block.set(bytes); block[bytes.length] = 0x80; const view = new DataView(block.buffer); // The message's length in bits, as a 64-bit big-endian number. view.setUint32(total - 8, Math.floor(bytes.length / 0x20000000), false); view.setUint32(total - 4, (bytes.length * 8) >>> 0, false); const h = Uint32Array.from(H0); const w = new Uint32Array(64); for (let at = 0; at < total; at += 64) { for (let i = 0; i < 16; i++) w[i] = view.getUint32(at + i * 4, false); for (let i = 16; i < 64; i++) { const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); w[i] = w[i - 16] + s0 + w[i - 7] + s1; } let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], k = h[7]; for (let i = 0; i < 64; i++) { const t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0; const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0; k = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; } h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += k; } return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join(""); } /** The batch without its stamp. */ function unstamped(batch) { if (!isObject(batch)) throw new TypeError("A batch is a JSON object."); const out = {}; for (const [key, value] of Object.entries(batch)) if (key !== "stamp") out[key] = value; return out; } /** The batch's fingerprint: the SHA-256 of canonicalJson of the batch without its stamp. */ export function fingerprint(batch) { return sha256Hex(canonicalJson(unstamped(batch))); } /** The terms a batch names as identifying the project: the ones the site can check for. */ export function identifyingTermsOf(batch) { return isObject(batch) && Array.isArray(batch.identifyingTerms) ? batch.identifyingTerms.filter((t) => typeof t === "string") : []; } /** * The batch with its stamp: this cleaner's name and version and the batch's * fingerprint. Refuses a batch the cleaning would still change with the * terms it names (the check the site makes), so a stamp is only ever put on * a cleaned batch. */ export function stampBatch(batch) { const rest = unstamped(batch); const left = checkClean(rest, identifyingTermsOf(rest)); if (left.length > 0) throw new Error(`The cleaning would still change ${left.map((l) => l.field).join(", ")}: clean the batch first.`); return { ...rest, stamp: { cleaner: CLEANER_NAME, version: CLEANER_VERSION, fingerprint: fingerprint(rest) } }; }