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,44 @@
1
+ 'use strict';
2
+ // Finds where the memory archive lives, in order:
3
+ // 1. `MEMORY_LINT_DIR` env var (mainly for tests, and for pointing the CLI
4
+ // at a non-default location)
5
+ // 2. `autoMemoryDirectory` in `~/.claude/settings.local.json` or
6
+ // `~/.claude/settings.json` (Claude Code's own setting)
7
+ // 3. a default derived from the home directory, matching Claude Code's own
8
+ // per-project memory path
9
+ //
10
+ // The result always says WHICH of these decided (`source`): a path with no
11
+ // provenance has, in practice, caused audits to silently scan the wrong
12
+ // folder and report a false "zero problems".
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { normalize } = require('./collector');
17
+
18
+ function projectKey(dir) {
19
+ return String(dir).replace(/[:\\/]/g, '-');
20
+ }
21
+
22
+ function readJson(file) {
23
+ try {
24
+ return JSON.parse(normalize(fs.readFileSync(file, 'utf8')));
25
+ } catch (e) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function locateArchive(env = process.env, home = os.homedir()) {
31
+ if (env.MEMORY_LINT_DIR) return { dir: env.MEMORY_LINT_DIR, source: 'MEMORY_LINT_DIR' };
32
+ for (const name of ['settings.local.json', 'settings.json']) {
33
+ const cfg = readJson(path.join(home, '.claude', name));
34
+ if (cfg && typeof cfg.autoMemoryDirectory === 'string' && cfg.autoMemoryDirectory.trim()) {
35
+ return { dir: cfg.autoMemoryDirectory.trim(), source: `autoMemoryDirectory (${name})` };
36
+ }
37
+ }
38
+ return {
39
+ dir: path.join(home, '.claude', 'projects', projectKey(home), 'memory'),
40
+ source: 'default derived from home directory',
41
+ };
42
+ }
43
+
44
+ module.exports = { locateArchive, projectKey, readJson };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ // Masks Markdown code (fenced blocks and inline spans) so detectors never
3
+ // treat a quoted example as a real claim. "Mention is not use": a marker or
4
+ // keyword shown inside backticks as an example of the convention must not
5
+ // count as an actual occurrence.
6
+ //
7
+ // `maskCode` replaces every character that sits inside code with a single
8
+ // space (keeping line breaks intact), so line/column offsets never shift —
9
+ // callers can still map a match back to its original line and position.
10
+
11
+ // Masks a whole text. Returns a same-length string where code regions are
12
+ // blanked out.
13
+ function maskCode(text) {
14
+ const s = String(text);
15
+ const n = s.length;
16
+ const inCode = new Array(n).fill(false);
17
+
18
+ // 1) Fenced blocks. Opens and closes on its own line; everything inside is
19
+ // quoted.
20
+ const lines = [];
21
+ let start = 0;
22
+ for (let k = 0; k <= n; k++) {
23
+ if (k === n || s[k] === '\n') {
24
+ lines.push({ start, end: k });
25
+ start = k + 1;
26
+ }
27
+ }
28
+ let fence = null;
29
+ for (const L of lines) {
30
+ const txt = s.slice(L.start, L.end);
31
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(txt);
32
+ if (fence) {
33
+ for (let k = L.start; k < L.end; k++) inCode[k] = true;
34
+ if (m && m[1][0] === fence.char && m[1].length >= fence.len && txt.slice(m[0].length).trim() === '') {
35
+ fence = null;
36
+ }
37
+ } else if (m) {
38
+ fence = { char: m[1][0], len: m[1].length };
39
+ for (let k = L.start; k < L.end; k++) inCode[k] = true;
40
+ }
41
+ }
42
+
43
+ // 2) Inline code spans. A run of N backticks opens and only closes with
44
+ // another run of exactly N — and the pair MAY cross a line break (real
45
+ // Markdown prose does this when a quoted term wraps).
46
+ let k = 0;
47
+ while (k < n) {
48
+ if (inCode[k] || s[k] !== '`') {
49
+ k++;
50
+ continue;
51
+ }
52
+ let j = k;
53
+ while (j < n && s[j] === '`') j++;
54
+ const len = j - k;
55
+ let p = j;
56
+ let close = -1;
57
+ while (p < n) {
58
+ if (s[p] === '`' && !inCode[p]) {
59
+ let q = p;
60
+ while (q < n && s[q] === '`') q++;
61
+ if (q - p === len) {
62
+ close = p;
63
+ break;
64
+ }
65
+ p = q;
66
+ } else {
67
+ p++;
68
+ }
69
+ }
70
+ // A backtick run with no matching close is a literal backtick, not an
71
+ // opener: mask nothing. A pair spanning a blank line is not a code span
72
+ // either — that boundary keeps a stray backtick from swallowing whole
73
+ // paragraphs and hiding a real claim inside "quoted" text.
74
+ if (close === -1 || s.slice(k, close + len).includes('\n\n')) {
75
+ k = j;
76
+ continue;
77
+ }
78
+ for (let t = k; t < close + len; t++) inCode[t] = true;
79
+ k = close + len;
80
+ }
81
+
82
+ let out = '';
83
+ for (let t = 0; t < n; t++) out += inCode[t] && s[t] !== '\n' ? ' ' : s[t];
84
+ return out;
85
+ }
86
+
87
+ module.exports = { maskCode };
@@ -0,0 +1,403 @@
1
+ {
2
+ "_meta": {
3
+ "purpose": "This is THE list. The PII detector (lib/detectors/pii.js) reads this file and declares no category in code — a list in two places becomes two lists that drift, and the one that drifts is always the one the gate reads.",
4
+ "fields": {
5
+ "id": "stable key; the name the detector reports and the tests plant",
6
+ "name": "human-readable category name",
7
+ "description": "what this category covers",
8
+ "carveOut": "what INSIDE this category may still be recorded. Without this the rule paralyzes every note that even mentions the topic",
9
+ "gate": "block = exit 1 on the CLI | warn = counted and reported, never exit 1 | doctrine = not detectable by form, never enters code as a pattern",
10
+ "gateRationale": "why THIS category sits at THIS gate — for block/warn categories under the same GDPR Art. 9/10 exposure, the gate reflects how precisely the pattern can be trusted, not a claim that the underlying data is less sensitive. For `doctrine`, it says why no pattern could ever check it.",
11
+ "precision": "high = the pattern only matches the thing itself | closed-vocabulary = matches narrowly, on purpose (declared low recall, not silent)",
12
+ "patterns": "regex in JS syntax, compiled with the 'i' flag. Empty list = prose-only category (doctrine)",
13
+ "exemptions": "if any exemption matches the SAME clause as a pattern match (and, when the exemption also names `appliesTo`, only for the pattern ids listed there), the pattern does not block or warn — the carve-out in executable form. An exemption may also carry `nullifiedBy`: a list of pattern ids that, if ANY of them also matches in that same clause, voids the exemption even where it would otherwise apply — used when the exemption's own wording (e.g. naming the category to teach it) sits right next to a real match of the thing it was never meant to excuse.",
14
+ "example": "a synthetic case for `block`/`warn`, and one for `exempt` (the carve-out). Both are fabricated for this file; neither is a real record"
15
+ },
16
+ "globalExemptionsNote": "See `globalExemptions` below: quoting a term inside backticks or a fenced code block is citation, not use — a note that TEACHES this list needs to be able to show the phrase it catches without tripping the gate on itself.",
17
+ "languageScope": "Patterns here are English-vocabulary only. A memory archive kept in another language will have lower recall on the prose-based categories (health, minor, specialCategoryOther) until someone adds that language's vocabulary — this is a declared limit, not a silent one. The regex-only categories (nationalId, bankAccount, credential, directIdentifier's email pattern, onlineIdentifier) are format-based and language-independent.",
18
+ "scopeNote": "This file draws the line technology can enforce. It does not replace legal advice: which categories apply, and under which law (GDPR Art. 9, a US state privacy statute, or something else), is a judgment call for whoever operates this tool, not something the tool decides for them.",
19
+ "coverageScope": [
20
+ "a person's NAME in free-form prose (no fixed shape to match on; catching it needs named-entity recognition, which this list does not attempt)",
21
+ "a full POSTAL ADDRESS in free-form prose (same reason — city/street text has no fixed shape)",
22
+ "PRECISE GEOLOCATION expressed as prose (\"outside the school on Elm Street\") rather than coordinates",
23
+ "a COOKIE OR DEVICE IDENTIFIER (no universal format across vendors to match on)",
24
+ "PHILOSOPHICAL OR POLITICAL CONVICTION beyond the closed vocabulary already in specialCategoryOther",
25
+ "this whole list is ENGLISH-vocabulary for its prose categories (see languageScope above)"
26
+ ]
27
+ },
28
+ "globalExemptions": [
29
+ {
30
+ "id": "backtick-mention",
31
+ "regex": "`[^`]*`",
32
+ "descriptionNote": "quoting a term in backticks is citation, not a record of it"
33
+ },
34
+ {
35
+ "id": "fenced-code-block",
36
+ "regex": "^\\s*```",
37
+ "descriptionNote": "inside a fenced code block, the form is an example, not a record"
38
+ }
39
+ ],
40
+ "version": 1,
41
+ "categories": [
42
+ {
43
+ "id": "nationalId",
44
+ "name": "National ID number and government document",
45
+ "description": "A national identity number, passport number, driver's license number, or social-security-style number. Counts whole or partial: several national ID formats embed a birth date, so even a truncated fragment is not \"harmless partial\".",
46
+ "carveOut": "The FACT that one exists is recordable (\"has a national ID on file\", \"pending — no ID issued yet\"), and a birth YEAR the person volunteered themselves. The number itself, never — not even truncated.",
47
+ "gate": "block",
48
+ "gateRationale": "the patterns are format-based (`precision: high`): a match is the number itself, not a word that merely suggests one, so a false positive is rare enough that treating every hit as a hard stop is the right tradeoff.",
49
+ "precision": "high",
50
+ "maskCodeSpans": false,
51
+ "maskCodeSpansNote": "A national ID number pasted inside a fenced code block (a copied log, a support ticket dump) is still a leaked ID — masking it out of the scan is where a prior bug hid it entirely",
52
+ "patterns": [
53
+ {
54
+ "id": "nordic-personal-id",
55
+ "regex": "\\b(?:0[1-9]|[12]\\d|3[01]|4[1-9]|[56]\\d|7[01])(?:0[1-9]|1[0-2])\\d{2}-?\\d{4}\\b",
56
+ "description": "Nordic-style personal identity number: DDMMYY(-)NNNC, where DD is a valid day (01-31, or 41-71 for the legal-entity variant some countries use) and MM a valid month (01-12). A bare `\\d{10}` would also match error codes and comment IDs — requiring a valid date prefix keeps the pattern to actual ID numbers"
57
+ },
58
+ {
59
+ "id": "us-ssn",
60
+ "regex": "\\b\\d{3}-\\d{2}-\\d{4}\\b",
61
+ "description": "US Social Security Number format (NNN-NN-NNNN)"
62
+ },
63
+ {
64
+ "id": "br-cpf",
65
+ "regex": "\\b\\d{3}\\.?\\d{3}\\.?\\d{3}-\\d{2}\\b",
66
+ "description": "Brazilian CPF, punctuated or not"
67
+ }
68
+ ],
69
+ "exemptions": [
70
+ {
71
+ "id": "phone-number",
72
+ "regex": "(tel:|phone|whatsapp|call|mobile)",
73
+ "description": "a line about a phone number has a run of digits and is not a document"
74
+ },
75
+ {
76
+ "id": "technical-id-or-timestamp",
77
+ "regex": "(epoch|timestamp|commit|sha|issue #|pr #|comment id|https?://|0x[0-9a-f]{4,}|exit code|error code)",
78
+ "description": "a technical number with declared context — a URL, a code-review comment id, an error code — is not a national ID even when the digit count lines up"
79
+ }
80
+ ],
81
+ "example": {
82
+ "block": "Their national ID is 120385-2399, file closed.",
83
+ "exempt": "They're a foreign national with no ID on file yet; said they were born in 1985."
84
+ }
85
+ },
86
+ {
87
+ "id": "bankAccount",
88
+ "name": "Bank account, card, and payment credential",
89
+ "description": "IBAN, account and routing number, card number, CVV, expiry date, a payment-app handle tied to one person.",
90
+ "carveOut": "The LAST FOUR digits of a card are recordable — it's already on the receipt and doesn't re-identify by itself. The full card number, the full IBAN, and the CVV, never. Amount paid, currency, date, and payment status are all recordable.",
91
+ "gate": "block",
92
+ "gateRationale": "format-based and checksum-gated (`precision: high`): a card number has to pass Luhn, an IBAN has a fixed shape, so a hit is the credential itself, not a guess.",
93
+ "precision": "high",
94
+ "maskCodeSpans": false,
95
+ "maskCodeSpansNote": "A card number or IBAN pasted inside a fenced block (a copied receipt, a support ticket) is still a leaked payment credential",
96
+ "patterns": [
97
+ {
98
+ "id": "iban",
99
+ "regex": "\\b[A-Z]{2}\\d{2}[A-Z0-9]{11,30}\\b",
100
+ "description": "IBAN, any issuing country"
101
+ },
102
+ {
103
+ "id": "card-number",
104
+ "regex": "\\b(?:\\d[ -]?){13,19}\\b",
105
+ "checksum": "luhn",
106
+ "description": "a card number, with or without separators. Gated on a Luhn (mod-10) check: every real card scheme's number passes it, and a random 13-19 digit platform ID (an ad account, a page ID) passes only by coincidence — narrows the match without exempting a whole clause the way a keyword carve-out would"
107
+ }
108
+ ],
109
+ "exemptions": [
110
+ {
111
+ "id": "last-four-only",
112
+ "regex": "(\\*{2,}\\s*\\d{4}|•{2,}\\s*\\d{4}|final\\s+\\d{4}|ending\\s+in\\s+\\d{4}|last\\s*4)",
113
+ "appliesTo": ["card-number"],
114
+ "description": "the carve-out in executable form: a mask plus four digits passes. Scoped to card-number only — a masked card number says nothing about whether a nearby full IBAN is safe to record"
115
+ },
116
+ {
117
+ "id": "platform-technical-id",
118
+ "regex": "(account id|page id|asset id|ad account|portfolio|draft \\d|legacy|migrations|epoch|timestamp|commit|sha|issue #|pr #)",
119
+ "appliesTo": ["card-number"],
120
+ "description": "a numeric platform ID (an ad account, a page ID, a draft or migration number) can land in the 13-19 digit range the card pattern matches without being a payment instrument at all. Scoped to card-number only — an IBAN next to \"ad account\" in the same sentence is still a full IBAN"
121
+ }
122
+ ],
123
+ "example": {
124
+ "block": "Paid with card 4539 1488 0343 6467, IBAN GB29NWBK60161331926819.",
125
+ "exempt": "Paid 249 on the card ending in 6467, dated 2026-09-12."
126
+ }
127
+ },
128
+ {
129
+ "id": "health",
130
+ "name": "Health data (a special category under most privacy laws)",
131
+ "description": "A medical condition, diagnosis, treatment, medication, pregnancy, allergy, or a photo of a person taken during treatment.",
132
+ "carveOut": "The OPERATIONAL FACT without the condition behind it is recordable: \"out on leave until the 14th\", \"asked to reschedule\", \"prefers a room without air conditioning\", \"not available in September\". The condition itself, never — and it doesn't stop being health data when it's dressed up as a preference.",
133
+ "gate": "block",
134
+ "gateRationale": "block despite `closed-vocabulary` precision (unlike `minor`/`specialCategoryOther`, also Art. 9 categories, which are `warn`): the closed vocabulary here is clinical terms with almost no other meaning (\"chemotherapy\", \"pregnan*\"), so the false-positive rate is low enough to justify a hard stop — the gate reflects that precision, not a claim that religion or trade-union membership matter less under Art. 9(1) than health does.",
135
+ "precision": "closed-vocabulary",
136
+ "coverage": "declared-low: matches narrowly on purpose. A detector that scans prose for health data gets this wrong in both directions, and a declared narrow match is safer than a silent gap on the category with the highest legal exposure.",
137
+ "patterns": [
138
+ {
139
+ "id": "condition",
140
+ "regex": "\\b(cancer|diabetes|HIV|depression|anxiety|asthma|allerg\\w*|pregnan\\w*)\\b",
141
+ "description": "a closed clinical vocabulary, deliberately narrow"
142
+ },
143
+ {
144
+ "id": "treatment",
145
+ "regex": "\\b(medication|prescription|chemotherapy|medical treatment)\\b",
146
+ "description": "a named clinical act"
147
+ },
148
+ {
149
+ "id": "clinical-diagnosis",
150
+ "regex": "(diagnosis)[^.\\n]{0,40}(medical|clinical|patient)|(medical|clinical|patient)[^.\\n]{0,40}(diagnosis)",
151
+ "description": "\"diagnosis\" alone is common engineering jargon (\"diagnosis of the failing build\"); requiring a medical/clinical qualifier in the same sentence keeps that jargon out. The cost, declared: \"diagnosis of a condition\" that names an actual illness elsewhere in the sentence is caught by the `condition` pattern instead, not here"
152
+ }
153
+ ],
154
+ "exemptions": [
155
+ {
156
+ "id": "leave-without-condition",
157
+ "regex": "\\b(on leave|sick leave|out of office)\\b",
158
+ "description": "the carve-out: the operational fact alone passes"
159
+ },
160
+ {
161
+ "id": "service-menu",
162
+ "regex": "\\b(treatment menu|service menu|list of services|our services)\\b",
163
+ "description": "a business's own service listing names a service, not a person"
164
+ },
165
+ {
166
+ "id": "mentions-the-category",
167
+ "regex": "\\b(health data|special category|what not to store)\\b",
168
+ "nullifiedBy": ["condition", "treatment", "clinical-diagnosis"],
169
+ "description": "mention is not use, in prose form: a line that talks ABOUT health data to teach the rule is not itself a record of anyone's health. \"PII\" and \"never store\" were dropped from this vocabulary because real notes use both while also naming a real condition (\"Never store this kind of thing: she is pregnant\"), and `nullifiedBy` voids this exemption whenever `condition`/`treatment`/`clinical-diagnosis` also matches in the same clause — the same clinical word can't be both the disclosure and its own excuse."
170
+ }
171
+ ],
172
+ "example": {
173
+ "block": "They rescheduled Tuesday's session because of the pregnancy; still on medication.",
174
+ "exempt": "They rescheduled Tuesday's session; on leave until the 14th."
175
+ }
176
+ },
177
+ {
178
+ "id": "minor",
179
+ "name": "A minor (under 18)",
180
+ "description": "Any data that identifies a person under 18: name, photo, school, appointment time, contact details.",
181
+ "carveOut": "An aggregated fact with nobody identifiable is recordable: \"the package serves minors and requires guardian consent\" is a business rule and stays.",
182
+ "gate": "warn",
183
+ "gateRationale": "warn, not because a minor's data matters less under Art. 9(1) than health, but because the vocabulary (\"minor\", \"child of\", \"kid of\") collides constantly with ordinary engineering and business prose (a DOM \"child\", \"the child process\") — the gate reflects that collision risk, not lower legal exposure. Same Art. 9 category as `health`, deliberately looser gate because the pattern is less trustworthy, not the data less sensitive.",
184
+ "precision": "closed-vocabulary",
185
+ "patterns": [
186
+ {
187
+ "id": "minor-mention",
188
+ "regex": "\\b(minor|underage|child of|kid of)\\b",
189
+ "description": "prose: reports, never blocks by itself"
190
+ }
191
+ ],
192
+ "exemptions": [
193
+ {
194
+ "id": "business-rule",
195
+ "regex": "\\b(the package|the service|the policy|guardian consent|parental consent)\\b",
196
+ "description": "the aggregated carve-out"
197
+ },
198
+ {
199
+ "id": "technical-node",
200
+ "regex": "\\b(container|element|node|DOM|div|flex|grid|clipping|overflow|CSS|PID|process|supervisor|worker|port)\\b",
201
+ "description": "\"child of\" also matches \"child of a container\" or \"the child process\" — a DOM or OS node, not a person"
202
+ }
203
+ ],
204
+ "example": {
205
+ "block": "Their minor child, age 14, comes in Thursdays at 4pm.",
206
+ "exempt": "The package serves minors and requires guardian consent."
207
+ }
208
+ },
209
+ {
210
+ "id": "specialCategoryOther",
211
+ "name": "Other special categories",
212
+ "description": "Racial or ethnic origin, political opinion, religious belief, trade-union membership, biometric data used for identification, sexual orientation, and genetic data (GDPR Art. 9(1) names all of these in the same list — this category previously only carried a vocabulary term for biometric).",
213
+ "carveOut": "A service language is recordable and is not ethnicity (\"serves clients in Polish\"); a holiday that closes the calendar is recordable and is not religion (\"closed on the 25th\").",
214
+ "gate": "warn",
215
+ "gateRationale": "warn for the same reason as `minor`: origin, religion, orientation, and union membership have the SAME Art. 9(1) exposure as health, but the vocabulary here (\"religious\", \"political party\", \"biometric\") sits closer to everyday business language (a religious holiday closing, a biometric login feature) than the health category's clinical terms do — the looser gate measures that precision gap, not a lesser legal claim.",
216
+ "precision": "closed-vocabulary",
217
+ "patterns": [
218
+ {
219
+ "id": "special-category",
220
+ "regex": "\\b(union member|trade union|religious|muslim|jewish|christian|sexual orientation|political party|biometric)\\b",
221
+ "description": "prose: reports, never blocks by itself"
222
+ },
223
+ {
224
+ "id": "genetic-data",
225
+ "regex": "\\b(genetic (test|marker|screening|data)|DNA (test|sample|marker))\\b",
226
+ "description": "Art. 9(1) names genetic data alongside biometric data, but the shipped vocabulary only had a term for the latter — closed vocabulary, same declared-low-recall tradeoff as the rest of this category"
227
+ }
228
+ ],
229
+ "exemptions": [
230
+ {
231
+ "id": "language-or-holiday",
232
+ "regex": "\\b(serves clients in|language|holiday|closed on)\\b",
233
+ "description": "the operational carve-out"
234
+ },
235
+ {
236
+ "id": "market-data-source",
237
+ "regex": "\\b(source|directory|list of|database|company registry|sample)\\b",
238
+ "description": "\"the union membership directory\" is a data source's provenance, not a person's union membership"
239
+ }
240
+ ],
241
+ "example": {
242
+ "block": "The owner is a union member and Evangelical — use that angle in the copy.",
243
+ "exempt": "Serves clients in Polish; closed on the 25th for the holiday."
244
+ }
245
+ },
246
+ {
247
+ "id": "credential",
248
+ "name": "Credential and secret",
249
+ "description": "An API token, key, password, session cookie, or the value of an environment variable. Not personal data, but on this list for the same reason: no configuration unblocks it.",
250
+ "carveOut": "The NAME of the vault entry or variable is recordable and is the correct way to record it (\"the key is `SERVICE_TOKEN`, in the vault\"). The value, never — a leaked value gets rotated, not edited out of a text file.",
251
+ "gate": "block",
252
+ "gateRationale": "format-based (`precision: high`): every pattern here, including the private-key and plaintext-password patterns, matches a shape that is not used for anything except the secret itself — there is no legitimate English sentence that happens to contain a PEM private-key header or `password: <value>` by coincidence.",
253
+ "precision": "high",
254
+ "maskCodeSpans": false,
255
+ "maskCodeSpansNote": "This is the exact bug a prior audit found: a live key pasted inside a fenced ``` block (a copied error log) was invisible because the scan masked the block before judging it. A live key in a code block is still a live key.",
256
+ "patterns": [
257
+ {
258
+ "id": "airtable-token",
259
+ "regex": "\\bpat[A-Za-z0-9]{14,}\\b",
260
+ "description": "an Airtable personal access token"
261
+ },
262
+ {
263
+ "id": "github-token",
264
+ "regex": "\\bgh[pousr]_[A-Za-z0-9]{20,}\\b",
265
+ "description": "a GitHub token"
266
+ },
267
+ {
268
+ "id": "openai-style-key",
269
+ "regex": "\\bsk-[A-Za-z0-9_-]{20,}\\b",
270
+ "description": "an sk- format API key"
271
+ },
272
+ {
273
+ "id": "slack-token",
274
+ "regex": "\\bxox[baprs]-[A-Za-z0-9-]{10,}\\b",
275
+ "description": "a Slack token"
276
+ },
277
+ {
278
+ "id": "aws-access-key",
279
+ "regex": "\\bAKIA[0-9A-Z]{16}\\b",
280
+ "description": "an AWS access key ID"
281
+ },
282
+ {
283
+ "id": "private-key-block",
284
+ "regex": "-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----",
285
+ "description": "a PEM-format private key block header (RSA, EC, OpenSSH, or generic). The header line alone is the signal — real material always has the key body right after it, and waiting for the body adds nothing but a chance to miss a truncated paste"
286
+ },
287
+ {
288
+ "id": "plaintext-password",
289
+ "regex": "\\bpassword\\s*[:=]\\s*\\S{6,}",
290
+ "description": "a password assigned or shown in cleartext next to the word \"password\" (`password: hunter2SuperSecret`, `password=abc123!!`). Vendor-token patterns above catch a NAMED format; this catches the generic case a person types when handing over a login by hand"
291
+ }
292
+ ],
293
+ "exemptions": [],
294
+ "example": {
295
+ "block": "The API token is patAbCdEfGhIjKlMnOp.1234567890abcdef",
296
+ "exempt": "The key is `SERVICE_TOKEN`, in the vault; the value never leaves it."
297
+ }
298
+ },
299
+ {
300
+ "id": "directIdentifier",
301
+ "name": "Direct identifier (email, phone)",
302
+ "description": "GDPR Art. 4(1) direct identifiers this list can match by FORMAT: an email address and a phone number. A person's NAME is a direct identifier too and is deliberately NOT here — free-form prose has no fixed shape to match a name against; see `_meta.coverageScope`. A postal address is the same story and is also declared out of scope there.",
303
+ "carveOut": "A business's own published contact address (\"reach us at hello@thecompany.com\") is recordable — it identifies the business, not a private person. A generic role mailbox (info@, support@, hello@) is lower risk than a personal name@ address, but this list does not try to tell them apart by pattern; that judgment stays with whoever reviews a finding.",
304
+ "gate": "warn",
305
+ "gateRationale": "warn: the format is precise (an email/phone shape rarely appears by accident), but the pattern cannot tell a private person's address from a business's own published one (\"support@\" vs. a client's name@) — the gate reflects that every hit still needs a human look, not that a phone number matters less than a national ID.",
306
+ "precision": "closed-format",
307
+ "coverage": "declared-low: catches an email or phone number by shape. It does not, and cannot by regex alone, tell a client's personal address from a business's own published one — every hit is a finding to review, not an automatic verdict on whose address it is.",
308
+ "patterns": [
309
+ {
310
+ "id": "email-address",
311
+ "regex": "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b",
312
+ "description": "an email address, any domain"
313
+ },
314
+ {
315
+ "id": "phone-number",
316
+ "regex": "\\b(?:\\+\\d{1,3}[ .-]?)?(?:\\(?\\d{2,4}\\)?[ .-]?){2,4}\\d{2,4}\\b",
317
+ "description": "a phone number with a country code or grouped digits — deliberately loose on grouping, since phone formatting varies by country"
318
+ }
319
+ ],
320
+ "exemptions": [
321
+ {
322
+ "id": "business-contact",
323
+ "regex": "\\b(our (email|phone|number)|company (email|phone)|support@|info@|hello@|contact us at|book (a|an) (call|appointment) at)\\b",
324
+ "description": "a business's own published contact channel is not a private person's identifier"
325
+ }
326
+ ],
327
+ "example": {
328
+ "block": "Client's email is jon.somebody@example.com and mobile is 555-0142.",
329
+ "exempt": "Our support email is support@example.com; book an appointment at 555-0100."
330
+ }
331
+ },
332
+ {
333
+ "id": "onlineIdentifier",
334
+ "name": "Online identifier (IP address)",
335
+ "description": "GDPR Art. 4(1) + Recital 30 treat an IP address as personal data when it can be linked to a person. A cookie or device ID is the same class and is deliberately NOT matched here — there is no shared format across vendors to match on; see `_meta.coverageScope`.",
336
+ "carveOut": "A CIDR range or example IP used to document infrastructure (\"the office is on 10.0.0.0/8\") is recordable — it names a network, not a person's connection.",
337
+ "gate": "warn",
338
+ "gateRationale": "warn: an IPv4 address is only personal data when it links back to a person, and this list has no way to tell a client's home connection from an office subnet or a documentation example by shape alone — every hit needs the same human read a business-vs-personal email address needs.",
339
+ "precision": "closed-format",
340
+ "patterns": [
341
+ {
342
+ "id": "ipv4",
343
+ "regex": "\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b",
344
+ "description": "an IPv4 address"
345
+ }
346
+ ],
347
+ "exemptions": [
348
+ {
349
+ "id": "infra-documentation",
350
+ "regex": "\\b(cidr|subnet|localhost|private range|internal network|10\\.0\\.0\\.0|192\\.168\\.0\\.0|reserved range)\\b",
351
+ "description": "documenting a network, not recording a person's connection"
352
+ }
353
+ ],
354
+ "example": {
355
+ "block": "Their home IP is 192.168.10.44, seen at login.",
356
+ "exempt": "The office subnet is 192.168.0.0/16, per the network docs."
357
+ }
358
+ },
359
+ {
360
+ "id": "criminalRecord",
361
+ "name": "Criminal conviction or offence data",
362
+ "description": "GDPR Art. 10: data about criminal convictions and offences sits OUTSIDE Art. 9's special categories, under its own regime, requiring official-authority processing or member-state law — separate legal basis from health/religion/etc., which is why it is its own category here rather than folded into specialCategoryOther.",
363
+ "carveOut": "An aggregated policy fact is recordable (\"the package requires a background check\"); a specific person's record or charge, never.",
364
+ "gate": "warn",
365
+ "gateRationale": "warn despite Art. 10's own strict regime (official-authority processing, member-state law): the vocabulary (\"convicted of\", \"charged with\") reads naturally in a policy sentence (\"requires a background check\") as often as in a real disclosure, so the gate is set by that ambiguity, not by Art. 10 mattering less than Art. 9.",
366
+ "precision": "closed-vocabulary",
367
+ "coverage": "declared-low: matches narrowly on purpose, same tradeoff as `health`.",
368
+ "patterns": [
369
+ {
370
+ "id": "criminal-history",
371
+ "regex": "\\b(criminal (record|conviction|history)|prior conviction|convicted of|arrested for|charged with|prison sentence)\\b",
372
+ "description": "a closed clinical-style vocabulary for offence data, deliberately narrow"
373
+ }
374
+ ],
375
+ "exemptions": [
376
+ {
377
+ "id": "policy-requirement",
378
+ "regex": "\\b(background check|the package|the policy|requires a check)\\b",
379
+ "description": "an aggregated policy requirement, not one person's record"
380
+ }
381
+ ],
382
+ "example": {
383
+ "block": "He has a prior criminal conviction for fraud.",
384
+ "exempt": "The package requires a background check for anyone handling cash."
385
+ }
386
+ },
387
+ {
388
+ "id": "falseAnonymity",
389
+ "name": "False anonymity (a combination that re-identifies)",
390
+ "description": "The record names no one, and still points at exactly one person: \"the client who complained on Tuesday\", or a small town plus profession plus appointment date. Dropping the name doesn't anonymize — a handful of attributes is often enough in a small enough population.",
391
+ "carveOut": "The same fact with the singular detail dropped is recordable: \"there was a scheduling complaint in September\" teaches the same lesson without pointing at anyone.",
392
+ "gate": "doctrine",
393
+ "gateRationale": "doctrine, not warn: re-identification by combination has no fixed shape at all — it's a judgment about how small a population a set of attributes narrows down to, which regex cannot approximate even loosely. This category is never evaluated by the detector, so the report must say so explicitly instead of printing a count that looks like a completed check.",
394
+ "precision": "not-detectable",
395
+ "patterns": [],
396
+ "exemptions": [],
397
+ "example": {
398
+ "block": "The client who complained on Tuesday backed off.",
399
+ "exempt": "There was a scheduling complaint in September."
400
+ }
401
+ }
402
+ ]
403
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "_doc": "Template config. Copy/edit these numbers to your own archive's real size. They are placeholders, not a real measurement. See lib/config.js for the built-in defaults this file overrides.",
3
+ "budget": {
4
+ "lines": 200,
5
+ "bytes": 20000,
6
+ "readerLineLimit": 200,
7
+ "proximityWarning": 190
8
+ },
9
+ "provenance": {
10
+ "baselineFile": "memory-lint.baseline.json",
11
+ "markers": ["stated", "measured", "inferred"]
12
+ },
13
+ "perishable": {
14
+ "cutoffDate": null
15
+ },
16
+ "pii": {
17
+ "categoriesFile": null
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "claude-memory-lint",
3
+ "version": "0.1.0",
4
+ "description": "Structural checks for a Claude Code MEMORY.md memory archive: closed size budget, required frontmatter, per-line provenance markers, perishable-state tracking, a personal-data/secrets scan, and a suppression-instruction detector judged by effect.",
5
+ "license": "MIT",
6
+ "author": "Nord Leads",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/vgrosetti-maker/claude-memory-lint.git"
10
+ },
11
+ "homepage": "https://github.com/vgrosetti-maker/claude-memory-lint#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/vgrosetti-maker/claude-memory-lint/issues"
14
+ },
15
+ "bin": {
16
+ "memory-lint": "bin/memory-lint.js"
17
+ },
18
+ "main": "lib/index.js",
19
+ "files": [
20
+ "bin",
21
+ "lib",
22
+ "memory-lint.config.json",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js"
30
+ }
31
+ }