multimodel-dev-os 3.2.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.
Files changed (68) hide show
  1. package/.ai/policies/registry-policy.yaml +29 -1
  2. package/.ai/registries/trusted-keys.yaml +12 -0
  3. package/.ai/schema/registry-manifest.schema.json +31 -2
  4. package/.ai/schema/registry-policy.schema.json +37 -1
  5. package/.ai/schema/trusted-keys.schema.json +69 -0
  6. package/AGENTS.md +22 -26
  7. package/MEMORY.md +34 -11
  8. package/README.md +1 -1
  9. package/RUNBOOK.md +28 -36
  10. package/TASKS.md +15 -5
  11. package/bin/multimodel-dev-os.js +1366 -548
  12. package/docs/.vitepress/config.js +3 -1
  13. package/docs/architecture.md +3 -1
  14. package/docs/index.md +5 -5
  15. package/docs/npm-publishing.md +5 -5
  16. package/docs/package-safety.md +3 -2
  17. package/docs/public/llms-full.txt +5 -1
  18. package/docs/public/llms.txt +6 -1
  19. package/docs/public/sitemap.xml +10 -0
  20. package/docs/registry-policy.md +29 -1
  21. package/docs/registry-security.md +73 -6
  22. package/docs/registry-signing.md +70 -0
  23. package/docs/registry-sync.md +5 -2
  24. package/docs/registry-trust-store.md +66 -0
  25. package/docs/release-policy.md +1 -1
  26. package/docs/security-threat-model.md +96 -0
  27. package/docs/testing.md +15 -2
  28. package/docs/trusted-registries.md +1 -1
  29. package/docs/v3-roadmap.md +11 -6
  30. package/docs/v3.5.0-readiness.md +46 -0
  31. package/package.json +1 -1
  32. package/scripts/install.ps1 +1 -1
  33. package/scripts/install.sh +1 -1
  34. package/scripts/verify.js +206 -9
  35. package/src/cli/help.js +1 -1
  36. package/src/cli/main.js +626 -81
  37. package/src/core/policy.js +9 -1
  38. package/src/registry/provenance.js +114 -0
  39. package/src/registry/signing.js +392 -0
  40. package/src/registry/trust-store.js +41 -0
  41. package/src/registry/verdict.js +51 -0
  42. package/tests/fixtures/signed-registries/README.md +4 -0
  43. package/tests/fixtures/signed-registries/revoked-key/catalog.yaml +8 -0
  44. package/tests/fixtures/signed-registries/revoked-key/expected-verdict.json +7 -0
  45. package/tests/fixtures/signed-registries/revoked-key/registry-manifest.yaml +14 -0
  46. package/tests/fixtures/signed-registries/tampered-manifest/catalog.yaml +8 -0
  47. package/tests/fixtures/signed-registries/tampered-manifest/expected-verdict.json +7 -0
  48. package/tests/fixtures/signed-registries/tampered-manifest/registry-manifest.yaml +14 -0
  49. package/tests/fixtures/signed-registries/trusted-keys.yaml +23 -0
  50. package/tests/fixtures/signed-registries/unsigned-remote-required/catalog.yaml +8 -0
  51. package/tests/fixtures/signed-registries/unsigned-remote-required/expected-verdict.json +7 -0
  52. package/tests/fixtures/signed-registries/unsigned-remote-required/registry-manifest.yaml +9 -0
  53. package/tests/fixtures/signed-registries/unsupported-algorithm/catalog.yaml +8 -0
  54. package/tests/fixtures/signed-registries/unsupported-algorithm/expected-verdict.json +7 -0
  55. package/tests/fixtures/signed-registries/unsupported-algorithm/registry-manifest.yaml +14 -0
  56. package/tests/fixtures/signed-registries/valid-signed-registry/catalog.yaml +8 -0
  57. package/tests/fixtures/signed-registries/valid-signed-registry/expected-verdict.json +7 -0
  58. package/tests/fixtures/signed-registries/valid-signed-registry/registry-manifest.yaml +14 -0
  59. package/tests/fixtures/signed-registries/wrong-key/catalog.yaml +8 -0
  60. package/tests/fixtures/signed-registries/wrong-key/expected-verdict.json +7 -0
  61. package/tests/fixtures/signed-registries/wrong-key/registry-manifest.yaml +14 -0
  62. package/tests/unit/registry-e2e-signature-fixtures.test.js +288 -0
  63. package/tests/unit/registry-policy.test.js +6 -0
  64. package/tests/unit/registry-provenance.test.js +185 -0
  65. package/tests/unit/registry-public-signing.test.js +109 -0
  66. package/tests/unit/registry-signature-policy.test.js +100 -0
  67. package/tests/unit/registry-signing.test.js +193 -0
  68. package/tests/unit/registry-trust-store.test.js +133 -0
