@vib795/agent-memory 0.4.0 → 0.5.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/HOWTO.md +8 -1
- package/README.md +84 -1
- package/package.json +1 -1
- package/src/cli.js +33 -7
- package/src/pii.js +274 -0
package/HOWTO.md
CHANGED
|
@@ -304,7 +304,7 @@ Everything is in one folder:
|
|
|
304
304
|
memory/notes/ your notes, as plain markdown files
|
|
305
305
|
```
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
Five things worth knowing:
|
|
308
308
|
|
|
309
309
|
**Nothing leaves your machine.** No server, no account, no telemetry, no background
|
|
310
310
|
process, nothing to get approved by IT. It is a small program that writes text files.
|
|
@@ -322,6 +322,13 @@ nothing to be locked into.
|
|
|
322
322
|
**Nothing is ever deleted.** Notes that get replaced or go stale move to an `archive`
|
|
323
323
|
folder rather than disappearing.
|
|
324
324
|
|
|
325
|
+
**Sharing is a separate decision.** If you export your notes to hand to a colleague,
|
|
326
|
+
personal details — phone numbers, card numbers, national IDs, email addresses — are
|
|
327
|
+
stripped on the way out, and the file tells you what was removed. A note that is
|
|
328
|
+
mostly contact details is left out of the file entirely rather than shipped with holes
|
|
329
|
+
in it. Names and addresses written in ordinary prose are not something a pattern can
|
|
330
|
+
find, so ask your assistant to read the file over before you send it.
|
|
331
|
+
|
|
325
332
|
---
|
|
326
333
|
|
|
327
334
|
## 11. When something is wrong
|
package/README.md
CHANGED
|
@@ -124,6 +124,89 @@ Two things are deliberately dropped on the way through:
|
|
|
124
124
|
Importing the same file twice updates rather than duplicates, so re-running after a
|
|
125
125
|
change is safe.
|
|
126
126
|
|
|
127
|
+
### Sharing an export with another person
|
|
128
|
+
|
|
129
|
+
Scope decides *whose* knowledge travels. It does not decide whether the file can be
|
|
130
|
+
handed to a person, and those are separate questions. Every export is scanned for
|
|
131
|
+
personal identifiers on the way out, and the file carries a receipt naming the rule
|
|
132
|
+
set that ran and what it took.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
$ agent-memory export --out carry.json
|
|
136
|
+
Exported 41 global-scope notes to carry.json.
|
|
137
|
+
rule set: pii/v1
|
|
138
|
+
redacted: 6 phone, 2 payment-card, 1 ssn
|
|
139
|
+
withheld: 1 note
|
|
140
|
+
oncall-roster — 8 identifiers (phone)
|
|
141
|
+
|
|
142
|
+
Structured identifiers only. Have an agent review this file for names and
|
|
143
|
+
identifying prose before sharing it outside your team.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Notes are **redacted in place**, so the knowledge survives and the identifier does
|
|
147
|
+
not. A note that is *mostly* identifiers — a roster, a contact sheet — is withheld
|
|
148
|
+
whole and named in the receipt, because a redacted skeleton is useless to the reader
|
|
149
|
+
and still re-identifiable from the structure that remains.
|
|
150
|
+
|
|
151
|
+
**Two layers, and the markers tell you which one acted.**
|
|
152
|
+
|
|
153
|
+
| | Runs at | Asks | Marker |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| `src/redact.js` | capture | could this authenticate as someone? | `<redacted:kind>` |
|
|
156
|
+
| `src/pii.js` | export | could this identify a person? | `[redacted:kind]` |
|
|
157
|
+
|
|
158
|
+
They disagree about your own email address on purpose. Capture keeps it, because it
|
|
159
|
+
is already public in every commit you have ever pushed. Export removes it, because it
|
|
160
|
+
is not yours to hand to someone else alongside a hundred notes about how a client
|
|
161
|
+
works.
|
|
162
|
+
|
|
163
|
+
**What the deterministic layer does not catch.** Names, job titles, postal addresses
|
|
164
|
+
and identifying prose have no shape to match on. They are language, and the honest
|
|
165
|
+
place to judge them is the model already in the conversation — so the receipt says so
|
|
166
|
+
instead of letting a pattern imply a completeness it does not have. Ask your agent to
|
|
167
|
+
review the export before it leaves your team.
|
|
168
|
+
|
|
169
|
+
## Evals and governance
|
|
170
|
+
|
|
171
|
+
Disclosure control is a claim until it is measured, so it is measured on every run of
|
|
172
|
+
`npm test`:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
[pii/v1] recall by kind
|
|
176
|
+
email 2/2
|
|
177
|
+
ip-address 2/2
|
|
178
|
+
payment-card 3/3
|
|
179
|
+
phone 4/4
|
|
180
|
+
ssn 2/2
|
|
181
|
+
[pii/v1] precision 15/15 on the negative corpus
|
|
182
|
+
[pii/v1] 3 known gaps pass through by design: person name, postal address, bare internal identifier
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The practices behind that number, which are the transferable part:
|
|
186
|
+
|
|
187
|
+
- **Both error directions are costed, not just the obvious one.** A miss discloses one
|
|
188
|
+
identifier. A false hit shreds a sentence, and a tool that mangles ordinary prose
|
|
189
|
+
gets switched off, which discloses everything. Recall is held at 1.0 because no
|
|
190
|
+
number of leaked identifiers is acceptable; precision is held at 1.0 against a
|
|
191
|
+
corpus built to tempt each detector with what it most resembles — a git SHA for a
|
|
192
|
+
card, a four-part version for an address, a date for a phone number.
|
|
193
|
+
- **Gaps are asserted as gaps.** `test/pii.eval.test.js` proves that names and
|
|
194
|
+
addresses pass through, so closing one has to be a deliberate act rather than a
|
|
195
|
+
silent change in what an old receipt meant.
|
|
196
|
+
- **The eval is checked for teeth.** Removing the Luhn validation drops precision to
|
|
197
|
+
14/15 and fails the build. A green suite that cannot go red is decoration.
|
|
198
|
+
- **Corpora are synthetic.** Reserved ranges only — RFC 5737 addresses, the card
|
|
199
|
+
networks' published test numbers — so the file that tests for leaks is not one.
|
|
200
|
+
- **Rule sets are versioned.** A receipt means something only against the rules that
|
|
201
|
+
produced it, so `pii/v1` is stamped into every export and changing a detector
|
|
202
|
+
changes the name.
|
|
203
|
+
|
|
204
|
+
For sharing outside your team: keep the receipt with the file, re-export rather than
|
|
205
|
+
hand-editing a file you already sent, and treat `--scope all` as a decision with your
|
|
206
|
+
name on it. What this is not: it is not encryption, access control, or a DLP product.
|
|
207
|
+
It is one command that refuses to hand over identifiers, and it says exactly what it
|
|
208
|
+
checked.
|
|
209
|
+
|
|
127
210
|
## Engagements
|
|
128
211
|
|
|
129
212
|
If you work for more than one client on one machine, knowledge from one of them is
|
|
@@ -389,7 +472,7 @@ is generated state, and the committed value is only a placeholder.
|
|
|
389
472
|
|
|
390
473
|
Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old.
|
|
391
474
|
|
|
392
|
-
Run `npm test` for the suite (
|
|
475
|
+
Run `npm test` for the suite (88 tests, no dependencies). CI runs it on Linux,
|
|
393
476
|
macOS and Windows across Node 22 and 24, and separately installs the packed tarball
|
|
394
477
|
and exercises it end to end on all three.
|
|
395
478
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vib795/agent-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-copilot",
|
package/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { setup as runSetup, unlinkSkills, danglingSkillLinks, SKILLS } from './s
|
|
|
17
17
|
import { detectTargets, installableTargets } from './targets.js';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
19
|
import { atomicWrite } from './atomic.js';
|
|
20
|
+
import { redactNodeForExport, buildReceipt, renderReceipt } from './pii.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* One process, one answer.
|
|
@@ -581,6 +582,10 @@ const EXPORT_SCOPES = ['global', 'repo', 'all'];
|
|
|
581
582
|
* `captured_sha` is deliberately dropped. It names a commit that does not exist
|
|
582
583
|
* anywhere else, and a staleness signal that cannot be checked is worse than none:
|
|
583
584
|
* it would either read as current forever or claim the history was rewritten.
|
|
585
|
+
*
|
|
586
|
+
* Personal identifiers are removed on the way out, and the export carries a receipt
|
|
587
|
+
* saying which rule set ran and what it took. Scope decides whose knowledge travels;
|
|
588
|
+
* the receipt decides whether it can be handed to a person.
|
|
584
589
|
*/
|
|
585
590
|
function cmdExport(opts) {
|
|
586
591
|
const scope = opts.scope === true ? 'global' : (opts.scope ?? 'global');
|
|
@@ -612,21 +617,42 @@ function cmdExport(opts) {
|
|
|
612
617
|
source: n.source ?? 'manual',
|
|
613
618
|
}));
|
|
614
619
|
|
|
615
|
-
|
|
620
|
+
// Export is the only path by which the store leaves this machine, which makes it
|
|
621
|
+
// the only place disclosure control belongs. It is a different question from the
|
|
622
|
+
// one capture-time redaction answers, and src/pii.js says why at length.
|
|
623
|
+
const results = nodes.map((n) => ({ id: n.id, ...redactNodeForExport(n) }));
|
|
624
|
+
const clean = results.filter((r) => !r.withheld).map((r) => r.node);
|
|
625
|
+
const receipt = buildReceipt(results, { scanned: nodes.length });
|
|
626
|
+
const payload = `${JSON.stringify({ nodes: clean, redaction: receipt }, null, 2)}\n`;
|
|
627
|
+
|
|
616
628
|
if (typeof opts.out === 'string') {
|
|
617
629
|
atomicWrite(opts.out, payload);
|
|
618
630
|
return {
|
|
619
631
|
ok: true,
|
|
620
|
-
exported:
|
|
632
|
+
exported: clean.length,
|
|
633
|
+
withheld: receipt.withheld.length,
|
|
621
634
|
scope,
|
|
622
635
|
out: opts.out,
|
|
623
|
-
|
|
636
|
+
redaction: receipt,
|
|
637
|
+
text: [
|
|
638
|
+
`Exported ${clean.length} ${scope}-scope note${clean.length === 1 ? '' : 's'} to ${opts.out}.`,
|
|
639
|
+
...renderReceipt(receipt),
|
|
640
|
+
].join('\n'),
|
|
624
641
|
};
|
|
625
642
|
}
|
|
626
|
-
// Straight to stdout so it pipes, with the count on stderr where
|
|
627
|
-
// corrupt the document.
|
|
628
|
-
process.stderr.write(`${
|
|
629
|
-
|
|
643
|
+
// Straight to stdout so it pipes, with the count and the receipt on stderr where
|
|
644
|
+
// they will not corrupt the document.
|
|
645
|
+
process.stderr.write(`${clean.length} ${scope}-scope notes\n`);
|
|
646
|
+
for (const line of renderReceipt(receipt)) process.stderr.write(`${line}\n`);
|
|
647
|
+
return {
|
|
648
|
+
ok: true,
|
|
649
|
+
exported: clean.length,
|
|
650
|
+
withheld: receipt.withheld.length,
|
|
651
|
+
scope,
|
|
652
|
+
nodes: clean,
|
|
653
|
+
redaction: receipt,
|
|
654
|
+
text: payload.trimEnd(),
|
|
655
|
+
};
|
|
630
656
|
}
|
|
631
657
|
|
|
632
658
|
/**
|
package/src/pii.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disclosure control for export.
|
|
3
|
+
*
|
|
4
|
+
* This is not the same job as src/redact.js and must not be folded into it. That one
|
|
5
|
+
* runs at capture and asks "could this authenticate as someone?" — its threat is a
|
|
6
|
+
* credential reaching disk, and it deliberately preserves the operator's own email
|
|
7
|
+
* because that address is already public in every commit they have ever pushed.
|
|
8
|
+
*
|
|
9
|
+
* This one runs at export and asks "could this identify a person?" — its threat is a
|
|
10
|
+
* human reading the file. The operator's own email is the clearest case of the
|
|
11
|
+
* difference: safe to store, not yours to hand to someone else along with a hundred
|
|
12
|
+
* notes about how their client works.
|
|
13
|
+
*
|
|
14
|
+
* Two layers, and only one of them lives here. Structured identifiers have shape, so
|
|
15
|
+
* they are matched and validated deterministically, which makes them testable and the
|
|
16
|
+
* result reproducible from a named rule set. Names, addresses and identifying prose
|
|
17
|
+
* have no shape; they are language, and the honest place to judge them is the model
|
|
18
|
+
* already sitting in the conversation. The receipt says so out loud rather than
|
|
19
|
+
* letting a regex imply a completeness it does not have.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Bump when a detector changes. A receipt is only reproducible against its rule set. */
|
|
23
|
+
export const RULE_SET = 'pii/v1';
|
|
24
|
+
|
|
25
|
+
/** Detections in one note before the note is withheld whole rather than redacted. */
|
|
26
|
+
export const DEFAULT_WITHHOLD_COUNT = 8;
|
|
27
|
+
|
|
28
|
+
/** Share of the text that may be replaced before redaction stops being meaningful. */
|
|
29
|
+
export const DEFAULT_WITHHOLD_RATIO = 0.3;
|
|
30
|
+
|
|
31
|
+
/** Luhn, the checksum every payment card carries. Turns a digit run into a card. */
|
|
32
|
+
function luhnValid(digits) {
|
|
33
|
+
let sum = 0;
|
|
34
|
+
let double = false;
|
|
35
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
36
|
+
let d = digits.charCodeAt(i) - 48;
|
|
37
|
+
if (d < 0 || d > 9) return false;
|
|
38
|
+
if (double) {
|
|
39
|
+
d *= 2;
|
|
40
|
+
if (d > 9) d -= 9;
|
|
41
|
+
}
|
|
42
|
+
sum += d;
|
|
43
|
+
double = !double;
|
|
44
|
+
}
|
|
45
|
+
return sum % 10 === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A US SSN has structure the Social Security Administration never issues.
|
|
50
|
+
* Checking it is what separates an SSN from any other nine digits with dashes.
|
|
51
|
+
*/
|
|
52
|
+
function ssnValid(area, group, serial) {
|
|
53
|
+
if (area === '000' || area === '666' || area[0] === '9') return false;
|
|
54
|
+
if (group === '00' || serial === '0000') return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function octetsValid(ip) {
|
|
59
|
+
const parts = ip.split('.');
|
|
60
|
+
return parts.length === 4 && parts.every((p) => p.length <= 3 && Number(p) <= 255);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Addresses that describe infrastructure rather than a person.
|
|
65
|
+
*
|
|
66
|
+
* The privacy interest in an IP is that a routable one, with a timestamp, can be put
|
|
67
|
+
* to a subscriber by whoever holds the logs. A pod CIDR cannot. Capture-time
|
|
68
|
+
* redaction already removes these ranges as infrastructure, so scrubbing them again
|
|
69
|
+
* here would only cost meaning in ordinary engineering notes.
|
|
70
|
+
*/
|
|
71
|
+
function reservedV4(ip) {
|
|
72
|
+
const [a, b] = ip.split('.').map(Number);
|
|
73
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return true;
|
|
74
|
+
if (a === 169 && b === 254) return true;
|
|
75
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
76
|
+
if (a === 192 && b === 168) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every detector is precision-first.
|
|
82
|
+
*
|
|
83
|
+
* A false negative leaks one identifier. A false positive shreds a sentence, and a
|
|
84
|
+
* tool that mangles ordinary prose gets switched off, which leaks everything. So each
|
|
85
|
+
* pattern here carries a checksum, a structural rule, or a separator requirement that
|
|
86
|
+
* ordinary technical writing does not satisfy. Bare digit runs are left to the
|
|
87
|
+
* semantic pass rather than guessed at, because a git SHA, an order number and a
|
|
88
|
+
* national ID are the same characters.
|
|
89
|
+
*
|
|
90
|
+
* Order is load-bearing. A grouped card number is also a valid phone shape, so cards
|
|
91
|
+
* are consumed before phones get a chance; reordering this array silently changes
|
|
92
|
+
* what the labels say happened.
|
|
93
|
+
*/
|
|
94
|
+
const DETECTORS = [
|
|
95
|
+
{
|
|
96
|
+
kind: 'email',
|
|
97
|
+
// No self-address exemption. That exemption is correct at capture and wrong here.
|
|
98
|
+
re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
kind: 'ssn',
|
|
102
|
+
// Only the delimited form. Nine adjacent digits are too many other things.
|
|
103
|
+
re: /(?<![\w-])(\d{3})-(\d{2})-(\d{4})(?![\w-])/g,
|
|
104
|
+
validate: (m) => ssnValid(m[1], m[2], m[3]),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
kind: 'payment-card',
|
|
108
|
+
re: /(?<![\w-])(?:\d[ -]?){12,18}\d(?![\w-])/g,
|
|
109
|
+
validate: (m) => {
|
|
110
|
+
const digits = m[0].replace(/\D/g, '');
|
|
111
|
+
return digits.length >= 13 && digits.length <= 19 && luhnValid(digits);
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
kind: 'phone',
|
|
116
|
+
// Requires separators or a country code, so `10.2.4` and `v1.2.3` cannot match,
|
|
117
|
+
// and a bare run of ten digits is left alone on purpose.
|
|
118
|
+
re: /(?<![\w-])(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)|\d{2,4})[\s.-]\d{2,4}[\s.-]\d{2,5}(?:[\s.-]\d{1,5})?(?![\w-])/g,
|
|
119
|
+
validate: (m) => {
|
|
120
|
+
const digits = m[0].replace(/\D/g, '');
|
|
121
|
+
if (digits.length < 10 || digits.length > 15) return false;
|
|
122
|
+
// One repeated digit is a placeholder or a masked field, never a number.
|
|
123
|
+
if (/^(\d)\1+$/.test(digits)) return false;
|
|
124
|
+
// A date reads as three separated numbers too. Reject anything shaped like one
|
|
125
|
+
// rather than deciding by punctuation, which varies by locale.
|
|
126
|
+
return !/^\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}$/.test(m[0].trim());
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
kind: 'ip-address',
|
|
131
|
+
// Capture-time redaction removes RFC1918 ranges as infrastructure. A routable
|
|
132
|
+
// address is the one that can identify a subscriber, and it survives to here.
|
|
133
|
+
re: /(?<![\w.])\d{1,3}(?:\.\d{1,3}){3}(?![\w.])/g,
|
|
134
|
+
validate: (m) => {
|
|
135
|
+
if (!octetsValid(m[0])) return false;
|
|
136
|
+
// Four single-digit groups is a four-part version string (6.0.1.2) far more
|
|
137
|
+
// often than an address. A deliberate recall gap, measured in the eval and
|
|
138
|
+
// covered by the semantic pass rather than paid for in shredded prose.
|
|
139
|
+
if (/^\d(\.\d){3}$/.test(m[0])) return false;
|
|
140
|
+
return !reservedV4(m[0]);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scan text, returning the redacted form and what fired.
|
|
147
|
+
*
|
|
148
|
+
* Fail-closed in the same sense as capture: a non-string is a programming error, and
|
|
149
|
+
* returning it unscanned would be the one outcome worse than throwing.
|
|
150
|
+
*/
|
|
151
|
+
export function scanText(text) {
|
|
152
|
+
if (typeof text !== 'string') {
|
|
153
|
+
throw new TypeError('scanText() requires a string; refusing to export unscanned content');
|
|
154
|
+
}
|
|
155
|
+
const counts = new Map();
|
|
156
|
+
let redactedChars = 0;
|
|
157
|
+
let out = text;
|
|
158
|
+
|
|
159
|
+
for (const { kind, re, validate } of DETECTORS) {
|
|
160
|
+
out = out.replace(re, (...args) => {
|
|
161
|
+
const match = args.slice(0, -2);
|
|
162
|
+
const whole = match[0];
|
|
163
|
+
if (validate && !validate(match)) return whole;
|
|
164
|
+
counts.set(kind, (counts.get(kind) || 0) + 1);
|
|
165
|
+
redactedChars += whole.length;
|
|
166
|
+
return `[redacted:${kind}]`;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
text: out,
|
|
172
|
+
findings: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
|
|
173
|
+
redactedChars,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clean one node for export, or decide it cannot be cleaned.
|
|
179
|
+
*
|
|
180
|
+
* Withholding is not a fallback for a broken redactor; it answers a different
|
|
181
|
+
* question. A note that is mostly identifiers is a contact record rather than
|
|
182
|
+
* knowledge, and its redacted skeleton is both useless to the reader and still
|
|
183
|
+
* re-identifiable from the structure that remains. Better to name it in the receipt
|
|
184
|
+
* and leave it behind.
|
|
185
|
+
*/
|
|
186
|
+
export function redactNodeForExport(node, opts = {}) {
|
|
187
|
+
const withholdCount = opts.withholdCount ?? DEFAULT_WITHHOLD_COUNT;
|
|
188
|
+
const withholdRatio = opts.withholdRatio ?? DEFAULT_WITHHOLD_RATIO;
|
|
189
|
+
|
|
190
|
+
const clean = { ...node };
|
|
191
|
+
const counts = new Map();
|
|
192
|
+
let redactedChars = 0;
|
|
193
|
+
let textChars = 0;
|
|
194
|
+
|
|
195
|
+
for (const field of ['title', 'body']) {
|
|
196
|
+
if (typeof clean[field] !== 'string') continue;
|
|
197
|
+
textChars += clean[field].length;
|
|
198
|
+
const r = scanText(clean[field]);
|
|
199
|
+
clean[field] = r.text;
|
|
200
|
+
redactedChars += r.redactedChars;
|
|
201
|
+
for (const f of r.findings) counts.set(f.kind, (counts.get(f.kind) || 0) + f.count);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const findings = [...counts.entries()].map(([kind, count]) => ({ kind, count }));
|
|
205
|
+
const total = findings.reduce((n, f) => n + f.count, 0);
|
|
206
|
+
const ratio = textChars ? redactedChars / textChars : 0;
|
|
207
|
+
|
|
208
|
+
if (total >= withholdCount || ratio > withholdRatio) {
|
|
209
|
+
return {
|
|
210
|
+
node: null,
|
|
211
|
+
findings,
|
|
212
|
+
withheld: true,
|
|
213
|
+
reason:
|
|
214
|
+
total >= withholdCount ? `${total} identifiers` : `${Math.round(ratio * 100)}% of the text`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { node: clean, findings, withheld: false, reason: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fold per-note results into the receipt that ships with the export.
|
|
222
|
+
*
|
|
223
|
+
* The receipt is the governance artifact. An export without one is a file of unknown
|
|
224
|
+
* provenance, and the reason to write it down is that "we redact PII" is a claim
|
|
225
|
+
* while "pii/v1 removed four emails and withheld two notes" is evidence.
|
|
226
|
+
*/
|
|
227
|
+
export function buildReceipt(results, { scanned }) {
|
|
228
|
+
const counts = new Map();
|
|
229
|
+
const withheld = [];
|
|
230
|
+
for (const r of results) {
|
|
231
|
+
if (r.withheld) {
|
|
232
|
+
// Counted under `withheld`, not under `redacted`. Nothing in a withheld note
|
|
233
|
+
// was redacted, because the note is not in the file; folding its findings into
|
|
234
|
+
// the redaction totals would overstate what the reader is holding, and a
|
|
235
|
+
// governance artifact that overstates is worth less than none.
|
|
236
|
+
withheld.push({ id: r.id, reason: r.reason, kinds: r.findings.map((f) => f.kind) });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
for (const f of r.findings) counts.set(f.kind, (counts.get(f.kind) || 0) + f.count);
|
|
240
|
+
}
|
|
241
|
+
const redactions = [...counts.entries()]
|
|
242
|
+
.map(([kind, count]) => ({ kind, count }))
|
|
243
|
+
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
ruleSet: RULE_SET,
|
|
247
|
+
scanned,
|
|
248
|
+
exported: scanned - withheld.length,
|
|
249
|
+
redactions,
|
|
250
|
+
withheld,
|
|
251
|
+
// Stated in the artifact rather than only in the docs, because the person who
|
|
252
|
+
// reads the receipt is the person deciding whether to send the file.
|
|
253
|
+
semanticReviewRequired:
|
|
254
|
+
'Structured identifiers only. Names, job titles, addresses and identifying prose are not detected here; have an agent review the export before sharing it outside your team.',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The receipt as lines, for the humans who will read it in a terminal. */
|
|
259
|
+
export function renderReceipt(receipt) {
|
|
260
|
+
const out = [`rule set: ${receipt.ruleSet}`];
|
|
261
|
+
if (receipt.redactions.length) {
|
|
262
|
+
out.push(`redacted: ${receipt.redactions.map((r) => `${r.count} ${r.kind}`).join(', ')}`);
|
|
263
|
+
} else {
|
|
264
|
+
out.push('redacted: nothing matched');
|
|
265
|
+
}
|
|
266
|
+
if (receipt.withheld.length) {
|
|
267
|
+
out.push(`withheld: ${receipt.withheld.length} note${receipt.withheld.length === 1 ? '' : 's'}`);
|
|
268
|
+
for (const w of receipt.withheld) out.push(` ${w.id} — ${w.reason} (${w.kinds.join(', ')})`);
|
|
269
|
+
}
|
|
270
|
+
out.push('');
|
|
271
|
+
out.push('Structured identifiers only. Have an agent review this file for names and');
|
|
272
|
+
out.push('identifying prose before sharing it outside your team.');
|
|
273
|
+
return out;
|
|
274
|
+
}
|