@dzhechkov/harness-cli 0.3.210 → 0.3.211

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/README.md CHANGED
@@ -280,6 +280,81 @@ npx @dzhechkov/keysarium init # 7-phase research + comman
280
280
  - Need a **themed set** that works together → `--preset`
281
281
  - Need a **full pipeline** with commands and governance → `npx`
282
282
 
283
+ ### `dz sign` / `dz verify-pack` — cryptographic tamper-evidence
284
+
285
+ Ed25519 over a file manifest, plus a CycloneDX SBOM. Zero dependencies (`node:crypto`).
286
+
287
+ **What it answers:** *are the bytes I am looking at the bytes the holder of the pinned key signed?*
288
+ **What it does NOT answer:** whether those bytes are any good. A signature gives provenance and
289
+ tamper-evidence, never truthfulness.
290
+
291
+ ```bash
292
+ # Sign a pack. The private key MUST live outside the repo — dz refuses otherwise.
293
+ dz sign --pack packages/@dzhechkov/skills-qe --key ~/.dz/keys/dz.key
294
+
295
+ # Verify. The public key comes from the REPO (keys/dz.pub), never from the pack itself:
296
+ # whoever replaced the pack would have replaced a key shipped inside it.
297
+ dz verify-pack --pack packages/@dzhechkov/skills-qe # exit 0 = unmodified
298
+ dz verify-pack --pack ./downloaded-pack --pubkey keys/dz.pub # explicit trust root
299
+ ```
300
+
301
+ A single flipped byte, a deleted file, or an **added** file all fail verification and the offending
302
+ path is named. Every degenerate input (no manifest, empty file list, empty signature, no public key)
303
+ fails **closed** — absence never reads as success.
304
+
305
+ `dz publish` runs the check before publishing anything. With `keys/dz.pub` committed, an unsigned or
306
+ mismatching pack **blocks the release**. Until then, packs publish unsigned and `dz publish` says so on
307
+ every run; `--require-signing` turns that into a refusal today.
308
+
309
+ **From Claude Code, in plain language:**
310
+ > "Sign the QE skill pack with my key at ~/.dz/keys/dz.key, then verify it."
311
+ > "Check whether the pack I just downloaded matches what we published."
312
+ > "Publish, but refuse if anything is unsigned."
313
+
314
+ ### Signature checks in `dz doctor` and `dz upgrade`
315
+
316
+ `dz doctor` verifies every installed `skills-*` pack against a **pinned** Ed25519 key, and `dz upgrade`
317
+ verifies what it just installed. A pack that does not match its signed manifest is **fatal**:
318
+
319
+ ```bash
320
+ dz doctor # ... signatures: 3 verified, 0 unsigned, 0 TAMPERED, 0 unverifiable
321
+ dz doctor --require-signing # an unsigned pack becomes fatal too
322
+ dz upgrade # a TAMPERED pack aborts the upgrade
323
+ dz doctor --pubkey ./my.pub # verify against a key you pinned yourself
324
+ ```
325
+
326
+ Key precedence: `--pubkey` > the repository's `keys/dz.pub` > the key shipped inside `harness-cli`.
327
+ The key **never** comes from the pack being verified — whoever replaced the pack would have replaced a
328
+ key that travelled inside it.
329
+
330
+ **Honest limits.** The packaged key lives in the verifier and vouches for *other* packs; a compromised
331
+ `harness-cli` is outside the threat model, because you have already run its code. And a signature proves
332
+ the bytes are unmodified — never that the skill is any good. Today no key is committed, so every pack
333
+ reports `unverifiable` and nothing fails.
334
+
335
+ **From Claude Code, in plain language:**
336
+ > "Run doctor and tell me if any installed pack was modified."
337
+ > "Upgrade, but abort if anything fails its signature."
338
+
339
+ ### npm provenance on release
340
+
341
+ `dz publish` appends `--provenance` **only** where an OIDC token can be minted — `GITHUB_ACTIONS=true`
342
+ plus both `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which GitHub exports under
343
+ `permissions: id-token: write`.
344
+
345
+ ```bash
346
+ dz publish # auto: provenance in CI, silent no-op locally
347
+ dz publish --provenance # force. FAILS before the batch if the environment cannot mint a token
348
+ dz publish --no-provenance # escape hatch for a registry outage; prints why it was used
349
+ ```
350
+
351
+ Provenance proves *which workflow, at which commit, built this tarball* — and there is no private key for
352
+ anyone to leak. It does **not** prove the code is good, and an attacker who can push to `main` and trigger
353
+ the release job gets a perfectly attested malicious package.
354
+
355
+ A ready-to-install workflow is at `features/publish-provenance/07_code_changes/publish.yml`; copy it to
356
+ `.github/workflows/` and add an `NPM_TOKEN` secret.
357
+
283
358
  ### Skill Packs (22 packs · 173 skills)
284
359
 
285
360
  Each pack is an npm package — click through for the **full per-skill documentation** (what each skill does + how to trigger it). Install a whole pack with `dz install <pkg>`, or pick skills with `dz init --select` / a `--preset`.
@@ -301,7 +376,7 @@ Each pack is an npm package — click through for the **full per-skill documenta
301
376
  | [@dzhechkov/skills-pm](https://www.npmjs.com/package/@dzhechkov/skills-pm) | 18 | *imported (MIT)* — product-management toolkit: OST, RICE/ICE prioritization, product-strategy, pricing, OKRs, NSM/metrics/A-B/cohort, outcome-roadmap, stakeholder-map, sprint-plan, strategy-red-team, GTM/growth/beachhead, market-sizing; curated from phuryn/pm-skills (`dz init --preset pm`) ([ADR-0002](https://github.com/djd1m/dz-harness-hub/blob/main/docs/adr/0002-product-and-design-expansion.md)) |
302
377
  | [@dzhechkov/skills-taste](https://www.npmjs.com/package/@dzhechkov/skills-taste) | 1 | *imported (MIT)* — `design-taste-frontend`: anti-slop landing/portfolio/redesign framework (dials + pre-flight + GSAP skeletons); complements frontend-design (`dz init --select design-taste-frontend`) ([ADR-0002](https://github.com/djd1m/dz-harness-hub/blob/main/docs/adr/0002-product-and-design-expansion.md)) |
303
378
  | [@dzhechkov/skills-book-digitizer](https://www.npmjs.com/package/@dzhechkov/skills-book-digitizer) | 8 | *dz-original* — book → installable methodology pack: `digitize-book` (orchestrator) + ingest/extract/distill/pack/kb-index + `book-brain-register` (CP6 promote → cross-project brain) + `source-brain-ingest` (repo sibling). Verified provenance, IP-safe, resumable (`dz init --select digitize-book`) ([ADR-001](https://github.com/djd1m/dz-harness-hub/blob/main/features/book-knowledge-digitizer/03_adr/001-book-to-skillpack-pipeline.md)) |
304
- | [@dzhechkov/skills-12factor](https://www.npmjs.com/package/@dzhechkov/skills-12factor) | 12 | *generated by the digitizer, CC BY 4.0* — The Twelve-Factor App distilled into 12 decision-moment skills (one per factor). The first PUBLIC digitized-book pack; paraphrased (shingling-gated), routing-gated 100%/0%, attributed (`NOTICE`) (`dz init --select 12factor-config-in-environment,…`) |
379
+ | [@dzhechkov/skills-12factor](https://www.npmjs.com/package/@dzhechkov/skills-12factor) | 12 | *generated by the digitizer, CC BY 4.0* — The Twelve-Factor App distilled into 12 decision-moment skills (one per factor). The first PUBLIC digitized-book pack; paraphrased (shingling-gated), routing-gated (every factor carries triggers), attributed (`NOTICE`) (`dz init --select 12factor-config-in-environment,…`) |
305
380
 
306
381
  ### Available Presets (13)
307
382
 
@@ -356,7 +431,7 @@ Get the whole set with `dz init --target claude-code --preset meta`, or pick one
356
431
 
357
432
  > **A skill and its npx toolkit are not duplicates — they're a graduation.** Several skills (e.g. `feature-adr`, `design-thinking`) exist BOTH as a skill inside a `dz` preset AND as a standalone `npx` package. The preset's SKILL.md is **fully functional on its own** (the whole methodology — modules + references — travels with it, and it auto-activates by description), and it's the only way to compile that capability to the **non-Claude platforms** (Codex/OpenCode/Hermes/OpenClaude) via `dz`. The npx package adds **project-level runtime governance** around the same skill: a slash command, governance rules, a context shard, and (for feature-adr) reward-learning + `/harvest`. So: pick the **skill/preset** for a working capability across platforms; pick the **npx toolkit** when you want it as a governed, command-driven fixture of one project.
358
433
 
359
- ## All Commands (41)
434
+ ## All Commands (43)
360
435
 
361
436
  ```
