@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
@@ -0,0 +1,824 @@
1
+ /**
2
+ * `npx massu adapters <subcommand>` — Plan 3c Phase 5 5I-J.
3
+ *
4
+ * Sub-dispatcher mirroring `cli.ts:handleConfigSubcommand`. Each subcommand
5
+ * returns `{ exitCode }` so the dispatcher in cli.ts calls process.exit(N)
6
+ * (per VR-LIBRARY-NO-PROCESS-EXIT — library throws / returns; CLI exits).
7
+ *
8
+ * Subcommands shipped in this commit (read-only operational):
9
+ * - list Show all discovered adapters + their origin class
10
+ * - refresh [--force --check] Refresh the cached registry manifest (gap-55 UX)
11
+ * - search <query> List manifest entries matching a substring
12
+ * - --help / -h Print this list
13
+ *
14
+ * Subcommands deliberately scoped to the next Phase 5 commits (still in-flight
15
+ * Phase 5 work, NOT deferred-ideas):
16
+ * - add-local / remove-local / resync-local-fingerprint
17
+ * (these need security/local-fingerprint.ts gap-32 mechanism)
18
+ * - install / resign
19
+ * (these need security/install-tracking.ts gap-37 install-time sha256
20
+ * sidecar + npm walk integration)
21
+ *
22
+ * Each unshipped subcommand returns exit code 64 (EX_USAGE per BSD sysexits)
23
+ * with stderr explaining which Phase 5 follow-up commit will land it. This
24
+ * is honest "not yet implemented in this @massu/core release" UX, not a
25
+ * silent stub.
26
+ */
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import { resolve } from 'node:path';
29
+ import { parseDocument } from 'yaml';
30
+ import { getConfig, getProjectRoot, resetConfig, AdapterLocalPathSchema } from '../config.js';
31
+ import { getManifest } from '../security/manifest-cache.js';
32
+ import { discoverAdapters } from '../detect/adapters/discover.js';
33
+ import { CORE_BUNDLED_IDS } from '../detect/adapters/index.js';
34
+ import { writeFingerprintSentinel } from '../security/local-fingerprint.js';
35
+ import { atomicWrite } from '../security/atomic-write.js';
36
+ import { withFileLockSync } from '../lib/fileLock.js';
37
+ import {
38
+ sha256OfDir,
39
+ readInstalledManifest,
40
+ writeInstalledManifestEntry,
41
+ removeInstalledManifestEntry,
42
+ containsHiddenDirs,
43
+ type InstallEntry,
44
+ } from '../security/install-tracking.js';
45
+ import type { AdapterDescriptor } from '../security/adapter-origin.js';
46
+ import type { Envelope } from '../security/manifest-schema.js';
47
+
48
+ export interface AdaptersResult {
49
+ exitCode: number;
50
+ }
51
+
52
+ /**
53
+ * Top-level sub-dispatcher. cli.ts:case 'adapters' delegates here with
54
+ * args.slice(1) (everything after the `adapters` token).
55
+ */
56
+ export async function handleAdaptersSubcommand(args: string[]): Promise<AdaptersResult> {
57
+ const sub = args[0];
58
+ const rest = args.slice(1);
59
+ switch (sub) {
60
+ case 'list':
61
+ return runAdaptersList(rest);
62
+ case 'refresh':
63
+ return runAdaptersRefresh(rest);
64
+ case 'search':
65
+ return runAdaptersSearch(rest);
66
+ case 'add-local':
67
+ return runAdaptersAddLocal(rest);
68
+ case 'remove-local':
69
+ return runAdaptersRemoveLocal(rest);
70
+ case 'resync-local-fingerprint':
71
+ return runAdaptersResyncLocalFingerprint(rest);
72
+ case 'install':
73
+ return runAdaptersInstall(rest);
74
+ case 'resign':
75
+ return runAdaptersResign(rest);
76
+ case '--help':
77
+ case '-h':
78
+ case undefined:
79
+ printAdaptersHelp();
80
+ return { exitCode: 0 };
81
+ default:
82
+ process.stderr.write(`massu adapters: unknown subcommand: ${sub}\n`);
83
+ printAdaptersHelp();
84
+ return { exitCode: 1 };
85
+ }
86
+ }
87
+
88
+ function notYetImplemented(sub: string, reason: string): AdaptersResult {
89
+ process.stderr.write(
90
+ `massu adapters ${sub}: not yet implemented in this @massu/core release.\n` +
91
+ ` Reason: requires ${reason}.\n` +
92
+ ` Track via the Plan 3c Phase 5 gap referenced in the reason text;\n` +
93
+ ` ships in the next @massu/core minor release.\n`,
94
+ );
95
+ return { exitCode: 64 }; // EX_USAGE per BSD sysexits.h
96
+ }
97
+
98
+ /**
99
+ * `npx massu adapters list` — show all discovered adapters + origin + version.
100
+ *
101
+ * Behavior:
102
+ * - Loads getConfig() + getManifest() (cache-fresh fast path; falls back to
103
+ * refresh on stale/expired/rotation-detected).
104
+ * - Runs discoverAdapters across all three trust classes.
105
+ * - Renders a single tab-separated table to stdout.
106
+ * - Prints any warnings to stderr.
107
+ * - Exits 0 on any successful classification (even if some candidates were
108
+ * refused with warnings); exits 2 if discovery itself fails (e.g. project
109
+ * root not found).
110
+ */
111
+ export async function runAdaptersList(_args: string[]): Promise<AdaptersResult> {
112
+ let projectRoot: string;
113
+ try {
114
+ projectRoot = getProjectRoot();
115
+ } catch (err) {
116
+ process.stderr.write(
117
+ `adapters list: cannot resolve project root: ${err instanceof Error ? err.message : String(err)}\n`,
118
+ );
119
+ return { exitCode: 2 };
120
+ }
121
+
122
+ const config = getConfig();
123
+ const localPaths = config.adapters?.local ?? [];
124
+ const adaptersEnabled = config.adapters?.enabled === true;
125
+
126
+ // Best-effort: try to load the manifest. If unreachable, discovery still
127
+ // runs but REGISTRY-VERIFIED candidates will be refused with a clear
128
+ // "registry manifest unavailable" warning per the discovery module.
129
+ let manifestEnvelope: Envelope | undefined;
130
+ const manifestResult = await getManifest();
131
+ if (manifestResult.kind === 'ok') {
132
+ manifestEnvelope = manifestResult.envelope;
133
+ for (const w of manifestResult.warnings) {
134
+ process.stderr.write(`[manifest] ${w}\n`);
135
+ }
136
+ if (manifestResult.source === 'cache-stale' && manifestResult.staleReason) {
137
+ process.stderr.write(`[manifest] cache-stale: ${manifestResult.staleReason}\n`);
138
+ }
139
+ } else {
140
+ for (const r of manifestResult.reasons) {
141
+ process.stderr.write(`[manifest] ${r}\n`);
142
+ }
143
+ }
144
+
145
+ const { adapters, warnings } = discoverAdapters({
146
+ projectRoot,
147
+ coreBundledIds: CORE_BUNDLED_IDS,
148
+ manifestEnvelope,
149
+ configLocalPaths: localPaths,
150
+ adaptersEnabled,
151
+ });
152
+
153
+ for (const w of warnings) {
154
+ process.stderr.write(`[discover] ${w}\n`);
155
+ }
156
+
157
+ renderAdapterTable(adapters);
158
+ return { exitCode: 0 };
159
+ }
160
+
161
+ function renderAdapterTable(adapters: AdapterDescriptor[]): void {
162
+ if (adapters.length === 0) {
163
+ process.stdout.write('No adapters discovered.\n');
164
+ process.stdout.write(' - CORE-BUNDLED: @massu/core ships first-party adapters; check massu.config.yaml.framework.\n');
165
+ process.stdout.write(' - REGISTRY-VERIFIED: install via `npm install @massu/adapter-<name>` then `massu adapters install <name>`.\n');
166
+ process.stdout.write(' - LOCAL-EXPLICIT: add via `massu adapters add-local <path>`.\n');
167
+ return;
168
+ }
169
+ // Header
170
+ process.stdout.write(['ID', 'ORIGIN', 'VERSION', 'PACKAGE_DIR'].join('\t') + '\n');
171
+ for (const a of adapters) {
172
+ const row = [a.id, a.origin, a.version ?? '-', a.packageDir ?? '-'];
173
+ process.stdout.write(row.join('\t') + '\n');
174
+ }
175
+ }
176
+
177
+ /**
178
+ * `npx massu adapters refresh [--force] [--check]` — re-fetch manifest from
179
+ * registry.massu.ai. Plan 3c gap-55 UX semantics:
180
+ * exit 0 = refreshed (or cache was current and --force not passed)
181
+ * exit 1 = signature mismatch / verification failed
182
+ * exit 2 = network unreachable
183
+ * exit 3 = cache write failure
184
+ * exit 4 = pubkey rotation desync detected (gap-54 path)
185
+ *
186
+ * Flags:
187
+ * --force Force refresh even when cache <24h old.
188
+ * --check Dry-run: print what would happen, don't write.
189
+ */
190
+ export async function runAdaptersRefresh(args: string[]): Promise<AdaptersResult> {
191
+ const flags = new Set(args);
192
+ const force = flags.has('--force');
193
+ const check = flags.has('--check');
194
+
195
+ process.stderr.write('Refreshing adapter manifest from registry.massu.ai...\n');
196
+
197
+ if (check) {
198
+ // Dry-run: load cache, report state, exit without write.
199
+ const result = await getManifest({ force: false });
200
+ if (result.kind === 'ok') {
201
+ process.stderr.write(
202
+ `[--check] cache state: ${result.source}; ` +
203
+ `would ${result.source === 'cache-fresh' ? 'NOT ' : ''}refresh.\n`,
204
+ );
205
+ return { exitCode: 0 };
206
+ }
207
+ process.stderr.write(`[--check] cache absent / stale; would refresh. Reasons: ${result.reasons.join('; ')}\n`);
208
+ return { exitCode: 0 };
209
+ }
210
+
211
+ const result = await getManifest({ force });
212
+ if (result.kind === 'ok' && result.source === 'refreshed') {
213
+ process.stderr.write(
214
+ `Refreshed: ${result.envelope.manifest.adapters.length} adapter entries from registry.\n`,
215
+ );
216
+ return { exitCode: 0 };
217
+ }
218
+ if (result.kind === 'ok' && result.source === 'cache-fresh' && !force) {
219
+ process.stderr.write('Cache is fresh (< 24h); skipping refresh. Use --force to override.\n');
220
+ return { exitCode: 0 };
221
+ }
222
+ if (result.kind === 'ok' && result.source === 'cache-stale') {
223
+ process.stderr.write(
224
+ `Refresh failed; existing cache retained. Reason: ${result.staleReason ?? 'unknown'}\n`,
225
+ );
226
+ return { exitCode: 2 };
227
+ }
228
+ if (result.kind === 'fail') {
229
+ const reasonText = result.reasons.join('; ');
230
+ if (/signature/i.test(reasonText) || /verify/i.test(reasonText)) {
231
+ process.stderr.write(`Refresh aborted: signature verification failed: ${reasonText}\n`);
232
+ return { exitCode: 1 };
233
+ }
234
+ if (/rotation/i.test(reasonText)) {
235
+ process.stderr.write(
236
+ `Refresh aborted: pubkey rotation desync. Upgrade @massu/core to a release ` +
237
+ `bundling the current registry pubkey, then retry. Detail: ${reasonText}\n`,
238
+ );
239
+ return { exitCode: 4 };
240
+ }
241
+ if (/cache.*write|write.*cache|atomicWrite/i.test(reasonText)) {
242
+ process.stderr.write(`Refresh failed: cache write error: ${reasonText}\n`);
243
+ return { exitCode: 3 };
244
+ }
245
+ process.stderr.write(`Refresh failed: ${reasonText}\n`);
246
+ return { exitCode: 2 };
247
+ }
248
+
249
+ return { exitCode: 0 };
250
+ }
251
+
252
+ /**
253
+ * `npx massu adapters search <query>` — list manifest entries matching the
254
+ * query (substring match, case-insensitive on package name). Useful for
255
+ * operators discovering what's available before running `massu adapters install`.
256
+ */
257
+ export async function runAdaptersSearch(args: string[]): Promise<AdaptersResult> {
258
+ const query = args[0];
259
+ if (!query) {
260
+ process.stderr.write('Usage: massu adapters search <query>\n');
261
+ return { exitCode: 1 };
262
+ }
263
+ const result = await getManifest();
264
+ if (result.kind !== 'ok') {
265
+ process.stderr.write(`Cannot search: registry manifest unavailable. ${result.reasons.join('; ')}\n`);
266
+ return { exitCode: 2 };
267
+ }
268
+ const needle = query.toLowerCase();
269
+ const matches = result.envelope.manifest.adapters.filter((e) =>
270
+ e.package.toLowerCase().includes(needle),
271
+ );
272
+ if (matches.length === 0) {
273
+ process.stdout.write(`No adapters matching '${query}'.\n`);
274
+ return { exitCode: 0 };
275
+ }
276
+ process.stdout.write(['PACKAGE', 'VERSION', 'STATUS'].join('\t') + '\n');
277
+ for (const m of matches) {
278
+ let status = 'available';
279
+ if (m.unpublished) status = 'unpublished (REFUSE)';
280
+ else if (m.deprecated) status = `deprecated (since ${m.deprecated.since})`;
281
+ process.stdout.write([m.package, m.version, status].join('\t') + '\n');
282
+ }
283
+ return { exitCode: 0 };
284
+ }
285
+
286
+ /**
287
+ * `npx massu adapters add-local <path>` (Plan 3c gap-32 + gap-58).
288
+ *
289
+ * Validates <path> through AdapterLocalPathSchema (rejects absolute,
290
+ * rejects parent-traversal, normalizes to POSIX). Reads massu.config.yaml,
291
+ * appends the normalized path to adapters.local, writes the file back
292
+ * preserving comments via yaml.parseDocument, and updates the
293
+ * fingerprint sentinel with source="cli".
294
+ *
295
+ * Exit codes:
296
+ * 0 = added; 1 = bad usage / invalid path; 2 = config / fs error
297
+ */
298
+ export async function runAdaptersAddLocal(args: string[]): Promise<AdaptersResult> {
299
+ const userPath = args[0];
300
+ if (!userPath) {
301
+ process.stderr.write('Usage: massu adapters add-local <path>\n');
302
+ return { exitCode: 1 };
303
+ }
304
+ const validated = AdapterLocalPathSchema.safeParse(userPath);
305
+ if (!validated.success) {
306
+ const issues = validated.error.issues.map((i) => i.message).join('; ');
307
+ process.stderr.write(`add-local refused: ${issues}\n`);
308
+ return { exitCode: 1 };
309
+ }
310
+ const normalizedPath = validated.data;
311
+
312
+ return mutateLocalArray((current) => {
313
+ if (current.includes(normalizedPath)) {
314
+ process.stderr.write(`adapters.local already contains '${normalizedPath}'; nothing to do.\n`);
315
+ return null; // signal no-op
316
+ }
317
+ return [...current, normalizedPath];
318
+ }, 'add-local');
319
+ }
320
+
321
+ /**
322
+ * `npx massu adapters remove-local <path>` — remove from adapters.local
323
+ * + update fingerprint sentinel.
324
+ */
325
+ export async function runAdaptersRemoveLocal(args: string[]): Promise<AdaptersResult> {
326
+ const userPath = args[0];
327
+ if (!userPath) {
328
+ process.stderr.write('Usage: massu adapters remove-local <path>\n');
329
+ return { exitCode: 1 };
330
+ }
331
+ // Run the user input through the same normalization so 'adapters\foo.js'
332
+ // input matches a stored 'adapters/foo.js' entry.
333
+ const validated = AdapterLocalPathSchema.safeParse(userPath);
334
+ if (!validated.success) {
335
+ process.stderr.write(`remove-local: path is malformed; nothing matches.\n`);
336
+ return { exitCode: 1 };
337
+ }
338
+ const normalizedPath = validated.data;
339
+
340
+ return mutateLocalArray((current) => {
341
+ if (!current.includes(normalizedPath)) {
342
+ process.stderr.write(`adapters.local does not contain '${normalizedPath}'; nothing to do.\n`);
343
+ return null;
344
+ }
345
+ return current.filter((p) => p !== normalizedPath);
346
+ }, 'remove-local');
347
+ }
348
+
349
+ /**
350
+ * `npx massu adapters resync-local-fingerprint` — operator escape hatch
351
+ * to acknowledge an out-of-band edit to adapters.local. Recomputes the
352
+ * fingerprint over whatever the current config says + writes the
353
+ * sentinel with source='cli-resync'. Use after manually editing
354
+ * massu.config.yaml.
355
+ *
356
+ * Does NOT touch the yaml — only the sentinel. The intent is "I edited
357
+ * the yaml directly + I trust the result; please stop refusing my local
358
+ * adapters."
359
+ */
360
+ export async function runAdaptersResyncLocalFingerprint(_args: string[]): Promise<AdaptersResult> {
361
+ resetConfig();
362
+ let cfg;
363
+ let projectRoot: string;
364
+ try {
365
+ projectRoot = getProjectRoot();
366
+ cfg = getConfig();
367
+ } catch (err) {
368
+ process.stderr.write(`resync-local-fingerprint: config invalid: ${err instanceof Error ? err.message : String(err)}\n`);
369
+ return { exitCode: 2 };
370
+ }
371
+ const localPaths = cfg.adapters?.local ?? [];
372
+ const result = writeFingerprintSentinel(localPaths, 'cli-resync', projectRoot);
373
+ if (!result.written) {
374
+ process.stderr.write(`resync-local-fingerprint: sentinel write failed: ${result.error}\n`);
375
+ return { exitCode: 2 };
376
+ }
377
+ process.stderr.write(
378
+ `Sentinel updated: ${localPaths.length} local adapter(s) acknowledged.\n`,
379
+ );
380
+ return { exitCode: 0 };
381
+ }
382
+
383
+ /**
384
+ * Shared yaml-mutation logic for add-local / remove-local. The mutator
385
+ * callback receives the current adapters.local array and returns either
386
+ * the new array OR null to signal "no-op" (e.g. trying to add a duplicate
387
+ * or remove a non-existent entry).
388
+ */
389
+ function mutateLocalArray(
390
+ mutator: (current: string[]) => string[] | null,
391
+ command: 'add-local' | 'remove-local',
392
+ ): AdaptersResult {
393
+ let projectRoot: string;
394
+ try {
395
+ projectRoot = getProjectRoot();
396
+ } catch (err) {
397
+ process.stderr.write(
398
+ `${command}: cannot resolve project root: ${err instanceof Error ? err.message : String(err)}\n`,
399
+ );
400
+ return { exitCode: 2 };
401
+ }
402
+ const yamlPath = resolve(projectRoot, 'massu.config.yaml');
403
+ if (!existsSync(yamlPath)) {
404
+ process.stderr.write(
405
+ `${command}: massu.config.yaml not found at ${yamlPath}. Run \`massu init\` first.\n`,
406
+ );
407
+ return { exitCode: 2 };
408
+ }
409
+ let yamlText: string;
410
+ try {
411
+ yamlText = readFileSync(yamlPath, 'utf-8');
412
+ } catch (err) {
413
+ process.stderr.write(`${command}: failed to read ${yamlPath}: ${err instanceof Error ? err.message : String(err)}\n`);
414
+ return { exitCode: 2 };
415
+ }
416
+ // pattern-scanner-allow: yaml-parse — reason: comment-preserving YAML edit for `massu adapters add-local <path>` (Plan 3c gap-32). getConfig() returns a parsed object that destroys user comments on round-trip; parseDocument is the structurally-correct tool.
417
+ const doc = parseDocument(yamlText);
418
+
419
+ // Read current adapters.local. yaml-Document's toJS on a node returns the
420
+ // plain JS value; we use this to feed the mutator.
421
+ const currentNode = doc.getIn(['adapters', 'local']) as { toJSON?: () => unknown } | unknown[] | undefined;
422
+ let current: string[] = [];
423
+ if (Array.isArray(currentNode)) {
424
+ current = currentNode.filter((x): x is string => typeof x === 'string');
425
+ } else if (currentNode && typeof currentNode === 'object' && 'toJSON' in currentNode && typeof currentNode.toJSON === 'function') {
426
+ const jsArr = currentNode.toJSON() as unknown;
427
+ if (Array.isArray(jsArr)) {
428
+ current = jsArr.filter((x): x is string => typeof x === 'string');
429
+ }
430
+ }
431
+
432
+ const next = mutator(current);
433
+ if (next === null) {
434
+ // Mutator decided no-op; sentinel stays in sync because nothing changed.
435
+ return { exitCode: 0 };
436
+ }
437
+
438
+ // iter-2 MED-NEW-3 fix: wrap the yaml-write + getConfig-revalidate +
439
+ // fingerprint-sentinel-write in a single file lock. Without it, a
440
+ // concurrent same-user process could swap a local adapter file
441
+ // between `doc.setIn` (yaml mutation) and `writeFingerprintSentinel`
442
+ // (which reads the file content for hashing) — locking in attacker-
443
+ // controlled content as the operator-acked baseline. The lock is on
444
+ // the project's `.massu/adapters-local-mutate.lock` which is per-
445
+ // project, NOT per-user, so multiple operator-launched massu CLI
446
+ // processes against the same project serialize on this lock.
447
+ const lockPath = resolve(projectRoot, '.massu', 'adapters-local-mutate.lock');
448
+ return withFileLockSync(lockPath, () => {
449
+ // Write back the mutated array. doc.setIn auto-creates intermediate
450
+ // nodes (adapters: {} → adapters: { local: [...] }) when they don't exist.
451
+ doc.setIn(['adapters', 'local'], next);
452
+ const newYaml = doc.toString();
453
+ const writeResult = atomicWrite(yamlPath, newYaml);
454
+ if (!writeResult.written) {
455
+ process.stderr.write(`${command}: yaml write failed: ${writeResult.error}\n`);
456
+ return { exitCode: 2 };
457
+ }
458
+
459
+ // Re-parse via getConfig() to validate the mutated yaml against the
460
+ // full schema (catches malformations the partial parsing might have missed).
461
+ resetConfig();
462
+ try {
463
+ getConfig();
464
+ } catch (err) {
465
+ process.stderr.write(
466
+ `${command}: yaml mutation produced invalid config: ${err instanceof Error ? err.message : String(err)}\n`,
467
+ );
468
+ return { exitCode: 2 };
469
+ }
470
+
471
+ // Update fingerprint sentinel — source='cli' marks this as an
472
+ // operator-acknowledged change so the loader's gap-32 check passes.
473
+ const fpResult = writeFingerprintSentinel(next, 'cli', projectRoot);
474
+ if (!fpResult.written) {
475
+ process.stderr.write(
476
+ `${command}: yaml updated but sentinel write failed: ${fpResult.error}. ` +
477
+ `Run \`massu adapters resync-local-fingerprint\` to retry.\n`,
478
+ );
479
+ return { exitCode: 2 };
480
+ }
481
+
482
+ process.stderr.write(`${command}: success — adapters.local now has ${next.length} entry(ies).\n`);
483
+ return { exitCode: 0 };
484
+ });
485
+ }
486
+
487
+ /**
488
+ * `npx massu adapters install <package>` (Plan 3c gap-37).
489
+ *
490
+ * Operator workflow:
491
+ * $ npm install @massu/adapter-rails
492
+ * $ npx massu adapters install @massu/adapter-rails
493
+ *
494
+ * The npm install step is the operator's responsibility; this command
495
+ * registers the freshly-installed package in the install-tracking
496
+ * sidecar so the loader's load-time integrity check (verifyInstalledIntegrity
497
+ * in discover.ts) accepts it on next startup. Concretely:
498
+ *
499
+ * 1. Look up <package> in the cached registry manifest. Refuse if not
500
+ * found OR unpublished.
501
+ * 2. Locate the package in node_modules at the project root.
502
+ * 3. Compute sha256OfDir of the package directory (content-addressed,
503
+ * stable across machines).
504
+ * 4. Compare to the manifest entry's sha256. Refuse on mismatch
505
+ * (tampering OR wrong-version-installed condition).
506
+ * 5. Write the install entry to ~/.massu/adapter-manifest-installed.json.
507
+ *
508
+ * Exit codes:
509
+ * 0 = registered; 1 = bad usage / package not in manifest / not installed;
510
+ * 2 = sha mismatch (tampering or wrong version); 3 = sidecar write failed
511
+ */
512
+ /**
513
+ * CR-9 audit H4 fix: validate package name against npm's strict naming
514
+ * spec BEFORE using it as path segments OR writing to stderr. Rejects:
515
+ * - control characters (would log-inject if echoed to stderr)
516
+ * - path traversal segments (.., absolute paths, embedded slashes
517
+ * beyond the single scope/name boundary)
518
+ * - non-npm-spec characters
519
+ */
520
+ const NPM_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
521
+
522
+ function validatePackageName(name: string): { ok: true; name: string } | { ok: false; reason: string } {
523
+ if (typeof name !== 'string' || name.length === 0) {
524
+ return { ok: false, reason: 'package name is empty' };
525
+ }
526
+ // Reject control characters explicitly — npm name regex below also
527
+ // does this implicitly, but a more specific message helps operators.
528
+ if (/[\x00-\x1f\x7f]/.test(name)) {
529
+ return { ok: false, reason: 'package name contains control characters' };
530
+ }
531
+ if (!NPM_PACKAGE_NAME_RE.test(name)) {
532
+ return {
533
+ ok: false,
534
+ reason:
535
+ `package name '${name}' does not match npm spec ` +
536
+ `(^(@scope/)?name$ where each component matches [a-z0-9][a-z0-9._-]*). ` +
537
+ `Refusing to use as path segment.`,
538
+ };
539
+ }
540
+ return { ok: true, name };
541
+ }
542
+
543
+ export async function runAdaptersInstall(args: string[]): Promise<AdaptersResult> {
544
+ const packageNameRaw = args[0];
545
+ if (!packageNameRaw) {
546
+ process.stderr.write('Usage: massu adapters install <package-name>\n');
547
+ return { exitCode: 1 };
548
+ }
549
+ const validated = validatePackageName(packageNameRaw);
550
+ if (!validated.ok) {
551
+ process.stderr.write(`install refused: ${validated.reason}\n`);
552
+ return { exitCode: 1 };
553
+ }
554
+ const packageName = validated.name;
555
+
556
+ let projectRoot: string;
557
+ try {
558
+ projectRoot = getProjectRoot();
559
+ } catch (err) {
560
+ process.stderr.write(`install: cannot resolve project root: ${err instanceof Error ? err.message : String(err)}\n`);
561
+ return { exitCode: 1 };
562
+ }
563
+
564
+ // Locate the package in node_modules. Handles scoped names like
565
+ // @massu/adapter-rails by joining at the path level.
566
+ const packageDir = resolve(projectRoot, 'node_modules', ...packageName.split('/'));
567
+ if (!existsSync(packageDir)) {
568
+ process.stderr.write(
569
+ `install: ${packageName} is not installed in node_modules. Run \`npm install ${packageName}\` first.\n`,
570
+ );
571
+ return { exitCode: 1 };
572
+ }
573
+
574
+ // Read the package's own version + verify the package looks like an adapter.
575
+ const pkgJsonPath = resolve(packageDir, 'package.json');
576
+ if (!existsSync(pkgJsonPath)) {
577
+ process.stderr.write(`install: ${packageName} has no package.json at ${pkgJsonPath}\n`);
578
+ return { exitCode: 1 };
579
+ }
580
+ let pkgJson: { name?: unknown; version?: unknown; 'massu-adapter'?: unknown };
581
+ try {
582
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
583
+ } catch (err) {
584
+ process.stderr.write(`install: ${packageName} has malformed package.json: ${err instanceof Error ? err.message : String(err)}\n`);
585
+ return { exitCode: 1 };
586
+ }
587
+ if (pkgJson.name !== packageName || typeof pkgJson.version !== 'string') {
588
+ process.stderr.write(`install: ${packageName}'s package.json name/version mismatch.\n`);
589
+ return { exitCode: 1 };
590
+ }
591
+ const installedVersion = pkgJson.version;
592
+
593
+ // Look up in cached registry manifest.
594
+ const manifestResult = await getManifest();
595
+ if (manifestResult.kind !== 'ok') {
596
+ process.stderr.write(
597
+ `install: registry manifest unavailable. Run \`massu adapters refresh\` first. Reasons: ${manifestResult.reasons.join('; ')}\n`,
598
+ );
599
+ return { exitCode: 1 };
600
+ }
601
+ const manifestEntry = manifestResult.envelope.manifest.adapters.find((e) => e.package === packageName);
602
+ if (!manifestEntry) {
603
+ process.stderr.write(
604
+ `install: refusing — ${packageName} is not in the signed registry manifest. ` +
605
+ `Submit a PR per AUTHORING-ADAPTERS.md before installing.\n`,
606
+ );
607
+ return { exitCode: 1 };
608
+ }
609
+ if (manifestEntry.unpublished === true) {
610
+ process.stderr.write(
611
+ `install: refusing — ${packageName} is marked unpublished in the manifest. ` +
612
+ `Run \`npm uninstall ${packageName}\` to remove it.\n`,
613
+ );
614
+ return { exitCode: 1 };
615
+ }
616
+ if (manifestEntry.version !== installedVersion) {
617
+ process.stderr.write(
618
+ `install: WARNING — installed version ${installedVersion} differs from manifest entry ${manifestEntry.version}. ` +
619
+ `The sha256 check below uses the manifest's hash; if the installed package was tampered to look like a different ` +
620
+ `version, the check will catch it.\n`,
621
+ );
622
+ }
623
+
624
+ // CR-9 audit M5 fix (+ iter-2 MED-NEW-1/-2: shared helper used at install,
625
+ // resign, and discovery load-time). Refuse any package whose tree
626
+ // contains hidden directories. Published npm tarballs should not ship
627
+ // these; sha256OfDir excludes them from hashing, so a payload there
628
+ // would be invisible to install + load checks. Single source of truth:
629
+ // containsHiddenDirs in security/install-tracking.ts.
630
+ const hiddenDir = containsHiddenDirs(packageDir);
631
+ if (hiddenDir !== null) {
632
+ process.stderr.write(
633
+ `install refused: ${packageName} contains a '${hiddenDir}' subdirectory. ` +
634
+ `Published npm tarballs should not ship these directories — refusing as a ` +
635
+ `precaution against payload smuggling.\n`,
636
+ );
637
+ return { exitCode: 1 };
638
+ }
639
+
640
+ // Compute sha256OfDir + compare.
641
+ let computedSha: string;
642
+ try {
643
+ computedSha = sha256OfDir(packageDir);
644
+ } catch (err) {
645
+ process.stderr.write(`install: sha256OfDir failed: ${err instanceof Error ? err.message : String(err)}\n`);
646
+ return { exitCode: 2 };
647
+ }
648
+
649
+ // Note: per gap-37 spec, the manifest entry's sha256 is the
650
+ // CONTENT-ADDRESSED hash of the published package (same algorithm
651
+ // sha256OfDir computes here). For the registry's currently empty
652
+ // manifest, no entries exist yet; once Phase 9 ships the 5
653
+ // first-party adapters, their manifest entries' sha256 will be
654
+ // computed via the same sha256OfDir at publish time.
655
+ if (computedSha !== manifestEntry.sha256) {
656
+ process.stderr.write(
657
+ `install: refusing — ${packageName} content sha256 (${computedSha.slice(0, 16)}...) ` +
658
+ `does not match manifest entry sha256 (${manifestEntry.sha256.slice(0, 16)}...). ` +
659
+ `This indicates either (a) tampering of the installed files, or (b) a different version installed than the manifest pins.\n`,
660
+ );
661
+ return { exitCode: 2 };
662
+ }
663
+
664
+ // Write to install-tracking sidecar.
665
+ const entry: InstallEntry = {
666
+ version: installedVersion,
667
+ installed_sha256: computedSha,
668
+ manifest_sha256: manifestEntry.sha256,
669
+ ts: new Date().toISOString(),
670
+ };
671
+ const writeResult = writeInstalledManifestEntry(packageName, entry);
672
+ if (!writeResult.written) {
673
+ process.stderr.write(`install: sidecar write failed: ${writeResult.error}\n`);
674
+ return { exitCode: 3 };
675
+ }
676
+
677
+ process.stderr.write(`install: registered ${packageName}@${installedVersion} (sha ${computedSha.slice(0, 16)}...)\n`);
678
+ return { exitCode: 0 };
679
+ }
680
+
681
+ /**
682
+ * `npx massu adapters resign` (Plan 3c gap-37 + gap-54).
683
+ *
684
+ * Re-fetches the registry manifest under the currently-bundled @massu/core
685
+ * pubkey + walks every entry in the install-tracking sidecar:
686
+ * - If still in the new manifest with matching sha256 → refresh `ts`
687
+ * - If sha256 mismatches OR no longer in manifest → REMOVE from sidecar
688
+ * + emit operator-actionable warning (recover via npm uninstall +
689
+ * npm install + massu adapters install)
690
+ *
691
+ * Used after a key rotation event when the cached manifest's
692
+ * bundled_pubkey_fingerprint != current pubkey: the operator reinstalls
693
+ * the affected packages, then runs `resign` to re-link them to the new
694
+ * manifest's signatures.
695
+ */
696
+ export async function runAdaptersResign(_args: string[]): Promise<AdaptersResult> {
697
+ const refreshed = await getManifest({ force: true });
698
+ if (refreshed.kind !== 'ok') {
699
+ process.stderr.write(
700
+ `resign: refresh failed. Cannot reconcile install-tracking sidecar without a verified manifest. ` +
701
+ `Reasons: ${refreshed.reasons.join('; ')}\n`,
702
+ );
703
+ return { exitCode: 1 };
704
+ }
705
+
706
+ const installed = readInstalledManifest();
707
+ const manifestByName = new Map(refreshed.envelope.manifest.adapters.map((e) => [e.package, e]));
708
+ let kept = 0;
709
+ let removed = 0;
710
+ const warnings: string[] = [];
711
+
712
+ let projectRoot: string;
713
+ try {
714
+ projectRoot = getProjectRoot();
715
+ } catch (err) {
716
+ process.stderr.write(`resign: cannot resolve project root: ${err instanceof Error ? err.message : String(err)}\n`);
717
+ return { exitCode: 1 };
718
+ }
719
+
720
+ for (const [name, entry] of Object.entries(installed)) {
721
+ // iter-2 audit LOW-NEW-1 fix: re-validate sidecar keys via the same
722
+ // npm-name regex used at install. A same-user filesystem write to
723
+ // ~/.massu/adapter-manifest-installed.json could embed control chars
724
+ // in a key, which would log-inject when echoed to stderr below.
725
+ // Refusing to process malformed keys catches this BEFORE any
726
+ // process.stderr.write that includes `name`.
727
+ const nameValidation = validatePackageName(name);
728
+ if (!nameValidation.ok) {
729
+ removed++;
730
+ // Render the sidecar key in JSON.stringify form so any control
731
+ // characters are escaped — the warning is operator-readable + safe
732
+ // to put in CI logs.
733
+ warnings.push(
734
+ `sidecar key ${JSON.stringify(name)} (rendered safely) is malformed: ${nameValidation.reason} — REMOVED from sidecar`,
735
+ );
736
+ removeInstalledManifestEntry(name);
737
+ continue;
738
+ }
739
+ const newEntry = manifestByName.get(name);
740
+ if (!newEntry) {
741
+ removed++;
742
+ warnings.push(`${name}@${entry.version}: no longer in manifest after resign — REMOVED from sidecar`);
743
+ removeInstalledManifestEntry(name);
744
+ continue;
745
+ }
746
+ const packageDir = resolve(projectRoot, 'node_modules', ...name.split('/'));
747
+ if (!existsSync(packageDir)) {
748
+ removed++;
749
+ warnings.push(`${name}@${entry.version}: not present in node_modules — REMOVED from sidecar`);
750
+ removeInstalledManifestEntry(name);
751
+ continue;
752
+ }
753
+ // iter-2 MED-NEW-1 fix: same hidden-dir refusal that runAdaptersInstall
754
+ // applies — without this, an attacker could install a clean package,
755
+ // get registered, swap in a malicious version with `.git/payload.js`,
756
+ // then have resign re-record it.
757
+ const hiddenDirAtResign = containsHiddenDirs(packageDir);
758
+ if (hiddenDirAtResign !== null) {
759
+ removed++;
760
+ warnings.push(
761
+ `${name}@${entry.version}: contains '${hiddenDirAtResign}' subdirectory ` +
762
+ `— REMOVED from sidecar. Recover via \`npm uninstall ${name} && ` +
763
+ `npm install ${name} && massu adapters install ${name}\``,
764
+ );
765
+ removeInstalledManifestEntry(name);
766
+ continue;
767
+ }
768
+ let computedSha: string;
769
+ try {
770
+ computedSha = sha256OfDir(packageDir);
771
+ } catch (err) {
772
+ removed++;
773
+ warnings.push(`${name}: sha256OfDir failed: ${err instanceof Error ? err.message : String(err)} — REMOVED from sidecar`);
774
+ removeInstalledManifestEntry(name);
775
+ continue;
776
+ }
777
+ if (computedSha !== newEntry.sha256) {
778
+ removed++;
779
+ warnings.push(
780
+ `${name}: post-resign sha mismatch (manifest ${newEntry.sha256.slice(0, 16)}... vs ` +
781
+ `installed ${computedSha.slice(0, 16)}...) — REMOVED from sidecar. Recover via ` +
782
+ `\`npm uninstall ${name} && npm install ${name} && massu adapters install ${name}\``,
783
+ );
784
+ removeInstalledManifestEntry(name);
785
+ continue;
786
+ }
787
+ // Refresh ts so the operator sees the resign happened.
788
+ writeInstalledManifestEntry(name, {
789
+ ...entry,
790
+ manifest_sha256: newEntry.sha256,
791
+ ts: new Date().toISOString(),
792
+ });
793
+ kept++;
794
+ }
795
+
796
+ for (const w of warnings) {
797
+ process.stderr.write(`resign: ${w}\n`);
798
+ }
799
+ process.stderr.write(`resign: kept ${kept} entries; removed ${removed} entries.\n`);
800
+ return { exitCode: 0 };
801
+ }
802
+
803
+ function printAdaptersHelp(): void {
804
+ console.log(`
805
+ Massu adapters — third-party adapter registry CLI
806
+
807
+ Usage:
808
+ massu adapters <subcommand>
809
+
810
+ Subcommands (read-only operational):
811
+ list Show all discovered adapters + their origin class
812
+ refresh [--force --check] Refresh the cached registry manifest
813
+ search <query> List registry manifest entries matching <query>
814
+
815
+ Subcommands (config-mutation; in-flight Phase 5 follow-up):
816
+ add-local <path> Add a project-local adapter file to massu.config.yaml
817
+ remove-local <path> Remove a project-local adapter from massu.config.yaml
818
+ resync-local-fingerprint Acknowledge an out-of-band edit to adapters.local
819
+ install <package> Record install-time sha256 for a freshly-installed adapter
820
+ resign Re-fetch + re-trust adapters under a rotated registry key
821
+
822
+ Documentation: https://massu.ai/docs/adapters
823
+ `);
824
+ }