@massu/core 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli.js +9431 -5167
  2. package/dist/hooks/auto-learning-pipeline.js +18 -0
  3. package/dist/hooks/classify-failure.js +18 -0
  4. package/dist/hooks/cost-tracker.js +18 -0
  5. package/dist/hooks/fix-detector.js +18 -0
  6. package/dist/hooks/incident-pipeline.js +18 -0
  7. package/dist/hooks/post-edit-context.js +18 -0
  8. package/dist/hooks/post-tool-use.js +18 -0
  9. package/dist/hooks/pre-compact.js +18 -0
  10. package/dist/hooks/pre-delete-check.js +18 -0
  11. package/dist/hooks/quality-event.js +18 -0
  12. package/dist/hooks/rule-enforcement-pipeline.js +18 -0
  13. package/dist/hooks/session-end.js +18 -0
  14. package/dist/hooks/session-start.js +2952 -2740
  15. package/dist/hooks/user-prompt.js +18 -0
  16. package/docs/AUTHORING-ADAPTERS.md +207 -0
  17. package/docs/SECURITY.md +250 -0
  18. package/package.json +7 -3
  19. package/src/adapter.ts +90 -0
  20. package/src/cli.ts +7 -0
  21. package/src/commands/adapters.ts +824 -0
  22. package/src/commands/config-check-drift.ts +1 -0
  23. package/src/commands/config-refresh.ts +1 -0
  24. package/src/commands/config-upgrade.ts +1 -0
  25. package/src/commands/doctor.ts +2 -0
  26. package/src/commands/init.ts +151 -2
  27. package/src/config.ts +63 -0
  28. package/src/detect/adapters/aspnet.ts +293 -0
  29. package/src/detect/adapters/discover.ts +469 -0
  30. package/src/detect/adapters/go-chi.ts +261 -0
  31. package/src/detect/adapters/index.ts +49 -0
  32. package/src/detect/adapters/phoenix.ts +277 -0
  33. package/src/detect/adapters/python-flask.ts +235 -0
  34. package/src/detect/adapters/rails.ts +279 -0
  35. package/src/detect/adapters/runner.ts +32 -0
  36. package/src/detect/adapters/spring.ts +284 -0
  37. package/src/detect/adapters/tree-sitter-loader.ts +50 -0
  38. package/src/detect/adapters/types.ts +18 -0
  39. package/src/detect/framework-detector.ts +26 -0
  40. package/src/detect/manifest-registry.ts +261 -0
  41. package/src/detect/monorepo-detector.ts +1 -0
  42. package/src/detect/package-detector.ts +162 -62
  43. package/src/detect/source-dir-detector.ts +7 -0
  44. package/src/hooks/post-tool-use.ts +1 -0
  45. package/src/hooks/session-start.ts +1 -0
  46. package/src/lib/fileLock.ts +203 -0
  47. package/src/lib/installLock.ts +31 -144
  48. package/src/memory-file-ingest.ts +1 -0
  49. package/src/security/adapter-origin.ts +130 -0
  50. package/src/security/adapter-verifier.ts +319 -0
  51. package/src/security/atomic-write.ts +164 -0
  52. package/src/security/fetcher.ts +200 -0
  53. package/src/security/install-tracking.ts +319 -0
  54. package/src/security/local-fingerprint.ts +225 -0
  55. package/src/security/manifest-cache.ts +333 -0
  56. package/src/security/manifest-schema.ts +129 -0
  57. package/src/security/registry-pubkey.generated.ts +35 -0
  58. package/src/security/telemetry.ts +320 -0
  59. package/templates/aspnet/massu.config.yaml +61 -0
  60. package/templates/go-chi/massu.config.yaml +52 -0
  61. package/templates/phoenix/massu.config.yaml +54 -0
  62. package/templates/python-flask/massu.config.yaml +51 -0
  63. package/templates/rails/massu.config.yaml +56 -0
  64. package/templates/spring/massu.config.yaml +56 -0
@@ -8,49 +8,29 @@
8
8
  * `runConfigRefresh` path AND the watcher auto-trigger. Without
9
9
  * serialization, two concurrent callers can race on `.claude/commands/`
10
10
  * file writes. proper-lockfile gives us atomic mkdir-based locks that
11
- * work cross-platform; we wrap it to:
11
+ * work cross-platform.
12
12
  *