362
437
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force]
@@ -1049,7 +1124,7 @@ Or cherry-pick: `dz compose meta+keysarium` for competitive analysis.
1049
1124
 
1050
1125
  Without optional skills, design-thinking uses built-in fallbacks.
1051
1126
 
1052
- BTO benchmark: L0 Grade A (100%), L2 Opus weighted 7.58/10.
1127
+ BTO benchmark: L0 Grade A, L2 Opus weighted 7.58/10.
1053
1128
 
1054
1129
  ---
1055
1130
 
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAuKH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AA+xHD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkH5E"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAqLH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAyiID,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAsH5E"}
package/dist/cli.js CHANGED
@@ -5,11 +5,11 @@
5
5
  */
6
6
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { execSync } from 'node:child_process';
9
10
  import { homedir } from 'node:os';
10
11
  import { createRequire } from 'node:module';
11
- import { fileURLToPath } from 'node:url';
12
- import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, WORKFLOW_NAMES, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, } from '@dzhechkov/harness-core';
12
+ import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, WORKFLOW_NAMES, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, decideProvenance, isInsideTree, signManifest, verifyManifest, assertKeyOutsideTree, decidePublishGate, MANIFEST_NAME, SBOM_NAME, } from '@dzhechkov/harness-core';
13
13
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
14
14
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
15
15
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -22,13 +22,16 @@ Usage:
22
22
  dz list [--skills-dir <dir>]
