multimodel-dev-os 3.1.0 → 3.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.
- package/.ai/policies/registry-policy.yaml +29 -1
- package/.ai/registries/trusted-keys.yaml +12 -0
- package/.ai/schema/registry-manifest.schema.json +31 -2
- package/.ai/schema/registry-policy.schema.json +37 -1
- package/.ai/schema/trusted-keys.schema.json +69 -0
- package/AGENTS.md +22 -26
- package/MEMORY.md +34 -11
- package/README.md +2 -1
- package/RUNBOOK.md +28 -36
- package/TASKS.md +15 -5
- package/bin/multimodel-dev-os.js +1366 -548
- package/docs/.vitepress/config.js +3 -1
- package/docs/architecture.md +3 -1
- package/docs/index.md +5 -5
- package/docs/npm-publishing.md +5 -5
- package/docs/package-safety.md +17 -0
- package/docs/public/llms-full.txt +5 -1
- package/docs/public/llms.txt +6 -1
- package/docs/public/sitemap.xml +15 -0
- package/docs/registry-policy.md +29 -1
- package/docs/registry-security.md +73 -6
- package/docs/registry-signing.md +70 -0
- package/docs/registry-sync.md +5 -2
- package/docs/registry-trust-store.md +66 -0
- package/docs/release-policy.md +6 -5
- package/docs/security-threat-model.md +96 -0
- package/docs/testing.md +25 -2
- package/docs/trusted-registries.md +1 -1
- package/docs/v3-roadmap.md +17 -6
- package/docs/v3.5.0-readiness.md +46 -0
- package/package.json +5 -2
- package/scripts/build-cli.js +45 -3
- package/scripts/check-build-fresh.js +52 -0
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/verify.js +327 -14
- package/scripts/verify.sh +10 -0
- package/src/catalog/loader.js +117 -0
- package/src/cli/args.js +118 -0
- package/src/cli/help.js +60 -0
- package/src/cli/main.js +6263 -0
- package/src/core/globals.js +52 -0
- package/src/core/hashes.js +15 -0
- package/src/core/policy.js +44 -0
- package/src/core/security.js +61 -0
- package/src/core/yaml.js +136 -0
- package/src/plugin/manifest.js +95 -0
- package/src/registry/provenance.js +114 -0
- package/src/registry/signing.js +392 -0
- package/src/registry/sources.js +40 -0
- package/src/registry/trust-store.js +41 -0
- package/src/registry/validation.js +45 -0
- package/src/registry/verdict.js +51 -0
- package/tests/README.md +37 -0
- package/tests/fixtures/README.md +22 -0
- package/tests/fixtures/custom-template-example/README.md +10 -0
- package/tests/fixtures/proposals/approved-append-line.md +28 -0
- package/tests/fixtures/proposals/approved-create-file.md +29 -0
- package/tests/fixtures/proposals/approved-replace-text.md +30 -0
- package/tests/fixtures/proposals/existing-create-file-no-overwrite.md +29 -0
- package/tests/fixtures/proposals/no-operations.md +18 -0
- package/tests/fixtures/proposals/path-traversal.md +29 -0
- package/tests/fixtures/proposals/pending-proposal.md +29 -0
- package/tests/fixtures/proposals/protected-path.md +29 -0
- package/tests/fixtures/proposals/replace-multiple-without-allow.md +30 -0
- package/tests/fixtures/registry-overrides/README.md +20 -0
- package/tests/fixtures/signed-registries/README.md +4 -0
- package/tests/fixtures/signed-registries/revoked-key/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/revoked-key/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/revoked-key/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/tampered-manifest/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/tampered-manifest/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/tampered-manifest/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/trusted-keys.yaml +23 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/registry-manifest.yaml +9 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/wrong-key/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/wrong-key/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/wrong-key/registry-manifest.yaml +14 -0
- package/tests/smoke/README.md +37 -0
- package/tests/smoke/cli-smoke.md +49 -0
- package/tests/unit/build-output.test.js +40 -0
- package/tests/unit/catalog-loader.test.js +44 -0
- package/tests/unit/path-safety.test.js +62 -0
- package/tests/unit/plugin-manifest.test.js +94 -0
- package/tests/unit/prepublish-guard.test.js +35 -0
- package/tests/unit/registry-e2e-signature-fixtures.test.js +288 -0
- package/tests/unit/registry-policy.test.js +52 -0
- package/tests/unit/registry-provenance.test.js +185 -0
- package/tests/unit/registry-public-signing.test.js +109 -0
- package/tests/unit/registry-signature-policy.test.js +100 -0
- package/tests/unit/registry-signing.test.js +193 -0
- package/tests/unit/registry-trust-store.test.js +133 -0
- package/tests/unit/registry-url-validation.test.js +64 -0
- package/tests/unit/yaml.test.js +92 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Signing — HMAC-SHA256 and Ed25519 key management + verification
|
|
3
|
+
*
|
|
4
|
+
* Provides project-scoped HMAC signing and public-key Ed25519 signature verification
|
|
5
|
+
* for registry manifests.
|
|
6
|
+
*
|
|
7
|
+
* HMAC key is stored at `.ai/registry-signing-key` as a 64-char hex string
|
|
8
|
+
* (32 random bytes). The file should be gitignored.
|
|
9
|
+
*
|
|
10
|
+
* Uses only Node.js built-in `crypto` — zero runtime dependencies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { generateKeyPairSync, sign, verify, createHmac, timingSafeEqual, randomBytes } from 'crypto';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
|
|
17
|
+
const SIGNING_KEY_FILENAME = 'registry-signing-key';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the absolute path to the signing key file for a given project dir.
|
|
21
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function getSigningKeyPath(targetDir) {
|
|
25
|
+
return join(targetDir, '.ai', SIGNING_KEY_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the project-scoped signing key from `.ai/registry-signing-key`.
|
|
30
|
+
* Returns null (without throwing) if the file does not exist.
|
|
31
|
+
* Throws if the file exists but contains an invalid key format.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
34
|
+
* @returns {string|null} 64-char hex key string, or null.
|
|
35
|
+
*/
|
|
36
|
+
export function loadSigningKey(targetDir) {
|
|
37
|
+
const keyPath = getSigningKeyPath(targetDir);
|
|
38
|
+
if (!existsSync(keyPath)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = readFileSync(keyPath, 'utf8').trim();
|
|
43
|
+
|
|
44
|
+
if (!/^[0-9a-f]{64}$/.test(raw)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Signing key at '${keyPath}' is malformed. Expected a 64-character lowercase hex string (32 bytes). ` +
|
|
47
|
+
`Re-generate with: npx multimodel-dev-os registry keygen --approved`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate a new random 32-byte signing key (64-char hex).
|
|
56
|
+
* @returns {string} 64-char hex string.
|
|
57
|
+
*/
|
|
58
|
+
export function generateSigningKey() {
|
|
59
|
+
return randomBytes(32).toString('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write a signing key to disk at the project-scoped location.
|
|
64
|
+
* Creates the `.ai/` directory if it does not exist.
|
|
65
|
+
* Sets file permissions to 0o600 (owner read/write only) where supported.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
68
|
+
* @param {string} key 64-char hex key string.
|
|
69
|
+
*/
|
|
70
|
+
export function saveSigningKey(targetDir, key) {
|
|
71
|
+
const keyPath = getSigningKeyPath(targetDir);
|
|
72
|
+
const keyDir = dirname(keyPath);
|
|
73
|
+
|
|
74
|
+
if (!existsSync(keyDir)) {
|
|
75
|
+
mkdirSync(keyDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writeFileSync(keyPath, key + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
79
|
+
|
|
80
|
+
// Best-effort chmod on platforms that support it (no-op on Windows)
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(keyPath, 0o600);
|
|
83
|
+
} catch (_e) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute HMAC-SHA256 of a payload using the provided hex key.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} hexKey 64-char hex signing key.
|
|
90
|
+
* @param {string} payload Plaintext string to sign (typically the catalog_sha256 hex).
|
|
91
|
+
* @returns {string} HMAC-SHA256 hex digest.
|
|
92
|
+
*/
|
|
93
|
+
export function signPayload(hexKey, payload) {
|
|
94
|
+
if (typeof hexKey !== 'string' || !/^[0-9a-f]{64}$/.test(hexKey)) {
|
|
95
|
+
throw new Error('Invalid signing key: must be a 64-character lowercase hex string.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof payload !== 'string') {
|
|
98
|
+
throw new Error('Payload to sign must be a string.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const keyBytes = Buffer.from(hexKey, 'hex');
|
|
102
|
+
return createHmac('sha256', keyBytes).update(payload, 'utf8').digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify that a previously computed HMAC-SHA256 signature matches the payload.
|
|
107
|
+
* Uses `timingSafeEqual` to prevent timing-based side-channel attacks.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} hexKey 64-char hex signing key.
|
|
110
|
+
* @param {string} payload Plaintext string that was signed.
|
|
111
|
+
* @param {string} expectedSig The stored HMAC-SHA256 hex digest to compare against.
|
|
112
|
+
* @returns {boolean} true if valid, false if tampered or mismatched.
|
|
113
|
+
*/
|
|
114
|
+
export function verifySignature(hexKey, payload, expectedSig) {
|
|
115
|
+
if (typeof hexKey !== 'string' || typeof payload !== 'string' || typeof expectedSig !== 'string') {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let actualSig;
|
|
120
|
+
try {
|
|
121
|
+
actualSig = signPayload(hexKey, payload);
|
|
122
|
+
} catch (_e) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (actualSig.length !== expectedSig.length) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return timingSafeEqual(
|
|
132
|
+
Buffer.from(actualSig, 'hex'),
|
|
133
|
+
Buffer.from(expectedSig, 'hex')
|
|
134
|
+
);
|
|
135
|
+
} catch (_e) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate a deterministic canonical payload string from an object based on specified fields.
|
|
142
|
+
* Recursively sorts keys of nested objects to ensure key order stability.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} data The source object.
|
|
145
|
+
* @param {string[]} fields The fields to extract and canonicalize.
|
|
146
|
+
* @returns {string} JSON-serialized canonical string.
|
|
147
|
+
*/
|
|
148
|
+
export function createCanonicalPayload(data, fields) {
|
|
149
|
+
if (!data || typeof data !== 'object') {
|
|
150
|
+
throw new Error('Data must be an object.');
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(fields)) {
|
|
153
|
+
throw new Error('Fields must be an array of strings.');
|
|
154
|
+
}
|
|
155
|
+
const sortedFields = [...fields].sort();
|
|
156
|
+
const obj = {};
|
|
157
|
+
for (const field of sortedFields) {
|
|
158
|
+
if (data[field] !== undefined) {
|
|
159
|
+
obj[field] = data[field];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return JSON.stringify(obj, (key, value) => {
|
|
163
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
164
|
+
return Object.keys(value).sort().reduce((sorted, k) => {
|
|
165
|
+
sorted[k] = value[k];
|
|
166
|
+
return sorted;
|
|
167
|
+
}, {});
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate an Ed25519 keypair in PEM SPKI/PKCS8 format.
|
|
175
|
+
*
|
|
176
|
+
* @returns {{ publicKey: string, privateKey: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function generateEd25519KeyPair() {
|
|
179
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
180
|
+
publicKeyEncoding: {
|
|
181
|
+
type: 'spki',
|
|
182
|
+
format: 'pem'
|
|
183
|
+
},
|
|
184
|
+
privateKeyEncoding: {
|
|
185
|
+
type: 'pkcs8',
|
|
186
|
+
format: 'pem'
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return { publicKey, privateKey };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sign a payload using an Ed25519 private key PEM.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} privateKey Ed25519 PEM private key string.
|
|
196
|
+
* @param {string} payload Canonical payload string.
|
|
197
|
+
* @returns {string} Base64-encoded signature.
|
|
198
|
+
*/
|
|
199
|
+
export function signEd25519Payload(privateKey, payload) {
|
|
200
|
+
if (typeof privateKey !== 'string') {
|
|
201
|
+
throw new Error('Private key must be a PEM string.');
|
|
202
|
+
}
|
|
203
|
+
if (typeof payload !== 'string') {
|
|
204
|
+
throw new Error('Payload to sign must be a string.');
|
|
205
|
+
}
|
|
206
|
+
const signatureBuffer = sign(null, Buffer.from(payload, 'utf8'), privateKey);
|
|
207
|
+
return signatureBuffer.toString('base64');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Normalize a public key to ensure it is in PEM SPKI format.
|
|
212
|
+
* Wraps bare base64 public keys in SPKI headers/footers if needed.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} input PEM public key or raw base64.
|
|
215
|
+
* @returns {string} Normalized PEM public key string.
|
|
216
|
+
*/
|
|
217
|
+
export function normalizePublicKey(input) {
|
|
218
|
+
if (typeof input !== 'string') {
|
|
219
|
+
throw new Error('Public key must be a string.');
|
|
220
|
+
}
|
|
221
|
+
let trimmed = input.trim();
|
|
222
|
+
if (trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) {
|
|
223
|
+
return trimmed;
|
|
224
|
+
}
|
|
225
|
+
if (trimmed.startsWith('-----BEGIN')) {
|
|
226
|
+
return trimmed;
|
|
227
|
+
}
|
|
228
|
+
const clean = trimmed.replace(/\s+/g, '');
|
|
229
|
+
const lines = [];
|
|
230
|
+
for (let i = 0; i < clean.length; i += 64) {
|
|
231
|
+
lines.push(clean.slice(i, i + 64));
|
|
232
|
+
}
|
|
233
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Verify an Ed25519 signature of a payload.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} publicKey Public key in PEM or raw base64.
|
|
240
|
+
* @param {string} payload Payload that was signed.
|
|
241
|
+
* @param {string} signature Base64 signature to verify.
|
|
242
|
+
* @returns {boolean} true if signature is valid, false otherwise.
|
|
243
|
+
*/
|
|
244
|
+
export function verifyEd25519Payload(publicKey, payload, signature) {
|
|
245
|
+
if (typeof publicKey !== 'string' || typeof payload !== 'string' || typeof signature !== 'string') {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const pubKey = normalizePublicKey(publicKey);
|
|
250
|
+
const sigBuffer = Buffer.from(signature, 'base64');
|
|
251
|
+
return verify(null, Buffer.from(payload, 'utf8'), pubKey, sigBuffer);
|
|
252
|
+
} catch (_e) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Detect the signature algorithm from a signature block object.
|
|
259
|
+
*
|
|
260
|
+
* @param {Object} signatureBlock The signature block.
|
|
261
|
+
* @returns {string|null} Algorithm name or null.
|
|
262
|
+
*/
|
|
263
|
+
export function detectSignatureAlgorithm(signatureBlock) {
|
|
264
|
+
if (!signatureBlock || typeof signatureBlock !== 'object') {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return signatureBlock.algorithm || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Verify a manifest signature or signatures array block using trusted keys and policy.
|
|
272
|
+
*
|
|
273
|
+
* @param {Object} options
|
|
274
|
+
* @param {Object} options.manifest Parsed registry manifest object.
|
|
275
|
+
* @param {Object[]} options.trustedKeys Array of trusted publishers from trust store.
|
|
276
|
+
* @param {Object} [options.policy] Policy settings.
|
|
277
|
+
* @param {string|null} [options.hmacKey] Project HMAC key.
|
|
278
|
+
* @param {Object} [options.source] Source configuration for the registry.
|
|
279
|
+
* @returns {{ verified: boolean, status: string, error?: string, errors?: string[], warning?: string, message?: string, verified_signatures?: Object[] }}
|
|
280
|
+
*/
|
|
281
|
+
export function verifySignatureBlock({ manifest, trustedKeys, policy = {}, hmacKey = null, source = {} }) {
|
|
282
|
+
const isBundled = source.name === 'bundled';
|
|
283
|
+
const isLocal = source.type === 'local';
|
|
284
|
+
const isRemote = source.type === 'remote' || (!isBundled && !isLocal);
|
|
285
|
+
|
|
286
|
+
const signatureBlocks = [];
|
|
287
|
+
if (manifest.signature && typeof manifest.signature === 'object') {
|
|
288
|
+
signatureBlocks.push(manifest.signature);
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(manifest.signatures)) {
|
|
291
|
+
signatureBlocks.push(...manifest.signatures);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (signatureBlocks.length === 0) {
|
|
295
|
+
if (policy.require_signature) {
|
|
296
|
+
return { verified: false, status: 'failed', error: 'Signature is required by policy but missing from manifest.' };
|
|
297
|
+
}
|
|
298
|
+
if (isRemote && policy.allow_unsigned_remote === false) {
|
|
299
|
+
return { verified: false, status: 'failed', error: 'Unsigned remote registries are not allowed by policy.' };
|
|
300
|
+
}
|
|
301
|
+
if (isBundled && policy.allow_unsigned_bundled === false) {
|
|
302
|
+
return { verified: false, status: 'failed', error: 'Unsigned bundled registries are not allowed by policy.' };
|
|
303
|
+
}
|
|
304
|
+
if (isLocal && !isBundled && policy.allow_unsigned_local === false) {
|
|
305
|
+
return { verified: false, status: 'failed', error: 'Unsigned local registries are not allowed by policy.' };
|
|
306
|
+
}
|
|
307
|
+
return { verified: true, status: 'unsigned', message: 'Registry is unsigned (allowed by policy).' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let verifiedCount = 0;
|
|
311
|
+
const errors = [];
|
|
312
|
+
const allowedAlgs = policy.allowed_signature_algorithms || ['ed25519', 'hmac-sha256'];
|
|
313
|
+
|
|
314
|
+
for (const sigBlock of signatureBlocks) {
|
|
315
|
+
const alg = sigBlock.algorithm;
|
|
316
|
+
const keyId = sigBlock.key_id;
|
|
317
|
+
const signature = sigBlock.signature;
|
|
318
|
+
const signedFields = sigBlock.signed_fields;
|
|
319
|
+
|
|
320
|
+
if (!alg || !keyId || !signature || !Array.isArray(signedFields)) {
|
|
321
|
+
errors.push(`Malformed signature block for key_id '${keyId || 'unknown'}'.`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!allowedAlgs.includes(alg)) {
|
|
326
|
+
errors.push(`Signature algorithm '${alg}' is not allowed by policy (allowed: ${allowedAlgs.join(', ')}).`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (alg === 'hmac-sha256') {
|
|
331
|
+
if (!hmacKey) {
|
|
332
|
+
errors.push(`HMAC key not configured locally for key_id '${keyId}'.`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const payload = createCanonicalPayload(manifest, signedFields);
|
|
337
|
+
const expected = createHmac('sha256', Buffer.from(hmacKey, 'hex')).update(payload, 'utf8').digest('hex');
|
|
338
|
+
if (timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
339
|
+
verifiedCount++;
|
|
340
|
+
} else {
|
|
341
|
+
errors.push(`Invalid HMAC signature for key_id '${keyId}'.`);
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
errors.push(`HMAC signature verification failed: ${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
} else if (alg === 'ed25519') {
|
|
347
|
+
const trustedKey = trustedKeys ? trustedKeys.find(k => k.key_id === keyId) : null;
|
|
348
|
+
if (!trustedKey) {
|
|
349
|
+
errors.push(`Key ID '${keyId}' not found in trust store.`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (trustedKey.status !== 'active') {
|
|
354
|
+
errors.push(`Key ID '${keyId}' is ${trustedKey.status} (must be active).`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const scopes = trustedKey.scopes || [];
|
|
359
|
+
if (!scopes.includes('registry') && !scopes.includes('catalog')) {
|
|
360
|
+
errors.push(`Key ID '${keyId}' does not have required scope 'registry' or 'catalog' (scopes: ${scopes.join(', ')}).`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const payload = createCanonicalPayload(manifest, signedFields);
|
|
366
|
+
if (verifyEd25519Payload(trustedKey.public_key, payload, signature)) {
|
|
367
|
+
verifiedCount++;
|
|
368
|
+
} else {
|
|
369
|
+
errors.push(`Invalid Ed25519 signature for key_id '${keyId}'.`);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
errors.push(`Ed25519 signature verification failed: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
errors.push(`Unsupported signature algorithm '${alg}' for key_id '${keyId}'.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (verifiedCount > 0) {
|
|
380
|
+
return {
|
|
381
|
+
verified: true,
|
|
382
|
+
status: 'verified',
|
|
383
|
+
verified_signatures: signatureBlocks.map(s => ({ key_id: s.key_id, algorithm: s.algorithm }))
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
verified: false,
|
|
389
|
+
status: 'failed',
|
|
390
|
+
errors
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { sourceRoot } from '../core/globals.js';
|
|
4
|
+
import { parseYaml } from '../core/yaml.js';
|
|
5
|
+
|
|
6
|
+
export function loadRegistrySources() {
|
|
7
|
+
const paths = [
|
|
8
|
+
join(sourceRoot, '.ai', 'registries', 'sources.yaml')
|
|
9
|
+
];
|
|
10
|
+
for (const p of paths) {
|
|
11
|
+
if (existsSync(p)) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = parseYaml(readFileSync(p, 'utf8'));
|
|
14
|
+
return parsed.sources || [];
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return [{ name: 'bundled', type: 'local', url: '.ai/plugins/catalog.yaml', enabled: true, trust_level: 'trusted', safety_policy: 'sandboxed', signature_required: false, checksum_required: false }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveRegistrySources(sources) {
|
|
22
|
+
const path = join(sourceRoot, '.ai', 'registries', 'sources.yaml');
|
|
23
|
+
let yaml = '# Registry Sources Configuration\n';
|
|
24
|
+
yaml += '# Remote registries are DISABLED by default.\n';
|
|
25
|
+
yaml += '# Enable via .ai/policies/registry-policy.yaml (set allow_remote_registries: true)\n\n';
|
|
26
|
+
yaml += 'sources:\n';
|
|
27
|
+
sources.forEach(s => {
|
|
28
|
+
yaml += ` - name: "${s.name}"\n`;
|
|
29
|
+
yaml += ` type: "${s.type}"\n`;
|
|
30
|
+
yaml += ` url: "${s.url}"\n`;
|
|
31
|
+
yaml += ` enabled: ${s.enabled}\n`;
|
|
32
|
+
yaml += ` trust_level: "${s.trust_level}"\n`;
|
|
33
|
+
yaml += ` safety_policy: "${s.safety_policy}"\n`;
|
|
34
|
+
yaml += ` signature_required: ${s.signature_required}\n`;
|
|
35
|
+
yaml += ` checksum_required: ${s.checksum_required}\n`;
|
|
36
|
+
if (s.last_synced_at) yaml += ` last_synced_at: "${s.last_synced_at}"\n`;
|
|
37
|
+
if (s.pinned_commit_or_hash) yaml += ` pinned_commit_or_hash: "${s.pinned_commit_or_hash}"\n`;
|
|
38
|
+
});
|
|
39
|
+
writeFileSync(path, yaml, 'utf8');
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Trust Store
|
|
3
|
+
*
|
|
4
|
+
* Manages trusted publisher public keys stored in `.ai/registries/trusted-keys.yaml`
|
|
5
|
+
* (or as configured in registry-policy.yaml).
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js built-in fs and yaml parser from the codebase.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { join, isAbsolute } from 'path';
|
|
12
|
+
import { parseYaml } from '../core/yaml.js';
|
|
13
|
+
import { loadRegistryPolicy } from '../core/policy.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load trusted publisher keys from disk.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} targetDir Absolute path to project root.
|
|
19
|
+
* @param {Object} [policy] Preloaded policy (optional).
|
|
20
|
+
* @returns {Object[]} Array of trusted publisher records.
|
|
21
|
+
*/
|
|
22
|
+
export function loadTrustedKeys(targetDir, policy) {
|
|
23
|
+
const pol = policy || loadRegistryPolicy(targetDir);
|
|
24
|
+
const keyFile = pol.trusted_keys_file || '.ai/registries/trusted-keys.yaml';
|
|
25
|
+
const filePath = isAbsolute(keyFile) ? keyFile : join(targetDir, keyFile);
|
|
26
|
+
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
33
|
+
const parsed = parseYaml(raw);
|
|
34
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.trusted_publishers)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return parsed.trusted_publishers;
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function validateRegistryUrl(urlStr, policy = {}) {
|
|
2
|
+
if (!urlStr || typeof urlStr !== 'string') {
|
|
3
|
+
throw new Error('Registry URL must be a non-empty string.');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Reject empty/whitespace/control characters
|
|
7
|
+
if (urlStr.trim() === '' || /\s/.test(urlStr) || /[\x00-\x1F\x7F-\x9F]/.test(urlStr)) {
|
|
8
|
+
throw new Error('Registry URL must not contain whitespace or control characters.');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Reject single quotes, double quotes, backticks
|
|
12
|
+
if (/['"`]/.test(urlStr)) {
|
|
13
|
+
throw new Error('Registry URL must not contain quotes or backticks.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Reject shell metacharacters
|
|
17
|
+
if (/[\$\;\&\|<>\(\)\*]/.test(urlStr)) {
|
|
18
|
+
throw new Error('Registry URL must not contain shell metacharacters.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let parsedUrl;
|
|
22
|
+
try {
|
|
23
|
+
parsedUrl = new URL(urlStr);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
throw new Error('Registry URL is malformed or invalid.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reject username/password credentials
|
|
29
|
+
if (parsedUrl.username || parsedUrl.password) {
|
|
30
|
+
throw new Error('Registry URL must not contain credentials.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const protocol = parsedUrl.protocol;
|
|
34
|
+
const allowedProtocols = ['https:'];
|
|
35
|
+
|
|
36
|
+
if (policy.allow_http_localhost === true) {
|
|
37
|
+
if (parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1') {
|
|
38
|
+
allowedProtocols.push('http:');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!allowedProtocols.includes(protocol)) {
|
|
43
|
+
throw new Error(`Registry URL protocol '${protocol}' is not allowed. Only HTTPS is permitted.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Trust Verdict Helper
|
|
3
|
+
*
|
|
4
|
+
* Builds a deterministic, JSON-serializable structured trust verdict object.
|
|
5
|
+
* Used for both CLI display and provenance lockfile persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a structured trust verdict object.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} options.source Registry name.
|
|
13
|
+
* @param {string} options.source_type "bundled" | "local" | "remote".
|
|
14
|
+
* @param {string} [options.manifest_hash_status] Manifest verification status.
|
|
15
|
+
* @param {string} [options.catalog_hash_status] Catalog verification status.
|
|
16
|
+
* @param {string} [options.lockfile_status] Lockfile existence status.
|
|
17
|
+
* @param {string} [options.provenance_status] Provenance lock match status.
|
|
18
|
+
* @param {string} [options.signature_status] Signature check status.
|
|
19
|
+
* @param {string} [options.trusted_publisher_status] Trust store lookup status.
|
|
20
|
+
* @param {string[]} [options.errors] List of verification errors.
|
|
21
|
+
* @param {string[]} [options.warnings] List of verification warnings.
|
|
22
|
+
* @param {"trusted"|"warning"|"untrusted"|"unknown"} [options.final_status] Overall trust state.
|
|
23
|
+
* @returns {Object}
|
|
24
|
+
*/
|
|
25
|
+
export function createTrustVerdict({
|
|
26
|
+
source,
|
|
27
|
+
source_type,
|
|
28
|
+
manifest_hash_status = 'N/A',
|
|
29
|
+
catalog_hash_status = 'N/A',
|
|
30
|
+
lockfile_status = 'N/A',
|
|
31
|
+
provenance_status = 'N/A',
|
|
32
|
+
signature_status = 'N/A',
|
|
33
|
+
trusted_publisher_status = 'N/A',
|
|
34
|
+
errors = [],
|
|
35
|
+
warnings = [],
|
|
36
|
+
final_status = 'unknown'
|
|
37
|
+
}) {
|
|
38
|
+
return {
|
|
39
|
+
source,
|
|
40
|
+
source_type,
|
|
41
|
+
manifest_hash_status,
|
|
42
|
+
catalog_hash_status,
|
|
43
|
+
lockfile_status,
|
|
44
|
+
provenance_status,
|
|
45
|
+
signature_status,
|
|
46
|
+
trusted_publisher_status,
|
|
47
|
+
errors: Array.isArray(errors) ? errors : [],
|
|
48
|
+
warnings: Array.isArray(warnings) ? warnings : [],
|
|
49
|
+
final_status
|
|
50
|
+
};
|
|
51
|
+
}
|
package/tests/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# MultiModel Dev OS Testing Suite
|
|
2
|
+
|
|
3
|
+
This directory contains the testing manuals, schemas, and smoke routines to protect the code quality of `multimodel-dev-os`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Testing Strategy
|
|
8
|
+
|
|
9
|
+
We enforce a multi-tiered validation approach to protect release packaging:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌────────────────────────────────────────────────────────┐
|
|
13
|
+
│ Tier 1: Structural Verification (scripts/verify.js) │
|
|
14
|
+
│ Enforces 100+ files and contract schemas checkouts │
|
|
15
|
+
└──────────────────────────┬─────────────────────────────┘
|
|
16
|
+
│
|
|
17
|
+
┌──────────────────────────▼─────────────────────────────┐
|
|
18
|
+
│ Tier 2: CLI Smoke Checks (bin/multimodel-dev-os.js) │
|
|
19
|
+
│ Validates command signature help and version trackers │
|
|
20
|
+
└──────────────────────────┬─────────────────────────────┘
|
|
21
|
+
│
|
|
22
|
+
┌──────────────────────────▼─────────────────────────────┐
|
|
23
|
+
│ Tier 3: Template QA checks │
|
|
24
|
+
│ Scaffolds tech templates to confirm zero-warnings runs │
|
|
25
|
+
└────────────────────────────────────────────────────────┘
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 2. Running the Linter Verification
|
|
31
|
+
|
|
32
|
+
Always execute the strict linter before committing or tagging:
|
|
33
|
+
```bash
|
|
34
|
+
npm run verify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This dynamic zero-dependency Node.js pipeline audits files structures, config directories, schemas, and package dry-run tarball footprint constraints.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Integration Fixture Strategy
|
|
2
|
+
|
|
3
|
+
To confirm that `multimodel-dev-os` behaves consistently across distinct operating systems and target directory boundaries, future automated integrations tests should utilize the fixtures detailed here.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Planned Fixtures Layout
|
|
8
|
+
|
|
9
|
+
1. **`tests/fixtures/pristine/`**:
|
|
10
|
+
- Representing an empty repository where `init` command can execute with zero conflict.
|
|
11
|
+
2. **`tests/fixtures/cluttered/`**:
|
|
12
|
+
- Represents a repository containing legacy rule files to test conflict resolution and `-f, --force` parameters.
|
|
13
|
+
3. **`tests/fixtures/broken-config/`**:
|
|
14
|
+
- Formatted with syntactic invalid config configurations to verify validate catches and exits with appropriate non-zero error logs.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 2. Dynamic Fixture Auditing
|
|
19
|
+
|
|
20
|
+
When writing assertions:
|
|
21
|
+
- Never commit active `.cursorrules` or `.gemini/` folders directly inside the templates' fixture folders to avoid index pollutions.
|
|
22
|
+
- Use dry-run actions (`-d, --dry-run`) to assert targeted writes without mutative writes to actual testing paths.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Custom Template Test Fixture
|
|
2
|
+
|
|
3
|
+
This directory serves as a fixture for testing custom template validation and initialization flows.
|
|
4
|
+
It contains the bare minimum files to simulate a template source package:
|
|
5
|
+
|
|
6
|
+
* AGENTS.md
|
|
7
|
+
* MEMORY.md
|
|
8
|
+
* TASKS.md
|
|
9
|
+
* RUNBOOK.md
|
|
10
|
+
* .ai/config.yaml
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-000001
|
|
3
|
+
created_at: 2026-06-11T00:00:01Z
|
|
4
|
+
title: Approved Append Line Proposal
|
|
5
|
+
problem: Test append line behavior.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- tests/fixtures/custom-template-example/append-target.md
|
|
10
|
+
suggested_change: Append a line to the target.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: git checkout -- tests/fixtures/custom-template-example/append-target.md
|
|
13
|
+
approval_status: approved
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Approved Append Line Proposal
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"operations": [
|
|
21
|
+
{
|
|
22
|
+
"type": "append_line",
|
|
23
|
+
"path": "tests/fixtures/custom-template-example/append-target.md",
|
|
24
|
+
"line": "added line content"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
```
|