multimodel-dev-os 3.1.0 → 3.2.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/README.md +1 -0
- package/docs/.vitepress/config.js +1 -1
- package/docs/index.md +5 -5
- package/docs/npm-publishing.md +5 -5
- package/docs/package-safety.md +16 -0
- package/docs/public/llms-full.txt +1 -1
- package/docs/public/llms.txt +1 -1
- package/docs/public/sitemap.xml +5 -0
- package/docs/release-policy.md +6 -5
- package/docs/testing.md +12 -2
- package/docs/v3-roadmap.md +8 -2
- 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 +128 -12
- 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 +5718 -0
- package/src/core/globals.js +52 -0
- package/src/core/hashes.js +15 -0
- package/src/core/policy.js +36 -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/sources.js +40 -0
- package/src/registry/validation.js +45 -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/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-policy.test.js +46 -0
- package/tests/unit/registry-url-validation.test.js +64 -0
- package/tests/unit/yaml.test.js +92 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-333333
|
|
3
|
+
created_at: 2026-06-11T33:33:33Z
|
|
4
|
+
title: No Operations Proposal
|
|
5
|
+
problem: Vague proposal without machine readable block.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- README.md
|
|
10
|
+
suggested_change: Edit README.md manually.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: git checkout -- README.md
|
|
13
|
+
approval_status: approved
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# No Operations Proposal
|
|
17
|
+
|
|
18
|
+
Please edit the README.md to describe the codebase in more detail.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-000003
|
|
3
|
+
created_at: 2026-06-11T00:00:03Z
|
|
4
|
+
title: Path Traversal Proposal
|
|
5
|
+
problem: Test directory traversal boundaries check.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- ../outside.md
|
|
10
|
+
suggested_change: Modify file outside root.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: N/A
|
|
13
|
+
approval_status: approved
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Path Traversal Proposal
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"operations": [
|
|
21
|
+
{
|
|
22
|
+
"type": "create_file",
|
|
23
|
+
"path": "../outside-file.md",
|
|
24
|
+
"content": "outside content\n",
|
|
25
|
+
"overwrite": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-111111
|
|
3
|
+
created_at: 2026-06-11T11:11:11Z
|
|
4
|
+
title: Pending Proposal
|
|
5
|
+
problem: Test pending proposal.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- tests/fixtures/custom-template-example/pending.md
|
|
10
|
+
suggested_change: Create pending file.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: rm tests/fixtures/custom-template-example/pending.md
|
|
13
|
+
approval_status: pending
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Pending Proposal
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"operations": [
|
|
21
|
+
{
|
|
22
|
+
"type": "create_file",
|
|
23
|
+
"path": "tests/fixtures/custom-template-example/pending.md",
|
|
24
|
+
"content": "pending\n",
|
|
25
|
+
"overwrite": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-222222
|
|
3
|
+
created_at: 2026-06-11T22:22:22Z
|
|
4
|
+
title: Protected Path Proposal
|
|
5
|
+
problem: Try to modify protected path.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- .env
|
|
10
|
+
suggested_change: Modify .env file.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: git checkout -- .env
|
|
13
|
+
approval_status: approved
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Protected Path Proposal
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"operations": [
|
|
21
|
+
{
|
|
22
|
+
"type": "create_file",
|
|
23
|
+
"path": ".env",
|
|
24
|
+
"content": "SECRET_KEY=stolen\n",
|
|
25
|
+
"overwrite": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: proposal-20260611-000005
|
|
3
|
+
created_at: 2026-06-11T00:00:05Z
|
|
4
|
+
title: Replace Multiple Without Allow Proposal
|
|
5
|
+
problem: Test replace text matching multiple times without allow_multiple.
|
|
6
|
+
evidence: N/A
|
|
7
|
+
risk_level: low
|
|
8
|
+
affected_files:
|
|
9
|
+
- tests/fixtures/custom-template-example/multiple.md
|
|
10
|
+
suggested_change: Replace multiple occurrences without allow_multiple flag.
|
|
11
|
+
verify_command: npm run verify
|
|
12
|
+
rollback_plan: N/A
|
|
13
|
+
approval_status: approved
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Replace Multiple Without Allow Proposal
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"operations": [
|
|
21
|
+
{
|
|
22
|
+
"type": "replace_text",
|
|
23
|
+
"path": "tests/fixtures/custom-template-example/multiple.md",
|
|
24
|
+
"find": "target",
|
|
25
|
+
"replace": "replaced",
|
|
26
|
+
"allow_multiple": false
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Registry Overrides Test Fixture Guide
|
|
2
|
+
|
|
3
|
+
This fixture folder demonstrates and validates overriding the default model/adapter/template registries using custom YAML files and the `--registry` flag.
|
|
4
|
+
|
|
5
|
+
## Files Structure
|
|
6
|
+
|
|
7
|
+
For testing registry overrides, you can specify custom templates/adapters files:
|
|
8
|
+
|
|
9
|
+
* `custom-templates.yaml`: Defines mock template profiles.
|
|
10
|
+
* `custom-adapters.yaml`: Defines mock adapters setup.
|
|
11
|
+
|
|
12
|
+
## Usage in Testing
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# List templates from the custom templates fixture file
|
|
16
|
+
node bin/multimodel-dev-os.js templates --registry tests/fixtures/registry-overrides/custom-templates.yaml
|
|
17
|
+
|
|
18
|
+
# Run validations on a custom template entry
|
|
19
|
+
node bin/multimodel-dev-os.js validate-template my-mock-template --registry tests/fixtures/registry-overrides/custom-templates.yaml
|
|
20
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Smoke Tests Playbook
|
|
2
|
+
|
|
3
|
+
Follow these quick manual verification commands to assert the functional integrity of `multimodel-dev-os` before pushing tag releases.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Quick Verification Pipeline
|
|
8
|
+
|
|
9
|
+
Run the following actions sequentially inside a target sandbox:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 1. Inspect version parameters and command menu
|
|
13
|
+
node bin/multimodel-dev-os.js --help
|
|
14
|
+
|
|
15
|
+
# 2. View all built-in configurations
|
|
16
|
+
node bin/multimodel-dev-os.js templates
|
|
17
|
+
|
|
18
|
+
# 3. Inspect a specific template's specifications
|
|
19
|
+
node bin/multimodel-dev-os.js show-template nextjs-saas
|
|
20
|
+
|
|
21
|
+
# 4. Dry-run scaffold inside the current repository
|
|
22
|
+
node bin/multimodel-dev-os.js init --template general-app --dry-run
|
|
23
|
+
|
|
24
|
+
# 5. Execute strict validation lints
|
|
25
|
+
node bin/multimodel-dev-os.js validate --target .
|
|
26
|
+
|
|
27
|
+
# 6. Execute diagnostic doctor checkups
|
|
28
|
+
node bin/multimodel-dev-os.js doctor --target .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 2. Dry-Run Verification
|
|
34
|
+
|
|
35
|
+
Assert that no physical files are created when running the `--dry-run` parameter:
|
|
36
|
+
- Verify stdout logs indicate `[DRY-RUN] Would create...`
|
|
37
|
+
- Confirm `git status` reports zero untracked files or modifications.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# CLI Smoke Test Specification
|
|
2
|
+
|
|
3
|
+
This document lists the essential CLI commands to execute when verifying the MultiModel Dev OS installation stability.
|
|
4
|
+
|
|
5
|
+
## Sanity Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Verify help output
|
|
9
|
+
node bin/multimodel-dev-os.js --help
|
|
10
|
+
|
|
11
|
+
# List templates
|
|
12
|
+
node bin/multimodel-dev-os.js templates
|
|
13
|
+
node bin/multimodel-dev-os.js templates --json
|
|
14
|
+
|
|
15
|
+
# List models and adapters
|
|
16
|
+
node bin/multimodel-dev-os.js models --json
|
|
17
|
+
node bin/multimodel-dev-os.js adapters --json
|
|
18
|
+
|
|
19
|
+
# List active skills
|
|
20
|
+
node bin/multimodel-dev-os.js skills
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Validation & Doctor Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Validate built-in resources
|
|
27
|
+
node bin/multimodel-dev-os.js validate-template nextjs-saas
|
|
28
|
+
node bin/multimodel-dev-os.js validate-adapter cursor
|
|
29
|
+
node bin/multimodel-dev-os.js validate-skill custom-skill.example
|
|
30
|
+
|
|
31
|
+
# Validate all registry entries
|
|
32
|
+
node bin/multimodel-dev-os.js validate --all-registries
|
|
33
|
+
|
|
34
|
+
# Release advisory checkup
|
|
35
|
+
node bin/multimodel-dev-os.js doctor --release
|
|
36
|
+
|
|
37
|
+
# Token budget checks
|
|
38
|
+
node bin/multimodel-dev-os.js doctor --tokens --threshold 100KB
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Initialization Dry-Runs
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Dry-run general app init
|
|
45
|
+
node bin/multimodel-dev-os.js init --dry-run --template general-app
|
|
46
|
+
|
|
47
|
+
# Dry-run with adapter injections
|
|
48
|
+
node bin/multimodel-dev-os.js init --dry-run --template nextjs-saas -a cursor -a claude
|
|
49
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
describe('Build Output Verification', () => {
|
|
7
|
+
const buildPath = join(process.cwd(), 'bin', 'multimodel-dev-os.js');
|
|
8
|
+
|
|
9
|
+
it('should compile the output file successfully', () => {
|
|
10
|
+
expect(existsSync(buildPath)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should contain exactly one shebang at the top', () => {
|
|
14
|
+
const content = readFileSync(buildPath, 'utf8');
|
|
15
|
+
const totalShebangs = (content.match(/#!/g) || []).length;
|
|
16
|
+
|
|
17
|
+
expect(content.startsWith('#!/usr/bin/env node')).toBe(true);
|
|
18
|
+
expect(totalShebangs).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should contain the generation warning header', () => {
|
|
22
|
+
const content = readFileSync(buildPath, 'utf8');
|
|
23
|
+
expect(content).toContain('// Generated from src/. Do not edit directly.');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should not contain dangerous registry URL interpolation', () => {
|
|
27
|
+
const content = readFileSync(buildPath, 'utf8');
|
|
28
|
+
|
|
29
|
+
const hasUnsafeSync = content.includes("mod.get('${targetUrl}'") || (content.includes('execSync(`node -e "') && content.includes('${targetUrl}'));
|
|
30
|
+
expect(hasUnsafeSync).toBe(false);
|
|
31
|
+
|
|
32
|
+
expect(content).toContain('execFileSync(process.execPath');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should be completely fresh and match the source modules', () => {
|
|
36
|
+
expect(() => {
|
|
37
|
+
execSync('node scripts/check-build-fresh.js', { stdio: 'ignore' });
|
|
38
|
+
}).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { loadCatalogFromSource, loadCatalog } from '../../src/catalog/loader.js';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
describe('Catalog Loader', () => {
|
|
7
|
+
const tempDir = join(process.cwd(), 'temp-catalog-test');
|
|
8
|
+
const localCatalogDir = join(tempDir, '.ai', 'plugins');
|
|
9
|
+
const localCatalogFile = join(localCatalogDir, 'catalog.yaml');
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
mkdirSync(localCatalogDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
if (existsSync(tempDir)) {
|
|
17
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should load bundled catalog from fallback or global if no target is specified', () => {
|
|
22
|
+
const catalog = loadCatalog();
|
|
23
|
+
expect(catalog).toBeDefined();
|
|
24
|
+
expect(Array.isArray(catalog.plugins)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should load local catalog when source is "local"', () => {
|
|
28
|
+
const localYaml = `
|
|
29
|
+
catalog:
|
|
30
|
+
plugins:
|
|
31
|
+
- name: local-plugin
|
|
32
|
+
slug: local-slug
|
|
33
|
+
version: 1.0.0
|
|
34
|
+
description: local test
|
|
35
|
+
author: test
|
|
36
|
+
`;
|
|
37
|
+
writeFileSync(localCatalogFile, localYaml, 'utf8');
|
|
38
|
+
|
|
39
|
+
const result = loadCatalogFromSource('local', { target: tempDir });
|
|
40
|
+
expect(result.plugins).toHaveLength(1);
|
|
41
|
+
expect(result.plugins[0].name).toBe('local-plugin');
|
|
42
|
+
expect(result.plugins[0]._source).toBe('local');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -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,46 @@
|
|
|
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
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should override default fields with written policy configurations', () => {
|
|
30
|
+
const yamlConfig = `
|
|
31
|
+
allow_remote_registries: true
|
|
32
|
+
allow_http_localhost: true
|
|
33
|
+
require_checksum: false
|
|
34
|
+
max_plugin_files: 50
|
|
35
|
+
`;
|
|
36
|
+
writeFileSync(policyFile, yamlConfig, 'utf8');
|
|
37
|
+
|
|
38
|
+
const policy = loadRegistryPolicy(tempDir);
|
|
39
|
+
expect(policy.allow_remote_registries).toBe(true);
|
|
40
|
+
expect(policy.allow_http_localhost).toBe(true);
|
|
41
|
+
expect(policy.require_checksum).toBe(false);
|
|
42
|
+
expect(policy.max_plugin_files).toBe(50);
|
|
43
|
+
// Unspecified fields should keep defaults
|
|
44
|
+
expect(policy.max_registry_cache_size_kb).toBe(512);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateRegistryUrl } from '../../src/registry/validation.js';
|
|
3
|
+
|
|
4
|
+
describe('Registry URL Validation', () => {
|
|
5
|
+
it('should accept valid HTTPS URLs', () => {
|
|
6
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml')).not.toThrow();
|
|
7
|
+
expect(() => validateRegistryUrl('https://raw.githubusercontent.com/user/repo/main/catalog.yaml')).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should reject empty or non-string URLs', () => {
|
|
11
|
+
expect(() => validateRegistryUrl(null)).toThrow('Registry URL must be a non-empty string.');
|
|
12
|
+
expect(() => validateRegistryUrl('')).toThrow('Registry URL must be a non-empty string.');
|
|
13
|
+
expect(() => validateRegistryUrl(123)).toThrow('Registry URL must be a non-empty string.');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should reject URLs with whitespace or control characters', () => {
|
|
17
|
+
expect(() => validateRegistryUrl('https://example.com/cat alog.yaml')).toThrow('whitespace or control characters');
|
|
18
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml\n')).toThrow('whitespace or control characters');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should reject URLs containing quotes or backticks', () => {
|
|
22
|
+
expect(() => validateRegistryUrl("https://example.com/catalog.yaml'")).toThrow('quotes or backticks');
|
|
23
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml"')).toThrow('quotes or backticks');
|
|
24
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml`')).toThrow('quotes or backticks');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should reject URLs containing shell metacharacters', () => {
|
|
28
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml;echo')).toThrow('shell metacharacters');
|
|
29
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml&foo')).toThrow('shell metacharacters');
|
|
30
|
+
expect(() => validateRegistryUrl('https://example.com/catalog.yaml$foo')).toThrow('shell metacharacters');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should reject malformed URLs', () => {
|
|
34
|
+
expect(() => validateRegistryUrl('not-a-url')).toThrow('malformed or invalid');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should reject URLs containing credentials', () => {
|
|
38
|
+
expect(() => validateRegistryUrl('https://user:password@example.com/catalog.yaml')).toThrow('credentials');
|
|
39
|
+
expect(() => validateRegistryUrl('https://user@example.com/catalog.yaml')).toThrow('credentials');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should reject non-HTTPS remote URLs by default', () => {
|
|
43
|
+
expect(() => validateRegistryUrl('http://example.com/catalog.yaml')).toThrow('Only HTTPS is permitted');
|
|
44
|
+
expect(() => validateRegistryUrl('ftp://example.com/catalog.yaml')).toThrow('Only HTTPS is permitted');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Localhost HTTP Allowance', () => {
|
|
48
|
+
it('should reject HTTP localhost by default', () => {
|
|
49
|
+
expect(() => validateRegistryUrl('http://localhost/catalog.yaml')).toThrow('Only HTTPS is permitted');
|
|
50
|
+
expect(() => validateRegistryUrl('http://127.0.0.1/catalog.yaml')).toThrow('Only HTTPS is permitted');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should allow HTTP localhost if policy explicitly allows it', () => {
|
|
54
|
+
const policy = { allow_http_localhost: true };
|
|
55
|
+
expect(() => validateRegistryUrl('http://localhost/catalog.yaml', policy)).not.toThrow();
|
|
56
|
+
expect(() => validateRegistryUrl('http://127.0.0.1/catalog.yaml', policy)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should still reject HTTP non-localhost even if policy allows HTTP localhost', () => {
|
|
60
|
+
const policy = { allow_http_localhost: true };
|
|
61
|
+
expect(() => validateRegistryUrl('http://example.com/catalog.yaml', policy)).toThrow('Only HTTPS is permitted');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|