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.
Files changed (102) 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 +2 -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 +17 -0
  17. package/docs/public/llms-full.txt +5 -1
  18. package/docs/public/llms.txt +6 -1
  19. package/docs/public/sitemap.xml +15 -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 +6 -5
  26. package/docs/security-threat-model.md +96 -0
  27. package/docs/testing.md +25 -2
  28. package/docs/trusted-registries.md +1 -1
  29. package/docs/v3-roadmap.md +17 -6
  30. package/docs/v3.5.0-readiness.md +46 -0
  31. package/package.json +5 -2
  32. package/scripts/build-cli.js +45 -3
  33. package/scripts/check-build-fresh.js +52 -0
  34. package/scripts/install.ps1 +1 -1
  35. package/scripts/install.sh +1 -1
  36. package/scripts/verify.js +327 -14
  37. package/scripts/verify.sh +10 -0
  38. package/src/catalog/loader.js +117 -0
  39. package/src/cli/args.js +118 -0
  40. package/src/cli/help.js +60 -0
  41. package/src/cli/main.js +6263 -0
  42. package/src/core/globals.js +52 -0
  43. package/src/core/hashes.js +15 -0
  44. package/src/core/policy.js +44 -0
  45. package/src/core/security.js +61 -0
  46. package/src/core/yaml.js +136 -0
  47. package/src/plugin/manifest.js +95 -0
  48. package/src/registry/provenance.js +114 -0
  49. package/src/registry/signing.js +392 -0
  50. package/src/registry/sources.js +40 -0
  51. package/src/registry/trust-store.js +41 -0
  52. package/src/registry/validation.js +45 -0
  53. package/src/registry/verdict.js +51 -0
  54. package/tests/README.md +37 -0
  55. package/tests/fixtures/README.md +22 -0
  56. package/tests/fixtures/custom-template-example/README.md +10 -0
  57. package/tests/fixtures/proposals/approved-append-line.md +28 -0
  58. package/tests/fixtures/proposals/approved-create-file.md +29 -0
  59. package/tests/fixtures/proposals/approved-replace-text.md +30 -0
  60. package/tests/fixtures/proposals/existing-create-file-no-overwrite.md +29 -0
  61. package/tests/fixtures/proposals/no-operations.md +18 -0
  62. package/tests/fixtures/proposals/path-traversal.md +29 -0
  63. package/tests/fixtures/proposals/pending-proposal.md +29 -0
  64. package/tests/fixtures/proposals/protected-path.md +29 -0
  65. package/tests/fixtures/proposals/replace-multiple-without-allow.md +30 -0
  66. package/tests/fixtures/registry-overrides/README.md +20 -0
  67. package/tests/fixtures/signed-registries/README.md +4 -0
  68. package/tests/fixtures/signed-registries/revoked-key/catalog.yaml +8 -0
  69. package/tests/fixtures/signed-registries/revoked-key/expected-verdict.json +7 -0
  70. package/tests/fixtures/signed-registries/revoked-key/registry-manifest.yaml +14 -0
  71. package/tests/fixtures/signed-registries/tampered-manifest/catalog.yaml +8 -0
  72. package/tests/fixtures/signed-registries/tampered-manifest/expected-verdict.json +7 -0
  73. package/tests/fixtures/signed-registries/tampered-manifest/registry-manifest.yaml +14 -0
  74. package/tests/fixtures/signed-registries/trusted-keys.yaml +23 -0
  75. package/tests/fixtures/signed-registries/unsigned-remote-required/catalog.yaml +8 -0
  76. package/tests/fixtures/signed-registries/unsigned-remote-required/expected-verdict.json +7 -0
  77. package/tests/fixtures/signed-registries/unsigned-remote-required/registry-manifest.yaml +9 -0
  78. package/tests/fixtures/signed-registries/unsupported-algorithm/catalog.yaml +8 -0
  79. package/tests/fixtures/signed-registries/unsupported-algorithm/expected-verdict.json +7 -0
  80. package/tests/fixtures/signed-registries/unsupported-algorithm/registry-manifest.yaml +14 -0
  81. package/tests/fixtures/signed-registries/valid-signed-registry/catalog.yaml +8 -0
  82. package/tests/fixtures/signed-registries/valid-signed-registry/expected-verdict.json +7 -0
  83. package/tests/fixtures/signed-registries/valid-signed-registry/registry-manifest.yaml +14 -0
  84. package/tests/fixtures/signed-registries/wrong-key/catalog.yaml +8 -0
  85. package/tests/fixtures/signed-registries/wrong-key/expected-verdict.json +7 -0
  86. package/tests/fixtures/signed-registries/wrong-key/registry-manifest.yaml +14 -0
  87. package/tests/smoke/README.md +37 -0
  88. package/tests/smoke/cli-smoke.md +49 -0
  89. package/tests/unit/build-output.test.js +40 -0
  90. package/tests/unit/catalog-loader.test.js +44 -0
  91. package/tests/unit/path-safety.test.js +62 -0
  92. package/tests/unit/plugin-manifest.test.js +94 -0
  93. package/tests/unit/prepublish-guard.test.js +35 -0
  94. package/tests/unit/registry-e2e-signature-fixtures.test.js +288 -0
  95. package/tests/unit/registry-policy.test.js +52 -0
  96. package/tests/unit/registry-provenance.test.js +185 -0
  97. package/tests/unit/registry-public-signing.test.js +109 -0
  98. package/tests/unit/registry-signature-policy.test.js +100 -0
  99. package/tests/unit/registry-signing.test.js +193 -0
  100. package/tests/unit/registry-trust-store.test.js +133 -0
  101. package/tests/unit/registry-url-validation.test.js +64 -0
  102. package/tests/unit/yaml.test.js +92 -0
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isSafePath, shouldIgnorePath } from '../../src/core/security.js';
3
+
4
+ describe('Path Safety and Ignore Engine', () => {
5
+ describe('isSafePath', () => {
6
+ const policy = {
7
+ allowed_write_roots: ['.ai/', 'adapters/'],
8
+ blocked_paths: ['.env', '.npmrc', '.git/', 'node_modules/', 'package.json', 'package-lock.json']
9
+ };
10
+
11
+ it('should allow paths under allowed write roots', () => {
12
+ expect(isSafePath('.ai/plugins/my-plugin.yaml', policy)).toBe(true);
13
+ expect(isSafePath('adapters/vscode/settings.json', policy)).toBe(true);
14
+ });
15
+
16
+ it('should reject paths outside allowed write roots', () => {
17
+ expect(isSafePath('src/cli/main.js', policy)).toBe(false);
18
+ expect(isSafePath('index.js', policy)).toBe(false);
19
+ });
20
+
21
+ it('should reject paths with directory traversal', () => {
22
+ expect(isSafePath('.ai/plugins/../../index.js', policy)).toBe(false);
23
+ expect(isSafePath('adapters/../package.json', policy)).toBe(false);
24
+ });
25
+
26
+ it('should reject absolute paths', () => {
27
+ expect(isSafePath('/etc/passwd', policy)).toBe(false);
28
+ expect(isSafePath('C:/Windows/System32', policy)).toBe(false);
29
+ });
30
+
31
+ it('should reject blacklisted paths', () => {
32
+ expect(isSafePath('.ai/plugins/.env', policy)).toBe(false);
33
+ expect(isSafePath('adapters/.npmrc', policy)).toBe(false);
34
+ expect(isSafePath('adapters/node_modules/foo', policy)).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('shouldIgnorePath', () => {
39
+ it('should ignore dependency and git folders', () => {
40
+ expect(shouldIgnorePath('node_modules/lodash/index.js')).toBe(true);
41
+ expect(shouldIgnorePath('.git/config')).toBe(true);
42
+ expect(shouldIgnorePath('dist/bundle.js')).toBe(true);
43
+ });
44
+
45
+ it('should ignore generated runtime memory files', () => {
46
+ expect(shouldIgnorePath('.ai/intelligence/memory.hash.json')).toBe(true);
47
+ expect(shouldIgnorePath('.ai/intelligence/feedback-log.jsonl')).toBe(true);
48
+ });
49
+
50
+ it('should ignore credential and secret files', () => {
51
+ expect(shouldIgnorePath('.env')).toBe(true);
52
+ expect(shouldIgnorePath('src/.env.production')).toBe(true);
53
+ expect(shouldIgnorePath('key.pem')).toBe(true);
54
+ expect(shouldIgnorePath('id_rsa')).toBe(true);
55
+ });
56
+
57
+ it('should not ignore standard application source files', () => {
58
+ expect(shouldIgnorePath('src/core/yaml.js')).toBe(false);
59
+ expect(shouldIgnorePath('README.md')).toBe(false);
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validatePluginManifest } from '../../src/plugin/manifest.js';
3
+
4
+ describe('Plugin Manifest Validator', () => {
5
+ it('should accept a completely valid plugin manifest', () => {
6
+ const manifest = {
7
+ name: 'My Test Plugin',
8
+ slug: 'my-test-plugin',
9
+ version: '1.0.0',
10
+ description: 'A valid test plugin description',
11
+ author: 'Tester',
12
+ allowed_file_patterns: ['.ai/plugins/config.yaml', 'adapters/vscode/settings.json']
13
+ };
14
+
15
+ const result = validatePluginManifest(manifest);
16
+ expect(result.success).toBe(true);
17
+ expect(result.errors).toHaveLength(0);
18
+ });
19
+
20
+ it('should reject missing metadata keys', () => {
21
+ const manifest = {
22
+ name: 'My Test Plugin',
23
+ version: '1.0.0',
24
+ description: 'A description',
25
+ author: 'Tester'
26
+ // slug is missing
27
+ };
28
+
29
+ const result = validatePluginManifest(manifest);
30
+ expect(result.success).toBe(false);
31
+ expect(result.errors).toContain('Missing required key: slug');
32
+ });
33
+
34
+ it('should reject invalid slug formats', () => {
35
+ const manifest = {
36
+ name: 'My Test Plugin',
37
+ slug: 'invalid slug here',
38
+ version: '1.0.0',
39
+ description: 'A description',
40
+ author: 'Tester'
41
+ };
42
+
43
+ const result = validatePluginManifest(manifest);
44
+ expect(result.success).toBe(false);
45
+ expect(result.errors).toContain("Key 'slug' must be alphanumeric with dashes or underscores only");
46
+ });
47
+
48
+ it('should reject patterns writing outside allowed write roots', () => {
49
+ const manifest = {
50
+ name: 'My Test Plugin',
51
+ slug: 'my-test-plugin',
52
+ version: '1.0.0',
53
+ description: 'A description',
54
+ author: 'Tester',
55
+ allowed_file_patterns: ['src/cli/main.js'] // not in .ai/ or adapters/
56
+ };
57
+
58
+ const result = validatePluginManifest(manifest);
59
+ expect(result.success).toBe(false);
60
+ expect(result.errors[0]).toContain('violates safety boundaries');
61
+ });
62
+
63
+ it('should reject patterns containing traversal or leading slash', () => {
64
+ const manifest = {
65
+ name: 'My Test Plugin',
66
+ slug: 'my-test-plugin',
67
+ version: '1.0.0',
68
+ description: 'A description',
69
+ author: 'Tester',
70
+ allowed_file_patterns: ['.ai/plugins/../../index.js', '/.ai/plugins/test.yaml']
71
+ };
72
+
73
+ const result = validatePluginManifest(manifest);
74
+ expect(result.success).toBe(false);
75
+ expect(result.errors).toHaveLength(2);
76
+ expect(result.errors[0]).toContain('violates safety boundaries');
77
+ expect(result.errors[1]).toContain('violates safety boundaries');
78
+ });
79
+
80
+ it('should reject patterns containing blacklisted files', () => {
81
+ const manifest = {
82
+ name: 'My Test Plugin',
83
+ slug: 'my-test-plugin',
84
+ version: '1.0.0',
85
+ description: 'A description',
86
+ author: 'Tester',
87
+ allowed_file_patterns: ['.ai/plugins/.env', 'adapters/package.json']
88
+ };
89
+
90
+ const result = validatePluginManifest(manifest);
91
+ expect(result.success).toBe(false);
92
+ expect(result.errors).toHaveLength(2);
93
+ });
94
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execSync } from 'child_process';
3
+ import { join } from 'path';
4
+
5
+ describe('Prepublish Guard', () => {
6
+ const guardPath = join(process.cwd(), 'scripts', 'prepublish-guard.js');
7
+
8
+ it('should block publish when MMDO_ALLOW_PUBLISH is not set', () => {
9
+ try {
10
+ execSync(`node "${guardPath}"`, {
11
+ stdio: 'pipe',
12
+ env: {
13
+ ...process.env,
14
+ MMDO_ALLOW_PUBLISH: 'false' // explicitly set to false to override any ambient env
15
+ }
16
+ });
17
+ throw new Error('Prepublish guard did not block the publish.');
18
+ } catch (err) {
19
+ expect(err.status).toBe(1);
20
+ expect(err.stderr.toString()).toContain('Publishing requires explicit release approval');
21
+ }
22
+ });
23
+
24
+ it('should allow publish when MMDO_ALLOW_PUBLISH=true for stable version >= 2', () => {
25
+ const output = execSync(`node "${guardPath}"`, {
26
+ env: {
27
+ ...process.env,
28
+ MMDO_ALLOW_PUBLISH: 'true',
29
+ MMDO_ALLOW_PRERELEASE_PUBLISH: undefined
30
+ },
31
+ encoding: 'utf8'
32
+ });
33
+ expect(output).toContain('Prepublish guard passed');
34
+ });
35
+ });
@@ -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
+
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { loadRegistryPolicy } from '../../src/core/policy.js';
3
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ describe('Registry Policy Engine', () => {
7
+ const tempDir = join(process.cwd(), 'temp-policy-test');
8
+ const policySubdir = join(tempDir, '.ai', 'policies');
9
+ const policyFile = join(policySubdir, 'registry-policy.yaml');
10
+
11
+ beforeAll(() => {
12
+ mkdirSync(policySubdir, { recursive: true });
13
+ });
14
+
15
+ afterAll(() => {
16
+ if (existsSync(tempDir)) {
17
+ rmSync(tempDir, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it('should load default policy fields if no file is found', () => {
22
+ const policy = loadRegistryPolicy('non-existent-directory-random-path');
23
+ expect(policy.allow_remote_registries).toBe(false);
24
+ expect(policy.allow_http_localhost).toBe(false);
25
+ expect(policy.require_checksum).toBe(true);
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']);
33
+ });
34
+
35
+ it('should override default fields with written policy configurations', () => {
36
+ const yamlConfig = `
37
+ allow_remote_registries: true
38
+ allow_http_localhost: true
39
+ require_checksum: false
40
+ max_plugin_files: 50
41
+ `;
42
+ writeFileSync(policyFile, yamlConfig, 'utf8');
43
+
44
+ const policy = loadRegistryPolicy(tempDir);
45
+ expect(policy.allow_remote_registries).toBe(true);
46
+ expect(policy.allow_http_localhost).toBe(true);
47
+ expect(policy.require_checksum).toBe(false);
48
+ expect(policy.max_plugin_files).toBe(50);
49
+ // Unspecified fields should keep defaults
50
+ expect(policy.max_registry_cache_size_kb).toBe(512);
51
+ });
52
+ });