@chatpanel/pii 0.7.4 → 0.8.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/index.js +2 -0
- package/package.json +4 -2
- 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`).
|
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.8.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,8 @@
|
|
|
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
|
+
"./trust.js": "./trust.js"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"index.js",
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"tool-harness.js",
|
|
24
25
|
"sanitize.js",
|
|
25
26
|
"net.js",
|
|
27
|
+
"trust.js",
|
|
26
28
|
"LICENSE",
|
|
27
29
|
"README.md"
|
|
28
30
|
],
|
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
|
+
}
|