@chatpanel/pii 0.7.3 → 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 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.7.3",
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/tool-harness.js CHANGED
@@ -54,6 +54,22 @@ export function restoreToolArgs(value, vault) {
54
54
  // Codex chose its own web search over ChatPanel's `find` on one turn and answered from
55
55
  // nothing. The extra sentence says which tools restore and which do not, so the choice is
56
56
  // no longer a coin toss.
57
+ /**
58
+ * The note for a turn with NO tools armed. The model still meets `[[LOCATION_1]]` in the
59
+ * conversation, and a coding agent told nothing about it stops to ask what the "unresolved
60
+ * placeholder" means — exactly what a redaction layer must never cause. So: treat it as a
61
+ * concrete value, write around it, echo it exactly; the real value is restored on the way
62
+ * back. Short, because there is nothing to call.
63
+ */
64
+ export function placeholderNote() {
65
+ return 'PRIVACY PLACEHOLDERS: some values in this conversation are tokens like [[PERSON_1]], '
66
+ + '[[LOCATION_1]], [[ORG_1]] that stand in for the user\'s real private data. Treat each one as '
67
+ + 'a CONCRETE, specific value you already have — not missing or unknown information. Do not ask '
68
+ + 'what it stands for, do not say it is unresolved, and do not refuse on privacy grounds. Reason '
69
+ + 'and write with the placeholder exactly as written; the real value is restored in your answer '
70
+ + 'automatically.';
71
+ }
72
+
57
73
  export function placeholderToolNote({ toolData = 'real', ownTools = false } = {}) {
58
74
  const intro =
59
75
  'PRIVACY PLACEHOLDERS: some values in this conversation are tokens like [[PERSON_1]], '
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
+ }