13
- * 1. mkdirSync the lock dir (fresh repos may not have `.massu/`)
14
- * 2. surface ELOCKED (POSIX) and EBUSY (Windows) as the same error
15
- * 3. keep installAll() synchronous (lockSync, not lock)
13
+ * Plan 3c gap-59 / Rule 0 single-source-of-truth refactor (commit pending,
14
+ * 2026-05-07): the proper-lockfile-wrapping logic + manual retry loop +
15
+ * .pid sidecar bookkeeping now lives in `lib/fileLock.ts:withFileLockSync`.
16
+ * This file is a thin domain-specific wrapper that:
17
+ * 1. Computes the project-root-anchored lockPath
18
+ * 2. Delegates to withFileLockSync via an `errorFactory` that returns
19
+ * `InstallLockBusyError` (preserving the exact error message format
20
+ * Plan 3a §243 specified and the install-lock tests assert)
16
21
  *
17
- * Plan §190 retry behavior: "second caller blocks up to 30s, then bails".
18
- * proper-lockfile's `lockSync` REJECTS `retries>0` (see node_modules/
19
- * proper-lockfile/lib/adapter.js: `Cannot use retries with the sync api`).
20
- * We implement the retry-block manually via a `lockfile.checkSync` /
21
- * `lockSync` loop with a busy-wait sleep.
22
- *
23
- * iter-3 (third pass, G3-iter3-1+2): align error message with plan §243
24
- * format `"installAll already running (PID=X) — try again in <N>s"` AND
25
- * add the manual retry-block loop.
22
+ * Plan 3a §190 retry behavior preserved: "second caller blocks up to 30s,
23
+ * then bails". InstallLockBusyError instances continue to expose
24
+ * `lockPath`, `holderPid`, `retryAfterSeconds`, `causeCode` backwards-
25
+ * compatible for any caller that does `instanceof InstallLockBusyError`.
26
26
  */
27
27
 
28
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
29
- import { dirname, resolve } from 'path';
30
- import * as lockfile from 'proper-lockfile';
28
+ import { resolve } from 'path';
29
+ import { withFileLockSync, type FileLockOpts } from './fileLock.js';
31
30
 
