@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,144 @@
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
+
26
+ import {
27
+ classifyPii,
28
+ type PiiCandidate,
29
+ type PiiClassification,
30
+ type PiiFinding,
31
+ } from "./classifier.ts";
32
+
33
+ /**
34
+ * Thrown by {@link PiiGuard.assert} (and the default guard) when a candidate
35
+ * carries PII. Carries the located {@link PiiFinding}s so callers can log /
36
+ * surface exactly what was rejected without re-running the classifier.
37
+ */
38
+ export class PiiGuardError extends Error {
39
+ /** The located detections that caused the rejection (never empty). */
40
+ readonly findings: readonly PiiFinding[];
41
+
42
+ constructor(findings: readonly PiiFinding[]) {
43
+ const summary = findings
44
+ .map((f) => `${f.kind}${f.path ? ` @ ${f.path}` : ""}`)
45
+ .join(", ");
46
+ super(`PII pre-commit guard rejected the write: ${summary}`);
47
+ this.name = "PiiGuardError";
48
+ // Defensively snapshot: a caller mutating the array it passed in must never
49
+ // retroactively change what this error reports it rejected.
50
+ this.findings = Object.freeze([...findings]);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A pre-commit guard. The published seam S3 registers as its default,
56
+ * non-bypassable write-path check.
57
+ *
58
+ * Implementations MUST be default-DENY: {@link assert} throws on any detected
59
+ * PII and returns normally only for clean content.
60
+ */
61
+ export interface PiiGuard {
62
+ /** Stable identifier (useful when a registry holds several guards). */
63
+ readonly name: string;
64
+ /**
65
+ * Non-throwing classification — returns the decision so a caller can inspect
66
+ * findings without catching. Does not enforce policy on its own.
67
+ */
68
+ inspect(candidate: PiiCandidate): PiiClassification;
69
+ /**
70
+ * Default-DENY enforcement. Returns `void` when the candidate is clean;
71
+ * THROWS {@link PiiGuardError} (carrying the findings) when PII is detected.
72
+ * This is the method a write path calls immediately before committing.
73
+ */
74
+ assert(candidate: PiiCandidate): void;
75
+ }
76
+
77
+ /**
78
+ * Options for {@link createPiiGuard}. Additive and backward-compatible; the
79
+ * default (no options) is the mandatory, no-PII-by-construction guard.
80
+ */
81
+ export interface PiiGuardOptions {
82
+ /** Override the guard's `name` (defaults to `"pre-commit-pii-guard"`). */
83
+ readonly name?: string;
84
+ /**
85
+ * An ADDITIONAL classifier, unioned with the built-in {@link classifyPii}.
86
+ * The built-in classifier ALWAYS runs; this override's findings are merged on
87
+ * top, so an override can only make the guard STRICTER — it can never suppress
88
+ * a built-in detection or otherwise disable enforcement (a guard is always
89
+ * default-DENY). Intended for tests and for a future stricter policy.
90
+ */
91
+ readonly classify?: (candidate: PiiCandidate) => PiiClassification;
92
+ }
93
+
94
+ /**
95
+ * Build a default-DENY PII guard. With no options this returns the same policy
96
+ * as {@link preCommitPiiGuard}: it classifies with the built-in classifier and
97
+ * rejects any candidate that carries PII. A caller-supplied `classify` runs IN
98
+ * ADDITION to the built-in classifier (findings are unioned), so it can only
99
+ * ever make the guard stricter — never weaker.
100
+ */
101
+ export function createPiiGuard(options: PiiGuardOptions = {}): PiiGuard {
102
+ const name = options.name ?? "pre-commit-pii-guard";
103
+ const override = options.classify;
104
+ // The built-in classifier ALWAYS runs; an override can only ADD findings.
105
+ // This makes the `classify` option unable to disable the default-DENY guard.
106
+ const classify = (candidate: PiiCandidate): PiiClassification => {
107
+ const base = classifyPii(candidate);
108
+ if (override === undefined) return base;
109
+ const extra = override(candidate);
110
+ const findings = [...base.findings, ...extra.findings];
111
+ // Derive the verdict from the merged findings — the single source of truth.
112
+ // A guard is default-DENY, so any `!clean` classification MUST carry the
113
+ // findings that justify it; a non-clean result with no findings would make
114
+ // `assert` throw a PiiGuardError whose summary is empty, contradicting the
115
+ // "findings are never empty" contract. Surface that override bug loudly
116
+ // rather than as a confusing, empty rejection.
117
+ if (findings.length === 0 && (!base.clean || !extra.clean)) {
118
+ throw new TypeError(
119
+ "PII classifier override returned a non-clean classification with no findings; " +
120
+ "an override may only ADD located findings, never report PII without locating it.",
121
+ );
122
+ }
123
+ return findings.length === 0
124
+ ? { clean: true, findings: [] }
125
+ : { clean: false, findings };
126
+ };
127
+ return {
128
+ name,
129
+ inspect(candidate) {
130
+ return classify(candidate);
131
+ },
132
+ assert(candidate) {
133
+ const result = classify(candidate);
134
+ if (!result.clean) throw new PiiGuardError(result.findings);
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The ready-to-use default guard. S3 registers THIS instance as the
141
+ * non-optional, default pre-commit step on every write path so that PII is
142
+ * blocked by construction. Do not gate it behind a caller opt-in.
143
+ */
144
+ export const preCommitPiiGuard: PiiGuard = createPiiGuard();
@@ -1,2 +1,39 @@
1
- // Slice S6 (pii guard-core, then S6 CI) — owner replaces this placeholder barrel.
2
- export {};
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
+
24
+ export {
25
+ type PiiKind,
26
+ type PiiFinding,
27
+ type PiiClassification,
28
+ type PiiCandidate,
29
+ classifyPii,
30
+ isPiiClean,
31
+ } from "./classifier.ts";
32
+
33
+ export {
34
+ type PiiGuard,
35
+ type PiiGuardOptions,
36
+ PiiGuardError,
37
+ createPiiGuard,
38
+ preCommitPiiGuard,
39
+ } from "./guard.ts";