pi-privacy 0.1.1 → 0.2.1

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
@@ -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.1.1",
3
+ "version": "0.2.1",
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",
@@ -25,7 +25,9 @@
25
25
  "sev-snp"
26
26
  ],
27
27
  "pi": {
28
- "extensions": ["./extensions"]
28
+ "extensions": [
29
+ "./extensions"
30
+ ]
29
31
  },
30
32
  "exports": {
31
33
  ".": "./src/index.ts",
@@ -54,8 +56,12 @@
54
56
  "@earendil-works/pi-ai": ">=0.80.0"
55
57
  },
56
58
  "peerDependenciesMeta": {
57
- "@earendil-works/pi-coding-agent": { "optional": true },
58
- "@earendil-works/pi-ai": { "optional": true }
59
+ "@earendil-works/pi-coding-agent": {
60
+ "optional": true
61
+ },
62
+ "@earendil-works/pi-ai": {
63
+ "optional": true
64
+ }
59
65
  },
60
66
  "dependencies": {
61
67
  "undici": "^7.28.0"
@@ -115,7 +115,12 @@ export function interpretReport(modelId: string, nonce: string, raw: unknown): A
115
115
  const hardware: string[] = [];
116
116
  if (/nvidia|gpu/.test(blob)) hardware.push("NVIDIA");
117
117
  if (/intel|tdx/.test(blob)) hardware.push("Intel TDX");
118
- const nonceEchoed = blob.includes(nonce.toLowerCase());
118
+ // Freshness/anti-replay: our nonce must appear in the report. Require a non-trivial
119
+ // nonce — a real attestation nonce is 32 bytes (64 hex, see randomNonce). Guard
120
+ // against an empty/short nonce, since blob.includes("") is vacuously true and would
121
+ // score a missing nonce as "echoed" (matters when the nonce is externally supplied,
122
+ // e.g. a server-proxied report rather than one bound to our own randomNonce).
123
+ const nonceEchoed = nonce.length >= 16 && blob.includes(nonce.toLowerCase());
119
124
 
120
125
  return { model: modelId, nonce, signingAddress, nonceEchoed, hardware, raw };
121
126
  }
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
- ui?: { notify?: (message: string, level?: string) => void };
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 + publish posture for the current model.
173
+ // Recompute posture for the current model; cache the tier and publish the badge.
121
174
  const refreshPosture = async () => {
122
- if (!currentProviderId || !currentModelId || !onPosture) return;
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
- onPosture(result);
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. Scoped to the current provider so we never
139
- // mutate a payload bound for a different endpoint.
140
- pi.on("before_provider_request", (event) => {
141
- if (currentProviderId === "venice") return veniceRequestPatch(event?.payload);
142
- if (currentProviderId === "openrouter" && enforceOpenRouterZdr) {
143
- return openRouterZdrPatch(event?.payload);
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
- return undefined;
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
+ }