@mhingston5/conduit 1.0.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/.env.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { AuthMiddleware } from '../src/core/middleware/auth.middleware.js';
|
|
3
|
+
import { RateLimitMiddleware } from '../src/core/middleware/ratelimit.middleware.js';
|
|
4
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
5
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
6
|
+
import { ConduitError } from '../src/core/types.js';
|
|
7
|
+
|
|
8
|
+
describe('Middleware Tests', () => {
|
|
9
|
+
let mockSecurityService: any;
|
|
10
|
+
let mockLogger: any;
|
|
11
|
+
let context: ExecutionContext;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockSecurityService = {
|
|
15
|
+
validateToken: vi.fn(),
|
|
16
|
+
checkRateLimit: vi.fn(),
|
|
17
|
+
getIpcToken: vi.fn(),
|
|
18
|
+
validateIpcToken: vi.fn(),
|
|
19
|
+
getSession: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
mockLogger = {
|
|
22
|
+
info: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
debug: vi.fn(),
|
|
26
|
+
child: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
mockLogger.child.mockReturnValue(mockLogger);
|
|
29
|
+
context = new ExecutionContext({ logger: mockLogger });
|
|
30
|
+
mockNext.mockClear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const mockNext = vi.fn().mockResolvedValue({ jsonrpc: '2.0', id: 1, result: 'ok' });
|
|
34
|
+
|
|
35
|
+
describe('AuthMiddleware', () => {
|
|
36
|
+
let authMiddleware: AuthMiddleware;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
authMiddleware = new AuthMiddleware(mockSecurityService as SecurityService);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should validate bearer token', () => {
|
|
43
|
+
mockSecurityService.validateToken.mockReturnValue(true);
|
|
44
|
+
const request = {
|
|
45
|
+
jsonrpc: '2.0',
|
|
46
|
+
id: 1,
|
|
47
|
+
method: 'test',
|
|
48
|
+
auth: { bearerToken: 'valid-token' }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
mockSecurityService.getIpcToken.mockReturnValue('master-token');
|
|
52
|
+
mockSecurityService.validateIpcToken.mockReturnValue(false);
|
|
53
|
+
// Mock validateToken behavior via logic or specific mock if used, but AuthMiddleware uses getIpcToken/validateIpcToken
|
|
54
|
+
|
|
55
|
+
authMiddleware.handle(request as any, context, mockNext);
|
|
56
|
+
expect(mockSecurityService.validateIpcToken).toHaveBeenCalledWith('valid-token');
|
|
57
|
+
expect(mockNext).not.toHaveBeenCalled(); // Should fail because neither master nor session valid
|
|
58
|
+
// Wait, logic says: isMaster = token === getIpcToken(). isSession = validateIpcToken() && !isMaster.
|
|
59
|
+
// If valid-token is NOT master and NOT session, it returns 403.
|
|
60
|
+
|
|
61
|
+
// Let's make it a master token to pass 'valid-token' test
|
|
62
|
+
mockSecurityService.getIpcToken.mockReturnValue('valid-token');
|
|
63
|
+
authMiddleware.handle(request as any, context, mockNext);
|
|
64
|
+
expect(mockNext).toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should throw Forbidden if token is invalid', async () => {
|
|
68
|
+
mockSecurityService.getIpcToken.mockReturnValue('master-token');
|
|
69
|
+
mockSecurityService.validateIpcToken.mockReturnValue(false);
|
|
70
|
+
|
|
71
|
+
const request = {
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
id: 1,
|
|
74
|
+
method: 'test',
|
|
75
|
+
auth: { bearerToken: 'invalid-token' }
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = await authMiddleware.handle(request as any, context, mockNext);
|
|
79
|
+
expect(result.error?.code).toBe(ConduitError.Forbidden);
|
|
80
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('RateLimitMiddleware', () => {
|
|
85
|
+
let rateLimitMiddleware: RateLimitMiddleware;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
rateLimitMiddleware = new RateLimitMiddleware(mockSecurityService as SecurityService);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should call checkRateLimit', () => {
|
|
92
|
+
const request = {
|
|
93
|
+
jsonrpc: '2.0',
|
|
94
|
+
id: 1,
|
|
95
|
+
method: 'test'
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
mockSecurityService.checkRateLimit.mockReturnValue(true);
|
|
99
|
+
|
|
100
|
+
rateLimitMiddleware.handle(request as any, context, mockNext);
|
|
101
|
+
// Default context has undefined remoteAddress and request has no token -> key is 'unknown'
|
|
102
|
+
expect(mockSecurityService.checkRateLimit).toHaveBeenCalledWith('unknown');
|
|
103
|
+
expect(mockNext).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { OpsServer } from '../src/core/ops.server.js';
|
|
3
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
4
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
5
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
6
|
+
import { RequestController } from '../src/core/request.controller.js';
|
|
7
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
8
|
+
import { ExecutorRegistry } from '../src/core/registries/executor.registry.js';
|
|
9
|
+
import { buildDefaultMiddleware } from '../src/core/middleware/middleware.builder.js';
|
|
10
|
+
import pino from 'pino';
|
|
11
|
+
|
|
12
|
+
const logger = pino({ level: 'silent' });
|
|
13
|
+
|
|
14
|
+
describe('OpsServer', () => {
|
|
15
|
+
let opsServer: OpsServer;
|
|
16
|
+
let configService: ConfigService;
|
|
17
|
+
let gatewayService: GatewayService;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
configService = new ConfigService({ port: 0 as any });
|
|
21
|
+
const securityService = new SecurityService(logger, 'test-token');
|
|
22
|
+
gatewayService = new GatewayService(logger, securityService);
|
|
23
|
+
const executorRegistry = new ExecutorRegistry();
|
|
24
|
+
executorRegistry.register('python', { healthCheck: vi.fn().mockResolvedValue({ status: 'ok' }) } as any);
|
|
25
|
+
|
|
26
|
+
const executionService = new ExecutionService(
|
|
27
|
+
logger,
|
|
28
|
+
configService.get('resourceLimits'),
|
|
29
|
+
gatewayService,
|
|
30
|
+
securityService,
|
|
31
|
+
executorRegistry
|
|
32
|
+
);
|
|
33
|
+
const requestController = new RequestController(logger, executionService, gatewayService, buildDefaultMiddleware(securityService));
|
|
34
|
+
opsServer = new OpsServer(logger, configService.all, gatewayService, requestController);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await opsServer.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should respond to /health', async () => {
|
|
42
|
+
const address = await opsServer.listen();
|
|
43
|
+
const url = new URL(address);
|
|
44
|
+
const port = url.port;
|
|
45
|
+
const response = await fetch(`http://localhost:${port}/health`);
|
|
46
|
+
const data = await response.json() as any;
|
|
47
|
+
expect(response.status).toBe(200);
|
|
48
|
+
expect(data.status).toBe('ok');
|
|
49
|
+
expect(data.request).toBeDefined();
|
|
50
|
+
expect(data.request.pyodide).toBeDefined();
|
|
51
|
+
expect(data.request.pyodide.status).toBe('ok');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should respond to /metrics in prometheus format', async () => {
|
|
55
|
+
const address = await opsServer.listen();
|
|
56
|
+
const url = new URL(address);
|
|
57
|
+
const port = url.port;
|
|
58
|
+
const response = await fetch(`http://localhost:${port}/metrics`);
|
|
59
|
+
expect(response.status).toBe(200);
|
|
60
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
expect(text).toContain('conduit_uptime_seconds');
|
|
63
|
+
expect(text).toContain('conduit_memory_rss_bytes');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PolicyService, ToolIdentifier } from '../src/core/policy.service.js';
|
|
3
|
+
|
|
4
|
+
describe('PolicyService', () => {
|
|
5
|
+
let policyService: PolicyService;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
policyService = new PolicyService();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('parseToolName', () => {
|
|
12
|
+
it('should parse namespace and name from qualified string', () => {
|
|
13
|
+
const result = policyService.parseToolName('github__createIssue');
|
|
14
|
+
expect(result).toEqual({ namespace: 'github', name: 'createIssue' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should handle nested tool names', () => {
|
|
18
|
+
const result = policyService.parseToolName('github__api__v2__listRepos');
|
|
19
|
+
expect(result).toEqual({ namespace: 'github', name: 'api__v2__listRepos' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should handle name without namespace', () => {
|
|
23
|
+
const result = policyService.parseToolName('soloTool');
|
|
24
|
+
expect(result).toEqual({ namespace: '', name: 'soloTool' });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('formatToolName', () => {
|
|
29
|
+
it('should format ToolIdentifier to qualified string', () => {
|
|
30
|
+
const result = policyService.formatToolName({ namespace: 'github', name: 'createIssue' });
|
|
31
|
+
expect(result).toBe('github__createIssue');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should handle empty namespace', () => {
|
|
35
|
+
const result = policyService.formatToolName({ namespace: '', name: 'soloTool' });
|
|
36
|
+
expect(result).toBe('soloTool');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('isToolAllowed', () => {
|
|
41
|
+
describe('exact matching', () => {
|
|
42
|
+
it('should match exact tool name', () => {
|
|
43
|
+
expect(policyService.isToolAllowed('github__createIssue', ['github.createIssue'])).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should reject non-matching tool', () => {
|
|
47
|
+
expect(policyService.isToolAllowed('github__createIssue', ['github.deleteIssue'])).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should accept ToolIdentifier', () => {
|
|
51
|
+
const tool: ToolIdentifier = { namespace: 'github', name: 'createIssue' };
|
|
52
|
+
expect(policyService.isToolAllowed(tool, ['github.createIssue'])).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('wildcard matching', () => {
|
|
57
|
+
it('should match any tool in namespace with wildcard', () => {
|
|
58
|
+
expect(policyService.isToolAllowed('github__createIssue', ['github.*'])).toBe(true);
|
|
59
|
+
expect(policyService.isToolAllowed('github__deleteIssue', ['github.*'])).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should not match different namespace with wildcard', () => {
|
|
63
|
+
expect(policyService.isToolAllowed('gitlab__createIssue', ['github.*'])).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should not allow partial namespace match (security)', () => {
|
|
67
|
+
// "github.*" should NOT match "githubenterprise__tool"
|
|
68
|
+
expect(policyService.isToolAllowed('githubenterprise__tool', ['github.*'])).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should match nested wildcards', () => {
|
|
72
|
+
expect(policyService.isToolAllowed('github__api__listRepos', ['github.api.*'])).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should not match shallow when deep wildcard specified', () => {
|
|
76
|
+
// "github.api.*" should NOT match "github__createIssue" (no api segment)
|
|
77
|
+
expect(policyService.isToolAllowed('github__createIssue', ['github.api.*'])).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('multiple patterns', () => {
|
|
82
|
+
it('should match if any pattern matches', () => {
|
|
83
|
+
const allowed = ['github.createIssue', 'gitlab.*'];
|
|
84
|
+
expect(policyService.isToolAllowed('github__createIssue', allowed)).toBe(true);
|
|
85
|
+
expect(policyService.isToolAllowed('gitlab__anything', allowed)).toBe(true);
|
|
86
|
+
expect(policyService.isToolAllowed('bitbucket__tool', allowed)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { PyodideExecutor } from '../src/executors/pyodide.executor.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { ConduitError } from '../src/core/request.controller.js';
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
|
|
7
|
+
const logger = pino({ level: 'silent' });
|
|
8
|
+
|
|
9
|
+
describe('PyodideExecutor', () => {
|
|
10
|
+
let executor: PyodideExecutor;
|
|
11
|
+
let context: ExecutionContext;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
executor = new PyodideExecutor();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await executor.shutdown();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
context = new ExecutionContext({ logger });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should execute simple python code', async () => {
|
|
26
|
+
const code = 'print(1 + 1)';
|
|
27
|
+
const result = await executor.execute(code, {
|
|
28
|
+
timeoutMs: 10000,
|
|
29
|
+
memoryLimitMb: 128,
|
|
30
|
+
maxOutputBytes: 1024,
|
|
31
|
+
maxLogEntries: 100,
|
|
32
|
+
}, context);
|
|
33
|
+
|
|
34
|
+
expect(result.exitCode).toBe(0);
|
|
35
|
+
expect(result.stdout).toContain('2');
|
|
36
|
+
}, 20000); // Higher timeout for pyodide load
|
|
37
|
+
|
|
38
|
+
it('should capture stdout', async () => {
|
|
39
|
+
const code = 'print("hello python")';
|
|
40
|
+
const result = await executor.execute(code, {
|
|
41
|
+
timeoutMs: 10000,
|
|
42
|
+
memoryLimitMb: 128,
|
|
43
|
+
maxOutputBytes: 1024,
|
|
44
|
+
maxLogEntries: 100,
|
|
45
|
+
}, context);
|
|
46
|
+
|
|
47
|
+
expect(result.stdout).toContain('hello python');
|
|
48
|
+
}, 20000);
|
|
49
|
+
|
|
50
|
+
it('should timeout on long execution', async () => {
|
|
51
|
+
const code = 'import time; time.sleep(5)';
|
|
52
|
+
const result = await executor.execute(code, {
|
|
53
|
+
timeoutMs: 1000, // 1s timeout
|
|
54
|
+
memoryLimitMb: 128,
|
|
55
|
+
maxOutputBytes: 1024,
|
|
56
|
+
maxLogEntries: 100,
|
|
57
|
+
}, context);
|
|
58
|
+
|
|
59
|
+
expect(result.error?.code).toBe(-32008);
|
|
60
|
+
}, 20000);
|
|
61
|
+
|
|
62
|
+
it('should terminate worker on output limit breach', async () => {
|
|
63
|
+
const code = 'print("this is longer than 10 bytes")';
|
|
64
|
+
const result = await executor.execute(code, {
|
|
65
|
+
timeoutMs: 5000,
|
|
66
|
+
memoryLimitMb: 128,
|
|
67
|
+
maxOutputBytes: 10, // Very small limit
|
|
68
|
+
maxLogEntries: 100,
|
|
69
|
+
}, context);
|
|
70
|
+
|
|
71
|
+
expect(result.error?.code).toBe(ConduitError.OutputLimitExceeded);
|
|
72
|
+
// Pyodide might wrap the JS error as an OSError/I/O error
|
|
73
|
+
const errMsg = result.error?.message || '';
|
|
74
|
+
expect(errMsg.includes('Output limit exceeded') || errMsg.includes('I/O error')).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Next execution should work (new worker)
|
|
77
|
+
const result2 = await executor.execute('print("ok")', {
|
|
78
|
+
timeoutMs: 5000,
|
|
79
|
+
memoryLimitMb: 128,
|
|
80
|
+
maxOutputBytes: 1000,
|
|
81
|
+
maxLogEntries: 100,
|
|
82
|
+
}, context);
|
|
83
|
+
expect(result2.error).toBeUndefined();
|
|
84
|
+
expect(result2.stdout.trim()).toBe('ok');
|
|
85
|
+
}, 20000);
|
|
86
|
+
|
|
87
|
+
it('should recycle worker after every execution (zero state leak)', async () => {
|
|
88
|
+
// First execution
|
|
89
|
+
await executor.execute('print("hello")', {
|
|
90
|
+
timeoutMs: 5000,
|
|
91
|
+
memoryLimitMb: 128,
|
|
92
|
+
maxOutputBytes: 1024,
|
|
93
|
+
maxLogEntries: 100,
|
|
94
|
+
}, context);
|
|
95
|
+
|
|
96
|
+
const poolSize = (executor as any).pool.length;
|
|
97
|
+
// Since maxRunsPerWorker = 1, it should have been terminated.
|
|
98
|
+
// The pool might be 0 if it was the only worker, or 1 if a new one was pre-warmed (unlikely without request)
|
|
99
|
+
expect(poolSize).toBeLessThanOrEqual(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
|
|
3
|
+
const server = Fastify();
|
|
4
|
+
|
|
5
|
+
server.post('/', async (request, reply) => {
|
|
6
|
+
const { method, params, id } = request.body as any;
|
|
7
|
+
|
|
8
|
+
if (method === 'list_tools') {
|
|
9
|
+
return {
|
|
10
|
+
jsonrpc: '2.0',
|
|
11
|
+
id,
|
|
12
|
+
result: {
|
|
13
|
+
tools: [
|
|
14
|
+
{
|
|
15
|
+
name: 'echo',
|
|
16
|
+
description: 'Echo back params',
|
|
17
|
+
inputSchema: { type: 'object' },
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (method === 'call_tool') {
|
|
25
|
+
return {
|
|
26
|
+
jsonrpc: '2.0',
|
|
27
|
+
id,
|
|
28
|
+
result: {
|
|
29
|
+
content: [{ type: 'text', text: `Echo: ${JSON.stringify(params.arguments)}` }]
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const startReferenceMCP = async (port: number) => {
|
|
38
|
+
await server.listen({ port, host: '0.0.0.0' });
|
|
39
|
+
return server;
|
|
40
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { NetworkPolicyService } from '../src/core/network.policy.service.js';
|
|
3
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
4
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
5
|
+
import { ExecutorRegistry } from '../src/core/registries/executor.registry.js';
|
|
6
|
+
import { pino } from 'pino';
|
|
7
|
+
import dns from 'node:dns/promises';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
|
|
10
|
+
const logger = pino({ level: 'silent' });
|
|
11
|
+
|
|
12
|
+
describe('Remediation Tests', () => {
|
|
13
|
+
describe('NetworkPolicyService (SSRF & IPv6-mapped IPv4)', () => {
|
|
14
|
+
let policy: NetworkPolicyService;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
policy = new NetworkPolicyService(logger);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should block IPv6-mapped IPv4 private addresses (::ffff:127.0.0.1)', async () => {
|
|
21
|
+
// Mock dns.lookup to return an IPv6-mapped IPv4 address
|
|
22
|
+
vi.spyOn(dns, 'lookup').mockResolvedValue([{ address: '::ffff:127.0.0.1', family: 6 }] as any);
|
|
23
|
+
|
|
24
|
+
const result = await policy.validateUrl('http://malicious.com');
|
|
25
|
+
|
|
26
|
+
expect(result.valid).toBe(false);
|
|
27
|
+
expect(result.message).toContain('resolves to private network');
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return resolvedIp for DNS rebinding protection', async () => {
|
|
32
|
+
vi.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as any);
|
|
33
|
+
|
|
34
|
+
const result = await policy.validateUrl('http://example.com');
|
|
35
|
+
|
|
36
|
+
expect(result.valid).toBe(true);
|
|
37
|
+
expect(result.resolvedIp).toBe('93.184.216.34');
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('SecurityService (Timing Attacks)', () => {
|
|
43
|
+
let security: SecurityService;
|
|
44
|
+
const secretToken = 'very-secret-token-123';
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
security = new SecurityService(logger, secretToken);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should validate valid token', () => {
|
|
51
|
+
expect(security.validateIpcToken(secretToken)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should reject invalid token', () => {
|
|
55
|
+
expect(security.validateIpcToken('wrong-token')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should reject token with correct prefix but different length', () => {
|
|
59
|
+
expect(security.validateIpcToken(secretToken + 'extra')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('ExecutionService (Routing & Guards)', () => {
|
|
64
|
+
let executionService: ExecutionService;
|
|
65
|
+
let mockDeno: any;
|
|
66
|
+
let mockIsolate: any;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
const registry = new ExecutorRegistry();
|
|
70
|
+
mockDeno = { execute: vi.fn().mockResolvedValue({ stdout: 'deno' }) };
|
|
71
|
+
mockIsolate = { execute: vi.fn().mockResolvedValue({ stdout: 'isolate' }) };
|
|
72
|
+
registry.register('deno', mockDeno);
|
|
73
|
+
registry.register('isolate', mockIsolate);
|
|
74
|
+
|
|
75
|
+
executionService = new ExecutionService(
|
|
76
|
+
logger,
|
|
77
|
+
{} as any,
|
|
78
|
+
{
|
|
79
|
+
discoverTools: vi.fn().mockResolvedValue([]),
|
|
80
|
+
listToolPackages: vi.fn().mockResolvedValue([]),
|
|
81
|
+
listToolStubs: vi.fn().mockResolvedValue([]),
|
|
82
|
+
} as any,
|
|
83
|
+
new SecurityService(logger, 'test'),
|
|
84
|
+
registry
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should fail if ipcAddress is not set and Deno is needed', async () => {
|
|
89
|
+
const result = await executionService.executeTypeScript('import "os"', {} as any, {} as any);
|
|
90
|
+
expect(result.error).toBeDefined();
|
|
91
|
+
expect(result.error?.message).toContain('IPC address not initialized');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should route simple code to Isolate even if ipcAddress is missing', async () => {
|
|
95
|
+
const result = await executionService.executeTypeScript('console.log(1)', {} as any, {} as any);
|
|
96
|
+
expect(mockIsolate.execute).toHaveBeenCalled();
|
|
97
|
+
expect(result.stdout).toBe('isolate');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should route code with "import" to Deno', async () => {
|
|
101
|
+
executionService.ipcAddress = '127.0.0.1:1234';
|
|
102
|
+
await executionService.executeTypeScript('import { x } from "y"', {} as any, {} as any);
|
|
103
|
+
expect(mockDeno.execute).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should route code with "Deno" to Deno', async () => {
|
|
107
|
+
executionService.ipcAddress = '127.0.0.1:1234';
|
|
108
|
+
await executionService.executeTypeScript('console.log(Deno.version)', {} as any, {} as any);
|
|
109
|
+
expect(mockDeno.execute).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should NOT be fooled by "import" in string literals', async () => {
|
|
113
|
+
executionService.ipcAddress = '127.0.0.1:1234';
|
|
114
|
+
const result = await executionService.executeTypeScript('const x = "import this"', {} as any, {} as any);
|
|
115
|
+
expect(mockIsolate.execute).toHaveBeenCalled();
|
|
116
|
+
expect(result.stdout).toBe('isolate');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { RequestController } from '../src/core/request.controller';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context';
|
|
4
|
+
import { buildDefaultMiddleware } from '../src/core/middleware/middleware.builder.js';
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
|
|
7
|
+
const logger = pino({ level: 'silent' });
|
|
8
|
+
|
|
9
|
+
describe('RequestController Routing', () => {
|
|
10
|
+
let controller: RequestController;
|
|
11
|
+
let mockContext: ExecutionContext;
|
|
12
|
+
let mockGatewayService: any;
|
|
13
|
+
let mockDenoExecutor: any;
|
|
14
|
+
let mockPyodideExecutor: any;
|
|
15
|
+
let mockIsolateExecutor: any;
|
|
16
|
+
let mockSecurityService: any;
|
|
17
|
+
let mockSdkGenerator: any;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockContext = new ExecutionContext({ logger });
|
|
21
|
+
mockGatewayService = {
|
|
22
|
+
callTool: vi.fn(),
|
|
23
|
+
discoverTools: vi.fn().mockResolvedValue([]),
|
|
24
|
+
};
|
|
25
|
+
mockDenoExecutor = {
|
|
26
|
+
execute: vi.fn().mockResolvedValue({ stdout: 'deno', stderr: '', exitCode: 0 }),
|
|
27
|
+
};
|
|
28
|
+
mockPyodideExecutor = {
|
|
29
|
+
execute: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
mockIsolateExecutor = {
|
|
32
|
+
execute: vi.fn().mockResolvedValue({ stdout: 'isolate', stderr: '', exitCode: 0 }),
|
|
33
|
+
};
|
|
34
|
+
mockSecurityService = {
|
|
35
|
+
validateCode: vi.fn().mockReturnValue({ valid: true }),
|
|
36
|
+
createSession: vi.fn().mockReturnValue('token'),
|
|
37
|
+
invalidateSession: vi.fn(),
|
|
38
|
+
getIpcToken: vi.fn().mockReturnValue('master-token'),
|
|
39
|
+
validateIpcToken: vi.fn().mockReturnValue(true),
|
|
40
|
+
getSession: vi.fn(),
|
|
41
|
+
checkRateLimit: vi.fn().mockReturnValue(true),
|
|
42
|
+
};
|
|
43
|
+
mockSdkGenerator = {
|
|
44
|
+
generateTypeScript: vi.fn().mockReturnValue('sdk'),
|
|
45
|
+
generateIsolateSDK: vi.fn().mockReturnValue('sdk'),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Create controller with mock execution service
|
|
49
|
+
const mockExecutionService = {
|
|
50
|
+
executeTypeScript: vi.fn().mockImplementation(async (code) => {
|
|
51
|
+
const cleanCode = code.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '$1');
|
|
52
|
+
const hasImports = /^\s*import\s/m.test(cleanCode) ||
|
|
53
|
+
/^\s*export\s/m.test(cleanCode) ||
|
|
54
|
+
/\bDeno\./.test(cleanCode) ||
|
|
55
|
+
/\bDeno\b/.test(cleanCode);
|
|
56
|
+
|
|
57
|
+
if (!hasImports) {
|
|
58
|
+
await mockIsolateExecutor.execute();
|
|
59
|
+
return { stdout: 'isolate', stderr: '', exitCode: 0 };
|
|
60
|
+
} else {
|
|
61
|
+
await mockDenoExecutor.execute();
|
|
62
|
+
return { stdout: 'deno', stderr: '', exitCode: 0 };
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
ipcAddress: '',
|
|
66
|
+
shutdown: vi.fn(),
|
|
67
|
+
healthCheck: vi.fn().mockResolvedValue({ status: 'ok' }),
|
|
68
|
+
warmup: vi.fn(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
controller = new RequestController(
|
|
72
|
+
logger,
|
|
73
|
+
mockExecutionService as any,
|
|
74
|
+
mockGatewayService,
|
|
75
|
+
buildDefaultMiddleware(mockSecurityService)
|
|
76
|
+
);
|
|
77
|
+
(controller as any).denoExecutor = mockDenoExecutor;
|
|
78
|
+
(controller as any).pyodideExecutor = mockPyodideExecutor;
|
|
79
|
+
(controller as any).isolateExecutor = mockIsolateExecutor;
|
|
80
|
+
(controller as any).sdkGenerator = mockSdkGenerator;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should route simple scripts (no imports) to IsolateExecutor', async () => {
|
|
84
|
+
const result = await controller.handleRequest({
|
|
85
|
+
jsonrpc: '2.0',
|
|
86
|
+
id: 1,
|
|
87
|
+
method: 'mcp.executeTypeScript',
|
|
88
|
+
params: {
|
|
89
|
+
code: 'console.log("simple")',
|
|
90
|
+
limits: {}
|
|
91
|
+
},
|
|
92
|
+
auth: { bearerToken: 'master-token' }
|
|
93
|
+
}, mockContext);
|
|
94
|
+
|
|
95
|
+
expect(mockIsolateExecutor.execute).toHaveBeenCalled();
|
|
96
|
+
expect(mockDenoExecutor.execute).not.toHaveBeenCalled();
|
|
97
|
+
expect(result.result.stdout).toBe('isolate');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should route scripts with imports to DenoExecutor', async () => {
|
|
101
|
+
const result = await controller.handleRequest({
|
|
102
|
+
jsonrpc: '2.0',
|
|
103
|
+
id: 1,
|
|
104
|
+
method: 'mcp.executeTypeScript',
|
|
105
|
+
params: {
|
|
106
|
+
code: 'import { foo } from "bar"; console.log(foo)',
|
|
107
|
+
limits: {}
|
|
108
|
+
},
|
|
109
|
+
auth: { bearerToken: 'master-token' }
|
|
110
|
+
}, mockContext);
|
|
111
|
+
|
|
112
|
+
expect(mockDenoExecutor.execute).toHaveBeenCalled();
|
|
113
|
+
expect(mockIsolateExecutor.execute).not.toHaveBeenCalled();
|
|
114
|
+
expect(result.result.stdout).toBe('deno');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should route scripts with exports to DenoExecutor', async () => {
|
|
118
|
+
const result = await controller.handleRequest({
|
|
119
|
+
jsonrpc: '2.0',
|
|
120
|
+
id: 1,
|
|
121
|
+
method: 'mcp.executeTypeScript',
|
|
122
|
+
params: {
|
|
123
|
+
code: 'export const foo = "bar"',
|
|
124
|
+
limits: {}
|
|
125
|
+
},
|
|
126
|
+
auth: { bearerToken: 'master-token' }
|
|
127
|
+
}, mockContext);
|
|
128
|
+
|
|
129
|
+
expect(mockDenoExecutor.execute).toHaveBeenCalled();
|
|
130
|
+
expect(mockIsolateExecutor.execute).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should route scripts using Deno global to DenoExecutor', async () => {
|
|
134
|
+
const result = await controller.handleRequest({
|
|
135
|
+
jsonrpc: '2.0',
|
|
136
|
+
id: 1,
|
|
137
|
+
method: 'mcp.executeTypeScript',
|
|
138
|
+
params: {
|
|
139
|
+
code: 'console.log(Deno.version)',
|
|
140
|
+
limits: {}
|
|
141
|
+
},
|
|
142
|
+
auth: { bearerToken: 'master-token' }
|
|
143
|
+
}, mockContext);
|
|
144
|
+
|
|
145
|
+
expect(mockDenoExecutor.execute).toHaveBeenCalled();
|
|
146
|
+
expect(mockIsolateExecutor.execute).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|