@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,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>;
|