@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,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
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Atomic write-then-rename primitive for security-relevant state files.
3
+ *
4
+ * Plan 3c gap-37 + gap-41 deliverable: extracted from init.ts:writeConfigAtomic
5
+ * (which used to inline this pattern with a hardcoded 0o644 mode and a
6
+ * config-specific YAML/Zod validation step) so that BOTH init.ts and the new
7
+ * Phase 5 security modules import the same helper. CR-46 / Rule 0 compliance:
8
+ * no parallel third atomic-write helper exists in the codebase.
9
+ *
10
+ * Guarantees:
11
+ * 1. Parent directory is created (recursively) with `ensureParentDirMode` if
12
+ * passed (typically 0o700 for ~/.massu/ per gap-37). Existing parent dirs
13
+ * are NOT chmod'd — that would clobber an operator's deliberate widening.
14
+ * 2. Content is written to `${path}.tmp` via openSync + writeSync + fsyncSync
15
+ * + closeSync. The fsync is REQUIRED (xfs / ext4 `data=writeback` will
16
+ * rename-before-data on crash without it; init.ts iter-7 fix codified this).
17
+ * 3. After tmp is durably on disk, renameSync moves it to the final path
18
+ * atomically (POSIX guarantees readers see EITHER old OR new, never torn).
19
+ * 4. If `mode` is provided, chmodSync is applied AFTER rename so the final
20
+ * mode is exact (openSync's third arg is masked by the process umask;
21
+ * explicit chmod is the only way to guarantee 0o600 on systems with
22
+ * umask 0o022 or stricter).
23
+ * 5. ANY error during the write/rename sequence triggers tmp cleanup before
24
+ * the error propagates. Original file at `path` is untouched on error.
25
+ *
26
+ * Concurrency:
27
+ * - Reader side: NO lock acquired. POSIX renameSync is atomic; readers see
28
+ * EITHER old OR new content, never torn. This is sufficient for the cache
29
+ * read paths (manifest cache, fingerprint cache).
30
+ * - Writer side: this helper does NOT serialize concurrent writers. If two
31
+ * processes call atomicWrite on the same path concurrently, both will
32
+ * succeed but the second will silently overwrite the first. For Phase 5's
33
+ * manifest cache (where racing `adapters refresh` invocations from the 3a
34
+ * watcher + manual install + coverage CLI are plausible per gap-59), the
35
+ * caller must acquire the advisory lock at `~/.massu/.adapter-manifest.lock`
36
+ * FIRST via `withFileLock()` (sibling helper). This module's job is just
37
+ * the write atomicity, not write serialization.
38
+ *
39
+ * Use this helper for any file that:
40
+ * - Gates security decisions (cache invalidation, signature verification,
41
+ * path fingerprinting), OR
42
+ * - Must survive crash / SIGKILL / power loss without ending up zero-byte, OR
43
+ * - Has concurrent readers that must never see partial content.
44
+ */
45
+ import {
46
+ chmodSync,
47
+ closeSync,
48
+ existsSync,
49
+ fsyncSync,
50
+ mkdirSync,
51
+ openSync,
52
+ renameSync,
53
+ rmSync,
54
+ statSync,
55
+ writeSync,
56
+ } from 'node:fs';
57
+ import { dirname } from 'node:path';
58
+
59
+ export interface AtomicWriteOptions {
60
+ /**
61
+ * File mode applied to the final path via chmodSync after rename.
62
+ * Defaults to undefined (existing-mode preservation, or system default for
63
+ * new files). Pass 0o600 for security-relevant cache files (manifest,
64
+ * fingerprint, etc.) per Plan 3c gap-37.
65
+ */
66
+ mode?: number;
67
+ /**
68
+ * If passed AND the parent directory does NOT exist, mkdirSync creates it
69
+ * with this mode. Pass 0o700 for ~/.massu/ per Plan 3c gap-37 deliverable.
70
+ * Existing parent directories are NOT chmod'd — operators may have
71
+ * deliberately widened the dir for sharing/sync.
72
+ */
73
+ ensureParentDirMode?: number;
74
+ }
75
+
76
+ export interface AtomicWriteResult {
77
+ /** True when the rename succeeded and the final mode (if requested) is in place. */
78
+ written: boolean;
79
+ /** Error message when written is false. */
80
+ error?: string;
81
+ }
82
+
83
+ /**
84
+ * Atomically write `content` to `path`. See module-level doc for guarantees.
85
+ *
86
+ * Returns `{ written: true }` on success, `{ written: false, error }` on
87
+ * failure (tmp file is cleaned up; original `path` is untouched).
88
+ */
89
+ export function atomicWrite(
90
+ path: string,
91
+ content: string | Buffer,
92
+ opts: AtomicWriteOptions = {},
93
+ ): AtomicWriteResult {
94
+ const tmpPath = `${path}.tmp`;
95
+ const parentDir = dirname(path);
96
+
97
+ try {
98
+ if (!existsSync(parentDir)) {
99
+ const mkdirOpts: { recursive: true; mode?: number } = { recursive: true };
100
+ if (opts.ensureParentDirMode !== undefined) {
101
+ mkdirOpts.mode = opts.ensureParentDirMode;
102
+ }
103
+ mkdirSync(parentDir, mkdirOpts);
104
+ }
105
+
106
+ const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
107
+ const openMode = opts.mode ?? 0o644;
108
+ const fd = openSync(tmpPath, 'w', openMode);
109
+ try {
110
+ writeSync(fd, buf, 0, buf.length, 0);
111
+ fsyncSync(fd);
112
+ } finally {
113
+ closeSync(fd);
114
+ }
115
+
116
+ if (opts.mode !== undefined) {
117
+ // CR-9 audit M1 fix: chmod the TMP file BEFORE rename so the final
118
+ // file appears with the correct mode atomically. Without this, the
119
+ // prior post-rename chmod left a microsecond TOCTOU window where the
120
+ // file existed at the final path with umask-default mode (e.g.
121
+ // 0o644) — an attacker process polling the cache file could read
122
+ // the contents before chmod fires. With chmod-before-rename, the
123
+ // rename atomically delivers a file already at mode 0o600.
124
+ chmodSync(tmpPath, opts.mode);
125
+ }
126
+
127
+ renameSync(tmpPath, path);
128
+
129
+ return { written: true };
130
+ } catch (err) {
131
+ if (existsSync(tmpPath)) {
132
+ try {
133
+ rmSync(tmpPath, { force: true });
134
+ } catch {
135
+ // Tmp cleanup is best-effort; primary error wins.
136
+ }
137
+ }
138
+ return { written: false, error: err instanceof Error ? err.message : String(err) };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Stat a path and return whether it is group-writable or world-writable.
144
+ * Used by Plan 3c gap-37 deliverable to emit a stderr warning when ~/.massu/
145
+ * is shared across users via NFS / symlink / chmod widening — security-
146
+ * relevant cache files MUST NOT be readable by other accounts on shared
147
+ * systems.
148
+ *
149
+ * CR-9 audit L4 fix: returns `null` (unknown) on stat error instead of
150
+ * `false` (definitely-not-writable). Caller treats unknown as "warn"
151
+ * since a stat error could itself indicate adversarial filesystem state
152
+ * (mounted by another user, ownership flip, etc.). Returning false
153
+ * silently in that path was a defense-bypass.
154
+ */
155
+ export function isGroupOrWorldWritable(path: string): boolean | null {
156
+ if (!existsSync(path)) return false;
157
+ try {
158
+ const mode = statSync(path).mode;
159
+ // 0o020 = group-write, 0o002 = other-write
160
+ return (mode & 0o022) !== 0;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }