@dzhechkov/harness-core 0.7.12 → 0.8.2
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/.dz-manifest.json +210 -70
- package/README.md +34 -1
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +17 -0
- package/dist/book-kb.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +61 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +116 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +31 -8
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +57 -16
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +11 -9
- package/dist/named-lock.js.map +1 -1
- package/dist/native-dep-probe.d.ts +11 -0
- package/dist/native-dep-probe.d.ts.map +1 -0
- package/dist/native-dep-probe.js +100 -0
- package/dist/native-dep-probe.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -1
- package/dist/operations.js.map +1 -1
- package/dist/parser-safe-region.d.ts +47 -0
- package/dist/parser-safe-region.d.ts.map +1 -0
- package/dist/parser-safe-region.js +47 -0
- package/dist/parser-safe-region.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -1
- package/dist/patterns.js.map +1 -1
- package/dist/profile.d.ts +202 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +536 -0
- package/dist/profile.js.map +1 -0
- package/dist/publish.d.ts +5 -3
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +46 -8
- package/dist/publish.js.map +1 -1
- package/dist/qe-rounds.d.ts +75 -0
- package/dist/qe-rounds.d.ts.map +1 -0
- package/dist/qe-rounds.js +174 -0
- package/dist/qe-rounds.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -1
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +8 -1
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +35 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +86 -23
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts +29 -24
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +183 -61
- package/dist/sign.js.map +1 -1
- package/dist/store-location.d.ts +56 -0
- package/dist/store-location.d.ts.map +1 -0
- package/dist/store-location.js +56 -0
- package/dist/store-location.js.map +1 -0
- package/dist/store-lock.d.ts +1 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +7 -1
- package/dist/store-lock.js.map +1 -1
- package/dist/store-merge.d.ts +55 -0
- package/dist/store-merge.d.ts.map +1 -0
- package/dist/store-merge.js +77 -0
- package/dist/store-merge.js.map +1 -0
- package/dist/teach-target.d.ts +61 -0
- package/dist/teach-target.d.ts.map +1 -0
- package/dist/teach-target.js +105 -0
- package/dist/teach-target.js.map +1 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +63 -20
- package/dist/vector-tier.js.map +1 -1
- package/package.json +13 -13
- package/sbom.json +424 -70
- package/src/book-kb.ts +17 -0
- package/src/feature-adr-checkpoints.ts +111 -2
- package/src/index.ts +39 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +74 -17
- package/src/named-lock.ts +10 -8
- package/src/native-dep-probe.ts +118 -0
- package/src/operations.ts +77 -1
- package/src/parser-safe-region.ts +65 -0
- package/src/patterns.ts +10 -1
- package/src/profile.ts +593 -0
- package/src/publish.ts +43 -7
- package/src/qe-rounds.ts +226 -0
- package/src/registry.ts +5 -1
- package/src/run-records.ts +9 -1
- package/src/setup.ts +87 -23
- package/src/sign.ts +180 -60
- package/src/store-location.ts +76 -0
- package/src/store-lock.ts +8 -1
- package/src/store-merge.ts +91 -0
- package/src/teach-target.ts +126 -0
- package/src/vector-tier.ts +58 -9
package/src/sign.ts
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { createHash, sign as cryptoSign, verify as cryptoVerify, createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
|
|
16
16
|
import { readFileSync, existsSync, readdirSync, openSync, fstatSync, closeSync, constants as fsConstants } from 'node:fs';
|
|
17
|
-
import {
|
|
17
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
18
18
|
|
|
19
19
|
export const MANIFEST_NAME = '.dz-manifest.json';
|
|
20
20
|
export const SBOM_NAME = 'sbom.json';
|
|
21
|
-
|
|
21
|
+
/** v3 preserves order-sensitive package.json condition maps while canonicalising packer noise. */
|
|
22
|
+
export const MANIFEST_VERSION = 3;
|
|
22
23
|
|
|
23
24
|
export interface ManifestEntry {
|
|
24
25
|
readonly path: string;
|
|
@@ -48,6 +49,16 @@ export interface VerifyResult {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
/** CycloneDX 1.5 JSON for current output. Canonical JSON digests are always explicitly labelled. */
|
|
53
|
+
export function buildSbom(manifest: Manifest): Sbom {
|
|
54
|
+
return buildSbomEnvelope(manifest, false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Compatibility-only decoder target for v1 packs that historically mislabeled package.json. */
|
|
58
|
+
function buildLegacySbomV1(manifest: Manifest): Sbom {
|
|
59
|
+
return buildSbomEnvelope(manifest, true);
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
/**
|
|
52
63
|
* Files the PACKER REWRITES, so their bytes are not stable across two packs of the same unchanged
|
|
53
64
|
* tree — hashing them raw makes a signature that can never verify.
|
|
@@ -59,10 +70,12 @@ export interface VerifyResult {
|
|
|
59
70
|
* the publisher packs again, and the consumer receives the second one.
|
|
60
71
|
*
|
|
61
72
|
* These files are therefore hashed in a CANONICAL form — parsed, keys sorted, stable separators.
|
|
62
|
-
* HONEST LIMIT:
|
|
63
|
-
*
|
|
73
|
+
* HONEST LIMIT: key order, insignificant whitespace, and negative-zero spelling become invisible to
|
|
74
|
+
* the signature. JavaScript can distinguish negative zero with `Object.is`, but JSON/pnpm round-trips
|
|
75
|
+
* normalize it to zero; tolerating that packer rewrite is explicit here. The alternative — excusing
|
|
76
|
+
* `package.json` from signing altogether — would
|
|
64
77
|
* leave the dependency pins and the bin map unsigned, which is the opposite of a fix. The two
|
|
65
|
-
* differences that
|
|
78
|
+
* differences that remain unsafe across parsers — duplicate keys and precision-losing/non-finite numbers — are
|
|
66
79
|
* refused canonicalisation outright; see `canonicalisationIsFaithful`.
|
|
67
80
|
*/
|
|
68
81
|
const CANONICALISED_PACK_FILES: ReadonlySet<string> = new Set(['package.json']);
|
|
@@ -77,6 +90,28 @@ function stableJson(value: unknown): string {
|
|
|
77
90
|
return JSON.stringify(value) ?? 'null';
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
/**
|
|
94
|
+
* v3 package.json canonical form. pnpm may reorder ordinary object keys, but Node's conditional
|
|
95
|
+
* `exports`/`imports` objects are first-match maps and TypeScript consumers can likewise observe
|
|
96
|
+
* `typesVersions` order. Preserve every descendant object order under those roots while sorting
|
|
97
|
+
* ordinary metadata keys. This authenticates entry-point semantics without reintroducing pnpm's
|
|
98
|
+
* nondeterministic dependency-key ordering.
|
|
99
|
+
*/
|
|
100
|
+
const ORDER_SENSITIVE_PACKAGE_ROOTS = new Set(['exports', 'imports', 'typesVersions']);
|
|
101
|
+
function stablePackageJsonV2(value: unknown, preserveOrder = false, atRoot = true): string {
|
|
102
|
+
if (Array.isArray(value)) return '[' + value.map((item) => stablePackageJsonV2(item, preserveOrder, false)).join(',') + ']';
|
|
103
|
+
if (value !== null && typeof value === 'object') {
|
|
104
|
+
const record = value as Record<string, unknown>;
|
|
105
|
+
const keys = Object.keys(record);
|
|
106
|
+
if (!preserveOrder) keys.sort();
|
|
107
|
+
return '{' + keys.map((key) => {
|
|
108
|
+
const childPreservesOrder = preserveOrder || (atRoot && ORDER_SENSITIVE_PACKAGE_ROOTS.has(key));
|
|
109
|
+
return JSON.stringify(key) + ':' + stablePackageJsonV2(record[key], childPreservesOrder, false);
|
|
110
|
+
}).join(',') + '}';
|
|
111
|
+
}
|
|
112
|
+
return JSON.stringify(value) ?? 'null';
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
/**
|
|
81
116
|
* Is this JSON text one whose PARSED value is a faithful stand-in for its BYTES?
|
|
82
117
|
*
|
|
@@ -87,13 +122,28 @@ function stableJson(value: unknown): string {
|
|
|
87
122
|
* - DUPLICATE KEYS. `{"bin":"evil","bin":"signed"}` parses to the signed value in every JavaScript
|
|
88
123
|
* parser (last wins), so it would hash as clean — while a first-wins parser in another language
|
|
89
124
|
* reads `evil` from the same signed bytes.
|
|
90
|
-
* - LOSSY
|
|
91
|
-
*
|
|
125
|
+
* - LOSSY NUMBERS. `9007199254740993` parses to `...992`, a long decimal can collapse to a shorter
|
|
126
|
+
* value, and `1e999` becomes `Infinity` before JSON serialization turns it into `null`.
|
|
92
127
|
*
|
|
93
128
|
* Neither is reachable through `pnpm pack` — this is a guard against a hand-crafted artifact, not
|
|
94
|
-
* against the packer.
|
|
95
|
-
*
|
|
129
|
+
* against the packer. Current/v3 generation refuses either form instead of producing an SBOM that
|
|
130
|
+
* could mislabel a raw-byte fallback as a canonical digest. Consumer verification returns a mismatch
|
|
131
|
+
* for the same refusal because its no-follow hashing wrapper maps this throw to `null`.
|
|
96
132
|
*/
|
|
133
|
+
function normalizedJsonNumber(literal: string): string | null {
|
|
134
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
|
|
135
|
+
if (!match || !Number.isFinite(Number(literal))) return null;
|
|
136
|
+
const negative = match[1] === '-';
|
|
137
|
+
const fraction = match[3] ?? '';
|
|
138
|
+
let digits = `${match[2]}${fraction}`.replace(/^0+/, '');
|
|
139
|
+
// pnpm/JSON round-trips collapse every mathematical negative zero spelling to `0`.
|
|
140
|
+
if (digits === '') return '0';
|
|
141
|
+
let exponent = Number(match[4] ?? '0') - fraction.length;
|
|
142
|
+
if (!Number.isSafeInteger(exponent)) return null;
|
|
143
|
+
while (digits.endsWith('0')) { digits = digits.slice(0, -1); exponent += 1; }
|
|
144
|
+
return `${negative ? '-' : ''}${digits}e${exponent}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
97
147
|
function canonicalisationIsFaithful(text: string): boolean {
|
|
98
148
|
const scopes: Array<Set<string> | null> = []; // Set = inside an object, null = inside an array
|
|
99
149
|
let i = 0;
|
|
@@ -126,9 +176,11 @@ function canonicalisationIsFaithful(text: string): boolean {
|
|
|
126
176
|
const start = i;
|
|
127
177
|
while (i < text.length && /[-+0-9eE.]/.test(text.charAt(i))) i++;
|
|
128
178
|
const lit = text.slice(start, i);
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
179
|
+
// Compare exact decimal values after removing insignificant spelling differences. This keeps
|
|
180
|
+
// `1`, `1.0`, and `1e0` equivalent while rejecting IEEE-754 rounding, overflow and underflow.
|
|
181
|
+
const exact = normalizedJsonNumber(lit);
|
|
182
|
+
const parsed = normalizedJsonNumber(String(Number(lit)));
|
|
183
|
+
if (exact === null || parsed === null || exact !== parsed) return false;
|
|
132
184
|
continue;
|
|
133
185
|
}
|
|
134
186
|
i++;
|
|
@@ -138,26 +190,32 @@ function canonicalisationIsFaithful(text: string): boolean {
|
|
|
138
190
|
|
|
139
191
|
/**
|
|
140
192
|
* Hash the bytes of one pack file. A packer-rewritten file is hashed canonically; everything else
|
|
141
|
-
* byte-for-byte. Unparseable
|
|
142
|
-
*
|
|
193
|
+
* byte-for-byte. Unparseable, ambiguous, or precision-losing root `package.json` bytes are refused:
|
|
194
|
+
* a current/v3 signer must never emit a raw digest under a canonical-digest SBOM label.
|
|
143
195
|
*/
|
|
144
|
-
export function hashPackBytes(relPath: string, bytes: Buffer): string {
|
|
196
|
+
export function hashPackBytes(relPath: string, bytes: Buffer, manifestVersion = MANIFEST_VERSION): string {
|
|
145
197
|
const rel = relPath.split(sep).join('/');
|
|
146
198
|
if (CANONICALISED_PACK_FILES.has(rel)) {
|
|
147
199
|
try {
|
|
148
200
|
const text = bytes.toString('utf-8');
|
|
149
201
|
if (!canonicalisationIsFaithful(text)) throw new Error('not canonicalisable');
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
202
|
+
const parsed = JSON.parse(text) as unknown;
|
|
203
|
+
const canonical = manifestVersion >= 3 ? stablePackageJsonV2(parsed) : stableJson(parsed);
|
|
204
|
+
return createHash('sha256').update(Buffer.from(canonical, 'utf-8')).digest('hex');
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`refusing non-canonicalisable ${rel}: ${String((error as Error)?.message ?? error)}`);
|
|
153
207
|
}
|
|
154
208
|
}
|
|
155
209
|
return createHash('sha256').update(bytes).digest('hex');
|
|
156
210
|
}
|
|
157
211
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
212
|
+
/**
|
|
213
|
+
* sha256 of a file's bytes, hex. Follows symlinks — used only when building a manifest we control.
|
|
214
|
+
* Canonical pack rewriting is opt-in through the explicit root-relative path. Omitting `relPath`
|
|
215
|
+
* hashes raw bytes, so an unrelated nested file merely named `package.json` is never relaxed.
|
|
216
|
+
*/
|
|
217
|
+
export function hashFile(absPath: string, relPath?: string, manifestVersion = MANIFEST_VERSION): string {
|
|
218
|
+
return hashPackBytes(relPath ?? '', readFileSync(absPath), manifestVersion);
|
|
161
219
|
}
|
|
162
220
|
|
|
163
221
|
/**
|
|
@@ -167,14 +225,14 @@ export function hashFile(absPath: string, relPath?: string): string {
|
|
|
167
225
|
*
|
|
168
226
|
* Returns `null` when the path is not a regular file, or is a symlink.
|
|
169
227
|
*/
|
|
170
|
-
export function hashRegularFileNoFollow(absPath: string, relPath?: string): string | null {
|
|
228
|
+
export function hashRegularFileNoFollow(absPath: string, relPath?: string, manifestVersion = MANIFEST_VERSION): string | null {
|
|
171
229
|
let fd: number | undefined;
|
|
172
230
|
try {
|
|
173
231
|
fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
174
232
|
if (!fstatSync(fd).isFile()) return null;
|
|
175
233
|
// Canonicalise the bytes ALREADY READ from this descriptor — never re-open, or the TOCTOU
|
|
176
234
|
// window this function exists to close would be reopened by the fix.
|
|
177
|
-
return hashPackBytes(relPath ??
|
|
235
|
+
return hashPackBytes(relPath ?? '', readFileSync(fd), manifestVersion);
|
|
178
236
|
} catch {
|
|
179
237
|
return null; // ELOOP on a symlink, ENOENT, EACCES — all fail closed
|
|
180
238
|
} finally {
|
|
@@ -182,6 +240,24 @@ export function hashRegularFileNoFollow(absPath: string, relPath?: string): stri
|
|
|
182
240
|
}
|
|
183
241
|
}
|
|
184
242
|
|
|
243
|
+
/** Read one regular file through the same no-follow descriptor used by hashing. */
|
|
244
|
+
export function readRegularFileNoFollow(absPath: string, expectedLength?: number | readonly number[]): Buffer | null {
|
|
245
|
+
let fd: number | undefined;
|
|
246
|
+
try {
|
|
247
|
+
fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
248
|
+
const stat = fstatSync(fd);
|
|
249
|
+
const allowedLengths = Array.isArray(expectedLength) ? expectedLength : expectedLength === undefined ? null : [expectedLength];
|
|
250
|
+
if (!stat.isFile() || (allowedLengths !== null && !allowedLengths.includes(stat.size))) return null;
|
|
251
|
+
const bytes = readFileSync(fd);
|
|
252
|
+
if (allowedLengths !== null && !allowedLengths.includes(bytes.length)) return null;
|
|
253
|
+
return bytes;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
} finally {
|
|
257
|
+
if (fd !== undefined) closeSync(fd);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
185
261
|
/**
|
|
186
262
|
* Cross-model security review (codex exec, 2026-07-10) — a SIGNED manifest is not a TRUSTED manifest.
|
|
187
263
|
* Its own contents are attacker-controlled until the signature checks out, and even then they must be
|
|
@@ -224,31 +300,49 @@ export function checkManifestEntries(files: unknown): string | null {
|
|
|
224
300
|
}
|
|
225
301
|
|
|
226
302
|
/**
|
|
227
|
-
*
|
|
228
|
-
* installed deps / VCS metadata. `.agentic-qe`/`.dz`: LOCAL RUNTIME STATE (learning DBs, RVF
|
|
303
|
+
* State-entry names that may be excluded from BOTH walks. node_modules/.git:
|
|
304
|
+
* installed deps / VCS metadata. `.agentic-qe`/`.dz`: LOCAL RUNTIME STATE entries (learning DBs, RVF
|
|
229
305
|
* stores, witness keys) that background workers create inside pack cwds — gitignored, machine-
|
|
230
306
|
* specific, never shipped. Signing them made 19 packs verify green LOCALLY (files present, hashes
|
|
231
307
|
* match) and TAMPERED in CI (clean checkout lacks the gitignored state) — found live on GH run
|
|
232
308
|
* #103, 2026-07-19. State is not content; the walks must never see it.
|
|
233
309
|
*/
|
|
234
|
-
const
|
|
310
|
+
const EXCLUDED_STATE_NAMES = new Set(['node_modules', '.git', '.agentic-qe', '.dz']);
|
|
311
|
+
|
|
312
|
+
function isExcludedStateEntry(entry: {
|
|
313
|
+
name: string;
|
|
314
|
+
isDirectory(): boolean;
|
|
315
|
+
isSymbolicLink(): boolean;
|
|
316
|
+
}): boolean {
|
|
317
|
+
if (!EXCLUDED_STATE_NAMES.has(entry.name)) return false;
|
|
318
|
+
// A worktree's `.git` is commonly a regular pointer file, and workspace/local-state entries may be
|
|
319
|
+
// symlinks. They remain VCS/dependency/runtime state. A same-named ordinary file other than `.git`
|
|
320
|
+
// is package content and must stay visible.
|
|
321
|
+
if (entry.name === '.git') return true;
|
|
322
|
+
return entry.isDirectory() || entry.isSymbolicLink();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Generated metadata FILES are self-excluded only at the pack root; directories remain content. */
|
|
326
|
+
function isRootGeneratedMetadata(relDir: string, name: string, isFile: boolean): boolean {
|
|
327
|
+
return isFile && relDir === '' && (name === MANIFEST_NAME || name === SBOM_NAME);
|
|
328
|
+
}
|
|
235
329
|
|
|
236
330
|
/** Every file under `root`, POSIX-relative, excluding the manifest and the SBOM themselves. */
|
|
237
331
|
export function listPackFiles(root: string): string[] {
|
|
238
332
|
const out: string[] = [];
|
|
239
333
|
const walk = (dir: string, rel: string): void => {
|
|
240
334
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
241
|
-
//
|
|
335
|
+
// Role-classified state entries are unsigned territory BY DESIGN (see EXCLUDED_STATE_NAMES). The SIGN and VERIFY
|
|
242
336
|
// walks MUST share these exclusions: an asymmetry here false-TAMPERs every pnpm workspace
|
|
243
337
|
// pack whose node_modules holds symlinks (found live arming task #36 — 10 of 23 packs).
|
|
244
|
-
if (
|
|
245
|
-
if (e.name
|
|
338
|
+
if (isExcludedStateEntry(e)) continue;
|
|
339
|
+
if (isRootGeneratedMetadata(rel, e.name, e.isFile())) continue;
|
|
246
340
|
const abs = join(dir, e.name);
|
|
247
341
|
const r = rel ? rel + '/' + e.name : e.name;
|
|
248
342
|
if (e.isDirectory()) walk(abs, r);
|
|
249
|
-
// NOT else-isFile: a symlink (or other non-file) OUTSIDE
|
|
343
|
+
// NOT else-isFile: a symlink (or other non-file) OUTSIDE role-classified state must stay VISIBLE to the
|
|
250
344
|
// verify sweep — it fails as "present but not signed" / "is a symlink" (R3-4: a smuggled symlink is a
|
|
251
|
-
// finding, not something to silently ignore). Only
|
|
345
|
+
// finding, not something to silently ignore). Only EXCLUDED_STATE_NAMES entries are exempt territory.
|
|
252
346
|
else out.push(r);
|
|
253
347
|
}
|
|
254
348
|
};
|
|
@@ -265,8 +359,8 @@ export function listSignablePackFiles(root: string): string[] {
|
|
|
265
359
|
const out: string[] = [];
|
|
266
360
|
const walk = (dir: string, rel: string): void => {
|
|
267
361
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
268
|
-
if (
|
|
269
|
-
if (e.name
|
|
362
|
+
if (isExcludedStateEntry(e)) continue;
|
|
363
|
+
if (isRootGeneratedMetadata(rel, e.name, e.isFile())) continue;
|
|
270
364
|
const abs = join(dir, e.name);
|
|
271
365
|
const r = rel ? rel + '/' + e.name : e.name;
|
|
272
366
|
if (e.isDirectory()) walk(abs, r);
|
|
@@ -356,19 +450,6 @@ export function verifyManifest(
|
|
|
356
450
|
root: string,
|
|
357
451
|
signed: SignedManifest | null | undefined,
|
|
358
452
|
pubKeyPem: string,
|
|
359
|
-
/**
|
|
360
|
-
* The paths the pack actually SHIPS, when the caller can establish them (from `npm pack`). The
|
|
361
|
-
* added-file sweep is then scoped to those, because a working-tree file `files[]` excludes was
|
|
362
|
-
* never "added to the pack" — it simply is not part of it.
|
|
363
|
-
*
|
|
364
|
-
* Omit it and the sweep covers the whole tree, exactly as before. That is correct for the case that
|
|
365
|
-
* matters most: a consumer verifying an EXTRACTED tarball, where the tree IS the shipped set.
|
|
366
|
-
*
|
|
367
|
-
* This parameter exists because the signer and the sweep must never disagree about what a pack
|
|
368
|
-
* contains — the 10-false-TAMPERED lesson, recorded at `packFiles` and re-learned on 2026-08-21 the
|
|
369
|
-
* moment the signer started scoping and the sweep did not.
|
|
370
|
-
*/
|
|
371
|
-
shippedPaths?: readonly string[],
|
|
372
453
|
): VerifyResult {
|
|
373
454
|
const fail = (path: string, reason: string): VerifyResult => ({ ok: false, failures: [{ path, reason }] });
|
|
374
455
|
|
|
@@ -378,6 +459,9 @@ export function verifyManifest(
|
|
|
378
459
|
if (typeof manifest.version !== 'number' || !Number.isInteger(manifest.version)) {
|
|
379
460
|
return fail(MANIFEST_NAME, 'manifest version is not an integer');
|
|
380
461
|
}
|
|
462
|
+
if (![1, 2, MANIFEST_VERSION].includes(manifest.version)) {
|
|
463
|
+
return fail(MANIFEST_NAME, `unsupported manifest version ${manifest.version}`);
|
|
464
|
+
}
|
|
381
465
|
if (!isSafeManifestPath(manifest.pack)) {
|
|
382
466
|
return fail(MANIFEST_NAME, 'manifest pack name is unsafe: ' + JSON.stringify(manifest.pack));
|
|
383
467
|
}
|
|
@@ -405,6 +489,8 @@ export function verifyManifest(
|
|
|
405
489
|
if (!sigOk) return fail(MANIFEST_NAME, 'signature does not verify against the pinned key');
|
|
406
490
|
|
|
407
491
|
const failures: VerifyFailure[] = [];
|
|
492
|
+
const sbomFailure = verifySbomAgainstManifest(root, manifest);
|
|
493
|
+
if (sbomFailure !== null) failures.push({ path: SBOM_NAME, reason: sbomFailure });
|
|
408
494
|
for (const entry of manifest.files) {
|
|
409
495
|
const abs = join(root, entry.path);
|
|
410
496
|
if (!existsSync(abs)) {
|
|
@@ -412,9 +498,9 @@ export function verifyManifest(
|
|
|
412
498
|
continue;
|
|
413
499
|
}
|
|
414
500
|
// One open, O_NOFOLLOW, fstat the descriptor, hash from it: no symlink follow, no TOCTOU window.
|
|
415
|
-
const digest = hashRegularFileNoFollow(abs, entry.path);
|
|
501
|
+
const digest = hashRegularFileNoFollow(abs, entry.path, manifest.version);
|
|
416
502
|
if (digest === null) {
|
|
417
|
-
failures.push({ path: entry.path, reason: 'is a symlink
|
|
503
|
+
failures.push({ path: entry.path, reason: 'is a symlink, not a regular file, unreadable, or outside the canonical JSON domain — refusing to hash it' });
|
|
418
504
|
continue;
|
|
419
505
|
}
|
|
420
506
|
if (digest !== entry.sha256) {
|
|
@@ -423,14 +509,10 @@ export function verifyManifest(
|
|
|
423
509
|
}
|
|
424
510
|
|
|
425
511
|
// Bidirectional, always: hashing only what the manifest lists lets an attacker ADD a file.
|
|
426
|
-
const shipped = shippedPaths === undefined ? null : new Set(shippedPaths);
|
|
427
512
|
const present = listPackFiles(root);
|
|
428
513
|
const listed = new Set(manifest.files.map((f) => f.path));
|
|
429
514
|
for (const rel of present) {
|
|
430
515
|
const p = rel.split(sep).join('/');
|
|
431
|
-
// A file the pack does not ship is not an ADDED file; scoping here is what keeps the sweep and the
|
|
432
|
-
// signer describing the same object.
|
|
433
|
-
if (shipped !== null && !shipped.has(p)) continue;
|
|
434
516
|
if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
|
|
435
517
|
}
|
|
436
518
|
|
|
@@ -467,7 +549,10 @@ export function assertKeyOutsideTree(keyPath: string, repoRoot: string): void {
|
|
|
467
549
|
export interface SbomComponent {
|
|
468
550
|
readonly type: 'file';
|
|
469
551
|
readonly name: string;
|
|
470
|
-
|
|
552
|
+
/** Raw file-byte digests only. A canonical JSON verification digest must never masquerade here. */
|
|
553
|
+
readonly hashes?: readonly { readonly alg: 'SHA-256'; readonly content: string }[];
|
|
554
|
+
/** Explicit verification-digest metadata for packer-rewritten JSON such as package.json. */
|
|
555
|
+
readonly properties?: readonly { readonly name: string; readonly value: string }[];
|
|
471
556
|
}
|
|
472
557
|
|
|
473
558
|
export interface Sbom {
|
|
@@ -478,21 +563,56 @@ export interface Sbom {
|
|
|
478
563
|
readonly components: readonly SbomComponent[];
|
|
479
564
|
}
|
|
480
565
|
|
|
481
|
-
|
|
482
|
-
export function buildSbom(manifest: Manifest): Sbom {
|
|
566
|
+
function buildSbomEnvelope(manifest: Manifest, legacyV1PackageHash: boolean): Sbom {
|
|
483
567
|
return {
|
|
484
568
|
bomFormat: 'CycloneDX',
|
|
485
569
|
specVersion: '1.5',
|
|
486
570
|
version: 1,
|
|
487
571
|
metadata: { component: { type: 'library', name: manifest.pack } },
|
|
488
|
-
components: manifest.files.map((f) =>
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
572
|
+
components: [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).map((f) =>
|
|
573
|
+
!legacyV1PackageHash && CANONICALISED_PACK_FILES.has(f.path)
|
|
574
|
+
? {
|
|
575
|
+
type: 'file' as const,
|
|
576
|
+
name: f.path,
|
|
577
|
+
properties: [
|
|
578
|
+
{ name: 'dz:digest-basis', value: manifest.version >= 3
|
|
579
|
+
? 'package-json-ordered-conditions-v2' : 'recursive-key-sorted-json-v1' },
|
|
580
|
+
{ name: manifest.version >= 3
|
|
581
|
+
? 'dz:canonical-json-sha256-v2' : 'dz:canonical-json-sha256-v1', value: f.sha256 },
|
|
582
|
+
],
|
|
583
|
+
}
|
|
584
|
+
: {
|
|
585
|
+
type: 'file' as const,
|
|
586
|
+
name: f.path,
|
|
587
|
+
hashes: [{ alg: 'SHA-256' as const, content: f.sha256 }],
|
|
588
|
+
}),
|
|
493
589
|
};
|
|
494
590
|
}
|
|
495
591
|
|
|
592
|
+
/**
|
|
593
|
+
* ADR-003: SBOM authenticity is acyclic. The SBOM is not self-hashed; instead its sole canonical
|
|
594
|
+
* value is derived from the already signature-authenticated manifest and compared byte-for-byte.
|
|
595
|
+
*/
|
|
596
|
+
export function verifySbomAgainstManifest(root: string, manifest: Manifest): string | null {
|
|
597
|
+
if (![1, 2, MANIFEST_VERSION].includes(manifest.version)) {
|
|
598
|
+
return `unsupported manifest version ${manifest.version}`;
|
|
599
|
+
}
|
|
600
|
+
const expected = JSON.stringify(buildSbom(manifest), null, 2) + '\n';
|
|
601
|
+
const accepted = manifest.version === 1
|
|
602
|
+
? [expected, JSON.stringify(buildLegacySbomV1(manifest), null, 2) + '\n']
|
|
603
|
+
: [expected];
|
|
604
|
+
const bytes = readRegularFileNoFollow(join(root, SBOM_NAME), [...new Set(accepted.map((value) => Buffer.byteLength(value)))]);
|
|
605
|
+
if (bytes === null) return 'missing, symlinked, not a regular file, or has a non-canonical byte length';
|
|
606
|
+
const actual = bytes.toString('utf8');
|
|
607
|
+
if (!canonicalisationIsFaithful(actual)) return 'JSON is malformed, ambiguous, or precision-losing';
|
|
608
|
+
try {
|
|
609
|
+
JSON.parse(actual);
|
|
610
|
+
} catch {
|
|
611
|
+
return 'JSON is malformed';
|
|
612
|
+
}
|
|
613
|
+
return accepted.includes(actual) ? null : 'content does not equal canonical CycloneDX derived from the signed manifest';
|
|
614
|
+
}
|
|
615
|
+
|
|
496
616
|
// ── The publish gate (FR-4), as a pure decision ─────────────────────────────
|
|
497
617
|
//
|
|
498
618
|
// The operator's decision was "the publish gate BLOCKS". Reality intervened: with no trust root
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store-location — one sentence about WHERE a learning store lives, used by every command that
|
|
3
|
+
* touches one.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED 2026-08-27: `dz teach` and `dz recall` both resolve their store as
|
|
6
|
+
* `resolve(cwd, --project ?? '.')` (`cli.ts:2882`, `:3450`) — plain cwd, no git-root walk, no
|
|
7
|
+
* upward search, and no config key or environment variable to pin a canonical root. Neither
|
|
8
|
+
* command printed the path.
|
|
9
|
+
*
|
|
10
|
+
* So a user who runs `dz teach` in eight project directories gets eight isolated stores and
|
|
11
|
+
* believes they are accumulating. The path alone does not reveal that: `/home/u/proj/.dz` looks
|
|
12
|
+
* equally deliberate whether it was chosen or defaulted into. **That difference is the defect**,
|
|
13
|
+
* so the line names the SOURCE, not only the location.
|
|
14
|
+
*
|
|
15
|
+
* This ships BEFORE the intended per-session mode on purpose. A mode set once and forgotten lies
|
|
16
|
+
* silently, and a mode is only safe when its effect is visible on every operation it governs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Who decided this path. `global` is separate from `cwd`/`explicit` because the advice differs:
|
|
23
|
+
* "pass --project" is useful for a cwd-derived store and MISLEADING for the home store, which
|
|
24
|
+
* neither the cwd nor --project chose (cross-family QE, 2026-08-27: `--project /tmp/p --to global`
|
|
25
|
+
* printed the right path under the wrong explanation).
|
|
26
|
+
*/
|
|
27
|
+
export type StoreOrigin = 'explicit' | 'cwd' | 'global';
|
|
28
|
+
|
|
29
|
+
export interface StoreLocation {
|
|
30
|
+
/** Absolute path of the store directory — `<projectRoot>/.dz`. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
/** Where the project root came from: an explicit `--project`, or the current directory. */
|
|
33
|
+
readonly origin: StoreOrigin;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Describe the store a command is about to use.
|
|
38
|
+
*
|
|
39
|
+
* `projectOption` is the RAW `--project` value as the user typed it (or `undefined`), and
|
|
40
|
+
* `resolvedRoot` is what the command already computed. Both are taken rather than re-resolving,
|
|
41
|
+
* so this helper can never disagree with the command about which directory was chosen — a second,
|
|
42
|
+
* independent resolution is exactly how two lookalike code paths come to name different stores.
|
|
43
|
+
*/
|
|
44
|
+
export function describeStoreLocation(
|
|
45
|
+
resolvedRoot: string,
|
|
46
|
+
projectOption: string | undefined,
|
|
47
|
+
/**
|
|
48
|
+
* Overrides the derived origin when the caller KNOWS what chose the path — today only the
|
|
49
|
+
* cross-project home store, which neither the cwd nor `--project` selected. Omit it and the
|
|
50
|
+
* origin is derived exactly as before, so every existing call is unchanged.
|
|
51
|
+
*/
|
|
52
|
+
origin?: StoreOrigin,
|
|
53
|
+
): StoreLocation {
|
|
54
|
+
return {
|
|
55
|
+
path: join(resolvedRoot, '.dz'),
|
|
56
|
+
origin: origin ?? (projectOption === undefined ? 'cwd' : 'explicit'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The one human line, shared by every command.
|
|
62
|
+
*
|
|
63
|
+
* Two commands writing this sentence independently is how they come to disagree about what a store
|
|
64
|
+
* is — the lookalike-normalisation class this repo has already paid for once.
|
|
65
|
+
*
|
|
66
|
+
* `verb` is what the command did with the store, so the line reads naturally in both directions:
|
|
67
|
+
* "store (written): …" and "store (read): …".
|
|
68
|
+
*/
|
|
69
|
+
export function storeLocationLine(loc: StoreLocation, verb: 'written' | 'read'): string {
|
|
70
|
+
const because = loc.origin === 'explicit'
|
|
71
|
+
? 'from --project'
|
|
72
|
+
: loc.origin === 'global'
|
|
73
|
+
? 'the cross-project store in your home directory'
|
|
74
|
+
: 'from the current directory — pass --project to choose deliberately';
|
|
75
|
+
return ` store (${verb}): ${loc.path} [${because}]`;
|
|
76
|
+
}
|
package/src/store-lock.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const LOCK_TIMEOUT_MS = 10_000;
|
|
|
68
68
|
|
|
69
69
|
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
70
70
|
const MIN_STALE_MS = 2_000;
|
|
71
|
+
/** Highest accepted environment override; keeps proper-lockfile's heartbeat timer in range. */
|
|
72
|
+
const MAX_STALE_MS = 600_000;
|
|
71
73
|
|
|
72
74
|
/** Path of the lock guarding a project's pattern store (a DIRECTORY when held). */
|
|
73
75
|
export function storeLockPath(projectRoot: string): string {
|
|
@@ -115,6 +117,11 @@ const defaultTimeoutMs = (): number => {
|
|
|
115
117
|
return Number.isFinite(env) && env > 0 ? env : LOCK_TIMEOUT_MS;
|
|
116
118
|
};
|
|
117
119
|
|
|
120
|
+
export function resolveStaleMs(): number {
|
|
121
|
+
const env = Number(process.env['DZ_STORE_LOCK_STALE_MS']);
|
|
122
|
+
return Number.isFinite(env) && env > 0 && env <= MAX_STALE_MS ? env : STALE_LOCK_MS;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
const sleep = (ms: number): Promise<void> => new Promise((r) => { setTimeout(r, ms); });
|
|
119
126
|
|
|
120
127
|
/** Synchronous sleep without spinning (Atomics.wait on a throwaway buffer). */
|
|
@@ -176,7 +183,7 @@ function tryAcquire(projectRoot: string, staleMs: number, onCompromised: (e: Err
|
|
|
176
183
|
|
|
177
184
|
function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
|
|
178
185
|
return {
|
|
179
|
-
staleMs: Math.max(opts.staleMs ??
|
|
186
|
+
staleMs: Math.max(opts.staleMs ?? resolveStaleMs(), MIN_STALE_MS),
|
|
180
187
|
timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
|
|
181
188
|
pollMs: opts.pollMs ?? 25,
|
|
182
189
|
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store-merge — read two learning stores together, and never merge them on disk.
|
|
3
|
+
*
|
|
4
|
+
* The design comes from our own shipped precedent, `learning_bridge.py:23`:
|
|
5
|
+
*
|
|
6
|
+
* "the compounding objection was answered by making RECALL read both stores rather than by
|
|
7
|
+
* merging them"
|
|
8
|
+
*
|
|
9
|
+
* and `:880`: "two stores are different stores, whatever mechanism might have made them the same."
|
|
10
|
+
*
|
|
11
|
+
* That precedent records an ASYMMETRY, and it is the whole design here: WRITING needs a choice —
|
|
12
|
+
* is this lesson about this project, or general? — while READING almost never does. You want your
|
|
13
|
+
* project's specifics AND your accumulated expertise. So recall reads both, always, with no mode:
|
|
14
|
+
* a mode here would only add a way to be wrong, and a mode set once and forgotten lies silently.
|
|
15
|
+
*
|
|
16
|
+
* The cross-project store is not a new kind of thing. MEASURED: `~/.dz` already exists, and
|
|
17
|
+
* `loadPatterns(projectRoot)` already takes a root — so the global store is simply a project store
|
|
18
|
+
* whose root is the home directory. Same code, same format, nothing to migrate.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** Which store a record came from. `both` means the same record exists in each. */
|
|
25
|
+
export type HitOrigin = 'project' | 'global' | 'both';
|
|
26
|
+
|
|
27
|
+
export interface OriginTagged {
|
|
28
|
+
readonly origin: HitOrigin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The cross-project store's root: the home directory.
|
|
33
|
+
*
|
|
34
|
+
* Taken as a parameter rather than read from the environment inside the merge, so the pure half
|
|
35
|
+
* stays testable without a filesystem and a caller cannot be surprised by which home it meant.
|
|
36
|
+
*/
|
|
37
|
+
export function globalStoreRoot(home: string = homedir()): string {
|
|
38
|
+
return resolve(home);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Are these two roots the same store?
|
|
43
|
+
*
|
|
44
|
+
* If a user runs `dz recall` FROM their home directory, "read both" would read one file twice and
|
|
45
|
+
* double every hit. Resolved-path comparison, before either read — cheaper and more certain than
|
|
46
|
+
* de-duplicating afterwards.
|
|
47
|
+
*/
|
|
48
|
+
export function sameStore(projectRoot: string, globalRoot: string): boolean {
|
|
49
|
+
return resolve(projectRoot) === resolve(globalRoot);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge two record sets into one origin-tagged list.
|
|
54
|
+
*
|
|
55
|
+
* `key` decides identity. It is supplied by the caller rather than assumed here, because two
|
|
56
|
+
* lookalike identity rules is how a merge quietly starts treating one record as two — the class
|
|
57
|
+
* this repo has already paid for.
|
|
58
|
+
*
|
|
59
|
+
* ORDER: project records first, then global-only ones. A project's own lessons are the more
|
|
60
|
+
* specific answer to a question asked inside that project, and a reader scanning from the top
|
|
61
|
+
* should meet them first. Records present in BOTH keep the project position and are labelled
|
|
62
|
+
* `both`, because that is where the reader will look for them.
|
|
63
|
+
*/
|
|
64
|
+
export function mergeStoreHits<T>(
|
|
65
|
+
projectRecords: readonly T[],
|
|
66
|
+
globalRecords: readonly T[],
|
|
67
|
+
key: (r: T) => string,
|
|
68
|
+
): (T & OriginTagged)[] {
|
|
69
|
+
const inGlobal = new Set(globalRecords.map(key));
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const out: (T & OriginTagged)[] = [];
|
|
72
|
+
|
|
73
|
+
for (const r of projectRecords) {
|
|
74
|
+
const k = key(r);
|
|
75
|
+
if (seen.has(k)) continue; // a store with an internal duplicate stays one record here
|
|
76
|
+
seen.add(k);
|
|
77
|
+
out.push({ ...r, origin: inGlobal.has(k) ? 'both' : 'project' });
|
|
78
|
+
}
|
|
79
|
+
for (const r of globalRecords) {
|
|
80
|
+
const k = key(r);
|
|
81
|
+
if (seen.has(k)) continue;
|
|
82
|
+
seen.add(k);
|
|
83
|
+
out.push({ ...r, origin: 'global' });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How the header should describe what was read — "1 store" is a fact worth stating, not assuming. */
|
|
89
|
+
export function storeCountLabel(count: number): string {
|
|
90
|
+
return count === 1 ? '1 store' : `${count} stores`;
|
|
91
|
+
}
|