@massu/core 1.4.0-soak.0 → 1.5.0

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 (68) hide show
  1. package/commands/README.md +0 -3
  2. package/dist/cli.js +9423 -5453
  3. package/dist/hooks/auto-learning-pipeline.js +27 -1
  4. package/dist/hooks/classify-failure.js +27 -1
  5. package/dist/hooks/cost-tracker.js +27 -1
  6. package/dist/hooks/fix-detector.js +27 -1
  7. package/dist/hooks/incident-pipeline.js +27 -1
  8. package/dist/hooks/post-edit-context.js +27 -1
  9. package/dist/hooks/post-tool-use.js +27 -1
  10. package/dist/hooks/pre-compact.js +27 -1
  11. package/dist/hooks/pre-delete-check.js +27 -1
  12. package/dist/hooks/quality-event.js +27 -1
  13. package/dist/hooks/rule-enforcement-pipeline.js +27 -1
  14. package/dist/hooks/session-end.js +27 -1
  15. package/dist/hooks/session-start.js +2677 -2675
  16. package/dist/hooks/user-prompt.js +27 -1
  17. package/docs/AUTHORING-ADAPTERS.md +207 -0
  18. package/docs/SECURITY.md +250 -0
  19. package/package.json +10 -3
  20. package/src/adapter.ts +90 -0
  21. package/src/cli.ts +7 -0
  22. package/src/commands/adapters.ts +824 -0
  23. package/src/commands/config-check-drift.ts +1 -0
  24. package/src/commands/config-refresh.ts +4 -3
  25. package/src/commands/config-upgrade.ts +1 -0
  26. package/src/commands/doctor.ts +2 -0
  27. package/src/commands/init.ts +3 -1
  28. package/src/commands/template-engine.ts +0 -2
  29. package/src/commands/watch.ts +1 -1
  30. package/src/config.ts +71 -0
  31. package/src/detect/adapters/aspnet.ts +293 -0
  32. package/src/detect/adapters/discover.ts +469 -0
  33. package/src/detect/adapters/go-chi.ts +261 -0
  34. package/src/detect/adapters/index.ts +49 -0
  35. package/src/detect/adapters/phoenix.ts +277 -0
  36. package/src/detect/adapters/python-flask.ts +235 -0
  37. package/src/detect/adapters/rails.ts +279 -0
  38. package/src/detect/adapters/runner.ts +32 -0
  39. package/src/detect/adapters/spring.ts +284 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +171 -2
  41. package/src/detect/adapters/types.ts +19 -2
  42. package/src/detect/migrate.ts +4 -4
  43. package/src/detect/monorepo-detector.ts +1 -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/lsp/auto-detect.ts +10 -1
  49. package/src/lsp/client.ts +188 -2
  50. package/src/memory-file-ingest.ts +1 -0
  51. package/src/security/adapter-origin.ts +130 -0
  52. package/src/security/adapter-verifier.ts +319 -0
  53. package/src/security/atomic-write.ts +164 -0
  54. package/src/security/fetcher.ts +200 -0
  55. package/src/security/install-tracking.ts +319 -0
  56. package/src/security/local-fingerprint.ts +225 -0
  57. package/src/security/manifest-cache.ts +333 -0
  58. package/src/security/manifest-schema.ts +129 -0
  59. package/src/security/registry-pubkey.generated.ts +35 -0
  60. package/src/security/telemetry.ts +320 -0
  61. package/src/watch/daemon.ts +1 -1
  62. package/src/watch/paths.ts +2 -2
  63. package/templates/aspnet/massu.config.yaml +57 -0
  64. package/templates/go-chi/massu.config.yaml +52 -0
  65. package/templates/phoenix/massu.config.yaml +54 -0
  66. package/templates/python-flask/massu.config.yaml +51 -0
  67. package/templates/rails/massu.config.yaml +56 -0
  68. package/templates/spring/massu.config.yaml +56 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Adapter discovery — Plan 3c Phase 5 5H deliverable.
