claude-memory-lint 0.1.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.
@@ -0,0 +1,511 @@
1
+ 'use strict';
2
+ // PII detector: personal data and secrets that should never sit in a
3
+ // long-lived agent memory file, checked against the ONE list this package
4
+ // ships — `lib/pii-categories.json`.
5
+ //
6
+ // -- THE LIST LIVES IN ONE PLACE ---------------------------------------------
7
+ //
8
+ // This file declares no category. No PII pattern, no carve-out, no clinical
9
+ // word appears here — all of it comes from `pii-categories.json`, compiled by
10
+ // `compileCategories`. A category in two places becomes two categories that
11
+ // drift, and the one that drifts is always the one the gate reads. If a
12
+ // pattern is wrong, the fix is in the JSON, and the test breaks with it.
13
+ //
14
+ // -- THE THREE `gate` VALUES --------------------------------------------------
15
+ //
16
+ // block -> an occurrence exits 1 (nationalId, bankAccount, health, credential)
17
+ // warn -> an occurrence is counted and reported, never exits 1 (minor,
18
+ // specialCategoryOther)
19
+ // doctrine -> not detectable by form; `patterns: []`, never enters code
20
+ // (falseAnonymity). The detector REFUSES a category that
21
+ // declares `doctrine` and still carries a pattern — that would
22
+ // be doctrine becoming a gate through the back door.
23
+ //
24
+ // -- MENTION IS NOT USE -------------------------------------------------------
25
+ //
26
+ // The category list's `globalExemptions` (backtick span, fenced block) are
27
+ // NOT reimplemented here: they are exactly `maskCode` (lib/mask-code.js),
28
+ // shared with the other detectors in this package. A note that teaches this
29
+ // list needs to be able to show the phrase it catches without tripping the
30
+ // gate on itself.
31
+ //
32
+ // -- DECLARED LOW RECALL ------------------------------------------------------
33
+ //
34
+ // `health` ships with `precision: closed-vocabulary` and a declared
35
+ // `coverage: declared-low` right in the category record. It is the pattern
36
+ // most likely to miss a real case: prose describing a condition in the
37
+ // person's own words will not match a closed vocabulary, and tightening the
38
+ // regex is not the fix — declaring the gap is. The test suite reflects this:
39
+ // it proves what the list catches, and separately plants a case it does NOT
40
+ // catch, instead of implying coverage that doesn't exist.
41
+ //
42
+ // -- WHAT THIS DETECTOR DOES NOT DO -------------------------------------------
43
+ //
44
+ // It does not fix or delete anything. A hit in someone's own memory archive
45
+ // is a FINDING TO REPORT, not something this tool edits on its own — changing
46
+ // someone else's recorded material is a decision for the person who owns it.
47
+ //
48
+ // Exit: 0 nothing blocking | 1 a `block`-gate category matched | 2 the
49
+ // category list is unreadable (not checked — a detector with no list would
50
+ // otherwise go green over an archive it never actually read)
51
+
52
+ const fs = require('fs');
53
+ const path = require('path');
54
+ const { readCollection, INDEX_NAME } = require('../collector');
55
+ const { locateArchive } = require('../locate-archive');
56
+ const { maskCode } = require('../mask-code');
57
+
58
+ const DEFAULT_CATEGORIES_FILE = path.join(__dirname, '..', 'pii-categories.json');
59
+ const GATES = new Set(['block', 'warn', 'doctrine']);
60
+
61
+ // Checksum validators a pattern can opt into via `"checksum": "<name>"` in
62
+ // the JSON. This is generic math, not PII data — the LIST of which pattern
63
+ // uses which checksum still lives entirely in pii-categories.json, per the
64
+ // single-source-of-truth rule at the top of this file.
65
+ const CHECKSUMS = {
66
+ // Standard Luhn (mod-10) check, used by every major card scheme. A random
67
+ // 13-19 digit platform ID (an ad account, a page ID) passes this by
68
+ // coincidence roughly 1 time in 10 — far better than the 10-in-10 false
69
+ // positive rate of "any run of 13-19 digits".
70
+ luhn(digits) {
71
+ let sum = 0;
72
+ let alt = false;
73
+ for (let i = digits.length - 1; i >= 0; i--) {
74
+ let d = digits.charCodeAt(i) - 48;
75
+ if (d < 0 || d > 9) return false;
76
+ if (alt) {
77
+ d *= 2;
78
+ if (d > 9) d -= 9;
79
+ }
80
+ sum += d;
81
+ alt = !alt;
82
+ }
83
+ return digits.length > 0 && sum % 10 === 0;
84
+ },
85
+ };
86
+
87
+ // --- pure: the list -> compiled categories ----------------------------------
88
+ // Takes the ALREADY-PARSED object (disk reading and the CLI are the only
89
+ // callers that touch fs). Throws with the reason if the list is broken — a
90
+ // broken list must not silently become a green gate.
91
+ function compileCategories(doc) {
92
+ if (!doc || !Array.isArray(doc.categories) || doc.categories.length === 0) {
93
+ throw new Error('category list has no non-empty `categories` array');
94
+ }
95
+ const compilePatterns = (list, catId) =>
96
+ (Array.isArray(list) ? list : []).map((p) => {
97
+ try {
98
+ if (p.checksum && !CHECKSUMS[p.checksum]) {
99
+ throw new Error(`unknown checksum "${p.checksum}"`);
100
+ }
101
+ return {
102
+ id: p.id,
103
+ description: p.description || null,
104
+ re: new RegExp(p.regex, 'i'),
105
+ checksum: p.checksum || null,
106
+ };
107
+ } catch (e) {
108
+ throw new Error(`category ${catId}: pattern "${p.id}" does not compile — ${e.message}`);
109
+ }
110
+ });
111
+ const compileExemptions = (list, catId) =>
112
+ (Array.isArray(list) ? list : []).map((e) => {
113
+ try {
114
+ return {
115
+ id: e.id,
116
+ description: e.description || null,
117
+ re: new RegExp(e.regex, 'i'),
118
+ // `appliesTo`: pattern ids this exemption is allowed to neutralize.
119
+ // Absent/empty means "every pattern in this category" (the old,
120
+ // coarser default) — set it to scope a carve-out meant for one
121
+ // pattern (e.g. "last four digits" only makes sense for a card
122
+ // number, never for a full IBAN in the same category).
123
+ appliesTo: Array.isArray(e.appliesTo) ? e.appliesTo : null,
124
+ // `nullifiedBy`: pattern ids that, if any of them ALSO matches in
125
+ // the same clause as the match this exemption would otherwise
126
+ // excuse, void the exemption. This is how an exemption whose
127
+ // own wording names the category ("special category", "what not
128
+ // to store") stops laundering a real disclosure sitting in the
129
+ // same clause — including when the disclosure IS the very pattern
130
+ // the exemption would apply to.
131
+ nullifiedBy: Array.isArray(e.nullifiedBy) ? e.nullifiedBy : null,
132
+ };
133
+ } catch (err) {
134
+ throw new Error(`category ${catId}: exemption "${e.id}" does not compile — ${err.message}`);
135
+ }
136
+ });
137
+ const globalExemptions = compileExemptions(doc.globalExemptions, '(global)');
138
+ const categories = doc.categories.map((c) => {
139
+ if (!c.id || !c.name) throw new Error(`category with no id/name: ${JSON.stringify(c).slice(0, 80)}`);
140
+ if (!GATES.has(c.gate)) throw new Error(`category ${c.id}: gate "${c.gate}" is not one of block|warn|doctrine`);
141
+ const patterns = Array.isArray(c.patterns) ? c.patterns : [];
142
+ if (c.gate === 'doctrine' && patterns.length) {
143
+ throw new Error(`category ${c.id}: gate "doctrine" carries ${patterns.length} pattern(s) — doctrine does not become a gate by form`);
144
+ }
145
+ return {
146
+ id: c.id,
147
+ name: c.name,
148
+ gate: c.gate,
149
+ precision: c.precision || null,
150
+ coverage: c.coverage || null,
151
+ // Why THIS category sits at THIS gate, so the report can say it
152
+ // instead of leaving a reader to guess why `minor` is `warn` and
153
+ // `health` is `block` when both are Art. 9 categories.
154
+ gateRationale: c.gateRationale || null,
155
+ carveOut: c.carveOut || null,
156
+ example: c.example || null,
157
+ // Whether pattern matching runs against the line AFTER `maskCode`
158
+ // blanks fenced/backtick spans (true, the old universal behavior), or
159
+ // against the raw line (false). "Mention is not use" is correct
160
+ // doctrine for prose categories a note might cite as an example of
161
+ // itself (health, minor, specialCategoryOther) — it is WRONG for a
162
+ // category whose match IS the leak: a credential or a national ID
163
+ // pasted inside a fenced log block is still a leaked credential or ID.
164
+ // Default true preserves prior behavior for every category that
165
+ // doesn't say otherwise.
166
+ maskCodeSpans: c.maskCodeSpans !== false,
167
+ patterns: compilePatterns(patterns, c.id),
168
+ exemptions: compileExemptions(c.exemptions, c.id),
169
+ };
170
+ });
171
+ return { categories, globalExemptions };
172
+ }
173
+
174
+ // Splits a line into clauses on `, ; : . ! ?` — the "same neighborhood"
175
+ // the exemption check is fixed down to. A carve-out phrase and the pattern
176
+ // it's supposed to excuse have to share a clause, not just a line: "Call
177
+ // them back: national ID 120385-2399" is one LINE but two clauses, and the
178
+ // phone-number carve-out in the first clause has no business excusing a
179
+ // national ID number that appears after the colon.
180
+ function splitClauses(line) {
181
+ const bounds = [0];
182
+ const seps = /[,;:.!?]/g;
183
+ let m;
184
+ while ((m = seps.exec(line))) bounds.push(m.index + 1);
185
+ bounds.push(line.length + 1);
186
+ const clauses = [];
187
+ for (let i = 0; i < bounds.length - 1; i++) clauses.push({ start: bounds[i], end: bounds[i + 1] - 1 });
188
+ return clauses;
189
+ }
190
+
191
+ function clauseIndexAt(clauses, pos) {
192
+ for (let i = 0; i < clauses.length; i++) {
193
+ if (pos >= clauses[i].start && pos < clauses[i].end) return i;
194
+ }
195
+ return clauses.length - 1;
196
+ }
197
+
198
+ // Does this exemption excuse THIS pattern match, in THIS line? Both halves of
199
+ // that scoping live here: `appliesTo` scopes WHICH pattern a carve-out can
200
+ // touch, and the clause check scopes WHERE in the line it has to appear.
201
+ function exemptionCovers(exemption, patternId, line, clauses, matchClause) {
202
+ if (exemption.appliesTo && !exemption.appliesTo.includes(patternId)) return false;
203
+ const g = new RegExp(exemption.re.source, exemption.re.flags.includes('g') ? exemption.re.flags : `${exemption.re.flags}g`);
204
+ let m;
205
+ while ((m = g.exec(line))) {
206
+ if (clauseIndexAt(clauses, m.index) === matchClause) return true;
207
+ if (m.index === g.lastIndex) g.lastIndex += 1;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ // --- pure: judge ONE line ----------------------------------------------------
213
+ // `masked` is the line after `maskCode` (a backtick mention or fenced block
214
+ // already turned to spaces); `raw` is the line as written. Which one a
215
+ // category matches against is `c.maskCodeSpans` (see compileCategories) — a
216
+ // credential or national ID pasted inside a fenced block is still a leak,
217
+ // so those categories match against `raw`, not `masked`.
218
+ //
219
+ // A category's exemption no longer blankets the whole line for every
220
+ // pattern: it has to (a) apply to the specific pattern that matched
221
+ // (`appliesTo`) and (b) share a clause with that match (`exemptionCovers`).
222
+ // "card ending in 6467" still passes — carve-out and match are one clause,
223
+ // one pattern.
224
+ function judgeLine(masked, raw, categories) {
225
+ const found = [];
226
+ for (const c of categories) {
227
+ if (!c.patterns.length) continue;
228
+ const line = c.maskCodeSpans ? masked : raw;
229
+ const clauses = splitClauses(line);
230
+
231
+ // First pass: where does each pattern first match, if at all? Needed
232
+ // twice over: to report the occurrence itself, and so a
233
+ // `nullifiedBy` exemption check can ask "did pattern X ALSO match in
234
+ // this clause" without re-running every pattern's regex per exemption.
235
+ const matchClauseByPattern = {};
236
+ for (const p of c.patterns) {
237
+ const idx = line.search(p.re);
238
+ if (idx === -1) continue;
239
+ if (p.checksum) {
240
+ const m = line.match(p.re);
241
+ const digits = m ? m[0].replace(/\D/g, '') : '';
242
+ if (!CHECKSUMS[p.checksum](digits)) continue;
243
+ }
244
+ matchClauseByPattern[p.id] = clauseIndexAt(clauses, idx);
245
+ }
246
+
247
+ for (const p of c.patterns) {
248
+ if (!(p.id in matchClauseByPattern)) continue;
249
+ const matchClause = matchClauseByPattern[p.id];
250
+ const exempt = c.exemptions.find((e) => {
251
+ if (!exemptionCovers(e, p.id, line, clauses, matchClause)) return false;
252
+ // An exemption whose text names the category ("special
253
+ // category", "what not to store") is voided when a listed pattern
254
+ // ALSO matches in this same clause — including p itself, so a
255
+ // clinical word can't excuse its own disclosure.
256
+ if (e.nullifiedBy && e.nullifiedBy.some((otherId) => matchClauseByPattern[otherId] === matchClause)) {
257
+ return false;
258
+ }
259
+ return true;
260
+ });
261
+ found.push({
262
+ category: c.id,
263
+ name: c.name,
264
+ gate: c.gate,
265
+ pattern: p.id,
266
+ exempt: Boolean(exempt),
267
+ exemption: exempt ? exempt.id : null,
268
+ excerpt: String(raw).trim().slice(0, 160),
269
+ });
270
+ }
271
+ }
272
+ // An exempt hit stays exempt: it doesn't count as an occurrence, only shows
273
+ // up in the control report — proof that the carve-out test measures a real
274
+ // exemption, not just absence of detection.
275
+ return found;
276
+ }
277
+
278
+ // --- pure: judge ONE text (a file, or one section of it) --------------------
279
+ function judgeText(piece, categories) {
280
+ const text = String(piece.text || '');
281
+ const rawLines = text.split('\n');
282
+ const maskedLines = maskCode(text).split('\n');
283
+ const occurrences = [];
284
+ const exempted = [];
285
+ for (let i = 0; i < rawLines.length; i++) {
286
+ for (const f of judgeLine(maskedLines[i], rawLines[i], categories)) {
287
+ const rec = { ...f, line: i + 1 };
288
+ if (f.exempt) exempted.push(rec);
289
+ else occurrences.push(rec);
290
+ }
291
+ }
292
+ return { name: piece.name, section: piece.section || 'file', occurrences, exempted };
293
+ }
294
+
295
+ function auditPii(pieces, categories) {
296
+ return pieces.map((p) => judgeText(p, categories));
297
+ }
298
+
299
+ // --- pure: summary -----------------------------------------------------------
300
+ function summarizePii(verdicts, categories) {
301
+ const byCategory = {};
302
+ for (const c of categories) {
303
+ byCategory[c.id] = {
304
+ gate: c.gate,
305
+ precision: c.precision,
306
+ gateRationale: c.gateRationale || null,
307
+ // `doctrine` never runs a pattern — see the CLI reporting below,
308
+ // which prints this instead of a count that would read as "checked,
309
+ // found nothing."
310
+ machineCheckable: c.gate !== 'doctrine',
311
+ occurrences: 0,
312
+ exempted: 0,
313
+ files: [],
314
+ };
315
+ }
316
+ let blocking = 0;
317
+ let warning = 0;
318
+ const filesWithHits = new Set();
319
+ for (const v of verdicts) {
320
+ for (const o of v.occurrences) {
321
+ const target = byCategory[o.category] ||
322
+ (byCategory[o.category] = {
323
+ gate: o.gate,
324
+ precision: null,
325
+ gateRationale: null,
326
+ machineCheckable: o.gate !== 'doctrine',
327
+ occurrences: 0,
328
+ exempted: 0,
329
+ files: [],
330
+ });
331
+ target.occurrences += 1;
332
+ if (!target.files.includes(v.name)) target.files.push(v.name);
333
+ if (o.gate === 'block') blocking += 1;
334
+ else if (o.gate === 'warn') warning += 1;
335
+ filesWithHits.add(v.name);
336
+ }
337
+ for (const o of v.exempted) {
338
+ const target = byCategory[o.category];
339
+ if (target) target.exempted += 1;
340
+ }
341
+ }
342
+ return {
343
+ pieces: verdicts.length,
344
+ filesWithHits: filesWithHits.size,
345
+ occurrences: blocking + warning,
346
+ blocking,
347
+ warning,
348
+ byCategory,
349
+ };
350
+ }
351
+
352
+ // --- I/O: only from here down does this module touch disk -------------------
353
+
354
+ // Turns a collected archive into judgeable pieces: frontmatter and body enter
355
+ // as SEPARATE pieces, so a report's line number is the section's own.
356
+ //
357
+ // The index file (`MEMORY.md`) is out of scope for FILE COUNTS (`total`
358
+ // below stays the note count, matching every other detector's `filesRead`)
359
+ // but it is judged here like any other piece: one index LINE (title +
360
+ // suffix note, e.g. "- [c](c.md) - the client has cancer") is exactly where
361
+ // a person free-typing a one-line summary can paste the same personal data
362
+ // they were careful to keep out of the note body. The index is also the
363
+ // single file every boot reads in full, so a false negative here is the
364
+ // worst possible place for one. Nothing about the line is stripped before
365
+ // judging it — including the `(file.md)` link target — because that target
366
+ // is a local filename, not free prose, and stripping it would be a second
367
+ // unstated carve-out; if a real one is ever needed it belongs declared in
368
+ // `_meta.coverageScope`, not silently trimmed here.
369
+ function collectPieces(dir) {
370
+ const pieces = [];
371
+ const exists = fs.existsSync(dir);
372
+ let total = 0;
373
+ if (exists) {
374
+ const r = readCollection(dir);
375
+ total = r.files.length;
376
+ for (const f of r.files) {
377
+ if (f.frontmatter && f.frontmatter.raw) {
378
+ pieces.push({ name: f.name, section: 'frontmatter', text: f.frontmatter.raw });
379
+ }
380
+ pieces.push({ name: f.name, section: 'body', text: f.body });
381
+ }
382
+ for (const entry of r.indexEntries) {
383
+ pieces.push({ name: INDEX_NAME, section: 'index', text: entry.raw });
384
+ }
385
+ }
386
+ return { pieces, exists, total };
387
+ }
388
+
389
+ module.exports = {
390
+ DEFAULT_CATEGORIES_FILE,
391
+ GATES,
392
+ compileCategories,
393
+ judgeLine,
394
+ judgeText,
395
+ auditPii,
396
+ summarizePii,
397
+ collectPieces,
398
+ };
399
+
400
+ // --- CLI ---------------------------------------------------------------
401
+ // Usage: node lib/detectors/pii.js [--json]
402
+ // Exit: 0 nothing in a `block` category | 1 a `block` category matched |
403
+ // 2 archive not found, or the category list is unreadable (not checked).
404
+ if (require.main === module) {
405
+ const { loadConfig } = require('../config');
406
+ const json = process.argv.includes('--json');
407
+ const { config } = loadConfig();
408
+ const listPath =
409
+ process.env.MEMORY_LINT_PII_CATEGORIES || config.pii.categoriesFile || DEFAULT_CATEGORIES_FILE;
410
+
411
+ const notChecked = (state, msg, extra = {}) => {
412
+ if (json) console.log(JSON.stringify({ state, categoriesFile: listPath, summary: null, ...extra }, null, 2));
413
+ else console.log(msg);
414
+ console.error(msg);
415
+ process.exit(2);
416
+ };
417
+
418
+ let categories = null;
419
+ let listVersion = null;
420
+ let coverageScope = null;
421
+ try {
422
+ const doc = JSON.parse(fs.readFileSync(listPath, 'utf8'));
423
+ const compiled = compileCategories(doc);
424
+ categories = compiled.categories;
425
+ listVersion = doc.version || null;
426
+ // The classes this list does NOT scan have to be stated, not just
427
+ // absent. `languageScope` already declares the language limit; this is
428
+ // the same declaration for CLASS of data — printed on every run so
429
+ // "PII: nothing in a blocking category" never reads as "archive is
430
+ // clean of personal data" when whole categories were never checked.
431
+ coverageScope = (doc._meta && Array.isArray(doc._meta.coverageScope)) ? doc._meta.coverageScope : null;
432
+ } catch (e) {
433
+ notChecked(
434
+ 'unreadable_list',
435
+ `PII: NOT CHECKED - category list ${listPath} is unreadable or invalid: ${e.message}. ` +
436
+ 'There is nothing to detect without the list, and a detector with no list would go green over an archive it never read.'
437
+ );
438
+ }
439
+
440
+ const { dir, source } = locateArchive();
441
+ const { pieces, exists, total } = collectPieces(dir);
442
+
443
+ if (!exists) {
444
+ notChecked(
445
+ 'archive_absent',
446
+ `PII: NOT CHECKED - ${dir} not found (path from ${source}). Absence is not a clean scan: run this where the archive exists.`
447
+ );
448
+ }
449
+
450
+ const verdicts = auditPii(pieces, categories);
451
+ const summary = summarizePii(verdicts, categories);
452
+ const hits = verdicts.filter((v) => v.occurrences.length);
453
+ // `ok` used to cover an archive with a `warn`-gate occurrence
454
+ // sitting in it (specialCategoryOther, minor, directIdentifier, ...) —
455
+ // exit code 0 either way, since only `block` trips exit 1, but the STATE
456
+ // STRING must not say "ok" over something a human still has to look at.
457
+ const state = summary.blocking > 0 ? 'blocked' : summary.warning > 0 ? 'ok_with_warnings' : 'ok';
458
+
459
+ if (json) {
460
+ console.log(
461
+ JSON.stringify(
462
+ {
463
+ state,
464
+ categoriesFile: listPath,
465
+ listVersion,
466
+ coverageScope,
467
+ dir,
468
+ source,
469
+ filesRead: total,
470
+ summary,
471
+ findings: hits.map((v) => ({ name: v.name, section: v.section, occurrences: v.occurrences })),
472
+ },
473
+ null,
474
+ 2
475
+ )
476
+ );
477
+ } else {
478
+ console.log(`archive: ${dir} (${source}, ${total} file(s))`);
479
+ console.log(`categories: ${listPath}`);
480
+ console.log(
481
+ `pieces judged: ${summary.pieces} occurrences: ${summary.occurrences} (block ${summary.blocking} / warn ${summary.warning})`
482
+ );
483
+ for (const [id, d] of Object.entries(summary.byCategory)) {
484
+ // `doctrine` (falseAnonymity) never runs a pattern — saying
485
+ // "0 occurrence(s)" reads as "checked, found nothing" for the one
486
+ // category this tool structurally cannot check.
487
+ if (!d.machineCheckable) {
488
+ console.log(` ${id} [${d.gate}]: not machine-checkable — needs a human pass`);
489
+ } else {
490
+ console.log(` ${id} [${d.gate}]: ${d.occurrences} occurrence(s), ${d.exempted} exempt(ed) by carve-out`);
491
+ }
492
+ if (d.gateRationale) console.log(` gate: ${d.gateRationale}`);
493
+ }
494
+ if (coverageScope && coverageScope.length) {
495
+ console.log(`\nNOT scanned by this list (declared limit, not a clean result for these): ${coverageScope.join('; ')}`);
496
+ }
497
+ if (hits.length) {
498
+ console.log('');
499
+ for (const v of hits) {
500
+ for (const o of v.occurrences) {
501
+ console.log(` ${v.name} (${v.section}):${o.line} [${o.category}/${o.pattern}]`);
502
+ console.log(` ... ${o.excerpt} ...`);
503
+ }
504
+ }
505
+ console.log('\nThis is a FINDING to report, not something this tool edits on its own.');
506
+ } else {
507
+ console.log('\nPII: nothing in a blocking category.');
508
+ }
509
+ }
510
+ process.exit(summary.blocking > 0 ? 1 : 0);
511
+ }