@@ -0,0 +1,14 @@
1
+ registry_name: "test-fixture-registry"
2
+ publisher: "Mock Valid Publisher"
3
+ version: "1.0.0"
4
+ generated_at: "2026-06-20T12:00:00Z"
5
+ minimum_mmdo_version: "3.2.0"
6
+ safety_policy_version: "1.0.0"
7
+ catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
8
+ files_hashes:
9
+ catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
10
+ signature:
11
+ algorithm: "ed25519"
12
+ key_id: "test-key-valid"
13
+ signature: "MCowBQYDK2VwAyEA9vWwyE5+fY0dvEzl9S1UcvtoMkOAIDhDCzZAkP+CVNo="
14
+ signed_fields: ["registry_name", "publisher", "version", "generated_at", "catalog_hash", "minimum_mmdo_version", "safety_policy_version"]
@@ -0,0 +1,23 @@
1
+
2
+ # MultiModel Dev OS Test-Only Trusted Keys Fixture
3
+ # WARNING: These keys are for unit testing and E2E verification validation only.
4
+ # DO NOT USE IN PRODUCTION.
5
+ trusted_publishers:
6
+ - key_id: test-key-valid
7
+ name: "Mock Valid Publisher"
8
+ algorithm: ed25519
9
+ public_key: "MCowBQYDK2VwAyEAU1uu8t1/5aXZRvNDNkKpBRJ2SgkKJtlX6pMmuF90tx8="
10
+ scopes: ["registry"]
11
+ status: "active"
12
+ - key_id: test-key-revoked
13
+ name: "Mock Revoked Publisher"
14
+ algorithm: ed25519
15
+ public_key: "MCowBQYDK2VwAyEAvcxhJeWu5VSDxdAva1GwtzgWspYFJ9U1TRJ1a7bUyRA="
16
+ scopes: ["registry"]
17
+ status: "revoked"
18
+ - key_id: test-key-disabled
19
+ name: "Mock Disabled Publisher"
20
+ algorithm: ed25519
21
+ public_key: "MCowBQYDK2VwAyEA9uWCD3w/zkUoQ9RTpQUZ52sib3ctl3fre1PZZowVmvg="
22
+ scopes: ["registry"]
23
+ status: "disabled"
@@ -0,0 +1,8 @@
1
+
2
+ # Catalog fixture
3
+ catalog:
4
+ plugins:
5
+ - slug: test-plugin
6
+ version: 1.0.0
7
+ name: Test Plugin
8
+ description: A mock plugin for testing
@@ -0,0 +1,7 @@
1
+ {
2
+ "final_status": "untrusted",
3
+ "signature_status": "failed",
4
+ "errors": [
5
+ "Unsigned remote registries are not allowed by policy."
6
+ ]
7
+ }
@@ -0,0 +1,9 @@
1
+ registry_name: "test-fixture-registry"
2
+ publisher: "Mock Valid Publisher"
3
+ version: "1.0.0"
4
+ generated_at: "2026-06-20T12:00:00Z"
5
+ minimum_mmdo_version: "3.2.0"
6
+ safety_policy_version: "1.0.0"
7
+ catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
8
+ files_hashes:
9
+ catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
@@ -0,0 +1,8 @@
1
+
2
+ # Catalog fixture
3
+ catalog:
4
+ plugins:
5
+ - slug: test-plugin
6
+ version: 1.0.0
7
+ name: Test Plugin
8
+ description: A mock plugin for testing
@@ -0,0 +1,7 @@
1
+ {
2
+ "final_status": "untrusted",
3
+ "signature_status": "failed",
4
+ "errors": [
5
+ "Signature algorithm 'rsa-sha256' is not allowed by policy (allowed: ed25519, hmac-sha256)."
6
+ ]
7
+ }
@@ -0,0 +1,14 @@
1
+ registry_name: "test-fixture-registry"
2
+ publisher: "Mock Valid Publisher"
3
+ version: "1.0.0"
4
+ generated_at: "2026-06-20T12:00:00Z"
5
+ minimum_mmdo_version: "3.2.0"
6
+ safety_policy_version: "1.0.0"
7
+ catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
8
+ files_hashes:
9
+ catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
10
+ signature:
11
+ algorithm: "rsa-sha256"
12
+ key_id: "test-key-valid"
13
+ signature: "mock-sig"
14
+ signed_fields: ["registry_name", "publisher", "version", "generated_at", "catalog_hash", "minimum_mmdo_version", "safety_policy_version"]
@@ -0,0 +1,8 @@
1
+
2
+ # Catalog fixture
3
+ catalog:
4
+ plugins:
5
+ - slug: test-plugin
6
+ version: 1.0.0
7
+ name: Test Plugin
8
+ description: A mock plugin for testing
@@ -0,0 +1,7 @@
1
+ {
2
+ "final_status": "trusted",
3
+ "signature_status": "verified",
4
+ "trusted_publisher_status": "trusted",
5
+ "errors": [],
6
+ "warnings": []
7
+ }
@@ -0,0 +1,14 @@
1
+ registry_name: "test-fixture-registry"
2
+ publisher: "Mock Valid Publisher"
3
+ version: "1.0.0"
4
+ generated_at: "2026-06-20T12:00:00Z"
5
+ minimum_mmdo_version: "3.2.0"
6
+ safety_policy_version: "1.0.0"
7
+ catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
8
+ files_hashes:
9
+ catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
10
+ signature:
11
+ algorithm: "ed25519"
12
+ key_id: "test-key-valid"
13
+ signature: "CjhqbNnmTh9wzBTBRlnkaCjZDTvxj8rImdtwOQIBOu0I+kCd8ec3x/jcNvJesy/Fqd2yl70vToqKAiVxD0GVAQ=="
14
+ signed_fields: ["registry_name", "publisher", "version", "generated_at", "catalog_hash", "minimum_mmdo_version", "safety_policy_version"]
@@ -0,0 +1,8 @@
1
+
2
+ # Catalog fixture
3
+ catalog:
4
+ plugins:
5
+ - slug: test-plugin
6
+ version: 1.0.0
7
+ name: Test Plugin
8
+ description: A mock plugin for testing
@@ -0,0 +1,7 @@
1
+ {
2
+ "final_status": "untrusted",
3
+ "signature_status": "failed",
4
+ "errors": [
5
+ "Key ID 'test-key-wrong' not found in trust store."
6
+ ]
7
+ }
@@ -0,0 +1,14 @@
1
+ registry_name: "test-fixture-registry"
2
+ publisher: "Mock Valid Publisher"
3
+ version: "1.0.0"
4
+ generated_at: "2026-06-20T12:00:00Z"
5
+ minimum_mmdo_version: "3.2.0"
6
+ safety_policy_version: "1.0.0"
7
+ catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
8
+ files_hashes:
9
+ catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
10
+ signature:
11
+ algorithm: "ed25519"
12
+ key_id: "test-key-wrong"
13
+ signature: "XChca7Nje+hM8QPfHHL8iwVjW8mL9wOeNhZBOo0hloV5CHhaFH9qm0k5e2xvFLMLOc3j+8CMCqJozPdWgiqxBw=="
14
+ signed_fields: ["registry_name", "publisher", "version", "generated_at", "catalog_hash", "minimum_mmdo_version", "safety_policy_version"]
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { readFileSync, mkdirSync, writeFileSync, copyFileSync, rmSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { verifySignatureBlock } from '../../src/registry/signing.js';
6
+ import { parseYaml } from '../../src/core/yaml.js';
7
+ import { createTrustVerdict } from '../../src/registry/verdict.js';
8
+
9
+ const fixturesDir = join(process.cwd(), 'tests', 'fixtures', 'signed-registries');
10
+
11
+ // Helper to load trust store from YAML fixture
12
+ function loadFixtureTrustedKeys() {
13
+ const content = readFileSync(join(fixturesDir, 'trusted-keys.yaml'), 'utf8');
14
+ const parsed = parseYaml(content);
15
+ return parsed.trusted_publishers || [];
16
+ }
17
+
18
+ // Helper to load registry manifest from fixture
19
+ function loadFixtureManifest(folderName) {
20
+ const content = readFileSync(join(fixturesDir, folderName, 'registry-manifest.yaml'), 'utf8');
21
+ return parseYaml(content);
22
+ }
23
+
24
+ // Helper to load expected verdict
25
+ function loadExpectedVerdict(folderName) {
26
+ const content = readFileSync(join(fixturesDir, folderName, 'expected-verdict.json'), 'utf8');
27
+ return JSON.parse(content);
28
+ }
29
+
30
+ describe('Registry E2E Signatures — Fixtures Validation', () => {
31
+ const trustedKeys = loadFixtureTrustedKeys();
32
+
33
+ it('valid-signed-registry passes verification', () => {
34
+ const manifest = loadFixtureManifest('valid-signed-registry');
35
+ const policy = {
36
+ allow_unsigned_remote: false,
37
+ require_signature: true,
38
+ allowed_signature_algorithms: ['ed25519']
39
+ };
40
+ const source = { name: 'official', type: 'remote' };
41
+
42
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
43
+ expect(res.verified).toBe(true);
44
+ expect(res.status).toBe('verified');
45
+
46
+ // Create structured verdict
47
+ const verdict = createTrustVerdict({
48
+ source: source.name,
49
+ source_type: source.type,
50
+ manifest_hash_status: 'verified',
51
+ catalog_hash_status: 'verified',
52
+ lockfile_status: 'present',
53
+ provenance_status: 'matched',
54
+ signature_status: 'verified',
55
+ trusted_publisher_status: 'trusted',
56
+ errors: res.errors || [],
57
+ warnings: [],
58
+ final_status: 'trusted'
59
+ });
60
+
61
+ const expected = loadExpectedVerdict('valid-signed-registry');
62
+ expect(verdict.final_status).toBe(expected.final_status);
63
+ expect(verdict.signature_status).toBe(expected.signature_status);
64
+ expect(verdict.trusted_publisher_status).toBe(expected.trusted_publisher_status);
65
+ });
66
+
67
+ it('tampered-manifest fails verification', () => {
68
+ const manifest = loadFixtureManifest('tampered-manifest');
69
+ const policy = {
70
+ allow_unsigned_remote: false,
71
+ allowed_signature_algorithms: ['ed25519']
72
+ };
73
+ const source = { name: 'official', type: 'remote' };
74
+
75
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
76
+ expect(res.verified).toBe(false);
77
+ expect(res.status).toBe('failed');
78
+ expect(res.errors[0]).toContain('Invalid Ed25519 signature');
79
+
80
+ const expected = loadExpectedVerdict('tampered-manifest');
81
+ expect(res.errors[0]).toContain(expected.errors[0]);
82
+ });
83
+
84
+ it('changed catalog hash fails verification', () => {
85
+ const manifest = loadFixtureManifest('valid-signed-registry');
86
+ // Modify catalog_hash after signing
87
+ manifest.catalog_hash = 'sha256:1111111111111111111111111111111111111111111111111111111111111111';
88
+
89
+ const policy = {
90
+ allow_unsigned_remote: false,
91
+ allowed_signature_algorithms: ['ed25519']
92
+ };
93
+ const source = { name: 'official', type: 'remote' };
94
+
95
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
96
+ expect(res.verified).toBe(false);
97
+ expect(res.status).toBe('failed');
98
+ expect(res.errors[0]).toContain('Invalid Ed25519 signature');
99
+ });
100
+
101
+ it('wrong trusted key fails verification', () => {
102
+ const manifest = loadFixtureManifest('wrong-key');
103
+ const policy = {
104
+ allow_unsigned_remote: false,
105
+ allowed_signature_algorithms: ['ed25519']
106
+ };
107
+ const source = { name: 'official', type: 'remote' };
108
+
109
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
110
+ expect(res.verified).toBe(false);
111
+ expect(res.status).toBe('failed');
112
+ expect(res.errors[0]).toContain('not found in trust store');
113
+
114
+ const expected = loadExpectedVerdict('wrong-key');
115
+ expect(res.errors[0]).toContain(expected.errors[0]);
116
+ });
117
+
118
+ it('revoked key fails verification', () => {
119
+ const manifest = loadFixtureManifest('revoked-key');
120
+ const policy = {
121
+ allow_unsigned_remote: false,
122
+ allowed_signature_algorithms: ['ed25519']
123
+ };
124
+ const source = { name: 'official', type: 'remote' };
125
+
126
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
127
+ expect(res.verified).toBe(false);
128
+ expect(res.status).toBe('failed');
129
+ expect(res.errors[0]).toContain('is revoked (must be active)');
130
+
131
+ const expected = loadExpectedVerdict('revoked-key');
132
+ expect(res.errors[0]).toContain(expected.errors[0]);
133
+ });
134
+
135
+ it('disabled key fails verification', () => {
136
+ const manifest = loadFixtureManifest('valid-signed-registry');
137
+ // Mutate the valid manifest key_id to test-key-disabled
138
+ manifest.signature.key_id = 'test-key-disabled';
139
+
140
+ const policy = {
141
+ allow_unsigned_remote: false,
142
+ allowed_signature_algorithms: ['ed25519']
143
+ };
144
+ const source = { name: 'official', type: 'remote' };
145
+
146
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
147
+ expect(res.verified).toBe(false);
148
+ expect(res.status).toBe('failed');
149
+ expect(res.errors[0]).toContain('is disabled (must be active)');
150
+ });
151
+
152
+ it('scope mismatch fails verification', () => {
153
+ const manifest = loadFixtureManifest('valid-signed-registry');
154
+
155
+ // Create a trust store where valid-key has scope mismatch (e.g. only 'plugin' scope)
156
+ const customTrustedKeys = trustedKeys.map(k => {
157
+ if (k.key_id === 'test-key-valid') {
158
+ return { ...k, scopes: ['plugin'] };
159
+ }
160
+ return k;
161
+ });
162
+
163
+ const policy = {
164
+ allow_unsigned_remote: false,
165
+ allowed_signature_algorithms: ['ed25519']
166
+ };
167
+ const source = { name: 'official', type: 'remote' };
168
+
169
+ const res = verifySignatureBlock({ manifest, trustedKeys: customTrustedKeys, policy, source });
170
+ expect(res.verified).toBe(false);
171
+ expect(res.status).toBe('failed');
172
+ expect(res.errors[0]).toContain('does not have required scope');
173
+ });
174
+
175
+ it('unsigned remote registry fails verification when allow_unsigned_remote is false', () => {
176
+ const manifest = loadFixtureManifest('unsigned-remote-required');
177
+ const policy = {
178
+ allow_unsigned_remote: false,
179
+ require_signature: false,
180
+ allowed_signature_algorithms: ['ed25519']
181
+ };
182
+ const source = { name: 'official', type: 'remote' };
183
+
184
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
185
+ expect(res.verified).toBe(false);
186
+ expect(res.status).toBe('failed');
187
+ expect(res.error).toContain('Unsigned remote registries are not allowed');
188
+
189
+ const expected = loadExpectedVerdict('unsigned-remote-required');
190
+ expect(res.error).toContain(expected.errors[0]);
191
+ });
192
+
193
+ it('unsupported algorithm fails verification', () => {
194
+ const manifest = loadFixtureManifest('unsupported-algorithm');
195
+ const policy = {
196
+ allow_unsigned_remote: false,
197
+ allowed_signature_algorithms: ['ed25519', 'hmac-sha256']
198
+ };
199
+ const source = { name: 'official', type: 'remote' };
200
+ const res = verifySignatureBlock({ manifest, trustedKeys, policy, source });
201
+ expect(res.verified).toBe(false);
202
+ expect(res.status).toBe('failed');
203
+ expect(res.errors[0]).toContain('Signature algorithm \'rsa-sha256\' is not allowed by policy');
204
+
205
+ const expected = loadExpectedVerdict('unsupported-algorithm');
206
+ expect(res.errors[0]).toContain(expected.errors[0]);
207
+ });
208
+ });
209
+
210
+ describe('Registry CLI Integration E2E Tests', () => {
211
+ const tempCliDir = join(process.cwd(), 'tests', 'fixtures', 'temp-cli-trust');
212
+ const cliPath = join(process.cwd(), 'bin', 'multimodel-dev-os.js');
213
+
214
+ beforeAll(() => {
215
+ if (existsSync(tempCliDir)) {
216
+ rmSync(tempCliDir, { recursive: true, force: true });
217
+ }
218
+ mkdirSync(join(tempCliDir, '.ai', 'registries'), { recursive: true });
219
+ copyFileSync(
220
+ join(fixturesDir, 'trusted-keys.yaml'),
221
+ join(tempCliDir, '.ai', 'registries', 'trusted-keys.yaml')
222
+ );
223
+ copyFileSync(
224
+ join(process.cwd(), 'examples', 'general-app', '.ai', 'config.yaml'),
225
+ join(tempCliDir, '.ai', 'config.yaml')
226
+ );
227
+ });
228
+
229
+ afterAll(() => {
230
+ if (existsSync(tempCliDir)) {
231
+ rmSync(tempCliDir, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ const stripAnsi = (str) => str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
236
+
237
+ it('registry trust list outputs keys from trust store', () => {
238
+ const rawOutput = execSync(`node ${cliPath} registry trust list --target ${tempCliDir}`, { encoding: 'utf8' });
239
+ const output = stripAnsi(rawOutput);
240
+ expect(output).toContain('test-key-valid');
241
+ expect(output).toContain('test-key-revoked');
242
+ expect(output).toContain('test-key-disabled');
243
+ expect(output).toContain('Total Keys: 3');
244
+ });
245
+
246
+ it('registry trust show test-key-valid outputs correct key details', () => {
247
+ const rawOutput = execSync(`node ${cliPath} registry trust show test-key-valid --target ${tempCliDir}`, { encoding: 'utf8' });
248
+ const output = stripAnsi(rawOutput);
249
+ expect(output).toContain('Key ID: test-key-valid');
250
+ expect(output).toContain('Publisher: Mock Valid Publisher');
251
+ expect(output).toContain('Algorithm: ed25519');
252
+ expect(output).toContain('Public Key:');
253
+ expect(output).toContain('MCowBQYDK2VwAyE');
254
+ });
255
+
256
+ it('registry trust verify validates format of keys in trust store', () => {
257
+ const rawOutput = execSync(`node ${cliPath} registry trust verify --target ${tempCliDir}`, { encoding: 'utf8' });
258
+ const output = stripAnsi(rawOutput);
259
+ expect(output).toContain("Key 'test-key-valid' public key format is valid.");
260
+ expect(output).toContain("Key 'test-key-revoked' public key format is valid.");
261
+ expect(output).toContain("Key 'test-key-disabled' public key format is valid.");
262
+ expect(output).toContain('Trust store verification passed.');
263
+ });
264
+
265
+ it('registry verify bundled passes', () => {
266
+ const rawOutput = execSync(`node ${cliPath} registry verify bundled --target ${tempCliDir}`, { encoding: 'utf8' });
267
+ const output = stripAnsi(rawOutput);
268
+ expect(output).toContain('Final Trust: ✓ Verified (Implicit local trust)');
269
+ });
270
+
271
+ it('registry status displays security/signature policy', () => {
272
+ const rawOutput = execSync(`node ${cliPath} registry status --target ${tempCliDir}`, { encoding: 'utf8' });
273
+ const output = stripAnsi(rawOutput);
274
+ expect(output).toContain('Policy State:');
275
+ expect(output).toContain('require_signature:');
276
+ expect(output).toContain('allow_unsigned_remote:');
277
+ });
278
+
279
+ it('registry sync fails safely without approval', () => {
280
+ try {
281
+ execSync(`node ${cliPath} registry sync official --target ${tempCliDir}`, { encoding: 'utf8', stdio: 'pipe' });
282
+ throw new Error('Should have failed');
283
+ } catch (err) {
284
+ expect(err.message).toContain('Command failed');
285
+ }
286
+ });
287
+ });
288
+
@@ -24,6 +24,12 @@ describe('Registry Policy Engine', () => {
24
24
  expect(policy.allow_http_localhost).toBe(false);
25
25
  expect(policy.require_checksum).toBe(true);
26
26
  expect(policy.allowed_write_roots).toEqual(['.ai/', 'adapters/']);
27
+ expect(policy.allow_unsigned_local).toBe(true);
28
+ expect(policy.allow_unsigned_bundled).toBe(true);
29
+ expect(policy.allow_unsigned_remote).toBe(false);
30
+ expect(policy.require_trusted_publisher).toBe(false);
31
+ expect(policy.provenance_required).toBe(true);
32
+ expect(policy.allowed_signature_algorithms).toEqual(['ed25519', 'hmac-sha256']);
27
33
  });
28
34
 
29
35
  it('should override default fields with written policy configurations', () => {
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import {
5
+ loadRegistryLockfile,
6
+ saveRegistryLockfile,
7
+ updateLockfileEntry,
8
+ getLockfilePath
9
+ } from '../../src/registry/provenance.js';
10
+
11
+ const tempDir = join(process.cwd(), 'temp-provenance-test');
12
+
13
+ describe('Registry Provenance — loadRegistryLockfile', () => {
14
+ beforeAll(() => {
15
+ mkdirSync(tempDir, { recursive: true });
16
+ });
17
+
18
+ afterAll(() => {
19
+ if (existsSync(tempDir)) {
20
+ rmSync(tempDir, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ it('returns an empty well-formed structure when no lockfile exists', () => {
25
+ const result = loadRegistryLockfile(tempDir);
26
+ expect(result).toEqual({
27
+ lockfile_version: '1',
28
+ generated_at: '',
29
+ entries: {}
30
+ });
31
+ });
32
+
33
+ it('returns empty structure if lockfile JSON is malformed', () => {
34
+ const aiDir = join(tempDir, '.ai');
35
+ mkdirSync(aiDir, { recursive: true });
36
+ writeFileSync(join(aiDir, 'registry-lock.json'), 'NOT VALID JSON', 'utf8');
37
+ const result = loadRegistryLockfile(tempDir);
38
+ expect(result.entries).toEqual({});
39
+ });
40
+
41
+ it('returns empty structure if lockfile JSON has no entries field', () => {
42
+ const aiDir = join(tempDir, '.ai');
43
+ mkdirSync(aiDir, { recursive: true });
44
+ writeFileSync(join(aiDir, 'registry-lock.json'), JSON.stringify({ lockfile_version: '1' }), 'utf8');
45
+ const result = loadRegistryLockfile(tempDir);
46
+ expect(result.entries).toEqual({});
47
+ });
48
+ });
49
+
50
+
51
+ describe('Registry Provenance — updateLockfileEntry', () => {
52
+ it('upserts a new entry into an empty lockfile', () => {
53
+ const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
54
+ const entry = {
55
+ url: 'https://example.com/catalog.yaml',
56
+ synced_at: '2026-01-01T00:00:00.000Z',
57
+ catalog_sha256: 'abc123',
58
+ manifest_sha256: null,
59
+ signature: null,
60
+ signature_alg: 'hmac-sha256'
61
+ };
62
+ updateLockfileEntry(lockfile, 'official', entry);
63
+ expect(lockfile.entries['official']).toBeDefined();
64
+ expect(lockfile.entries['official'].url).toBe('https://example.com/catalog.yaml');
65
+ expect(lockfile.entries['official'].catalog_sha256).toBe('abc123');
66
+ expect(lockfile.entries['official'].signature).toBeNull();
67
+ });
68
+
69
+ it('overwrites an existing entry for the same registry name', () => {
70
+ const lockfile = {
71
+ lockfile_version: '1',
72
+ generated_at: '',
73
+ entries: {
74
+ official: {
75
+ url: 'https://old.example.com/catalog.yaml',
76
+ synced_at: '2025-01-01T00:00:00.000Z',
77
+ catalog_sha256: 'oldhash',
78
+ manifest_sha256: null,
79
+ signature: null,
80
+ signature_alg: 'hmac-sha256'
81
+ }
82
+ }
83
+ };
84
+ updateLockfileEntry(lockfile, 'official', {
85
+ url: 'https://new.example.com/catalog.yaml',
86
+ synced_at: '2026-06-01T00:00:00.000Z',
87
+ catalog_sha256: 'newhash',
88
+ manifest_sha256: 'mhash',
89
+ signature: 'sig123',
90
+ signature_alg: 'hmac-sha256'
91
+ });
92
+ expect(lockfile.entries['official'].catalog_sha256).toBe('newhash');
93
+ expect(lockfile.entries['official'].signature).toBe('sig123');
94
+ expect(lockfile.entries['official'].url).toBe('https://new.example.com/catalog.yaml');
95
+ });
96
+
97
+ it('stores null for optional fields when not provided', () => {
98
+ const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
99
+ updateLockfileEntry(lockfile, 'test', {
100
+ url: 'https://example.com/catalog.yaml',
101
+ catalog_sha256: 'hash1'
102
+ });
103
+ expect(lockfile.entries['test'].manifest_sha256).toBeNull();
104
+ expect(lockfile.entries['test'].signature).toBeNull();
105
+ expect(lockfile.entries['test'].signature_alg).toBe('hmac-sha256');
106
+ expect(lockfile.entries['test'].public_signature_status).toBeNull();
107
+ expect(lockfile.entries['test'].public_signature_algorithm).toBeNull();
108
+ expect(lockfile.entries['test'].public_signature_key_id).toBeNull();
109
+ expect(lockfile.entries['test'].trusted_publisher_status).toBeNull();
110
+ expect(lockfile.entries['test'].trust_store_path).toBeNull();
111
+ expect(lockfile.entries['test'].trust_verdict).toBeNull();
112
+ expect(lockfile.entries['test'].lockfile_verdict).toBeNull();
113
+ expect(lockfile.entries['test'].verification_errors).toEqual([]);
114
+ expect(lockfile.entries['test'].verification_warnings).toEqual([]);
115
+ });
116
+
117
+ it('initialises entries object if missing from lockfile', () => {
118
+ const lockfile = { lockfile_version: '1', generated_at: '' };
119
+ updateLockfileEntry(lockfile, 'test', {
120
+ url: 'https://example.com/catalog.yaml',
121
+ catalog_sha256: 'hash1'
122
+ });
123
+ expect(lockfile.entries).toBeDefined();
124
+ expect(lockfile.entries['test']).toBeDefined();
125
+ });
126
+ });
127
+
128
+ describe('Registry Provenance — saveRegistryLockfile + round-trip', () => {
129
+ const roundTripDir = join(process.cwd(), 'temp-provenance-roundtrip');
130
+
131
+ beforeEach(() => {
132
+ mkdirSync(roundTripDir, { recursive: true });
133
+ });
134
+
135
+ afterAll(() => {
136
+ if (existsSync(roundTripDir)) {
137
+ rmSync(roundTripDir, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ it('writes and re-reads the lockfile with correct content', () => {
142
+ const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
143
+ updateLockfileEntry(lockfile, 'alpha', {
144
+ url: 'https://alpha.example.com/catalog.yaml',
145
+ synced_at: '2026-06-01T10:00:00.000Z',
146
+ catalog_sha256: 'deadbeef',
147
+ manifest_sha256: 'cafe1234',
148
+ signature: 'sig_hex_abc',
149
+ signature_alg: 'hmac-sha256'
150
+ });
151
+ saveRegistryLockfile(roundTripDir, lockfile);
152
+
153
+ const reloaded = loadRegistryLockfile(roundTripDir);
154
+ expect(reloaded.lockfile_version).toBe('1');
155
+ expect(reloaded.generated_at).toBeTruthy();
156
+ expect(reloaded.entries['alpha'].catalog_sha256).toBe('deadbeef');
157
+ expect(reloaded.entries['alpha'].signature).toBe('sig_hex_abc');
158
+ expect(reloaded.entries['alpha'].manifest_sha256).toBe('cafe1234');
159
+ });
160
+
161
+ it('getLockfilePath returns the expected path', () => {
162
+ const expectedPath = join(roundTripDir, '.ai', 'registry-lock.json');
163
+ expect(getLockfilePath(roundTripDir)).toBe(expectedPath);
164
+ });
165
+
166
+ it('round-trip is idempotent for multiple saves', () => {
167
+ const lockfile1 = { lockfile_version: '1', generated_at: '', entries: {} };
168
+ updateLockfileEntry(lockfile1, 'beta', {
169
+ url: 'https://beta.example.com/catalog.yaml',
170
+ catalog_sha256: 'hash_beta'
171
+ });
172
+ saveRegistryLockfile(roundTripDir, lockfile1);
173
+
174
+ const lockfile2 = loadRegistryLockfile(roundTripDir);
175
+ updateLockfileEntry(lockfile2, 'gamma', {
176
+ url: 'https://gamma.example.com/catalog.yaml',
177
+ catalog_sha256: 'hash_gamma'
178
+ });
179
+ saveRegistryLockfile(roundTripDir, lockfile2);
180
+
181
+ const reloaded = loadRegistryLockfile(roundTripDir);
182
+ expect(reloaded.entries['beta'].catalog_sha256).toBe('hash_beta');
183
+ expect(reloaded.entries['gamma'].catalog_sha256).toBe('hash_gamma');
184
+ });
185
+ });