@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,200 @@
1
+ /**
2
+ * HTTPS fetch helper with strict host allowlist + bounded timeout.
3
+ *
4
+ * Plan 3c Phase 5 deliverable (gap-3 / Phase 5 deps line 114). The Phase 5
5
+ * adapter-verifier is the only consumer; fetching from arbitrary URLs is
6
+ * forbidden. Allowlist:
7
+ * - registry.massu.ai (manifest fetch)
8
+ * - telemetry.massu.ai (adapter-discovery telemetry)
9
+ *
10
+ * Why an allowlist: an unrestricted fetch primitive in @massu/core would
11
+ * be a supply-chain liability — a future bug or compromised adapter could
12
+ * exfiltrate data to attacker-controlled hosts. The allowlist lives in
13
+ * THIS module's source code (single source of truth), not in
14
+ * massu.config.yaml — operators cannot widen it without auditing this
15
+ * file.
16
+ *
17
+ * Why this module instead of a third-party http lib: Node 18+ has a built-in
18
+ * fetch (per Plan 3c gap-3 audit-fact line 46 — "no deps for `node-fetch`").
19
+ * This module wraps it with the allowlist + timeout + JSON parsing in a
20
+ * single typed surface. The verifier doesn't need redirects, retry, or
21
+ * streaming — those features are attack surface we don't want.
22
+ */
23
+
24
+ const DEFAULT_TIMEOUT_MS = 10_000;
25
+ /**
26
+ * CR-9 audit H3 fix: hard cap on response body size. Without this, a
27
+ * compromised CDN (or TLS-MITM) could serve a multi-GB body that exceeds
28
+ * available heap before the timeout fires. The manifest is on the order
29
+ * of KB; even with 1000 adapter entries it should fit comfortably under
30
+ * 1 MB. The 10 MB ceiling here is generous enough to never affect
31
+ * legitimate manifests AND tight enough to bound damage from a malicious
32
+ * upstream.
33
+ */
34
+ const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
35
+
36
+ /**
37
+ * Allowlist of hosts the fetcher will GET from. Exported for unit testing
38
+ * but intentionally read-only (`as const`) — runtime mutation is impossible.
39
+ * To widen the allowlist for a new use case, edit this constant in source
40
+ * and ship a `@massu/core` minor release.
41
+ */
42
+ export const ALLOWED_HOSTS = [
43
+ 'registry.massu.ai',
44
+ 'telemetry.massu.ai',
45
+ ] as const;
46
+
47
+ export interface FetchUrlOptions {
48
+ /** Maximum time the request may take, in milliseconds. Defaults to 10s. */
49
+ timeoutMs?: number;
50
+ /**
51
+ * Override the allowlist for tests. Production code MUST NOT pass this —
52
+ * the default ALLOWED_HOSTS is the production contract. Test-only.
53
+ */
54
+ allowedHosts?: readonly string[];
55
+ /**
56
+ * Override the response body size cap (test-only). Defaults to 10 MB.
57
+ * CR-9 audit H3 fix: bounds OOM exposure from a malicious upstream.
58
+ */
59
+ maxBodyBytes?: number;
60
+ }
61
+
62
+ export interface FetchUrlResult {
63
+ status: number;
64
+ /** Response body as UTF-8 string. */
65
+ body: string;
66
+ }
67
+
68
+ export class FetchAllowlistError extends Error {
69
+ constructor(public readonly url: string, public readonly host: string) {
70
+ super(
71
+ `Refusing to fetch ${url}: host '${host}' is not in the @massu/core ` +
72
+ `fetcher allowlist. Allowed hosts: ${ALLOWED_HOSTS.join(', ')}. ` +
73
+ `Widening the allowlist requires editing packages/core/src/security/fetcher.ts ` +
74
+ `at the source level.`,
75
+ );
76
+ this.name = 'FetchAllowlistError';
77
+ }
78
+ }
79
+
80
+ export class FetchTimeoutError extends Error {
81
+ constructor(public readonly url: string, public readonly timeoutMs: number) {
82
+ super(`Fetch of ${url} timed out after ${timeoutMs}ms`);
83
+ this.name = 'FetchTimeoutError';
84
+ }
85
+ }
86
+
87
+ export class FetchBodySizeError extends Error {
88
+ constructor(public readonly url: string, public readonly maxBodyBytes: number) {
89
+ super(
90
+ `Fetch of ${url} exceeded body size cap of ${maxBodyBytes} bytes. ` +
91
+ `This indicates either a malicious upstream OR an unexpectedly large ` +
92
+ `legitimate response. Refusing to load.`,
93
+ );
94
+ this.name = 'FetchBodySizeError';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * GET an HTTPS URL with allowlist enforcement + timeout. Returns the response
100
+ * body as a string (caller is responsible for JSON.parse + schema validation).
101
+ *
102
+ * Throws:
103
+ * - FetchAllowlistError if the URL's host is not in ALLOWED_HOSTS (or the
104
+ * test override). Includes the host name in the error so operators can
105
+ * debug misconfigurations.
106
+ * - FetchTimeoutError if the request exceeds the timeout.
107
+ * - TypeError if the URL does not parse as a valid URL.
108
+ * - Error (with .cause set to the underlying network error) for other failures.
109
+ */
110
+ export async function fetchUrl(url: string, opts: FetchUrlOptions = {}): Promise<FetchUrlResult> {
111
+ const allowedHosts = opts.allowedHosts ?? ALLOWED_HOSTS;
112
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
113
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
114
+
115
+ let parsed: URL;
116
+ try {
117
+ parsed = new URL(url);
118
+ } catch (err) {
119
+ throw new TypeError(`fetchUrl: invalid URL: ${url}`);
120
+ }
121
+
122
+ if (parsed.protocol !== 'https:') {
123
+ throw new FetchAllowlistError(url, parsed.host);
124
+ }
125
+
126
+ if (!allowedHosts.includes(parsed.host as (typeof ALLOWED_HOSTS)[number])) {
127
+ throw new FetchAllowlistError(url, parsed.host);
128
+ }
129
+
130
+ const controller = new AbortController();
131
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
132
+
133
+ try {
134
+ const response = await fetch(url, {
135
+ method: 'GET',
136
+ signal: controller.signal,
137
+ redirect: 'error', // refuse redirects — would defeat the allowlist
138
+ });
139
+
140
+ // CR-9 audit H3 fix: stream the body with a hard byte cap. Without this,
141
+ // a malicious upstream could OOM the host with a multi-GB response
142
+ // before the wall-clock timeout fires. content-length header check is
143
+ // a fast pre-filter; the streaming reader is the actual enforcement.
144
+ // Headers can be absent in test mocks; treat missing as "no pre-filter".
145
+ if (response.headers && typeof response.headers.get === 'function') {
146
+ const contentLengthHeader = response.headers.get('content-length');
147
+ if (contentLengthHeader !== null && contentLengthHeader !== undefined) {
148
+ const declared = Number.parseInt(contentLengthHeader, 10);
149
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
150
+ throw new FetchBodySizeError(url, maxBodyBytes);
151
+ }
152
+ }
153
+ }
154
+
155
+ const body = response.body;
156
+ if (body && typeof body.getReader === 'function') {
157
+ // Streaming path: bound the read by maxBodyBytes. Used in production
158
+ // (Node 18+ fetch returns a ReadableStream).
159
+ const reader = body.getReader();
160
+ const chunks: Uint8Array[] = [];
161
+ let received = 0;
162
+ while (true) {
163
+ const { value, done } = await reader.read();
164
+ if (done) break;
165
+ if (value) {
166
+ received += value.byteLength;
167
+ if (received > maxBodyBytes) {
168
+ try { await reader.cancel(); } catch { /* best effort */ }
169
+ throw new FetchBodySizeError(url, maxBodyBytes);
170
+ }
171
+ chunks.push(value);
172
+ }
173
+ }
174
+ const merged = new Uint8Array(received);
175
+ let offset = 0;
176
+ for (const chunk of chunks) {
177
+ merged.set(chunk, offset);
178
+ offset += chunk.byteLength;
179
+ }
180
+ return { status: response.status, body: new TextDecoder().decode(merged) };
181
+ }
182
+
183
+ // Fallback when response.body is not a ReadableStream (e.g. test
184
+ // mocks that only stub `text()`). The size cap is still enforced
185
+ // post-read because the text length is the byte length for ASCII +
186
+ // a strict upper bound for UTF-8 content.
187
+ const text = await response.text();
188
+ if (text.length > maxBodyBytes) {
189
+ throw new FetchBodySizeError(url, maxBodyBytes);
190
+ }
191
+ return { status: response.status, body: text };
192
+ } catch (err) {
193
+ if (err instanceof Error && err.name === 'AbortError') {
194
+ throw new FetchTimeoutError(url, timeoutMs);
195
+ }
196
+ throw err;
197
+ } finally {
198
+ clearTimeout(timeoutHandle);
199
+ }
200
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Install-time + load-time sha256 verification of REGISTRY-VERIFIED adapter
3
+ * package contents (Plan 3c gap-37).
4
+ *
5
+ * Two checks defend against post-install tampering:
6
+ *
7
+ * 1. INSTALL-time: when the operator runs `npx massu adapters install <pkg>`,
8
+ * this module walks the installed package's directory, computes a content-
9
+ * addressed sha256, AND verifies it equals the sha256 the signed registry
10
+ * manifest entry pinned. On match → record in
11
+ * ~/.massu/adapter-manifest-installed.json with the version + timestamp.
12
+ * Mismatch → refuse to register; the operator should `npm uninstall` the
13
+ * suspicious package.
14
+ *
15
+ * 2. LOAD-time: discovery (detect/adapters/discover.ts) re-computes the
16
+ * package's sha256 on every startup, compares to the install-time recorded
17
+ * hash from the sidecar file. Mismatch → REFUSE to load (post-install
18
+ * tampering of unpacked files in node_modules). The load-time check
19
+ * compares to the LOCAL sidecar, NOT the live registry — re-fetching
20
+ * from the registry every startup would be a network dependency on the
21
+ * boot path (per Plan 3c gap-3 deliverable: NO network on boot).
22
+ *
23
+ * Why content-addressed (not tarball sha256):
24
+ * The published tarball's sha256 is a moving target — npm tarballs include
25
+ * timestamps + permission bits that vary across operating systems. The
26
+ * SAME tarball extracted to two different machines produces different
27
+ * tarball hashes but identical FILE CONTENT hashes. Content-addressed
28
+ * hashing (sha256 of canonical-stringified path:fileSha pairs, sorted by
29
+ * path) is stable across machines + filesystems.
30
+ *
31
+ * Per CR-46 / Rule 0 single-source-of-truth: this is the ONLY recursive
32
+ * directory hashing in @massu/core. Future modules that need to hash
33
+ * directories MUST consume sha256OfDir from here, not re-implement.
34
+ */
35
+ import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
36
+ import { join, relative, sep } from 'node:path';
37
+ import { homedir } from 'node:os';
38
+ import { resolve } from 'node:path';
39
+ import { createHash } from 'node:crypto';
40
+ import { z } from 'zod';
41
+ import { atomicWrite } from './atomic-write.js';
42
+
43
+ export const INSTALLED_MANIFEST_PATH = resolve(homedir(), '.massu', 'adapter-manifest-installed.json');
44
+
45
+ /**
46
+ * Compute a content-addressed sha256 of a directory recursively. Stable
47
+ * across machines + filesystems: sorts files by relative POSIX path,
48
+ * hashes each file's bytes, concatenates `<path>\0<sha256-hex>\n` per
49
+ * file, then sha256s the whole concatenation.
50
+ *
51
+ * Symlinks are NOT followed (they're hashed as their target path content).
52
+ * Directory entries (themselves) are NOT hashed (their content is implied
53
+ * by the files inside). Files exceeding `maxFileBytes` (default 64 MB)
54
+ * abort with an error — adapter packages should not ship huge binary
55
+ * blobs; the cap is a sanity ceiling against accidental misuse.
56
+ *
57
+ * Excluded patterns (NEVER hashed; they are install-time artifacts that
58
+ * vary across machines):
59
+ * - .git/
60
+ * - node_modules/ (transitive deps; their integrity is npm's concern)
61
+ * - any path containing /.cache/ or /.tmp/
62
+ */
63
+ const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
64
+ /**
65
+ * Directory names that sha256OfDir EXCLUDES from hashing — these are
66
+ * install-time artifacts that vary across machines (.git history,
67
+ * transitive deps, build caches, scratch dirs). Their CONTENT is not
68
+ * part of the adapter package's content-addressable hash.
69
+ *
70
+ * CR-9 audit M5 + iter-2 audit MED-NEW-1/-2 enforcement: a published
71
+ * npm tarball MUST NOT ship these directories. Any package that does
72
+ * is refused at install + resign + load-time discovery via
73
+ * `containsHiddenDirs()` below. Without these refusals, a malicious
74
+ * tarball could smuggle payload files under `.git/payload.js` (excluded
75
+ * from the hash) and have them require()'d by the legitimate adapter
76
+ * at runtime — hash matches, payload runs.
77
+ */
78
+ export const EXCLUDED_DIR_NAMES: ReadonlySet<string> = new Set(['.git', 'node_modules', '.cache', '.tmp']);
79
+
80
+ /**
81
+ * Returns the first hidden-dir name found in `packageDir`, or null if
82
+ * none are present. Caller (install / resign / discovery) refuses the
83
+ * package on non-null return.
84
+ */
85
+ export function containsHiddenDirs(packageDir: string): string | null {
86
+ for (const hidden of EXCLUDED_DIR_NAMES) {
87
+ if (existsSync(`${packageDir}/${hidden}`)) {
88
+ return hidden;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export interface Sha256OfDirOpts {
95
+ /** Override the file-size cap (test-only). */
96
+ maxFileBytes?: number;
97
+ }
98
+
99
+ export function sha256OfDir(dir: string, opts: Sha256OfDirOpts = {}): string {
100
+ const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
101
+ const files: Array<{ relativePath: string; absPath: string }> = [];
102
+
103
+ function walk(currentDir: string): void {
104
+ let entries: string[];
105
+ try {
106
+ entries = readdirSync(currentDir);
107
+ } catch {
108
+ return;
109
+ }
110
+ for (const entry of entries.sort()) {
111
+ const absPath = join(currentDir, entry);
112
+ let lst;
113
+ try {
114
+ // CR-9 audit H1 fix: lstatSync (not statSync) so symlinks are
115
+ // detected + skipped without following. Following symlinks would
116
+ // expose a read-anywhere primitive (a malicious dist/x.js -> /etc/shadow
117
+ // would have the target's content captured into the hash) AND would
118
+ // cause readFileSync to block on named-pipe targets.
119
+ lst = lstatSync(absPath);
120
+ } catch {
121
+ continue;
122
+ }
123
+ if (lst.isSymbolicLink()) {
124
+ // Skip symlinks entirely — they're not part of the package's
125
+ // content-addressable hash. A package author who legitimately wants
126
+ // to ship a symlink (rare) must replace it with a real file at
127
+ // publish time.
128
+ continue;
129
+ }
130
+ if (lst.isDirectory()) {
131
+ if (EXCLUDED_DIR_NAMES.has(entry)) continue;
132
+ walk(absPath);
133
+ continue;
134
+ }
135
+ if (!lst.isFile()) continue;
136
+ if (lst.size > maxFileBytes) {
137
+ throw new Error(
138
+ `sha256OfDir: file ${absPath} exceeds maxFileBytes (${lst.size} > ${maxFileBytes}); ` +
139
+ `adapter packages should not ship files this large.`,
140
+ );
141
+ }
142
+ const rel = relative(dir, absPath).split(sep).join('/');
143
+ files.push({ relativePath: rel, absPath });
144
+ }
145
+ }
146
+ walk(dir);
147
+
148
+ // Sort by relative POSIX path so the hash is stable regardless of
149
+ // readdir order (some filesystems return entries in inode order).
150
+ files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0));
151
+
152
+ const top = createHash('sha256');
153
+ for (const f of files) {
154
+ const fileHash = createHash('sha256').update(readFileSync(f.absPath)).digest('hex');
155
+ top.update(f.relativePath, 'utf-8');
156
+ top.update('\0', 'utf-8');
157
+ top.update(fileHash, 'utf-8');
158
+ top.update('\n', 'utf-8');
159
+ }
160
+ return top.digest('hex');
161
+ }
162
+
163
+ /**
164
+ * Per-package install record. Keyed by package name in the sidecar's
165
+ * top-level object.
166
+ */
167
+ const InstallEntrySchema = z.object({
168
+ // CR-9 iter-3 audit LOW-NEW3-1 fix: reject control characters in version.
169
+ // Without this, a same-user attacker writing the install-tracking
170
+ // sidecar could embed ANSI escapes (or other control chars) in version,
171
+ // log-injecting via runAdaptersResign's `${name}@${entry.version}`
172
+ // stderr emits. The regex permits printable ASCII (0x20-0x7e) plus
173
+ // common semver characters; control chars (0x00-0x1f, 0x7f) are
174
+ // rejected at parse time. Schema-level validation closes the vector
175
+ // at every callsite that reads the sidecar — no per-callsite escaping
176
+ // gymnastics needed.
177
+ version: z.string().min(1).regex(/^[\x20-\x7e]+$/, 'version must be printable ASCII (no control characters)'),
178
+ installed_sha256: z.string().regex(/^[0-9a-f]{64}$/),
179
+ manifest_sha256: z.string().regex(/^[0-9a-f]{64}$/),
180
+ ts: z.string().min(1).regex(/^[\x20-\x7e]+$/, 'ts must be printable ASCII (no control characters)'),
181
+ }).strict();
182
+ export type InstallEntry = z.infer<typeof InstallEntrySchema>;
183
+
184
+ const InstalledManifestSchema = z.record(z.string(), InstallEntrySchema);
185
+ export type InstalledManifest = z.infer<typeof InstalledManifestSchema>;
186
+
187
+ /**
188
+ * Read the install-tracking sidecar at ~/.massu/adapter-manifest-installed.json.
189
+ * Returns an empty object when the file is absent OR fails parse / schema —
190
+ * caller should treat absent + corrupt the same way (no install records, so
191
+ * REGISTRY-VERIFIED adapters cannot satisfy the load-time check + are refused
192
+ * until reinstalled via `npx massu adapters install`).
193
+ */
194
+ export function readInstalledManifest(path: string = INSTALLED_MANIFEST_PATH): InstalledManifest {
195
+ if (!existsSync(path)) return {};
196
+ let raw: unknown;
197
+ try {
198
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
199
+ } catch {
200
+ return {};
201
+ }
202
+ const parsed = InstalledManifestSchema.safeParse(raw);
203
+ return parsed.success ? parsed.data : {};
204
+ }
205
+
206
+ export type InstallTrackingWriteResult = { written: true } | { written: false; error: string };
207
+
208
+ /**
209
+ * Write a single package's install entry to the sidecar (additive). Reads
210
+ * the current sidecar, updates the named key, writes back atomically with
211
+ * mode 0o600. Concurrent calls to this function for DIFFERENT package
212
+ * names are NOT serialized — the manifest cache lock at
213
+ * ~/.massu/.adapter-manifest.lock applies only to the cache, not this
214
+ * sidecar. Since the install flow runs interactively (one CLI invocation
215
+ * at a time per shell), racing writes are not a practical concern.
216
+ */
217
+ export function writeInstalledManifestEntry(
218
+ packageName: string,
219
+ entry: InstallEntry,
220
+ path: string = INSTALLED_MANIFEST_PATH,
221
+ ): InstallTrackingWriteResult {
222
+ // CR-9 iter-4 audit LOW-NEW4-1 fix: validate entry against the schema
223
+ // BEFORE writing. TypeScript types do NOT enforce Zod regex constraints
224
+ // at runtime; without this .parse(), a caller passing a control-char-
225
+ // contaminated entry (e.g. version from an attacker-controlled
226
+ // node_modules package.json) would write the malformed entry to disk,
227
+ // and the corresponding stderr emit at the install/resign callsite
228
+ // would log-inject before the next read-time validation could catch it.
229
+ const validated = InstallEntrySchema.safeParse(entry);
230
+ if (!validated.success) {
231
+ const issues = validated.error.issues
232
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
233
+ .join('; ');
234
+ return { written: false, error: `install-tracking entry shape invalid: ${issues}` };
235
+ }
236
+ const current = readInstalledManifest(path);
237
+ current[packageName] = validated.data;
238
+ const result = atomicWrite(path, JSON.stringify(current, null, 2), {
239
+ mode: 0o600,
240
+ ensureParentDirMode: 0o700,
241
+ });
242
+ if (!result.written) {
243
+ return { written: false, error: result.error ?? 'unknown atomicWrite error' };
244
+ }
245
+ return { written: true };
246
+ }
247
+
248
+ /**
249
+ * Remove a single package's install entry from the sidecar. Used by
250
+ * `npx massu adapters resign --uninstall` (or by future cleanup tooling).
251
+ * No-op if the entry is absent; sidecar file persists.
252
+ */
253
+ export function removeInstalledManifestEntry(
254
+ packageName: string,
255
+ path: string = INSTALLED_MANIFEST_PATH,
256
+ ): InstallTrackingWriteResult {
257
+ const current = readInstalledManifest(path);
258
+ if (!(packageName in current)) {
259
+ return { written: true };
260
+ }
261
+ delete current[packageName];
262
+ const result = atomicWrite(path, JSON.stringify(current, null, 2), {
263
+ mode: 0o600,
264
+ ensureParentDirMode: 0o700,
265
+ });
266
+ if (!result.written) {
267
+ return { written: false, error: result.error ?? 'unknown atomicWrite error' };
268
+ }
269
+ return { written: true };
270
+ }
271
+
272
+ export type IntegrityCheckResult =
273
+ | { kind: 'ok'; entry: InstallEntry }
274
+ | { kind: 'no-entry'; reason: string }
275
+ | { kind: 'drift'; entry: InstallEntry; computedSha: string; reason: string };
276
+
277
+ /**
278
+ * Load-time integrity check for an installed REGISTRY-VERIFIED adapter
279
+ * package. Compares the package directory's CURRENT sha256OfDir output
280
+ * to the install-time hash recorded in the sidecar. Mismatch → drift,
281
+ * caller refuses to load.
282
+ *
283
+ * Caller (typically discoverAdapters) interprets:
284
+ * - 'ok' → load is safe
285
+ * - 'no-entry' → package is in node_modules but never registered via
286
+ * `massu adapters install`; refuse + tell operator to run install
287
+ * - 'drift' → contents tampered after install; REFUSE; tell operator
288
+ * to `npm uninstall` + `npm install` + `massu adapters install`
289
+ */
290
+ export function verifyInstalledIntegrity(
291
+ packageName: string,
292
+ packageDir: string,
293
+ sidecarPath: string = INSTALLED_MANIFEST_PATH,
294
+ ): IntegrityCheckResult {
295
+ const installed = readInstalledManifest(sidecarPath);
296
+ const entry = installed[packageName];
297
+ if (!entry) {
298
+ return {
299
+ kind: 'no-entry',
300
+ reason:
301
+ `${packageName} is in node_modules but has no install-tracking entry in ~/.massu/adapter-manifest-installed.json. ` +
302
+ `Run \`npx massu adapters install ${packageName}\` to register it.`,
303
+ };
304
+ }
305
+ const computedSha = sha256OfDir(packageDir);
306
+ if (computedSha !== entry.installed_sha256) {
307
+ return {
308
+ kind: 'drift',
309
+ entry,
310
+ computedSha,
311
+ reason:
312
+ `${packageName}@${entry.version} contents changed after install: ` +
313
+ `expected sha256 ${entry.installed_sha256.slice(0, 16)}..., got ${computedSha.slice(0, 16)}.... ` +
314
+ `This indicates post-install tampering of the package files. Recover by running ` +
315
+ `\`npm uninstall ${packageName} && npm install ${packageName} && npx massu adapters install ${packageName}\`.`,
316
+ };
317
+ }
318
+ return { kind: 'ok', entry };
319
+ }