@nanobpm/urban 0.66.1 → 0.67.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,183 @@
1
+ // Unit tests for the PII classifier (slice S6 core). Run via
2
+ // `npm run test --workspace @nanobpm/urban` (node --test, src/**/*.test.ts).
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import { classifyPii, isPiiClean, type PiiCandidate, type PiiKind } from "./classifier.ts";
7
+ import { MEMORY_RECORD_SCHEMA_VERSION, type MemoryRecord } from "../schema/index.ts";
8
+
9
+ function record(overrides: Partial<MemoryRecord> = {}): MemoryRecord {
10
+ const full: MemoryRecord = {
11
+ schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
12
+ id: "rec-1",
13
+ scope: "repo",
14
+ mode: "empirical",
15
+ provenance: "human",
16
+ authority: "authoritative",
17
+ statement: "the build is green after fixing the test failure",
18
+ createdAt: "2026-08-19T14:00:00.000Z",
19
+ };
20
+ return { ...full, ...overrides };
21
+ }
22
+
23
+ function kinds(input: PiiCandidate): PiiKind[] {
24
+ const r = classifyPii(input);
25
+ return r.clean ? [] : r.findings.map((f) => f.kind);
26
+ }
27
+
28
+ test("clean record classifies clean", () => {
29
+ const result = classifyPii(record());
30
+ assert.equal(result.clean, true);
31
+ assert.deepEqual(result.findings, []);
32
+ assert.equal(isPiiClean(record()), true);
33
+ });
34
+
35
+ test("numeric and timestamp fields never false-positive", () => {
36
+ // schemaVersion (number) and createdAt (ISO 4-2-2) must not trip phone/ssn/date detectors.
37
+ const result = classifyPii(record({ statement: "shipped at 2026-08-19T14:00:00.000Z, v1.2.3" }));
38
+ assert.equal(result.clean, true);
39
+ });
40
+
41
+ test("detects a plain email", () => {
42
+ assert.ok(kinds("contact alice@example.com for details").includes("email"));
43
+ });
44
+
45
+ test("detects obfuscated emails", () => {
46
+ assert.ok(kinds("reach alice (at) example dot com").includes("email"));
47
+ assert.ok(kinds("reach alice[at]example[dot]com").includes("email"));
48
+ assert.ok(kinds("reach alice AT example DOT com").includes("email"));
49
+ });
50
+
51
+ test("reports BOTH plain and obfuscated emails in the same field", () => {
52
+ // A plain email must not suppress obfuscated hits elsewhere in the same field:
53
+ // the dedupe is per-location, not a field-wide "already saw an email" flag.
54
+ const result = classifyPii("alice@example.com and reach bob (at) example dot com");
55
+ assert.equal(result.clean, false);
56
+ if (!result.clean) {
57
+ const emails = result.findings.filter((f) => f.kind === "email");
58
+ assert.equal(emails.length, 2);
59
+ assert.ok(emails.some((f) => f.reason === "email address"));
60
+ assert.ok(emails.some((f) => f.reason === "obfuscated email address"));
61
+ }
62
+ });
63
+
64
+ test("detects US SSN", () => {
65
+ assert.ok(kinds("ssn 123-45-6789 on file").includes("ssn"));
66
+ });
67
+
68
+ test("detects phone numbers but not ISO timestamps", () => {
69
+ assert.ok(kinds("call +1 (415) 555-0132 tomorrow").includes("phone"));
70
+ assert.ok(kinds("call 415-555-0132").includes("phone"));
71
+ assert.equal(kinds("2026-08-19T14:00:00").includes("phone"), false);
72
+ });
73
+
74
+ test("detects Luhn-valid credit-card numbers, ignores random digit runs", () => {
75
+ // 4242 4242 4242 4242 is a Luhn-valid test card.
76
+ assert.ok(kinds("card 4242 4242 4242 4242 expires soon").includes("credit-card"));
77
+ // A 16-digit non-Luhn run must NOT be flagged as a card.
78
+ assert.equal(kinds("ticket 1234123412341234 in tracker").includes("credit-card"), false);
79
+ });
80
+
81
+ test("detects IPv4 addresses with valid octets only", () => {
82
+ assert.ok(kinds("connect to 192.168.1.10").includes("ip-address"));
83
+ // 999 is out of range — not a valid octet.
84
+ assert.equal(kinds("version 999.1.1.1").includes("ip-address"), false);
85
+ });
86
+
87
+ test("detects secrets: AWS key id, private key, JWT", () => {
88
+ assert.ok(kinds("AKIAIOSFODNN7EXAMPLE").includes("aws-access-key-id"));
89
+ assert.ok(kinds("-----BEGIN RSA PRIVATE KEY-----\nMIIE...").includes("private-key"));
90
+ // Build the JWT from real base64url-encoded JSON segments at runtime. A literal
91
+ // eyJ...eyJ... token is rewritten to `******` by secret-masking in diffs/tooling,
92
+ // which hid from reviewers that this test exercises the detector at all. Deriving
93
+ // it keeps the source diff reviewable while still matching the JWT pattern.
94
+ const seg = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
95
+ const jwt = [seg({ alg: "HS256", typ: "JWT" }), seg({ sub: "test-subject" }), "c2lnbmF0dXJlXY"].join(".");
96
+ assert.ok(kinds(`token ${jwt}`).includes("jwt"));
97
+ });
98
+
99
+ test("scans every string field of a record and reports its path", () => {
100
+ const result = classifyPii(
101
+ record({ statement: "no pii here", subject: "email bob@corp.example.org" }),
102
+ );
103
+ assert.equal(result.clean, false);
104
+ if (!result.clean) {
105
+ const emailFinding = result.findings.find((f) => f.kind === "email");
106
+ assert.ok(emailFinding);
107
+ assert.equal(emailFinding?.path, "subject");
108
+ }
109
+ });
110
+
111
+ test("scans string-array fields (evidence[]) with an indexed path", () => {
112
+ const result = classifyPii(record({ evidence: ["ok", "ssn 123-45-6789"] }));
113
+ assert.equal(result.clean, false);
114
+ if (!result.clean) {
115
+ const finding = result.findings.find((f) => f.kind === "ssn");
116
+ assert.equal(finding?.path, "evidence.1");
117
+ }
118
+ });
119
+
120
+ test("walks nested objects (no shallow false-negative through the guard)", () => {
121
+ const result = classifyPii({ meta: { contact: { email: "alice@example.com" } } });
122
+ assert.equal(result.clean, false);
123
+ if (!result.clean) {
124
+ const finding = result.findings.find((f) => f.kind === "email");
125
+ assert.equal(finding?.path, "meta.contact.email");
126
+ }
127
+ });
128
+
129
+ test("walks arrays of nested objects with a fully indexed path", () => {
130
+ const result = classifyPii({ items: [{ note: "ok" }, { note: "ssn 123-45-6789" }] });
131
+ assert.equal(result.clean, false);
132
+ if (!result.clean) {
133
+ const finding = result.findings.find((f) => f.kind === "ssn");
134
+ assert.equal(finding?.path, "items.1.note");
135
+ }
136
+ });
137
+
138
+ test("obfuscated-email finding index maps back to the original text offset", () => {
139
+ const text = "reach alice (at) example dot com now";
140
+ const result = classifyPii(text);
141
+ assert.equal(result.clean, false);
142
+ if (!result.clean) {
143
+ const finding = result.findings.find((f) => f.reason === "obfuscated email address");
144
+ assert.ok(finding);
145
+ // Must locate the value in the ORIGINAL text, not the shorter normalised copy.
146
+ assert.equal(finding?.index, text.indexOf("alice"));
147
+ }
148
+ });
149
+
150
+ test("findings are located and redacted, never re-leaking the value", () => {
151
+ const value = "alice@example.com";
152
+ const result = classifyPii(`email ${value}`);
153
+ assert.equal(result.clean, false);
154
+ if (!result.clean) {
155
+ const f = result.findings[0];
156
+ assert.equal(typeof f.index, "number");
157
+ assert.ok(f.index >= 0);
158
+ assert.equal(f.excerpt.includes(value), false);
159
+ // Defect class: the excerpt must not leak ANY 2-char run of the value — not
160
+ // just the whole string. A first/last-character redaction would slip through
161
+ // an `includes(value)` check while still surfacing part of the secret.
162
+ for (let i = 0; i + 2 <= value.length; i++) {
163
+ assert.equal(
164
+ f.excerpt.includes(value.slice(i, i + 2)),
165
+ false,
166
+ `excerpt leaked substring "${value.slice(i, i + 2)}"`,
167
+ );
168
+ }
169
+ }
170
+ });
171
+
172
+ test("reports multiple occurrences", () => {
173
+ const result = classifyPii("a@x.com and b@y.com");
174
+ assert.equal(result.clean, false);
175
+ if (!result.clean) {
176
+ assert.ok(result.findings.filter((f) => f.kind === "email").length >= 2);
177
+ }
178
+ });
179
+
180
+ test("is pure — repeated calls on the same input are identical", () => {
181
+ const input = record({ statement: "phone 415-555-0132" });
182
+ assert.deepEqual(classifyPii(input), classifyPii(input));
183
+ });
@@ -0,0 +1,321 @@
1
+ // Slice S6 CORE (pii guard-core) — the PII *classifier*.
2
+ //
3
+ // A PURE, dependency-free function that inspects candidate memory-record content
4
+ // and reports whether it carries personally-identifiable information (PII) or
5
+ // PII-adjacent secrets. It is the analytical core the mandatory pre-commit guard
6
+ // (`./guard.ts`) is built on: the guard decides *policy* (default-DENY), this
7
+ // module decides *facts* (what looks like PII and where).
8
+ //
9
+ // Design goals:
10
+ // - PURE & deterministic: same input → same classification, no I/O, no state.
11
+ // Safe to call on a hot path (S3 runs it before every commit) and trivial to
12
+ // unit-test and reuse (the S6 CI slice reuses it to scan staged records).
13
+ // - By-construction MVP: the context substrate is *no-PII*, so the classifier
14
+ // is deliberately conservative-to-strict — it errs toward flagging obfuscated
15
+ // look-alikes (e.g. `name (at) host dot com`) because a false *positive* is a
16
+ // safe rejection while a false *negative* would launder PII into the record.
17
+ // - Located findings: every hit reports the field `path` and offset so callers
18
+ // (and humans) can see exactly what was flagged and why.
19
+
20
+ import type { MemoryRecord } from "../schema/index.ts";
21
+
22
+ /** The categories of PII / sensitive material the classifier recognises. */
23
+ export type PiiKind =
24
+ | "email"
25
+ | "phone"
26
+ | "ssn"
27
+ | "credit-card"
28
+ | "ip-address"
29
+ | "aws-access-key-id"
30
+ | "private-key"
31
+ | "jwt";
32
+
33
+ /** A single located detection. */
34
+ export interface PiiFinding {
35
+ /** Which category matched. */
36
+ readonly kind: PiiKind;
37
+ /**
38
+ * Dotted path to the offending field within the candidate. Empty (`""`) when
39
+ * the candidate is a bare string. For array elements the index is appended,
40
+ * e.g. `evidence.0`.
41
+ */
42
+ readonly path: string;
43
+ /** Character offset of the match within that field's text. */
44
+ readonly index: number;
45
+ /**
46
+ * A length-only placeholder for the matched substring (e.g. `[redacted:17]`).
47
+ * Contains NO part of the value, so a finding can be logged/surfaced without
48
+ * re-leaking any character of the PII/secret it located.
49
+ */
50
+ readonly excerpt: string;
51
+ /** Human-readable explanation of why this was flagged. */
52
+ readonly reason: string;
53
+ }
54
+
55
+ /**
56
+ * The classification decision. `clean: true` means no PII was found; otherwise
57
+ * `findings` is a non-empty list of located detections.
58
+ */
59
+ export type PiiClassification =
60
+ | { readonly clean: true; readonly findings: readonly [] }
61
+ | { readonly clean: false; readonly findings: readonly PiiFinding[] };
62
+
63
+ /**
64
+ * Anything the classifier can inspect: a bare string, a {@link MemoryRecord}, or
65
+ * any plain object whose string / string-array properties should be scanned.
66
+ * Nested objects and arrays are walked recursively, so PII buried in a
67
+ * sub-object is still found; non-string leaf values (numbers, booleans, `null`)
68
+ * are ignored, so a record's numeric `schemaVersion` or an ISO `createdAt`
69
+ * timestamp never trips a false positive.
70
+ */
71
+ export type PiiCandidate = string | MemoryRecord | Readonly<Record<string, unknown>>;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Detectors
75
+ // ---------------------------------------------------------------------------
76
+
77
+ interface Detector {
78
+ readonly kind: PiiKind;
79
+ readonly reason: string;
80
+ readonly pattern: RegExp;
81
+ /** Optional extra guard to reject a syntactic match that is not real PII. */
82
+ readonly accept?: (match: string) => boolean;
83
+ }
84
+
85
+ function luhnValid(digits: string): boolean {
86
+ const only = digits.replace(/\D/g, "");
87
+ if (only.length < 13 || only.length > 19) return false;
88
+ let sum = 0;
89
+ let alternate = false;
90
+ for (let i = only.length - 1; i >= 0; i--) {
91
+ let n = only.charCodeAt(i) - 48;
92
+ if (alternate) {
93
+ n *= 2;
94
+ if (n > 9) n -= 9;
95
+ }
96
+ sum += n;
97
+ alternate = !alternate;
98
+ }
99
+ return sum % 10 === 0;
100
+ }
101
+
102
+ function validIpv4(match: string): boolean {
103
+ return match.split(".").every((octet) => {
104
+ const n = Number(octet);
105
+ return octet.length <= 3 && n >= 0 && n <= 255;
106
+ });
107
+ }
108
+
109
+ // Detectors run against the raw text. `pattern` MUST be global (`g`) so every
110
+ // occurrence is reported, not just the first.
111
+ const DETECTORS: readonly Detector[] = [
112
+ {
113
+ kind: "private-key",
114
+ reason: "PEM private-key block",
115
+ pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
116
+ },
117
+ {
118
+ kind: "aws-access-key-id",
119
+ reason: "AWS access key id",
120
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
121
+ },
122
+ {
123
+ kind: "jwt",
124
+ reason: "JSON Web Token",
125
+ pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g,
126
+ },
127
+ {
128
+ kind: "ssn",
129
+ reason: "US Social Security Number",
130
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
131
+ },
132
+ {
133
+ kind: "credit-card",
134
+ reason: "credit-card number (Luhn-valid)",
135
+ pattern: /\b(?:\d[ -]?){13,19}\b/g,
136
+ accept: luhnValid,
137
+ },
138
+ {
139
+ kind: "phone",
140
+ reason: "telephone number",
141
+ // Optional country code, then 3-3-4 grouping with separators — deliberately
142
+ // strict so ISO timestamps / ids (4-2-2, 3-1-1…) do not match.
143
+ pattern: /(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]\d{3}[\s.-]\d{4}\b/g,
144
+ },
145
+ {
146
+ kind: "ip-address",
147
+ reason: "IPv4 address",
148
+ pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
149
+ accept: validIpv4,
150
+ },
151
+ {
152
+ kind: "email",
153
+ reason: "email address",
154
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
155
+ },
156
+ ];
157
+
158
+ // Obfuscated-email detector. Real-world PII is often written to evade naive
159
+ // scanners: `alice (at) example dot com`, `alice[at]example[dot]com`. We
160
+ // normalise those spellings back to `@`/`.` on a COPY of the text and re-run the
161
+ // plain email detector; a hit is reported at the original field. Because the
162
+ // MVP is no-PII by construction we accept the (safe) risk of flagging a benign
163
+ // "X at Y dot Z" phrase — a false positive is a rejected write, never a leak.
164
+ //
165
+ // The two obfuscation spellings are matched in ONE left-to-right pass (the `at`
166
+ // alternation first, then `dot`) so a normalised offset can be mapped straight
167
+ // back to the original text — a normalising `.replace()` chain would shift every
168
+ // offset after a rewritten run and mis-locate the finding.
169
+ const OBFUSCATION_TOKEN =
170
+ /(\s*[([{]?\s*(?:at|@)\s*[)\]}]?\s*)|(\s*[([{]?\s*(?:dot|\.)\s*[)\]}]?\s*)/gi;
171
+
172
+ /**
173
+ * Normalise obfuscated `@`/`.` spellings back to their canonical characters.
174
+ * Returns the normalised text plus a `map` where `map[i]` is the index in the
175
+ * ORIGINAL text that normalised character `i` originated from, so a match found
176
+ * in the normalised copy can be reported at its true original offset.
177
+ */
178
+ function deobfuscate(text: string): { text: string; map: number[] } {
179
+ let out = "";
180
+ const map: number[] = [];
181
+ let last = 0;
182
+ OBFUSCATION_TOKEN.lastIndex = 0;
183
+ let m: RegExpExecArray | null = OBFUSCATION_TOKEN.exec(text);
184
+ while (m !== null) {
185
+ for (let i = last; i < m.index; i++) {
186
+ out += text[i];
187
+ map.push(i);
188
+ }
189
+ out += m[1] !== undefined ? "@" : ".";
190
+ map.push(m.index);
191
+ last = m.index + m[0].length;
192
+ if (m.index === OBFUSCATION_TOKEN.lastIndex) OBFUSCATION_TOKEN.lastIndex++;
193
+ m = OBFUSCATION_TOKEN.exec(text);
194
+ }
195
+ for (let i = last; i < text.length; i++) {
196
+ out += text[i];
197
+ map.push(i);
198
+ }
199
+ return { text: out, map };
200
+ }
201
+
202
+ function redact(match: string): string {
203
+ // Findings are explicitly logged/surfaced, so the excerpt must never contain
204
+ // ANY part of the sensitive value — even the first/last characters would leak
205
+ // PII/secrets into logs. Emit a length-only placeholder: useful for
206
+ // debugging ("how long was it?") without re-leaking a single character.
207
+ return `[redacted:${match.trim().length}]`;
208
+ }
209
+
210
+ function scanText(path: string, text: string): PiiFinding[] {
211
+ const findings: PiiFinding[] = [];
212
+ for (const detector of DETECTORS) {
213
+ detector.pattern.lastIndex = 0;
214
+ let m: RegExpExecArray | null = detector.pattern.exec(text);
215
+ while (m !== null) {
216
+ const value = m[0];
217
+ if (!detector.accept || detector.accept(value)) {
218
+ findings.push({
219
+ kind: detector.kind,
220
+ path,
221
+ index: m.index,
222
+ excerpt: redact(value),
223
+ reason: detector.reason,
224
+ });
225
+ }
226
+ // Guard against zero-width matches looping forever.
227
+ if (m.index === detector.pattern.lastIndex) detector.pattern.lastIndex++;
228
+ m = detector.pattern.exec(text);
229
+ }
230
+ }
231
+
232
+ // Obfuscated email pass — only add hits the plain pass did not already report.
233
+ const { text: normalised, map } = deobfuscate(text);
234
+ if (normalised !== text) {
235
+ const emailDetector = DETECTORS.find((d) => d.kind === "email");
236
+ if (emailDetector) {
237
+ // Dedupe per LOCATION, not per field: a field-wide "already saw an email"
238
+ // flag would drop every obfuscated hit as soon as one plain email exists
239
+ // anywhere in the field (under-reporting a mixed plain+obfuscated field).
240
+ const plainEmailIndices = new Set(
241
+ findings.filter((f) => f.kind === "email").map((f) => f.index),
242
+ );
243
+ emailDetector.pattern.lastIndex = 0;
244
+ let em: RegExpExecArray | null = emailDetector.pattern.exec(normalised);
245
+ while (em !== null) {
246
+ // Map the offset in the normalised copy back to the original text so the
247
+ // finding is located where the obfuscated value actually is.
248
+ const originalIndex = map[em.index] ?? em.index;
249
+ if (!plainEmailIndices.has(originalIndex)) {
250
+ findings.push({
251
+ kind: "email",
252
+ path,
253
+ index: originalIndex,
254
+ excerpt: redact(em[0]),
255
+ reason: "obfuscated email address",
256
+ });
257
+ }
258
+ if (em.index === emailDetector.pattern.lastIndex) emailDetector.pattern.lastIndex++;
259
+ em = emailDetector.pattern.exec(normalised);
260
+ }
261
+ }
262
+ }
263
+
264
+ return findings;
265
+ }
266
+
267
+ /**
268
+ * Flatten a candidate into the `(path, text)` fields the detectors scan. String
269
+ * leaves are scanned; every object / array is walked RECURSIVELY so PII nested
270
+ * inside sub-objects or arrays of objects cannot slip past the default-deny
271
+ * guard as a false negative. Non-string, non-container leaves (numbers,
272
+ * booleans, `null`) are ignored. A `WeakSet` breaks any accidental reference
273
+ * cycle so a self-referential candidate cannot loop forever.
274
+ */
275
+ function collectFields(candidate: PiiCandidate): Array<{ path: string; text: string }> {
276
+ if (typeof candidate === "string") return [{ path: "", text: candidate }];
277
+ const fields: Array<{ path: string; text: string }> = [];
278
+ const seen = new WeakSet<object>();
279
+ const walk = (value: unknown, path: string): void => {
280
+ if (typeof value === "string") {
281
+ fields.push({ path, text: value });
282
+ return;
283
+ }
284
+ if (value === null || typeof value !== "object") return;
285
+ if (seen.has(value)) return;
286
+ seen.add(value);
287
+ if (Array.isArray(value)) {
288
+ value.forEach((entry, i) => walk(entry, path === "" ? `${i}` : `${path}.${i}`));
289
+ return;
290
+ }
291
+ for (const [key, v] of Object.entries(value)) {
292
+ walk(v, path === "" ? key : `${path}.${key}`);
293
+ }
294
+ };
295
+ for (const [key, value] of Object.entries(candidate)) {
296
+ walk(value, key);
297
+ }
298
+ return fields;
299
+ }
300
+
301
+ /**
302
+ * Classify a candidate for PII. PURE and deterministic — no I/O, no shared
303
+ * state. Returns `{ clean: true }` when nothing is found, otherwise
304
+ * `{ clean: false, findings }` with a located reason for every hit.
305
+ *
306
+ * Inspect a whole {@link MemoryRecord} (every string / string-array field is
307
+ * scanned), a single field's text, or any plain object.
308
+ */
309
+ export function classifyPii(candidate: PiiCandidate): PiiClassification {
310
+ const findings: PiiFinding[] = [];
311
+ for (const { path, text } of collectFields(candidate)) {
312
+ findings.push(...scanText(path, text));
313
+ }
314
+ if (findings.length === 0) return { clean: true, findings: [] };
315
+ return { clean: false, findings };
316
+ }
317
+
318
+ /** Convenience predicate: `true` when the candidate carries no detected PII. */
319
+ export function isPiiClean(candidate: PiiCandidate): boolean {
320
+ return classifyPii(candidate).clean;
321
+ }
@@ -0,0 +1,124 @@
1
+ // Unit tests for the mandatory pre-commit PII guard (slice S6 core). Run via
2
+ // `npm run test --workspace @nanobpm/urban` (node --test, src/**/*.test.ts).
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import {
7
+ preCommitPiiGuard,
8
+ createPiiGuard,
9
+ PiiGuardError,
10
+ type PiiGuard,
11
+ } from "./guard.ts";
12
+ import type { PiiFinding } from "./classifier.ts";
13
+ import { MEMORY_RECORD_SCHEMA_VERSION, type MemoryRecord } from "../schema/index.ts";
14
+
15
+ function record(overrides: Partial<MemoryRecord> = {}): MemoryRecord {
16
+ const full: MemoryRecord = {
17
+ schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
18
+ id: "rec-1",
19
+ scope: "repo",
20
+ mode: "empirical",
21
+ provenance: "human",
22
+ authority: "authoritative",
23
+ statement: "the release passed all gates",
24
+ createdAt: "2026-08-19T14:00:00.000Z",
25
+ };
26
+ return { ...full, ...overrides };
27
+ }
28
+
29
+ test("default guard passes clean content through (returns void)", () => {
30
+ assert.equal(preCommitPiiGuard.assert(record()), undefined);
31
+ assert.equal(preCommitPiiGuard.inspect(record()).clean, true);
32
+ });
33
+
34
+ test("default guard is default-DENY: rejects a PII-carrying record", () => {
35
+ assert.throws(
36
+ () => preCommitPiiGuard.assert(record({ statement: "owner alice@example.com approved" })),
37
+ PiiGuardError,
38
+ );
39
+ });
40
+
41
+ test("PiiGuardError carries the located findings", () => {
42
+ try {
43
+ preCommitPiiGuard.assert(record({ subject: "ssn 123-45-6789" }));
44
+ assert.fail("expected PiiGuardError");
45
+ } catch (err) {
46
+ assert.ok(err instanceof PiiGuardError);
47
+ assert.ok(err.findings.length >= 1);
48
+ assert.equal(err.findings[0].kind, "ssn");
49
+ assert.equal(err.findings[0].path, "subject");
50
+ }
51
+ });
52
+
53
+ test("guard rejects obfuscated PII too (evasion attempt)", () => {
54
+ assert.throws(
55
+ () => preCommitPiiGuard.assert("contact me: bob (at) corp dot com"),
56
+ PiiGuardError,
57
+ );
58
+ });
59
+
60
+ test("inspect is non-throwing and returns findings", () => {
61
+ const result = preCommitPiiGuard.inspect("card 4242 4242 4242 4242");
62
+ assert.equal(result.clean, false);
63
+ if (!result.clean) assert.equal(result.findings[0].kind, "credit-card");
64
+ });
65
+
66
+ test("createPiiGuard() with no options matches the default guard policy", () => {
67
+ const guard = createPiiGuard();
68
+ assert.equal(guard.name, "pre-commit-pii-guard");
69
+ assert.throws(() => guard.assert("email x@y.com"), PiiGuardError);
70
+ assert.doesNotThrow(() => guard.assert("nothing sensitive"));
71
+ });
72
+
73
+ test("guard name is overridable but policy stays default-DENY", () => {
74
+ const guard: PiiGuard = createPiiGuard({ name: "write-path-guard" });
75
+ assert.equal(guard.name, "write-path-guard");
76
+ assert.throws(() => guard.assert("ssn 123-45-6789"), PiiGuardError);
77
+ });
78
+
79
+ test("custom classifier override is still enforced as default-DENY", () => {
80
+ // A stricter policy that flags a banned word — proving the guard enforces
81
+ // whatever classifier it is given, and always denies on a non-clean result.
82
+ const guard = createPiiGuard({
83
+ classify: (c) =>
84
+ typeof c === "string" && c.includes("secret")
85
+ ? { clean: false, findings: [{ kind: "email", path: "", index: 0, excerpt: "***", reason: "banned" }] }
86
+ : { clean: true, findings: [] },
87
+ });
88
+ assert.throws(() => guard.assert("this is secret"), PiiGuardError);
89
+ assert.doesNotThrow(() => guard.assert("this is fine"));
90
+ });
91
+
92
+ test("classify override cannot disable the built-in default-DENY classifier", () => {
93
+ // An override that always reports clean must NOT be able to launder PII past
94
+ // the guard: the built-in classifier always runs and its findings are unioned.
95
+ const bypass = createPiiGuard({ classify: () => ({ clean: true, findings: [] }) });
96
+ assert.throws(() => bypass.assert("owner alice@example.com approved"), PiiGuardError);
97
+ const inspected = bypass.inspect("ssn 123-45-6789");
98
+ assert.equal(inspected.clean, false);
99
+ });
100
+
101
+ test("PiiGuardError defensively snapshots findings — caller mutation can't corrupt it", () => {
102
+ const findings: PiiFinding[] = [
103
+ { kind: "email", path: "", index: 0, excerpt: "***", reason: "x" },
104
+ ];
105
+ const err = new PiiGuardError(findings);
106
+ findings.pop();
107
+ findings.push({ kind: "ssn", path: "y", index: 1, excerpt: "###", reason: "z" });
108
+ assert.equal(err.findings.length, 1);
109
+ assert.equal(err.findings[0].kind, "email");
110
+ });
111
+
112
+ test("override reporting !clean with no findings throws a loud error, not an empty PiiGuardError", () => {
113
+ // Defect class: a buggy override that claims PII but locates nothing would
114
+ // otherwise make `assert` throw a PiiGuardError with an empty (contract says
115
+ // "never empty") findings array and a meaningless summary. The guard must
116
+ // surface that as a loud programming error instead — while never letting the
117
+ // write through (default-DENY is preserved).
118
+ const guard = createPiiGuard({ classify: () => ({ clean: false, findings: [] }) });
119
+ assert.throws(() => guard.assert("nothing sensitive"), (err: unknown) => {
120
+ assert.ok(err instanceof TypeError);
121
+ assert.ok(!(err instanceof PiiGuardError));
122
+ return true;
123
+ });
124
+ });