@dzhechkov/harness-core 0.3.106 → 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.
Files changed (40) hide show
  1. package/dist/claim-check-hook-policy.d.ts +8 -4
  2. package/dist/claim-check-hook-policy.d.ts.map +1 -1
  3. package/dist/claim-check-hook-policy.js +8 -24
  4. package/dist/claim-check-hook-policy.js.map +1 -1
  5. package/dist/claim-check.d.ts +31 -0
  6. package/dist/claim-check.d.ts.map +1 -1
  7. package/dist/claim-check.js +172 -14
  8. package/dist/claim-check.js.map +1 -1
  9. package/dist/feature-adr-routing.d.ts +103 -0
  10. package/dist/feature-adr-routing.d.ts.map +1 -1
  11. package/dist/feature-adr-routing.js +220 -0
  12. package/dist/feature-adr-routing.js.map +1 -1
  13. package/dist/index.d.ts +6 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/publish.d.ts +28 -0
  18. package/dist/publish.d.ts.map +1 -1
  19. package/dist/publish.js +48 -1
  20. package/dist/publish.js.map +1 -1
  21. package/dist/registry.d.ts.map +1 -1
  22. package/dist/registry.js +13 -2
  23. package/dist/registry.js.map +1 -1
  24. package/dist/sign.d.ts +158 -0
  25. package/dist/sign.d.ts.map +1 -0
  26. package/dist/sign.js +325 -0
  27. package/dist/sign.js.map +1 -0
  28. package/dist/skill-schema.d.ts +24 -0
  29. package/dist/skill-schema.d.ts.map +1 -0
  30. package/dist/skill-schema.js +42 -0
  31. package/dist/skill-schema.js.map +1 -0
  32. package/package.json +3 -3
  33. package/src/claim-check-hook-policy.ts +8 -20
  34. package/src/claim-check.ts +186 -14
  35. package/src/feature-adr-routing.ts +271 -0
  36. package/src/index.ts +6 -4
  37. package/src/publish.ts +73 -1
  38. package/src/registry.ts +9 -2
  39. package/src/sign.ts +421 -0
  40. package/src/skill-schema.ts +53 -0
package/src/publish.ts CHANGED
@@ -80,6 +80,72 @@ function maxPublished(name: string, localVersion: string): string {
80
80
  return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
81
81
  }
82
82
 
83
+
84
+ // ── npm provenance (ADR-001, publish-provenance) ────────────────────────────
85
+ //
86
+ // Provenance is minted from a GitHub OIDC token during the publish job. There is no private key for us
87
+ // to hold, leak, or rotate — which is why it supersedes the Ed25519 signing key we never generated.
88
+ // It can only be produced where a token can be minted, so the DECISION belongs to the environment, and
89
+ // the decision is a pure function whose output is the exact argv a test can assert.
90
+
91
+ export type ProvenanceMode = 'auto' | 'on' | 'off';
92
+
93
+ /**
94
+ * The facts that mean an OIDC token can actually be minted.
95
+ *
96
+ * Cross-model review (codex exec, 2026-07-10): GitHub sets BOTH `ACTIONS_ID_TOKEN_REQUEST_URL` and
97
+ * `ACTIONS_ID_TOKEN_REQUEST_TOKEN` when `permissions: id-token: write` is granted. Checking only the
98
+ * URL would pass `--provenance` in a job where minting then fails.
99
+ *
100
+ * Honest limit: presence is not proof that a token can be minted (a stale or unreachable URL still
101
+ * looks capable). npm fails loudly in that case; this guard only prevents the failure we can foresee.
102
+ */
103
+ export function environmentCanMintProvenance(env: NodeJS.ProcessEnv): boolean {
104
+ const nonEmpty = (v: string | undefined): boolean => typeof v === 'string' && v.length > 0;
105
+ return (
106
+ env.GITHUB_ACTIONS === 'true' &&
107
+ nonEmpty(env.ACTIONS_ID_TOKEN_REQUEST_URL) &&
108
+ nonEmpty(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN)
109
+ );
110
+ }
111
+
112
+ export interface ProvenanceDecision {
113
+ readonly useProvenance: boolean;
114
+ readonly reason: string;
115
+ }
116
+
117
+ /**
118
+ * `on` in an environment that cannot mint a token is an ERROR, not a downgrade: failing before the batch
119
+ * starts beats failing halfway through 45 packages.
120
+ *
121
+ * `off` is an escape hatch for a registry outage, and it says so out loud — a safety check the caller can
122
+ * quietly narrow is not a safety check.
123
+ */
124
+ export function decideProvenance(mode: ProvenanceMode, env: NodeJS.ProcessEnv): ProvenanceDecision {
125
+ const capable = environmentCanMintProvenance(env);
126
+ if (mode === 'off') {
127
+ return { useProvenance: false, reason: 'provenance disabled explicitly (--no-provenance)' };
128
+ }
129
+ if (mode === 'on') {
130
+ if (!capable) {
131
+ throw new Error(
132
+ 'dz publish: --provenance requires GITHUB_ACTIONS=true and ACTIONS_ID_TOKEN_REQUEST_URL ' +
133
+ '(an OIDC token cannot be minted here) — refusing to start the batch',
134
+ );
135
+ }
136
+ return { useProvenance: true, reason: 'provenance forced on (--provenance)' };
137
+ }
138
+ return capable
139
+ ? { useProvenance: true, reason: 'provenance auto-enabled: GitHub Actions with an OIDC token' }
140
+ : { useProvenance: false, reason: 'provenance auto-disabled: no OIDC token in this environment' };
141
+ }
142
+
143
+ /** The exact command. A test asserts this string; nothing is assembled inline at the call site. */
144
+ export function publishArgv(mode: ProvenanceMode, env: NodeJS.ProcessEnv): string {
145
+ const base = 'pnpm publish --access public --no-git-checks';
146
+ return decideProvenance(mode, env).useProvenance ? base + ' --provenance' : base;
147
+ }
148
+
83
149
  /** Discover all publishable @dzhechkov packages. */
