@loomwork/classify 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +73 -2
  4. package/dist/classify.d.ts +47 -0
  5. package/dist/classify.d.ts.map +1 -0
  6. package/dist/classify.js +147 -0
  7. package/dist/classify.js.map +1 -0
  8. package/dist/esm/classify.js +135 -0
  9. package/dist/esm/classify.js.map +1 -0
  10. package/dist/esm/expand.js +87 -0
  11. package/dist/esm/expand.js.map +1 -0
  12. package/dist/esm/external.js +33 -0
  13. package/dist/esm/external.js.map +1 -0
  14. package/dist/esm/index.js +7 -0
  15. package/dist/esm/index.js.map +1 -0
  16. package/dist/esm/redact.js +56 -0
  17. package/dist/esm/redact.js.map +1 -0
  18. package/dist/esm/taint.js +142 -0
  19. package/dist/esm/taint.js.map +1 -0
  20. package/dist/esm/unreachable.js +126 -0
  21. package/dist/esm/unreachable.js.map +1 -0
  22. package/dist/expand.d.ts +16 -0
  23. package/dist/expand.d.ts.map +1 -0
  24. package/dist/expand.js +91 -0
  25. package/dist/expand.js.map +1 -0
  26. package/dist/external.d.ts +15 -0
  27. package/dist/external.d.ts.map +1 -0
  28. package/dist/external.js +38 -0
  29. package/dist/external.js.map +1 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +38 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/redact.d.ts +4 -0
  35. package/dist/redact.d.ts.map +1 -0
  36. package/dist/redact.js +60 -0
  37. package/dist/redact.js.map +1 -0
  38. package/dist/taint.d.ts +85 -0
  39. package/dist/taint.d.ts.map +1 -0
  40. package/dist/taint.js +152 -0
  41. package/dist/taint.js.map +1 -0
  42. package/dist/unreachable.d.ts +20 -0
  43. package/dist/unreachable.d.ts.map +1 -0
  44. package/dist/unreachable.js +129 -0
  45. package/dist/unreachable.js.map +1 -0
  46. package/package.json +55 -3
  47. package/src/classify.ts +175 -0
  48. package/src/expand.ts +97 -0
  49. package/src/external.ts +40 -0
  50. package/src/index.ts +31 -0
  51. package/src/redact.ts +59 -0
  52. package/src/taint.ts +189 -0
  53. package/src/unreachable.ts +164 -0
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@loomwork/classify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
  }
@@ -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
+ }
@@ -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
+ }