32
- export interface InstallLockOpts {
33
- /** Default 30sproper-lockfile considers a lock stale after this elapses. */
34
- staleMs?: number;
35
- /**
36
- * How long the manual retry loop should block waiting for the holder to
37
- * release before bailing with `InstallLockBusyError`. Default 30s per
38
- * plan §190 ("second caller blocks up to 30s, then bails").
39
- * Pass `0` to bail immediately (used in tests).
40
- */
41
- blockMs?: number;
42
- /** Sleep granularity inside the retry loop. Default 100ms. */
43
- pollIntervalMs?: number;
44
- /**
45
- * Backwards-compat: legacy callers pass `retries: 0` to mean "do not
46
- * block". When set to a positive integer, used by tests that want to
47
- * exercise a specific retry count instead of the default time-based loop.
48
- */
49
- retries?: number;
50
- /** Override clock (test seam). */
51
- now?: () => number;
52
- /** Override sleep (test seam). Defaults to a busy-wait spinloop. */
53
- sleep?: (ms: number) => void;
31
+ export interface InstallLockOpts extends FileLockOpts {
32
+ // No additional fields all options come from FileLockOpts. This alias
33
+ // preserves the public surface for any caller that imported `InstallLockOpts`.
54
34
  }
55
35
 
56
36
  export class InstallLockBusyError extends Error {
@@ -67,113 +47,20 @@ export class InstallLockBusyError extends Error {
67
47
  }
68
48
 
69
49
  /**
70
- * Best-effort: read the PID of the current lock holder. proper-lockfile
71
- * stores the lock as a directory at `<lockPath>` containing nothing PID-
72
- * identifying, so we look at our own sidecar `<lockPath>.pid` file (written
73
- * by the lock acquirer below). On any read error we return null so the
74
- * error message degrades gracefully to `(PID=unknown)`.
75
- */
76
- function readHolderPid(lockPath: string): number | null {
77
- try {
78
- const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
79
- const pid = Number.parseInt(raw, 10);
80
- if (!Number.isFinite(pid) || pid <= 0) return null;
81
- return pid;
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- function busyWaitSync(ms: number): void {
88
- const end = Date.now() + ms;
89
- // Atomics.wait against a SharedArrayBuffer is the cleanest portable sync
90
- // sleep; fall back to a tight loop if SharedArrayBuffer is unavailable
91
- // (older runtimes / sandboxed envs).
92
- if (typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined') {
93
- const sab = new SharedArrayBuffer(4);
94
- const view = new Int32Array(sab);
95
- Atomics.wait(view, 0, 0, ms);
96
- return;
97
- }
98
- while (Date.now() < end) {
99
- // Spin — this should never run on modern Node, kept as belt-and-suspenders.
100
- }
101
- }
102
-
103
- /**
104
- * Acquire the lock, run `fn`, release on every exit path.
105
- * Synchronous all the way through so installAll() keeps its sync signature.
50
+ * Acquire the install lock for `projectRoot`, run `fn`, release on every
51
+ * exit path. Throws `InstallLockBusyError` when the lock is held beyond
52
+ * `blockMs`. See `lib/fileLock.ts:withFileLockSync` for the underlying
53
+ * primitive.
106
54
  */
107
55
  export function withInstallLock<T>(projectRoot: string, fn: () => T, opts: InstallLockOpts = {}): T {
108
56
  const lockPath = resolve(projectRoot, '.massu', 'installAll.lock');
109
- // iter-3 G3-A11: ensure parent dir exists (fresh repo case).
110
- mkdirSync(dirname(lockPath), { recursive: true });
111
-
112
- const staleMs = opts.staleMs ?? 30_000;
113
- // `retries: 0` legacy path = bail immediately, no wait.
114
- // Otherwise default to plan §190's 30s block.
115
- const blockMs = opts.retries === 0
116
- ? 0
117
- : (opts.blockMs ?? 30_000);
118
- const pollIntervalMs = opts.pollIntervalMs ?? 100;
119
- const now = opts.now ?? Date.now;
120
- const sleep = opts.sleep ?? busyWaitSync;
121
-
122
- let release: (() => void) | null = null;
123
- const deadline = now() + blockMs;
124
- let lastErr: NodeJS.ErrnoException | null = null;
125
-
126
- // Manual retry loop. proper-lockfile.lockSync forbids retries>0, so we
127
- // wrap it ourselves: try → on ELOCKED/EBUSY, sleep → try again until
128
- // deadline. This satisfies plan §190 "second caller blocks up to 30s".
129
- for (;;) {
130
- try {
131
- release = lockfile.lockSync(lockPath, {
132
- stale: staleMs,
133
- retries: 0,
134
- realpath: false,
135
- });
136
- // Persist our PID alongside the lock so the next contender can include
137
- // it in the user-friendly error per plan §243 format.
138
- try {
139
- writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
140
- } catch {
141
- // best-effort
142
- }
143
- break;
144
- } catch (err) {
145
- lastErr = err as NodeJS.ErrnoException;
146
- const code = lastErr.code;
147
- if (code !== 'ELOCKED' && code !== 'EBUSY') {
148
- throw err;
149
- }
150
- if (now() >= deadline) {
151
- const holderPid = readHolderPid(lockPath);
152
- const remainingMs = Math.max(0, deadline - now());
153
- // Surface a hint about how long the *next* poll cycle should wait.
154
- // When `blockMs=0` the user got bail-immediately semantics; report
155
- // the staleness window so they know the lock auto-releases in N s.
156
- const retryAfterSeconds = blockMs === 0
157
- ? Math.round(staleMs / 1000)
158
- : Math.round(remainingMs / 1000);
159
- throw new InstallLockBusyError(lockPath, holderPid, retryAfterSeconds, code);
160
- }
161
- sleep(pollIntervalMs);
162
- }
163
- }
164
-
165
- try {
166
- return fn();
167
- } finally {
168
- try {
169
- if (release) release();
170
- } catch {
171
- // best-effort
172
- }
173
- try {
174
- rmSync(`${lockPath}.pid`, { force: true });
175
- } catch {
176
- // best-effort
177
- }
178
- }
57
+ return withFileLockSync(
58
+ lockPath,
59
+ fn,
60
+ {
61
+ ...opts,
62
+ errorFactory: (path, pid, retrySeconds, code) =>
63
+ new InstallLockBusyError(path, pid, retrySeconds, code),
64
+ },
65
+ );
179
66
  }
@@ -42,6 +42,7 @@ export function ingestMemoryFile(
42
42
 
43
43
  if (frontmatterMatch) {
44
44
  try {
45
+ // pattern-scanner-allow: yaml-parse — reason: parses YAML FRONTMATTER from markdown memory files (NOT massu.config.yaml). This is document metadata parsing, not application config access; getConfig() does not apply.
45
46
  const fm = parseYaml(frontmatterMatch[1]) as Record<string, unknown>;
46
47
  name = (fm.name as string) ?? basename;
47
48
  description = (fm.description as string) ?? '';
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Three-class adapter trust model (Plan 3c gap-47 + gap-48 + gap-50 deliverable).
3
+ *
4
+ * Every adapter that the loader is asked to load MUST classify into exactly
5
+ * ONE of the three classes below. The verifier dispatches per origin; an
6
+ * adapter that classifies into ZERO classes (or matches multiple) is REFUSED
7
+ * to load with a clear stderr message.
8
+ *
9
+ * The three classes:
10
+ *
11
+ * 1. CORE-BUNDLED — adapters that ship inside @massu/core itself at
12
+ * packages/core/src/detect/adapters/*.ts. Trust derives from @massu/core's
13
+ * own npm publish (provenance attestations) + the prepublish-check.sh
14
+ * audit + the user's explicit `npm install @massu/core` choice. These
15
+ * adapters do NOT appear in manifest.json (they have no package, no
16
+ * separate version, no separate tarball). Loader skips signature
17
+ * verification AND emits no warning — this is the trusted baseline.
18
+ * Plan 3b shipped 4 (FastAPI, Django, tRPC, SwiftUI); Phase 7 ships 31
19
+ * more (35 total).
20
+ *
21
+ * 2. REGISTRY-VERIFIED — third-party npm packages that EITHER match
22
+ * `@massu/adapter-*` glob OR declare `"massu-adapter": true` in their
23
+ * package.json. Trust derives from the manifest.json allowlist + per-
24
+ * package sha256 + Ed25519 manifest signature. The 5 first-shipped
25
+ * `@massu`-org packages (rails, phoenix, aspnet, spring, go-chi) live
26
+ * in this class — being `@massu`-org-published does NOT exempt them
27
+ * from manifest signing (gap-48 named-resolution).
28
+ *
29
+ * 3. LOCAL-EXPLICIT — TypeScript / JavaScript files listed by the user in
30
+ * `massu.config.yaml > adapters.local`. Trust derives from the user's
31
+ * explicit per-path entry + the postinstall-poisoning fingerprint check
32
+ * (gap-32 / gap-58). Loader emits a one-time stderr note per startup
33
+ * naming the loaded local path.
34
+ *
35
+ * Concretely, getAdapterOrigin() returns the union type below. Anything
36
+ * that doesn't match exactly one class is the unclassified branch — the
37
+ * verifier MUST refuse to load.
38
+ */
39
+
40
+ export type AdapterOrigin = 'core-bundled' | 'registry-verified' | 'local-explicit';
41
+
42
+ export interface AdapterDescriptor {
43
+ /**
44
+ * Stable identifier the loader uses to address this adapter. For
45
+ * CORE-BUNDLED: the filename without `.ts` (e.g. `python-fastapi`).
46
+ * For REGISTRY-VERIFIED: the npm package name (e.g. `@massu/adapter-rails`).
47
+ * For LOCAL-EXPLICIT: the POSIX-normalized path from `adapters.local`.
48
+ */
49
+ id: string;
50
+ origin: AdapterOrigin;
51
+ /**
52
+ * For REGISTRY-VERIFIED only: package version (semver). Undefined for
53
+ * CORE-BUNDLED (which inherits @massu/core's version) and LOCAL-EXPLICIT
54
+ * (which has no semver — local files don't publish).
55
+ */
56
+ version?: string;
57
+ /**
58
+ * For REGISTRY-VERIFIED only: absolute path to the package directory in
59
+ * node_modules (or wherever the discovery scan found it). Used to compute
60
+ * the load-time sha256 (gap-37 install-time + load-time verification).
61
+ * Undefined for CORE-BUNDLED (resolved inside the @massu/core bundle) and
62
+ * LOCAL-EXPLICIT (use the path from adapters.local directly).
63
+ */
64
+ packageDir?: string;
65
+ }
66
+
67
+ export interface AdapterOriginInput {
68
+ /** The id under inspection. */
69
+ id: string;
70
+ /**
71
+ * Set of adapter ids that ship CORE-BUNDLED in @massu/core itself. The
72
+ * loader produces this set via `import.meta.glob`-style scan of
73
+ * packages/core/src/detect/adapters/*.ts. Pass ['python-fastapi',
74
+ * 'python-django', 'nextjs-trpc', 'swift-swiftui'] today; Phase 7 grows
75
+ * this to 35 entries.
76
+ */
77
+ coreBundledIds: ReadonlySet<string>;
78
+ /**
79
+ * Optional: the npm package metadata (when id matches a discovered
80
+ * node_modules package). When present + `id` matches an `@massu/adapter-*`
81
+ * pattern OR `massuAdapter === true`, this is REGISTRY-VERIFIED.
82
+ */
83
+ npmPackage?: {
84
+ name: string;
85
+ version: string;
86
+ massuAdapter: boolean;
87
+ };
88
+ /**
89
+ * Optional: set of POSIX-normalized adapter paths from
90
+ * `getConfig().adapters?.local ?? []`. When present + id matches one of
91
+ * these entries, this is LOCAL-EXPLICIT.
92
+ */
93
+ configLocalPaths?: ReadonlySet<string>;
94
+ }
95
+
96
+ /**
97
+ * Classify an adapter id into exactly one of the three trust classes, or
98
+ * return null if the id does not match any class. Loader MUST refuse to
99
+ * load null-classified adapters.
100
+ *
101
+ * Multi-class collision (id matches more than one class) is an error
102
+ * condition — caller should treat it as null + emit a clear stderr note
103
+ * naming all the matching classes. This shouldn't happen in practice
104
+ * because CORE-BUNDLED ids are kebab-case framework names while REGISTRY-
105
+ * VERIFIED ids start with `@` (npm scope) and LOCAL-EXPLICIT ids are file
106
+ * paths — they don't intersect under normal config.
107
+ */
108
+ export function getAdapterOrigin(input: AdapterOriginInput): AdapterOrigin | null {
109
+ const matches: AdapterOrigin[] = [];
110
+
111
+ if (input.coreBundledIds.has(input.id)) {
112
+ matches.push('core-bundled');
113
+ }
114
+
115
+ if (input.npmPackage) {
116
+ const isMassuOrgAdapter = /^@massu\/adapter-/.test(input.npmPackage.name);
117
+ const declaresMassuAdapter = input.npmPackage.massuAdapter === true;
118
+ if ((isMassuOrgAdapter || declaresMassuAdapter) && input.npmPackage.name === input.id) {
119
+ matches.push('registry-verified');
120
+ }
121
+ }
122
+
123
+ if (input.configLocalPaths && input.configLocalPaths.has(input.id)) {
124
+ matches.push('local-explicit');
125
+ }
126
+
127
+ // Exactly one match → that class. Zero or multiple → unclassified (refuse).
128
+ if (matches.length === 1) return matches[0]!;
129
+ return null;
130
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Adapter manifest signature + integrity verifier.
3
+ *
4
+ * Plan 3c Phase 5 deliverable (5F). The center of the supply-chain trust
5
+ * chain: every REGISTRY-VERIFIED adapter load (per the three-class trust
6
+ * model in adapter-origin.ts) goes through this module before the loader
7
+ * accepts it. Verification fails CLOSED — any failure path returns
8
+ * { ok: false, reason } and the loader refuses to load.
9
+ *
10
+ * Verification chain (all checks MUST pass):
11
+ *
12
+ * 1. Envelope shape passes EnvelopeSchema (manifest, manifest_b64,
13
+ * signature, manifest_sha256, signed_at, signing_key_id all present
14
+ * and well-typed).
15
+ * 2. base64-decode(envelope.manifest_b64) yields raw bytes B.
16
+ * 3. sha256(B) hex == envelope.manifest_sha256.
17
+ * 4. JSON.parse(B as utf-8) succeeds.
18
+ * 5. The parsed JSON deep-equals envelope.manifest (defense against
19
+ * publisher swapping the manifest_b64 and manifest fields independently).
20
+ * 6. ManifestBodySchema accepts the parsed JSON.
21
+ * 7. nacl.sign.detached.verify(B, base64-decode(signature), publicKey)
22
+ * returns true. publicKey is the bundled REGISTRY_PUBKEY_ED25519
23
+ * (caller passes it; default is the bundle from registry-pubkey.generated.ts).
24
+ * 8. envelope.signing_key_id == sha256(publicKey hex). This is the
25
+ * drift-detection mechanism for key rotation (Plan 3c gap-54): a
26
+ * stale @massu/core that holds an old pubkey reading a manifest
27
+ * signed by the new pubkey will see signing_key_id mismatch and
28
+ * refuse — the cache is then marked STALE-DUE-TO-ROTATION (handled
29
+ * by the cache module, not here).
30
+ * 9. manifest.manifest_schema_version >= MIN_KNOWN_SCHEMA_VERSION (else
31
+ * REFUSE — a future v2 that drops fields v1 expects is incompatible).
32
+ * > KNOWN_MAX_SCHEMA_VERSION → continue with warning (additive
33
+ * forward-compat per gap-56).
34
+ *
35
+ * Why every step matters:
36
+ * - Step 3 catches manifest_b64 tampering (someone swapped the bytes
37
+ * between the publisher's signing and the consumer's verify).
38
+ * - Step 5 catches publisher bugs that emit two non-equal views of
39
+ * the same field (this is the canonicalization-gap class — the
40
+ * publisher's self-check at registry-publish.sh enforces the same
41
+ * invariant before deploy, but we re-check on the consumer side).
42
+ * - Step 7 is the actual signature verification.
43
+ * - Step 8 protects against attackers who know an old key — they can
44
+ * sign a valid manifest under the old key, but signing_key_id will
45
+ * mismatch the bundled pubkey and the loader refuses.
46
+ *
47
+ * Test fixtures sign against the real Phase D Ed25519 private key via
48
+ * `bash scripts/sign-fixture-manifest.sh` (operator-runs, reads from
49
+ * macOS Keychain) producing signed envelopes that round-trip the verifier
50
+ * end-to-end without needing the live registry.
51
+ */
52
+ import { createHash } from 'node:crypto';
53
+ import nacl from 'tweetnacl';
54
+ import { z } from 'zod';
55
+ import {
56
+ EnvelopeSchema,
57
+ ManifestBodySchema,
58
+ KNOWN_MAX_SCHEMA_VERSION,
59
+ MIN_KNOWN_SCHEMA_VERSION,
60
+ type Envelope,
61
+ type ManifestBody,
62
+ } from './manifest-schema.js';
63
+ import {
64
+ REGISTRY_PUBKEY_ED25519,
65
+ REGISTRY_PUBKEY_FINGERPRINT_HEX,
66
+ KNOWN_PUBKEY_FINGERPRINTS,
67
+ } from './registry-pubkey.generated.js';
68
+
69
+ /**
70
+ * CR-9 audit L2 fix: assert at module init that the bundled pubkey's
71
+ * fingerprint is in the historically-trusted allowlist. A bundled key
72
+ * that doesn't appear here would refuse to load — defense against a
73
+ * future build that swaps the pem to an unauthorized key without
74
+ * updating the allowlist (which would otherwise pass --check at build
75
+ * time only if KNOWN_PUBKEY_FINGERPRINTS was also tampered).
76
+ */
77
+ if (!KNOWN_PUBKEY_FINGERPRINTS.has(REGISTRY_PUBKEY_FINGERPRINT_HEX)) {
78
+ throw new Error(
79
+ `@massu/core: bundled pubkey fingerprint ${REGISTRY_PUBKEY_FINGERPRINT_HEX.slice(0, 16)}... ` +
80
+ `is not in KNOWN_PUBKEY_FINGERPRINTS. This @massu/core build appears tampered. ` +
81
+ `Refusing to load.`,
82
+ );
83
+ }
84
+
85
+ export interface VerifyManifestInput {
86
+ /** Raw envelope JSON, parsed but not yet validated. */
87
+ envelope: unknown;
88
+ /**
89
+ * The bundled Ed25519 public key (32 raw bytes). Caller passes the
90
+ * bundled key from registry-pubkey.generated.ts. Test-only override
91
+ * to swap keys for fixture signing.
92
+ */
93
+ publicKey?: Uint8Array;
94
+ }
95
+
96
+ export type VerifyManifestResult =
97
+ | { ok: true; envelope: Envelope; manifest: ManifestBody; warnings: string[] }
98
+ | { ok: false; reason: string };
99
+
100
+ /**
101
+ * Compute sha256 hex of raw bytes. Helper used at multiple verification
102
+ * steps; centralized to avoid drift between callsites.
103
+ */
104
+ function sha256Hex(bytes: Uint8Array): string {
105
+ return createHash('sha256').update(bytes).digest('hex');
106
+ }
107
+
108
+ /**
109
+ * JSON.parse reviver that strips prototype-pollution keys. CR-9 audit H2
110
+ * fix: a malicious-but-signed manifest could include `"__proto__":{...}`
111
+ * as an own enumerable key that the schema's `.passthrough()` would
112
+ * preserve into downstream consumers. Returning undefined from a reviver
113
+ * tells JSON.parse to omit that key entirely.
114
+ */
115
+ function reviver(key: string, value: unknown): unknown {
116
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
117
+ return undefined;
118
+ }
119
+ return value;
120
+ }
121
+
122
+ /**
123
+ * Deep-equal check for the parsed JSON tree of envelope.manifest vs the
124
+ * JSON parsed from base64-decode(envelope.manifest_b64). Both inputs come
125
+ * from the same envelope so they MUST be deep-equal — any mismatch is a
126
+ * publisher bug or active tampering. Uses canonical JSON.stringify with
127
+ * sorted keys so the comparison ignores key-order differences.
128
+ */
129
+ function jsonDeepEqualByCanonical(a: unknown, b: unknown): boolean {
130
+ const ca = canonicalJsonStringify(a);
131
+ const cb = canonicalJsonStringify(b);
132
+ return ca === cb;
133
+ }
134
+
135
+ function canonicalJsonStringify(value: unknown): string {
136
+ if (value === null) return 'null';
137
+ if (Array.isArray(value)) {
138
+ return '[' + value.map(canonicalJsonStringify).join(',') + ']';
139
+ }
140
+ if (typeof value === 'object' && value !== undefined) {
141
+ const obj = value as Record<string, unknown>;
142
+ const keys = Object.keys(obj).sort();
143
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJsonStringify(obj[k])).join(',') + '}';
144
+ }
145
+ return JSON.stringify(value);
146
+ }
147
+
148
+ /**
149
+ * Verify a registry adapter manifest envelope. See module-level doc for the
150
+ * full chain. Returns ok=true with parsed envelope+manifest+warnings on
151
+ * success, or ok=false with a specific reason on any failure.
152
+ *
153
+ * This function does NOT cache, NOT fetch, and NOT mutate any state. Pure
154
+ * verification of a given input. The caller (cache module + CLI) is
155
+ * responsible for cache I/O and refusing-to-load semantics.
156
+ */
157
+ export function verifyManifest(input: VerifyManifestInput): VerifyManifestResult {
158
+ const publicKey = input.publicKey ?? REGISTRY_PUBKEY_ED25519;
159
+ const warnings: string[] = [];
160
+
161
+ // Step 1 — envelope shape
162
+ const envelopeParsed = EnvelopeSchema.safeParse(input.envelope);
163
+ if (!envelopeParsed.success) {
164
+ const issues = envelopeParsed.error.issues
165
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
166
+ .join('; ');
167
+ return { ok: false, reason: `envelope shape invalid: ${issues}` };
168
+ }
169
+ const envelope = envelopeParsed.data;
170
+
171
+ // Step 2 — base64-decode manifest_b64
172
+ let manifestBytes: Buffer;
173
+ try {
174
+ manifestBytes = Buffer.from(envelope.manifest_b64, 'base64');
175
+ } catch (err) {
176
+ return { ok: false, reason: `manifest_b64 base64 decode failed: ${err instanceof Error ? err.message : String(err)}` };
177
+ }
178
+ if (manifestBytes.length === 0) {
179
+ return { ok: false, reason: 'manifest_b64 decoded to zero bytes' };
180
+ }
181
+
182
+ // Step 3 — sha256 round-trip
183
+ const computedSha = sha256Hex(manifestBytes);
184
+ if (computedSha !== envelope.manifest_sha256) {
185
+ return {
186
+ ok: false,
187
+ reason:
188
+ `manifest_sha256 mismatch: computed ${computedSha}, envelope claims ${envelope.manifest_sha256}. ` +
189
+ `manifest_b64 was tampered with after signing.`,
190
+ };
191
+ }
192
+
193
+ // Step 4 — JSON.parse manifest_b64 bytes.
194
+ // CR-9 audit H2 fix: the JSON.parse reviver strips __proto__/constructor/
195
+ // prototype keys at parse time so they cannot be smuggled through the
196
+ // verifier into downstream consumers. Without this, a malicious-but-
197
+ // signed manifest could plant prototype-pollution-shaped values that
198
+ // .passthrough() preserves verbatim.
199
+ let manifestFromBytes: unknown;
200
+ try {
201
+ manifestFromBytes = JSON.parse(manifestBytes.toString('utf-8'), reviver);
202
+ } catch (err) {
203
+ return {
204
+ ok: false,
205
+ reason: `manifest_b64 does not decode to valid JSON: ${err instanceof Error ? err.message : String(err)}`,
206
+ };
207
+ }
208
+
209
+ // Step 6 — manifest body schema (run BEFORE step-5 deep-equal so both
210
+ // sides of the deep-equal comparison go through the same Zod defaults +
211
+ // transforms; otherwise an additive optional field with `.default()` on
212
+ // one side but not the other would cause spurious deep-equal failures).
213
+ // CR-9 audit M2 fix.
214
+ const manifestParsed = ManifestBodySchema.safeParse(manifestFromBytes);
215
+ if (!manifestParsed.success) {
216
+ const issues = manifestParsed.error.issues
217
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
218
+ .join('; ');
219
+ return { ok: false, reason: `manifest body shape invalid: ${issues}` };
220
+ }
221
+ const manifest = manifestParsed.data;
222
+
223
+ // Step 5 — deep-equal vs envelope.manifest. Compare AFTER both sides
224
+ // have been parsed by ManifestBodySchema so defaults are applied
225
+ // identically.
226
+ if (!jsonDeepEqualByCanonical(manifest, envelope.manifest)) {
227
+ return {
228
+ ok: false,
229
+ reason:
230
+ `manifest_b64 (decoded) does not deep-equal envelope.manifest field. ` +
231
+ `Publisher emitted inconsistent envelope — refusing to trust either view.`,
232
+ };
233
+ }
234
+
235
+ // Step 7 — Ed25519 signature verify
236
+ let signatureBytes: Buffer;
237
+ try {
238
+ signatureBytes = Buffer.from(envelope.signature, 'base64');
239
+ } catch (err) {
240
+ return { ok: false, reason: `signature base64 decode failed: ${err instanceof Error ? err.message : String(err)}` };
241
+ }
242
+ if (signatureBytes.length !== nacl.sign.signatureLength) {
243
+ return {
244
+ ok: false,
245
+ reason: `signature byte length ${signatureBytes.length} != expected ${nacl.sign.signatureLength}`,
246
+ };
247
+ }
248
+ if (publicKey.length !== nacl.sign.publicKeyLength) {
249
+ return {
250
+ ok: false,
251
+ reason: `publicKey byte length ${publicKey.length} != expected ${nacl.sign.publicKeyLength}`,
252
+ };
253
+ }
254
+ const sigOk = nacl.sign.detached.verify(
255
+ new Uint8Array(manifestBytes),
256
+ new Uint8Array(signatureBytes),
257
+ publicKey,
258
+ );
259
+ if (!sigOk) {
260
+ return {
261
+ ok: false,
262
+ reason: `Ed25519 signature verification failed against bundled public key ` +
263
+ `(fingerprint ${sha256Hex(publicKey).slice(0, 16)}...). ` +
264
+ `Manifest was either signed by a different key OR the signature is corrupt.`,
265
+ };
266
+ }
267
+
268
+ // Step 8 — signing_key_id matches sha256(publicKey)
269
+ const expectedKeyId = sha256Hex(publicKey);
270
+ if (envelope.signing_key_id !== expectedKeyId) {
271
+ return {
272
+ ok: false,
273
+ reason:
274
+ `signing_key_id mismatch: envelope claims ${envelope.signing_key_id}, ` +
275
+ `bundled pubkey sha256 is ${expectedKeyId}. ` +
276
+ `This indicates a key rotation; cache must refresh from the live registry, ` +
277
+ `or @massu/core must be upgraded to a version with the new bundled pubkey.`,
278
+ };
279
+ }
280
+
281
+ // Step 8b — every manifest entry's signing_key_id MUST equal the envelope's
282
+ // signing_key_id. CR-9 audit M3 fix: per-entry signing_key_id was parsed
283
+ // but never validated. v1 always uses the single registry key per
284
+ // SECURITY.md; a future federated v2 may countersign per-entry, in which
285
+ // case this check moves into a per-entry-key verification loop. Today,
286
+ // any divergence between entry.signing_key_id and envelope.signing_key_id
287
+ // indicates either a publisher bug or a mixed-signing-key attack.
288
+ for (const entry of manifest.adapters) {
289
+ if (entry.signing_key_id !== envelope.signing_key_id) {
290
+ return {
291
+ ok: false,
292
+ reason:
293
+ `manifest entry ${entry.package} has signing_key_id ${entry.signing_key_id} ` +
294
+ `which does not match envelope signing_key_id ${envelope.signing_key_id}. ` +
295
+ `v1 manifests use a single registry key for every entry; this divergence ` +
296
+ `indicates either a publisher bug or mixed-key attack — refusing.`,
297
+ };
298
+ }
299
+ }
300
+
301
+ // Step 9 — schema version compat
302
+ if (manifest.manifest_schema_version < MIN_KNOWN_SCHEMA_VERSION) {
303
+ return {
304
+ ok: false,
305
+ reason:
306
+ `manifest_schema_version ${manifest.manifest_schema_version} < MIN ${MIN_KNOWN_SCHEMA_VERSION}. ` +
307
+ `This @massu/core does not support reading this manifest; upgrade required.`,
308
+ };
309
+ }
310
+ if (manifest.manifest_schema_version > KNOWN_MAX_SCHEMA_VERSION) {
311
+ warnings.push(
312
+ `Adapter registry uses schema v${manifest.manifest_schema_version}, this @massu/core ` +
313
+ `supports up to v${KNOWN_MAX_SCHEMA_VERSION}. Some adapter metadata may be ignored. ` +
314
+ `Upgrade @massu/core to access new features.`,
315
+ );
316
+ }
317
+
318
+ return { ok: true, envelope, manifest, warnings };
319
+ }