@loomwork/classify 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +73 -2
- package/dist/classify.d.ts +47 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +147 -0
- package/dist/classify.js.map +1 -0
- package/dist/esm/classify.js +135 -0
- package/dist/esm/classify.js.map +1 -0
- package/dist/esm/expand.js +87 -0
- package/dist/esm/expand.js.map +1 -0
- package/dist/esm/external.js +33 -0
- package/dist/esm/external.js.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/redact.js +56 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/taint.js +142 -0
- package/dist/esm/taint.js.map +1 -0
- package/dist/esm/unreachable.js +126 -0
- package/dist/esm/unreachable.js.map +1 -0
- package/dist/expand.d.ts +16 -0
- package/dist/expand.d.ts.map +1 -0
- package/dist/expand.js +91 -0
- package/dist/expand.js.map +1 -0
- package/dist/external.d.ts +15 -0
- package/dist/external.d.ts.map +1 -0
- package/dist/external.js +38 -0
- package/dist/external.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +60 -0
- package/dist/redact.js.map +1 -0
- package/dist/taint.d.ts +85 -0
- package/dist/taint.d.ts.map +1 -0
- package/dist/taint.js +152 -0
- package/dist/taint.js.map +1 -0
- package/dist/unreachable.d.ts +20 -0
- package/dist/unreachable.d.ts.map +1 -0
- package/dist/unreachable.js +129 -0
- package/dist/unreachable.js.map +1 -0
- package/package.json +55 -3
- package/src/classify.ts +175 -0
- package/src/expand.ts +97 -0
- package/src/external.ts +40 -0
- package/src/index.ts +31 -0
- package/src/redact.ts +59 -0
- package/src/taint.ts +189 -0
- package/src/unreachable.ts +164 -0
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomwork/classify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Data-handling vocabulary for Loom — the sensitive()/secret()/internal() confidentiality markers + a schema walker, so a field is classified ONCE at its schema and every mechanism (crypto, redaction, egress) derives its wiring from that; plus the integrity axis (tainted()/unwrapTainted()), which attaches provenance to a VALUE at ingress because trustworthiness is not a property a schema can know. Kernel stays ignorant of both (G3).",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"module": "./dist/esm/index.js",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"require": "./dist/index.js",
|
|
13
|
+
"node": "./dist/index.js",
|
|
14
|
+
"import": "./dist/esm/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"zod": "^3.23.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.6.3",
|
|
23
|
+
"vitest": "^2.1.8"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/chhetrisushil/loom.git",
|
|
29
|
+
"directory": "packages/classify"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!src/**/*.test.tsx",
|
|
36
|
+
"!src/**/__tests__",
|
|
37
|
+
"!src/**/__fixtures__",
|
|
38
|
+
"!src/**/__snapshots__",
|
|
39
|
+
"!src/**/__golden__",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"tag": "next"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc --build && rm -rf dist/esm && tsc -p tsconfig.esm.json",
|
|
52
|
+
"build:esm": "rm -rf dist/esm && tsc -p tsconfig.esm.json",
|
|
53
|
+
"dev": "tsc --build --watch",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
56
|
+
"typecheck:tests": "tsc -p tsconfig.test.json"
|
|
57
|
+
}
|
|
6
58
|
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ── K10-R1 — the data-protection classification taxonomy ──────────────────────
|
|
4
|
+
//
|
|
5
|
+
// A field's sensitivity is authored ONCE, at its schema, instead of hand-wired as
|
|
6
|
+
// dot-paths at every store/middleware seam. Three *handling* classes, each named for
|
|
7
|
+
// the downstream mechanism that acts on it — so a marker tells you what will happen,
|
|
8
|
+
// not just "this is secret-ish":
|
|
9
|
+
//
|
|
10
|
+
// • "secret" — credential material that MUST NOT be persisted at all. Substituted
|
|
11
|
+
// out before the value reaches the log (K10-R2 secretRef / K10.3 redaction).
|
|
12
|
+
// • "sensitive" — personal/confidential data, persisted only encrypted-at-rest and
|
|
13
|
+
// crypto-shreddable (K10.2), and scrubbed from external egress (K10-R6).
|
|
14
|
+
// • "internal" — persisted in the clear but scrubbed from EXTERNAL egress
|
|
15
|
+
// (telemetry / agent-surface / CLI, K10-R6); never crosses the trust boundary.
|
|
16
|
+
//
|
|
17
|
+
// The kernel stays ignorant of these names (G3 — the mechanism lives below in the crypto
|
|
18
|
+
// plugin / middleware; the policy *vocabulary* lives here, above the kernel). A field with
|
|
19
|
+
// no marker is unclassified and handled by nothing — omitting the marker is a true no-op.
|
|
20
|
+
|
|
21
|
+
/** The three data-protection handling classes, ordered most-restrictive first. */
|
|
22
|
+
export type DataClass = "secret" | "sensitive" | "internal";
|
|
23
|
+
|
|
24
|
+
/** Rank for "the strictest class wins" merges (secret > sensitive > internal). */
|
|
25
|
+
const RANK: Record<DataClass, number> = { secret: 3, sensitive: 2, internal: 1 };
|
|
26
|
+
|
|
27
|
+
// A schema instance → its class. A WeakMap (not a mutated property) keeps the marker
|
|
28
|
+
// non-invasive: the Zod schema is returned byte-identical, its validation untouched, and
|
|
29
|
+
// a schema that is GC'd drops its entry. Keyed by the exact instance the author passed,
|
|
30
|
+
// which is the same instance a walker meets in `.shape`, so lookups line up.
|
|
31
|
+
const registry = new WeakMap<object, DataClass>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tag a schema with a {@link DataClass}. Returns the SAME schema (validation unchanged) so
|
|
35
|
+
* it drops into a `z.object({ … })` shape transparently. Prefer the {@link secret} /
|
|
36
|
+
* {@link sensitive} / {@link internal} sugar; use this directly only for a computed class.
|
|
37
|
+
*/
|
|
38
|
+
export function classify<T extends z.ZodTypeAny>(schema: T, dataClass: DataClass): T {
|
|
39
|
+
registry.set(schema, dataClass);
|
|
40
|
+
return schema;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mark a field `"secret"` — never persisted; substituted before it reaches the log. */
|
|
44
|
+
export const secret = <T extends z.ZodTypeAny>(schema: T): T => classify(schema, "secret");
|
|
45
|
+
/** Mark a field `"sensitive"` — encrypted-at-rest, crypto-shreddable, scrubbed from egress. */
|
|
46
|
+
export const sensitive = <T extends z.ZodTypeAny>(schema: T): T => classify(schema, "sensitive");
|
|
47
|
+
/** Mark a field `"internal"` — persisted in the clear but never egressed externally. */
|
|
48
|
+
export const internal = <T extends z.ZodTypeAny>(schema: T): T => classify(schema, "internal");
|
|
49
|
+
|
|
50
|
+
/** The class a schema was tagged with, or `undefined` if unclassified. */
|
|
51
|
+
export function classOf(schema: unknown): DataClass | undefined {
|
|
52
|
+
return typeof schema === "object" && schema !== null ? registry.get(schema as object) : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Unwrap the transparent wrappers Zod adds (.optional()/.nullable()/.default()/.refine())
|
|
56
|
+
// so a marker survives being wrapped AFTER classification, and so an object nested under a
|
|
57
|
+
// wrapper is still walked. Returns the wrapper chain (outermost→innermost) for class lookup
|
|
58
|
+
// plus the innermost node for shape inspection.
|
|
59
|
+
function chain(schema: z.ZodTypeAny): { nodes: z.ZodTypeAny[]; inner: z.ZodTypeAny } {
|
|
60
|
+
const nodes: z.ZodTypeAny[] = [];
|
|
61
|
+
let cur: z.ZodTypeAny = schema;
|
|
62
|
+
// Guard against a pathological self-referential def; real schemas terminate quickly.
|
|
63
|
+
for (let i = 0; i < 100; i++) {
|
|
64
|
+
nodes.push(cur);
|
|
65
|
+
const def = (cur as { _def?: Record<string, unknown> })._def;
|
|
66
|
+
const typeName = def?.typeName as string | undefined;
|
|
67
|
+
let next: unknown;
|
|
68
|
+
if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
|
|
69
|
+
next = def?.innerType;
|
|
70
|
+
} else if (typeName === "ZodEffects") {
|
|
71
|
+
next = def?.schema;
|
|
72
|
+
} else {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (next === undefined) break;
|
|
76
|
+
cur = next as z.ZodTypeAny;
|
|
77
|
+
}
|
|
78
|
+
return { nodes, inner: cur };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The object `.shape` of a node, or `undefined` when it is not a `ZodObject`. */
|
|
82
|
+
function objectShape(node: z.ZodTypeAny): Record<string, z.ZodTypeAny> | undefined {
|
|
83
|
+
const def = (node as { _def?: { typeName?: string; shape?: () => unknown } })._def;
|
|
84
|
+
if (def?.typeName !== "ZodObject" || typeof def.shape !== "function") return undefined;
|
|
85
|
+
return def.shape() as Record<string, z.ZodTypeAny>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The mark on a schema, looking through any transparent wrappers around it (first wins). */
|
|
89
|
+
function effectiveMark<T>(schema: z.ZodTypeAny, markOf: (node: z.ZodTypeAny) => T | undefined) {
|
|
90
|
+
for (const node of chain(schema).nodes) {
|
|
91
|
+
const mark = markOf(node);
|
|
92
|
+
if (mark !== undefined) return mark;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generic marked-path walker — the shared engine behind {@link collectClassifiedPaths} and the
|
|
99
|
+
* {@link externalized} marker. Walks an object schema, and for every node whose `markOf`
|
|
100
|
+
* returns a value emits `{ path, mark }`, recursing through nested objects and transparent
|
|
101
|
+
* wrappers. A marked *object* is reported at its own path and NOT descended into (the coarser
|
|
102
|
+
* mark wins — a marked address object is treated as a unit). Recurses THROUGH arrays with a
|
|
103
|
+
* `[]` crossing segment (`people[].ssn`), which the sinks expand against the concrete value
|
|
104
|
+
* (#260) — a mark inside an array element used to collect no path and silently do nothing.
|
|
105
|
+
* Deterministic: schema-declaration order (P2).
|
|
106
|
+
*/
|
|
107
|
+
export function collectMarkedPaths<T>(
|
|
108
|
+
schema: z.ZodTypeAny,
|
|
109
|
+
markOf: (node: z.ZodTypeAny) => T | undefined,
|
|
110
|
+
prefix = ""
|
|
111
|
+
): { path: string; mark: T }[] {
|
|
112
|
+
const out: { path: string; mark: T }[] = [];
|
|
113
|
+
const mark = effectiveMark(schema, markOf);
|
|
114
|
+
if (mark !== undefined && prefix !== "") {
|
|
115
|
+
out.push({ path: prefix, mark });
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const inner = chain(schema).inner;
|
|
119
|
+
const element = arrayElement(inner);
|
|
120
|
+
if (element !== undefined) {
|
|
121
|
+
// Cross the array. The prefix gains `[]`, so `people` + element mark `ssn` yields
|
|
122
|
+
// `people[].ssn`; a mark on the ELEMENT type itself yields `people[]`.
|
|
123
|
+
return collectMarkedPaths(element, markOf, `${prefix}[]`);
|
|
124
|
+
}
|
|
125
|
+
const shape = objectShape(inner);
|
|
126
|
+
if (shape !== undefined) {
|
|
127
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
128
|
+
const childPath = prefix === "" ? key : `${prefix}.${key}`;
|
|
129
|
+
out.push(...collectMarkedPaths(child, markOf, childPath));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The element type, if this node is an array. */
|
|
136
|
+
function arrayElement(node: z.ZodTypeAny): z.ZodTypeAny | undefined {
|
|
137
|
+
const def = (node as { _def?: { typeName?: string; type?: unknown } })._def;
|
|
138
|
+
if (def?.typeName !== "ZodArray") return undefined;
|
|
139
|
+
return def.type as z.ZodTypeAny | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A classified leaf/subtree and its dot-path from the walked root. */
|
|
143
|
+
export interface ClassifiedPath {
|
|
144
|
+
/** Dot-path from the schema root, e.g. `"user.ssn"`. Empty for the root itself. */
|
|
145
|
+
path: string;
|
|
146
|
+
dataClass: DataClass;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Walk an object schema and collect every classified field as a `{ path, dataClass }` (over the
|
|
151
|
+
* `secret`/`sensitive`/`internal` taxonomy). Thin wrapper over {@link collectMarkedPaths}.
|
|
152
|
+
*/
|
|
153
|
+
export function collectClassifiedPaths(schema: z.ZodTypeAny, prefix = ""): ClassifiedPath[] {
|
|
154
|
+
return collectMarkedPaths(schema, (node) => classOf(node), prefix).map((m) => ({
|
|
155
|
+
path: m.path,
|
|
156
|
+
dataClass: m.mark,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The subset of {@link collectClassifiedPaths} matching one class (e.g. only `"sensitive"`). */
|
|
161
|
+
export function pathsOfClass(schema: z.ZodTypeAny, dataClass: DataClass): string[] {
|
|
162
|
+
return collectClassifiedPaths(schema)
|
|
163
|
+
.filter((c) => c.dataClass === dataClass)
|
|
164
|
+
.map((c) => c.path);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Strictest of two classes (secret > sensitive > internal); `undefined` is weakest. */
|
|
168
|
+
export function strictest(
|
|
169
|
+
a: DataClass | undefined,
|
|
170
|
+
b: DataClass | undefined
|
|
171
|
+
): DataClass | undefined {
|
|
172
|
+
if (a === undefined) return b;
|
|
173
|
+
if (b === undefined) return a;
|
|
174
|
+
return RANK[a] >= RANK[b] ? a : b;
|
|
175
|
+
}
|
package/src/expand.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ── Wildcard path expansion (#260 option 2) ───────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The dot-path sinks — telemetry masking, field-level encryption, content externalization —
|
|
4
|
+
// address scalar paths and originally could not descend into array elements, so a mark
|
|
5
|
+
// placed inside one did nothing at all. `[]` is the array-crossing segment that fixes it:
|
|
6
|
+
// `people[].ssn` means "the `ssn` of every element of `people`".
|
|
7
|
+
//
|
|
8
|
+
// Rather than teach three near-identical spine-walkers about arrays, expansion happens ONCE
|
|
9
|
+
// against the concrete value and yields ordinary indexed paths (`people.0.ssn`,
|
|
10
|
+
// `people.1.ssn`) that every existing applier already handles — `array["0"]` is a normal
|
|
11
|
+
// property access, and each applier's spine copy already preserves arrays. So the walkers
|
|
12
|
+
// stay as they were, and there is one place where array semantics live.
|
|
13
|
+
//
|
|
14
|
+
// A path with no `[]` passes through untouched, so every pre-existing path behaves exactly
|
|
15
|
+
// as before (G2).
|
|
16
|
+
|
|
17
|
+
/** Split a segment into its key and how many array crossings follow it (`rows[][]` ⇒ 2). */
|
|
18
|
+
function parseSegment(segment: string): { key: string; depth: number } {
|
|
19
|
+
let depth = 0;
|
|
20
|
+
let key = segment;
|
|
21
|
+
while (key.endsWith("[]")) {
|
|
22
|
+
key = key.slice(0, -2);
|
|
23
|
+
depth++;
|
|
24
|
+
}
|
|
25
|
+
return { key, depth };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expand `paths` against `root`, replacing every `[]` with the concrete indices present in
|
|
30
|
+
* the value.
|
|
31
|
+
*
|
|
32
|
+
* - `"a.b"` (no wildcard) → `["a.b"]`, unchanged and unchecked; the appliers already skip
|
|
33
|
+
* absent paths, and pre-existing behaviour must not shift.
|
|
34
|
+
* - `"people[].ssn"` over two people → `["people.0.ssn", "people.1.ssn"]`.
|
|
35
|
+
* - `"tags[]"` (a marked array ELEMENT type) → one path per element.
|
|
36
|
+
* - A wildcard that does not resolve — absent, or `[]` over a non-array — expands to
|
|
37
|
+
* nothing, so nothing is masked/encrypted for it. Resolving against the value is the whole
|
|
38
|
+
* point of expanding late.
|
|
39
|
+
*/
|
|
40
|
+
export function expandPaths(root: unknown, paths: readonly string[]): string[] {
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
if (!path.includes("[]")) {
|
|
44
|
+
out.push(path);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
expandInto(root, path.split("."), 0, [], out);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True if any path uses a wildcard — lets callers skip expansion entirely (G2 fast path). */
|
|
53
|
+
export function hasWildcard(paths: readonly string[]): boolean {
|
|
54
|
+
for (const p of paths) if (p.includes("[]")) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expandInto(
|
|
59
|
+
value: unknown,
|
|
60
|
+
segments: readonly string[],
|
|
61
|
+
index: number,
|
|
62
|
+
acc: readonly string[],
|
|
63
|
+
out: string[]
|
|
64
|
+
): void {
|
|
65
|
+
if (index >= segments.length) {
|
|
66
|
+
out.push(acc.join("."));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== "object" || value === null) return;
|
|
70
|
+
const { key, depth } = parseSegment(segments[index] as string);
|
|
71
|
+
if (!Object.hasOwn(value, key)) return;
|
|
72
|
+
const child = (value as Record<string, unknown>)[key];
|
|
73
|
+
descend(child, depth, [...acc, key], segments, index, out);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Walk `depth` array levels, emitting an index per level, then continue with the next segment. */
|
|
77
|
+
function descend(
|
|
78
|
+
value: unknown,
|
|
79
|
+
depth: number,
|
|
80
|
+
acc: readonly string[],
|
|
81
|
+
segments: readonly string[],
|
|
82
|
+
index: number,
|
|
83
|
+
out: string[]
|
|
84
|
+
): void {
|
|
85
|
+
if (depth === 0) {
|
|
86
|
+
// Reached the value this segment names. If it is the last segment it IS the target;
|
|
87
|
+
// otherwise keep walking.
|
|
88
|
+
if (index === segments.length - 1) out.push(acc.join("."));
|
|
89
|
+
else expandInto(value, segments, index + 1, acc, out);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// A `[]` over a non-array resolves to nothing — the mark does not apply to this value.
|
|
93
|
+
if (!Array.isArray(value)) return;
|
|
94
|
+
for (const [i, element] of value.entries()) {
|
|
95
|
+
descend(element, depth - 1, [...acc, String(i)], segments, index, out);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/external.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { collectMarkedPaths } from "./classify";
|
|
3
|
+
|
|
4
|
+
// ── ADR 0046 — the `externalized()` marker ────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// Orthogonal to the secret/sensitive/internal taxonomy: that axis says HOW sensitive a field
|
|
7
|
+
// is; this one says WHERE its value is stored — externalized to a content store, with the log
|
|
8
|
+
// keeping only a reference. A field can be both (a large AND sensitive document: externalize
|
|
9
|
+
// the bytes, and encrypt them in the content store). Because it is a separate axis, it has its
|
|
10
|
+
// own registry rather than a fourth DataClass.
|
|
11
|
+
//
|
|
12
|
+
// A non-invasive WeakMap keeps the marker from mutating the Zod schema (same instance returned,
|
|
13
|
+
// validation untouched), exactly like the classification registry.
|
|
14
|
+
|
|
15
|
+
const externalRegistry = new WeakMap<object, true>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mark a field `externalized` — its value is moved to a content store on append and the log
|
|
19
|
+
* keeps a content-addressed reference (ADR 0046). Returns the SAME schema (validation
|
|
20
|
+
* unchanged). Marking an object externalizes its whole subtree as one blob.
|
|
21
|
+
*/
|
|
22
|
+
export function externalized<T extends z.ZodTypeAny>(schema: T): T {
|
|
23
|
+
externalRegistry.set(schema, true);
|
|
24
|
+
return schema;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a schema was marked {@link externalized} (through transparent wrappers is handled by the walker). */
|
|
28
|
+
export function isExternalized(schema: unknown): boolean {
|
|
29
|
+
return typeof schema === "object" && schema !== null && externalRegistry.get(schema) === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The dot-paths of every `externalized()`-marked field in a schema (declaration order,
|
|
34
|
+
* deterministic). A marked object is reported at its own path as a unit.
|
|
35
|
+
*/
|
|
36
|
+
export function externalizedPaths(schema: z.ZodTypeAny): string[] {
|
|
37
|
+
return collectMarkedPaths(schema, (node) =>
|
|
38
|
+
externalRegistry.get(node) === true ? true : undefined
|
|
39
|
+
).map((m) => m.path);
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export {
|
|
2
|
+
classify,
|
|
3
|
+
classOf,
|
|
4
|
+
collectClassifiedPaths,
|
|
5
|
+
collectMarkedPaths,
|
|
6
|
+
internal,
|
|
7
|
+
pathsOfClass,
|
|
8
|
+
secret,
|
|
9
|
+
sensitive,
|
|
10
|
+
strictest,
|
|
11
|
+
} from "./classify";
|
|
12
|
+
export type { ClassifiedPath, DataClass } from "./classify";
|
|
13
|
+
export { externalized, externalizedPaths, isExternalized } from "./external";
|
|
14
|
+
export {
|
|
15
|
+
TAINT_SCAN_EXCEEDED_ORIGIN,
|
|
16
|
+
TAINT_SCAN_MAX_DEPTH,
|
|
17
|
+
TAINT_SCAN_MAX_NODES,
|
|
18
|
+
isTainted,
|
|
19
|
+
nestedTaintOrigin,
|
|
20
|
+
taintOriginLabel,
|
|
21
|
+
TAINT_ORIGIN_LABEL_MAX,
|
|
22
|
+
scanTaint,
|
|
23
|
+
tainted,
|
|
24
|
+
taintOf,
|
|
25
|
+
unwrapTainted,
|
|
26
|
+
} from "./taint";
|
|
27
|
+
export type { Taint, TaintScan, TaintScanOptions, TaintedValue } from "./taint";
|
|
28
|
+
export { REDACTED, maskPaths } from "./redact";
|
|
29
|
+
export { expandPaths, hasWildcard } from "./expand";
|
|
30
|
+
export { collectUnreachableMarks } from "./unreachable";
|
|
31
|
+
export type { UnreachableMark } from "./unreachable";
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { expandPaths, hasWildcard } from "./expand";
|
|
2
|
+
// ── K10-R6 — the shared egress-masking primitive ──────────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// One structural path-masker, used by every egress surface (telemetry adapters, agent-surface
|
|
5
|
+
// surfaces, the CLI). It replaces the value at each dot-path with a mask, copying only the
|
|
6
|
+
// spine it touches (structural sharing elsewhere) and never mutating the caller's object.
|
|
7
|
+
// Absent paths are skipped, so masking a payload that doesn't carry a marked field is a
|
|
8
|
+
// no-op on that field. Lives here in `@loomwork/classify` — the neutral home the classification
|
|
9
|
+
// consumers already depend on — so no surface package depends on another.
|
|
10
|
+
|
|
11
|
+
/** The value an egress-masked field is replaced by (structure kept, value gone). */
|
|
12
|
+
export const REDACTED = "[redacted]" as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return a copy of `root` with each dot-path's value replaced by `mask`. A dot-path always
|
|
16
|
+
* roots at an object (an array/primitive root has nothing to address), so those are returned
|
|
17
|
+
* as-is. Paths are applied independently; a shared prefix is copied once.
|
|
18
|
+
*/
|
|
19
|
+
/** Copy one spine node, preserving arrays. */
|
|
20
|
+
function shallowCopy(value: object): Record<string, unknown> {
|
|
21
|
+
return Array.isArray(value)
|
|
22
|
+
? ([...value] as unknown as Record<string, unknown>)
|
|
23
|
+
: { ...(value as Record<string, unknown>) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function maskPaths(
|
|
27
|
+
root: unknown,
|
|
28
|
+
paths: readonly string[],
|
|
29
|
+
mask: unknown = REDACTED
|
|
30
|
+
): unknown {
|
|
31
|
+
if (typeof root !== "object" || root === null || Array.isArray(root)) return root;
|
|
32
|
+
const out: Record<string, unknown> = { ...(root as Record<string, unknown>) };
|
|
33
|
+
// `[]` segments are resolved against the concrete value first (#260); a path without one
|
|
34
|
+
// passes through untouched, so pre-existing behaviour is unchanged.
|
|
35
|
+
for (const path of hasWildcard(paths) ? expandPaths(root, paths) : paths) {
|
|
36
|
+
const segs = path.split(".");
|
|
37
|
+
let src = root as Record<string, unknown>;
|
|
38
|
+
let dst = out;
|
|
39
|
+
let reachable = true;
|
|
40
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
41
|
+
const seg = segs[i] as string;
|
|
42
|
+
const child = src[seg];
|
|
43
|
+
if (typeof child !== "object" || child === null) {
|
|
44
|
+
reachable = false;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
// Copy this spine node once (first path to reach it); later paths sharing the prefix
|
|
48
|
+
// descend into the already-copied node.
|
|
49
|
+
// Copy arrays AS arrays. `{ ...array }` would yield an object with numeric keys —
|
|
50
|
+
// harmless while paths never crossed arrays, corrupting now that they do.
|
|
51
|
+
if (dst[seg] === child) dst[seg] = shallowCopy(child as object);
|
|
52
|
+
src = child as Record<string, unknown>;
|
|
53
|
+
dst = dst[seg] as Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
const leaf = segs[segs.length - 1] as string;
|
|
56
|
+
if (reachable && Object.hasOwn(src, leaf)) dst[leaf] = mask;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
package/src/taint.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// ── The integrity axis — provenance for content the system did not author ────
|
|
2
|
+
//
|
|
3
|
+
// The existing markers are a CONFIDENTIALITY axis (`secret`/`sensitive`/
|
|
4
|
+
// `internal` — what may LEAVE) plus a where-stored axis (`externalized`). This
|
|
5
|
+
// is the third and opposite direction: what may ENTER an instruction position.
|
|
6
|
+
// A third-party MCP tool result, an A2A peer's response, a fetched document —
|
|
7
|
+
// all arrive as ordinary strings, and an ordinary string interpolated into a
|
|
8
|
+
// system prompt is indistinguishable from an instruction the operator wrote.
|
|
9
|
+
//
|
|
10
|
+
// WHY A VALUE WRAPPER AND NOT A SCHEMA MARKER. Confidentiality is a property of
|
|
11
|
+
// a FIELD: `ssn` is sensitive in every execution, forever, so declaring it once
|
|
12
|
+
// at the schema is exactly right. Integrity is a property of a VALUE: the same
|
|
13
|
+
// `summary: string` field is trustworthy when this flow computed it and
|
|
14
|
+
// untrusted when a third-party server returned it, and which one it is cannot be
|
|
15
|
+
// known until runtime. A schema marker would have to guess, and a marker that
|
|
16
|
+
// guesses is worse than none — it reads as coverage. So taint is attached where
|
|
17
|
+
// it is acquired, to the value, by the code that acquired it.
|
|
18
|
+
//
|
|
19
|
+
// The wrapper is plain JSON (`{ __loomTainted, value }`), the same structural
|
|
20
|
+
// shape K10.3's `secretRef` uses, so it survives the log verbatim and a replay
|
|
21
|
+
// hands the effect back a value that is still tainted. The corollary is the
|
|
22
|
+
// honest limit on retrofitting: taint applies to content ingested AFTER a
|
|
23
|
+
// producer starts applying it. An existing log replays exactly as it did, with
|
|
24
|
+
// no taint, because the fact was never recorded — the same argument ADR 0063 D3
|
|
25
|
+
// made about principals.
|
|
26
|
+
|
|
27
|
+
/** Where a tainted value came from — an origin label, e.g. `mcp:acme/chart`. */
|
|
28
|
+
export interface Taint {
|
|
29
|
+
/**
|
|
30
|
+
* Human-meaningful provenance, chosen by the producer. It is rendered into
|
|
31
|
+
* the fence around the content, so treat it as untrusted display text: it
|
|
32
|
+
* frequently contains a third-party-controlled fragment (a tool name, a peer
|
|
33
|
+
* id) and is escaped at the point of use, never trusted as markup.
|
|
34
|
+
*/
|
|
35
|
+
origin: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A value carrying its own provenance. Plain JSON — survives the log unchanged. */
|
|
39
|
+
export interface TaintedValue<T = unknown> {
|
|
40
|
+
__loomTainted: Taint;
|
|
41
|
+
value: T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wrap a value as untrusted, recording where it came from.
|
|
46
|
+
*
|
|
47
|
+
* Apply this at INGRESS — the moment content crosses into the system from a
|
|
48
|
+
* party that is not the operator. Tainting later is guesswork, and tainting
|
|
49
|
+
* never is how third-party text ends up in a system prompt.
|
|
50
|
+
*
|
|
51
|
+
* Re-tainting an already-tainted value keeps the ORIGINAL origin: provenance
|
|
52
|
+
* records where content entered, and a re-wrap would let a later, more
|
|
53
|
+
* flattering label overwrite the true one.
|
|
54
|
+
*/
|
|
55
|
+
export function tainted<T>(value: T, origin: string): TaintedValue<T> {
|
|
56
|
+
if (isTainted(value)) return value as TaintedValue<T>;
|
|
57
|
+
return { __loomTainted: { origin }, value };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a value carries taint. Structural, so it survives a JSON round-trip. */
|
|
61
|
+
export function isTainted(value: unknown): value is TaintedValue {
|
|
62
|
+
if (typeof value !== "object" || value === null) return false;
|
|
63
|
+
const mark = (value as { __loomTainted?: unknown }).__loomTainted;
|
|
64
|
+
return (
|
|
65
|
+
typeof mark === "object" &&
|
|
66
|
+
mark !== null &&
|
|
67
|
+
typeof (mark as { origin?: unknown }).origin === "string" &&
|
|
68
|
+
"value" in value
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The provenance on a value, or `undefined` when it carries none. */
|
|
73
|
+
export function taintOf(value: unknown): Taint | undefined {
|
|
74
|
+
return isTainted(value) ? value.__loomTainted : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The inner value, discarding provenance. Non-tainted values pass through.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately loud. Unwrapping is legitimate and often necessary — parsing a
|
|
81
|
+
* tainted JSON payload, indexing a tainted record — but every call is a place
|
|
82
|
+
* where provenance stops travelling. Unwrap to COMPUTE with content; never
|
|
83
|
+
* unwrap on the way to a prompt, which is the one path the taint exists to
|
|
84
|
+
* govern. Nothing can stop you; the point is that the call site is visible.
|
|
85
|
+
*/
|
|
86
|
+
export function unwrapTainted<T>(value: TaintedValue<T> | T): T {
|
|
87
|
+
return isTainted(value) ? (value.value as T) : (value as T);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Nested taint - a marker anywhere inside a value ──────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// `isTainted` looks at the TOP of a value only. A producer that taints part of what it returns (an A2A
|
|
93
|
+
// reply taints `content` and leaves `raw` for control flow, ADR 0085) hands back a plain object with a
|
|
94
|
+
// wrapper buried inside, and every top-level check reads it as clean. `scanTaint` is the one walk the
|
|
95
|
+
// agent loop and the claim-check middleware share to answer "is ANY of this untrusted".
|
|
96
|
+
|
|
97
|
+
/** Bounds of a {@link scanTaint} walk. */
|
|
98
|
+
export interface TaintScanOptions {
|
|
99
|
+
/** Deepest nesting visited (the root is depth 0). Default {@link TAINT_SCAN_MAX_DEPTH}. */
|
|
100
|
+
maxDepth?: number;
|
|
101
|
+
/** Most object/array nodes visited. Default {@link TAINT_SCAN_MAX_NODES}. */
|
|
102
|
+
maxNodes?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Default nesting bound of {@link scanTaint}. */
|
|
106
|
+
export const TAINT_SCAN_MAX_DEPTH = 64;
|
|
107
|
+
/** Default node bound of {@link scanTaint}. */
|
|
108
|
+
export const TAINT_SCAN_MAX_NODES = 100_000;
|
|
109
|
+
/** The origin reported when a scan hit its bound and could not prove the value clean. */
|
|
110
|
+
export const TAINT_SCAN_EXCEEDED_ORIGIN = "unknown:taint-scan-bound-exceeded";
|
|
111
|
+
|
|
112
|
+
/** What {@link scanTaint} found. */
|
|
113
|
+
export interface TaintScan {
|
|
114
|
+
/** Distinct origins of every wrapper found, in discovery order. */
|
|
115
|
+
readonly origins: readonly string[];
|
|
116
|
+
/** The walk hit a bound before it finished: the value is NOT proven clean. */
|
|
117
|
+
readonly exceeded: boolean;
|
|
118
|
+
/** `origins` is non-empty or `exceeded` - the value must be treated as untrusted. */
|
|
119
|
+
readonly tainted: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Walk `value` for taint wrappers at ANY depth. Iterative (no recursion, so a 100k-deep JSON cannot
|
|
124
|
+
* blow the stack), cycle-safe, and bounded by depth and node count. FAILS CLOSED: when a bound is hit
|
|
125
|
+
* the result is `exceeded: true` and `tainted: true`, because "too big to look at" is not "clean".
|
|
126
|
+
* Binary views are skipped (they cannot hold a wrapper). Pure: the value is never modified.
|
|
127
|
+
*/
|
|
128
|
+
export function scanTaint(value: unknown, options: TaintScanOptions = {}): TaintScan {
|
|
129
|
+
const maxDepth = options.maxDepth ?? TAINT_SCAN_MAX_DEPTH;
|
|
130
|
+
const maxNodes = options.maxNodes ?? TAINT_SCAN_MAX_NODES;
|
|
131
|
+
const origins: string[] = [];
|
|
132
|
+
const found = new Set<string>();
|
|
133
|
+
let exceeded = false;
|
|
134
|
+
let nodes = 0;
|
|
135
|
+
const seen = new WeakSet<object>();
|
|
136
|
+
const stack: Array<{ v: unknown; d: number }> = [{ v: value, d: 0 }];
|
|
137
|
+
while (stack.length > 0) {
|
|
138
|
+
const { v, d } = stack.pop() as { v: unknown; d: number };
|
|
139
|
+
if (typeof v !== "object" || v === null || seen.has(v) || ArrayBuffer.isView(v)) continue;
|
|
140
|
+
seen.add(v);
|
|
141
|
+
if (++nodes > maxNodes || d > maxDepth) {
|
|
142
|
+
exceeded = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (isTainted(v)) {
|
|
146
|
+
const origin = v.__loomTainted.origin;
|
|
147
|
+
if (!found.has(origin)) {
|
|
148
|
+
found.add(origin);
|
|
149
|
+
origins.push(origin);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const child of Array.isArray(v) ? v : Object.values(v)) {
|
|
153
|
+
if (typeof child === "object" && child !== null) stack.push({ v: child, d: d + 1 });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { origins, exceeded, tainted: exceeded || origins.length > 0 };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Most distinct origins named in a joined label; the rest are counted (`+N more`). */
|
|
160
|
+
export const TAINT_ORIGIN_LABEL_MAX = 8;
|
|
161
|
+
/** Longest single origin kept in a joined label (a forged wrapper can carry a megabyte origin). */
|
|
162
|
+
const TAINT_ORIGIN_MAX_CHARS = 200;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The label for a {@link TaintScan}, or `undefined` when it is clean. The first
|
|
166
|
+
* {@link TAINT_ORIGIN_LABEL_MAX} distinct origins are joined (`a, b`) and the rest counted
|
|
167
|
+
* (`+N more`), so a value stuffed with forged wrappers cannot make a megabyte label that is then
|
|
168
|
+
* journaled and fenced. A bound hit adds {@link TAINT_SCAN_EXCEEDED_ORIGIN}.
|
|
169
|
+
*/
|
|
170
|
+
export function taintOriginLabel(scan: TaintScan): string | undefined {
|
|
171
|
+
if (!scan.tainted) return undefined;
|
|
172
|
+
const shown = scan.origins
|
|
173
|
+
.slice(0, TAINT_ORIGIN_LABEL_MAX)
|
|
174
|
+
.map((o) =>
|
|
175
|
+
o.length > TAINT_ORIGIN_MAX_CHARS ? `${o.slice(0, TAINT_ORIGIN_MAX_CHARS)}...` : o
|
|
176
|
+
);
|
|
177
|
+
const more = scan.origins.length - shown.length;
|
|
178
|
+
if (more > 0) shown.push(`+${more} more`);
|
|
179
|
+
if (scan.exceeded) shown.push(TAINT_SCAN_EXCEEDED_ORIGIN);
|
|
180
|
+
return shown.join(", ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The origin label for a value that may hold nested taint, or `undefined` when it is provably clean
|
|
185
|
+
* (see {@link taintOriginLabel}).
|
|
186
|
+
*/
|
|
187
|
+
export function nestedTaintOrigin(value: unknown, options?: TaintScanOptions): string | undefined {
|
|
188
|
+
return taintOriginLabel(scanTaint(value, options));
|
|
189
|
+
}
|