@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,225 @@
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
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Adapter manifest cache (Plan 3c Phase 5 5F.3 — gap-37 + gap-54 + gap-59).
3
+ *
4
+ * Persists the verified registry manifest at `~/.massu/adapter-manifest.json`
5
+ * so subsequent `npx massu adapters *` invocations do NOT re-fetch (and the
6
+ * watcher daemon's auto-refresh-on-quiescence path is fast). The cache is
7
+ * self-validating: every read goes back through verifyManifest against the
8
+ * bundled Ed25519 pubkey, so a tampered cache file refuses to load.
9
+ *
10
+ * Concurrency model (gap-59):
11
+ * - Reader path: NO lock acquired. POSIX renameSync inside atomicWrite
12
+ * guarantees readers see EITHER old OR new content, never torn. Multiple
13
+ * parallel readers (CLI + watcher + coverage) read concurrently with no
14
+ * serialization cost.
15
+ * - Writer path: acquires `~/.massu/.adapter-manifest.lock` via the shared
16
+ * `withFileLockSync` primitive. Two concurrent `refreshManifest` invocations
17
+ * both perform the async fetch in parallel (independent network operations),
18
+ * then serialize on the lock for the brief atomicWrite call. The later
19
+ * write wins; both contents are equally valid (manifest is signed; the
20
+ * later signed-at timestamp is fresher).
21
+ * - Lock is held ONLY for the sync atomicWrite — never around the async
22
+ * fetch — so contention bounded to ~1ms even under heavy load.
23
+ *
24
+ * Rotation drift detection (gap-54):
25
+ * - Cache wrapper records `bundled_pubkey_fingerprint` at write time
26
+ * (sha256 hex of REGISTRY_PUBKEY_ED25519 the writer was bundling).
27
+ * - On read, if the cache's recorded fingerprint != currently-running
28
+ * @massu/core's bundled pubkey fingerprint, mark cache STALE-DUE-TO-
29
+ * ROTATION (separate from staleness-by-age). Caller must force refresh
30
+ * to pick up the new pubkey's signed manifest.
31
+ *
32
+ * Staleness model:
33
+ * - `fetched_at` records the cache write time (NOT the manifest's
34
+ * `signed_at`, which the publisher controls).
35
+ * - `MAX_FRESH_MS` (24h) — cache is considered fresh; reads short-circuit.
36
+ * - `MAX_STALE_MS` (7 days) — cache may be used offline. Caller surfaces
37
+ * the staleness in UX.
38
+ * - Beyond 7 days — refuse to use any new adapter from this cache; require
39
+ * manual `npx massu adapters refresh`.
40
+ *
41
+ * File mode (gap-37): cache file is mode 0o600; ~/.massu/ parent dir is 0o700.
42
+ */
43
+ import { existsSync, readFileSync, statSync } from 'node:fs';
44
+ import { homedir } from 'node:os';
45
+ import { resolve } from 'node:path';
46
+ import { z } from 'zod';
47
+ import { atomicWrite } from './atomic-write.js';
48
+ import { withFileLockSync } from '../lib/fileLock.js';
49
+ import { verifyManifest, type VerifyManifestResult } from './adapter-verifier.js';
50
+ import { EnvelopeSchema, PrintableAsciiStringSchema, type Envelope } from './manifest-schema.js';
51
+ import {
52
+ REGISTRY_PUBKEY_ED25519,
53
+ REGISTRY_PUBKEY_FINGERPRINT_HEX,
54
+ } from './registry-pubkey.generated.js';
55
+ import { fetchUrl } from './fetcher.js';
56
+
57
+ export const MAX_FRESH_MS = 24 * 60 * 60 * 1000; // 24h
58
+ export const MAX_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
59
+ export const REGISTRY_MANIFEST_URL = 'https://registry.massu.ai/adapters/manifest.json';
60
+
61
+ /**
62
+ * Wrapper format persisted to ~/.massu/adapter-manifest.json. The envelope
63
+ * is the verified registry envelope (post-verifyManifest); fetched_at is the
64
+ * client-side timestamp; bundled_pubkey_fingerprint records which @massu/core
65
+ * pubkey signed this cache entry (rotation detection per gap-54).
66
+ */
67
+ const CacheWrapperSchema = z.object({
68
+ envelope: EnvelopeSchema,
69
+ // CR-9 iter-6 audit LOW-NEW6-1 fix: fetched_at flows into the
70
+ // 'fetched_at not parseable as Date' reason render at line 163 → wrapped
71
+ // by getManifest into 'cache invalid: ...' → reaches stderr in
72
+ // commands/adapters.ts. Same control-char log-injection vector iter-3
73
+ // closed for InstallEntrySchema.ts AND iter-5 closed for
74
+ // FingerprintSentinelSchema.ts. Third sibling closure; the new AST
75
+ // drift-guard test (test_security_schemas_printable_ascii_drift.test.ts)
76
+ // makes this class of bug structurally impossible to recur.
77
+ fetched_at: PrintableAsciiStringSchema,
78
+ bundled_pubkey_fingerprint: z.string().regex(/^[0-9a-f]{64}$/),
79
+ }).strict();
80
+ export type CacheWrapper = z.infer<typeof CacheWrapperSchema>;
81
+
82
+ export type CacheReadResult =
83
+ | { kind: 'fresh'; envelope: Envelope; ageMs: number; warnings: string[] }
84
+ | { kind: 'stale'; envelope: Envelope; ageMs: number; warnings: string[]; reason: string }
85
+ | { kind: 'expired'; ageMs: number; reason: string }
86
+ | { kind: 'rotation-detected'; reason: string }
87
+ | { kind: 'absent' }
88
+ | { kind: 'invalid'; reason: string };
89
+
90
+ export interface CachePaths {
91
+ cachePath: string;
92
+ lockPath: string;
93
+ }
94
+
95
+ /**
96
+ * Compute the canonical cache + lock paths under the user's home directory.
97
+ * Exported so tests can reuse the same logic with a sandboxed home.
98
+ */
99
+ export function defaultCachePaths(): CachePaths {
100
+ const dir = resolve(homedir(), '.massu');
101
+ return {
102
+ cachePath: resolve(dir, 'adapter-manifest.json'),
103
+ lockPath: resolve(dir, '.adapter-manifest.lock'),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Read + validate the cached manifest. Returns a tagged result the caller
109
+ * dispatches on:
110
+ * - 'fresh' — cache is valid + signed + age < MAX_FRESH_MS
111
+ * - 'stale' — cache is valid + signed + age < MAX_STALE_MS
112
+ * (caller may use; should surface staleness in UX)
113
+ * - 'expired' — age > MAX_STALE_MS; caller MUST refresh
114
+ * - 'rotation-detected' — bundled_pubkey_fingerprint mismatch; caller MUST
115
+ * refresh under the new bundled pubkey
116
+ * - 'absent' — no cache file
117
+ * - 'invalid' — file exists but failed parse / signature verify;
118
+ * treat as 'absent' but surface reason for logs
119
+ */
120
+ export function loadCachedManifest(paths: CachePaths = defaultCachePaths()): CacheReadResult {
121
+ if (!existsSync(paths.cachePath)) {
122
+ return { kind: 'absent' };
123
+ }
124
+
125
+ let raw: unknown;
126
+ try {
127
+ const content = readFileSync(paths.cachePath, 'utf-8');
128
+ raw = JSON.parse(content);
129
+ } catch (err) {
130
+ return {
131
+ kind: 'invalid',
132
+ reason: `cache JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
133
+ };
134
+ }
135
+
136
+ const wrapperParsed = CacheWrapperSchema.safeParse(raw);
137
+ if (!wrapperParsed.success) {
138
+ const issues = wrapperParsed.error.issues
139
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
140
+ .join('; ');
141
+ return { kind: 'invalid', reason: `cache wrapper shape invalid: ${issues}` };
142
+ }
143
+ const wrapper = wrapperParsed.data;
144
+
145
+ // Rotation detection (gap-54): if the cache's recorded bundled-pubkey
146
+ // fingerprint does NOT match the currently-running @massu/core's pubkey,
147
+ // the cache was signed under a different key and we can't verify it
148
+ // with our current bundle. Caller MUST refresh.
149
+ if (wrapper.bundled_pubkey_fingerprint !== REGISTRY_PUBKEY_FINGERPRINT_HEX) {
150
+ return {
151
+ kind: 'rotation-detected',
152
+ reason:
153
+ `cache was written under @massu/core bundled pubkey ` +
154
+ `${wrapper.bundled_pubkey_fingerprint.slice(0, 16)}... but this @massu/core ` +
155
+ `bundles ${REGISTRY_PUBKEY_FINGERPRINT_HEX.slice(0, 16)}.... Cache must be ` +
156
+ `refreshed under the current bundled pubkey.`,
157
+ };
158
+ }
159
+
160
+ // Verify the envelope via the canonical 9-step verifier. Pure, no I/O.
161
+ const verifyResult: VerifyManifestResult = verifyManifest({
162
+ envelope: wrapper.envelope,
163
+ publicKey: REGISTRY_PUBKEY_ED25519,
164
+ });
165
+ if (!verifyResult.ok) {
166
+ return { kind: 'invalid', reason: `cached envelope failed verify: ${verifyResult.reason}` };
167
+ }
168
+
169
+ const fetchedAtMs = Date.parse(wrapper.fetched_at);
170
+ if (!Number.isFinite(fetchedAtMs)) {
171
+ return { kind: 'invalid', reason: `fetched_at not parseable as Date: ${wrapper.fetched_at}` };
172
+ }
173
+ const ageMs = Date.now() - fetchedAtMs;
174
+ if (ageMs < MAX_FRESH_MS) {
175
+ return { kind: 'fresh', envelope: verifyResult.envelope, ageMs, warnings: verifyResult.warnings };
176
+ }
177
+ if (ageMs < MAX_STALE_MS) {
178
+ return {
179
+ kind: 'stale',
180
+ envelope: verifyResult.envelope,
181
+ ageMs,
182
+ warnings: verifyResult.warnings,
183
+ reason: `cache is ${Math.floor(ageMs / 3600_000)}h old; consider refreshing`,
184
+ };
185
+ }
186
+ return {
187
+ kind: 'expired',
188
+ ageMs,
189
+ reason: `cache is ${Math.floor(ageMs / 86400_000)}d old (> 7d); refusing to use`,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Atomic write of a verified envelope to the cache file under the writer
195
+ * lock. Returns void on success; throws if (a) the lock cannot be acquired
196
+ * within the configured block, or (b) the atomicWrite fails.
197
+ *
198
+ * Caller is responsible for ensuring the envelope is verified BEFORE
199
+ * calling this function — cacheManifest does NOT re-verify (the verify
200
+ * already happens inside refreshManifest before writing).
201
+ */
202
+ export function cacheManifest(envelope: Envelope, paths: CachePaths = defaultCachePaths()): void {
203
+ const wrapper: CacheWrapper = {
204
+ envelope,
205
+ fetched_at: new Date().toISOString(),
206
+ bundled_pubkey_fingerprint: REGISTRY_PUBKEY_FINGERPRINT_HEX,
207
+ };
208
+ withFileLockSync(paths.lockPath, () => {
209
+ const result = atomicWrite(paths.cachePath, JSON.stringify(wrapper, null, 2), {
210
+ mode: 0o600,
211
+ ensureParentDirMode: 0o700,
212
+ });
213
+ if (!result.written) {
214
+ throw new Error(`cacheManifest: atomicWrite failed: ${result.error}`);
215
+ }
216
+ });
217
+ }
218
+
219
+ export type RefreshResult =
220
+ | { kind: 'refreshed'; envelope: Envelope; warnings: string[] }
221
+ | { kind: 'fetch-failed'; reason: string }
222
+ | { kind: 'verify-failed'; reason: string };
223
+
224
+ /**
225
+ * Fetch + verify the live registry manifest, write to cache. Returns a
226
+ * tagged result. The caller decides whether to surface refresh failures
227
+ * as fatal (stale-cache > 7d) or non-fatal (cache fresh but stale-by-age).
228
+ */
229
+ export async function refreshManifest(
230
+ paths: CachePaths = defaultCachePaths(),
231
+ fetchFn: typeof fetchUrl = fetchUrl,
232
+ ): Promise<RefreshResult> {
233
+ let body: string;
234
+ try {
235
+ const response = await fetchFn(REGISTRY_MANIFEST_URL);
236
+ if (response.status !== 200) {
237
+ return { kind: 'fetch-failed', reason: `registry returned HTTP ${response.status}` };
238
+ }
239
+ body = response.body;
240
+ } catch (err) {
241
+ return {
242
+ kind: 'fetch-failed',
243
+ reason: `fetch ${REGISTRY_MANIFEST_URL} failed: ${err instanceof Error ? err.message : String(err)}`,
244
+ };
245
+ }
246
+
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(body);
250
+ } catch (err) {
251
+ return {
252
+ kind: 'fetch-failed',
253
+ reason: `registry response not JSON: ${err instanceof Error ? err.message : String(err)}`,
254
+ };
255
+ }
256
+
257
+ const verified = verifyManifest({ envelope: parsed, publicKey: REGISTRY_PUBKEY_ED25519 });
258
+ if (!verified.ok) {
259
+ return { kind: 'verify-failed', reason: verified.reason };
260
+ }
261
+
262
+ cacheManifest(verified.envelope, paths);
263
+ return { kind: 'refreshed', envelope: verified.envelope, warnings: verified.warnings };
264
+ }
265
+
266
+ /**
267
+ * High-level getter: try cache → if fresh, return. Else refresh + return new
268
+ * envelope. If `force=true`, skip cache entirely. If refresh fails AND a
269
+ * stale cache is available (< 7d), return it with a `staleAcceptedReason`.
270
+ * If everything fails, returns null + error reasons in the result object.
271
+ */
272
+ export interface GetManifestOpts {
273
+ paths?: CachePaths;
274
+ force?: boolean;
275
+ fetchFn?: typeof fetchUrl;
276
+ }
277
+
278
+ export type GetManifestResult =
279
+ | { kind: 'ok'; envelope: Envelope; source: 'cache-fresh' | 'cache-stale' | 'refreshed'; warnings: string[]; staleReason?: string }
280
+ | { kind: 'fail'; reasons: string[] };
281
+
282
+ export async function getManifest(opts: GetManifestOpts = {}): Promise<GetManifestResult> {
283
+ const paths = opts.paths ?? defaultCachePaths();
284
+ const fetchFn = opts.fetchFn ?? fetchUrl;
285
+ const reasons: string[] = [];
286
+
287
+ if (!opts.force) {
288
+ const cacheRead = loadCachedManifest(paths);
289
+ if (cacheRead.kind === 'fresh') {
290
+ return { kind: 'ok', envelope: cacheRead.envelope, source: 'cache-fresh', warnings: cacheRead.warnings };
291
+ }
292
+ if (cacheRead.kind === 'stale') {
293
+ // Try refresh first; fall back to stale cache if refresh fails.
294
+ const refreshed = await refreshManifest(paths, fetchFn);
295
+ if (refreshed.kind === 'refreshed') {
296
+ return { kind: 'ok', envelope: refreshed.envelope, source: 'refreshed', warnings: refreshed.warnings };
297
+ }
298
+ reasons.push(`refresh failed: ${refreshed.kind === 'fetch-failed' ? refreshed.reason : refreshed.reason}`);
299
+ return {
300
+ kind: 'ok',
301
+ envelope: cacheRead.envelope,
302
+ source: 'cache-stale',
303
+ warnings: cacheRead.warnings,
304
+ staleReason: cacheRead.reason,
305
+ };
306
+ }
307
+ if (cacheRead.kind === 'invalid') {
308
+ reasons.push(`cache invalid: ${cacheRead.reason}`);
309
+ } else if (cacheRead.kind === 'rotation-detected') {
310
+ reasons.push(`rotation: ${cacheRead.reason}`);
311
+ } else if (cacheRead.kind === 'expired') {
312
+ reasons.push(`expired: ${cacheRead.reason}`);
313
+ }
314
+ }
315
+
316
+ // Fall through to refresh (force=true OR cache absent/invalid/rotation/expired).
317
+ const refreshed = await refreshManifest(paths, fetchFn);
318
+ if (refreshed.kind === 'refreshed') {
319
+ return { kind: 'ok', envelope: refreshed.envelope, source: 'refreshed', warnings: refreshed.warnings };
320
+ }
321
+ reasons.push(`refresh failed: ${refreshed.kind === 'fetch-failed' ? refreshed.reason : refreshed.reason}`);
322
+ return { kind: 'fail', reasons };
323
+ }
324
+
325
+ /**
326
+ * Helper for tests + telemetry observability: returns the on-disk file
327
+ * mode of the cache file (or null if absent). Used to assert gap-37
328
+ * file-mode discipline post-write.
329
+ */
330
+ export function getCacheFileMode(paths: CachePaths = defaultCachePaths()): number | null {
331
+ if (!existsSync(paths.cachePath)) return null;
332
+ return statSync(paths.cachePath).mode & 0o777;
333
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Zod schemas for the registry adapter manifest envelope and body.
3
+ *
4
+ * Plan 3c Phase 5 deliverable. The publisher (registry-publish.sh, version
5
+ * with manifest_b64 — see project_plan_3c_phase5_canonicalization_gap.md
6
+ * memory + commit 1b724d3) emits envelopes matching EnvelopeSchema; the
7
+ * verifier (adapter-verifier.ts) consumes ParsedEnvelope. The body inside
8
+ * manifest_b64 (base64-decoded) parses against ManifestBodySchema.
9
+ *
10
+ * Forward-compatibility model (Plan 3c gap-56):
11
+ * - Manifest body has `manifest_schema_version: 1` (integer, default 1 if
12
+ * absent for legacy pre-3c manifests).
13
+ * - Schemas use `.passthrough()` so unknown additive keys (gap-57
14
+ * `deprecated`, `unpublished`, future `revoked_at` etc.) are preserved
15
+ * on parse — verifier emits a one-time stderr warning when
16
+ * manifest_schema_version > KNOWN_MAX (NOT a refusal — additive
17
+ * forward-compat).
18
+ * - manifest_schema_version < MIN_KNOWN_VERSION → verifier REFUSES (a
19
+ * future v2 may drop fields v1 consumers expect).
20
+ *
21
+ * Single source of truth: this is the ONLY definition of the envelope and
22
+ * manifest shapes in @massu/core. Other modules (verifier, cache, CLI) MUST
23
+ * import these schemas + their inferred types instead of re-declaring.
24
+ */
25
+ import { z } from 'zod';
26
+
27
+ /** Minimum schema version this @massu/core build accepts. Below this → refuse. */
28
+ export const MIN_KNOWN_SCHEMA_VERSION = 1 as const;
29
+ /** Maximum schema version this @massu/core build understands. Above this → warn + continue. */
30
+ export const KNOWN_MAX_SCHEMA_VERSION = 1 as const;
31
+
32
+ /** sha256 hex string. 64 lowercase hex chars. */
33
+ const Sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, 'sha256 hex must be 64 lowercase hex chars');
34
+
35
+ /**
36
+ * Printable ASCII string — 0x20 to 0x7e, length 1+. Rejects control
37
+ * characters (including ESC \x1b for ANSI escapes), tabs, newlines, and
38
+ * any non-ASCII Unicode. Used for fields that get rendered to stderr or
39
+ * stdout in the CLI; without this constraint, an attacker could embed
40
+ * ANSI escape sequences in (manifest entry version, package.json name,
41
+ * sidecar version, etc.) to log-inject CI/operator-terminal output.
42
+ *
43
+ * CR-9 iter-4 audit single-source-of-truth (LOW-NEW4-1/2/3 fix): every
44
+ * schema field that's downstream of a stderr/stdout emit MUST use this
45
+ * type instead of bare z.string(). Adding a new string field that's
46
+ * later rendered in CLI output without reaching for this type is a
47
+ * regression worth catching at code review.
48
+ */
49
+ export const PrintableAsciiStringSchema = z.string().min(1).regex(
50
+ /^[\x20-\x7e]+$/,
51
+ 'must be printable ASCII (0x20-0x7e); control characters like ESC, tab, newline, and non-ASCII are rejected to prevent log injection',
52
+ );
53
+
54
+ /**
55
+ * Standard base64 (RFC 4648) — alphabet [A-Za-z0-9+/], length must be a
56
+ * multiple of 4 (with up to two `=` padding chars), no newlines.
57
+ * CR-9 audit L1 fix: the prior `^[A-Za-z0-9+/]*={0,2}$` regex permitted a
58
+ * lone `=` (decodes to zero bytes) which downstream code would parse as
59
+ * a valid empty input. The strict RFC 4648 form below rejects malformed
60
+ * base64 at the schema layer instead of relying on a downstream length
61
+ * check.
62
+ */
63
+ const Base64Schema = z.string()
64
+ .regex(
65
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/,
66
+ 'must be standard base64 (RFC 4648; length divisible by 4 with optional == or = padding)',
67
+ )
68
+ .min(1);
69
+
70
+ /**
71
+ * Single adapter entry in the manifest. Plan 3c gap-31 + gap-57:
72
+ * - `package` + `version` + `sha256` are the install-time + load-time
73
+ * verification primitives.
74
+ * - `signing_key_id` is the sha256 fingerprint of the pubkey that signed
75
+ * THIS entry (per gap-61, v1 always uses the single registry key).
76
+ * - `deprecated` (gap-57) — additive optional. Loader warns + still loads.
77
+ * - `unpublished` (gap-57) — additive optional. Loader REFUSES to load.
78
+ * - `.passthrough()` preserves unknown additive fields (gap-56 forward-compat).
79
+ */
80
+ export const AdapterEntrySchema = z.object({
81
+ // CR-9 iter-4 audit LOW-NEW4-3 fix: every string field rendered in CLI
82
+ // output (discover.ts deprecation warning + adapters.ts search status)
83
+ // uses PrintableAsciiStringSchema to prevent log-injection from a
84
+ // compromised registry. The trust model assumes the registry can be
85
+ // adversarial pre-signing; the verifier's signature check + this
86
+ // schema together neutralize that vector.
87
+ package: PrintableAsciiStringSchema,
88
+ version: PrintableAsciiStringSchema,
89
+ sha256: Sha256HexSchema,
90
+ signing_key_id: Sha256HexSchema,
91
+ deprecated: z.object({
92
+ since: PrintableAsciiStringSchema,
93
+ replacement: PrintableAsciiStringSchema.nullable().optional(),
94
+ reason: PrintableAsciiStringSchema,
95
+ }).optional(),
96
+ unpublished: z.boolean().optional(),
97
+ }).passthrough();
98
+ export type AdapterEntry = z.infer<typeof AdapterEntrySchema>;
99
+
100
+ /**
101
+ * Manifest body — the JSON object inside base64-decoded `manifest_b64`.
102
+ * Plan 3c gap-56: schema_version is a numeric integer. Defaults to 1 if
103
+ * absent (legacy pre-3c manifests) so the verifier can still read them
104
+ * during the rollout transition.
105
+ */
106
+ export const ManifestBodySchema = z.object({
107
+ manifest_schema_version: z.number().int().positive().default(1),
108
+ issued_at: z.string().min(1),
109
+ adapters: z.array(AdapterEntrySchema),
110
+ }).passthrough();
111
+ export type ManifestBody = z.infer<typeof ManifestBodySchema>;
112
+
113
+ /**
114
+ * Full envelope as served by registry.massu.ai/adapters/manifest.json AND
115
+ * cached at ~/.massu/adapter-manifest.json. Plan 3c canonicalization-gap
116
+ * fix (commit 1b724d3): manifest_b64 is the byte-equal-to-signed-input
117
+ * field; verifier MUST consume manifest_b64 (NOT the parsed `manifest`
118
+ * field, which is a human-readable re-serialization that does NOT round-
119
+ * trip to the signed bytes).
120
+ */
121
+ export const EnvelopeSchema = z.object({
122
+ manifest: ManifestBodySchema,
123
+ manifest_b64: Base64Schema,
124
+ signature: Base64Schema,
125
+ manifest_sha256: Sha256HexSchema,
126
+ signed_at: z.string().min(1),
127
+ signing_key_id: Sha256HexSchema,
128
+ }).passthrough();
129
+ export type Envelope = z.infer<typeof EnvelopeSchema>;