@nanobpm/urban 0.66.2 → 0.68.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.
- package/dist/context/pii/classifier.d.ts +55 -0
- package/dist/context/pii/classifier.js +250 -0
- package/dist/context/pii/guard.d.ts +63 -0
- package/dist/context/pii/guard.js +94 -0
- package/dist/context/pii/index.d.ts +2 -1
- package/dist/context/pii/index.js +24 -1
- package/dist/runtime/core/modules/pages.js +7 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/pii/NOTES.md +34 -0
- package/src/context/pii/classifier.test.ts +183 -0
- package/src/context/pii/classifier.ts +321 -0
- package/src/context/pii/guard.test.ts +124 -0
- package/src/context/pii/guard.ts +144 -0
- package/src/context/pii/index.ts +39 -2
- package/src/runtime/browser/runtime.browser.js +248 -10
- package/src/runtime/browser/runtime.detailform.test.ts +177 -1
- package/src/runtime/core/modules/pages.ts +7 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
2
|
+
/** The categories of PII / sensitive material the classifier recognises. */
|
|
3
|
+
export type PiiKind = "email" | "phone" | "ssn" | "credit-card" | "ip-address" | "aws-access-key-id" | "private-key" | "jwt";
|
|
4
|
+
/** A single located detection. */
|
|
5
|
+
export interface PiiFinding {
|
|
6
|
+
/** Which category matched. */
|
|
7
|
+
readonly kind: PiiKind;
|
|
8
|
+
/**
|
|
9
|
+
* Dotted path to the offending field within the candidate. Empty (`""`) when
|
|
10
|
+
* the candidate is a bare string. For array elements the index is appended,
|
|
11
|
+
* e.g. `evidence.0`.
|
|
12
|
+
*/
|
|
13
|
+
readonly path: string;
|
|
14
|
+
/** Character offset of the match within that field's text. */
|
|
15
|
+
readonly index: number;
|
|
16
|
+
/**
|
|
17
|
+
* A length-only placeholder for the matched substring (e.g. `[redacted:17]`).
|
|
18
|
+
* Contains NO part of the value, so a finding can be logged/surfaced without
|
|
19
|
+
* re-leaking any character of the PII/secret it located.
|
|
20
|
+
*/
|
|
21
|
+
readonly excerpt: string;
|
|
22
|
+
/** Human-readable explanation of why this was flagged. */
|
|
23
|
+
readonly reason: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The classification decision. `clean: true` means no PII was found; otherwise
|
|
27
|
+
* `findings` is a non-empty list of located detections.
|
|
28
|
+
*/
|
|
29
|
+
export type PiiClassification = {
|
|
30
|
+
readonly clean: true;
|
|
31
|
+
readonly findings: readonly [];
|
|
32
|
+
} | {
|
|
33
|
+
readonly clean: false;
|
|
34
|
+
readonly findings: readonly PiiFinding[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Anything the classifier can inspect: a bare string, a {@link MemoryRecord}, or
|
|
38
|
+
* any plain object whose string / string-array properties should be scanned.
|
|
39
|
+
* Nested objects and arrays are walked recursively, so PII buried in a
|
|
40
|
+
* sub-object is still found; non-string leaf values (numbers, booleans, `null`)
|
|
41
|
+
* are ignored, so a record's numeric `schemaVersion` or an ISO `createdAt`
|
|
42
|
+
* timestamp never trips a false positive.
|
|
43
|
+
*/
|
|
44
|
+
export type PiiCandidate = string | MemoryRecord | Readonly<Record<string, unknown>>;
|
|
45
|
+
/**
|
|
46
|
+
* Classify a candidate for PII. PURE and deterministic — no I/O, no shared
|
|
47
|
+
* state. Returns `{ clean: true }` when nothing is found, otherwise
|
|
48
|
+
* `{ clean: false, findings }` with a located reason for every hit.
|
|
49
|
+
*
|
|
50
|
+
* Inspect a whole {@link MemoryRecord} (every string / string-array field is
|
|
51
|
+
* scanned), a single field's text, or any plain object.
|
|
52
|
+
*/
|
|
53
|
+
export declare function classifyPii(candidate: PiiCandidate): PiiClassification;
|
|
54
|
+
/** Convenience predicate: `true` when the candidate carries no detected PII. */
|
|
55
|
+
export declare function isPiiClean(candidate: PiiCandidate): boolean;
|
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
function luhnValid(digits) {
|
|
20
|
+
const only = digits.replace(/\D/g, "");
|
|
21
|
+
if (only.length < 13 || only.length > 19)
|
|
22
|
+
return false;
|
|
23
|
+
let sum = 0;
|
|
24
|
+
let alternate = false;
|
|
25
|
+
for (let i = only.length - 1; i >= 0; i--) {
|
|
26
|
+
let n = only.charCodeAt(i) - 48;
|
|
27
|
+
if (alternate) {
|
|
28
|
+
n *= 2;
|
|
29
|
+
if (n > 9)
|
|
30
|
+
n -= 9;
|
|
31
|
+
}
|
|
32
|
+
sum += n;
|
|
33
|
+
alternate = !alternate;
|
|
34
|
+
}
|
|
35
|
+
return sum % 10 === 0;
|
|
36
|
+
}
|
|
37
|
+
function validIpv4(match) {
|
|
38
|
+
return match.split(".").every((octet) => {
|
|
39
|
+
const n = Number(octet);
|
|
40
|
+
return octet.length <= 3 && n >= 0 && n <= 255;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Detectors run against the raw text. `pattern` MUST be global (`g`) so every
|
|
44
|
+
// occurrence is reported, not just the first.
|
|
45
|
+
const DETECTORS = [
|
|
46
|
+
{
|
|
47
|
+
kind: "private-key",
|
|
48
|
+
reason: "PEM private-key block",
|
|
49
|
+
pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
kind: "aws-access-key-id",
|
|
53
|
+
reason: "AWS access key id",
|
|
54
|
+
pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
kind: "jwt",
|
|
58
|
+
reason: "JSON Web Token",
|
|
59
|
+
pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
kind: "ssn",
|
|
63
|
+
reason: "US Social Security Number",
|
|
64
|
+
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
kind: "credit-card",
|
|
68
|
+
reason: "credit-card number (Luhn-valid)",
|
|
69
|
+
pattern: /\b(?:\d[ -]?){13,19}\b/g,
|
|
70
|
+
accept: luhnValid,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
kind: "phone",
|
|
74
|
+
reason: "telephone number",
|
|
75
|
+
// Optional country code, then 3-3-4 grouping with separators — deliberately
|
|
76
|
+
// strict so ISO timestamps / ids (4-2-2, 3-1-1…) do not match.
|
|
77
|
+
pattern: /(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]\d{3}[\s.-]\d{4}\b/g,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
kind: "ip-address",
|
|
81
|
+
reason: "IPv4 address",
|
|
82
|
+
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
83
|
+
accept: validIpv4,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
kind: "email",
|
|
87
|
+
reason: "email address",
|
|
88
|
+
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
// Obfuscated-email detector. Real-world PII is often written to evade naive
|
|
92
|
+
// scanners: `alice (at) example dot com`, `alice[at]example[dot]com`. We
|
|
93
|
+
// normalise those spellings back to `@`/`.` on a COPY of the text and re-run the
|
|
94
|
+
// plain email detector; a hit is reported at the original field. Because the
|
|
95
|
+
// MVP is no-PII by construction we accept the (safe) risk of flagging a benign
|
|
96
|
+
// "X at Y dot Z" phrase — a false positive is a rejected write, never a leak.
|
|
97
|
+
//
|
|
98
|
+
// The two obfuscation spellings are matched in ONE left-to-right pass (the `at`
|
|
99
|
+
// alternation first, then `dot`) so a normalised offset can be mapped straight
|
|
100
|
+
// back to the original text — a normalising `.replace()` chain would shift every
|
|
101
|
+
// offset after a rewritten run and mis-locate the finding.
|
|
102
|
+
const OBFUSCATION_TOKEN = /(\s*[([{]?\s*(?:at|@)\s*[)\]}]?\s*)|(\s*[([{]?\s*(?:dot|\.)\s*[)\]}]?\s*)/gi;
|
|
103
|
+
/**
|
|
104
|
+
* Normalise obfuscated `@`/`.` spellings back to their canonical characters.
|
|
105
|
+
* Returns the normalised text plus a `map` where `map[i]` is the index in the
|
|
106
|
+
* ORIGINAL text that normalised character `i` originated from, so a match found
|
|
107
|
+
* in the normalised copy can be reported at its true original offset.
|
|
108
|
+
*/
|
|
109
|
+
function deobfuscate(text) {
|
|
110
|
+
let out = "";
|
|
111
|
+
const map = [];
|
|
112
|
+
let last = 0;
|
|
113
|
+
OBFUSCATION_TOKEN.lastIndex = 0;
|
|
114
|
+
let m = OBFUSCATION_TOKEN.exec(text);
|
|
115
|
+
while (m !== null) {
|
|
116
|
+
for (let i = last; i < m.index; i++) {
|
|
117
|
+
out += text[i];
|
|
118
|
+
map.push(i);
|
|
119
|
+
}
|
|
120
|
+
out += m[1] !== undefined ? "@" : ".";
|
|
121
|
+
map.push(m.index);
|
|
122
|
+
last = m.index + m[0].length;
|
|
123
|
+
if (m.index === OBFUSCATION_TOKEN.lastIndex)
|
|
124
|
+
OBFUSCATION_TOKEN.lastIndex++;
|
|
125
|
+
m = OBFUSCATION_TOKEN.exec(text);
|
|
126
|
+
}
|
|
127
|
+
for (let i = last; i < text.length; i++) {
|
|
128
|
+
out += text[i];
|
|
129
|
+
map.push(i);
|
|
130
|
+
}
|
|
131
|
+
return { text: out, map };
|
|
132
|
+
}
|
|
133
|
+
function redact(match) {
|
|
134
|
+
// Findings are explicitly logged/surfaced, so the excerpt must never contain
|
|
135
|
+
// ANY part of the sensitive value — even the first/last characters would leak
|
|
136
|
+
// PII/secrets into logs. Emit a length-only placeholder: useful for
|
|
137
|
+
// debugging ("how long was it?") without re-leaking a single character.
|
|
138
|
+
return `[redacted:${match.trim().length}]`;
|
|
139
|
+
}
|
|
140
|
+
function scanText(path, text) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
for (const detector of DETECTORS) {
|
|
143
|
+
detector.pattern.lastIndex = 0;
|
|
144
|
+
let m = detector.pattern.exec(text);
|
|
145
|
+
while (m !== null) {
|
|
146
|
+
const value = m[0];
|
|
147
|
+
if (!detector.accept || detector.accept(value)) {
|
|
148
|
+
findings.push({
|
|
149
|
+
kind: detector.kind,
|
|
150
|
+
path,
|
|
151
|
+
index: m.index,
|
|
152
|
+
excerpt: redact(value),
|
|
153
|
+
reason: detector.reason,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Guard against zero-width matches looping forever.
|
|
157
|
+
if (m.index === detector.pattern.lastIndex)
|
|
158
|
+
detector.pattern.lastIndex++;
|
|
159
|
+
m = detector.pattern.exec(text);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Obfuscated email pass — only add hits the plain pass did not already report.
|
|
163
|
+
const { text: normalised, map } = deobfuscate(text);
|
|
164
|
+
if (normalised !== text) {
|
|
165
|
+
const emailDetector = DETECTORS.find((d) => d.kind === "email");
|
|
166
|
+
if (emailDetector) {
|
|
167
|
+
// Dedupe per LOCATION, not per field: a field-wide "already saw an email"
|
|
168
|
+
// flag would drop every obfuscated hit as soon as one plain email exists
|
|
169
|
+
// anywhere in the field (under-reporting a mixed plain+obfuscated field).
|
|
170
|
+
const plainEmailIndices = new Set(findings.filter((f) => f.kind === "email").map((f) => f.index));
|
|
171
|
+
emailDetector.pattern.lastIndex = 0;
|
|
172
|
+
let em = emailDetector.pattern.exec(normalised);
|
|
173
|
+
while (em !== null) {
|
|
174
|
+
// Map the offset in the normalised copy back to the original text so the
|
|
175
|
+
// finding is located where the obfuscated value actually is.
|
|
176
|
+
const originalIndex = map[em.index] ?? em.index;
|
|
177
|
+
if (!plainEmailIndices.has(originalIndex)) {
|
|
178
|
+
findings.push({
|
|
179
|
+
kind: "email",
|
|
180
|
+
path,
|
|
181
|
+
index: originalIndex,
|
|
182
|
+
excerpt: redact(em[0]),
|
|
183
|
+
reason: "obfuscated email address",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (em.index === emailDetector.pattern.lastIndex)
|
|
187
|
+
emailDetector.pattern.lastIndex++;
|
|
188
|
+
em = emailDetector.pattern.exec(normalised);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Flatten a candidate into the `(path, text)` fields the detectors scan. String
|
|
196
|
+
* leaves are scanned; every object / array is walked RECURSIVELY so PII nested
|
|
197
|
+
* inside sub-objects or arrays of objects cannot slip past the default-deny
|
|
198
|
+
* guard as a false negative. Non-string, non-container leaves (numbers,
|
|
199
|
+
* booleans, `null`) are ignored. A `WeakSet` breaks any accidental reference
|
|
200
|
+
* cycle so a self-referential candidate cannot loop forever.
|
|
201
|
+
*/
|
|
202
|
+
function collectFields(candidate) {
|
|
203
|
+
if (typeof candidate === "string")
|
|
204
|
+
return [{ path: "", text: candidate }];
|
|
205
|
+
const fields = [];
|
|
206
|
+
const seen = new WeakSet();
|
|
207
|
+
const walk = (value, path) => {
|
|
208
|
+
if (typeof value === "string") {
|
|
209
|
+
fields.push({ path, text: value });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (value === null || typeof value !== "object")
|
|
213
|
+
return;
|
|
214
|
+
if (seen.has(value))
|
|
215
|
+
return;
|
|
216
|
+
seen.add(value);
|
|
217
|
+
if (Array.isArray(value)) {
|
|
218
|
+
value.forEach((entry, i) => walk(entry, path === "" ? `${i}` : `${path}.${i}`));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
for (const [key, v] of Object.entries(value)) {
|
|
222
|
+
walk(v, path === "" ? key : `${path}.${key}`);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
for (const [key, value] of Object.entries(candidate)) {
|
|
226
|
+
walk(value, key);
|
|
227
|
+
}
|
|
228
|
+
return fields;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Classify a candidate for PII. PURE and deterministic — no I/O, no shared
|
|
232
|
+
* state. Returns `{ clean: true }` when nothing is found, otherwise
|
|
233
|
+
* `{ clean: false, findings }` with a located reason for every hit.
|
|
234
|
+
*
|
|
235
|
+
* Inspect a whole {@link MemoryRecord} (every string / string-array field is
|
|
236
|
+
* scanned), a single field's text, or any plain object.
|
|
237
|
+
*/
|
|
238
|
+
export function classifyPii(candidate) {
|
|
239
|
+
const findings = [];
|
|
240
|
+
for (const { path, text } of collectFields(candidate)) {
|
|
241
|
+
findings.push(...scanText(path, text));
|
|
242
|
+
}
|
|
243
|
+
if (findings.length === 0)
|
|
244
|
+
return { clean: true, findings: [] };
|
|
245
|
+
return { clean: false, findings };
|
|
246
|
+
}
|
|
247
|
+
/** Convenience predicate: `true` when the candidate carries no detected PII. */
|
|
248
|
+
export function isPiiClean(candidate) {
|
|
249
|
+
return classifyPii(candidate).clean;
|
|
250
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type PiiCandidate, type PiiClassification, type PiiFinding } from "./classifier.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link PiiGuard.assert} (and the default guard) when a candidate
|
|
4
|
+
* carries PII. Carries the located {@link PiiFinding}s so callers can log /
|
|
5
|
+
* surface exactly what was rejected without re-running the classifier.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PiiGuardError extends Error {
|
|
8
|
+
/** The located detections that caused the rejection (never empty). */
|
|
9
|
+
readonly findings: readonly PiiFinding[];
|
|
10
|
+
constructor(findings: readonly PiiFinding[]);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A pre-commit guard. The published seam S3 registers as its default,
|
|
14
|
+
* non-bypassable write-path check.
|
|
15
|
+
*
|
|
16
|
+
* Implementations MUST be default-DENY: {@link assert} throws on any detected
|
|
17
|
+
* PII and returns normally only for clean content.
|
|
18
|
+
*/
|
|
19
|
+
export interface PiiGuard {
|
|
20
|
+
/** Stable identifier (useful when a registry holds several guards). */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/**
|
|
23
|
+
* Non-throwing classification — returns the decision so a caller can inspect
|
|
24
|
+
* findings without catching. Does not enforce policy on its own.
|
|
25
|
+
*/
|
|
26
|
+
inspect(candidate: PiiCandidate): PiiClassification;
|
|
27
|
+
/**
|
|
28
|
+
* Default-DENY enforcement. Returns `void` when the candidate is clean;
|
|
29
|
+
* THROWS {@link PiiGuardError} (carrying the findings) when PII is detected.
|
|
30
|
+
* This is the method a write path calls immediately before committing.
|
|
31
|
+
*/
|
|
32
|
+
assert(candidate: PiiCandidate): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Options for {@link createPiiGuard}. Additive and backward-compatible; the
|
|
36
|
+
* default (no options) is the mandatory, no-PII-by-construction guard.
|
|
37
|
+
*/
|
|
38
|
+
export interface PiiGuardOptions {
|
|
39
|
+
/** Override the guard's `name` (defaults to `"pre-commit-pii-guard"`). */
|
|
40
|
+
readonly name?: string;
|
|
41
|
+
/**
|
|
42
|
+
* An ADDITIONAL classifier, unioned with the built-in {@link classifyPii}.
|
|
43
|
+
* The built-in classifier ALWAYS runs; this override's findings are merged on
|
|
44
|
+
* top, so an override can only make the guard STRICTER — it can never suppress
|
|
45
|
+
* a built-in detection or otherwise disable enforcement (a guard is always
|
|
46
|
+
* default-DENY). Intended for tests and for a future stricter policy.
|
|
47
|
+
*/
|
|
48
|
+
readonly classify?: (candidate: PiiCandidate) => PiiClassification;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a default-DENY PII guard. With no options this returns the same policy
|
|
52
|
+
* as {@link preCommitPiiGuard}: it classifies with the built-in classifier and
|
|
53
|
+
* rejects any candidate that carries PII. A caller-supplied `classify` runs IN
|
|
54
|
+
* ADDITION to the built-in classifier (findings are unioned), so it can only
|
|
55
|
+
* ever make the guard stricter — never weaker.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createPiiGuard(options?: PiiGuardOptions): PiiGuard;
|
|
58
|
+
/**
|
|
59
|
+
* The ready-to-use default guard. S3 registers THIS instance as the
|
|
60
|
+
* non-optional, default pre-commit step on every write path so that PII is
|
|
61
|
+
* blocked by construction. Do not gate it behind a caller opt-in.
|
|
62
|
+
*/
|
|
63
|
+
export declare const preCommitPiiGuard: PiiGuard;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Slice S6 CORE (pii guard-core) — the MANDATORY pre-commit PII guard.
|
|
2
|
+
//
|
|
3
|
+
// This is the enforcement seam S3 (git substrate / governance) consumes. It
|
|
4
|
+
// wraps the pure classifier (`./classifier.ts`) in a default-DENY policy: a
|
|
5
|
+
// candidate carrying PII is REJECTED (the guard throws); clean content passes
|
|
6
|
+
// through untouched.
|
|
7
|
+
//
|
|
8
|
+
// ┌────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
// │ CONTRACT — read before wiring: │
|
|
10
|
+
// │ guard.assert(candidate) → returns void when clean, THROWS │
|
|
11
|
+
// │ PiiGuardError when PII is detected. │
|
|
12
|
+
// │ guard.inspect(candidate) → non-throwing; returns the classification. │
|
|
13
|
+
// │ │
|
|
14
|
+
// │ S3 MUST register `preCommitPiiGuard` (or a `createPiiGuard()` instance) │
|
|
15
|
+
// │ as the NON-OPTIONAL, default pre-commit step on EVERY write path, so │
|
|
16
|
+
// │ PII is blocked BY CONSTRUCTION — not merely when a caller opts in. The │
|
|
17
|
+
// │ later S6 CI slice adds a build-time classification workflow + │
|
|
18
|
+
// │ erasure/immutability docs on TOP of this in-process guard; it must not │
|
|
19
|
+
// │ weaken or bypass it. │
|
|
20
|
+
// └────────────────────────────────────────────────────────────────────────┘
|
|
21
|
+
//
|
|
22
|
+
// This slice owns the classifier + guard ONLY. It has no git / network access
|
|
23
|
+
// and does NOT import `../git` (S3 imports us — the dependency points one way to
|
|
24
|
+
// keep the graph acyclic).
|
|
25
|
+
import { classifyPii, } from "./classifier.js";
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by {@link PiiGuard.assert} (and the default guard) when a candidate
|
|
28
|
+
* carries PII. Carries the located {@link PiiFinding}s so callers can log /
|
|
29
|
+
* surface exactly what was rejected without re-running the classifier.
|
|
30
|
+
*/
|
|
31
|
+
export class PiiGuardError extends Error {
|
|
32
|
+
/** The located detections that caused the rejection (never empty). */
|
|
33
|
+
findings;
|
|
34
|
+
constructor(findings) {
|
|
35
|
+
const summary = findings
|
|
36
|
+
.map((f) => `${f.kind}${f.path ? ` @ ${f.path}` : ""}`)
|
|
37
|
+
.join(", ");
|
|
38
|
+
super(`PII pre-commit guard rejected the write: ${summary}`);
|
|
39
|
+
this.name = "PiiGuardError";
|
|
40
|
+
// Defensively snapshot: a caller mutating the array it passed in must never
|
|
41
|
+
// retroactively change what this error reports it rejected.
|
|
42
|
+
this.findings = Object.freeze([...findings]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build a default-DENY PII guard. With no options this returns the same policy
|
|
47
|
+
* as {@link preCommitPiiGuard}: it classifies with the built-in classifier and
|
|
48
|
+
* rejects any candidate that carries PII. A caller-supplied `classify` runs IN
|
|
49
|
+
* ADDITION to the built-in classifier (findings are unioned), so it can only
|
|
50
|
+
* ever make the guard stricter — never weaker.
|
|
51
|
+
*/
|
|
52
|
+
export function createPiiGuard(options = {}) {
|
|
53
|
+
const name = options.name ?? "pre-commit-pii-guard";
|
|
54
|
+
const override = options.classify;
|
|
55
|
+
// The built-in classifier ALWAYS runs; an override can only ADD findings.
|
|
56
|
+
// This makes the `classify` option unable to disable the default-DENY guard.
|
|
57
|
+
const classify = (candidate) => {
|
|
58
|
+
const base = classifyPii(candidate);
|
|
59
|
+
if (override === undefined)
|
|
60
|
+
return base;
|
|
61
|
+
const extra = override(candidate);
|
|
62
|
+
const findings = [...base.findings, ...extra.findings];
|
|
63
|
+
// Derive the verdict from the merged findings — the single source of truth.
|
|
64
|
+
// A guard is default-DENY, so any `!clean` classification MUST carry the
|
|
65
|
+
// findings that justify it; a non-clean result with no findings would make
|
|
66
|
+
// `assert` throw a PiiGuardError whose summary is empty, contradicting the
|
|
67
|
+
// "findings are never empty" contract. Surface that override bug loudly
|
|
68
|
+
// rather than as a confusing, empty rejection.
|
|
69
|
+
if (findings.length === 0 && (!base.clean || !extra.clean)) {
|
|
70
|
+
throw new TypeError("PII classifier override returned a non-clean classification with no findings; " +
|
|
71
|
+
"an override may only ADD located findings, never report PII without locating it.");
|
|
72
|
+
}
|
|
73
|
+
return findings.length === 0
|
|
74
|
+
? { clean: true, findings: [] }
|
|
75
|
+
: { clean: false, findings };
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
inspect(candidate) {
|
|
80
|
+
return classify(candidate);
|
|
81
|
+
},
|
|
82
|
+
assert(candidate) {
|
|
83
|
+
const result = classify(candidate);
|
|
84
|
+
if (!result.clean)
|
|
85
|
+
throw new PiiGuardError(result.findings);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The ready-to-use default guard. S3 registers THIS instance as the
|
|
91
|
+
* non-optional, default pre-commit step on every write path so that PII is
|
|
92
|
+
* blocked by construction. Do not gate it behind a caller opt-in.
|
|
93
|
+
*/
|
|
94
|
+
export const preCommitPiiGuard = createPiiGuard();
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { type PiiKind, type PiiFinding, type PiiClassification, type PiiCandidate, classifyPii, isPiiClean, } from "./classifier.ts";
|
|
2
|
+
export { type PiiGuard, type PiiGuardOptions, PiiGuardError, createPiiGuard, preCommitPiiGuard, } from "./guard.ts";
|
|
@@ -1 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
// @nanobpm/urban/context/pii — slice S6 CORE (pii guard-core).
|
|
2
|
+
//
|
|
3
|
+
// The enforcement seam of the Urban context layer. It publishes two things:
|
|
4
|
+
//
|
|
5
|
+
// 1. A PURE PII classifier (`classifyPii`) — inspects candidate record content
|
|
6
|
+
// and returns a located clean/violation decision. Reusable on any hot path
|
|
7
|
+
// and by the S6 CI slice's build-time scan.
|
|
8
|
+
// 2. A MANDATORY, default-DENY pre-commit guard (`preCommitPiiGuard` /
|
|
9
|
+
// `createPiiGuard`) built on the classifier. S3 (git substrate / governance)
|
|
10
|
+
// registers this as the NON-OPTIONAL default pre-commit step on every write
|
|
11
|
+
// path, so PII is blocked BY CONSTRUCTION — not merely when a caller opts in.
|
|
12
|
+
//
|
|
13
|
+
// Contract summary (see ./guard.ts and ./NOTES.md for detail):
|
|
14
|
+
// guard.assert(candidate) → void when clean, THROWS PiiGuardError on PII.
|
|
15
|
+
// guard.inspect(candidate) → non-throwing PiiClassification.
|
|
16
|
+
//
|
|
17
|
+
// The MVP substrate is no-PII by construction; this slice builds neither a
|
|
18
|
+
// mutable/erasure backend nor the CI workflow (that is the later S6 CI slice,
|
|
19
|
+
// which layers a build-time net + erasure/immutability docs on top of this).
|
|
20
|
+
//
|
|
21
|
+
// This slice imports the record types from `../schema` but NOT `../git`: S3
|
|
22
|
+
// imports us, so the dependency points one way and the graph stays acyclic.
|
|
23
|
+
export { classifyPii, isPiiClean, } from "./classifier.js";
|
|
24
|
+
export { PiiGuardError, createPiiGuard, preCommitPiiGuard, } from "./guard.js";
|
|
@@ -592,6 +592,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
592
592
|
.pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
|
|
593
593
|
.pc-prompt { font-size:.85rem; color:var(--nano-text-muted); margin-bottom:.4rem; white-space:pre-wrap; }
|
|
594
594
|
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
|
|
595
|
+
/* Schema-derived multi-field detail.form widgets (#372): a short free-text
|
|
596
|
+
<input> and an enumerated-choice <select>, styled to match .pc-textarea so a
|
|
597
|
+
select/text/textarea form reads as one coherent set. A field hidden by its
|
|
598
|
+
conditional.hide expression carries the plain [hidden] attribute (display:none
|
|
599
|
+
by default), so no extra rule is needed to remove it from the layout. */
|
|
600
|
+
.pc-input, .pc-select { width:100%; padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
|
|
601
|
+
.pc-subform .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
595
602
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
596
603
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
597
604
|
.pc-card-body[hidden] { display:none; }
|