@edgeproc/privacy-core 0.2.0 → 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 +323 -111
- package/dist/detect/patterns.d.ts +1 -1
- package/dist/detect/patterns.js +1 -1
- package/dist/egressReceipt.d.ts +8 -1
- package/dist/egressReceipt.d.ts.map +1 -1
- package/dist/egressReceipt.js +8 -1
- package/dist/egressReceipt.js.map +1 -1
- package/dist/providers/nollm.d.ts +2 -1
- package/dist/providers/nollm.d.ts.map +1 -1
- package/dist/providers/nollm.js +2 -1
- package/dist/providers/nollm.js.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,60 +1,61 @@
|
|
|
1
1
|
# @edgeproc/privacy-core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
## TL;DR
|
|
10
|
-
|
|
11
|
-
- **What it is** — a TypeScript library that redacts sensitive text *before* it
|
|
12
|
-
leaves the browser, swapping each detected value for a typed placeholder
|
|
13
|
-
(`[CARD_1]`, `[NAME_2]`, `[AMOUNT_1]`), then restores the real values locally
|
|
14
|
-
when the model replies. You see and approve the exact text that will be sent.
|
|
15
|
-
- **Why it works** — you can't send raw text by accident. The only way to hand
|
|
16
|
-
text to the model runs through the redaction step, and you see and approve the
|
|
17
|
-
exact placeholders-only version before anything is sent. If code tries to skip
|
|
18
|
-
that and send raw text, it won't even build — the safety is baked into the code
|
|
19
|
-
itself, not a rule you have to remember. (The mechanism is in
|
|
20
|
-
[Under the hood](#under-the-hood-for-developers).)
|
|
21
|
-
- **Why it exists** — people paste bank statements, medical notes, and contracts
|
|
22
|
-
into chatbots every day, and all of it leaves the device in the clear. This
|
|
23
|
-
makes the boundary *visible and approvable* instead of invisible.
|
|
24
|
-
- **Status** — `0.1.0`, graduated from a proven spike. Deterministic detection
|
|
25
|
-
spine + reversible **in-memory** vault + Egress Guard + redact→rehydrate loop +
|
|
26
|
-
runnable demo. Unpublished (pre-registry). The encrypted vault and contextual
|
|
27
|
-
NER are deliberately **deferred** — see [Roadmap](#roadmap).
|
|
28
|
-
|
|
29
|
-
## Quickstart — one command, see the proof
|
|
3
|
+
You get a letter from a doctor, or a bank statement with a charge you don't
|
|
4
|
+
recognize, and you want an AI chatbot to explain it. So you paste the whole
|
|
5
|
+
thing in — your name, your phone number, your card number, all of it — because
|
|
6
|
+
that's the only way to get the answer.
|
|
30
7
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
8
|
+
This library is the other way around. It finds the private bits **on your own
|
|
9
|
+
device**, swaps each one for a label like `[CARD_1]`, and sends only the
|
|
10
|
+
labeled version to the model. When the answer comes back, it puts your real
|
|
11
|
+
values back in, locally. The model helps you. The model never sees you.
|
|
12
|
+
|
|
13
|
+
Here is the whole idea. Say you paste this:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
I want to dispute a charge on my card.
|
|
17
|
+
Account holder: Grace Hopper
|
|
18
|
+
Email: grace.hopper@example.com
|
|
19
|
+
Phone: (415) 555-0132
|
|
20
|
+
Card on file: 4242 4242 4242 4242
|
|
21
|
+
On 01/14/2026 I was charged $482.10 at Whole Foods and I was never there.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This is what actually leaves your device:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
I want to dispute a charge on my card.
|
|
28
|
+
Account holder: [NAME_1]
|
|
29
|
+
Email: [EMAIL_1]
|
|
30
|
+
Phone: [PHONE_1]
|
|
31
|
+
Card on file: [CARD_1]
|
|
32
|
+
On [DATE_1] I was charged [AMOUNT_1] at [MERCHANT_1] and I was never there.
|
|
34
33
|
```
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
The model writes its reply about `[NAME_1]` and `[CARD_1]`. Before you read it,
|
|
36
|
+
the library swaps the real values back in — on your machine, from a table the
|
|
37
|
+
model was never shown. You see a normal answer about Grace Hopper's card.
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
2. **The redaction set + the exact wire payload** — every real value replaced by
|
|
41
|
-
a placeholder. Open your browser's network tab to confirm: only this redacted
|
|
42
|
-
text would leave the device.
|
|
43
|
-
3. Click **Send**. By default it uses the offline echo provider (runs cold). To
|
|
44
|
-
call a real model, set `OPENROUTER_API_KEY` + `VITE_USE_OPENROUTER=1` (copy
|
|
45
|
-
`.env.example` → `examples/demo/.env`): the demo then routes through a
|
|
46
|
-
same-origin **dev proxy** that injects the key server-side, so it never
|
|
47
|
-
reaches the browser. Either way, **only placeholders cross the wire**.
|
|
48
|
-
4. **The rehydrated answer** — placeholders swapped back to real values locally,
|
|
49
|
-
values that never left your machine.
|
|
39
|
+
Three words you'll see below, defined once:
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
41
|
+
- **redact** — replace a private value with a label.
|
|
42
|
+
- **rehydrate** — put the real value back when the answer returns.
|
|
43
|
+
- **egress** — anything leaving your device for the network.
|
|
54
44
|
|
|
55
|
-
##
|
|
45
|
+
## Run it yourself in one minute
|
|
56
46
|
|
|
57
|
-
|
|
47
|
+
You need [Node](https://nodejs.org) 22.13 or newer. No API key, no account —
|
|
48
|
+
the loop runs against a built-in offline stand-in model.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
mkdir privacy-try && cd privacy-try
|
|
52
|
+
npm init -y && npm pkg set type=module
|
|
53
|
+
npm install @edgeproc/privacy-core
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Save this as `try-it.mjs`:
|
|
57
|
+
|
|
58
|
+
```js
|
|
58
59
|
import {
|
|
59
60
|
approve,
|
|
60
61
|
makeProvider,
|
|
@@ -63,62 +64,227 @@ import {
|
|
|
63
64
|
Vault,
|
|
64
65
|
} from "@edgeproc/privacy-core";
|
|
65
66
|
|
|
67
|
+
// The thing you'd normally paste straight into a chatbot.
|
|
68
|
+
const statement = `I want to dispute a charge on my card.
|
|
69
|
+
Account holder: Grace Hopper
|
|
70
|
+
Email: grace.hopper@example.com
|
|
71
|
+
Phone: (415) 555-0132
|
|
72
|
+
Card on file: 4242 4242 4242 4242
|
|
73
|
+
On 01/14/2026 I was charged $482.10 at Whole Foods and I was never there.`;
|
|
74
|
+
|
|
75
|
+
// The vault holds your real values. It lives in this process, on this machine.
|
|
66
76
|
const vault = new Vault();
|
|
67
|
-
const { provider } = makeProvider({ apiKey: process.env.OPENROUTER_API_KEY });
|
|
68
77
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
// 1. REDACT — swap every detected value for a label.
|
|
79
|
+
const pending = await redactForEgress(statement, vault);
|
|
80
|
+
console.log("--- what would leave your device ---");
|
|
81
|
+
console.log(pending.redactedText);
|
|
82
|
+
|
|
83
|
+
// 2. APPROVE — you read the above and say yes. Nothing is sendable until you do.
|
|
84
|
+
const payload = approve(pending, (entry) => console.log("\n[audit]", entry.kind));
|
|
85
|
+
|
|
86
|
+
// 3. SEND — with no API key, this uses a built-in offline stand-in model.
|
|
87
|
+
const { provider, label } = makeProvider();
|
|
88
|
+
const reply = await provider.complete(payload);
|
|
89
|
+
console.log(`\n--- reply from ${label} — it only ever saw the text above ---`);
|
|
90
|
+
console.log(reply.redactedText);
|
|
91
|
+
|
|
92
|
+
// 4. REHYDRATE — put your real values back, locally.
|
|
93
|
+
console.log("\n--- what you actually read ---");
|
|
94
|
+
console.log(rehydrate(reply.redactedText, vault, payload.vaultRef));
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then `node try-it.mjs`. You get:
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
```text
|
|
100
|
+
--- what would leave your device ---
|
|
101
|
+
I want to dispute a charge on my card.
|
|
102
|
+
Account holder: [NAME_1]
|
|
103
|
+
Email: [EMAIL_1]
|
|
104
|
+
Phone: [PHONE_1]
|
|
105
|
+
Card on file: [CARD_1]
|
|
106
|
+
On [DATE_1] I was charged [AMOUNT_1] at [MERCHANT_1] and I was never there.
|
|
107
|
+
|
|
108
|
+
[audit] approve
|
|
109
|
+
|
|
110
|
+
--- reply from NoLLMProvider (offline echo) — it only ever saw the text above ---
|
|
111
|
+
Summary (offline echo — no API key set):
|
|
112
|
+
I reviewed your statement. It referenced 7 redacted value(s): [NAME_1], [EMAIL_1], [PHONE_1], [CARD_1], [DATE_1], [AMOUNT_1], [MERCHANT_1].
|
|
113
|
+
The first flagged value, [NAME_1], is the one to check.
|
|
114
|
+
(Set OPENROUTER_API_KEY + VITE_USE_OPENROUTER=1 to call a real model via the dev proxy.)
|
|
115
|
+
|
|
116
|
+
--- what you actually read ---
|
|
117
|
+
Summary (offline echo — no API key set):
|
|
118
|
+
I reviewed your statement. It referenced 7 redacted value(s): Grace Hopper, grace.hopper@example.com, (415) 555-0132, 4242 4242 4242 4242, 01/14/2026, $482.10, Whole Foods.
|
|
119
|
+
The first flagged value, Grace Hopper, is the one to check.
|
|
120
|
+
(Set OPENROUTER_API_KEY + VITE_USE_OPENROUTER=1 to call a real model via the dev proxy.)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The last two blocks are the point: the model's reply mentions `[NAME_1]`, and
|
|
124
|
+
what you read says *Grace Hopper*. That substitution happened on your machine,
|
|
125
|
+
after the network call was over.
|
|
76
126
|
|
|
77
|
-
|
|
78
|
-
const response = await provider.complete(payload);
|
|
127
|
+
## See it in a browser
|
|
79
128
|
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
Clone this repo and run the demo, which does the same loop with a live preview
|
|
130
|
+
of exactly what will be sent:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pnpm install && pnpm demo # then open http://localhost:5173
|
|
82
134
|
```
|
|
83
135
|
|
|
84
|
-
|
|
136
|
+

|
|
137
|
+
|
|
138
|
+
Open your browser's network tab and watch the request. Only labels go out.
|
|
139
|
+
Nothing is sent until you click **Approve & send** — you approve the exact
|
|
140
|
+
outgoing text, not a promise about it.
|
|
141
|
+
|
|
142
|
+
To prove it without a browser window, `pnpm test:e2e` drives the same loop in
|
|
143
|
+
real Chromium, intercepts the outbound request, and fails if any real value
|
|
144
|
+
appears in it.
|
|
145
|
+
|
|
146
|
+
## What this does not protect you from
|
|
147
|
+
|
|
148
|
+
Read this before you trust it with anything that matters. Over-claiming privacy
|
|
149
|
+
is worse than claiming none.
|
|
150
|
+
|
|
151
|
+
- **It only hides what it recognizes.** Detection is a fixed ruleset — patterns
|
|
152
|
+
plus checksums (card numbers are Luhn-checked, IBANs mod-97-checked) plus small
|
|
153
|
+
dictionaries. It will miss an oddly formatted account number, an unusual name,
|
|
154
|
+
a kind of private data nobody wrote a rule for. The built-in name list is three
|
|
155
|
+
demo names; general name detection is not shipped. **That is why you review the
|
|
156
|
+
outgoing text before it goes.** A human catching a miss is the actual
|
|
157
|
+
guarantee; the tool's job is to make the text you're about to send visible and
|
|
158
|
+
approvable, not to promise it caught everything.
|
|
85
159
|
|
|
86
|
-
|
|
87
|
-
|
|
160
|
+
- **Hiding names is not the same as being anonymous.** Even with every name and
|
|
161
|
+
number stripped, the shape of the text can identify you: "$482.10, the word
|
|
162
|
+
*insurance*, early January" can point at one person with no identifier left in
|
|
163
|
+
it. This reduces direct leakage of identifiers. It does not make data
|
|
164
|
+
anonymous, and it will not stop someone deliberately trying to re-identify you.
|
|
88
165
|
|
|
89
|
-
- **
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
does **not** promise "we redact everything." The v0 guarantee is scoped to
|
|
93
|
-
**user-confirmed redaction**: the human reviews the proposed redaction set in
|
|
94
|
-
the preview and approves it before send. Misses are caught by a person, not
|
|
95
|
-
promised away by the tool. The guard's job is to make the boundary *visible and
|
|
96
|
-
approvable*, not to claim perfect detection.
|
|
166
|
+
- **The vault is in memory and clears on reload.** Your real values are held in
|
|
167
|
+
ordinary process/tab memory for the length of the session, by design in this
|
|
168
|
+
version. An encrypted stored vault is on the roadmap, not shipped.
|
|
97
169
|
|
|
98
|
-
- **
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
re-identification attack. Generalization modes (amount bucketing, date
|
|
104
|
-
coarsening) that would start to address this are explicitly **deferred**.
|
|
170
|
+
- **Browser key custody is same-origin, not hardware-backed.** Signing keys held
|
|
171
|
+
in a browser are protected by the browser's same-origin rules and nothing
|
|
172
|
+
stronger. Anything that can run code on your origin — a malicious extension, a
|
|
173
|
+
cross-site scripting bug, a compromised dependency — can reach them. There is
|
|
174
|
+
no secure element or OS keychain involved.
|
|
105
175
|
|
|
106
|
-
If those
|
|
176
|
+
If any of those limits are unacceptable for what you're doing, this is the wrong
|
|
177
|
+
tool. Say so out loud rather than working around it.
|
|
178
|
+
|
|
179
|
+
## Receipts: a record of what was allowed and what was blocked
|
|
180
|
+
|
|
181
|
+
Receipts are **opt-in**. Hand `guardedProvider` a governance context — a
|
|
182
|
+
provider name, your signing key, and an `onReceipt` callback — and from then on
|
|
183
|
+
every decision it makes is signed into a **receipt**: a small record saying
|
|
184
|
+
"text with this fingerprint was allowed (or refused) to go to this provider",
|
|
185
|
+
signed with your key. Refusals are recorded too, so a blocked send can't just
|
|
186
|
+
vanish. A receipt never contains the text itself, only a SHA-256 hash of it.
|
|
187
|
+
|
|
188
|
+
Omit that argument and you get exactly the same redaction and the same
|
|
189
|
+
fail-closed guard — just no receipt. Turn receipts on when you need to prove
|
|
190
|
+
afterwards what left the device; leave them off when you don't.
|
|
191
|
+
|
|
192
|
+
Signing and verifying live in `@edgeproc/avow`, so add it alongside:
|
|
193
|
+
`npm install @edgeproc/avow`.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { generateSeedHex, publicKeyHex, verifySignature } from "@edgeproc/avow";
|
|
197
|
+
import {
|
|
198
|
+
approve,
|
|
199
|
+
guardedProvider,
|
|
200
|
+
NoLLMProvider,
|
|
201
|
+
redactForEgress,
|
|
202
|
+
Vault,
|
|
203
|
+
} from "@edgeproc/privacy-core";
|
|
204
|
+
|
|
205
|
+
const seedHex = generateSeedHex(); // your signing key, generated on this device
|
|
206
|
+
const receipts = [];
|
|
207
|
+
|
|
208
|
+
const provider = guardedProvider(new NoLLMProvider(), {
|
|
209
|
+
provider: "offline-echo",
|
|
210
|
+
seedHex,
|
|
211
|
+
onReceipt: (r) => receipts.push(r),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const vault = new Vault();
|
|
215
|
+
const pending = await redactForEgress("Card on file: 4242 4242 4242 4242", vault);
|
|
216
|
+
await provider.complete(approve(pending, () => {}));
|
|
217
|
+
|
|
218
|
+
// A payload the guard never approved is refused — and the refusal is recorded too.
|
|
219
|
+
// (In TypeScript this call wouldn't even compile; plain JS shows the runtime half.)
|
|
220
|
+
await provider
|
|
221
|
+
.complete({ redactedText: "raw card 4242 4242 4242 4242", vaultRef: { id: "x" } })
|
|
222
|
+
.catch((err) => console.log("refused:", err.constructor.name));
|
|
223
|
+
|
|
224
|
+
for (const r of receipts) console.log(r.payload);
|
|
225
|
+
|
|
226
|
+
// Anyone holding your public key can check the receipts were not edited later.
|
|
227
|
+
await verifySignature(receipts[0], await publicKeyHex(seedHex));
|
|
228
|
+
console.log("\nsignature check: passed");
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```text
|
|
232
|
+
refused: UnapprovedPayloadError
|
|
233
|
+
{
|
|
234
|
+
action: 'llm.egress',
|
|
235
|
+
provider: 'offline-echo',
|
|
236
|
+
args_digest: 'sha256:091a3728dd5622843e14ffb925abcec1bd1cb5ad6461154ab0893b68f63d50b1',
|
|
237
|
+
decision: 'allow',
|
|
238
|
+
detector_version: '1'
|
|
239
|
+
}
|
|
240
|
+
{
|
|
241
|
+
action: 'llm.egress',
|
|
242
|
+
provider: 'offline-echo',
|
|
243
|
+
args_digest: 'sha256:6726c6222d515ab998abb62680724ca993157f40a9021ab0643d0e967f4b417b',
|
|
244
|
+
decision: 'deny',
|
|
245
|
+
detector_version: '1'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
signature check: passed
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**What a receipt is worth, precisely.** A signature proves a record has not been
|
|
252
|
+
altered *since it was signed*. It says nothing about whether the machine that
|
|
253
|
+
signed it was already compromised at the time. If an attacker controls the host,
|
|
254
|
+
they can make it sign a true-looking record of a decision you never wanted.
|
|
255
|
+
Receipts give you tamper-evidence after the fact, not a trustworthy host.
|
|
107
256
|
|
|
108
257
|
---
|
|
109
258
|
|
|
110
|
-
|
|
259
|
+
# For developers
|
|
260
|
+
|
|
261
|
+
## The part that makes leaking a compile error
|
|
262
|
+
|
|
263
|
+
The rule "don't send raw text to the model" isn't a convention here, and it isn't
|
|
264
|
+
a runtime check you could forget to call. Provider adapters accept **only** a
|
|
265
|
+
branded `RedactedPayload` type, and the only thing that mints one is the
|
|
266
|
+
redaction pipeline (`redactForEgress` → `approve`). A plain `string` is not
|
|
267
|
+
assignable to it, so this:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { NoLLMProvider } from "@edgeproc/privacy-core";
|
|
271
|
+
|
|
272
|
+
const provider = new NoLLMProvider();
|
|
273
|
+
await provider.complete("my card is 4242 4242 4242 4242");
|
|
274
|
+
```
|
|
111
275
|
|
|
112
|
-
|
|
113
|
-
convention. Provider adapters accept *only* a branded `RedactedPayload`, whose
|
|
114
|
-
sole constructor is the redaction pipeline (`redactForEgress` → `approve`). A
|
|
115
|
-
raw `string` is not assignable to that type, so **handing raw text to a provider
|
|
116
|
-
is a compile error** — not a discipline you have to remember. `pnpm build` (tsc)
|
|
117
|
-
proves it on every build.
|
|
276
|
+
fails before it ever runs:
|
|
118
277
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
278
|
+
```text
|
|
279
|
+
oops.ts(4,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'RedactedPayload'.
|
|
280
|
+
Type 'string' is not assignable to type 'Branded<"RedactedPayload">'.
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
TypeScript's brand disappears at runtime, so the same guarantee is enforced a
|
|
284
|
+
second way: every approved payload is registered by object identity, and
|
|
285
|
+
`assertApproved()` rejects a hand-built or spread-cloned look-alike before any
|
|
286
|
+
network call — that's the `UnapprovedPayloadError` in the receipts example
|
|
287
|
+
above. `pnpm build` re-proves the compile-time half on every build.
|
|
122
288
|
|
|
123
289
|
## Architecture — maps 1:1 to `src/`
|
|
124
290
|
|
|
@@ -126,10 +292,12 @@ hand-forged or spread-cloned look-alike before any network call is made.
|
|
|
126
292
|
src/
|
|
127
293
|
├── index.ts # public API barrel — the production surface, nothing else
|
|
128
294
|
├── types.ts # shared domain types (EntityType, Span, AuditEntry, …)
|
|
129
|
-
├── egress.ts # the
|
|
295
|
+
├── egress.ts # the boundary: branded RedactedPayload + LlmProvider + unsafeBypass
|
|
296
|
+
├── egressReceipt.ts # signs allow/deny decisions into receipts when governed (hash only)
|
|
297
|
+
├── errors.ts # typed fail-closed errors
|
|
130
298
|
├── redact.ts # redactForEgress — the ONLY legitimate payload constructor
|
|
131
299
|
├── rehydrate.ts # local restore of real values after the reply
|
|
132
|
-
├── vault.ts # Vault — reversible token<->value map (in-memory
|
|
300
|
+
├── vault.ts # Vault — reversible token<->value map (in-memory)
|
|
133
301
|
├── detect/
|
|
134
302
|
│ ├── detector.ts # detect() — merges patterns + dictionaries, drops overlaps
|
|
135
303
|
│ ├── patterns.ts # the deterministic ruleset (generic + finance packs)
|
|
@@ -142,46 +310,90 @@ src/
|
|
|
142
310
|
# NEVER from the main barrel
|
|
143
311
|
```
|
|
144
312
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
313
|
+
Detection is deliberately deterministic — same input, same spans, no model, no
|
|
314
|
+
download, testable offline. The contextual name-detection tier that would widen
|
|
315
|
+
recall is a separate, optional adapter and is not shipped (see
|
|
316
|
+
[Roadmap](#roadmap)). See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the
|
|
317
|
+
flow in detail.
|
|
149
318
|
|
|
150
|
-
|
|
319
|
+
## Public API
|
|
151
320
|
|
|
152
321
|
Everything `src/index.ts` exports, and nothing more:
|
|
153
322
|
|
|
154
323
|
| Export | Kind | Role |
|
|
155
324
|
|---|---|---|
|
|
156
325
|
| `detect` | fn | deterministic PII span detection |
|
|
157
|
-
| `redactForEgress` | fn | detect → vault-write → brand → a `PendingRedaction` proposal |
|
|
158
326
|
| `approve` | fn | explicit review step → mints the sendable payload (audit sink required) |
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `RedactedPayload` | type | the branded egress type |
|
|
327
|
+
| `assertApproved` | fn | runtime half of the guard — rejects unminted payloads |
|
|
328
|
+
| `guardedProvider` | fn | wrap a provider so the runtime guard runs at one chokepoint — plus receipts if given a governance context |
|
|
162
329
|
| `LlmProvider` | interface | provider contract — accepts only `RedactedPayload` |
|
|
330
|
+
| `PendingRedaction` | type | a redaction proposal awaiting explicit review — not yet sendable |
|
|
331
|
+
| `RedactedPayload` | type | the branded egress type |
|
|
332
|
+
| `unsafeBypass` | fn | the explicit, audited escape hatch |
|
|
333
|
+
| `buildEgressSubject` | fn | build the signed subject (hash of redacted text, decision, provider) |
|
|
334
|
+
| `contentHash` | fn | the canonical hash a verifier recomputes `args_digest` with |
|
|
335
|
+
| `DETECTOR_VERSION` | const | version tag for the detector ruleset, recorded in each receipt |
|
|
336
|
+
| `EgressDecision` | type | the guard's verdict on one egress attempt — allow or deny |
|
|
337
|
+
| `EgressGovernance` | interface | how a guarded provider seals its decisions (signer seed + receipt sink) |
|
|
338
|
+
| `EgressSubject` | type | the signed, hash-only record of one egress decision |
|
|
339
|
+
| `EgressSubjectInput` | interface | what the caller supplies to build an `EgressSubject` |
|
|
340
|
+
| `sealEgressReceipt` | fn | sign one egress decision into a receipt |
|
|
341
|
+
| `makeProvider` | fn | config-driven provider selector |
|
|
342
|
+
| `ProviderConfig` | interface | host-supplied config (API key, model, endpoint) for `makeProvider` |
|
|
343
|
+
| `SelectedProvider` | interface | the provider `makeProvider` picked, plus a label for the UI |
|
|
163
344
|
| `NoLLMProvider` | class | offline echo provider |
|
|
345
|
+
| `OpenRouterConfig` | interface | config for `OpenRouterProvider` (API key, model, endpoint) |
|
|
164
346
|
| `OpenRouterProvider` | class | OpenAI-compatible provider |
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
347
|
+
| `redactForEgress` | fn | detect → vault-write → brand → a `PendingRedaction` proposal |
|
|
348
|
+
| `rehydrate` | fn | restore real values locally from placeholders |
|
|
349
|
+
| `AuditEntry` | interface | one append-only audit record (redact / approve / unsafe-bypass) |
|
|
350
|
+
| `AuditSink` | type | the audit callback signature callers supply to `approve`/`unsafeBypass` |
|
|
351
|
+
| `EntityType` | type | the PII categories the detector recognizes (CARD, SSN, EMAIL, ...) |
|
|
352
|
+
| `RedactedResponse` | interface | a provider's reply, still in placeholder form until rehydrated |
|
|
353
|
+
| `Span` | interface | one detected PII span (type, value, start, end) |
|
|
354
|
+
| `VaultRef` | interface | opaque handle to a vault's token → value mappings |
|
|
355
|
+
| `Vault` | class | reversible token↔value map |
|
|
356
|
+
|
|
357
|
+
Typed fail-closed errors are exported too: `ForgedPayloadError`,
|
|
358
|
+
`PlaceholderCollisionError`, `ResidualValueError`, `UnapprovedPayloadError`,
|
|
359
|
+
`UnresolvedPlaceholderError`, `VaultMismatchError`.
|
|
168
360
|
|
|
169
361
|
The brand factory (`mintPendingRedaction`) and the `SYNTHETIC_STATEMENT` fixture
|
|
170
362
|
are intentionally **not** on the front door — a payload can be earned, not
|
|
171
363
|
forged, and a fixture is never shipped by accident.
|
|
172
364
|
|
|
173
|
-
##
|
|
365
|
+
## Calling a real model
|
|
366
|
+
|
|
367
|
+
The library is environment-agnostic: you pass your own key in, and it is never
|
|
368
|
+
read from the environment for you. The bundled demo keeps `OPENROUTER_API_KEY`
|
|
369
|
+
server-side — a same-origin dev proxy injects it, so it never reaches the browser
|
|
370
|
+
bundle. Copy `.env.example` → `examples/demo/.env`, set the key, add
|
|
371
|
+
`VITE_USE_OPENROUTER=1`, and re-run `pnpm demo`.
|
|
372
|
+
|
|
373
|
+
## Working on this repo
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
pnpm install
|
|
377
|
+
pnpm gate # lint → typecheck → unit tests with coverage → browser e2e → build
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`pnpm gate` is exactly what CI runs. See
|
|
381
|
+
[docs/QUICKSTART.md](docs/QUICKSTART.md) and
|
|
382
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
383
|
+
|
|
384
|
+
## Roadmap
|
|
174
385
|
|
|
175
386
|
Labeled so no one mistakes it for current scope:
|
|
176
387
|
|
|
177
|
-
- **Encrypted
|
|
178
|
-
|
|
179
|
-
- **Contextual
|
|
180
|
-
|
|
181
|
-
- **
|
|
182
|
-
|
|
183
|
-
- **More domain packs** (medical, legal, HR, identity) beyond the
|
|
184
|
-
|
|
388
|
+
- **Encrypted stored vault** (AES-GCM + passphrase KDF over IndexedDB). Today's
|
|
389
|
+
vault is in-memory and clears on reload.
|
|
390
|
+
- **Contextual name/place detection** to widen recall past the fixed ruleset;
|
|
391
|
+
would be an optional, off-by-default adapter.
|
|
392
|
+
- **Durable audit and receipt storage** — the sinks are wired today; persistence
|
|
393
|
+
is not.
|
|
394
|
+
- **More domain rule packs** (medical, legal, HR, identity) beyond the generic +
|
|
395
|
+
finance set, and generalization modes (amount bucketing, date coarsening) that
|
|
396
|
+
would start to address the anonymity limit above.
|
|
185
397
|
|
|
186
398
|
## License
|
|
187
399
|
|
|
@@ -16,7 +16,7 @@ export declare const MERCHANTS: readonly string[];
|
|
|
16
16
|
/**
|
|
17
17
|
* A tiny known-names dictionary. Real name coverage is the job of the deferred
|
|
18
18
|
* NER adapter (see Roadmap); this keeps the deterministic spine self-contained
|
|
19
|
-
* and the
|
|
19
|
+
* and the demo runnable with zero download.
|
|
20
20
|
*/
|
|
21
21
|
export declare const NAMES: readonly string[];
|
|
22
22
|
//# sourceMappingURL=patterns.d.ts.map
|
package/dist/detect/patterns.js
CHANGED
|
@@ -30,7 +30,7 @@ export const MERCHANTS = [
|
|
|
30
30
|
/**
|
|
31
31
|
* A tiny known-names dictionary. Real name coverage is the job of the deferred
|
|
32
32
|
* NER adapter (see Roadmap); this keeps the deterministic spine self-contained
|
|
33
|
-
* and the
|
|
33
|
+
* and the demo runnable with zero download.
|
|
34
34
|
*/
|
|
35
35
|
export const NAMES = [
|
|
36
36
|
"Ada Lovelace",
|
package/dist/egressReceipt.d.ts
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The Egress Guard already gates whether redacted text may leave the device
|
|
5
5
|
* (see {@link file://./egress.ts}). This module makes that decision *provable*:
|
|
6
|
-
*
|
|
6
|
+
* an allow or deny is expressed as a Writ-style governed effect — an
|
|
7
7
|
* `EgressSubject` — and signed into a tamper-evident receipt via the shared
|
|
8
8
|
* `@edgeproc/avow` envelope. A holder of the signer's public key can later
|
|
9
9
|
* verify exactly which egress decisions were taken, without ever seeing the
|
|
10
10
|
* content.
|
|
11
11
|
*
|
|
12
|
+
* SCOPE — sealing is opt-in: receipts are produced only for a
|
|
13
|
+
* {@link file://./egress.ts guardedProvider} that was given an
|
|
14
|
+
* {@link EgressGovernance} context. A caller who omits it still gets the full
|
|
15
|
+
* redaction and the same fail-closed guard, just no receipt. The guarantee
|
|
16
|
+
* below therefore reads: *once governed*, every allow and every deny at that
|
|
17
|
+
* chokepoint is sealed.
|
|
18
|
+
*
|
|
12
19
|
* INVARIANT — no plaintext in a receipt: the subject stores only
|
|
13
20
|
* `args_digest = sha256(canonical({ redactedText }))`, never the text itself,
|
|
14
21
|
* and never the raw PII the vault holds. A receipt records *that* a decision
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"egressReceipt.d.ts","sourceRoot":"","sources":["../src/egressReceipt.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"egressReceipt.d.ts","sourceRoot":"","sources":["../src/egressReceipt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAe,KAAK,aAAa,EAAe,MAAM,gBAAgB,CAAC;AAI9E,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,+EAA+E;AAC/E,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,MAAM,CAAC;AAE9C;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CACnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,0DAA0D;AAC1D,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,8EAA8E;AAC9E,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,aAAa,CAAC,CAQxB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,aAAa,CAAC,KAAK,IAAI,CAAC;IACpE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,4EAA4E;AAC5E,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,kBAAkB,EACzB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,aAAa,CAAC,aAAa,CAAC,CAAC,CAEvC"}
|
package/dist/egressReceipt.js
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The Egress Guard already gates whether redacted text may leave the device
|
|
5
5
|
* (see {@link file://./egress.ts}). This module makes that decision *provable*:
|
|
6
|
-
*
|
|
6
|
+
* an allow or deny is expressed as a Writ-style governed effect — an
|
|
7
7
|
* `EgressSubject` — and signed into a tamper-evident receipt via the shared
|
|
8
8
|
* `@edgeproc/avow` envelope. A holder of the signer's public key can later
|
|
9
9
|
* verify exactly which egress decisions were taken, without ever seeing the
|
|
10
10
|
* content.
|
|
11
11
|
*
|
|
12
|
+
* SCOPE — sealing is opt-in: receipts are produced only for a
|
|
13
|
+
* {@link file://./egress.ts guardedProvider} that was given an
|
|
14
|
+
* {@link EgressGovernance} context. A caller who omits it still gets the full
|
|
15
|
+
* redaction and the same fail-closed guard, just no receipt. The guarantee
|
|
16
|
+
* below therefore reads: *once governed*, every allow and every deny at that
|
|
17
|
+
* chokepoint is sealed.
|
|
18
|
+
*
|
|
12
19
|
* INVARIANT — no plaintext in a receipt: the subject stores only
|
|
13
20
|
* `args_digest = sha256(canonical({ redactedText }))`, never the text itself,
|
|
14
21
|
* and never the raw PII the vault holds. A receipt records *that* a decision
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"egressReceipt.js","sourceRoot":"","sources":["../src/egressReceipt.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"egressReceipt.js","sourceRoot":"","sources":["../src/egressReceipt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,WAAW,EAAsB,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE9E,6EAA6E;AAC7E,6EAA6E;AAC7E,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAmB7C;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAWpC,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,KAAyB;IAEzB,OAAO;QACL,MAAM,EAAE,YAAY;QACpB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,WAAW,EAAE,MAAM,WAAW,CAAC,EAAE,YAAY,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC;QACpE,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,gBAAgB,EAAE,KAAK,CAAC,eAAe,IAAI,gBAAgB;KAC5D,CAAC;AACJ,CAAC;AAkBD,4EAA4E;AAC5E,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,KAAyB,EACzB,OAAe;IAEf,OAAO,WAAW,CAAC,MAAM,kBAAkB,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,CAAC;AAC/D,CAAC"}
|
|
@@ -4,7 +4,8 @@ import type { RedactedResponse } from "../types.js";
|
|
|
4
4
|
* Offline fallback provider — runs with NO API key. It does NOT touch the
|
|
5
5
|
* network; it echoes a plausible analyst reply that references the same
|
|
6
6
|
* placeholders, so the rehydrate step has something to restore. This is what
|
|
7
|
-
* makes the demo runnable cold, with the
|
|
7
|
+
* makes the demo runnable cold, with the full redact → send → rehydrate loop
|
|
8
|
+
* intact.
|
|
8
9
|
*/
|
|
9
10
|
export declare class NoLLMProvider implements LlmProvider {
|
|
10
11
|
complete(payload: RedactedPayload): Promise<RedactedResponse>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nollm.d.ts","sourceRoot":"","sources":["../../src/providers/nollm.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD
|
|
1
|
+
{"version":3,"file":"nollm.d.ts","sourceRoot":"","sources":["../../src/providers/nollm.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD;;;;;;GAMG;AACH,qBAAa,aAAc,YAAW,WAAW;IACzC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqBlE;CACF"}
|
package/dist/providers/nollm.js
CHANGED
|
@@ -3,7 +3,8 @@ import { assertApproved, } from "../egress.js";
|
|
|
3
3
|
* Offline fallback provider — runs with NO API key. It does NOT touch the
|
|
4
4
|
* network; it echoes a plausible analyst reply that references the same
|
|
5
5
|
* placeholders, so the rehydrate step has something to restore. This is what
|
|
6
|
-
* makes the demo runnable cold, with the
|
|
6
|
+
* makes the demo runnable cold, with the full redact → send → rehydrate loop
|
|
7
|
+
* intact.
|
|
7
8
|
*/
|
|
8
9
|
export class NoLLMProvider {
|
|
9
10
|
async complete(payload) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nollm.js","sourceRoot":"","sources":["../../src/providers/nollm.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,GAGf,MAAM,cAAc,CAAC;AAGtB
|
|
1
|
+
{"version":3,"file":"nollm.js","sourceRoot":"","sources":["../../src/providers/nollm.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,GAGf,MAAM,cAAc,CAAC;AAGtB;;;;;;GAMG;AACH,MAAM,OAAO,aAAa;IACxB,KAAK,CAAC,QAAQ,CAAC,OAAwB;QACrC,iEAAiE;QACjE,cAAc,CAAC,OAAO,CAAC,CAAC;QACxB,MAAM,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC,CAAC,GAAG,CACtE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CACZ,CAAC;QACF,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,qBAAqB,CAAC;QACvE,MAAM,KAAK,GAAG;YACZ,0CAA0C;YAC1C,4CAA4C,MAAM,CAAC,MAAM,uBAAuB,IAAI,GAAG;SACxF,CAAC;QACF,yEAAyE;QACzE,0EAA0E;QAC1E,kDAAkD;QAClD,IAAI,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;YACd,KAAK,CAAC,IAAI,CAAC,4BAA4B,MAAM,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC;QAC5E,CAAC;QACD,KAAK,CAAC,IAAI,CACR,0FAA0F,CAC3F,CAAC;QACF,OAAO,EAAE,YAAY,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAC5C,CAAC;CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgeproc/privacy-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"description": "A browser-side privacy boundary for LLM calls: redact on-device, send only placeholders, rehydrate locally — enforced by a type-checked Egress Guard.",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"author": "Harish Seshadri",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/hseshadr/privacy-core.git"
|
|
14
|
+
},
|
|
11
15
|
"packageManager": "pnpm@11.5.0",
|
|
12
16
|
"engines": {
|
|
13
17
|
"node": ">=22.13"
|