@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/keys/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # Trust root
2
+
3
+ `dz.pub` — the pinned Ed25519 public key that `dz doctor` / `dz upgrade` use to verify **other**
4
+ `skills-*` packs. It is not here yet: no project key has been generated (see task #36). Until it is,
5
+ every pack reports `no-trust-root`, and nothing fails.
6
+
7
+ This key verifies other packs, never `harness-cli` itself. A compromised verifier is outside the threat
8
+ model: you have already executed its code. See `features/verify-apply-leg/03_adr/001-keyring-in-the-verifier.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-cli",
3
- "version": "0.3.210",
3
+ "version": "0.3.211",
4
4
  "description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 35 commands, 13 presets, 6 platform targets.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,8 @@
35
35
  "files": [
36
36
  "dist",
37
37
  "src",
38
- "README.md"
38
+ "README.md",
39
+ "keys"
39
40
  ],
40
41
  "dependencies": {
41
42
  "@dzhechkov/harness-presets": "^0.5.0",
@@ -54,7 +55,7 @@
54
55
  "@dzhechkov/skills-reverse-engineering": "^0.1.0",
55
56
  "@dzhechkov/skills-presentation-storyteller": "^0.1.0",
56
57
  "@dzhechkov/skills-website-cloner": "^0.1.0",
57
- "@dzhechkov/harness-core": "0.3.107"
58
+ "@dzhechkov/harness-core": "0.3.108"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/node": "^25.6.0",
package/src/cli.ts CHANGED
@@ -6,10 +6,10 @@
6
6
 
7
7
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
8
8
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
9
10
  import { execSync } from 'node:child_process';
10
11
  import { homedir } from 'node:os';
11
12
  import { createRequire } from 'node:module';
12
- import { fileURLToPath } from 'node:url';
13
13
 
14
14
  import {
15
15
  createSkill,
@@ -96,8 +96,20 @@ import {
96
96
  RECALL_USAGE_LOG_MAX_BYTES,
97
97
  parseRecallUsageLog,
98
98
  buildRecallUsageReport,
99
+ buildManifest,
100
+ buildSbom,
101
+ resolveTrustRoot,
102
+ decideVerifyPolicy,
103
+ decideProvenance,
104
+ isInsideTree,
105
+ signManifest,
106
+ verifyManifest,
107
+ assertKeyOutsideTree,
108
+ decidePublishGate,
109
+ MANIFEST_NAME,
110
+ SBOM_NAME,
99
111
  } from '@dzhechkov/harness-core';
100
- import type { ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow } from '@dzhechkov/harness-core';
112
+ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow } from '@dzhechkov/harness-core';
101
113
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
102
114
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
103
115
 
@@ -111,13 +123,16 @@ Usage:
111
123
  dz list [--skills-dir <dir>]
112
124
  dz info <skill-id> [--skills-dir <dir>]
113
125
  dz migrate [--project <dir>]
114
- dz doctor [--project <dir>]
115
126
  dz create-skill --name <id> [--description <text>] [--skills-dir <dir>] [--tier <1-3>] [--with-references] [--no-evals] [--bto]
116
127
  dz scout [--topics <list>] [--since <date>] [--deep] [--output <file>] [--diff] [--report]
117
128
  dz workflow <task> [--dry-run]
118
129
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
119
130
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
120
- 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)
131
+ dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
132
+ dz upgrade [--target <name>] [--pubkey <path>] [--require-signing] (a TAMPERED pack aborts the upgrade)
133
+ dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
134
+ dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
135
+ 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)
121
136
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force] [--enrich]
122
137
  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)
123
138
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
@@ -146,7 +161,6 @@ Usage:
146
161
  dz recommend "<task description>"
147
162
  dz compose <preset1+preset2+...> [--target <name>]
148
163
  dz diff <skill-dir>
149
- dz upgrade [--target <name>] [--project <dir>]
150
164
  dz auto-canonicalize --source <github-url> --pack <skills-pack>
151
165
  dz registry [search <query>] [--category <cat>]
152
166
  dz benchmark <skill-dir> [--compare <dir>] [--all]
@@ -653,14 +667,18 @@ function cmdMigrate(options: Map<string, string>, cwd: string, write: Write): nu
653
667
  return 0;
654
668
  }
655
669
 
656
- async function cmdDoctor(options: Map<string, string>, cwd: string, write: Write): Promise<number> {
670
+ async function cmdDoctor(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
657
671
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
658
672
  const report = await runDoctor({ projectRoot });
659
673
  write(`dz doctor (${report.node}):`);
660
674
  for (const check of report.checks) {
661
675
  write(` [${check.ok ? 'OK' : 'XX'}] ${check.name} - ${check.detail}`);
662
676
  }
663
- return report.ok ? 0 : 1;
677
+ // ADR-001 (verify-apply-leg): the consumer-side apply-leg. A TAMPERED pack is fatal; an unsigned
678
+ // pack or a missing trust root is reported. A signature proves the bytes are unmodified — never
679
+ // that the skill is any good.
680
+ const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
681
+ return report.ok && sigFatal === 0 ? 0 : 1;
664
682
  }
665
683
 
666
684
  function cmdRoam(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
@@ -2873,7 +2891,7 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
2873
2891
  return 0;
2874
2892
  }
2875
2893
 
2876
- function cmdUpgrade(options: Map<string, string>, cwd: string, write: Write): number {
2894
+ function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2877
2895
  const targetOpt = options.get('target') ?? 'claude-code';
2878
2896
  if (!isTargetName(targetOpt)) {
2879
2897
  write(`dz upgrade: --target must be one of: ${TARGET_NAMES.join(', ')}`);
@@ -2917,6 +2935,12 @@ function cmdUpgrade(options: Map<string, string>, cwd: string, write: Write): nu
2917
2935
  if (report.needsUpdate > 0) {
2918
2936
  write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${targetOpt} --force`);
