@dzhechkov/harness-core 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +60 -796
- package/README.md +40 -1
- package/dist/agentdb-index.d.ts +11 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +8 -1
- package/dist/agentdb-index.js.map +1 -1
- package/dist/guard.d.ts +29 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +54 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/publish-signing.d.ts +113 -0
- package/dist/publish-signing.d.ts.map +1 -0
- package/dist/publish-signing.js +124 -0
- package/dist/publish-signing.js.map +1 -0
- package/dist/publish.d.ts +21 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +106 -0
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +19 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +91 -1
- package/dist/registry.js.map +1 -1
- package/dist/sign.d.ts +38 -4
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +174 -8
- package/dist/sign.js.map +1 -1
- package/dist/vector-tier.d.ts +81 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +137 -10
- package/dist/vector-tier.js.map +1 -1
- package/package.json +5 -5
- package/sbom.json +89 -1929
- package/src/agentdb-index.ts +8 -1
- package/src/guard.ts +74 -0
- package/src/index.ts +3 -1
- package/src/publish-signing.ts +217 -0
- package/src/publish.ts +102 -1
- package/src/registry.ts +84 -1
- package/src/sign.ts +162 -8
- package/src/vector-tier.ts +202 -12
package/src/agentdb-index.ts
CHANGED
|
@@ -173,7 +173,14 @@ export async function indexPatternsToAgentdb(
|
|
|
173
173
|
* The RECALL/search default task_types. Deliberately EXCLUDES `dz-backlog`: `dz recall` (and
|
|
174
174
|
* feature-adr Step-0) must never surface raw backlog ideas as if they were earned lessons (ADR-005).
|
|
175
175
|
*/
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The PATTERN scope — the task types a learned-pattern count covers. Exported so `dz vector status`
|
|
178
|
+
* can report a mirrored count comparable to its lexical one; the lifecycle superset below is for
|
|
179
|
+
* ownership, and reporting IT beside a pattern count once led a reader to conclude half the index
|
|
180
|
+
* was orphaned when none of it was.
|
|
181
|
+
*/
|
|
182
|
+
export const DZ_PATTERN_TASK_TYPES = ['dz-teach', 'dz-learning'] as const;
|
|
183
|
+
const DZ_TASK_TYPES = DZ_PATTERN_TASK_TYPES;
|
|
177
184
|
|
|
178
185
|
/**
|
|
179
186
|
* The dz-owned task_types for LIFECYCLE scans (id enumeration + reindex ownership) — a SUPERSET of
|
package/src/guard.ts
CHANGED
|
@@ -73,6 +73,28 @@ export interface GuardFacts {
|
|
|
73
73
|
readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
|
|
74
74
|
/** for readme-first: per publishable package, is a version bump staged without a README change? */
|
|
75
75
|
readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
|
|
76
|
+
/**
|
|
77
|
+
* for review-round: per publishable package, does this change bump a version AND touch SOURCE, and
|
|
78
|
+
* did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could
|
|
79
|
+
* not be read — the rule then reports nothing, which is different from reporting "no review".
|
|
80
|
+
*/
|
|
81
|
+
readonly reviewRound?: {
|
|
82
|
+
readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[];
|
|
83
|
+
/** grades parsed out of `features/*∕08_qe_report.md` files in this change set, in file order. */
|
|
84
|
+
readonly grades: readonly { readonly report: string; readonly grade: string }[];
|
|
85
|
+
/**
|
|
86
|
+
* Optional floor from `.dz/guard.json` → `reviewRound.minGrade`. Carried in the FACT because a
|
|
87
|
+
* rule body is a pure function of facts and takes no config — and because the owner reserved the
|
|
88
|
+
* choice of threshold, so the DEFAULT must stay "a grade is present".
|
|
89
|
+
*/
|
|
90
|
+
readonly minGrade?: string | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* `false` when the gatherer TRIED and could not read the tree. The note below fires only on
|
|
93
|
+
* that, never on a caller that simply never gathered — otherwise every synthetic evaluation
|
|
94
|
+
* carries a warning about evidence nobody asked for.
|
|
95
|
+
*/
|
|
96
|
+
readonly gathered?: boolean | undefined;
|
|
97
|
+
};
|
|
76
98
|
/**
|
|
77
99
|
* for agents-md-policy-sync: result of the pure policy drift detector, gathered by the CLI.
|
|
78
100
|
* `applicable:false` is a repo whose canonical policy sources are unreadable; omission means the
|
|
@@ -246,6 +268,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
246
268
|
// Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
|
|
247
269
|
// (structural self-exemption — tested in no-stubs.test.ts).
|
|
248
270
|
{ id: 'no-stubs', severity: 'soft', ops: ['publish'], description: `an unfinished-stub marker (${STUB_MARKERS.join('/')} / "${STUB_PHRASES.join('", "')}") left in a CHANGED file — any unwaived match means the change ships incomplete; waive per line with "no-stubs: <reason>" or per path in .dz/guard.json stubWaivers (reason MANDATORY)` },
|
|
271
|
+
{ id: 'review-round', severity: 'hard', ops: ['publish'], description: 'a package publishing CHANGED SOURCE must bring a GRADED features/*/08_qe_report.md in the same change. Scoped to source so a docs-only republish is never blocked; the floor is PRESENCE of a grade unless .dz/guard.json sets reviewRound.minGrade. It proves a graded report EXISTS for this change — NOT that the review was independent, competent, or taken against this exact revision' },
|
|
249
272
|
{ id: 'licence-hold', severity: 'hard', ops: ['publish'], description: 'a pack that declares a licence hold (package.json.licenseHold — ADR-001 hermes-claude-adaptation) must not become publishable until the hold is satisfied: LICENSE present without the PENDING grant placeholder, a Grant-Confirmation URL, non-empty THIRD_PARTY_NOTICES, and a clean SPDX license field' },
|
|
250
273
|
];
|
|
251
274
|
|
|
@@ -337,6 +360,50 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
337
360
|
}
|
|
338
361
|
return out;
|
|
339
362
|
},
|
|
363
|
+
'review-round': (f, sev) => {
|
|
364
|
+
// The publish gate had eleven rules and not one asked whether anyone but the author had read the
|
|
365
|
+
// code. MEASURED cost (health-advisor slice H): five rounds graded F, thirteen packages published
|
|
366
|
+
// on the author's own verification, and round six found six defects in ALREADY-PUBLISHED code.
|
|
367
|
+
//
|
|
368
|
+
// Scoped to CHANGED SOURCE on purpose (ADR-001): a HARD rule that also fired on a docs-only
|
|
369
|
+
// republish would be a rule someone switches off. `undefined` facts mean the tree could not be
|
|
370
|
+
// read — silence, not an accusation.
|
|
371
|
+
const rr = f.reviewRound;
|
|
372
|
+
if (rr === undefined) return [];
|
|
373
|
+
const min = typeof rr.minGrade === 'string' ? rr.minGrade.trim().toUpperCase() : undefined;
|
|
374
|
+
// The grade must BE a letter, not merely START with one: keyed on the first character alone,
|
|
375
|
+
// "approved" reads as an A and "broken" as a B (found by cross-family review). The fact gatherer
|
|
376
|
+
// already extracts a bounded letter, so this is defence in depth — and a pure function has no
|
|
377
|
+
// business being looser than its caller.
|
|
378
|
+
const rank = (g: string): number => {
|
|
379
|
+
const t = String(g ?? '').trim().toUpperCase();
|
|
380
|
+
return t.length === 1 ? 'ABCDF'.indexOf(t) : -1;
|
|
381
|
+
};
|
|
382
|
+
const graded = rr.grades.filter((g) => rank(g.grade) >= 0);
|
|
383
|
+
const out: Violation[] = [];
|
|
384
|
+
for (const p of rr.packages) {
|
|
385
|
+
if (!(p.versionBumped === true && p.sourceChanged === true)) continue;
|
|
386
|
+
if (graded.length === 0) {
|
|
387
|
+
out.push({
|
|
388
|
+
rule: 'review-round',
|
|
389
|
+
severity: sev,
|
|
390
|
+
detail: `${p.name}: source changed and the version is bumped, but this change brings no GRADED features/*/08_qe_report.md — a publish gate that cannot tell "reviewed" from "not reviewed" treats them alike. (This proves a graded report EXISTS in this change; it does NOT prove the review was independent, was competent, covered THIS package, or was taken against this revision.)`,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (min !== undefined && rank(min) >= 0) {
|
|
395
|
+
const best = graded.reduce((a, b) => (rank(a.grade) <= rank(b.grade) ? a : b));
|
|
396
|
+
if (rank(best.grade) > rank(min)) {
|
|
397
|
+
out.push({
|
|
398
|
+
rule: 'review-round',
|
|
399
|
+
severity: sev,
|
|
400
|
+
detail: `${p.name}: the best review grade in this change is ${best.grade.trim()} (${best.report}), below the configured floor ${min} — .dz/guard.json reviewRound.minGrade`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
},
|
|
340
407
|
'agents-md-policy-sync': (f, _sev) => {
|
|
341
408
|
const fact = f.policyDrift;
|
|
342
409
|
if (!fact || fact.applicable !== true || !Array.isArray(fact.drifted)) return [];
|
|
@@ -557,6 +624,13 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
557
624
|
notes.push(`no-stubs: ${skipped} changed scannable file(s) not scanned (deleted/oversize/unreadable/beyond the file cap) — the stub scan is fail-open, so this is a coverage gap on the record, not a violation`);
|
|
558
625
|
}
|
|
559
626
|
}
|
|
627
|
+
if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
|
|
628
|
+
// A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
|
|
629
|
+
// tell from one that checked and approved (raised by cross-family review). It still does not
|
|
630
|
+
// BLOCK — absence of facts is ignorance, not an accusation, and blocking every non-git checkout
|
|
631
|
+
// would make the rule unusable — but the ignorance goes on the record.
|
|
632
|
+
notes.push('review-round: the working-tree change could not be read, so NO review evidence was gathered — this run neither confirms nor denies that the code was reviewed');
|
|
633
|
+
}
|
|
560
634
|
if (checked.includes('agents-md-policy-sync')) {
|
|
561
635
|
// A repo that never opted in (no `dz:policies` fence in AGENTS.md) is OUT OF SCOPE, not
|
|
562
636
|
// inconclusive — noting it on every run would put a permanent line in a channel that exists to
|
package/src/index.ts
CHANGED
|
@@ -78,7 +78,7 @@ export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePa
|
|
|
78
78
|
export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
|
|
79
79
|
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
80
80
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
81
|
-
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs } from './registry.js';
|
|
81
|
+
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverVerifiablePackDirs } from './registry.js';
|
|
82
82
|
// Package skill-layout resolution (feature dz-install-npx-init) — the ONE seam that knows where an
|
|
83
83
|
// npm package keeps its skills (flat / templates/.claude/skills / skills). `cmdInstall` calls it;
|
|
84
84
|
// `dz init`/`dz registry` are the filed follow-up consumers.
|
|
@@ -262,6 +262,8 @@ export {
|
|
|
262
262
|
decideReadBack,
|
|
263
263
|
recordVerdictLine,
|
|
264
264
|
} from './run-records.js';
|
|
265
|
+
export { decidePublishSigning, decidePostSigningVerification, decideSignableSet, publishSigningLine, signableSetLine } from './publish-signing.js';
|
|
266
|
+
export type { PublishSigningVerdict, PublishSigningDecision, SignableSetDecision } from './publish-signing.js';
|
|
265
267
|
export type { RecordKind, RecordVerdict, RecordDecision } from './run-records.js';
|
|
266
268
|
export type { AmendmentRow, AmendmentVerdict, AmendmentResolution, AmendmentOutcome, AmendmentDecision, PlanCoverageGap } from './amendment-trace.js';
|
|
267
269
|
export {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `dz publish` must re-sign a pack before packing it (feature `sign-after-bump`).
|
|
3
|
+
*
|
|
4
|
+
* `dz publish` never signed. It BUMPS the version and REWRITES the README, while
|
|
5
|
+
* `.dz-manifest.json` stays whatever an operator signed by hand at some earlier moment — so
|
|
6
|
+
* publish's own mutations invalidate it and the tarball ships an inventory that disagrees with its
|
|
7
|
+
* contents on at least `package.json` and `README.md`. MEASURED 2026-08-18 on a live published
|
|
8
|
+
* package: a recipient running `dz doctor --require-signing` sees TAMPERED.
|
|
9
|
+
*
|
|
10
|
+
* It became urgent on 2026-08-21, when the packaged trust root was restored. Until then a consumer's
|
|
11
|
+
* verifier reported `trust root: none` and checked nothing, so stale signatures were invisible. From
|
|
12
|
+
* the next release onward the verifier WORKS — and without this it greets every user with a false
|
|
13
|
+
* alarm indistinguishable from a real compromise.
|
|
14
|
+
*
|
|
15
|
+
* Pure: facts in, verdict out. The caller owns the filesystem and the key.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PublishSigningVerdict =
|
|
19
|
+
/** the pack is signed and the key is usable — re-sign after the bump, before packing */
|
|
20
|
+
| 're-sign'
|
|
21
|
+
/** the pack carries no manifest: it was never signed, and publish must not grant it a signature */
|
|
22
|
+
| 'skip-unsigned-pack'
|
|
23
|
+
/** dry run: sign nothing, write nothing */
|
|
24
|
+
| 'skip-dry-run'
|
|
25
|
+
/** --bump-only: no tarball is produced, so no signature can reach anyone */
|
|
26
|
+
| 'skip-bump-only'
|
|
27
|
+
/** re-signed, but the result does not verify against the trust root — publish must STOP */
|
|
28
|
+
| 'refuse-unverified-after-signing'
|
|
29
|
+
/** the pack is signed but no usable key is present — publishing would ship a stale inventory */
|
|
30
|
+
| 'refuse-no-key'
|
|
31
|
+
/** the key lives inside the repository working tree */
|
|
32
|
+
| 'refuse-key-inside-tree';
|
|
33
|
+
|
|
34
|
+
export interface PublishSigningDecision {
|
|
35
|
+
readonly verdict: PublishSigningVerdict;
|
|
36
|
+
/** True for every refusal — pre-sign and post-sign alike: publish must STOP rather than ship a stale or unverifiable manifest. */
|
|
37
|
+
readonly blocking: boolean;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ok = (verdict: PublishSigningVerdict, reason: string): PublishSigningDecision => ({ verdict, blocking: false, reason });
|
|
42
|
+
const stop = (verdict: PublishSigningVerdict, reason: string): PublishSigningDecision => ({ verdict, blocking: true, reason });
|
|
43
|
+
|
|
44
|
+
export function decidePublishSigning(input: {
|
|
45
|
+
/** Does the pack already carry a `.dz-manifest.json`? */
|
|
46
|
+
packHasManifest: boolean;
|
|
47
|
+
/** The resolved key path, or null when none was supplied or found. */
|
|
48
|
+
keyPath: string | null;
|
|
49
|
+
keyExists: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True when the resolved key path is inside the repository working tree. The CALLER must resolve
|
|
52
|
+
* symlinks before computing this — a pure function cannot canonicalise a path, and a link pointing
|
|
53
|
+
* into the tree would otherwise pass (cross-family review, 2026-08-21).
|
|
54
|
+
*/
|
|
55
|
+
keyInsideTree: boolean;
|
|
56
|
+
dryRun: boolean;
|
|
57
|
+
/** `--bump-only` produces no tarball, so nothing signed or unsigned can reach a consumer. */
|
|
58
|
+
bumpOnly?: boolean;
|
|
59
|
+
}): PublishSigningDecision {
|
|
60
|
+
// A6 — the default path must stay inert. Checked FIRST: a dry run must not refuse either, or it
|
|
61
|
+
// would report a failure for a publish that was never going to happen.
|
|
62
|
+
if (input.dryRun) return ok('skip-dry-run', 'dry run — nothing is signed and nothing is written');
|
|
63
|
+
|
|
64
|
+
// No tarball, no recipient: refusing here would block a bump for a signature nobody will receive.
|
|
65
|
+
if (input.bumpOnly === true) return ok('skip-bump-only', '--bump-only — no tarball is produced, so no signature can reach anyone');
|
|
66
|
+
|
|
67
|
+
// A5 — a pack that was never signed must not silently acquire a signature. Signing is a claim about
|
|
68
|
+
// provenance, and publish is not the place to start making one nobody asked for.
|
|
69
|
+
if (!input.packHasManifest) {
|
|
70
|
+
return ok('skip-unsigned-pack', 'this pack carries no .dz-manifest.json — it was never signed, and publish does not start signing it now');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A3 — a private key inside the tree is one `git add` away from being published. Refuse before any
|
|
74
|
+
// read of it: the check is about the PATH, not about what the file contains.
|
|
75
|
+
if (input.keyInsideTree) {
|
|
76
|
+
return stop('refuse-key-inside-tree', 'the signing key resolves to a path INSIDE the repository — a leaked signing key is not revertible');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A2 — the pack is signed, so a consumer WILL verify it. Publishing now ships an inventory the
|
|
80
|
+
// bump has already invalidated, and under a working trust root that reads as TAMPERED. Refusing is
|
|
81
|
+
// the smaller harm, and the message names the remedy.
|
|
82
|
+
if (input.keyPath === null || !input.keyExists) {
|
|
83
|
+
return stop(
|
|
84
|
+
'refuse-no-key',
|
|
85
|
+
'this pack is signed but no signing key is available, so publishing would ship a manifest the version bump already invalidated — pass --sign-key <path outside the repo>, or unsign the pack deliberately',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return ok('re-sign', 'signed pack with a usable key — re-sign after the bump and README sync, before packing');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The one line a caller prints, in the shape the other gates use. */
|
|
93
|
+
export function publishSigningLine(pack: string, d: PublishSigningDecision): string {
|
|
94
|
+
return `publish signing (${pack}): ${d.verdict.toUpperCase()} — ${d.reason}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The verdict AFTER re-signing, and the reason this feature does not try to enumerate pre-conditions.
|
|
100
|
+
*
|
|
101
|
+
* Cross-family review (2026-08-21) showed the pre-flight checks could not be made sufficient: a key
|
|
102
|
+
* that EXISTS may be the WRONG key, an unreadable file, a public key, or the wrong algorithm — and any
|
|
103
|
+
* of those produces a pack that a consumer's trust root rejects, which is the exact harm this feature
|
|
104
|
+
* exists to prevent, recreated by the fix. Enumerating those states is a losing game.
|
|
105
|
+
*
|
|
106
|
+
* So the guard is the OUTCOME: after re-signing, verify the pack against the trust root the CONSUMER
|
|
107
|
+
* will use. A pack that does not verify must not be published, whatever the reason. This is
|
|
108
|
+
* measure-don't-assume applied to our own repair.
|
|
109
|
+
*/
|
|
110
|
+
export function decidePostSigningVerification(input: {
|
|
111
|
+
/** Did `verifyManifest(pack, manifest, trustRootPem)` return ok? */
|
|
112
|
+
verifiesAgainstTrustRoot: boolean;
|
|
113
|
+
/** False when no trust root is available to verify against at all. */
|
|
114
|
+
trustRootPresent: boolean;
|
|
115
|
+
/** The pack the caller intended to verify. */
|
|
116
|
+
pack?: string;
|
|
117
|
+
/** The pack the verification actually ran against. A mismatch is a refusal, not a pass. */
|
|
118
|
+
verifiedPack?: string;
|
|
119
|
+
}): PublishSigningDecision {
|
|
120
|
+
// Cross-family review, round 2: this function took two bare booleans, so a caller could assert a
|
|
121
|
+
// PASS obtained from a different pack (or a different trust root) and get a non-blocking verdict.
|
|
122
|
+
// Binding the claim to its subject is the cheapest half of that fix; the whole fix is verifying the
|
|
123
|
+
// TARBALL rather than the source tree, which is filed as the next step.
|
|
124
|
+
if (input.pack !== undefined && input.verifiedPack !== undefined && input.pack !== input.verifiedPack) {
|
|
125
|
+
return stop(
|
|
126
|
+
'refuse-unverified-after-signing',
|
|
127
|
+
`the verification ran against \`${input.verifiedPack}\` but the pack being published is \`${input.pack}\` — a pass about another artifact is not a pass`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!input.trustRootPresent) {
|
|
131
|
+
// Not established is not a pass: we cannot claim the signature is good, and we must not pretend.
|
|
132
|
+
return stop(
|
|
133
|
+
'refuse-unverified-after-signing',
|
|
134
|
+
'the pack was re-signed but there is no trust root to verify the result against — publishing would ship a signature nobody here could check',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!input.verifiesAgainstTrustRoot) {
|
|
138
|
+
return stop(
|
|
139
|
+
'refuse-unverified-after-signing',
|
|
140
|
+
'the pack was re-signed but does not verify against the trust root — most likely the WRONG signing key; publishing it would hand every consumer a TAMPERED verdict',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return ok('re-sign', 're-signed and verified against the trust root the consumer will use');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Which files a pack's manifest must cover — the ones the CONSUMER receives, not the ones the author
|
|
148
|
+
* happens to have on disk.
|
|
149
|
+
*
|
|
150
|
+
* MEASURED 2026-08-21 by a live install of the published `harness-cli@0.6.1`: with the trust root
|
|
151
|
+
* restored, six packs reported TAMPERED. Two causes, and only one of them was the version bump.
|
|
152
|
+
* The other: `skills-devops` signs 127 files including `CHANGELOG.md` and a test file, while its
|
|
153
|
+
* `files[]` ships neither — so the recipient's verifier reports "listed in the manifest but absent",
|
|
154
|
+
* permanently, no matter when we sign. A manifest that describes the author's disk instead of the
|
|
155
|
+
* artifact is a signature over the wrong thing.
|
|
156
|
+
*
|
|
157
|
+
* The reverse direction matters more and was never checked at all: a file that SHIPS but is not in
|
|
158
|
+
* the manifest is an UNSIGNED file inside a signed pack, and nothing would have said so.
|
|
159
|
+
*/
|
|
160
|
+
export interface SignableSetDecision {
|
|
161
|
+
/** The files to sign: present on disk AND in the published tarball. */
|
|
162
|
+
readonly sign: readonly string[];
|
|
163
|
+
/** On disk, signed today, but never shipped — the cause of "listed in the manifest but absent". */
|
|
164
|
+
readonly droppedNotPublished: readonly string[];
|
|
165
|
+
/** SHIPPED but not signable — an unsigned file inside a signed pack. Reported LOUDLY. */
|
|
166
|
+
readonly publishedButUnsigned: readonly string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A packed inventory we refuse to reason about at all — the caller must fix the probe, not proceed. */
|
|
170
|
+
export class UnusableInventoryError extends Error {}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reject an inventory before it can be trusted. Every state here was fail-open until cross-family
|
|
174
|
+
* review named them (2026-08-21): an EMPTY list read as "nothing ships" and silently signed nothing,
|
|
175
|
+
* duplicates were erased by Set construction, and `./x`, `../outside`, `/abs` were compared literally
|
|
176
|
+
* so a traversal path could match on both sides and pass.
|
|
177
|
+
*/
|
|
178
|
+
function assertUsableInventory(packed: readonly string[]): void {
|
|
179
|
+
if (packed.length === 0) {
|
|
180
|
+
throw new UnusableInventoryError('the packed inventory is EMPTY — a failed or truncated probe is not the same as a pack that ships nothing');
|
|
181
|
+
}
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
for (const p of packed) {
|
|
184
|
+
if (p !== p.trim() || p === '') throw new UnusableInventoryError(`packed path is blank or padded: ${JSON.stringify(p)}`);
|
|
185
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) throw new UnusableInventoryError(`packed path is absolute: ${p}`);
|
|
186
|
+
if (p.startsWith('./') || p.includes('../') || p.includes('\\')) throw new UnusableInventoryError(`packed path is not a normal POSIX relative path: ${p}`);
|
|
187
|
+
const key = p.toLowerCase();
|
|
188
|
+
if (seen.has(key)) throw new UnusableInventoryError(`packed inventory contains a duplicate or case-collision: ${p}`);
|
|
189
|
+
seen.add(key);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function decideSignableSet(input: {
|
|
194
|
+
/** What the signable walk found on disk (manifest and SBOM already excluded). */
|
|
195
|
+
signable: readonly string[];
|
|
196
|
+
/** What `npm pack` says the tarball will contain, `package/`-prefix already stripped. */
|
|
197
|
+
packed: readonly string[];
|
|
198
|
+
/** Names the manifest never covers because they are written after signing. */
|
|
199
|
+
selfNames?: readonly string[];
|
|
200
|
+
}): SignableSetDecision {
|
|
201
|
+
assertUsableInventory(input.packed);
|
|
202
|
+
const self = new Set(input.selfNames ?? ['.dz-manifest.json', 'sbom.json']);
|
|
203
|
+
const packedSet = new Set(input.packed.filter((p) => !self.has(p)));
|
|
204
|
+
const signableSet = new Set(input.signable.filter((p) => !self.has(p)));
|
|
205
|
+
const sign = [...signableSet].filter((p) => packedSet.has(p)).sort();
|
|
206
|
+
const droppedNotPublished = [...signableSet].filter((p) => !packedSet.has(p)).sort();
|
|
207
|
+
const publishedButUnsigned = [...packedSet].filter((p) => !signableSet.has(p)).sort();
|
|
208
|
+
return { sign, droppedNotPublished, publishedButUnsigned };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One line naming both asymmetries, because a count alone tells an author nothing to act on. */
|
|
212
|
+
export function signableSetLine(pack: string, d: SignableSetDecision): string {
|
|
213
|
+
const parts = [`${d.sign.length} signed`];
|
|
214
|
+
if (d.droppedNotPublished.length > 0) parts.push(`${d.droppedNotPublished.length} on disk but never shipped (${d.droppedNotPublished.slice(0, 3).join(', ')})`);
|
|
215
|
+
if (d.publishedButUnsigned.length > 0) parts.push(`${d.publishedButUnsigned.length} SHIPPED BUT UNSIGNED (${d.publishedButUnsigned.slice(0, 3).join(', ')})`);
|
|
216
|
+
return `signable set (${pack}): ${parts.join(' · ')}`;
|
|
217
|
+
}
|
package/src/publish.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join as pathJoin, relative as pathRelative, resolve as pathResolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { decidePublishSigning, decidePostSigningVerification } from './publish-signing.js';
|
|
9
12
|
import { join } from 'node:path';
|
|
10
13
|
import { execSync } from 'node:child_process';
|
|
11
14
|
|
|
@@ -35,6 +38,12 @@ export interface PublishReport {
|
|
|
35
38
|
readonly dryRun: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/** Is `p` inside `dir`? Used to refuse a signing key that lives in the repository working tree. */
|
|
42
|
+
function isInsideDir(p: string, dir: string): boolean {
|
|
43
|
+
const rel = pathRelative(pathResolve(dir), pathResolve(p));
|
|
44
|
+
return rel !== '' && !rel.startsWith('..') && !pathResolve(p).startsWith('..');
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/** Bump patch version: 0.3.11 → 0.3.12 */
|
|
39
48
|
export function bumpPatch(version: string): string {
|
|
40
49
|
// Parse the core x.y.z, tolerating a 2-part "x.y" (treated as x.y.0) and a
|
|
@@ -100,7 +109,9 @@ export function formatPublishError(err: unknown): string {
|
|
|
100
109
|
// ── npm provenance (ADR-001, publish-provenance) ────────────────────────────
|
|
101
110
|
//
|
|
102
111
|
// Provenance is minted from a GitHub OIDC token during the publish job. There is no private key for us
|
|
103
|
-
// to hold, leak, or rotate
|
|
112
|
+
// to hold, leak, or rotate. It COMPLEMENTS the Ed25519 pack signature rather than superseding it: the
|
|
113
|
+
// key was generated on 2026-07-19 and the packs carry `.dz-manifest.json`, which is why publish must
|
|
114
|
+
// re-sign after its own bump (feature `sign-after-bump`).
|
|
104
115
|
// It can only be produced where a token can be minted, so the DECISION belongs to the environment, and
|
|
105
116
|
// the decision is a pure function whose output is the exact argv a test can assert.
|
|
106
117
|
|
|
@@ -293,6 +304,23 @@ export function publishPackages(
|
|
|
293
304
|
dryRun?: boolean | undefined;
|
|
294
305
|
filter?: string[] | undefined;
|
|
295
306
|
bumpOnly?: boolean | undefined;
|
|
307
|
+
/**
|
|
308
|
+
* Path to the Ed25519 signing key, OUTSIDE the repository. A pack that carries a
|
|
309
|
+
* `.dz-manifest.json` must be re-signed after publish's own bump and README sync, or the tarball
|
|
310
|
+
* ships an inventory it already invalidated. Absent + a signed pack ⇒ publish REFUSES that pack.
|
|
311
|
+
*/
|
|
312
|
+
signKey?: string | undefined;
|
|
313
|
+
/**
|
|
314
|
+
* How to actually re-sign. Injected rather than imported so this module stays free of the CLI's
|
|
315
|
+
* manifest writer, and so a test can observe the call without touching a real key.
|
|
316
|
+
*/
|
|
317
|
+
reSign?: ((packDir: string, keyPath: string) => void) | undefined;
|
|
318
|
+
/**
|
|
319
|
+
* Verify the pack against the trust root a CONSUMER would use, after re-signing. Injected for the
|
|
320
|
+
* same reason as `reSign`: this module stays free of the verifier, and a test can drive both
|
|
321
|
+
* outcomes without a real key.
|
|
322
|
+
*/
|
|
323
|
+
verifyAfterSign?: ((packDir: string) => { ok: boolean; trustRootPresent: boolean; pack?: string }) | undefined;
|
|
296
324
|
/**
|
|
297
325
|
* Pre-publish claim-check gate over each package's README (ADR-001). Default `'warn'`:
|
|
298
326
|
* records the finding count on the result but NEVER changes publish status — additive, so the
|
|
@@ -401,6 +429,79 @@ export function publishPackages(
|
|
|
401
429
|
execSync('pnpm build', { cwd: pkg.dir, stdio: 'pipe', encoding: 'utf-8' });
|
|
402
430
|
}
|
|
403
431
|
|
|
432
|
+
// Re-sign AFTER the bump, the README sync and the build, and BEFORE the tarball is built.
|
|
433
|
+
// publish MUTATES the pack — a signature taken at any earlier moment describes files that no
|
|
434
|
+
// longer exist, and the tarball then ships an inventory that disagrees with its own contents.
|
|
435
|
+
// MEASURED 2026-08-18 on a live published package: a recipient running `dz doctor
|
|
436
|
+
// --require-signing` sees TAMPERED. Harmless while consumers had no trust root; from the
|
|
437
|
+
// release that restores it, a false alarm indistinguishable from a real compromise.
|
|
438
|
+
const signing = decidePublishSigning({
|
|
439
|
+
packHasManifest: existsSync(pathJoin(pkg.dir, '.dz-manifest.json')),
|
|
440
|
+
keyPath: opts.signKey ?? null,
|
|
441
|
+
keyExists: opts.signKey !== undefined && existsSync(opts.signKey),
|
|
442
|
+
keyInsideTree: opts.signKey !== undefined && isInsideDir(opts.signKey, monorepoRoot),
|
|
443
|
+
dryRun: opts.dryRun ?? false,
|
|
444
|
+
bumpOnly: opts.bumpOnly ?? false,
|
|
445
|
+
});
|
|
446
|
+
if (signing.blocking) {
|
|
447
|
+
results.push({ name: pkg.name, oldVersion, newVersion, status: 'error', error: signing.reason, claimCheck: claimCheckSummary });
|
|
448
|
+
try { writeFileSync(pkgJsonPath, originalPkgJson); } catch { /* best-effort restore */ }
|
|
449
|
+
if (originalReadme !== undefined) {
|
|
450
|
+
try { writeFileSync(pathJoin(pkg.dir, 'README.md'), originalReadme); } catch { /* best-effort restore */ }
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (signing.verdict === 're-sign') {
|
|
455
|
+
// Re-sign, then VERIFY THE OUTCOME against the trust root the consumer will use. Enumerating
|
|
456
|
+
// pre-conditions (key present? outside the tree?) cannot be made sufficient — an existing key
|
|
457
|
+
// may be the WRONG key, unreadable, a public key, or the wrong algorithm, and each of those
|
|
458
|
+
// ships a pack the consumer rejects, recreating the very harm this fixes (cross-family review,
|
|
459
|
+
// 2026-08-21). Measure the result instead.
|
|
460
|
+
let verified = false;
|
|
461
|
+
let trustRootPresent = false;
|
|
462
|
+
let verifiedPack: string | undefined;
|
|
463
|
+
// Snapshot the manifest and SBOM: if the post-sign verification refuses, the tree must not be
|
|
464
|
+
// left holding a signature we just decided not to stand behind (round-2 review, finding 7).
|
|
465
|
+
const manifestPath = pathJoin(pkg.dir, '.dz-manifest.json');
|
|
466
|
+
const sbomPath = pathJoin(pkg.dir, 'sbom.json');
|
|
467
|
+
const priorManifest = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf-8') : null;
|
|
468
|
+
const priorSbom = existsSync(sbomPath) ? readFileSync(sbomPath, 'utf-8') : null;
|
|
469
|
+
const restoreSignature = (): void => {
|
|
470
|
+
try { if (priorManifest !== null) writeFileSync(manifestPath, priorManifest); } catch { /* best-effort */ }
|
|
471
|
+
try { if (priorSbom !== null) writeFileSync(sbomPath, priorSbom); } catch { /* best-effort */ }
|
|
472
|
+
};
|
|
473
|
+
try {
|
|
474
|
+
opts.reSign?.(pkg.dir, opts.signKey as string);
|
|
475
|
+
const check = opts.verifyAfterSign?.(pkg.dir);
|
|
476
|
+
trustRootPresent = check?.trustRootPresent ?? false;
|
|
477
|
+
verified = check?.ok ?? false;
|
|
478
|
+
verifiedPack = check?.pack;
|
|
479
|
+
} catch (err) {
|
|
480
|
+
restoreSignature();
|
|
481
|
+
results.push({ name: pkg.name, oldVersion, newVersion, status: 'error', error: `re-signing failed: ${(err as Error).message}`, claimCheck: claimCheckSummary });
|
|
482
|
+
try { writeFileSync(pkgJsonPath, originalPkgJson); } catch { /* best-effort restore */ }
|
|
483
|
+
if (originalReadme !== undefined) {
|
|
484
|
+
try { writeFileSync(pathJoin(pkg.dir, 'README.md'), originalReadme); } catch { /* best-effort restore */ }
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const after = decidePostSigningVerification({
|
|
489
|
+
verifiesAgainstTrustRoot: verified,
|
|
490
|
+
trustRootPresent,
|
|
491
|
+
pack: pkg.name,
|
|
492
|
+
...(verifiedPack === undefined ? {} : { verifiedPack }),
|
|
493
|
+
});
|
|
494
|
+
if (after.blocking) {
|
|
495
|
+
restoreSignature();
|
|
496
|
+
results.push({ name: pkg.name, oldVersion, newVersion, status: 'error', error: after.reason, claimCheck: claimCheckSummary });
|
|
497
|
+
try { writeFileSync(pkgJsonPath, originalPkgJson); } catch { /* best-effort restore */ }
|
|
498
|
+
if (originalReadme !== undefined) {
|
|
499
|
+
try { writeFileSync(pathJoin(pkg.dir, 'README.md'), originalReadme); } catch { /* best-effort restore */ }
|
|
500
|
+
}
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
404
505
|
// Publish
|
|
405
506
|
execSync(publishCmd, {
|
|
406
507
|
cwd: pkg.dir,
|
package/src/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dirent } from 'node:fs';
|
|
11
11
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
@@ -98,6 +98,89 @@ export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string
|
|
|
98
98
|
return out;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Every pack whose SIGNATURE should be checked: the skill packs above, PLUS any directory in the same
|
|
103
|
+
* base dirs that carries a `.dz-manifest.json`, whatever it is called.
|
|
104
|
+
*
|
|
105
|
+
* A separate function on purpose (ADR-001). `discoverSkillPackDirs` answers "which skill packs are
|
|
106
|
+
* here?" and the `skills-` prefix is a roughly correct answer to THAT. Signature verification asks a
|
|
107
|
+
* different question, and there the prefix is simply wrong: MEASURED 2026-08-21 — 52 signed packs on
|
|
108
|
+
* disk, 26 in the verdict, exactly half invisible, including `keysarium`, `health-advisor`,
|
|
109
|
+
* `harness-core`, `harness-cli` and all ten adapters. `keysarium` drifted from its own signature that
|
|
110
|
+
* same day and the tool said nothing; it was found by a hand-written hash comparison.
|
|
111
|
+
*
|
|
112
|
+
* The UNION rather than a replacement: enumerating only manifest-bearing directories would silently
|
|
113
|
+
* drop the `unsigned` verdict for a `skills-*` pack that carries no manifest — turning "unsigned" into
|
|
114
|
+
* "absent", which is the same class of silence this fixes.
|
|
115
|
+
*/
|
|
116
|
+
export function discoverVerifiablePackDirs(cwd: string): { pack: string; dir: string }[] {
|
|
117
|
+
const out: { pack: string; dir: string }[] = [];
|
|
118
|
+
// Keyed on the RESOLVED path: a globally-installed `dz` reaches its own bundled packs as well as the
|
|
119
|
+
// project's, and one directory counted twice inflates exactly the coverage number this exists to
|
|
120
|
+
// make honest.
|
|
121
|
+
const seen = new Set<string>();
|
|
122
|
+
const add = (pack: string, dir: string): void => {
|
|
123
|
+
// realpath, not resolve: pnpm links workspace packages into `node_modules` as SYMLINKS, so the
|
|
124
|
+
// SAME pack is reachable under several paths. MEASURED 2026-08-21 — 78 entries over 52 real
|
|
125
|
+
// directories, fourteen of them counted three times. `resolve` normalises a path; only `realpath`
|
|
126
|
+
// answers "is this the same directory".
|
|
127
|
+
let key: string;
|
|
128
|
+
try {
|
|
129
|
+
key = realpathSync(dir);
|
|
130
|
+
} catch {
|
|
131
|
+
key = resolve(dir);
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(key)) return;
|
|
134
|
+
seen.add(key);
|
|
135
|
+
// Return the RESOLVED path. A symlink is a name, not an object: reporting the link would make the
|
|
136
|
+
// verdict describe something a later swap can redirect, and would leave two names for one pack
|
|
137
|
+
// depending on which was seen first (cross-family review, 2026-08-21).
|
|
138
|
+
out.push({ pack, dir: key });
|
|
139
|
+
};
|
|
140
|
+
// An unreadable base must not abort the enumeration: the skill-pack scan does not catch its own
|
|
141
|
+
// readdir, so without this a single permission error hides EVERY pack — a silence far worse than the
|
|
142
|
+
// one this function was written to remove.
|
|
143
|
+
try {
|
|
144
|
+
for (const p of discoverSkillPackDirs(cwd)) add(p.pack, p.dir);
|
|
145
|
+
} catch {
|
|
146
|
+
/* fall through to the manifest scan, which catches per-base */
|
|
147
|
+
}
|
|
148
|
+
for (const base of skillPackBaseDirs(cwd)) {
|
|
149
|
+
let entries: Dirent[];
|
|
150
|
+
try {
|
|
151
|
+
entries = readdirSync(base, { withFileTypes: true });
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
const dir = join(base, e.name);
|
|
157
|
+
const isDir =
|
|
158
|
+
e.isDirectory() ||
|
|
159
|
+
(e.isSymbolicLink() &&
|
|
160
|
+
(() => {
|
|
161
|
+
try {
|
|
162
|
+
return statSync(dir).isDirectory();
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
})());
|
|
167
|
+
if (!isDir) continue;
|
|
168
|
+
// Presence of the manifest is the whole test — an unexpected one is a FACT to be judged, not
|
|
169
|
+
// noise to be filtered by name. But it must be a FILE: `existsSync` is true for a DIRECTORY
|
|
170
|
+
// named `.dz-manifest.json`, which would let any directory declare its parent verifiable
|
|
171
|
+
// (cross-family review, 2026-08-21).
|
|
172
|
+
let hasManifest = false;
|
|
173
|
+
try {
|
|
174
|
+
hasManifest = statSync(join(dir, '.dz-manifest.json')).isFile();
|
|
175
|
+
} catch {
|
|
176
|
+
hasManifest = false;
|
|
177
|
+
}
|
|
178
|
+
if (hasManifest) add(e.name, dir);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
101
184
|
/** A single skill in the registry. */
|
|
102
185
|
export interface RegistryEntry {
|
|
103
186
|
readonly id: string;
|