@massu/core 1.10.8 → 1.12.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 (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,149 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * P-H019 (plan-stage-c-high-batch / 1.10.5): Ed25519 verifier for signed
6
- * /validate-key responses from massu.ai.
7
- *
8
- * **Bug class closed**: pre-fix, `packages/core/src/license.ts:280-318`
9
- * accepted ANY `{ valid: true, plan: 'enterprise' }` JSON over HTTPS from
10
- * whatever endpoint `config.cloud?.endpoint` pointed to. MITM, malicious
11
- * cloud.endpoint, or local edit of the cached `license_cache` SQLite row
12
- * could grant arbitrary tier.
13
- *
14
- * **Structural fix**: the server (validate-key Supabase Edge Function)
15
- * signs every response with Ed25519. The client (this module, called
16
- * from `license.ts`) verifies the signature against the bundled public
17
- * key in `license-pubkey.generated.ts`. Invalid / missing signature →
18
- * reject under strict mode, warn-and-accept under transition mode.
19
- *
20
- * **Transition mode** (default): when `MASSU_REQUIRE_SIGNED_LICENSE` env
21
- * var is NOT `'true'`, unsigned/invalid-sig responses are accepted but
22
- * a one-shot stderr warning is emitted. Lets existing customers keep
23
- * working while operators provision the signing key.
24
- *
25
- * **Strict mode** (post-cutover): when `MASSU_REQUIRE_SIGNED_LICENSE=true`,
26
- * unsigned/invalid-sig responses are REJECTED (caller drops to grace-period
27
- * cached or 'free' tier). Operator flips this after Supabase Edge Function
28
- * env var `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64` is provisioned and
29
- * cutover smoke test passes.
30
- */
31
-
32
- import { createVerify, createPublicKey } from 'crypto';
33
- import {
34
- LICENSE_PUBKEY_ED25519,
35
- LICENSE_PUBKEY_FINGERPRINT_HEX,
36
- KNOWN_LICENSE_PUBKEY_FINGERPRINTS,
37
- } from './license-pubkey.generated.ts';
38
-
39
- export interface SignedLicenseResponse {
40
- /** Base64-encoded Ed25519 signature over the canonical-serialized payload. */
41
- _signature?: string;
42
- /** Signature algorithm tag (currently always `'ed25519'`). */
43
- _signature_alg?: string;
44
- /**
45
- * Sorted list of top-level keys (excluding the `_signature*` triplet) that
46
- * were included in the signed payload. Lets the client reconstruct the
47
- * canonical-serialization input deterministically.
48
- */
49
- _signature_payload_keys?: readonly string[];
50
- /** Hex sha256 of the LICENSE pubkey used for signing (so client can detect rotation). */
51
- _signature_pubkey_fingerprint?: string;
52
- // Plus the actual fields (plan, tier, validUntil, features, etc.)
53
- [key: string]: unknown;
54
- }
55
-
56
- export type VerificationResult =
57
- | { kind: 'valid' }
58
- | { kind: 'missing_signature' }
59
- | { kind: 'bad_signature' }
60
- | { kind: 'unknown_pubkey'; got: string }
61
- | { kind: 'error'; reason: string };
62
-
63
- /**
64
- * Verify a server-signed /validate-key response. Returns a tagged union
65
- * describing the verification outcome. Caller decides whether to accept
66
- * based on strict/transition mode.
67
- */
68
- export function verifyLicenseResponse(payload: SignedLicenseResponse): VerificationResult {
69
- // Self-check: confirm the bundled key fingerprint matches the allowlist.
70
- // CR-9 audit L2 pattern: a future build that swaps the pem to an unknown
71
- // key refuses to load even if compiled in.
72
- if (!KNOWN_LICENSE_PUBKEY_FINGERPRINTS.has(LICENSE_PUBKEY_FINGERPRINT_HEX)) {
73
- return {
74
- kind: 'error',
75
- reason: `Bundled license pubkey fingerprint ${LICENSE_PUBKEY_FINGERPRINT_HEX} is not in the trusted allowlist. Possible build-time tamper.`,
76
- };
77
- }
78
-
79
- const sig = payload._signature;
80
- const alg = payload._signature_alg;
81
- const payloadKeys = payload._signature_payload_keys;
82
- const sigPubkey = payload._signature_pubkey_fingerprint;
83
-
84
- if (typeof sig !== 'string' || sig.length === 0) {
85
- return { kind: 'missing_signature' };
86
- }
87
- if (alg !== 'ed25519') {
88
- return { kind: 'error', reason: `Unsupported signature algorithm: ${alg}` };
89
- }
90
- if (!Array.isArray(payloadKeys) || payloadKeys.length === 0) {
91
- return { kind: 'error', reason: 'Missing _signature_payload_keys' };
92
- }
93
- if (typeof sigPubkey === 'string' && sigPubkey !== LICENSE_PUBKEY_FINGERPRINT_HEX) {
94
- // Server signed with a different pubkey than we have bundled — rotation
95
- // mismatch. Caller should fail-open under transition mode (operator
96
- // hasn't published the new bundle yet) but reject under strict mode.
97
- return { kind: 'unknown_pubkey', got: sigPubkey };
98
- }
99
-
100
- // Reconstruct the canonical serialization the server signed:
101
- // JSON.stringify({ k1: payload[k1], k2: payload[k2], ... }, payloadKeysSorted)
102
- // where payloadKeysSorted is the sorted list of NON-underscore-prefixed keys.
103
- const canonicalObj: Record<string, unknown> = {};
104
- for (const k of payloadKeys) {
105
- if (typeof k !== 'string') continue;
106
- canonicalObj[k] = payload[k];
107
- }
108
- const canonical = JSON.stringify(canonicalObj, [...payloadKeys].sort());
109
-
110
- // Verify signature.
111
- try {
112
- // Construct a node KeyObject from the bundled raw Ed25519 pubkey bytes.
113
- // We use createPublicKey with SubjectPublicKeyInfo DER encoding (44 bytes:
114
- // 12-byte algorithm OID prefix + 32-byte raw key).
115
- const spkiPrefix = Buffer.from([
116
- 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
117
- 0x70, 0x03, 0x21, 0x00,
118
- ]);
119
- const der = Buffer.concat([spkiPrefix, Buffer.from(LICENSE_PUBKEY_ED25519)]);
120
- const pubkey = createPublicKey({ key: der, format: 'der', type: 'spki' });
121
-
122
- // Ed25519 in node uses `verify` directly (no createVerify needed for ed25519).
123
- // crypto.verify(algorithm, data, key, signature) — algorithm MUST be null for Ed25519.
124
- // eslint-disable-next-line @typescript-eslint/no-require-imports
125
- const { verify } = require('crypto');
126
- const ok = verify(
127
- null,
128
- Buffer.from(canonical, 'utf-8'),
129
- pubkey,
130
- Buffer.from(sig, 'base64'),
131
- );
132
-
133
- return ok ? { kind: 'valid' } : { kind: 'bad_signature' };
134
- } catch (err) {
135
- return {
136
- kind: 'error',
137
- reason: `Signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
138
- };
139
- }
140
- }
141
-
142
- /**
143
- * P-H019 strict-mode gate. Operator sets `MASSU_REQUIRE_SIGNED_LICENSE=true`
144
- * AFTER the validate-key Edge Function is provisioned with
145
- * `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64` and cutover smoke test passes.
146
- */
147
- export function isLicenseSignatureRequired(): boolean {
148
- return process.env.MASSU_REQUIRE_SIGNED_LICENSE === 'true';
149
- }
@@ -1,225 +0,0 @@
1
- /**
2
- * adapters.local postinstall-poisoning fingerprint (Plan 3c gap-32).
3
- *
4
- * Threat model: a malicious npm package's `postinstall` script could mutate
5
- * `massu.config.yaml > adapters.local` to add a path pointing at attacker-
6
- * controlled code. Local adapters bypass the registry-signed allowlist
7
- * entirely (operator opt-in per-path), so the only defense is verifying
8
- * that the operator INTENDED the mutation. The mechanism:
9
- *
10
- * 1. Every time the operator runs `massu adapters add-local <path>`,
11
- * `massu adapters remove-local <path>`, or `massu adapters resync-local-
12
- * fingerprint`, this module writes a sentinel file at
13
- * `~/.massu/adapters-local-fingerprint.json` recording:
14
- * { fingerprint: <sha256-hex of canonical-stringified sorted array>,
15
- * source: "cli" | "cli-resync",
16
- * ts: ISO8601-string }
17
- *
18
- * 2. At loader startup, discoverAdapters compares the CURRENT
19
- * massu.config.yaml.adapters.local content's fingerprint against the
20
- * sentinel. If they differ, the loader REFUSES to load any local
21
- * adapter and emits a stderr warning naming the additions/removals
22
- * that diverged from the last operator-acknowledged state.
23
- *
24
- * 3. Operators can re-acknowledge the current state by running
25
- * `massu adapters resync-local-fingerprint` — which recomputes the
26
- * fingerprint over whatever adapters.local currently holds (regardless
27
- * of how it got there) and writes the sentinel anew. This is
28
- * explicitly a "trust me, I know what I edited" CLI escape hatch.
29
- *
30
- * Why a SEPARATE file (not stored inside the cache, not stored in the
31
- * yaml itself):
32
- * - In yaml: a postinstall script could update both the entry AND the
33
- * fingerprint, defeating detection.
34
- * - In ~/.massu/adapter-manifest.json (signed cache): the cache wraps
35
- * registry data, mixing operator-trust state into it would conflate
36
- * two different security domains.
37
- * - Standalone: the file's path is well-known + stable; CLI-only
38
- * writes are the explicit acknowledgment signal.
39
- *
40
- * File mode: 0o600 (gap-37 — security-relevant cache files are owner-only).
41
- *
42
- * Drift-prevention (CR-46 / Rule 0 self-attest #2): the SAME canonical-
43
- * fingerprint computation is used at write time AND at check time —
44
- * `computeLocalFingerprint` is the single source of truth. A future
45
- * caller that hashes locally-different bytes would silently drift; this
46
- * module's API makes that impossible by exposing only the high-level
47
- * write/check primitives, not the raw sha256 step.
48
- */
49
- import { existsSync, readFileSync, lstatSync } from 'node:fs';
50
- import { homedir } from 'node:os';
51
- import { resolve, isAbsolute } from 'node:path';
52
- import { createHash } from 'node:crypto';
53
- import { z } from 'zod';
54
- import { atomicWrite } from './atomic-write.js';
55
- import { PrintableAsciiStringSchema } from './manifest-schema.js';
56
-
57
- export const FINGERPRINT_PATH = resolve(homedir(), '.massu', 'adapters-local-fingerprint.json');
58
-
59
- /**
60
- * Canonical fingerprint: sha256 hex of `[path, sha256(content)]` tuples for
61
- * each adapters.local entry, sorted by path. CR-9 audit C3 fix: hashing path
62
- * STRINGS only (the prior shape) let a postinstall script swap the FILE at
63
- * an already-acknowledged path with full bypass — fingerprint matched even
64
- * though the file content changed. This implementation hashes BOTH the path
65
- * AND the current file content, so swapping the file (even with a same-
66
- * length payload) triggers drift on the very next discovery + the loader
67
- * refuses until the operator runs `massu adapters resync-local-fingerprint`.
68
- *
69
- * Path inputs MUST be the AdapterLocalPathSchema-validated + POSIX-normalized
70
- * content from getConfig().adapters?.local. `projectRoot` is the absolute
71
- * path to the project so we can resolve relative entries to disk reads.
72
- *
73
- * Missing files: the fingerprint includes a sentinel string `<missing>` for
74
- * any adapters.local entry that does not resolve to a regular file at
75
- * fingerprint time. This means an absent file is part of the fingerprint
76
- * — adding the file later is a drift event the operator must explicitly
77
- * acknowledge. Symbolic links are detected via lstatSync + treated as
78
- * `<symlink>` (also a sentinel; the link target is NEVER followed for
79
- * hashing — a malicious symlink to /etc/shadow does not exfiltrate that
80
- * file's content into the fingerprint).
81
- */
82
- export function computeLocalFingerprint(
83
- localPaths: ReadonlyArray<string>,
84
- projectRoot: string,
85
- ): string {
86
- const tuples: Array<{ path: string; contentTag: string }> = [];
87
- for (const p of localPaths) {
88
- const abs = isAbsolute(p) ? p : resolve(projectRoot, p);
89
- let contentTag: string;
90
- try {
91
- const lst = lstatSync(abs);
92
- if (lst.isSymbolicLink()) {
93
- contentTag = '<symlink>';
94
- } else if (!lst.isFile()) {
95
- contentTag = '<not-a-file>';
96
- } else {
97
- contentTag = createHash('sha256').update(readFileSync(abs)).digest('hex');
98
- }
99
- } catch {
100
- contentTag = '<missing>';
101
- }
102
- tuples.push({ path: p, contentTag });
103
- }
104
- tuples.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
105
- const canonical = JSON.stringify(tuples);
106
- return createHash('sha256').update(canonical).digest('hex');
107
- }
108
-
109
- const FingerprintSentinelSchema = z.object({
110
- fingerprint: z.string().regex(/^[0-9a-f]{64}$/),
111
- source: z.enum(['cli', 'cli-resync']),
112
- // CR-9 iter-5 audit LOW-NEW5-1 fix: ts is rendered to stderr in the
113
- // drift warning at line 214 (`${sentinel.source} at ${sentinel.ts}`);
114
- // a same-user attacker writing the sentinel file with control chars
115
- // in ts would log-inject. Same vector iter-3 closed for
116
- // InstallEntrySchema.ts; closing the parallel sentinel schema for
117
- // single-source-of-truth consistency.
118
- ts: PrintableAsciiStringSchema,
119
- }).strict();
120
- export type FingerprintSentinel = z.infer<typeof FingerprintSentinelSchema>;
121
-
122
- /**
123
- * Read the on-disk sentinel. Returns null when:
124
- * - file is absent (no operator action has ever been recorded)
125
- * - file is present but unparseable (treat as absent — caller should
126
- * surface a stderr warning about the corrupt sentinel)
127
- * - file's shape doesn't match the strict schema
128
- *
129
- * Caller decides what to do with `null` (typically: refuse all
130
- * LOCAL-EXPLICIT loading until `massu adapters resync-local-fingerprint`
131
- * is run).
132
- */
133
- export function readFingerprintSentinel(path: string = FINGERPRINT_PATH): FingerprintSentinel | null {
134
- if (!existsSync(path)) return null;
135
- let raw: unknown;
136
- try {
137
- raw = JSON.parse(readFileSync(path, 'utf-8'));
138
- } catch {
139
- return null;
140
- }
141
- const parsed = FingerprintSentinelSchema.safeParse(raw);
142
- if (!parsed.success) return null;
143
- return parsed.data;
144
- }
145
-
146
- export type FingerprintWriteResult = { written: true } | { written: false; error: string };
147
-
148
- /**
149
- * Atomically write the sentinel with the given paths' fingerprint + source
150
- * tag. Mode 0o600; parent dir 0o700. Uses the shared atomicWrite primitive
151
- * so torn writes are impossible.
152
- */
153
- export function writeFingerprintSentinel(
154
- localPaths: ReadonlyArray<string>,
155
- source: FingerprintSentinel['source'],
156
- projectRoot: string,
157
- path: string = FINGERPRINT_PATH,
158
- ): FingerprintWriteResult {
159
- const sentinel: FingerprintSentinel = {
160
- fingerprint: computeLocalFingerprint(localPaths, projectRoot),
161
- source,
162
- ts: new Date().toISOString(),
163
- };
164
- const result = atomicWrite(path, JSON.stringify(sentinel, null, 2), {
165
- mode: 0o600,
166
- ensureParentDirMode: 0o700,
167
- });
168
- if (!result.written) {
169
- return { written: false, error: result.error ?? 'unknown atomicWrite error' };
170
- }
171
- return { written: true };
172
- }
173
-
174
- export type FingerprintCheckResult =
175
- | { kind: 'match'; sentinel: FingerprintSentinel }
176
- | { kind: 'no-sentinel'; reason: string }
177
- | { kind: 'drift'; sentinel: FingerprintSentinel; currentFingerprint: string; reason: string };
178
-
179
- /**
180
- * Compare the current adapters.local fingerprint to the on-disk sentinel.
181
- * Caller (typically discoverAdapters) interprets the result:
182
- * - 'match' — proceed to load LOCAL-EXPLICIT adapters
183
- * - 'no-sentinel' — refuse all LOCAL-EXPLICIT; tell operator to run
184
- * `massu adapters resync-local-fingerprint` once
185
- * - 'drift' — refuse all LOCAL-EXPLICIT; surface the additions/
186
- * removals so the operator can audit + ack via
187
- * `massu adapters resync-local-fingerprint`
188
- *
189
- * Note: `localPaths` MUST be the AdapterLocalPathSchema-validated +
190
- * POSIX-normalized content from getConfig() — passing a non-normalized
191
- * array will produce a fingerprint that doesn't match what the CLI
192
- * wrote. Drift-prevention from the schema side: AdapterLocalPathSchema
193
- * runs at config-parse time, so any code path reading
194
- * cfg.adapters.local always sees the canonical form.
195
- */
196
- export function checkFingerprintDrift(
197
- localPaths: ReadonlyArray<string>,
198
- projectRoot: string,
199
- path: string = FINGERPRINT_PATH,
200
- ): FingerprintCheckResult {
201
- const sentinel = readFingerprintSentinel(path);
202
- if (!sentinel) {
203
- return {
204
- kind: 'no-sentinel',
205
- reason:
206
- `no adapters-local-fingerprint sentinel at ${path}. ` +
207
- `If you have entries in adapters.local, run \`massu adapters resync-local-fingerprint\` ` +
208
- `to acknowledge them once.`,
209
- };
210
- }
211
- const currentFingerprint = computeLocalFingerprint(localPaths, projectRoot);
212
- if (currentFingerprint === sentinel.fingerprint) {
213
- return { kind: 'match', sentinel };
214
- }
215
- return {
216
- kind: 'drift',
217
- sentinel,
218
- currentFingerprint,
219
- reason:
220
- `adapters.local fingerprint drift: sentinel was ${sentinel.fingerprint.slice(0, 16)}... ` +
221
- `(written by ${sentinel.source} at ${sentinel.ts}); current is ${currentFingerprint.slice(0, 16)}.... ` +
222
- `If you edited massu.config.yaml directly OR a postinstall script may have mutated adapters.local, ` +
223
- `audit the diff and run \`massu adapters resync-local-fingerprint\` to acknowledge.`,
224
- };
225
- }