3
+ *
4
+ * Scans three source classes per the three-class trust model
5
+ * (CORE-BUNDLED + REGISTRY-VERIFIED + LOCAL-EXPLICIT, see security/
6
+ * adapter-origin.ts), classifies each candidate, and returns the
7
+ * deduplicated AdapterDescriptor[] the CLI + loader consume.
8
+ *
9
+ * Three sources scanned:
10
+ * 1. CORE-BUNDLED: caller-provided id set (typically built from a static
11
+ * list of bundled adapter filenames in @massu/core itself). The
12
+ * discovery module does NOT enumerate the filesystem for these — that
13
+ * enumeration is done at @massu/core build time and shipped as a
14
+ * constant. Discovery just classifies the ids.
15
+ * 2. REGISTRY-VERIFIED: walk node_modules/@massu/adapter-* directories
16
+ * + any node_modules/<pkg>/ where package.json declares
17
+ * "massu-adapter": true. Cross-reference each candidate against the
18
+ * cached registry manifest's adapters[] list — only entries that
19
+ * appear in the manifest are accepted (CR-46 Rule 0 single-source-of-
20
+ * truth: the registry manifest IS the authoritative allowlist).
21
+ * 3. LOCAL-EXPLICIT: read getConfig().adapters?.local entries. Each entry
22
+ * is a POSIX-normalized relative path (already validated +
23
+ * normalized at config-parse time per AdapterLocalPathSchema in
24
+ * config.ts). Discovery resolves the path relative to the project
25
+ * root and confirms the file exists.
26
+ *
27
+ * The discovery surface returns warnings (not errors) for candidate-
28
+ * classification refusals — a malformed package.json or a missing local
29
+ * file does NOT abort the whole scan; those candidates are simply not
30
+ * loaded, and the warning is surfaced to the CLI for operator awareness.
31
+ *
32
+ * What this module does NOT do (deferred to follow-up commits, all
33
+ * in-flight Phase 5 deliverables):
34
+ * - Install-time + load-time sha256 of installed adapter package
35
+ * contents (gap-37 install-tracking + tarball verification). This
36
+ * requires sha256 of the package's dist/ directory recursively + the
37
+ * ~/.massu/adapter-manifest-installed.json sidecar file.
38
+ * - adapters.local fingerprint check (gap-32 postinstall-poisoning).
39
+ * This requires a sha256 of the canonical adapters.local entry list
40
+ * stored in ~/.massu/adapters-local-fingerprint.json.
41
+ * - User-installed adapter scan at ~/.massu/adapters/ (CLI install
42
+ * path). Populated by `massu adapters install`, also Phase 5
43
+ * follow-up.
44
+ *
45
+ * Loading the actual adapter code (importing the JS module + invoking
46
+ * detect/extract) is the loader's job (Plan 3b runner.ts). Discovery
47
+ * just enumerates + classifies.
48
+ */
49
+ import { existsSync, readdirSync, readFileSync, lstatSync } from 'node:fs';
50
+ import { resolve, isAbsolute } from 'node:path';
51
+ import { z } from 'zod';
52
+ import {
53
+ getAdapterOrigin,
54
+ type AdapterDescriptor,
55
+ type AdapterOriginInput,
56
+ } from '../../security/adapter-origin.js';
57
+ import { PrintableAsciiStringSchema, type Envelope, type AdapterEntry } from '../../security/manifest-schema.js';
58
+ import { checkFingerprintDrift, FINGERPRINT_PATH } from '../../security/local-fingerprint.js';
59
+ import {
60
+ verifyInstalledIntegrity,
61
+ containsHiddenDirs,
62
+ INSTALLED_MANIFEST_PATH,
63
+ } from '../../security/install-tracking.js';
64
+
65
+ /**
66
+ * Minimal shape of a node_modules package.json that we care about for
67
+ * adapter discovery. Strict enough to reject malformed packages at parse
68
+ * time, loose enough (passthrough) to ignore unrelated keys.
69
+ */
70
+ const AdapterPackageJsonSchema = z.object({
71
+ // CR-9 iter-4 audit LOW-NEW4-2 fix: name/version are rendered in stderr
72
+ // warnings via `${pkg.name}@${pkg.version}` strings; without the
73
+ // printable-ASCII regex, a malicious local node_modules/<pkg>/package.json
74
+ // could embed ANSI escapes to log-inject. Postinstall scripts have the
75
+ // write access needed to mount this attack.
76
+ name: PrintableAsciiStringSchema,
77
+ version: PrintableAsciiStringSchema,
78
+ // Plan 3c gap-31 + gap-50: `"massu-adapter": true` is the explicit opt-in
79
+ // marker. @massu/adapter-* packages also declare this.
80
+ 'massu-adapter': z.union([z.boolean(), z.literal(undefined)]).optional(),
81
+ // Plan 3c gap-31: api version. Loader refuses incompatible major (caller-side).
82
+ 'massu-adapter-api-version': z.union([z.string(), z.number(), z.literal(undefined)]).optional(),
83
+ }).passthrough();
84
+
85
+ export interface DiscoverOptions {
86
+ /**
87
+ * Absolute path to the project root where node_modules/ lives. Caller
88
+ * passes from getProjectRoot() or equivalent.
89
+ */
90
+ projectRoot: string;
91
+ /**
92
+ * Set of adapter ids that are CORE-BUNDLED in @massu/core itself.
93
+ * Built from a static const at @massu/core build time. Pass an empty
94
+ * set in tests when not exercising CORE-BUNDLED classification.
95
+ */
96
+ coreBundledIds: ReadonlySet<string>;
97
+ /**
98
+ * The verified registry manifest envelope (from manifest-cache.getManifest).
99
+ * Discovery uses envelope.manifest.adapters[] as the REGISTRY-VERIFIED
100
+ * allowlist. Pass undefined if running offline + cache absent — discovery
101
+ * will then refuse all REGISTRY-VERIFIED candidates with a clear warning.
102
+ */
103
+ manifestEnvelope: Envelope | undefined;
104
+ /**
105
+ * POSIX-normalized relative paths from getConfig().adapters?.local.
106
+ * Each entry must already pass AdapterLocalPathSchema (config.ts). Pass
107
+ * empty array if no local adapters configured.
108
+ */
109
+ configLocalPaths: ReadonlyArray<string>;
110
+ /**
111
+ * Plan 3c gap-1 kill switch (CR-9 audit C1 fix). When false, ONLY
112
+ * CORE-BUNDLED adapters are emitted; REGISTRY-VERIFIED + LOCAL-EXPLICIT
113
+ * scans short-circuit immediately. Source-of-truth is
114
+ * `getConfig().adapters?.enabled === true`. Defaults to false at the
115
+ * config schema layer so operators MUST opt-in to third-party adapter
116
+ * loading — security-critical. Pass the flag through from
117
+ * commands/adapters.ts:runAdaptersList; a missing argument here would
118
+ * silently default to enabled, defeating the kill switch.
119
+ */
120
+ adaptersEnabled: boolean;
121
+ /**
122
+ * Override the on-disk fingerprint sentinel path for testing
123
+ * (gap-32 postinstall-poisoning check). Production callsite uses
124
+ * the default `~/.massu/adapters-local-fingerprint.json`.
125
+ */
126
+ fingerprintSentinelPath?: string;
127
+ /**
128
+ * Override the on-disk install-tracking sidecar path for testing
129
+ * (gap-37 install-time + load-time sha256 check). Production callsite
130
+ * uses the default `~/.massu/adapter-manifest-installed.json`.
131
+ */
132
+ installedManifestPath?: string;
133
+ /**
134
+ * Skip the gap-37 load-time sha256 integrity check. Default: false
135
+ * (always check). Setting this to `true` is a test seam ONLY — it
136
+ * exists so unit tests that don't materialize real package directories
137
+ * can still exercise the classification logic. Production callsites
138
+ * MUST NOT pass `true`; per CR-46 this is the most-robust posture.
139
+ */
140
+ skipInstalledIntegrityCheck?: boolean;
141
+ }
142
+
143
+ export interface DiscoveryResult {
144
+ adapters: AdapterDescriptor[];
145
+ warnings: string[];
146
+ }
147
+
148
+ /**
149
+ * Walk node_modules for @massu/adapter-* directories AND any other package
150
+ * declaring "massu-adapter": true. Returns the parsed package.json and
151
+ * absolute package directory for each candidate. Skips malformed packages
152
+ * with a warning.
153
+ *
154
+ * Walks ONLY one level of node_modules — does NOT descend into nested
155
+ * node_modules (transitive deps' adapter packages). Adapters are operator-
156
+ * installed top-level dependencies, not transitive.
157
+ */
158
+ function walkNodeModules(projectRoot: string, warnings: string[]): Array<{
159
+ packageDir: string;
160
+ pkg: z.infer<typeof AdapterPackageJsonSchema>;
161
+ }> {
162
+ const nodeModulesDir = resolve(projectRoot, 'node_modules');
163
+ if (!existsSync(nodeModulesDir)) {
164
+ return [];
165
+ }
166
+ const candidates: Array<{ packageDir: string; pkg: z.infer<typeof AdapterPackageJsonSchema> }> = [];
167
+ let topLevelEntries: string[];
168
+ try {
169
+ topLevelEntries = readdirSync(nodeModulesDir);
170
+ } catch (err) {
171
+ warnings.push(`failed to read node_modules: ${err instanceof Error ? err.message : String(err)}`);
172
+ return [];
173
+ }
174
+
175
+ for (const entry of topLevelEntries) {
176
+ if (entry.startsWith('.')) continue;
177
+ const entryPath = resolve(nodeModulesDir, entry);
178
+ let entryStat;
179
+ try {
180
+ // CR-9 audit HIGH-NEW-1 fix: lstatSync (not statSync). statSync
181
+ // followed symlinks, so a malicious package shipping
182
+ // `node_modules/evil-link -> /elsewhere` would walk into /elsewhere.
183
+ // The same fix was applied in install-tracking.ts (audit H1) but
184
+ // discover's node_modules walk was missed at the time.
185
+ entryStat = lstatSync(entryPath);
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (entryStat.isSymbolicLink()) {
190
+ warnings.push(
191
+ `skipping ${entryPath}: top-level node_modules entry is a symlink. ` +
192
+ `Adapter packages must be real directories (npm-installed); refusing to walk symlinks ` +
193
+ `as a defense against postinstall scripts that link to attacker-controlled paths.`,
194
+ );
195
+ continue;
196
+ }
197
+ if (!entryStat.isDirectory()) continue;
198
+
199
+ if (entry.startsWith('@')) {
200
+ // Scoped namespace — e.g., node_modules/@massu/. Walk one more level.
201
+ let scopedEntries: string[];
202
+ try {
203
+ scopedEntries = readdirSync(entryPath);
204
+ } catch {
205
+ continue;
206
+ }
207
+ for (const sub of scopedEntries) {
208
+ const subPath = resolve(entryPath, sub);
209
+ // Same lstatSync discipline at the inner level — a malicious
210
+ // postinstall could plant `node_modules/@massu/evil-link -> ...`.
211
+ let subStat;
212
+ try {
213
+ subStat = lstatSync(subPath);
214
+ } catch {
215
+ continue;
216
+ }
217
+ if (subStat.isSymbolicLink() || !subStat.isDirectory()) continue;
218
+ const result = tryReadAdapterPackage(subPath, warnings);
219
+ if (result) candidates.push(result);
220
+ }
221
+ } else {
222
+ const result = tryReadAdapterPackage(entryPath, warnings);
223
+ if (result) candidates.push(result);
224
+ }
225
+ }
226
+ return candidates;
227
+ }
228
+
229
+ function tryReadAdapterPackage(packageDir: string, warnings: string[]): {
230
+ packageDir: string;
231
+ pkg: z.infer<typeof AdapterPackageJsonSchema>;
232
+ } | null {
233
+ const pkgJsonPath = resolve(packageDir, 'package.json');
234
+ if (!existsSync(pkgJsonPath)) return null;
235
+ let raw: unknown;
236
+ try {
237
+ raw = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
238
+ } catch (err) {
239
+ warnings.push(
240
+ `skipping ${packageDir}: package.json parse failed (${err instanceof Error ? err.message : String(err)})`,
241
+ );
242
+ return null;
243
+ }
244
+ const parsed = AdapterPackageJsonSchema.safeParse(raw);
245
+ if (!parsed.success) {
246
+ warnings.push(`skipping ${packageDir}: package.json shape invalid`);
247
+ return null;
248
+ }
249
+ const pkg = parsed.data;
250
+
251
+ // Filter: only consider candidates that EITHER match @massu/adapter-*
252
+ // glob OR declare "massu-adapter": true. Other npm packages are ignored
253
+ // (vast majority of node_modules entries).
254
+ const isMassuAdapterGlob = /^@massu\/adapter-[a-z][a-z0-9-]*$/.test(pkg.name);
255
+ const declaresMassuAdapter = pkg['massu-adapter'] === true;
256
+ if (!isMassuAdapterGlob && !declaresMassuAdapter) {
257
+ return null;
258
+ }
259
+ return { packageDir, pkg };
260
+ }
261
+
262
+ /**
263
+ * Run discovery across all three trust classes. Returns the combined
264
+ * descriptor list + a list of human-readable warnings the CLI prints.
265
+ */
266
+ export function discoverAdapters(opts: DiscoverOptions): DiscoveryResult {
267
+ const warnings: string[] = [];
268
+ const adapters: AdapterDescriptor[] = [];
269
+ const seenIds = new Set<string>();
270
+
271
+ // 1. CORE-BUNDLED — pass-through. Each id in coreBundledIds becomes a
272
+ // descriptor with origin='core-bundled'. No verification needed (trust
273
+ // derives from @massu/core itself).
274
+ for (const id of opts.coreBundledIds) {
275
+ const origin = getAdapterOrigin({ id, coreBundledIds: opts.coreBundledIds });
276
+ if (origin !== 'core-bundled') {
277
+ // This shouldn't happen — coreBundledIds + getAdapterOrigin is a
278
+ // closed loop. If it does, it's a programmer bug worth surfacing.
279
+ warnings.push(`expected core-bundled classification for id=${id}, got ${origin ?? 'null'}`);
280
+ continue;
281
+ }
282
+ adapters.push({ id, origin: 'core-bundled' });
283
+ seenIds.add(id);
284
+ }
285
+
286
+ // Plan 3c gap-1 kill switch (CR-9 audit C1): when adapters.enabled=false,
287
+ // ONLY CORE-BUNDLED adapters load. Skip REGISTRY-VERIFIED + LOCAL-EXPLICIT
288
+ // entirely. Operators rely on this kill switch documented in SECURITY.md;
289
+ // bypassing it would silently load third-party code in violation of the
290
+ // documented contract.
291
+ if (!opts.adaptersEnabled) {
292
+ if (opts.configLocalPaths.length > 0) {
293
+ warnings.push(
294
+ `adapters.enabled=false — refusing all REGISTRY-VERIFIED + LOCAL-EXPLICIT adapters. ` +
295
+ `Set massu.config.yaml > adapters.enabled: true to opt in.`,
296
+ );
297
+ }
298
+ return { adapters, warnings };
299
+ }
300
+
301
+ // 2. REGISTRY-VERIFIED — walk node_modules. For each candidate, look
302
+ // up the manifest entry; refuse if not in the allowlist.
303
+ const manifestEntries = opts.manifestEnvelope?.manifest.adapters ?? [];
304
+ const manifestByName = new Map<string, AdapterEntry>(
305
+ manifestEntries.map((e) => [e.package, e]),
306
+ );
307
+ const npmCandidates = walkNodeModules(opts.projectRoot, warnings);
308
+ for (const { packageDir, pkg } of npmCandidates) {
309
+ if (seenIds.has(pkg.name)) continue;
310
+ const manifestEntry = manifestByName.get(pkg.name);
311
+ if (!manifestEntry) {
312
+ if (!opts.manifestEnvelope) {
313
+ warnings.push(
314
+ `cannot verify ${pkg.name}@${pkg.version}: registry manifest unavailable. ` +
315
+ `Refusing to load. Run \`massu adapters refresh\` when online.`,
316
+ );
317
+ } else {
318
+ warnings.push(
319
+ `refusing ${pkg.name}@${pkg.version}: not in the signed registry manifest. ` +
320
+ `If you authored this adapter, submit a PR to the registry per AUTHORING-ADAPTERS.md.`,
321
+ );
322
+ }
323
+ continue;
324
+ }
325
+ if (manifestEntry.unpublished === true) {
326
+ warnings.push(
327
+ `refusing ${pkg.name}@${pkg.version}: registry marks this package as unpublished. ` +
328
+ `Remove via: npm uninstall ${pkg.name}`,
329
+ );
330
+ continue;
331
+ }
332
+ if (manifestEntry.deprecated) {
333
+ warnings.push(
334
+ `${pkg.name}@${pkg.version} is deprecated since ${manifestEntry.deprecated.since}: ` +
335
+ `${manifestEntry.deprecated.reason}. Replacement: ${manifestEntry.deprecated.replacement ?? '(none listed)'}.`,
336
+ );
337
+ // Adapter still loads despite deprecation — gap-57.
338
+ }
339
+ if (manifestEntry.version !== pkg.version) {
340
+ warnings.push(
341
+ `${pkg.name}@${pkg.version} version mismatch with manifest entry ${manifestEntry.version}. ` +
342
+ `Loading the installed version; the gap-37 sha256 integrity check below will catch tampering.`,
343
+ );
344
+ }
345
+
346
+ // gap-37 LOAD-time integrity check: re-compute sha256OfDir on the
347
+ // installed package and compare to the install-time hash recorded in
348
+ // ~/.massu/adapter-manifest-installed.json. Missing sidecar entry →
349
+ // refuse (operator must run `massu adapters install <pkg>`); drift →
350
+ // refuse (post-install tampering). Caller can suppress for tests via
351
+ // skipInstalledIntegrityCheck=true.
352
+ // iter-2 MED-NEW-2 fix: load-time hidden-dir refusal. A postinstall
353
+ // script that adds `.git/payload.js` to a registered package does NOT
354
+ // change the load-time hash (sha256OfDir excludes hidden dirs), so
355
+ // the integrity check passes — but the legitimate adapter could
356
+ // require() the smuggled payload at runtime. Refusing at load time
357
+ // closes the gap.
358
+ if (!opts.skipInstalledIntegrityCheck) {
359
+ const hiddenDir = containsHiddenDirs(packageDir);
360
+ if (hiddenDir !== null) {
361
+ warnings.push(
362
+ `refusing ${pkg.name}@${pkg.version}: contains '${hiddenDir}' subdirectory. ` +
363
+ `Published adapter packages must not ship hidden directories. ` +
364
+ `Recover via \`npm uninstall ${pkg.name} && npm install ${pkg.name} && ` +
365
+ `massu adapters install ${pkg.name}\`.`,
366
+ );
367
+ continue;
368
+ }
369
+ const integrity = verifyInstalledIntegrity(
370
+ pkg.name,
371
+ packageDir,
372
+ opts.installedManifestPath ?? INSTALLED_MANIFEST_PATH,
373
+ );
374
+ if (integrity.kind !== 'ok') {
375
+ warnings.push(`refusing ${pkg.name}@${pkg.version}: ${integrity.reason}`);
376
+ continue;
377
+ }
378
+ // CR-9 audit M4 fix: the sidecar can be tampered post-write (mode
379
+ // 0o600 stops other users but NOT same-user processes running
380
+ // as the operator). Cross-check the install-time hash recorded in
381
+ // the sidecar against the SIGNATURE-VERIFIED manifest entry's
382
+ // sha256. If they diverge, the sidecar was modified after install
383
+ // — refuse to load. This closes the "attacker writes both the
384
+ // package AND the sidecar" gap.
385
+ if (integrity.entry.installed_sha256 !== manifestEntry.sha256) {
386
+ warnings.push(
387
+ `refusing ${pkg.name}@${pkg.version}: install-tracking sidecar's installed_sha256 ` +
388
+ `(${integrity.entry.installed_sha256.slice(0, 16)}...) does not match the signed ` +
389
+ `manifest entry's sha256 (${manifestEntry.sha256.slice(0, 16)}...). The sidecar ` +
390
+ `appears tampered post-install. Recover via \`npm uninstall ${pkg.name} && ` +
391
+ `npm install ${pkg.name} && massu adapters install ${pkg.name}\`.`,
392
+ );
393
+ continue;
394
+ }
395
+ }
396
+
397
+ const origin = getAdapterOrigin({
398
+ id: pkg.name,
399
+ coreBundledIds: opts.coreBundledIds,
400
+ npmPackage: { name: pkg.name, version: pkg.version, massuAdapter: pkg['massu-adapter'] === true },
401
+ });
402
+ if (origin !== 'registry-verified') {
403
+ warnings.push(`expected registry-verified classification for ${pkg.name}, got ${origin ?? 'null'}`);
404
+ continue;
405
+ }
406
+ adapters.push({
407
+ id: pkg.name,
408
+ origin: 'registry-verified',
409
+ version: pkg.version,
410
+ packageDir,
411
+ });
412
+ seenIds.add(pkg.name);
413
+ }
414
+
415
+ // 3. LOCAL-EXPLICIT — read configLocalPaths. Each entry is already POSIX-
416
+ // normalized + path-validated by AdapterLocalPathSchema (config.ts).
417
+ //
418
+ // Plan 3c gap-32 postinstall-poisoning defense: BEFORE classifying any
419
+ // local adapter, check the fingerprint sentinel. If the current
420
+ // adapters.local content's fingerprint does NOT match the last
421
+ // operator-acknowledged sentinel, REFUSE to load any local adapter and
422
+ // surface the drift in warnings. The operator must run
423
+ // `massu adapters resync-local-fingerprint` (or add-local/remove-local)
424
+ // to re-acknowledge before discovery accepts local adapters again.
425
+ const fingerprintCheck = opts.configLocalPaths.length === 0
426
+ ? { kind: 'match' as const }
427
+ : checkFingerprintDrift(
428
+ opts.configLocalPaths,
429
+ opts.projectRoot,
430
+ opts.fingerprintSentinelPath ?? FINGERPRINT_PATH,
431
+ );
432
+ if (fingerprintCheck.kind !== 'match') {
433
+ if (opts.configLocalPaths.length > 0) {
434
+ warnings.push(
435
+ `refusing all LOCAL-EXPLICIT adapters: ${fingerprintCheck.reason}`,
436
+ );
437
+ }
438
+ // Skip the LOCAL-EXPLICIT loop entirely.
439
+ return { adapters, warnings };
440
+ }
441
+ const localSet = new Set(opts.configLocalPaths);
442
+ for (const localPath of opts.configLocalPaths) {
443
+ if (seenIds.has(localPath)) continue;
444
+ const absPath = isAbsolute(localPath) ? localPath : resolve(opts.projectRoot, localPath);
445
+ if (!existsSync(absPath)) {
446
+ warnings.push(
447
+ `local adapter file not found: ${localPath} (resolved to ${absPath}). ` +
448
+ `Remove via: massu adapters remove-local ${localPath}`,
449
+ );
450
+ continue;
451
+ }
452
+ const origin = getAdapterOrigin({
453
+ id: localPath,
454
+ coreBundledIds: opts.coreBundledIds,
455
+ configLocalPaths: localSet,
456
+ });
457
+ if (origin !== 'local-explicit') {
458
+ warnings.push(`expected local-explicit classification for ${localPath}, got ${origin ?? 'null'}`);
459
+ continue;
460
+ }
461
+ adapters.push({
462
+ id: localPath,
463
+ origin: 'local-explicit',
464
+ });
465
+ seenIds.add(localPath);
466
+ }
467
+
468
+ return { adapters, warnings };
469
+ }