23
23
  dz info <skill-id> [--skills-dir <dir>]
24
24
  dz migrate [--project <dir>]
25
- dz doctor [--project <dir>]
26
25
  dz create-skill --name <id> [--description <text>] [--skills-dir <dir>] [--tier <1-3>] [--with-references] [--no-evals] [--bto]
27
26
  dz scout [--topics <list>] [--since <date>] [--deep] [--output <file>] [--diff] [--report]
28
27
  dz workflow <task> [--dry-run]
29
28
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
30
29
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
31
- dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
30
+ dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
31
+ dz upgrade [--target <name>] [--pubkey <path>] [--require-signing] (a TAMPERED pack aborts the upgrade)
32
+ dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
33
+ dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
34
+ dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
32
35
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force] [--enrich]
33
36
  dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
34
37
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
@@ -57,7 +60,6 @@ Usage:
57
60
  dz recommend "<task description>"
58
61
  dz compose <preset1+preset2+...> [--target <name>]
59
62
  dz diff <skill-dir>
60
- dz upgrade [--target <name>] [--project <dir>]
61
63
  dz auto-canonicalize --source <github-url> --pack <skills-pack>
62
64
  dz registry [search <query>] [--category <cat>]
63
65
  dz benchmark <skill-dir> [--compare <dir>] [--all]
@@ -505,14 +507,18 @@ function cmdMigrate(options, cwd, write) {
505
507
  write(`\nRecommendation: ${report.recommendation}`);
506
508
  return 0;
507
509
  }