2919
2937
  }
2938
+ // ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
2939
+ const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
2940
+ if (sigFatal !== 0) {
2941
+ write('dz upgrade: aborting — an installed pack does not match its signed manifest');
2942
+ return 1;
2943
+ }
2920
2944
  return 0;
2921
2945
  }
2922
2946
 
@@ -2988,10 +3012,213 @@ async function cmdAutoCanonicalize(options: Map<string, string>, cwd: string, wr
2988
3012
  }
2989
3013
  }
2990
3014
 
3015
+
3016
+ /** The pinned trust root. A key inside the artifact under verification is data, not a key (ADR-001). */
3017
+ const TRUST_ROOT_REL = 'keys/dz.pub';
3018
+
3019
+ function packFiles(dir: string): string[] {
3020
+ const out: string[] = [];
3021
+ const walk = (d: string, rel: string): void => {
3022
+ for (const e of readdirSync(d, { withFileTypes: true })) {
3023
+ if (e.name === 'node_modules' || e.name === '.git') continue;
3024
+ if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
3025
+ const abs = join(d, e.name);
3026
+ const r = rel ? rel + '/' + e.name : e.name;
3027
+ if (e.isDirectory()) walk(abs, r);
3028
+ else if (e.isFile()) out.push(r);
3029
+ }
3030
+ };
3031
+ walk(dir, '');
3032
+ return out.sort();
3033
+ }
3034
+
3035
+
3036
+ /**
3037
+ * The consumer-side apply-leg (ADR-001, verify-apply-leg). All security content lives in the two pure
3038
+ * functions in harness-core; this only resolves paths, reads bytes, and reports.
3039
+ *
3040
+ * The packaged key sits inside harness-cli — the VERIFIER — and vouches for other packs. It never
3041
+ * comes from the pack under verification.
3042
+ */
3043
+ function packagedTrustRootPath(): string | undefined {
3044
+ // dist/cli.js -> ../keys/dz.pub (and src/cli.ts -> ../keys/dz.pub when run from source)
3045
+ const p = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'keys', 'dz.pub');
3046
+ return existsSync(p) ? p : undefined;
3047
+ }
3048
+
3049
+ interface PackCheck {
3050
+ readonly pack: string;
3051
+ readonly verdict: PackVerdict;
3052
+ readonly failures: readonly { path: string; reason: string }[];
3053
+ }
3054
+
3055
+ function verifyInstalledPacks(cwd: string, explicitPubkey?: string | undefined): {
3056
+ readonly trustRoot: ReturnType<typeof resolveTrustRoot>;
3057
+ readonly checks: PackCheck[];
3058
+ } {
3059
+ // Cross-model review: `--pubkey ./missing.pub` used to fall back to the repo/packaged key and could
3060
+ // then report `verified` against a key the caller never asked for. An explicit request that cannot be
3061
+ // honoured is an error, not a suggestion.
3062
+ const explicit = explicitPubkey ? resolve(cwd, explicitPubkey) : undefined;
3063
+ if (explicit !== undefined && !existsSync(explicit)) {
3064
+ throw new Error('dz: --pubkey ' + explicit + ' does not exist — refusing to fall back to another key');
3065
+ }
3066
+ const repoKey = resolve(cwd, TRUST_ROOT_REL);
3067
+ const trustRoot = resolveTrustRoot({
3068
+ explicit: explicit && existsSync(explicit) ? explicit : undefined,
3069
+ repo: existsSync(repoKey) ? repoKey : undefined,
3070
+ packaged: packagedTrustRootPath(),
3071
+ });
3072
+
3073
+ const packs = discoverSkillPackDirs(cwd);
3074
+
3075
+ // Cross-model review: `--pubkey <pack>/evil.pub` would let the artifact supply its own verifying key
3076
+ // through the caller. The tool must never verify a pack against a key that lives inside it.
3077
+ if (trustRoot?.source === 'explicit') {
3078
+ for (const { dir } of packs) {
3079
+ if (isInsideTree(trustRoot.path, dir)) {
3080
+ throw new Error('dz: --pubkey lives inside the pack being verified (' + dir + ') — refusing');
3081
+ }
3082
+ }
3083
+ }
3084
+
3085
+ const checks: PackCheck[] = [];
3086
+ for (const { pack, dir } of packs) {
3087
+ if (trustRoot === null) {
3088
+ checks.push({ pack, verdict: 'no-trust-root', failures: [] });
3089
+ continue;
3090
+ }
3091
+ const manifestPath = join(dir, MANIFEST_NAME);
3092
+ if (!existsSync(manifestPath)) {
3093
+ checks.push({ pack, verdict: 'unsigned', failures: [] });
3094
+ continue;
3095
+ }
3096
+ let signed: unknown;
3097
+ try {
3098
+ signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
3099
+ } catch {
3100
+ checks.push({ pack, verdict: 'tampered', failures: [{ path: MANIFEST_NAME, reason: 'not valid JSON' }] });
3101
+ continue;
3102
+ }
3103
+ // The key existed when the trust root was resolved; it can vanish before it is read. A crash is
3104
+ // not a verdict — fail closed with a named reason.
3105
+ let keyPem: string;
3106
+ try {
3107
+ keyPem = readFileSync(trustRoot.path, 'utf8');
3108
+ } catch {
3109
+ checks.push({ pack, verdict: 'no-trust-root', failures: [] });
3110
+ continue;
3111
+ }
3112
+ const res = verifyManifest(dir, signed as never, keyPem);
3113
+ checks.push({
3114
+ pack,
3115
+ verdict: res.ok ? 'verified' : 'tampered',
3116
+ failures: res.failures.map((f) => ({ path: f.path, reason: f.reason })),
3117
+ });
3118
+ }
3119
+ return { trustRoot, checks };
3120
+ }
3121
+
3122
+ /** Print the pack verdicts and return 1 iff the policy says any of them is fatal. */
3123
+ function reportPackVerification(
3124
+ cwd: string,
3125
+ explicitPubkey: string | undefined,
3126
+ requireSigning: boolean,
3127
+ write: Write,
3128
+ ): number {
3129
+ let trustRoot: ReturnType<typeof resolveTrustRoot>;
3130
+ let checks: PackCheck[];
3131
+ try {
3132
+ ({ trustRoot, checks } = verifyInstalledPacks(cwd, explicitPubkey));
3133
+ } catch (err) {
3134
+ // A refusal is a result, not a crash: the user gets one line, not a stack trace.
3135
+ write(` [XX] ${(err as Error).message}`);
3136
+ return 1;
3137
+ }
3138
+ if (checks.length === 0) return 0;
3139
+
3140
+ const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0 } as Record<PackVerdict, number>;
3141
+ let fatal = 0;
3142
+ for (const c of checks) {
3143
+ counts[c.verdict]++;
3144
+ const decision = decideVerifyPolicy(c.verdict, requireSigning);
3145
+ // Only a FATAL verdict earns a line of its own. 22 identical "unverifiable" lines is noise, and
3146
+ // noise is how a real failure gets scrolled past.
3147
+ if (decision.action === 'fail') {
3148
+ fatal++;
3149
+ write(` [XX] ${c.pack} - ${decision.reason}`);
3150
+ for (const f of c.failures) write(` ${f.path}: ${f.reason}`);
3151
+ }
3152
+ }
3153
+ const root = trustRoot ? `${trustRoot.source} (${trustRoot.path})` : 'none';
3154
+ write(
3155
+ ` signatures: ${counts.verified} verified, ${counts.unsigned} unsigned, ` +
3156
+ `${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable; trust root: ${root}`,
3157
+ );
3158
+ // A signature proves the bytes are unmodified. It never proves the skill is any good.
3159
+ return fatal > 0 ? 1 : 0;
3160
+ }
3161
+
3162
+ function cmdSign(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3163
+ const pack = options.get('pack');
3164
+ const key = options.get('key');
3165
+ if (!pack || !key) {
3166
+ write('dz sign: --pack <dir> and --key <path> are both required');
3167
+ write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
3168
+ return 1;
3169
+ }
3170
+ const packDir = resolve(cwd, pack);
3171
+ if (!existsSync(packDir)) { write(`dz sign: no such pack: ${packDir}`); return 1; }
3172
+
3173
+ try {
3174
+ assertKeyOutsideTree(resolve(cwd, key), cwd);
3175
+ } catch (err) {
3176
+ write(`dz sign: ${(err as Error).message}`);
3177
+ return 1;
3178
+ }
3179
+ if (!existsSync(resolve(cwd, key))) { write(`dz sign: private key not found: ${resolve(cwd, key)}`); return 1; }
3180
+
3181
+ const files = packFiles(packDir);
3182
+ if (files.length === 0) { write('dz sign: the pack contains no files — refusing to sign nothing'); return 1; }
3183
+
3184
+ const manifest = buildManifest(packDir, basename(packDir), files);
3185
+ const signed = signManifest(manifest, readFileSync(resolve(cwd, key), 'utf8'));
3186
+ writeFileSync(join(packDir, MANIFEST_NAME), JSON.stringify(signed, null, 2) + '\n');
3187
+ writeFileSync(join(packDir, SBOM_NAME), JSON.stringify(buildSbom(manifest), null, 2) + '\n');
3188
+ write(`dz sign: signed ${files.length} file(s) in ${packDir}`);
3189
+ write(` ${MANIFEST_NAME} + ${SBOM_NAME} written. Ed25519 gives tamper-evidence, never truthfulness.`);
3190
+ return 0;
3191
+ }
3192
+
3193
+ function cmdVerifyPack(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3194
+ const pack = options.get('pack');
3195
+ if (!pack) { write('dz verify-pack: --pack <dir> is required'); return 1; }
3196
+ const packDir = resolve(cwd, pack);
3197
+
3198
+ // The key is pinned in the repo. Never read it from the pack (ADR-001, recalled lesson).
3199
+ const pubPath = resolve(cwd, options.get('pubkey') ?? TRUST_ROOT_REL);
3200
+ if (!existsSync(pubPath)) {
3201
+ write(`dz verify-pack: no trust root at ${pubPath} — refusing to verify (fail closed)`);
3202
+ return 1;
3203
+ }
3204
+ const manifestPath = join(packDir, MANIFEST_NAME);
3205
+ if (!existsSync(manifestPath)) { write(`dz verify-pack: ${packDir} carries no ${MANIFEST_NAME}`); return 1; }
3206
+
3207
+ let signed: unknown = null;
3208
+ try { signed = JSON.parse(readFileSync(manifestPath, 'utf8')); }
3209
+ catch { write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`); return 1; }
3210
+
3211
+ const res = verifyManifest(packDir, signed as never, readFileSync(pubPath, 'utf8'));
3212
+ if (res.ok) { write(`dz verify-pack: OK — ${packDir} matches its signed manifest`); return 0; }
3213
+ write(`dz verify-pack: FAILED — ${packDir}`);
3214
+ for (const f of res.failures) write(` ${f.path}: ${f.reason}`);
3215
+ return 1;
3216
+ }
3217
+
2991
3218
  function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2992
3219
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
2993
3220
  // silently swallowed and flip the command into live-publish mode.
2994
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help']);
3221
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
2995
3222
  const allowedOptions = new Set(['filter', 'claim-check']);
2996
3223
  const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>';
2997
3224
  for (const flag of flags) {
@@ -3010,6 +3237,20 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
3010
3237
  }
3011
3238
  }
3012
3239
 
3240
+ // ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
3241
+ // refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
3242
+ if (flags.has('provenance') && flags.has('no-provenance')) {
3243
+ write('dz publish: --provenance and --no-provenance are mutually exclusive');
3244
+ return 1;
3245
+ }
3246
+ const provenance: ProvenanceMode = flags.has('provenance') ? 'on' : flags.has('no-provenance') ? 'off' : 'auto';
3247
+ try {
3248
+ write(`dz publish: ${decideProvenance(provenance, process.env).reason}`);
3249
+ } catch (err) {
3250
+ write((err as Error).message);
3251
+ return 1;
3252
+ }
3253
+
3013
3254
  // Pre-publish claim-check gate strictness: reject (never coerce) an invalid value. Default 'warn'
3014
3255
  // per ADR-001 — findings are SURFACED on every publish, but 'warn' never changes publish status,
3015
3256
  // so the success path is unchanged. 'off' disables the gate; 'error' fails an offending package.
@@ -3065,7 +3306,46 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
3065
3306
  write(`╚══════════════════════════════════════════════════════════════════════╝`);
3066
3307
  }
3067
3308
 
3068
- const report = publishPackages(cwd, { dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
3309
+ // FR-4 the signature gate, BEFORE anything is published (ADR-001).
3310
+ // Strictness follows the trust root: with no keys/dz.pub committed there is nothing to verify
3311
+ // against, and blocking would refuse every release forever. That is stated on every run.
3312
+ {
3313
+ const trustRoot = resolve(cwd, TRUST_ROOT_REL);
3314
+ const trustRootPresent = existsSync(trustRoot);
3315
+ const requireSigning = flags.has('require-signing');
3316
+ // `filter` is a string[] of substrings (matching publishPackages' own semantics), not a string.
3317
+ const targets = discoverPackages(cwd).filter(
3318
+ (pk) => !filter || filter.length === 0 || filter.some((f) => pk.name.includes(f)),
3319
+ );
3320
+ let blocked = 0;
3321
+
3322
+ for (const pk of targets) {
3323
+ const manifestPath = join(pk.dir, MANIFEST_NAME);
3324
+ const manifestPresent = existsSync(manifestPath);
3325
+ let verifyOk = false;
3326
+ if (trustRootPresent && manifestPresent) {
3327
+ try {
3328
+ const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
3329
+ verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
3330
+ } catch {
3331
+ verifyOk = false;
3332
+ }
3333
+ }
3334
+ const decision = decidePublishGate({ trustRootPresent, manifestPresent, verifyOk, requireSigning });
3335
+ if (decision.action === 'block') {
3336
+ write(`dz publish: BLOCKED ${pk.name} — ${decision.reason}`);
3337
+ blocked++;
3338
+ } else if (decision.action === 'publish-unsigned') {
3339
+ write(`dz publish: ${pk.name} — ${decision.reason}`);
3340
+ }
3341
+ }
3342
+ if (blocked > 0) {
3343
+ write(`dz publish: refusing to publish (${blocked} package(s) failed the signature gate)`);
3344
+ return 1;
3345
+ }
3346
+ }
3347
+
3348
+ const report = publishPackages(cwd, { provenance, dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
3069
3349
 
3070
3350
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
3071
3351
  write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
@@ -4094,7 +4374,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
4094
4374
  case 'migrate':
4095
4375
  return cmdMigrate(options, cwd, write);
4096
4376
  case 'doctor':
4097
- return await cmdDoctor(options, cwd, write);
4377
+ return await cmdDoctor(options, flags, cwd, write);
4098
4378
  case 'install':
4099
4379
  return await cmdInstall(options, flags, cwd, write);
4100
4380
  case 'bundle':
@@ -4115,6 +4395,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
4115
4395
  return cmdUsage(options, optionLists, flags, cwd, write);
4116
4396
  case 'claim-check':
4117
4397
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
4398
+ case 'sign':
4399
+ return cmdSign(options, flags, cwd, write);
4400
+ case 'verify-pack':
4401
+ return cmdVerifyPack(options, flags, cwd, write);
4118
4402
  case 'setup':
4119
4403
  return await cmdSetup(options, flags, cwd, write);
4120
4404
  case 'pretrain':
@@ -4126,7 +4410,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
4126
4410
  case 'recommend':
4127
4411
  return cmdRecommend(options, cwd, write);
4128
4412
  case 'upgrade':
4129
- return cmdUpgrade(options, cwd, write);
4413
+ return cmdUpgrade(options, flags, cwd, write);
4130
4414
  case 'auto-canonicalize':
4131
4415
  return await cmdAutoCanonicalize(options, cwd, write);
4132
4416
  case 'publish':