@massu/core 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +9431 -5167
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2952 -2740
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +151 -2
- package/src/config.ts +63 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/framework-detector.ts +26 -0
- package/src/detect/manifest-registry.ts +261 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/detect/package-detector.ts +162 -62
- package/src/detect/source-dir-detector.ts +7 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/templates/aspnet/massu.config.yaml +61 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- package/templates/spring/massu.config.yaml +56 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter discovery — Plan 3c Phase 5 5H deliverable.
|
|
3
|
+
*
|
|
4
|
+
* Scans three source classes per the three-class trust model
|
|
5
|
+
* (CORE-BUNDLED + REGISTRY-VERIFIED + LOCAL-EXPLICIT, see security/
|
|
6
|
+
* adapter-origin.ts), classifies each candidate, and returns the
|
|
7
|
+
* deduplicated AdapterDescriptor[] the CLI + loader consume.
|
|
8
|
+
*
|
|
9
|
+
* Three sources scanned:
|
|
10
|
+
* 1. CORE-BUNDLED: caller-provided id set (typically built from a static
|
|
11
|
+
* list of bundled adapter filenames in @massu/core itself). The
|
|
12
|
+
* discovery module does NOT enumerate the filesystem for these — that
|
|
13
|
+
* enumeration is done at @massu/core build time and shipped as a
|
|
14
|
+
* constant. Discovery just classifies the ids.
|
|
15
|
+
* 2. REGISTRY-VERIFIED: walk node_modules/@massu/adapter-* directories
|
|
16
|
+
* + any node_modules/<pkg>/ where package.json declares
|
|
17
|
+
* "massu-adapter": true. Cross-reference each candidate against the
|
|
18
|
+
* cached registry manifest's adapters[] list — only entries that
|
|
19
|
+
* appear in the manifest are accepted (CR-46 Rule 0 single-source-of-
|
|
20
|
+
* truth: the registry manifest IS the authoritative allowlist).
|
|
21
|
+
* 3. LOCAL-EXPLICIT: read getConfig().adapters?.local entries. Each entry
|
|
22
|
+
* is a POSIX-normalized relative path (already validated +
|
|
23
|
+
* normalized at config-parse time per AdapterLocalPathSchema in
|
|
24
|
+
* config.ts). Discovery resolves the path relative to the project
|
|
25
|
+
* root and confirms the file exists.
|
|
26
|
+
*
|
|
27
|
+
* The discovery surface returns warnings (not errors) for candidate-
|
|
28
|
+
* classification refusals — a malformed package.json or a missing local
|
|
29
|
+
* file does NOT abort the whole scan; those candidates are simply not
|
|
30
|
+
* loaded, and the warning is surfaced to the CLI for operator awareness.
|
|
31
|
+
*
|
|
32
|
+
* What this module does NOT do (deferred to follow-up commits, all
|
|
33
|
+
* in-flight Phase 5 deliverables):
|
|
34
|
+
* - Install-time + load-time sha256 of installed adapter package
|
|
35
|
+
* contents (gap-37 install-tracking + tarball verification). This
|
|
36
|
+
* requires sha256 of the package's dist/ directory recursively + the
|
|
37
|
+
* ~/.massu/adapter-manifest-installed.json sidecar file.
|
|
38
|
+
* - adapters.local fingerprint check (gap-32 postinstall-poisoning).
|
|
39
|
+
* This requires a sha256 of the canonical adapters.local entry list
|
|
40
|
+
* stored in ~/.massu/adapters-local-fingerprint.json.
|
|
41
|
+
* - User-installed adapter scan at ~/.massu/adapters/ (CLI install
|
|
42
|
+
* path). Populated by `massu adapters install`, also Phase 5
|
|
43
|
+
* follow-up.
|
|
44
|
+
*
|
|
45
|
+
* Loading the actual adapter code (importing the JS module + invoking
|
|
46
|
+
* detect/extract) is the loader's job (Plan 3b runner.ts). Discovery
|
|
47
|
+
* just enumerates + classifies.
|
|
48
|
+
*/
|
|
49
|
+
import { existsSync, readdirSync, readFileSync, lstatSync } from 'node:fs';
|
|
50
|
+
import { resolve, isAbsolute } from 'node:path';
|
|
51
|
+
import { z } from 'zod';
|
|
52
|
+
import {
|
|
53
|
+
getAdapterOrigin,
|
|
54
|
+
type AdapterDescriptor,
|
|
55
|
+
type AdapterOriginInput,
|
|
56
|
+
} from '../../security/adapter-origin.js';
|
|
57
|
+
import { PrintableAsciiStringSchema, type Envelope, type AdapterEntry } from '../../security/manifest-schema.js';
|
|
58
|
+
import { checkFingerprintDrift, FINGERPRINT_PATH } from '../../security/local-fingerprint.js';
|
|
59
|
+
import {
|
|
60
|
+
verifyInstalledIntegrity,
|
|
61
|
+
containsHiddenDirs,
|
|
62
|
+
INSTALLED_MANIFEST_PATH,
|
|
63
|
+
} from '../../security/install-tracking.js';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Minimal shape of a node_modules package.json that we care about for
|
|
67
|
+
* adapter discovery. Strict enough to reject malformed packages at parse
|
|
68
|
+
* time, loose enough (passthrough) to ignore unrelated keys.
|
|
69
|
+
*/
|
|
70
|
+
const AdapterPackageJsonSchema = z.object({
|
|
71
|
+
// CR-9 iter-4 audit LOW-NEW4-2 fix: name/version are rendered in stderr
|
|
72
|
+
// warnings via `${pkg.name}@${pkg.version}` strings; without the
|
|
73
|
+
// printable-ASCII regex, a malicious local node_modules/<pkg>/package.json
|
|
74
|
+
// could embed ANSI escapes to log-inject. Postinstall scripts have the
|
|
75
|
+
// write access needed to mount this attack.
|
|
76
|
+
name: PrintableAsciiStringSchema,
|
|
77
|
+
version: PrintableAsciiStringSchema,
|
|
78
|
+
// Plan 3c gap-31 + gap-50: `"massu-adapter": true` is the explicit opt-in
|
|
79
|
+
// marker. @massu/adapter-* packages also declare this.
|
|
80
|
+
'massu-adapter': z.union([z.boolean(), z.literal(undefined)]).optional(),
|
|
81
|
+
// Plan 3c gap-31: api version. Loader refuses incompatible major (caller-side).
|
|
82
|
+
'massu-adapter-api-version': z.union([z.string(), z.number(), z.literal(undefined)]).optional(),
|
|
83
|
+
}).passthrough();
|
|
84
|
+
|
|
85
|
+
export interface DiscoverOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Absolute path to the project root where node_modules/ lives. Caller
|
|
88
|
+
* passes from getProjectRoot() or equivalent.
|
|
89
|
+
*/
|
|
90
|
+
projectRoot: string;
|
|
91
|
+
/**
|
|
92
|
+
* Set of adapter ids that are CORE-BUNDLED in @massu/core itself.
|
|
93
|
+
* Built from a static const at @massu/core build time. Pass an empty
|
|
94
|
+
* set in tests when not exercising CORE-BUNDLED classification.
|
|
95
|
+
*/
|
|
96
|
+
coreBundledIds: ReadonlySet<string>;
|
|
97
|
+
/**
|
|
98
|
+
* The verified registry manifest envelope (from manifest-cache.getManifest).
|
|
99
|
+
* Discovery uses envelope.manifest.adapters[] as the REGISTRY-VERIFIED
|
|
100
|
+
* allowlist. Pass undefined if running offline + cache absent — discovery
|
|
101
|
+
* will then refuse all REGISTRY-VERIFIED candidates with a clear warning.
|
|
102
|
+
*/
|
|
103
|
+
manifestEnvelope: Envelope | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* POSIX-normalized relative paths from getConfig().adapters?.local.
|
|
106
|
+
* Each entry must already pass AdapterLocalPathSchema (config.ts). Pass
|
|
107
|
+
* empty array if no local adapters configured.
|
|
108
|
+
*/
|
|
109
|
+
configLocalPaths: ReadonlyArray<string>;
|
|
110
|
+
/**
|
|
111
|
+
* Plan 3c gap-1 kill switch (CR-9 audit C1 fix). When false, ONLY
|
|
112
|
+
* CORE-BUNDLED adapters are emitted; REGISTRY-VERIFIED + LOCAL-EXPLICIT
|
|
113
|
+
* scans short-circuit immediately. Source-of-truth is
|
|
114
|
+
* `getConfig().adapters?.enabled === true`. Defaults to false at the
|
|
115
|
+
* config schema layer so operators MUST opt-in to third-party adapter
|
|
116
|
+
* loading — security-critical. Pass the flag through from
|
|
117
|
+
* commands/adapters.ts:runAdaptersList; a missing argument here would
|
|
118
|
+
* silently default to enabled, defeating the kill switch.
|
|
119
|
+
*/
|
|
120
|
+
adaptersEnabled: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Override the on-disk fingerprint sentinel path for testing
|
|
123
|
+
* (gap-32 postinstall-poisoning check). Production callsite uses
|
|
124
|
+
* the default `~/.massu/adapters-local-fingerprint.json`.
|
|
125
|
+
*/
|
|
126
|
+
fingerprintSentinelPath?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Override the on-disk install-tracking sidecar path for testing
|
|
129
|
+
* (gap-37 install-time + load-time sha256 check). Production callsite
|
|
130
|
+
* uses the default `~/.massu/adapter-manifest-installed.json`.
|
|
131
|
+
*/
|
|
132
|
+
installedManifestPath?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Skip the gap-37 load-time sha256 integrity check. Default: false
|
|
135
|
+
* (always check). Setting this to `true` is a test seam ONLY — it
|
|
136
|
+
* exists so unit tests that don't materialize real package directories
|
|
137
|
+
* can still exercise the classification logic. Production callsites
|
|
138
|
+
* MUST NOT pass `true`; per CR-46 this is the most-robust posture.
|
|
139
|
+
*/
|
|
140
|
+
skipInstalledIntegrityCheck?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface DiscoveryResult {
|
|
144
|
+
adapters: AdapterDescriptor[];
|
|
145
|
+
warnings: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Walk node_modules for @massu/adapter-* directories AND any other package
|
|
150
|
+
* declaring "massu-adapter": true. Returns the parsed package.json and
|
|
151
|
+
* absolute package directory for each candidate. Skips malformed packages
|
|
152
|
+
* with a warning.
|
|
153
|
+
*
|
|
154
|
+
* Walks ONLY one level of node_modules — does NOT descend into nested
|
|
155
|
+
* node_modules (transitive deps' adapter packages). Adapters are operator-
|
|
156
|
+
* installed top-level dependencies, not transitive.
|
|
157
|
+
*/
|
|
158
|
+
function walkNodeModules(projectRoot: string, warnings: string[]): Array<{
|
|
159
|
+
packageDir: string;
|
|
160
|
+
pkg: z.infer<typeof AdapterPackageJsonSchema>;
|
|
161
|
+
}> {
|
|
162
|
+
const nodeModulesDir = resolve(projectRoot, 'node_modules');
|
|
163
|
+
if (!existsSync(nodeModulesDir)) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
const candidates: Array<{ packageDir: string; pkg: z.infer<typeof AdapterPackageJsonSchema> }> = [];
|
|
167
|
+
let topLevelEntries: string[];
|
|
168
|
+
try {
|
|
169
|
+
topLevelEntries = readdirSync(nodeModulesDir);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
warnings.push(`failed to read node_modules: ${err instanceof Error ? err.message : String(err)}`);
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const entry of topLevelEntries) {
|
|
176
|
+
if (entry.startsWith('.')) continue;
|
|
177
|
+
const entryPath = resolve(nodeModulesDir, entry);
|
|
178
|
+
let entryStat;
|
|
179
|
+
try {
|
|
180
|
+
// CR-9 audit HIGH-NEW-1 fix: lstatSync (not statSync). statSync
|
|
181
|
+
// followed symlinks, so a malicious package shipping
|
|
182
|
+
// `node_modules/evil-link -> /elsewhere` would walk into /elsewhere.
|
|
183
|
+
// The same fix was applied in install-tracking.ts (audit H1) but
|
|
184
|
+
// discover's node_modules walk was missed at the time.
|
|
185
|
+
entryStat = lstatSync(entryPath);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (entryStat.isSymbolicLink()) {
|
|
190
|
+
warnings.push(
|
|
191
|
+
`skipping ${entryPath}: top-level node_modules entry is a symlink. ` +
|
|
192
|
+
`Adapter packages must be real directories (npm-installed); refusing to walk symlinks ` +
|
|
193
|
+
`as a defense against postinstall scripts that link to attacker-controlled paths.`,
|
|
194
|
+
);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!entryStat.isDirectory()) continue;
|
|
198
|
+
|
|
199
|
+
if (entry.startsWith('@')) {
|
|
200
|
+
// Scoped namespace — e.g., node_modules/@massu/. Walk one more level.
|
|
201
|
+
let scopedEntries: string[];
|
|
202
|
+
try {
|
|
203
|
+
scopedEntries = readdirSync(entryPath);
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
for (const sub of scopedEntries) {
|
|
208
|
+
const subPath = resolve(entryPath, sub);
|
|
209
|
+
// Same lstatSync discipline at the inner level — a malicious
|
|
210
|
+
// postinstall could plant `node_modules/@massu/evil-link -> ...`.
|
|
211
|
+
let subStat;
|
|
212
|
+
try {
|
|
213
|
+
subStat = lstatSync(subPath);
|
|
214
|
+
} catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (subStat.isSymbolicLink() || !subStat.isDirectory()) continue;
|
|
218
|
+
const result = tryReadAdapterPackage(subPath, warnings);
|
|
219
|
+
if (result) candidates.push(result);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
const result = tryReadAdapterPackage(entryPath, warnings);
|
|
223
|
+
if (result) candidates.push(result);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return candidates;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function tryReadAdapterPackage(packageDir: string, warnings: string[]): {
|
|
230
|
+
packageDir: string;
|
|
231
|
+
pkg: z.infer<typeof AdapterPackageJsonSchema>;
|
|
232
|
+
} | null {
|
|
233
|
+
const pkgJsonPath = resolve(packageDir, 'package.json');
|
|
234
|
+
if (!existsSync(pkgJsonPath)) return null;
|
|
235
|
+
let raw: unknown;
|
|
236
|
+
try {
|
|
237
|
+
raw = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
warnings.push(
|
|
240
|
+
`skipping ${packageDir}: package.json parse failed (${err instanceof Error ? err.message : String(err)})`,
|
|
241
|
+
);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const parsed = AdapterPackageJsonSchema.safeParse(raw);
|
|
245
|
+
if (!parsed.success) {
|
|
246
|
+
warnings.push(`skipping ${packageDir}: package.json shape invalid`);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const pkg = parsed.data;
|
|
250
|
+
|
|
251
|
+
// Filter: only consider candidates that EITHER match @massu/adapter-*
|
|
252
|
+
// glob OR declare "massu-adapter": true. Other npm packages are ignored
|
|
253
|
+
// (vast majority of node_modules entries).
|
|
254
|
+
const isMassuAdapterGlob = /^@massu\/adapter-[a-z][a-z0-9-]*$/.test(pkg.name);
|
|
255
|
+
const declaresMassuAdapter = pkg['massu-adapter'] === true;
|
|
256
|
+
if (!isMassuAdapterGlob && !declaresMassuAdapter) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return { packageDir, pkg };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run discovery across all three trust classes. Returns the combined
|
|
264
|
+
* descriptor list + a list of human-readable warnings the CLI prints.
|
|
265
|
+
*/
|
|
266
|
+
export function discoverAdapters(opts: DiscoverOptions): DiscoveryResult {
|
|
267
|
+
const warnings: string[] = [];
|
|
268
|
+
const adapters: AdapterDescriptor[] = [];
|
|
269
|
+
const seenIds = new Set<string>();
|
|
270
|
+
|
|
271
|
+
// 1. CORE-BUNDLED — pass-through. Each id in coreBundledIds becomes a
|
|
272
|
+
// descriptor with origin='core-bundled'. No verification needed (trust
|
|
273
|
+
// derives from @massu/core itself).
|
|
274
|
+
for (const id of opts.coreBundledIds) {
|
|
275
|
+
const origin = getAdapterOrigin({ id, coreBundledIds: opts.coreBundledIds });
|
|
276
|
+
if (origin !== 'core-bundled') {
|
|
277
|
+
// This shouldn't happen — coreBundledIds + getAdapterOrigin is a
|
|
278
|
+
// closed loop. If it does, it's a programmer bug worth surfacing.
|
|
279
|
+
warnings.push(`expected core-bundled classification for id=${id}, got ${origin ?? 'null'}`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
adapters.push({ id, origin: 'core-bundled' });
|
|
283
|
+
seenIds.add(id);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Plan 3c gap-1 kill switch (CR-9 audit C1): when adapters.enabled=false,
|
|
287
|
+
// ONLY CORE-BUNDLED adapters load. Skip REGISTRY-VERIFIED + LOCAL-EXPLICIT
|
|
288
|
+
// entirely. Operators rely on this kill switch documented in SECURITY.md;
|
|
289
|
+
// bypassing it would silently load third-party code in violation of the
|
|
290
|
+
// documented contract.
|
|
291
|
+
if (!opts.adaptersEnabled) {
|
|
292
|
+
if (opts.configLocalPaths.length > 0) {
|
|
293
|
+
warnings.push(
|
|
294
|
+
`adapters.enabled=false — refusing all REGISTRY-VERIFIED + LOCAL-EXPLICIT adapters. ` +
|
|
295
|
+
`Set massu.config.yaml > adapters.enabled: true to opt in.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return { adapters, warnings };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 2. REGISTRY-VERIFIED — walk node_modules. For each candidate, look
|
|
302
|
+
// up the manifest entry; refuse if not in the allowlist.
|
|
303
|
+
const manifestEntries = opts.manifestEnvelope?.manifest.adapters ?? [];
|
|
304
|
+
const manifestByName = new Map<string, AdapterEntry>(
|
|
305
|
+
manifestEntries.map((e) => [e.package, e]),
|
|
306
|
+
);
|
|
307
|
+
const npmCandidates = walkNodeModules(opts.projectRoot, warnings);
|
|
308
|
+
for (const { packageDir, pkg } of npmCandidates) {
|
|
309
|
+
if (seenIds.has(pkg.name)) continue;
|
|
310
|
+
const manifestEntry = manifestByName.get(pkg.name);
|
|
311
|
+
if (!manifestEntry) {
|
|
312
|
+
if (!opts.manifestEnvelope) {
|
|
313
|
+
warnings.push(
|
|
314
|
+
`cannot verify ${pkg.name}@${pkg.version}: registry manifest unavailable. ` +
|
|
315
|
+
`Refusing to load. Run \`massu adapters refresh\` when online.`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
warnings.push(
|
|
319
|
+
`refusing ${pkg.name}@${pkg.version}: not in the signed registry manifest. ` +
|
|
320
|
+
`If you authored this adapter, submit a PR to the registry per AUTHORING-ADAPTERS.md.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (manifestEntry.unpublished === true) {
|
|
326
|
+
warnings.push(
|
|
327
|
+
`refusing ${pkg.name}@${pkg.version}: registry marks this package as unpublished. ` +
|
|
328
|
+
`Remove via: npm uninstall ${pkg.name}`,
|
|
329
|
+
);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (manifestEntry.deprecated) {
|
|
333
|
+
warnings.push(
|
|
334
|
+
`${pkg.name}@${pkg.version} is deprecated since ${manifestEntry.deprecated.since}: ` +
|
|
335
|
+
`${manifestEntry.deprecated.reason}. Replacement: ${manifestEntry.deprecated.replacement ?? '(none listed)'}.`,
|
|
336
|
+
);
|
|
337
|
+
// Adapter still loads despite deprecation — gap-57.
|
|
338
|
+
}
|
|
339
|
+
if (manifestEntry.version !== pkg.version) {
|
|
340
|
+
warnings.push(
|
|
341
|
+
`${pkg.name}@${pkg.version} version mismatch with manifest entry ${manifestEntry.version}. ` +
|
|
342
|
+
`Loading the installed version; the gap-37 sha256 integrity check below will catch tampering.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// gap-37 LOAD-time integrity check: re-compute sha256OfDir on the
|
|
347
|
+
// installed package and compare to the install-time hash recorded in
|
|
348
|
+
// ~/.massu/adapter-manifest-installed.json. Missing sidecar entry →
|
|
349
|
+
// refuse (operator must run `massu adapters install <pkg>`); drift →
|
|
350
|
+
// refuse (post-install tampering). Caller can suppress for tests via
|
|
351
|
+
// skipInstalledIntegrityCheck=true.
|
|
352
|
+
// iter-2 MED-NEW-2 fix: load-time hidden-dir refusal. A postinstall
|
|
353
|
+
// script that adds `.git/payload.js` to a registered package does NOT
|
|
354
|
+
// change the load-time hash (sha256OfDir excludes hidden dirs), so
|
|
355
|
+
// the integrity check passes — but the legitimate adapter could
|
|
356
|
+
// require() the smuggled payload at runtime. Refusing at load time
|
|
357
|
+
// closes the gap.
|
|
358
|
+
if (!opts.skipInstalledIntegrityCheck) {
|
|
359
|
+
const hiddenDir = containsHiddenDirs(packageDir);
|
|
360
|
+
if (hiddenDir !== null) {
|
|
361
|
+
warnings.push(
|
|
362
|
+
`refusing ${pkg.name}@${pkg.version}: contains '${hiddenDir}' subdirectory. ` +
|
|
363
|
+
`Published adapter packages must not ship hidden directories. ` +
|
|
364
|
+
`Recover via \`npm uninstall ${pkg.name} && npm install ${pkg.name} && ` +
|
|
365
|
+
`massu adapters install ${pkg.name}\`.`,
|
|
366
|
+
);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const integrity = verifyInstalledIntegrity(
|
|
370
|
+
pkg.name,
|
|
371
|
+
packageDir,
|
|
372
|
+
opts.installedManifestPath ?? INSTALLED_MANIFEST_PATH,
|
|
373
|
+
);
|
|
374
|
+
if (integrity.kind !== 'ok') {
|
|
375
|
+
warnings.push(`refusing ${pkg.name}@${pkg.version}: ${integrity.reason}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
// CR-9 audit M4 fix: the sidecar can be tampered post-write (mode
|
|
379
|
+
// 0o600 stops other users but NOT same-user processes running
|
|
380
|
+
// as the operator). Cross-check the install-time hash recorded in
|
|
381
|
+
// the sidecar against the SIGNATURE-VERIFIED manifest entry's
|
|
382
|
+
// sha256. If they diverge, the sidecar was modified after install
|
|
383
|
+
// — refuse to load. This closes the "attacker writes both the
|
|
384
|
+
// package AND the sidecar" gap.
|
|
385
|
+
if (integrity.entry.installed_sha256 !== manifestEntry.sha256) {
|
|
386
|
+
warnings.push(
|
|
387
|
+
`refusing ${pkg.name}@${pkg.version}: install-tracking sidecar's installed_sha256 ` +
|
|
388
|
+
`(${integrity.entry.installed_sha256.slice(0, 16)}...) does not match the signed ` +
|
|
389
|
+
`manifest entry's sha256 (${manifestEntry.sha256.slice(0, 16)}...). The sidecar ` +
|
|
390
|
+
`appears tampered post-install. Recover via \`npm uninstall ${pkg.name} && ` +
|
|
391
|
+
`npm install ${pkg.name} && massu adapters install ${pkg.name}\`.`,
|
|
392
|
+
);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const origin = getAdapterOrigin({
|
|
398
|
+
id: pkg.name,
|
|
399
|
+
coreBundledIds: opts.coreBundledIds,
|
|
400
|
+
npmPackage: { name: pkg.name, version: pkg.version, massuAdapter: pkg['massu-adapter'] === true },
|
|
401
|
+
});
|
|
402
|
+
if (origin !== 'registry-verified') {
|
|
403
|
+
warnings.push(`expected registry-verified classification for ${pkg.name}, got ${origin ?? 'null'}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
adapters.push({
|
|
407
|
+
id: pkg.name,
|
|
408
|
+
origin: 'registry-verified',
|
|
409
|
+
version: pkg.version,
|
|
410
|
+
packageDir,
|
|
411
|
+
});
|
|
412
|
+
seenIds.add(pkg.name);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 3. LOCAL-EXPLICIT — read configLocalPaths. Each entry is already POSIX-
|
|
416
|
+
// normalized + path-validated by AdapterLocalPathSchema (config.ts).
|
|
417
|
+
//
|
|
418
|
+
// Plan 3c gap-32 postinstall-poisoning defense: BEFORE classifying any
|
|
419
|
+
// local adapter, check the fingerprint sentinel. If the current
|
|
420
|
+
// adapters.local content's fingerprint does NOT match the last
|
|
421
|
+
// operator-acknowledged sentinel, REFUSE to load any local adapter and
|
|
422
|
+
// surface the drift in warnings. The operator must run
|
|
423
|
+
// `massu adapters resync-local-fingerprint` (or add-local/remove-local)
|
|
424
|
+
// to re-acknowledge before discovery accepts local adapters again.
|
|
425
|
+
const fingerprintCheck = opts.configLocalPaths.length === 0
|
|
426
|
+
? { kind: 'match' as const }
|
|
427
|
+
: checkFingerprintDrift(
|
|
428
|
+
opts.configLocalPaths,
|
|
429
|
+
opts.projectRoot,
|
|
430
|
+
opts.fingerprintSentinelPath ?? FINGERPRINT_PATH,
|
|
431
|
+
);
|
|
432
|
+
if (fingerprintCheck.kind !== 'match') {
|
|
433
|
+
if (opts.configLocalPaths.length > 0) {
|
|
434
|
+
warnings.push(
|
|
435
|
+
`refusing all LOCAL-EXPLICIT adapters: ${fingerprintCheck.reason}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
// Skip the LOCAL-EXPLICIT loop entirely.
|
|
439
|
+
return { adapters, warnings };
|
|
440
|
+
}
|
|
441
|
+
const localSet = new Set(opts.configLocalPaths);
|
|
442
|
+
for (const localPath of opts.configLocalPaths) {
|
|
443
|
+
if (seenIds.has(localPath)) continue;
|
|
444
|
+
const absPath = isAbsolute(localPath) ? localPath : resolve(opts.projectRoot, localPath);
|
|
445
|
+
if (!existsSync(absPath)) {
|
|
446
|
+
warnings.push(
|
|
447
|
+
`local adapter file not found: ${localPath} (resolved to ${absPath}). ` +
|
|
448
|
+
`Remove via: massu adapters remove-local ${localPath}`,
|
|
449
|
+
);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const origin = getAdapterOrigin({
|
|
453
|
+
id: localPath,
|
|
454
|
+
coreBundledIds: opts.coreBundledIds,
|
|
455
|
+
configLocalPaths: localSet,
|
|
456
|
+
});
|
|
457
|
+
if (origin !== 'local-explicit') {
|
|
458
|
+
warnings.push(`expected local-explicit classification for ${localPath}, got ${origin ?? 'null'}`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
adapters.push({
|
|
462
|
+
id: localPath,
|
|
463
|
+
origin: 'local-explicit',
|
|
464
|
+
});
|
|
465
|
+
seenIds.add(localPath);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { adapters, warnings };
|
|
469
|
+
}
|