pi-privacy 0.1.1 → 0.2.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 +19 -0
- package/package.json +1 -1
- package/src/extension.ts +99 -12
- package/src/index.ts +10 -0
- package/src/pii/detect.ts +115 -0
package/README.md
CHANGED
|
@@ -49,6 +49,25 @@ Providers with no verifiable or default privacy channel (Together, DeepSeek, Min
|
|
|
49
49
|
Qwen, …) are intentionally left `standard` with **no badge** — anything else would
|
|
50
50
|
overclaim.
|
|
51
51
|
|
|
52
|
+
## Posture-aware PII gate
|
|
53
|
+
|
|
54
|
+
The second axis: not just *is the channel private*, but *should this data go down it*.
|
|
55
|
+
Before a request leaves for an **unverified** channel (anything below verified-TEE /
|
|
56
|
+
on-device), pi-privacy scans it for **structured PII** — emails, phones, SSNs, credit
|
|
57
|
+
cards (Luhn-checked), IPs — and, by default, **warns** you with the choice to send,
|
|
58
|
+
redact, or (implicitly) switch models. On a **verified-TEE** or **local** model it does
|
|
59
|
+
nothing — an attested enclave can't read your data and a loopback endpoint never sends
|
|
60
|
+
it. (ZDR is *not* exempt: a ZDR provider still *sees* the data, it just doesn't retain it.)
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
makePiPrivacyExtension({ piiPolicy: "warn" }); // "warn" (default) | "redact" | "off"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Honesty bound (the whole point):** this is *best-effort structured-PII detection*,
|
|
67
|
+
never a guarantee. It is local + deterministic — it never sends your data to a model to
|
|
68
|
+
detect PII (that would leak it) — so it catches patterns, not names/addresses/context.
|
|
69
|
+
It says so at the prompt. Treat it as a seatbelt, not a force field.
|
|
70
|
+
|
|
52
71
|
## How verification works
|
|
53
72
|
|
|
54
73
|
- **Tinfoil (SPKI pinning).** Pi's provider requests flow through a process-wide
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-privacy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Privacy posture + TEE attestation for Pi providers: cryptographically verified confidential-enclave (TEE) inference, enforced/labeled zero-data-retention (ZDR), and on-device detection — honestly graded so a verified guarantee never reads like a claimed one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/extension.ts
CHANGED
|
@@ -11,7 +11,39 @@ import { installAttestationDispatcher, dispatcherTransport } from "./attest/disp
|
|
|
11
11
|
import { PRIVACY_PROVIDERS, type PrivacyProvider } from "./providers/catalog.ts";
|
|
12
12
|
import { veniceRequestPatch, openRouterZdrPatch } from "./ext/patches.ts";
|
|
13
13
|
import { verifyModelPosture, type PostureResult } from "./posture/verify.ts";
|
|
14
|
-
import { TIERS } from "./posture/tiers.ts";
|
|
14
|
+
import { TIERS, type PrivacyTier } from "./posture/tiers.ts";
|
|
15
|
+
import { detectPii, redactPii, summarizePii } from "./pii/detect.ts";
|
|
16
|
+
|
|
17
|
+
// Verified-private tiers where PII needs no gate: an attested enclave can't read it,
|
|
18
|
+
// and a loopback endpoint never sends it. NOTE zdr-* is NOT here — a ZDR provider
|
|
19
|
+
// still SEES the data (it just doesn't retain it), so PII exposure remains.
|
|
20
|
+
function isVerifiedPrivate(tier: PrivacyTier | undefined): boolean {
|
|
21
|
+
return tier === "tee-verified" || tier === "local";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract the outbound message text for detection, and redact PII structurally in the
|
|
25
|
+
// payload's message content (string or content-part arrays).
|
|
26
|
+
function payloadText(payload: any): string {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(payload?.messages ?? payload ?? "");
|
|
29
|
+
} catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function redactPayloadPii(payload: any): any {
|
|
34
|
+
if (!payload || typeof payload !== "object" || !Array.isArray(payload.messages)) return payload;
|
|
35
|
+
const messages = payload.messages.map((m: any) => {
|
|
36
|
+
if (typeof m?.content === "string") return { ...m, content: redactPii(m.content) };
|
|
37
|
+
if (Array.isArray(m?.content)) {
|
|
38
|
+
return {
|
|
39
|
+
...m,
|
|
40
|
+
content: m.content.map((p: any) => (typeof p?.text === "string" ? { ...p, text: redactPii(p.text) } : p)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return m;
|
|
44
|
+
});
|
|
45
|
+
return { ...payload, messages };
|
|
46
|
+
}
|
|
15
47
|
|
|
16
48
|
// ── structural Pi surface (subset we use) ────────────────────────────────────
|
|
17
49
|
interface PiModel {
|
|
@@ -19,7 +51,11 @@ interface PiModel {
|
|
|
19
51
|
id?: string;
|
|
20
52
|
}
|
|
21
53
|
interface PiCtx {
|
|
22
|
-
|
|
54
|
+
hasUI?: boolean;
|
|
55
|
+
ui?: {
|
|
56
|
+
notify?: (message: string, level?: string) => void;
|
|
57
|
+
select?: (title: string, options: string[], opts?: unknown) => Promise<string | undefined>;
|
|
58
|
+
};
|
|
23
59
|
}
|
|
24
60
|
interface PiExtensionApiLike {
|
|
25
61
|
registerProvider?(name: string, config: unknown): void;
|
|
@@ -48,6 +84,16 @@ export interface PiPrivacyOptions {
|
|
|
48
84
|
// Bind Tinfoil attestation to the real provider connection via the dispatcher
|
|
49
85
|
// (default true when the dispatcher is installed). Falls back to httpsTransport.
|
|
50
86
|
useDispatcherTransport?: boolean;
|
|
87
|
+
// Override the tier resolution for providers pi-privacy doesn't know (e.g. a host's
|
|
88
|
+
// private account channel). Return a PrivacyTier to use it (drives the PII gate +
|
|
89
|
+
// badge), or undefined to fall back to pi-privacy's built-in verified posture.
|
|
90
|
+
resolveTier?: (provider: string, modelId: string) => Promise<PrivacyTier | undefined> | PrivacyTier | undefined;
|
|
91
|
+
// Posture-aware structured-PII policy on outbound requests. "warn" (default):
|
|
92
|
+
// interactively warn + offer redact before sending PII down an UNVERIFIED channel;
|
|
93
|
+
// "redact": silently mask; "off": disabled. Only acts below verified-TEE/local
|
|
94
|
+
// (an attested/on-device channel is safe), and only where a UI can prompt. Detection
|
|
95
|
+
// is best-effort structured PII (emails/phones/SSNs/cards/IPs) — NOT a guarantee.
|
|
96
|
+
piiPolicy?: "warn" | "redact" | "off";
|
|
51
97
|
}
|
|
52
98
|
|
|
53
99
|
// Config-only providers Pi doesn't ship: register these. Built-ins + custom skipped.
|
|
@@ -103,6 +149,8 @@ export function makePiPrivacyExtension(opts: PiPrivacyOptions = {}) {
|
|
|
103
149
|
enforceOpenRouterZdr = false,
|
|
104
150
|
onPosture,
|
|
105
151
|
useDispatcherTransport = true,
|
|
152
|
+
piiPolicy = "warn",
|
|
153
|
+
resolveTier,
|
|
106
154
|
} = opts;
|
|
107
155
|
|
|
108
156
|
return function piPrivacy(pi: PiExtensionApiLike): void {
|
|
@@ -116,16 +164,32 @@ export function makePiPrivacyExtension(opts: PiPrivacyOptions = {}) {
|
|
|
116
164
|
|
|
117
165
|
let currentProviderId: string | undefined;
|
|
118
166
|
let currentModelId: string | undefined;
|
|
167
|
+
// The VERIFIED tier of the current model (attestation result), cached for the PII
|
|
168
|
+
// gate. Undefined until computed → the gate treats "unknown" as not-verified (safe).
|
|
169
|
+
let currentTier: PrivacyTier | undefined;
|
|
170
|
+
// Session PII decision so we don't re-prompt every turn once the user has chosen.
|
|
171
|
+
let piiChoice: "ask" | "send" | "redact" = "ask";
|
|
119
172
|
|
|
120
|
-
// Recompute
|
|
173
|
+
// Recompute posture for the current model; cache the tier and publish the badge.
|
|
121
174
|
const refreshPosture = async () => {
|
|
122
|
-
if (!currentProviderId || !currentModelId
|
|
175
|
+
if (!currentProviderId || !currentModelId) return;
|
|
176
|
+
// A host-supplied resolver (e.g. a private account channel pi-privacy doesn't
|
|
177
|
+
// know) wins — otherwise use the built-in verified posture.
|
|
178
|
+
if (resolveTier) {
|
|
179
|
+
const t = await resolveTier(currentProviderId, currentModelId);
|
|
180
|
+
if (t !== undefined) {
|
|
181
|
+
currentTier = t;
|
|
182
|
+
onPosture?.({ providerId: currentProviderId, modelId: currentModelId, tier: t });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
123
186
|
const result = await verifyModelPosture(currentProviderId, currentModelId, {
|
|
124
187
|
apiKey: currentProviderId === "nearai" ? nearApiKey() : undefined,
|
|
125
188
|
zdrEnforced: currentProviderId === "openrouter" && enforceOpenRouterZdr,
|
|
126
189
|
transport: useDispatcherTransport && installDispatcher ? dispatcherTransport : undefined,
|
|
127
190
|
});
|
|
128
|
-
|
|
191
|
+
currentTier = result.tier;
|
|
192
|
+
onPosture?.(result);
|
|
129
193
|
};
|
|
130
194
|
|
|
131
195
|
pi.on("model_select", (event) => {
|
|
@@ -135,14 +199,37 @@ export function makePiPrivacyExtension(opts: PiPrivacyOptions = {}) {
|
|
|
135
199
|
void refreshPosture();
|
|
136
200
|
});
|
|
137
201
|
|
|
138
|
-
// Per-provider request patches
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (currentProviderId === "
|
|
143
|
-
|
|
202
|
+
// Per-provider request patches + the posture-aware PII gate.
|
|
203
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
204
|
+
let payload = event?.payload;
|
|
205
|
+
// Provider-specific patches first (scoped to the current provider).
|
|
206
|
+
if (currentProviderId === "venice") payload = veniceRequestPatch(payload);
|
|
207
|
+
else if (currentProviderId === "openrouter" && enforceOpenRouterZdr) payload = openRouterZdrPatch(payload);
|
|
208
|
+
|
|
209
|
+
// PII gate: only below a VERIFIED-private tier (TEE-verified/local are safe —
|
|
210
|
+
// the provider can't read the data), and only where we can actually prompt.
|
|
211
|
+
if (piiPolicy !== "off" && !isVerifiedPrivate(currentTier)) {
|
|
212
|
+
const hits = detectPii(payloadText(payload));
|
|
213
|
+
if (hits.length > 0) {
|
|
214
|
+
let action: "send" | "redact" =
|
|
215
|
+
piiChoice !== "ask" ? piiChoice : piiPolicy === "redact" ? "redact" : "send";
|
|
216
|
+
if (piiChoice === "ask" && piiPolicy === "warn" && ctx?.hasUI && typeof ctx.ui?.select === "function") {
|
|
217
|
+
const tierLabel = TIERS[currentTier ?? "standard"].label;
|
|
218
|
+
const choice = await ctx.ui.select(
|
|
219
|
+
`⚠ ${summarizePii(hits)} detected — sending to an unverified channel (${tierLabel}). ` +
|
|
220
|
+
`Best-effort structured-PII detection only, not a guarantee.`,
|
|
221
|
+
["Send as-is", "Redact PII", "Redact + remember for session", "Send + remember for session"],
|
|
222
|
+
);
|
|
223
|
+
if (choice === "Redact PII") action = "redact";
|
|
224
|
+
else if (choice === "Redact + remember for session") ((action = "redact"), (piiChoice = "redact"));
|
|
225
|
+
else if (choice === "Send + remember for session") ((action = "send"), (piiChoice = "send"));
|
|
226
|
+
else action = "send"; // "Send as-is" or cancelled
|
|
227
|
+
}
|
|
228
|
+
if (action === "redact") payload = redactPayloadPii(payload);
|
|
229
|
+
}
|
|
144
230
|
}
|
|
145
|
-
|
|
231
|
+
|
|
232
|
+
return payload === event?.payload ? undefined : payload;
|
|
146
233
|
});
|
|
147
234
|
|
|
148
235
|
if (typeof pi.registerCommand === "function") {
|
package/src/index.ts
CHANGED
|
@@ -66,3 +66,13 @@ export {
|
|
|
66
66
|
} from "./extension.ts";
|
|
67
67
|
|
|
68
68
|
export { veniceRequestPatch, openRouterZdrPatch } from "./ext/patches.ts";
|
|
69
|
+
|
|
70
|
+
// Local structured-PII detection (best-effort; emails/phones/SSNs/cards/IPs).
|
|
71
|
+
export {
|
|
72
|
+
type PiiType,
|
|
73
|
+
type PiiHit,
|
|
74
|
+
detectPii,
|
|
75
|
+
hasPii,
|
|
76
|
+
redactPii,
|
|
77
|
+
summarizePii,
|
|
78
|
+
} from "./pii/detect.ts";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Local, deterministic structured-PII detection. HONESTY BOUND (the whole reason
|
|
2
|
+
// this package exists): this catches STRUCTURED PII — emails, phones, SSNs, credit
|
|
3
|
+
// cards, IPs — via patterns only. It CANNOT catch names, addresses, or contextual
|
|
4
|
+
// PII, and it never uses a model (that would send the PII to detect it). So it is
|
|
5
|
+
// "best-effort structured-PII detection", never a guarantee. Callers must label it
|
|
6
|
+
// that way — the same verified-vs-claimed discipline as the posture engine.
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER: Record<PiiType, string> = {
|
|
9
|
+
email: "«email»",
|
|
10
|
+
phone: "«phone»",
|
|
11
|
+
ssn: "«ssn»",
|
|
12
|
+
"credit-card": "«card»",
|
|
13
|
+
ip: "«ip»",
|
|
14
|
+
iban: "«iban»",
|
|
15
|
+
mac: "«mac»",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PiiType = "email" | "phone" | "ssn" | "credit-card" | "ip" | "iban" | "mac";
|
|
19
|
+
|
|
20
|
+
// Order matters: run more-specific/structured patterns first so a card isn't also
|
|
21
|
+
// counted as a phone. Credit-card + phone are validated further below.
|
|
22
|
+
const PATTERNS: { type: PiiType; re: RegExp; validate?: (m: string) => boolean }[] = [
|
|
23
|
+
{ type: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
|
|
24
|
+
{ type: "ssn", re: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
25
|
+
// 13–19 digit runs (optionally space/dash grouped) that pass the Luhn check — this
|
|
26
|
+
// sharply cuts false positives vs "any long number".
|
|
27
|
+
{ type: "credit-card", re: /\b(?:\d[ -]?){13,19}\b/g, validate: luhn },
|
|
28
|
+
// IPv4 with each octet 0–255.
|
|
29
|
+
{ type: "ip", re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g },
|
|
30
|
+
// IBAN: 2-letter country + 2 check digits + 11–30 alphanumerics, mod-97 validated
|
|
31
|
+
// (cuts false positives on random alphanumeric runs sharply).
|
|
32
|
+
{ type: "iban", re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g, validate: ibanValid },
|
|
33
|
+
// MAC address (colon or dash separated).
|
|
34
|
+
{ type: "mac", re: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g },
|
|
35
|
+
// North-American / international-ish phone. Deliberately last + conservative to
|
|
36
|
+
// avoid eating IDs; requires a plausible separator or leading +.
|
|
37
|
+
{ type: "phone", re: /(?:\+\d{1,3}[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function luhn(s: string): boolean {
|
|
41
|
+
const digits = s.replace(/\D/g, "");
|
|
42
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
43
|
+
let sum = 0;
|
|
44
|
+
let alt = false;
|
|
45
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
46
|
+
let d = digits.charCodeAt(i) - 48;
|
|
47
|
+
if (alt) {
|
|
48
|
+
d *= 2;
|
|
49
|
+
if (d > 9) d -= 9;
|
|
50
|
+
}
|
|
51
|
+
sum += d;
|
|
52
|
+
alt = !alt;
|
|
53
|
+
}
|
|
54
|
+
return sum % 10 === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// IBAN mod-97 check (ISO 13616): move the first 4 chars to the end, map letters to
|
|
58
|
+
// numbers (A=10…Z=35), and verify the big-integer mod 97 === 1.
|
|
59
|
+
function ibanValid(s: string): boolean {
|
|
60
|
+
const iban = s.toUpperCase();
|
|
61
|
+
if (iban.length < 15 || iban.length > 34) return false;
|
|
62
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
63
|
+
let remainder = 0;
|
|
64
|
+
for (const ch of rearranged) {
|
|
65
|
+
const code = ch >= "A" && ch <= "Z" ? (ch.charCodeAt(0) - 55).toString() : ch;
|
|
66
|
+
for (const d of code) remainder = (remainder * 10 + (d.charCodeAt(0) - 48)) % 97;
|
|
67
|
+
}
|
|
68
|
+
return remainder === 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PiiHit {
|
|
72
|
+
type: PiiType;
|
|
73
|
+
count: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Detect structured PII in text. Returns the types present with counts (not the raw
|
|
77
|
+
// values — we don't want to log the PII we found).
|
|
78
|
+
export function detectPii(text: string): PiiHit[] {
|
|
79
|
+
if (!text) return [];
|
|
80
|
+
const counts = new Map<PiiType, number>();
|
|
81
|
+
for (const { type, re, validate } of PATTERNS) {
|
|
82
|
+
for (const m of text.matchAll(re)) {
|
|
83
|
+
if (validate && !validate(m[0])) continue;
|
|
84
|
+
counts.set(type, (counts.get(type) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...counts.entries()].map(([type, count]) => ({ type, count }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function hasPii(text: string): boolean {
|
|
91
|
+
return detectPii(text).length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Redact structured PII in text, replacing each match with a typed placeholder.
|
|
95
|
+
export function redactPii(text: string): string {
|
|
96
|
+
let out = text;
|
|
97
|
+
for (const { type, re, validate } of PATTERNS) {
|
|
98
|
+
out = out.replace(re, (m) => (validate && !validate(m) ? m : PLACEHOLDER[type]));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Human-readable summary of a hit list, e.g. "2 emails, 1 SSN".
|
|
104
|
+
export function summarizePii(hits: PiiHit[]): string {
|
|
105
|
+
const label: Record<PiiType, [string, string]> = {
|
|
106
|
+
email: ["email", "emails"],
|
|
107
|
+
phone: ["phone number", "phone numbers"],
|
|
108
|
+
ssn: ["SSN", "SSNs"],
|
|
109
|
+
"credit-card": ["card number", "card numbers"],
|
|
110
|
+
ip: ["IP address", "IP addresses"],
|
|
111
|
+
iban: ["IBAN", "IBANs"],
|
|
112
|
+
mac: ["MAC address", "MAC addresses"],
|
|
113
|
+
};
|
|
114
|
+
return hits.map((h) => `${h.count} ${label[h.type][h.count === 1 ? 0 : 1]}`).join(", ");
|
|
115
|
+
}
|