@chatpanel/pii 0.7.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/detector-router.js +112 -0
- package/index.js +2 -0
- package/package.json +6 -2
- package/pii-detect.js +50 -4
- package/trust.js +192 -0
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ real values — and they're reconstructed locally on the way back.
|
|
|
29
29
|
| `pii-redact.js` | `createVault`, `redactText`, `restoreText`, `restoreWithAliases`, `vaultToJSON`/`vaultFromJSON`, `hasToken` | deterministic redact/restore + the per-conversation vault |
|
|
30
30
|
| `pii-detect.js` | `detectEntities`, `normalizeEntities`, `EXTRACT_SYS`, … | local entity detection (any HTTP NER endpoint, or a local OpenAI-compatible LLM) |
|
|
31
31
|
| `pipeline.js` | `redactOutbound`, `makeStreamRestorer`, `restore`, `restoreDeep`, `redactResult`, `effectiveTier`, `gatedDictionary`, `gatedScope` | pure turn orchestration + the free/Pro tier, scope, and dictionary gating |
|
|
32
|
+
| `net.js` | `isBlockedHost`, `assertEndpointUrl`, `assertPublicWebUrl`, … | the SSRF host classifier + outbound-URL guard every ChatPanel process applies |
|
|
33
|
+
| `trust.js` | `callerTrust`, `classifyOrigin`, `isPaired`, `capRunOptions`, `minReach`, `createPairingStore` | who is calling a ChatPanel localhost server (pinned extension / paired token / unpaired / local), the reach ceiling that follows, and single-use pairing codes |
|
|
32
34
|
|
|
33
35
|
Import the barrel (`@chatpanel/pii`) or a submodule
|
|
34
36
|
(`@chatpanel/pii/pii-redact.js`).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// DETECTOR ROUTING — which detectors to run on THIS machine, and why (privacy-routing.md §4.4, §6).
|
|
2
|
+
//
|
|
3
|
+
// One model cannot cover chat text (Privacy Filter finds a lowercase name and never a city;
|
|
4
|
+
// a NER model the reverse), and the best model is a 1 GB resident that would swap an 8 GB
|
|
5
|
+
// laptop with a browser and a coding agent open. So the choice is a judgement about the
|
|
6
|
+
// machine: how much memory detectors may take, which of the catalogued models fit, and what
|
|
7
|
+
// to offer when the good ones do not — a detector on a server, which is what a hosted tier
|
|
8
|
+
// is too. Pure: the catalogue, the machine and what is installed come in; a primary, a union,
|
|
9
|
+
// the reasons and the alternatives come out. The gateway applies it; the clients draw it.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How much resident memory detectors may take on a machine of this size: 8% of RAM, never
|
|
13
|
+
* under 300 MB (the small English model must always fit). 8 GB → 655 MB; 16 GB → 1.3 GB;
|
|
14
|
+
* 32 GB → 2.6 GB. The figure is a budget for the UNION, not one model: the sum of `ramMB`
|
|
15
|
+
* of everything loaded.
|
|
16
|
+
*/
|
|
17
|
+
export function detectorBudgetMB(totalRamMB) {
|
|
18
|
+
const total = Number(totalRamMB) || 0;
|
|
19
|
+
return Math.max(300, Math.round(total * 0.08));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resident cost of a catalogue entry — `ramMB` when declared, else ~2.3× the q8 download:
|
|
24
|
+
* measured 2026-09-19, bert-base-NER (105 MB on disk) added ~240 MB to the process; Privacy
|
|
25
|
+
* Filter (1.6 GB int8 on disk) ~700 MB, which is why it declares `ramMB` itself.
|
|
26
|
+
*/
|
|
27
|
+
export function residentMB(model) {
|
|
28
|
+
if (Number(model?.ramMB) > 0) return Number(model.ramMB);
|
|
29
|
+
return Math.round((Number(model?.approxMB) || 200) * 2.3);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Is this catalogue entry a general NER model — the kind that finds places and organisations?
|
|
34
|
+
* Privacy Filter is not (no LOC/ORG label, by design); the PII-specialised multilingual model
|
|
35
|
+
* is not either for this purpose: it tags a city but measured 0 of 7 lowercase names, so it is
|
|
36
|
+
* never the model chosen to complement Privacy Filter (privacy-routing.md §3.2).
|
|
37
|
+
*/
|
|
38
|
+
function findsPlaces(m) { return !/private|privacy|pii/i.test(String(m.id)); }
|
|
39
|
+
function isPrivacyModel(m) { return /privacy-filter/i.test(String(m.id)); }
|
|
40
|
+
function multilingual(m) { return /multilingual|multilang/i.test(String(m.id)) || /multi|languages/i.test(String(m.lang || '')); }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recommend a primary and a union for this machine.
|
|
44
|
+
*
|
|
45
|
+
* @param catalog [{ id, label, approxMB, ramMB?, minRamMB?, lang?, installed? }]
|
|
46
|
+
* @param machine { totalRamMB, langs?: ['en','de',…] (languages seen in this user's text), nonLatin?: bool }
|
|
47
|
+
* @param providers optional [{ id, name, reach, capabilities: ['detect'…] }] — servers that can
|
|
48
|
+
* detect for this machine (an org box, the hosted tier) — offered when the
|
|
49
|
+
* good models do not fit here.
|
|
50
|
+
* @returns { primary, union, budgetMB, usedMB, reason, skipped: [{ id, why }], alternatives: [{ kind, id?, why }] }
|
|
51
|
+
*/
|
|
52
|
+
export function recommendDetector(catalog = [], machine = {}, providers = []) {
|
|
53
|
+
const budgetMB = detectorBudgetMB(machine.totalRamMB);
|
|
54
|
+
const wantMulti = !!machine.nonLatin || (Array.isArray(machine.langs) && machine.langs.some((l) => l && l !== 'en'));
|
|
55
|
+
const byId = (id) => catalog.find((m) => m.id === id);
|
|
56
|
+
const small = catalog.find((m) => /Xenova\/bert-base-NER$/.test(m.id)) || catalog[0];
|
|
57
|
+
const privacy = catalog.find(isPrivacyModel);
|
|
58
|
+
const placesModel = catalog.filter(findsPlaces).filter((m) => wantMulti ? multilingual(m) : !multilingual(m))
|
|
59
|
+
// the biggest place-finder that is not the privacy model: accuracy over size within the budget
|
|
60
|
+
.sort((a, b) => residentMB(b) - residentMB(a))[0] || small;
|
|
61
|
+
|
|
62
|
+
const chosen = [];
|
|
63
|
+
const skipped = [];
|
|
64
|
+
let usedMB = 0;
|
|
65
|
+
const take = (m, role) => {
|
|
66
|
+
if (!m || chosen.some((c) => c.id === m.id)) return false;
|
|
67
|
+
const cost = residentMB(m);
|
|
68
|
+
if (usedMB + cost > budgetMB) { skipped.push({ id: m.id, why: `${role}: needs ~${cost} MB, ${budgetMB - usedMB} MB of the ${budgetMB} MB detector budget left` }); return false; }
|
|
69
|
+
chosen.push(m); usedMB += cost; return true;
|
|
70
|
+
};
|
|
71
|
+
// The privacy model first — it is the one that finds what redaction is for — then a
|
|
72
|
+
// place-finder beside it; if the privacy model does not fit, the place-finder alone.
|
|
73
|
+
const gotPrivacy = take(privacy, 'private people, addresses, secrets');
|
|
74
|
+
take(placesModel, 'places and organisations');
|
|
75
|
+
if (!chosen.length) take(small, 'the smallest model');
|
|
76
|
+
if (!chosen.length && small) { chosen.push(small); usedMB += residentMB(small); } // the floor: always something
|
|
77
|
+
|
|
78
|
+
const primary = chosen[0].id;
|
|
79
|
+
const union = chosen.slice(1).map((m) => m.id);
|
|
80
|
+
const reasons = [];
|
|
81
|
+
if (gotPrivacy) reasons.push(`${byId(primary).label || primary} finds private people (lowercase too), addresses and secrets`);
|
|
82
|
+
if (union.length) reasons.push(`${union.map((id) => byId(id)?.label || id).join(' + ')} add${union.length === 1 ? 's' : ''} places and organisations`);
|
|
83
|
+
if (!gotPrivacy && privacy) reasons.push(`Privacy Filter (~${residentMB(privacy)} MB) does not fit the ${budgetMB} MB this machine can spare for detectors`);
|
|
84
|
+
reasons.push(`~${usedMB} MB of ${budgetMB} MB (this machine: ${Math.round((machine.totalRamMB || 0) / 1024)} GB)`);
|
|
85
|
+
|
|
86
|
+
const alternatives = [];
|
|
87
|
+
if (!gotPrivacy && privacy) {
|
|
88
|
+
const servers = (providers || []).filter((p) => (p.capabilities || p.provides || []).includes('detect'));
|
|
89
|
+
for (const p of servers) alternatives.push({ kind: p.reach === 'device' ? 'local-server' : 'server', id: p.id, why: `${p.name || p.id} can run the detection for this machine (${p.reach || 'remote'})` });
|
|
90
|
+
if (!servers.length) alternatives.push({ kind: 'server', why: 'run detection on a server — an engine server on a bigger machine, or a hosted detector — and add it under Models' });
|
|
91
|
+
}
|
|
92
|
+
return { primary, union, budgetMB, usedMB, reason: reasons.join(' · '), skipped, alternatives };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Trim a CONFIGURED union to what fits this machine, in priority order — the guard a
|
|
97
|
+
* gateway applies before loading, so a config copied to a smaller laptop never swaps it.
|
|
98
|
+
* Returns the ids to load and the ones skipped with the reason.
|
|
99
|
+
*/
|
|
100
|
+
export function fitUnion(primaryId, unionIds, catalog = [], machine = {}) {
|
|
101
|
+
const budgetMB = detectorBudgetMB(machine.totalRamMB);
|
|
102
|
+
const cost = (id) => residentMB(catalog.find((m) => m.id === id) || { approxMB: 200 });
|
|
103
|
+
let usedMB = cost(primaryId); // the primary always loads — the user chose it
|
|
104
|
+
const load = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
for (const id of unionIds || []) {
|
|
107
|
+
const c = cost(id);
|
|
108
|
+
if (usedMB + c > budgetMB) { skipped.push({ id, why: `needs ~${c} MB; ${Math.max(0, budgetMB - usedMB)} MB of the ${budgetMB} MB detector budget left on this machine` }); continue; }
|
|
109
|
+
load.push(id); usedMB += c;
|
|
110
|
+
}
|
|
111
|
+
return { load, skipped, budgetMB, usedMB };
|
|
112
|
+
}
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// 'chatpanel-pii/tool-rank.js' deterministic tool narrowing (auto mode)
|
|
12
12
|
// 'chatpanel-pii/sanitize.js' Unicode de-steganography (strip invisible/format chars)
|
|
13
13
|
// 'chatpanel-pii/net.js' SSRF host classifier + outbound-URL guard
|
|
14
|
+
// 'chatpanel-pii/trust.js' who is calling a localhost server (origin → trust), pairing codes
|
|
14
15
|
|
|
15
16
|
export * from './pii-redact.js';
|
|
16
17
|
export * from './pii-detect.js';
|
|
@@ -19,3 +20,4 @@ export * from './tool-rank.js';
|
|
|
19
20
|
export * from './tool-harness.js';
|
|
20
21
|
export * from './sanitize.js';
|
|
21
22
|
export * from './net.js';
|
|
23
|
+
export * from './trust.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/pii",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The canonical ChatPanel privacy engine \u2014 reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"./tool-rank.js": "./tool-rank.js",
|
|
13
13
|
"./tool-harness.js": "./tool-harness.js",
|
|
14
14
|
"./sanitize.js": "./sanitize.js",
|
|
15
|
-
"./net.js": "./net.js"
|
|
15
|
+
"./net.js": "./net.js",
|
|
16
|
+
"./detector-router.js": "./detector-router.js",
|
|
17
|
+
"./trust.js": "./trust.js"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"index.js",
|
|
@@ -23,6 +25,8 @@
|
|
|
23
25
|
"tool-harness.js",
|
|
24
26
|
"sanitize.js",
|
|
25
27
|
"net.js",
|
|
28
|
+
"detector-router.js",
|
|
29
|
+
"trust.js",
|
|
26
30
|
"LICENSE",
|
|
27
31
|
"README.md"
|
|
28
32
|
],
|
package/pii-detect.js
CHANGED
|
@@ -56,6 +56,8 @@ export function withTimeout(promise, ms, signal) {
|
|
|
56
56
|
// • HF bert-base-NER PER, ORG, LOC, MISC
|
|
57
57
|
// • Presidio PERSON, PHONE_NUMBER, EMAIL_ADDRESS, US_SSN…
|
|
58
58
|
// • ai4privacy / multilang GIVENNAME, SURNAME, STREET, ZIPCODE, TELEPHONENUM…
|
|
59
|
+
// • OpenAI privacy-filter private_person, private_address, private_email, private_phone,
|
|
60
|
+
// private_date, private_url, account_number, secret
|
|
59
61
|
//
|
|
60
62
|
// When adding a model, run one sentence through it and map every label it returns. An
|
|
61
63
|
// unrecognised label is a hole, and it is an invisible one.
|
|
@@ -63,7 +65,7 @@ function normType(t) {
|
|
|
63
65
|
const s = String(t || 'ENTITY').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'ENTITY';
|
|
64
66
|
const map = {
|
|
65
67
|
// People
|
|
66
|
-
PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON',
|
|
68
|
+
PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON', PRIVATEPERSON: 'PERSON',
|
|
67
69
|
GIVENNAME: 'PERSON', FIRSTNAME: 'PERSON', MIDDLENAME: 'PERSON',
|
|
68
70
|
SURNAME: 'PERSON', LASTNAME: 'PERSON', FULLNAME: 'PERSON',
|
|
69
71
|
// Organisations
|
|
@@ -74,10 +76,11 @@ function normType(t) {
|
|
|
74
76
|
CITY: 'LOCATION', STATE: 'LOCATION', COUNTY: 'LOCATION', COUNTRY: 'LOCATION',
|
|
75
77
|
STREET: 'ADDRESS', BUILDINGNUM: 'ADDRESS', BUILDINGNUMBER: 'ADDRESS',
|
|
76
78
|
ZIPCODE: 'ADDRESS', POSTCODE: 'ADDRESS', SECADDRESS: 'ADDRESS', ADDRESS: 'ADDRESS',
|
|
79
|
+
PRIVATEADDRESS: 'ADDRESS',
|
|
77
80
|
NORP: 'GROUP',
|
|
78
81
|
// Contact
|
|
79
|
-
EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
|
|
80
|
-
PHONE: 'PHONE', PHONENUMBER: 'PHONE', TELEPHONENUM: 'PHONE', PHONEIMEI: 'ID',
|
|
82
|
+
EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL', PRIVATEEMAIL: 'EMAIL',
|
|
83
|
+
PHONE: 'PHONE', PHONENUMBER: 'PHONE', TELEPHONENUM: 'PHONE', PRIVATEPHONE: 'PHONE', PHONEIMEI: 'ID',
|
|
81
84
|
// Numbers that identify a person. These are ALWAYS redacted (see ALWAYS_KEEP), which is
|
|
82
85
|
// the point of naming them rather than leaving them to the digit-count fallback.
|
|
83
86
|
SOCIALNUM: 'SSN', USSSN: 'SSN', SSN: 'SSN',
|
|
@@ -86,7 +89,9 @@ function normType(t) {
|
|
|
86
89
|
ACCOUNTNUM: 'ID', ACCOUNTNUMBER: 'ID', TAXNUM: 'ID', IDCARDNUM: 'ID',
|
|
87
90
|
DRIVERLICENSENUM: 'ID', PASSPORTNUM: 'ID', VEHICLEVRM: 'ID',
|
|
88
91
|
// A date of birth identifies; a plain date does not, and small models tag "today".
|
|
89
|
-
|
|
92
|
+
// privacy-filter's private_date is already the identifying kind (it tags "today" as O),
|
|
93
|
+
// and its private_url is a personal link — a profile, a shared doc — which identifies too.
|
|
94
|
+
DATEOFBIRTH: 'ID', DOB: 'ID', PRIVATEDATE: 'ID', PRIVATEURL: 'ID',
|
|
90
95
|
// Handles and secrets
|
|
91
96
|
USERNAME: 'ID', USERID: 'ID', IP: 'ID', IPADDRESS: 'ID', MAC: 'ID',
|
|
92
97
|
PASSWORD: 'SECRET', APIKEY: 'SECRET', SECRET: 'SECRET',
|
|
@@ -137,6 +142,47 @@ export function normalizeEntities(data, types) {
|
|
|
137
142
|
return out;
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
// CASE RECOVERY — the second pass for lowercase chat text.
|
|
146
|
+
//
|
|
147
|
+
// Every cased NER model is weak on a lowercase name: measured 2026-09-19, Privacy Filter
|
|
148
|
+
// returned nothing for `hi. I am suresh. last time I saw you in austin…` and 1.00 for the
|
|
149
|
+
// same sentence with `Suresh`; the multilingual NER tagged `Seattle` and missed `seattle`.
|
|
150
|
+
// People type chat in lowercase. So a detector runs twice when the draft is lowercase-heavy:
|
|
151
|
+
// once on the text, once on this re-cased copy, and the spans are unioned.
|
|
152
|
+
//
|
|
153
|
+
// The re-casing capitalises every word that is NOT a common word — the stop-list below —
|
|
154
|
+
// so `austin` becomes `Austin` but `am` does not become `Am` (capitalising everything
|
|
155
|
+
// dragged neighbours into spans: `Am Suresh`, `Priya Said`). Measured on the eight failing
|
|
156
|
+
// sentences: 2 of 3 misses recovered, zero false positives on neutral prose ("summarize
|
|
157
|
+
// this page about kubernetes" stays clean — `kubernetes` is capitalised and still nothing).
|
|
158
|
+
//
|
|
159
|
+
// LENGTH-PRESERVING BY CONSTRUCTION: only a single character is upper-cased, and only when
|
|
160
|
+
// its upper-case form is one character (`ß` → `SS` would not be). So an offset into the
|
|
161
|
+
// copy is the same offset into the original, and the caller takes the VALUE from the
|
|
162
|
+
// original — the redactor then sees `seattle`, not `Seattle`.
|
|
163
|
+
const RECASE_STOP = new Set(('a an the i am is are was were be been being to of in on at for and or but if so not no yes ok okay '
|
|
164
|
+
+ 'hi hello hey you your yours me my mine we us our ours they them their he him his she her it its this that these those '
|
|
165
|
+
+ 'what where when who whom why how which do does did done have has had having will would can could should may might must shall '
|
|
166
|
+
+ 'there here now then last next first time day days week month year today tomorrow yesterday see saw seen say said tell told '
|
|
167
|
+
+ 'go went gone come came get got give gave take took make made know knew think thought want need like just also very really '
|
|
168
|
+
+ 'with from into onto over under about after before between through during without within up down out off again still '
|
|
169
|
+
+ 'used use using live lived move moved work worked help please thanks thank sorry new old good bad more most some any all each '
|
|
170
|
+
+ 'because as than too so such only own same other another much many few both either neither every never always often').split(/\s+/));
|
|
171
|
+
|
|
172
|
+
export function recaseForDetection(text) {
|
|
173
|
+
const src = String(text || '');
|
|
174
|
+
let changed = false;
|
|
175
|
+
const out = src.replace(/(^|[^\p{L}\p{N}'’])(\p{Ll})(\p{L}*)/gu, (m, before, first, rest) => {
|
|
176
|
+
const word = first + rest;
|
|
177
|
+
if (RECASE_STOP.has(word)) return m;
|
|
178
|
+
const up = first.toUpperCase();
|
|
179
|
+
if (up.length !== 1 || up === first) return m;
|
|
180
|
+
changed = true;
|
|
181
|
+
return before + up + rest;
|
|
182
|
+
});
|
|
183
|
+
return { text: out, changed };
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
export function parseJsonLoose(s) {
|
|
141
187
|
if (!s) return null;
|
|
142
188
|
const a = String(s).indexOf('{');
|
package/trust.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Who is calling a ChatPanel localhost server — and how far to trust it.
|
|
2
|
+
//
|
|
3
|
+
// The bridge (4319) and the gateway (4320) bind to loopback, so every caller is on this
|
|
4
|
+
// machine or in this machine's browser. That is not one population, it is four, and the
|
|
5
|
+
// server has to tell them apart from the two things a request carries: its `Origin` header
|
|
6
|
+
// (set honestly by every browser, absent from a native process) and a per-install bearer
|
|
7
|
+
// token (a file only this user can read).
|
|
8
|
+
//
|
|
9
|
+
// token the per-install token was presented → a process running as the user (the
|
|
10
|
+
// desktop app, the CLI, `chatpanel-gateway mcp`) or a client the user PAIRED.
|
|
11
|
+
// pinned the Origin is one of ChatPanel's OWN published extension ids. A browser never
|
|
12
|
+
// lets one extension send another's origin, so this is the extension itself.
|
|
13
|
+
// unpaired a browser extension we do not recognise, or a page on localhost. Sandboxed —
|
|
14
|
+
// no filesystem, cannot read the token — but it CAN talk to this port, and it
|
|
15
|
+
// reads whatever the reply says. Treated as a stranger at the door: allowed to
|
|
16
|
+
// chat, never to reach the machine, until the user pairs it.
|
|
17
|
+
// local no Origin, no token: some native process. Fine for the open data plane, not
|
|
18
|
+
// for anything that spawns an agent or reconfigures a server.
|
|
19
|
+
// web any other web origin. Refused before this classification is ever consulted;
|
|
20
|
+
// it exists so the answer is never "undefined".
|
|
21
|
+
//
|
|
22
|
+
// The split matters because of what an agent can do. A capped tool policy ("read-only, no
|
|
23
|
+
// web tools") is a real defence when the REPLY goes somewhere safe (a paired phone) — but a
|
|
24
|
+
// caller that is itself the egress reads the reply, so for an unpaired caller the only cap
|
|
25
|
+
// that means anything is "no filesystem at all" (reach `device`). That is why `unpaired`
|
|
26
|
+
// maps to the conversational tier, not the read tier.
|
|
27
|
+
//
|
|
28
|
+
// Pure: no node APIs, no crypto — so the bridge can vendor it and the extension can show a
|
|
29
|
+
// user the same classification the server applied. The pairing-code store takes `now` and
|
|
30
|
+
// `random` injected for the same reason.
|
|
31
|
+
|
|
32
|
+
/** ChatPanel's published extension ids. A dev build has a different id — see EXTRA ids. */
|
|
33
|
+
export const CHATPANEL_EXTENSION_IDS = Object.freeze([
|
|
34
|
+
'icemacffhbgnfoofclgdbcdmnlkkklem', // Chrome Web Store
|
|
35
|
+
'jkmmbleapaognlonbnllpaoeibmfkjmp', // Microsoft Edge Add-ons
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const EXT_ID = /^[a-p]{32}$/;
|
|
39
|
+
|
|
40
|
+
/** Parse an operator's comma/space-separated extension-id list (an env var or config key). Invalid ids are dropped. */
|
|
41
|
+
export function parseExtensionIds(raw) {
|
|
42
|
+
return String(raw || '')
|
|
43
|
+
.split(/[\s,]+/)
|
|
44
|
+
.map((s) => s.trim().toLowerCase().replace(/^chrome-extension:\/\//, '').replace(/\/+$/, ''))
|
|
45
|
+
.filter((s) => EXT_ID.test(s));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What an Origin header says about the sender.
|
|
50
|
+
* 'none' no header — a native process
|
|
51
|
+
* 'pinned' chrome-extension://<one of ours or the operator's extra ids>
|
|
52
|
+
* 'extension' chrome-extension:// or moz-extension:// we do not recognise. Firefox origins are
|
|
53
|
+
* a per-profile UUID, so even ChatPanel's own Firefox build lands here — it pairs.
|
|
54
|
+
* 'localhost' http://localhost / 127.0.0.1 / [::1] — a dev page
|
|
55
|
+
* 'web' anything else
|
|
56
|
+
*/
|
|
57
|
+
export function classifyOrigin(origin, { extensionIds = [] } = {}) {
|
|
58
|
+
const o = String(origin || '').trim();
|
|
59
|
+
if (!o) return 'none';
|
|
60
|
+
const m = /^chrome-extension:\/\/([a-p]{32})\/?$/i.exec(o);
|
|
61
|
+
if (m) {
|
|
62
|
+
const id = m[1].toLowerCase();
|
|
63
|
+
if (CHATPANEL_EXTENSION_IDS.includes(id) || (extensionIds || []).includes(id)) return 'pinned';
|
|
64
|
+
return 'extension';
|
|
65
|
+
}
|
|
66
|
+
if (/^(chrome|moz)-extension:\/\//i.test(o)) return 'extension';
|
|
67
|
+
if (/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?\/?$/i.test(o)) return 'localhost';
|
|
68
|
+
return 'web';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The trust level of one request. `hasToken` is the server's own (timing-safe) token check;
|
|
73
|
+
* this function never sees the secret. Token beats everything: a paired Firefox extension or a
|
|
74
|
+
* dev build presents the token and is trusted exactly like the pinned one.
|
|
75
|
+
*/
|
|
76
|
+
export function callerTrust({ origin, hasToken = false, extensionIds = [] } = {}) {
|
|
77
|
+
if (hasToken) return 'token';
|
|
78
|
+
const kind = classifyOrigin(origin, { extensionIds });
|
|
79
|
+
if (kind === 'pinned') return 'pinned';
|
|
80
|
+
if (kind === 'extension' || kind === 'localhost') return 'unpaired';
|
|
81
|
+
if (kind === 'none') return 'local';
|
|
82
|
+
return 'web';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Trusted enough to run an agent with the user's configured permissions, reconfigure a server, or mint a pairing code. */
|
|
86
|
+
export function isPaired(trust) {
|
|
87
|
+
return trust === 'token' || trust === 'pinned';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Reach tiers, least to most. `device` is conversational only; `trusted` is machine-wide
|
|
91
|
+
// read with no egress; `any` is "no cap here — the configured permission mode applies".
|
|
92
|
+
const REACH_RANK = Object.freeze({ device: 0, trusted: 1, any: 2 });
|
|
93
|
+
|
|
94
|
+
/** The stricter of two reach tiers. An unknown tier is the strictest — fail closed. */
|
|
95
|
+
export function minReach(a, b) {
|
|
96
|
+
const ra = REACH_RANK[a] ?? 0;
|
|
97
|
+
const rb = REACH_RANK[b] ?? 0;
|
|
98
|
+
const pick = ra <= rb ? a : b;
|
|
99
|
+
return REACH_RANK[pick] === undefined ? 'device' : pick;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The reach ceiling a caller of this trust may run an agent under, or null for "no ceiling from trust". */
|
|
103
|
+
export function reachCeiling(trust) {
|
|
104
|
+
if (isPaired(trust)) return null;
|
|
105
|
+
return 'device';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Run options an unpaired caller must never choose. Each one reaches the machine directly:
|
|
109
|
+
// a working directory or worktree to read, credentials to hand the run, a permission mode
|
|
110
|
+
// to escalate, argv/env to smuggle a flag through. With reach `device` the agent has no
|
|
111
|
+
// filesystem anyway; stripping these is what makes that true before the engine is chosen.
|
|
112
|
+
const UNPAIRED_STRIP = Object.freeze([
|
|
113
|
+
'workingDir', 'workspace', 'grants', 'connectionId', 'permissionMode', 'extraArgs', 'env', 'runEnv', 'reach',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply a caller's ceiling to the run options a request asked for. A paired caller's options
|
|
118
|
+
* come back untouched. An unpaired caller's come back with the machine-reaching options
|
|
119
|
+
* removed and `reach` forced to the ceiling (never looser than what the body declared).
|
|
120
|
+
*/
|
|
121
|
+
export function capRunOptions(options, trust) {
|
|
122
|
+
const ceiling = reachCeiling(trust);
|
|
123
|
+
const src = options && typeof options === 'object' ? options : {};
|
|
124
|
+
if (!ceiling) return { ...src };
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const [k, v] of Object.entries(src)) if (!UNPAIRED_STRIP.includes(k)) out[k] = v;
|
|
127
|
+
out.reach = minReach(src.reach || ceiling, ceiling);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Engines that enforce a reach ceiling in their tool policy. A capped run may only use one of these. */
|
|
132
|
+
export const REACH_ENFORCING_ENGINES = Object.freeze(['claude']);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether an engine may run under a capped reach. Only an engine that turns `reach` into a
|
|
136
|
+
* tool policy can honour a cap; any other would silently run at its configured permission
|
|
137
|
+
* mode, which is the escalation the cap exists to prevent.
|
|
138
|
+
*/
|
|
139
|
+
export function engineHonoursReach(engine, reach) {
|
|
140
|
+
if (!reach || reach === 'any') return true;
|
|
141
|
+
return REACH_ENFORCING_ENGINES.includes(String(engine || ''));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------------------
|
|
145
|
+
// Pairing codes. How a client that CANNOT read the token file (a Firefox build, a dev build,
|
|
146
|
+
// a browser on the far side of a sandbox) gets one: the user asks the running server for a
|
|
147
|
+
// code (`chatpanel-gateway pair`, admin-authorized), types it into the client, and the client
|
|
148
|
+
// exchanges it for the token over loopback. The code is short-lived, single-use, and a
|
|
149
|
+
// handful of wrong guesses burn it — 6 digits at 5 attempts in 5 minutes is not brute-forceable
|
|
150
|
+
// from a page, and the page cannot ask for a new one.
|
|
151
|
+
|
|
152
|
+
export const PAIRING_TTL_MS = 5 * 60 * 1000;
|
|
153
|
+
export const PAIRING_MAX_ATTEMPTS = 5;
|
|
154
|
+
|
|
155
|
+
/** Render a 6-digit code as `123-456` for a human; the store compares digits only. */
|
|
156
|
+
export function formatPairingCode(code) {
|
|
157
|
+
const d = String(code || '').replace(/\D/g, '');
|
|
158
|
+
return d.length === 6 ? `${d.slice(0, 3)}-${d.slice(3)}` : d;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A single-slot pairing store. Creating a new code replaces the old one, so at most one code
|
|
163
|
+
* is live per server. `random()` must return a float in [0, 1) — `Math.random` is fine for a
|
|
164
|
+
* 6-digit code that lives five minutes behind an attempt cap; pass `crypto`-backed for taste.
|
|
165
|
+
*/
|
|
166
|
+
export function createPairingStore({ now = () => Date.now(), random = Math.random, ttlMs = PAIRING_TTL_MS, maxAttempts = PAIRING_MAX_ATTEMPTS } = {}) {
|
|
167
|
+
let live = null; // { code, expiresAt, attempts }
|
|
168
|
+
|
|
169
|
+
function issue() {
|
|
170
|
+
const code = String(Math.floor(random() * 1e6)).padStart(6, '0');
|
|
171
|
+
live = { code, expiresAt: now() + ttlMs, attempts: 0 };
|
|
172
|
+
return { code, display: formatPairingCode(code), expiresAt: live.expiresAt };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Try a code. Returns { ok: true } once and burns the code; { ok: false, reason } otherwise. */
|
|
176
|
+
function claim(input) {
|
|
177
|
+
if (!live) return { ok: false, reason: 'no pairing code is active — ask the server for one' };
|
|
178
|
+
if (now() > live.expiresAt) { live = null; return { ok: false, reason: 'pairing code expired — ask for a new one' }; }
|
|
179
|
+
live.attempts += 1;
|
|
180
|
+
const guess = String(input || '').replace(/\D/g, '');
|
|
181
|
+
if (guess.length === 6 && guess === live.code) { live = null; return { ok: true }; }
|
|
182
|
+
if (live.attempts >= maxAttempts) { live = null; return { ok: false, reason: 'too many wrong codes — ask for a new one' }; }
|
|
183
|
+
return { ok: false, reason: 'wrong pairing code' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function active() {
|
|
187
|
+
if (live && now() > live.expiresAt) live = null;
|
|
188
|
+
return live ? { expiresAt: live.expiresAt, attemptsLeft: maxAttempts - live.attempts } : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { issue, claim, active };
|
|
192
|
+
}
|