@dzhechkov/harness-core 0.3.107 → 0.3.108

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/src/sign.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * `dz sign` / `dz verify` — Ed25519 tamper-evidence for a skill pack.
3
+ *
4
+ * SCOPE, stated once so a green check mark cannot overstate itself (recalled lesson, security,
5
+ * 2026-07-06): *"Verifying a signature against a public key EMBEDDED in the same signed object proves
6
+ * nothing about issuer identity. Trust must bind issuer -> a PINNED out-of-band key. Ed25519 gives
7
+ * provenance/tamper-evidence, never truthfulness."*
8
+ *
9
+ * So: `verifyManifest` takes the public key as a REQUIRED PARAMETER. There is no default, no fallback,
10
+ * and no lookup inside the pack. A `pubkey.pem` sitting in the pack is data, not a key.
11
+ *
12
+ * Zero dependencies: `node:crypto`, `node:fs`, `node:path`.
13
+ */
14
+
15
+ import { createHash, sign as cryptoSign, verify as cryptoVerify, createPrivateKey, createPublicKey } from 'node:crypto';
16
+ import { readFileSync, existsSync, readdirSync, openSync, fstatSync, closeSync, constants as fsConstants } from 'node:fs';
17
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
18
+
19
+ export const MANIFEST_NAME = '.dz-manifest.json';
20
+ export const SBOM_NAME = 'sbom.json';
21
+ export const MANIFEST_VERSION = 1;
22
+
23
+ export interface ManifestEntry {
24
+ readonly path: string;
25
+ readonly sha256: string;
26
+ }
27
+
28
+ export interface Manifest {
29
+ readonly version: number;
30
+ readonly pack: string;
31
+ readonly files: readonly ManifestEntry[];
32
+ }
33
+
34
+ export interface SignedManifest {
35
+ readonly manifest: Manifest;
36
+ /** base64 Ed25519 signature over `canonicalizeManifest(manifest.files)`. */
37
+ readonly signature: string;
38
+ }
39
+
40
+ export interface VerifyFailure {
41
+ readonly path: string;
42
+ readonly reason: string;
43
+ }
44
+
45
+ export interface VerifyResult {
46
+ readonly ok: boolean;
47
+ readonly failures: readonly VerifyFailure[];
48
+ }
49
+
50
+ /** sha256 of a file's bytes, hex. Follows symlinks — used only when building a manifest we control. */
51
+ export function hashFile(absPath: string): string {
52
+ return createHash('sha256').update(readFileSync(absPath)).digest('hex');
53
+ }
54
+
55
+ /**
56
+ * Round-2 review (codex exec): `lstat` then `readFile` is a TOCTOU window — the path can be swapped
57
+ * for a symlink between the two calls. Open ONCE with `O_NOFOLLOW`, `fstat` the descriptor, and read
58
+ * from that same descriptor. The bytes hashed are the bytes the stat described.
59
+ *
60
+ * Returns `null` when the path is not a regular file, or is a symlink.
61
+ */
62
+ export function hashRegularFileNoFollow(absPath: string): string | null {
63
+ let fd: number | undefined;
64
+ try {
65
+ fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
66
+ if (!fstatSync(fd).isFile()) return null;
67
+ return createHash('sha256').update(readFileSync(fd)).digest('hex');
68
+ } catch {
69
+ return null; // ELOOP on a symlink, ENOENT, EACCES — all fail closed
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Cross-model security review (codex exec, 2026-07-10) — a SIGNED manifest is not a TRUSTED manifest.
77
+ * Its own contents are attacker-controlled until the signature checks out, and even then they must be
78
+ * structurally sane before they touch the filesystem.
79
+ *
80
+ * - `join(root, '../../etc/passwd')` escaped the pack (traversal).
81
+ * - a newline in a path makes the canonical `<hex> <path>\n` encoding ambiguous.
82
+ * - duplicate paths make canonical bytes depend on input order — the sort is not a total order.
83
+ * - a non-string `path` threw inside `hashFile` instead of failing closed.
84
+ */
85
+ const SAFE_MANIFEST_PATH = /^[A-Za-z0-9._-][A-Za-z0-9._/-]*$/;
86
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
87
+
88
+ export function isSafeManifestPath(p: unknown): p is string {
89
+ if (typeof p !== 'string' || p.length === 0 || p.length > 4096) return false;
90
+ if (!SAFE_MANIFEST_PATH.test(p)) return false; // no control chars, no backslash, no leading /
91
+ if (p.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false;
92
+ return true;
93
+ }
94
+
95
+ /** Returns an error message, or `null` when the entry list is structurally sound. */
96
+ export function checkManifestEntries(files: unknown): string | null {
97
+ if (!Array.isArray(files)) return 'manifest has no file list';
98
+ if (files.length === 0) return 'manifest signs nothing';
99
+ const seen = new Set<string>();
100
+ for (const f of files) {
101
+ if (!f || typeof f !== 'object') return 'manifest entry is not an object';
102
+ const { path: pth, sha256 } = f as { path?: unknown; sha256?: unknown };
103
+ if (!isSafeManifestPath(pth)) return 'manifest entry has an unsafe path: ' + JSON.stringify(pth);
104
+ if (typeof sha256 !== 'string' || !SHA256_HEX.test(sha256)) return 'manifest entry has a malformed sha256 for ' + pth;
105
+ // Round-2 review: an exact-string `seen` set misses collisions on case-insensitive filesystems
106
+ // (`Readme` vs `README`). Unicode normalization (`e\u0301.txt` vs `é.txt`) cannot arise: paths are
107
+ // ASCII-only by `SAFE_MANIFEST_PATH`, and a non-ASCII path is refused before it reaches here. An
108
+ // NFC check here would be unreachable code pretending to be a guard.
109
+ const fold = pth.toLowerCase();
110
+ if (seen.has(fold)) return 'manifest lists ' + pth + ' twice (case/unicode fold) — canonical bytes would depend on order';
111
+ seen.add(fold);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Every file under `root`, POSIX-relative, excluding the manifest and the SBOM themselves. */
117
+ export function listPackFiles(root: string): string[] {
118
+ const out: string[] = [];
119
+ const walk = (dir: string, rel: string): void => {
120
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
121
+ if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
122
+ const abs = join(dir, e.name);
123
+ const r = rel ? rel + '/' + e.name : e.name;
124
+ if (e.isDirectory()) walk(abs, r);
125
+ else out.push(r);
126
+ }
127
+ };
128
+ walk(root, '');
129
+ return out.sort();
130
+ }
131
+
132
+ /**
133
+ * The bytes that get signed (FR-7). Sorted by path, LF endings, no trailing whitespace, and no
134
+ * dependence on JSON key order — a signature must not depend on how a serialiser felt that day.
135
+ * Format is `sha256sum`-compatible: `<hex> <path>\n`.
136
+ */
137
+ export function canonicalizeManifest(files: readonly ManifestEntry[]): Buffer {
138
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
139
+ const body = sorted.map((f) => f.sha256 + ' ' + f.path + '\n').join('');
140
+ return Buffer.from(body, 'utf8');
141
+ }
142
+
143
+ /**
144
+ * The bytes actually signed. Cross-model review (2026-07-10) found `pack` and `version` were carried
145
+ * in the manifest but NOT covered by the signature: an attacker could relabel a signed pack. Nothing
146
+ * is signed yet, so the format can change today. It can never change once packs are in the wild.
147
+ */
148
+ export function canonicalizeSigned(manifest: Manifest): Buffer {
149
+ const header = 'dz-manifest\nversion ' + String(manifest.version) + '\npack ' + manifest.pack + '\n';
150
+ return Buffer.concat([Buffer.from(header, 'utf8'), canonicalizeManifest(manifest.files)]);
151
+ }
152
+
153
+ /** Build a manifest for `files` (paths relative to `root`, POSIX separators). */
154
+ export function buildManifest(root: string, pack: string, files: readonly string[]): Manifest {
155
+ const entries = files.map((rel) => ({
156
+ path: rel.split(sep).join('/'),
157
+ sha256: hashFile(join(root, rel)),
158
+ }));
159
+ return { version: MANIFEST_VERSION, pack, files: entries };
160
+ }
161
+
162
+ export function signManifest(manifest: Manifest, privateKeyPem: string): SignedManifest {
163
+ // Defence in depth (round-2 review): `pack` is interpolated into a newline-delimited signed header.
164
+ // `verifyManifest` already refuses an unsafe pack name, so an ambiguous header could never verify —
165
+ // but refusing to CREATE one is cheaper than reasoning about why it cannot be exploited.
166
+ if (!isSafeManifestPath(manifest.pack)) throw new Error('refusing to sign an unsafe pack name: ' + JSON.stringify(manifest.pack));
167
+ if (!Number.isInteger(manifest.version)) throw new Error('refusing to sign a non-integer manifest version');
168
+ const structural = checkManifestEntries(manifest.files);
169
+ if (structural) throw new Error('refusing to sign a malformed manifest: ' + structural);
170
+ const key = createPrivateKey(privateKeyPem);
171
+ const sig = cryptoSign(null, canonicalizeSigned(manifest), key);
172
+ return { manifest, signature: sig.toString('base64') };
173
+ }
174
+
175
+ /**
176
+ * FR-8 / risk R1: every degenerate input FAILS CLOSED. Absence must never read as success — that is the
177
+ * failure mode that turns a security tool into a lie.
178
+ *
179
+ * `pubKeyPem` is required. It comes from the repo, never from `root`.
180
+ */
181
+ /**
182
+ * Round-2 review killed the `expectedFiles` option: a caller could NARROW the added-file check and
183
+ * silently allow an unsigned `evil.js` to sit in the pack. A safety option that a caller may disable
184
+ * is not a safety option. The pack is always scanned.
185
+ */
186
+ export function verifyManifest(
187
+ root: string,
188
+ signed: SignedManifest | null | undefined,
189
+ pubKeyPem: string,
190
+ ): VerifyResult {
191
+ const fail = (path: string, reason: string): VerifyResult => ({ ok: false, failures: [{ path, reason }] });
192
+
193
+ if (!signed || typeof signed !== 'object') return fail(MANIFEST_NAME, 'no manifest');
194
+ const { manifest, signature } = signed;
195
+ if (!manifest || typeof manifest !== 'object') return fail(MANIFEST_NAME, 'manifest is missing');
196
+ if (typeof manifest.version !== 'number' || !Number.isInteger(manifest.version)) {
197
+ return fail(MANIFEST_NAME, 'manifest version is not an integer');
198
+ }
199
+ if (!isSafeManifestPath(manifest.pack)) {
200
+ return fail(MANIFEST_NAME, 'manifest pack name is unsafe: ' + JSON.stringify(manifest.pack));
201
+ }
202
+ const structural = checkManifestEntries(manifest.files);
203
+ if (structural) return fail(MANIFEST_NAME, structural);
204
+ if (typeof signature !== 'string' || signature.length === 0) {
205
+ return fail(MANIFEST_NAME, 'manifest is unsigned');
206
+ }
207
+ if (typeof pubKeyPem !== 'string' || pubKeyPem.length === 0) {
208
+ return fail(MANIFEST_NAME, 'no public key supplied — refusing to verify');
209
+ }
210
+
211
+ // The signature first: if the manifest itself was rewritten, its file list is not evidence.
212
+ let sigOk = false;
213
+ try {
214
+ sigOk = cryptoVerify(
215
+ null,
216
+ canonicalizeSigned(manifest),
217
+ createPublicKey(pubKeyPem),
218
+ Buffer.from(signature, 'base64'),
219
+ );
220
+ } catch {
221
+ return fail(MANIFEST_NAME, 'signature is malformed');
222
+ }
223
+ if (!sigOk) return fail(MANIFEST_NAME, 'signature does not verify against the pinned key');
224
+
225
+ const failures: VerifyFailure[] = [];
226
+ for (const entry of manifest.files) {
227
+ const abs = join(root, entry.path);
228
+ if (!existsSync(abs)) {
229
+ failures.push({ path: entry.path, reason: 'listed in the manifest but absent' });
230
+ continue;
231
+ }
232
+ // One open, O_NOFOLLOW, fstat the descriptor, hash from it: no symlink follow, no TOCTOU window.
233
+ const digest = hashRegularFileNoFollow(abs);
234
+ if (digest === null) {
235
+ failures.push({ path: entry.path, reason: 'is a symlink or not a regular file — refusing to hash it' });
236
+ continue;
237
+ }
238
+ if (digest !== entry.sha256) {
239
+ failures.push({ path: entry.path, reason: 'content does not match its signed hash' });
240
+ }
241
+ }
242
+
243
+ // Bidirectional, always: hashing only what the manifest lists lets an attacker ADD a file.
244
+ const present = listPackFiles(root);
245
+ const listed = new Set(manifest.files.map((f) => f.path));
246
+ for (const rel of present) {
247
+ const p = rel.split(sep).join('/');
248
+ if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
249
+ }
250
+
251
+ return { ok: failures.length === 0, failures };
252
+ }
253
+
254
+ /**
255
+ * FR-5, the one irreversible mistake. `.gitignore` covers `*.pem` and `*.key` and nothing else — a key
256
+ * written as `signing-key.json` would be committed without complaint. Refuse by location, not by name.
257
+ */
258
+ export function isInsideTree(keyPath: string, repoRoot: string): boolean {
259
+ const key = resolve(keyPath);
260
+ const root = resolve(repoRoot);
261
+ if (key === root) return true;
262
+ const rel = relative(root, key);
263
+ // `relative` yields '' for the root itself, a '..'-prefixed path for anything outside, and an
264
+ // absolute path when the two share no root. Anything else is inside.
265
+ if (rel === '') return true;
266
+ if (rel === '..' || rel.startsWith('..' + sep)) return false;
267
+ if (isAbsolute(rel)) return false;
268
+ return true;
269
+ }
270
+
271
+ export function assertKeyOutsideTree(keyPath: string, repoRoot: string): void {
272
+ if (isInsideTree(keyPath, repoRoot)) {
273
+ throw new Error(
274
+ 'refusing to write a private key inside the repository working tree: ' +
275
+ resolve(keyPath) +
276
+ ' (a leaked signing key cannot be reverted — it means a new key and re-signing every pack)',
277
+ );
278
+ }
279
+ }
280
+
281
+ export interface SbomComponent {
282
+ readonly type: 'file';
283
+ readonly name: string;
284
+ readonly hashes: readonly { readonly alg: 'SHA-256'; readonly content: string }[];
285
+ }
286
+
287
+ export interface Sbom {
288
+ readonly bomFormat: 'CycloneDX';
289
+ readonly specVersion: '1.5';
290
+ readonly version: number;
291
+ readonly metadata: { readonly component: { readonly type: 'library'; readonly name: string } };
292
+ readonly components: readonly SbomComponent[];
293
+ }
294
+
295
+ /** CycloneDX 1.5 JSON, hand-built — it is JSON against a schema, not a reason for a dependency. */
296
+ export function buildSbom(manifest: Manifest): Sbom {
297
+ return {
298
+ bomFormat: 'CycloneDX',
299
+ specVersion: '1.5',
300
+ version: 1,
301
+ metadata: { component: { type: 'library', name: manifest.pack } },
302
+ components: manifest.files.map((f) => ({
303
+ type: 'file' as const,
304
+ name: f.path,
305
+ hashes: [{ alg: 'SHA-256' as const, content: f.sha256 }],
306
+ })),
307
+ };
308
+ }
309
+
310
+ // ── The publish gate (FR-4), as a pure decision ─────────────────────────────
311
+ //
312
+ // The operator's decision was "the publish gate BLOCKS". Reality intervened: with no trust root
313
+ // committed, refusing to publish an unsigned pack refuses to publish anything, forever — a gate that
314
+ // blocks on nothing. You cannot verify against a key you do not have.
315
+ //
316
+ // So the gate's strictness is a function of the trust root's existence, and that is stated out loud:
317
+ // - trust root present → every pack MUST verify. A missing or failing manifest blocks the release.
318
+ // - trust root absent → packs publish UNSIGNED, and the CLI says so on every run. `--require-signing`
319
+ // turns that into a refusal for anyone who wants the strict posture today.
320
+
321
+ export type PublishGateAction = 'block' | 'publish-unsigned' | 'publish-verified';
322
+
323
+ export interface PublishGateInput {
324
+ readonly trustRootPresent: boolean;
325
+ readonly manifestPresent: boolean;
326
+ readonly verifyOk: boolean;
327
+ readonly requireSigning: boolean;
328
+ }
329
+
330
+ export interface PublishGateDecision {
331
+ readonly action: PublishGateAction;
332
+ readonly reason: string;
333
+ }
334
+
335
+ export function decidePublishGate(input: PublishGateInput): PublishGateDecision {
336
+ if (!input.trustRootPresent) {
337
+ if (input.requireSigning) {
338
+ return { action: 'block', reason: 'no trust root (keys/dz.pub) and --require-signing was passed' };
339
+ }
340
+ return {
341
+ action: 'publish-unsigned',
342
+ reason: 'no trust root committed (keys/dz.pub) — packs publish UNSIGNED and unverifiable',
343
+ };
344
+ }
345
+ if (!input.manifestPresent) {
346
+ return { action: 'block', reason: 'trust root is present but the pack carries no signature manifest' };
347
+ }
348
+ if (!input.verifyOk) {
349
+ return { action: 'block', reason: 'the pack does not match its signed manifest' };
350
+ }
351
+ return { action: 'publish-verified', reason: 'manifest verified against the pinned key' };
352
+ }
353
+
354
+ // ── The consumer-side apply-leg (ADR-001, verify-apply-leg) ─────────────────
355
+ //
356
+ // A verifier nobody runs is a signature nobody checks. These two pure functions carry the whole
357
+ // security content of `dz doctor` / `dz upgrade`; the CLI bodies only print and exit.
358
+
359
+ export type PackVerdict = 'verified' | 'unsigned' | 'tampered' | 'no-trust-root';
360
+
361
+ export interface TrustRootCandidates {
362
+ /** `--pubkey <path>`, if the caller passed one and it exists. */
363
+ readonly explicit?: string | undefined;
364
+ /** `keys/dz.pub` in the repository, if present. */
365
+ readonly repo?: string | undefined;
366
+ /** The key shipped inside harness-cli — the verifier vouching for OTHER packs. */
367
+ readonly packaged?: string | undefined;
368
+ }
369
+
370
+ export interface TrustRoot {
371
+ readonly source: 'explicit' | 'repo' | 'packaged';
372
+ readonly path: string;
373
+ }
374
+
375
+ /**
376
+ * Precedence, explicit and pure: `--pubkey` > repo `keys/dz.pub` > the packaged key.
377
+ * Never derived from the pack under verification.
378
+ */
379
+ export function resolveTrustRoot(c: TrustRootCandidates): TrustRoot | null {
380
+ if (c.explicit) return { source: 'explicit', path: c.explicit };
381
+ if (c.repo) return { source: 'repo', path: c.repo };
382
+ if (c.packaged) return { source: 'packaged', path: c.packaged };
383
+ return null;
384
+ }
385
+
386
+ export type PolicyAction = 'ok' | 'report' | 'fail';
387
+
388
+ export interface PolicyDecision {
389
+ readonly action: PolicyAction;
390
+ readonly reason: string;
391
+ }
392
+
393
+ /**
394
+ * The whole feature, as a table:
395
+ *
396
+ * | verdict | requireSigning off | on |
397
+ * |----------------|--------------------|------|
398
+ * | verified | ok | ok |
399
+ * | unsigned | report | fail |
400
+ * | tampered | FAIL | FAIL |
401
+ * | no-trust-root | report | fail |
402
+ *
403
+ * `tampered` is fatal in both columns; `no-trust-root` is never success. That is the load-bearing
404
+ * property. Today every pack is `unsigned` or `no-trust-root` — the leg is wired, not armed.
405
+ */
406
+ export function decideVerifyPolicy(verdict: PackVerdict, requireSigning: boolean): PolicyDecision {
407
+ if (verdict === 'tampered') {
408
+ return { action: 'fail', reason: 'pack does not match its signed manifest' };
409
+ }
410
+ if (verdict === 'verified') {
411
+ return { action: 'ok', reason: 'verified against the pinned key' };
412
+ }
413
+ if (verdict === 'no-trust-root') {
414
+ return requireSigning
415
+ ? { action: 'fail', reason: 'no trust root available and --require-signing was passed' }
416
+ : { action: 'report', reason: 'no trust root available — nothing could be verified' };
417
+ }
418
+ return requireSigning
419
+ ? { action: 'fail', reason: 'pack is unsigned and --require-signing was passed' }
420
+ : { action: 'report', reason: 'pack is unsigned — not verified' };
421
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Single-source trustTier (ADR-001, trusttier-single-source).
3
+ *
4
+ * A skill's `schemas/output.json` used to hardcode `properties.trustTier.const` — a second copy of the
5
+ * `trust_tier` in its `SKILL.md` frontmatter. Nothing read the schema copy; the frontmatter is the
6
+ * single source (`registry.ts` reads it). Two copies of a value drift by construction, and this one
7
+ * had. This transform removes the value, leaving only a shape constraint.
8
+ */
9
+
10
+ export interface TrustTierStripResult {
11
+ /** The schema object, mutated in place if a const was present. */
12
+ readonly schema: Record<string, unknown>;
13
+ /** True iff a `const` was found under `properties.trustTier` and replaced. */
14
+ readonly changed: boolean;
15
+ }
16
+
17
+ /**
18
+ * Replace `properties.trustTier.const: N` with `{ minimum: 1, maximum: 3 }`, preserving every sibling
19
+ * key (`type`, `description`, …). Idempotent: a schema that already uses a range, has no `const`, or has
20
+ * no `trustTier`, is returned unchanged with `changed: false`.
21
+ *
22
+ * Pure w.r.t. inputs it does not own: it mutates the passed object (the caller owns it) and returns it,
23
+ * so it is trivially testable without a filesystem.
24
+ */
25
+ export function stripTrustTierConst(schema: Record<string, unknown>): TrustTierStripResult {
26
+ const props = schema.properties as Record<string, unknown> | undefined;
27
+ if (!props || typeof props !== 'object') return { schema, changed: false };
28
+
29
+ const tt = props.trustTier as Record<string, unknown> | undefined;
30
+ // Cross-model review: `'const' in tt` also matches an INHERITED const on the prototype, and
31
+ // `!== undefined` cannot tell 'key absent' from 'key present, value undefined'. Use own-property
32
+ // checks throughout so a crafted object cannot trick the transform.
33
+ if (!tt || typeof tt !== 'object' || !Object.hasOwn(tt, 'const')) return { schema, changed: false };
34
+
35
+ // Preserve type/description; drop the value; add the range. Order kept sane for a clean diff:
36
+ // type (if any) → minimum → maximum → description (if any) → any other siblings.
37
+ const { const: _dropped, type, description, ...rest } = tt as {
38
+ const: unknown;
39
+ type?: unknown;
40
+ description?: unknown;
41
+ } & Record<string, unknown>;
42
+
43
+ const rebuilt: Record<string, unknown> = {};
44
+ rebuilt.type = Object.hasOwn(tt, 'type') ? type : 'integer';
45
+ rebuilt.minimum = 1;
46
+ rebuilt.maximum = 3;
47
+ if (Object.hasOwn(tt, 'description')) rebuilt.description = description;
48
+ // `rest` already excludes const/type/description via destructuring; Object.entries is own-keys only.
49
+ for (const [k, v] of Object.entries(rest)) rebuilt[k] = v;
50
+
51
+ props.trustTier = rebuilt;
52
+ return { schema, changed: true };
53
+ }