@massu/core 1.4.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 (60) hide show
  1. package/dist/cli.js +9445 -5483
  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 +2668 -2674
  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 +2 -0
  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/monorepo-detector.ts +1 -0
  40. package/src/hooks/post-tool-use.ts +1 -0
  41. package/src/hooks/session-start.ts +1 -0
  42. package/src/lib/fileLock.ts +203 -0
  43. package/src/lib/installLock.ts +31 -144
  44. package/src/memory-file-ingest.ts +1 -0
  45. package/src/security/adapter-origin.ts +130 -0
  46. package/src/security/adapter-verifier.ts +319 -0
  47. package/src/security/atomic-write.ts +164 -0
  48. package/src/security/fetcher.ts +200 -0
  49. package/src/security/install-tracking.ts +319 -0
  50. package/src/security/local-fingerprint.ts +225 -0
  51. package/src/security/manifest-cache.ts +333 -0
  52. package/src/security/manifest-schema.ts +129 -0
  53. package/src/security/registry-pubkey.generated.ts +35 -0
  54. package/src/security/telemetry.ts +320 -0
  55. package/templates/aspnet/massu.config.yaml +57 -0
  56. package/templates/go-chi/massu.config.yaml +52 -0
  57. package/templates/phoenix/massu.config.yaml +54 -0
  58. package/templates/python-flask/massu.config.yaml +51 -0
  59. package/templates/rails/massu.config.yaml +56 -0
  60. package/templates/spring/massu.config.yaml +56 -0
@@ -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
+ }
@@ -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
+ }