508
- async function cmdDoctor(options, cwd, write) {
510
+ async function cmdDoctor(options, flags, cwd, write) {
509
511
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
510
512
  const report = await runDoctor({ projectRoot });
511
513
  write(`dz doctor (${report.node}):`);
512
514
  for (const check of report.checks) {
513
515
  write(` [${check.ok ? 'OK' : 'XX'}] ${check.name} - ${check.detail}`);
514
516
  }
515
- return report.ok ? 0 : 1;
517
+ // ADR-001 (verify-apply-leg): the consumer-side apply-leg. A TAMPERED pack is fatal; an unsigned
518
+ // pack or a missing trust root is reported. A signature proves the bytes are unmodified — never
519
+ // that the skill is any good.
520
+ const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
521
+ return report.ok && sigFatal === 0 ? 0 : 1;
516
522
  }
517
523
  function cmdRoam(options, flags, cwd, write) {
518
524
  const apply = flags.has('apply');
@@ -2653,7 +2659,7 @@ function cmdRecommend(options, cwd, write) {
2653
2659
  write(` Get it: dz init --target claude-code --select skill-advisor (or --preset meta)`);
2654
2660
  return 0;
2655
2661
  }
2656
- function cmdUpgrade(options, cwd, write) {
2662
+ function cmdUpgrade(options, flags, cwd, write) {
2657
2663
  const targetOpt = options.get('target') ?? 'claude-code';
2658
2664
  if (!isTargetName(targetOpt)) {
2659
2665
  write(`dz upgrade: --target must be one of: ${TARGET_NAMES.join(', ')}`);
@@ -2692,6 +2698,12 @@ function cmdUpgrade(options, cwd, write) {
2692
2698
  if (report.needsUpdate > 0) {
2693
2699
  write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${targetOpt} --force`);
2694
2700
  }
2701
+ // ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
2702
+ const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
2703
+ if (sigFatal !== 0) {
2704
+ write('dz upgrade: aborting — an installed pack does not match its signed manifest');
2705
+ return 1;
2706
+ }
2695
2707
  return 0;
2696
2708
  }
2697
2709
  async function cmdAutoCanonicalize(options, cwd, write) {
@@ -2753,10 +2765,212 @@ async function cmdAutoCanonicalize(options, cwd, write) {
2753
2765
  return 1;
2754
2766
  }
2755
2767
  }
2768
+ /** The pinned trust root. A key inside the artifact under verification is data, not a key (ADR-001). */
2769
+ const TRUST_ROOT_REL = 'keys/dz.pub';
2770
+ function packFiles(dir) {
2771
+ const out = [];
2772
+ const walk = (d, rel) => {
2773
+ for (const e of readdirSync(d, { withFileTypes: true })) {
2774
+ if (e.name === 'node_modules' || e.name === '.git')
2775
+ continue;
2776
+ if (e.name === MANIFEST_NAME || e.name === SBOM_NAME)
2777
+ continue;
2778
+ const abs = join(d, e.name);
2779
+ const r = rel ? rel + '/' + e.name : e.name;
2780
+ if (e.isDirectory())
2781
+ walk(abs, r);
2782
+ else if (e.isFile())
2783
+ out.push(r);
2784
+ }
2785
+ };
2786
+ walk(dir, '');
2787
+ return out.sort();
2788
+ }
2789
+ /**
2790
+ * The consumer-side apply-leg (ADR-001, verify-apply-leg). All security content lives in the two pure
2791
+ * functions in harness-core; this only resolves paths, reads bytes, and reports.
2792
+ *
2793
+ * The packaged key sits inside harness-cli — the VERIFIER — and vouches for other packs. It never
2794
+ * comes from the pack under verification.
2795
+ */
2796
+ function packagedTrustRootPath() {
2797
+ // dist/cli.js -> ../keys/dz.pub (and src/cli.ts -> ../keys/dz.pub when run from source)
2798
+ const p = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'keys', 'dz.pub');
2799
+ return existsSync(p) ? p : undefined;
2800
+ }
2801
+ function verifyInstalledPacks(cwd, explicitPubkey) {
2802
+ // Cross-model review: `--pubkey ./missing.pub` used to fall back to the repo/packaged key and could
2803
+ // then report `verified` against a key the caller never asked for. An explicit request that cannot be
2804
+ // honoured is an error, not a suggestion.
2805
+ const explicit = explicitPubkey ? resolve(cwd, explicitPubkey) : undefined;
2806
+ if (explicit !== undefined && !existsSync(explicit)) {
2807
+ throw new Error('dz: --pubkey ' + explicit + ' does not exist — refusing to fall back to another key');
2808
+ }
2809
+ const repoKey = resolve(cwd, TRUST_ROOT_REL);
2810
+ const trustRoot = resolveTrustRoot({
2811
+ explicit: explicit && existsSync(explicit) ? explicit : undefined,
2812
+ repo: existsSync(repoKey) ? repoKey : undefined,
2813
+ packaged: packagedTrustRootPath(),
2814
+ });
2815
+ const packs = discoverSkillPackDirs(cwd);
2816
+ // Cross-model review: `--pubkey <pack>/evil.pub` would let the artifact supply its own verifying key
2817
+ // through the caller. The tool must never verify a pack against a key that lives inside it.
2818
+ if (trustRoot?.source === 'explicit') {
2819
+ for (const { dir } of packs) {
2820
+ if (isInsideTree(trustRoot.path, dir)) {
2821
+ throw new Error('dz: --pubkey lives inside the pack being verified (' + dir + ') — refusing');
2822
+ }
2823
+ }
2824
+ }
2825
+ const checks = [];
2826
+ for (const { pack, dir } of packs) {
2827
+ if (trustRoot === null) {
2828
+ checks.push({ pack, verdict: 'no-trust-root', failures: [] });
2829
+ continue;
2830
+ }
2831
+ const manifestPath = join(dir, MANIFEST_NAME);
2832
+ if (!existsSync(manifestPath)) {
2833
+ checks.push({ pack, verdict: 'unsigned', failures: [] });
2834
+ continue;
2835
+ }
2836
+ let signed;
2837
+ try {
2838
+ signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
2839
+ }
2840
+ catch {
2841
+ checks.push({ pack, verdict: 'tampered', failures: [{ path: MANIFEST_NAME, reason: 'not valid JSON' }] });
2842
+ continue;
2843
+ }
2844
+ // The key existed when the trust root was resolved; it can vanish before it is read. A crash is
2845
+ // not a verdict — fail closed with a named reason.
2846
+ let keyPem;
2847
+ try {
2848
+ keyPem = readFileSync(trustRoot.path, 'utf8');
2849
+ }
2850
+ catch {
2851
+ checks.push({ pack, verdict: 'no-trust-root', failures: [] });
2852
+ continue;
2853
+ }
2854
+ const res = verifyManifest(dir, signed, keyPem);
2855
+ checks.push({
2856
+ pack,
2857
+ verdict: res.ok ? 'verified' : 'tampered',
2858
+ failures: res.failures.map((f) => ({ path: f.path, reason: f.reason })),
2859
+ });
2860
+ }
2861
+ return { trustRoot, checks };
2862
+ }
2863
+ /** Print the pack verdicts and return 1 iff the policy says any of them is fatal. */
2864
+ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
2865
+ let trustRoot;
2866
+ let checks;
2867
+ try {
2868
+ ({ trustRoot, checks } = verifyInstalledPacks(cwd, explicitPubkey));
2869
+ }
2870
+ catch (err) {
2871
+ // A refusal is a result, not a crash: the user gets one line, not a stack trace.
2872
+ write(` [XX] ${err.message}`);
2873
+ return 1;
2874
+ }
2875
+ if (checks.length === 0)
2876
+ return 0;
2877
+ const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0 };
2878
+ let fatal = 0;
2879
+ for (const c of checks) {
2880
+ counts[c.verdict]++;
2881
+ const decision = decideVerifyPolicy(c.verdict, requireSigning);
2882
+ // Only a FATAL verdict earns a line of its own. 22 identical "unverifiable" lines is noise, and
2883
+ // noise is how a real failure gets scrolled past.
2884
+ if (decision.action === 'fail') {
2885
+ fatal++;
2886
+ write(` [XX] ${c.pack} - ${decision.reason}`);
2887
+ for (const f of c.failures)
2888
+ write(` ${f.path}: ${f.reason}`);
2889
+ }
2890
+ }
2891
+ const root = trustRoot ? `${trustRoot.source} (${trustRoot.path})` : 'none';
2892
+ write(` signatures: ${counts.verified} verified, ${counts.unsigned} unsigned, ` +
2893
+ `${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable; trust root: ${root}`);
2894
+ // A signature proves the bytes are unmodified. It never proves the skill is any good.
2895
+ return fatal > 0 ? 1 : 0;
2896
+ }
2897
+ function cmdSign(options, flags, cwd, write) {
2898
+ const pack = options.get('pack');
2899
+ const key = options.get('key');
2900
+ if (!pack || !key) {
2901
+ write('dz sign: --pack <dir> and --key <path> are both required');
2902
+ write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
2903
+ return 1;
2904
+ }
2905
+ const packDir = resolve(cwd, pack);
2906
+ if (!existsSync(packDir)) {
2907
+ write(`dz sign: no such pack: ${packDir}`);
2908
+ return 1;
2909
+ }
2910
+ try {
2911
+ assertKeyOutsideTree(resolve(cwd, key), cwd);
2912
+ }
2913
+ catch (err) {
2914
+ write(`dz sign: ${err.message}`);
2915
+ return 1;
2916
+ }
2917
+ if (!existsSync(resolve(cwd, key))) {
2918
+ write(`dz sign: private key not found: ${resolve(cwd, key)}`);
2919
+ return 1;
2920
+ }
2921
+ const files = packFiles(packDir);
2922
+ if (files.length === 0) {
2923
+ write('dz sign: the pack contains no files — refusing to sign nothing');
2924
+ return 1;
2925
+ }
2926
+ const manifest = buildManifest(packDir, basename(packDir), files);
2927
+ const signed = signManifest(manifest, readFileSync(resolve(cwd, key), 'utf8'));
2928
+ writeFileSync(join(packDir, MANIFEST_NAME), JSON.stringify(signed, null, 2) + '\n');
2929
+ writeFileSync(join(packDir, SBOM_NAME), JSON.stringify(buildSbom(manifest), null, 2) + '\n');
2930
+ write(`dz sign: signed ${files.length} file(s) in ${packDir}`);
2931
+ write(` ${MANIFEST_NAME} + ${SBOM_NAME} written. Ed25519 gives tamper-evidence, never truthfulness.`);
2932
+ return 0;
2933
+ }
2934
+ function cmdVerifyPack(options, flags, cwd, write) {
2935
+ const pack = options.get('pack');
2936
+ if (!pack) {
2937
+ write('dz verify-pack: --pack <dir> is required');
2938
+ return 1;
2939
+ }
2940
+ const packDir = resolve(cwd, pack);
2941
+ // The key is pinned in the repo. Never read it from the pack (ADR-001, recalled lesson).
2942
+ const pubPath = resolve(cwd, options.get('pubkey') ?? TRUST_ROOT_REL);
2943
+ if (!existsSync(pubPath)) {
2944
+ write(`dz verify-pack: no trust root at ${pubPath} — refusing to verify (fail closed)`);
2945
+ return 1;
2946
+ }
2947
+ const manifestPath = join(packDir, MANIFEST_NAME);
2948
+ if (!existsSync(manifestPath)) {
2949
+ write(`dz verify-pack: ${packDir} carries no ${MANIFEST_NAME}`);
2950
+ return 1;
2951
+ }
2952
+ let signed = null;
2953
+ try {
2954
+ signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
2955
+ }
2956
+ catch {
2957
+ write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`);
2958
+ return 1;
2959
+ }
2960
+ const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'));
2961
+ if (res.ok) {
2962
+ write(`dz verify-pack: OK — ${packDir} matches its signed manifest`);
2963
+ return 0;
2964
+ }
2965
+ write(`dz verify-pack: FAILED — ${packDir}`);
2966
+ for (const f of res.failures)
2967
+ write(` ${f.path}: ${f.reason}`);
2968
+ return 1;
2969
+ }
2756
2970
  function cmdPublish(options, flags, cwd, write) {
2757
2971
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
2758
2972
  // silently swallowed and flip the command into live-publish mode.
2759
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help']);
2973
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
2760
2974
  const allowedOptions = new Set(['filter', 'claim-check']);
2761
2975
  const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>';
2762
2976
  for (const flag of flags) {
@@ -2775,6 +2989,20 @@ function cmdPublish(options, flags, cwd, write) {
2775
2989
  return 1;
2776
2990
  }
2777
2991
  }
2992
+ // ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
2993
+ // refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
2994
+ if (flags.has('provenance') && flags.has('no-provenance')) {
2995
+ write('dz publish: --provenance and --no-provenance are mutually exclusive');
2996
+ return 1;
2997
+ }
2998
+ const provenance = flags.has('provenance') ? 'on' : flags.has('no-provenance') ? 'off' : 'auto';
2999
+ try {
3000
+ write(`dz publish: ${decideProvenance(provenance, process.env).reason}`);
3001
+ }
3002
+ catch (err) {
3003
+ write(err.message);
3004
+ return 1;
3005
+ }
2778
3006
  // Pre-publish claim-check gate strictness: reject (never coerce) an invalid value. Default 'warn'
2779
3007
  // per ADR-001 — findings are SURFACED on every publish, but 'warn' never changes publish status,
2780
3008
  // so the success path is unchanged. 'off' disables the gate; 'error' fails an offending package.
@@ -2825,7 +3053,44 @@ function cmdPublish(options, flags, cwd, write) {
2825
3053
  }
2826
3054
  write(`╚══════════════════════════════════════════════════════════════════════╝`);
2827
3055
  }
2828
- const report = publishPackages(cwd, { dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
3056
+ // FR-4 the signature gate, BEFORE anything is published (ADR-001).
3057
+ // Strictness follows the trust root: with no keys/dz.pub committed there is nothing to verify
3058
+ // against, and blocking would refuse every release forever. That is stated on every run.
3059
+ {
3060
+ const trustRoot = resolve(cwd, TRUST_ROOT_REL);
3061
+ const trustRootPresent = existsSync(trustRoot);
3062
+ const requireSigning = flags.has('require-signing');
3063
+ // `filter` is a string[] of substrings (matching publishPackages' own semantics), not a string.
3064
+ const targets = discoverPackages(cwd).filter((pk) => !filter || filter.length === 0 || filter.some((f) => pk.name.includes(f)));
3065
+ let blocked = 0;
3066
+ for (const pk of targets) {
3067
+ const manifestPath = join(pk.dir, MANIFEST_NAME);
3068
+ const manifestPresent = existsSync(manifestPath);
3069
+ let verifyOk = false;
3070
+ if (trustRootPresent && manifestPresent) {
3071
+ try {
3072
+ const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
3073
+ verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
3074
+ }
3075
+ catch {
3076
+ verifyOk = false;
3077
+ }
3078
+ }
3079
+ const decision = decidePublishGate({ trustRootPresent, manifestPresent, verifyOk, requireSigning });
3080
+ if (decision.action === 'block') {
3081
+ write(`dz publish: BLOCKED ${pk.name} — ${decision.reason}`);
3082
+ blocked++;
3083
+ }
3084
+ else if (decision.action === 'publish-unsigned') {
3085
+ write(`dz publish: ${pk.name} — ${decision.reason}`);
3086
+ }
3087
+ }
3088
+ if (blocked > 0) {
3089
+ write(`dz publish: refusing to publish (${blocked} package(s) failed the signature gate)`);
3090
+ return 1;
3091
+ }
3092
+ }
3093
+ const report = publishPackages(cwd, { provenance, dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
2829
3094
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
2830
3095
  write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
2831
3096
  for (const pkg of report.packages) {
@@ -3799,7 +4064,7 @@ export async function runCli(argv, io = {}) {
3799
4064
  case 'migrate':
3800
4065
  return cmdMigrate(options, cwd, write);
3801
4066
  case 'doctor':
3802
- return await cmdDoctor(options, cwd, write);
4067
+ return await cmdDoctor(options, flags, cwd, write);
3803
4068
  case 'install':
3804
4069
  return await cmdInstall(options, flags, cwd, write);
3805
4070
  case 'bundle':
@@ -3820,6 +4085,10 @@ export async function runCli(argv, io = {}) {
3820
4085
  return cmdUsage(options, optionLists, flags, cwd, write);
3821
4086
  case 'claim-check':
3822
4087
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
4088
+ case 'sign':
4089
+ return cmdSign(options, flags, cwd, write);
4090
+ case 'verify-pack':
4091
+ return cmdVerifyPack(options, flags, cwd, write);
3823
4092
  case 'setup':
3824
4093
  return await cmdSetup(options, flags, cwd, write);
3825
4094
  case 'pretrain':
@@ -3831,7 +4100,7 @@ export async function runCli(argv, io = {}) {
3831
4100
  case 'recommend':
3832
4101
  return cmdRecommend(options, cwd, write);
3833
4102
  case 'upgrade':
3834
- return cmdUpgrade(options, cwd, write);
4103
+ return cmdUpgrade(options, flags, cwd, write);
3835
4104
  case 'auto-canonicalize':
3836
4105
  return await cmdAutoCanonicalize(options, cwd, write);
3837
4106
  case 'publish':