84
150
  export function discoverPackages(monorepoRoot: string): { name: string; dir: string; version: string }[] {
85
151
  const baseDir = join(monorepoRoot, 'packages', '@dzhechkov');
@@ -219,8 +285,14 @@ export function publishPackages(
219
285
  * disables the gate entirely (no `claimCheck` field is emitted).
220
286
  */
221
287
  claimGate?: 'off' | 'warn' | 'error' | undefined;
288
+ /** ADR-001: `auto` (default) decides from the environment; `on` fails where it cannot work. */
289
+ provenance?: ProvenanceMode | undefined;
222
290
  } = {},
223
291
  ): PublishReport {
292
+ // Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
293
+ // package 7 of 45 (recalled lesson: a failed publish that retries with a bump orphans version numbers).
294
+ const publishCmd = publishArgv(opts.provenance ?? 'auto', process.env);
295
+
224
296
  const packages = discoverPackages(monorepoRoot);
225
297
  const results: PublishResult[] = [];
226
298
  const filtered = opts.filter && opts.filter.length > 0
@@ -314,7 +386,7 @@ export function publishPackages(
314
386
  }
315
387
 
316
388
  // Publish
317
- execSync('pnpm publish --access public --no-git-checks', {
389
+ execSync(publishCmd, {
318
390
  cwd: pkg.dir,
319
391
  stdio: 'pipe',
320
392
  encoding: 'utf-8',
package/src/registry.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
 
10
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
11
11
  import { basename, dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
@@ -82,7 +82,14 @@ export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string
82
82
  const out: { pack: string; dir: string }[] = [];
83
83
  for (const base of skillPackBaseDirs(cwd)) {
84
84
  for (const e of readdirSync(base, { withFileTypes: true })) {
85
- if (e.isDirectory() && e.name.startsWith('skills-') && !seen.has(e.name)) {
85
+ // pnpm links workspace/`node_modules` packages as SYMLINKS, so `isDirectory()` is false for
86
+ // them and every pack would be skipped — a verifier that checks nothing (cross-model review,
87
+ // 2026-07-10). Follow a symlink at the PACK-ROOT level only; file hashing below still refuses
88
+ // to follow symlinks (O_NOFOLLOW).
89
+ const isPackDir =
90
+ e.isDirectory() ||
91
+ (e.isSymbolicLink() && (() => { try { return statSync(join(base, e.name)).isDirectory(); } catch { return false; } })());
92
+ if (isPackDir && e.name.startsWith('skills-') && !seen.has(e.name)) {
86
93
  seen.add(e.name);
87
94
  out.push({ pack: e.name, dir: join(base, e.name) });
88
95
  }
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
+ }