@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,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
|
|
7
|
+
vi.mock('axios');
|
|
8
|
+
const logger = pino({ level: 'silent' });
|
|
9
|
+
|
|
10
|
+
describe('GatewayService', () => {
|
|
11
|
+
let gateway: GatewayService;
|
|
12
|
+
let context: ExecutionContext;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
const securityService = {
|
|
16
|
+
validateUrl: vi.fn().mockReturnValue({ valid: true }),
|
|
17
|
+
} as any;
|
|
18
|
+
gateway = new GatewayService(logger, securityService);
|
|
19
|
+
context = new ExecutionContext({ logger });
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should discover tools from multiple upstreams', async () => {
|
|
24
|
+
gateway.registerUpstream({ id: 'u1', url: 'http://u1' });
|
|
25
|
+
gateway.registerUpstream({ id: 'u2', url: 'http://u2' });
|
|
26
|
+
|
|
27
|
+
(axios.post as any).mockImplementation((url: string) => {
|
|
28
|
+
if (url === 'http://u1') return { data: { result: { tools: [{ name: 't1', inputSchema: {} }] } } };
|
|
29
|
+
if (url === 'http://u2') return { data: { result: { tools: [{ name: 't2', inputSchema: {} }] } } };
|
|
30
|
+
return { data: { result: { tools: [] } } };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const tools = await gateway.discoverTools(context);
|
|
34
|
+
expect(tools).toHaveLength(2);
|
|
35
|
+
expect(tools.find(t => t.name === 'u1__t1')).toBeDefined();
|
|
36
|
+
expect(tools.find(t => t.name === 'u2__t2')).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should route tool calls to correct upstream', async () => {
|
|
40
|
+
gateway.registerUpstream({ id: 'u1', url: 'http://u1' });
|
|
41
|
+
|
|
42
|
+
(axios.post as any).mockResolvedValue({
|
|
43
|
+
data: { result: { stdout: 'done' } }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const response = await gateway.callTool('u1__t1', { arg1: 'val' }, context);
|
|
47
|
+
|
|
48
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
49
|
+
'http://u1',
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
method: 'call_tool',
|
|
52
|
+
params: expect.objectContaining({ name: 't1' })
|
|
53
|
+
}),
|
|
54
|
+
expect.anything()
|
|
55
|
+
);
|
|
56
|
+
expect(response.result.stdout).toBe('done');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { createLogger } from '../src/core/logger.js';
|
|
5
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
6
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
7
|
+
import { ToolSchema } from '../src/gateway/schema.cache.js';
|
|
8
|
+
|
|
9
|
+
describe('GatewayService (Strict Verification)', () => {
|
|
10
|
+
let gateway: GatewayService;
|
|
11
|
+
let logger: any;
|
|
12
|
+
let mockClient: any;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
logger = createLogger(new ConfigService());
|
|
16
|
+
gateway = new GatewayService(logger, { validateUrl: vi.fn().mockResolvedValue({ valid: true }) } as any, new PolicyService());
|
|
17
|
+
|
|
18
|
+
mockClient = {
|
|
19
|
+
call: vi.fn(),
|
|
20
|
+
getManifest: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
(gateway as any).clients.set('mock-tool', mockClient);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const mockSchema: ToolSchema = {
|
|
26
|
+
name: 'test_tool',
|
|
27
|
+
description: 'A test tool',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
foo: { type: 'string' }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mockStub: ToolSchema = {
|
|
37
|
+
name: 'stub_tool',
|
|
38
|
+
description: 'A stub tool',
|
|
39
|
+
inputSchema: undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
it('should allow call with missing schema in non-strict mode', async () => {
|
|
43
|
+
const context = new ExecutionContext({ logger, strictValidation: false });
|
|
44
|
+
(gateway as any).schemaCache.set('mock-tool', [mockStub]);
|
|
45
|
+
mockClient.call.mockResolvedValue({ result: 'ok' });
|
|
46
|
+
|
|
47
|
+
const response = await gateway.callTool('mock-tool__stub_tool', {}, context);
|
|
48
|
+
|
|
49
|
+
expect(response.error).toBeUndefined();
|
|
50
|
+
expect(mockClient.call).toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should block call with missing schema in strict mode', async () => {
|
|
54
|
+
const context = new ExecutionContext({ logger, strictValidation: true });
|
|
55
|
+
(gateway as any).schemaCache.set('mock-tool', [mockStub]);
|
|
56
|
+
|
|
57
|
+
const response = await gateway.callTool('mock-tool__stub_tool', {}, context);
|
|
58
|
+
|
|
59
|
+
expect(response.error).toBeDefined();
|
|
60
|
+
expect(response.error?.code).toBe(-32602);
|
|
61
|
+
expect(response.error?.message).toContain('Strict mode');
|
|
62
|
+
expect(mockClient.call).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should block unknown tool in strict mode', async () => {
|
|
66
|
+
const context = new ExecutionContext({ logger, strictValidation: true });
|
|
67
|
+
(gateway as any).schemaCache.set('mock-tool', [mockSchema]);
|
|
68
|
+
|
|
69
|
+
const response = await gateway.callTool('mock-tool__unknown', {}, context);
|
|
70
|
+
|
|
71
|
+
expect(response.error).toBeDefined();
|
|
72
|
+
expect(response.error?.code).toBe(-32601);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { createLogger } from '../src/core/logger.js';
|
|
5
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
6
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
7
|
+
import { ToolSchema } from '../src/gateway/schema.cache.js';
|
|
8
|
+
|
|
9
|
+
describe('GatewayService (ValidateTool)', () => {
|
|
10
|
+
let gateway: GatewayService;
|
|
11
|
+
let logger: any;
|
|
12
|
+
let mockClient: any;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
logger = createLogger(new ConfigService());
|
|
16
|
+
gateway = new GatewayService(logger, { validateUrl: vi.fn().mockResolvedValue({ valid: true }) } as any, new PolicyService());
|
|
17
|
+
|
|
18
|
+
mockClient = {
|
|
19
|
+
call: vi.fn(),
|
|
20
|
+
getManifest: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
(gateway as any).clients.set('mock-package', mockClient);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const mockSchema: ToolSchema = {
|
|
26
|
+
name: 'test_tool',
|
|
27
|
+
description: 'A test tool',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
foo: { type: 'string' },
|
|
32
|
+
bar: { type: 'number' }
|
|
33
|
+
},
|
|
34
|
+
required: ['foo']
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
it('should return valid:true for valid parameters', async () => {
|
|
39
|
+
const context = new ExecutionContext({ logger });
|
|
40
|
+
|
|
41
|
+
// Mock listToolStubs behavior by pre-loading cache or mocking cache
|
|
42
|
+
// Let's use internal method to seed cache
|
|
43
|
+
(gateway as any).schemaCache.set('mock-package', [mockSchema]);
|
|
44
|
+
|
|
45
|
+
const result = await gateway.validateTool('mock-package__test_tool', { foo: 'hello', bar: 123 }, context);
|
|
46
|
+
|
|
47
|
+
expect(result.valid).toBe(true);
|
|
48
|
+
expect(result.errors).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return valid:false for invalid parameters', async () => {
|
|
52
|
+
const context = new ExecutionContext({ logger });
|
|
53
|
+
(gateway as any).schemaCache.set('mock-package', [mockSchema]);
|
|
54
|
+
|
|
55
|
+
const result = await gateway.validateTool('mock-package__test_tool', { bar: 'wrong type' }, context);
|
|
56
|
+
|
|
57
|
+
expect(result.valid).toBe(false);
|
|
58
|
+
expect(result.errors).toBeDefined();
|
|
59
|
+
// Check for specific error message if possible, but generic check is fine for now
|
|
60
|
+
expect(JSON.stringify(result.errors)).toContain('must have required property');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should lazy load schema if missing', async () => {
|
|
64
|
+
const context = new ExecutionContext({ logger });
|
|
65
|
+
|
|
66
|
+
// Mock listToolStubs to populate cache
|
|
67
|
+
// We can spy on listToolStubs but it's easier to verify behavior by effect
|
|
68
|
+
|
|
69
|
+
// Actually, listToolStubs populates cache from RPC/Manifest
|
|
70
|
+
mockClient.call.mockResolvedValue({
|
|
71
|
+
result: { tools: [mockSchema] }
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await gateway.validateTool('mock-package__test_tool', { foo: 'lazy' }, context);
|
|
75
|
+
|
|
76
|
+
expect(mockClient.call).toHaveBeenCalled(); // Should fetch since cache was empty
|
|
77
|
+
expect(result.valid).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return error if tool not found', async () => {
|
|
81
|
+
const context = new ExecutionContext({ logger });
|
|
82
|
+
mockClient.call.mockResolvedValue({ result: { tools: [] } });
|
|
83
|
+
|
|
84
|
+
const result = await gateway.validateTool('mock-package__non_existent', {}, context);
|
|
85
|
+
|
|
86
|
+
expect(result.valid).toBe(false);
|
|
87
|
+
expect(result.errors?.[0]).toContain('not found');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { UpstreamInfo } from '../src/gateway/upstream.client.js';
|
|
5
|
+
import { createLogger } from '../src/core/logger.js';
|
|
6
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
7
|
+
import { IUrlValidator } from '../src/core/interfaces/url.validator.interface.js';
|
|
8
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
9
|
+
|
|
10
|
+
class MockUrlValidator implements IUrlValidator {
|
|
11
|
+
async validateUrl(url: string) { return { valid: true }; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('GatewayService validation', () => {
|
|
15
|
+
let gateway: GatewayService;
|
|
16
|
+
let logger: any;
|
|
17
|
+
let urlValidator: IUrlValidator;
|
|
18
|
+
let policyService: PolicyService;
|
|
19
|
+
|
|
20
|
+
const mockUpstream: UpstreamInfo = {
|
|
21
|
+
id: 'mock-upstream',
|
|
22
|
+
type: 'http',
|
|
23
|
+
url: 'http://localhost:3000/mcp'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
logger = createLogger(new ConfigService());
|
|
28
|
+
urlValidator = new MockUrlValidator();
|
|
29
|
+
policyService = new PolicyService();
|
|
30
|
+
gateway = new GatewayService(logger, urlValidator, policyService);
|
|
31
|
+
gateway.registerUpstream(mockUpstream);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should validate params against schema', async () => {
|
|
39
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
40
|
+
const callSpy = vi.spyOn(client, 'call').mockResolvedValue({
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id: 1,
|
|
43
|
+
result: {
|
|
44
|
+
tools: [{
|
|
45
|
+
name: 'test',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: { count: { type: 'number' } },
|
|
49
|
+
required: ['count']
|
|
50
|
+
}
|
|
51
|
+
}]
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const context = new ExecutionContext({ logger });
|
|
56
|
+
|
|
57
|
+
// Fail validation
|
|
58
|
+
const failParams = { count: 'not a number' };
|
|
59
|
+
const failRes = await gateway.callTool('mock-upstream__test', failParams, context);
|
|
60
|
+
expect(failRes.error).toBeDefined();
|
|
61
|
+
expect(failRes.error?.code).toBe(-32602);
|
|
62
|
+
|
|
63
|
+
// Pass validation
|
|
64
|
+
callSpy.mockResolvedValueOnce({ jsonrpc: '2.0', id: 2, result: { success: true } });
|
|
65
|
+
const passParams = { count: 123 };
|
|
66
|
+
const passRes = await gateway.callTool('mock-upstream__test', passParams, context);
|
|
67
|
+
expect(passRes.error).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should allow tool call if schema is missing (backward compatibility / permissive)', async () => {
|
|
71
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
72
|
+
const callSpy = vi.spyOn(client, 'call').mockResolvedValue({
|
|
73
|
+
jsonrpc: '2.0',
|
|
74
|
+
id: 1,
|
|
75
|
+
result: { tools: [{ name: 'noschema' }] } // no inputSchema
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const context = new ExecutionContext({ logger });
|
|
79
|
+
const params = { any: 'thing' };
|
|
80
|
+
|
|
81
|
+
callSpy.mockResolvedValueOnce({ jsonrpc: '2.0', id: 2, result: { success: true } });
|
|
82
|
+
const res = await gateway.callTool('mock-upstream__noschema', params, context);
|
|
83
|
+
|
|
84
|
+
expect(res.error).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { SocketTransport } from '../src/transport/socket.transport.js';
|
|
4
|
+
import { RequestController } from '../src/core/request.controller.js';
|
|
5
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
6
|
+
import { ConcurrencyService } from '../src/core/concurrency.service.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
|
+
import net from 'net';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
const logger = pino({ level: 'silent' });
|
|
16
|
+
|
|
17
|
+
describe('V1 Hardening Tests', () => {
|
|
18
|
+
let transport: SocketTransport;
|
|
19
|
+
let securityService: SecurityService;
|
|
20
|
+
let requestController: RequestController;
|
|
21
|
+
let socketPath: string;
|
|
22
|
+
let mockDenoExecutor: any;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
const ipcToken = 'master-token';
|
|
26
|
+
securityService = new SecurityService(logger, ipcToken);
|
|
27
|
+
const concurrencyService = new ConcurrencyService(logger, { maxConcurrent: 10 });
|
|
28
|
+
|
|
29
|
+
const gatewayService = {
|
|
30
|
+
callTool: vi.fn(),
|
|
31
|
+
discoverTools: vi.fn().mockResolvedValue([]), // Return empty array for SDK generation
|
|
32
|
+
listToolPackages: vi.fn().mockResolvedValue([]),
|
|
33
|
+
listToolStubs: vi.fn().mockResolvedValue([])
|
|
34
|
+
} as any;
|
|
35
|
+
|
|
36
|
+
const defaultLimits = { timeoutMs: 1000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 5 }; // Low log limit
|
|
37
|
+
|
|
38
|
+
const executorRegistry = new ExecutorRegistry();
|
|
39
|
+
|
|
40
|
+
// Mock DenoExecutor
|
|
41
|
+
mockDenoExecutor = {
|
|
42
|
+
execute: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 })
|
|
43
|
+
};
|
|
44
|
+
executorRegistry.register('deno', mockDenoExecutor as any);
|
|
45
|
+
|
|
46
|
+
const executionService = new ExecutionService(
|
|
47
|
+
logger,
|
|
48
|
+
defaultLimits,
|
|
49
|
+
gatewayService,
|
|
50
|
+
securityService,
|
|
51
|
+
executorRegistry
|
|
52
|
+
);
|
|
53
|
+
executionService.ipcAddress = '127.0.0.1:0'; // Dummy address for tests
|
|
54
|
+
// Ensure executionService has required methods for RequestController healthCheck/warmup delegation
|
|
55
|
+
vi.spyOn(executionService, 'shutdown').mockResolvedValue();
|
|
56
|
+
vi.spyOn(executionService, 'warmup').mockResolvedValue();
|
|
57
|
+
vi.spyOn(executionService, 'healthCheck').mockResolvedValue({ status: 'ok' });
|
|
58
|
+
|
|
59
|
+
requestController = new RequestController(logger, executionService, gatewayService, buildDefaultMiddleware(securityService));
|
|
60
|
+
|
|
61
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
62
|
+
|
|
63
|
+
socketPath = path.join(os.tmpdir(), `conduit-test-${Math.random().toString(36).substring(7)}.sock`);
|
|
64
|
+
if (os.platform() === 'win32') {
|
|
65
|
+
socketPath = '\\\\.\\pipe\\conduit-test-' + Math.random().toString(36).substring(7);
|
|
66
|
+
}
|
|
67
|
+
await transport.listen({ path: socketPath });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await transport.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function sendRequest(request: any): Promise<any> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const client = net.createConnection({ path: socketPath }, () => {
|
|
77
|
+
client.write(JSON.stringify(request) + '\n');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
client.once('data', (data) => {
|
|
81
|
+
resolve(JSON.parse(data.toString()));
|
|
82
|
+
client.end();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
client.on('error', reject);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it('should deny executeTypeScript with session token', async () => {
|
|
90
|
+
const sessionToken = securityService.createSession();
|
|
91
|
+
|
|
92
|
+
const response = await sendRequest({
|
|
93
|
+
jsonrpc: '2.0',
|
|
94
|
+
id: 1,
|
|
95
|
+
method: 'mcp.executeTypeScript',
|
|
96
|
+
params: { code: 'console.log("hi")' },
|
|
97
|
+
auth: { bearerToken: sessionToken }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(response.error).toBeDefined();
|
|
101
|
+
expect(response.error.code).toBe(-32003); // Forbidden
|
|
102
|
+
expect(response.error.message).toContain('Session tokens are restricted');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should allow discoverTools with session token', async () => {
|
|
106
|
+
const sessionToken = securityService.createSession();
|
|
107
|
+
|
|
108
|
+
// Mock handleDiscoverTools response logic via requestController
|
|
109
|
+
// We mocked gatewayService but requestController calls it.
|
|
110
|
+
// We need to ensure requestController.handleDiscoverTools works.
|
|
111
|
+
// It calls gatewayService.discoverTools.
|
|
112
|
+
(requestController as any).gatewayService.discoverTools.mockResolvedValue([]);
|
|
113
|
+
|
|
114
|
+
const response = await sendRequest({
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id: 2,
|
|
117
|
+
method: 'mcp.discoverTools',
|
|
118
|
+
params: {},
|
|
119
|
+
auth: { bearerToken: sessionToken }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(response.error).toBeUndefined();
|
|
123
|
+
expect(response.result).toBeDefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should allow executeTypeScript with master token', async () => {
|
|
127
|
+
const response = await sendRequest({
|
|
128
|
+
jsonrpc: '2.0',
|
|
129
|
+
id: 3,
|
|
130
|
+
method: 'mcp.executeTypeScript',
|
|
131
|
+
params: { code: 'import * as os from "os"; console.log("hi")' },
|
|
132
|
+
auth: { bearerToken: 'master-token' }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// It should call the executor (which we mocked above to success)
|
|
136
|
+
expect(response.error).toBeUndefined();
|
|
137
|
+
expect(mockDenoExecutor.execute).toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import dns from 'node:dns/promises';
|
|
4
|
+
import { UpstreamClient } from '../src/gateway/upstream.client.js';
|
|
5
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
6
|
+
import { AuthService } from '../src/gateway/auth.service.js';
|
|
7
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
8
|
+
import pino from 'pino';
|
|
9
|
+
|
|
10
|
+
const logger = pino({ level: 'silent' });
|
|
11
|
+
|
|
12
|
+
vi.mock('axios');
|
|
13
|
+
vi.mock('node:dns/promises');
|
|
14
|
+
|
|
15
|
+
describe('V1 Hardening', () => {
|
|
16
|
+
let securityService: SecurityService;
|
|
17
|
+
let upstreamClient: UpstreamClient;
|
|
18
|
+
let authService: AuthService;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
securityService = new SecurityService(logger, 'ipctoken');
|
|
22
|
+
authService = new AuthService(logger);
|
|
23
|
+
upstreamClient = new UpstreamClient(
|
|
24
|
+
logger,
|
|
25
|
+
{ id: 'test', url: 'http://example.com/mcp' },
|
|
26
|
+
authService,
|
|
27
|
+
securityService
|
|
28
|
+
);
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('SSRF Protection', () => {
|
|
33
|
+
it('should disable redirects in axios config', async () => {
|
|
34
|
+
// Mock security check to pass
|
|
35
|
+
vi.spyOn(securityService, 'validateUrl').mockResolvedValue({ valid: true });
|
|
36
|
+
(axios.post as any).mockResolvedValue({ data: { jsonrpc: '2.0', id: 1, result: {} } });
|
|
37
|
+
|
|
38
|
+
await upstreamClient.call(
|
|
39
|
+
{ jsonrpc: '2.0', id: 1, method: 'tools/list' },
|
|
40
|
+
new ExecutionContext({ logger })
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
44
|
+
'http://example.com/mcp',
|
|
45
|
+
expect.any(Object),
|
|
46
|
+
expect.objectContaining({ maxRedirects: 0 })
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should check all resolved addresses for private IPs', async () => {
|
|
51
|
+
// Mock dns.lookup to return a private IP in the list
|
|
52
|
+
(dns.lookup as any).mockResolvedValue([
|
|
53
|
+
{ address: '1.1.1.1', family: 4 },
|
|
54
|
+
{ address: '127.0.0.1', family: 4 } // Private!
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const result = await securityService.validateUrl('http://example.com');
|
|
58
|
+
expect(result.valid).toBe(false);
|
|
59
|
+
expect(result.message).toContain('resolves to private network');
|
|
60
|
+
expect(dns.lookup).toHaveBeenCalledWith('example.com', { all: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should allow benign public IPs', async () => {
|
|
64
|
+
(dns.lookup as any).mockResolvedValue([
|
|
65
|
+
{ address: '93.184.216.34', family: 4 }
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const result = await securityService.validateUrl('http://example.com');
|
|
69
|
+
expect(result.valid).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { IsolateExecutor } from '../src/executors/isolate.executor.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
|
|
6
|
+
const logger = pino({ level: 'silent' });
|
|
7
|
+
|
|
8
|
+
describe('IsolateExecutor', () => {
|
|
9
|
+
let executor: IsolateExecutor;
|
|
10
|
+
let gatewayService: any;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
gatewayService = {
|
|
14
|
+
callTool: vi.fn().mockResolvedValue({ result: { content: 'test' } }),
|
|
15
|
+
discoverTools: vi.fn().mockResolvedValue([]),
|
|
16
|
+
};
|
|
17
|
+
executor = new IsolateExecutor(logger, gatewayService);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should execute simple JavaScript code', async () => {
|
|
21
|
+
const code = 'console.log("Hello from isolate")';
|
|
22
|
+
const context = new ExecutionContext({ logger });
|
|
23
|
+
const limits = { timeoutMs: 5000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 };
|
|
24
|
+
|
|
25
|
+
const result = await executor.execute(code, limits, context);
|
|
26
|
+
|
|
27
|
+
expect(result.exitCode).toBe(0);
|
|
28
|
+
expect(result.stdout).toContain('Hello from isolate');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should capture console.error output', async () => {
|
|
32
|
+
const code = 'console.error("Error message")';
|
|
33
|
+
const context = new ExecutionContext({ logger });
|
|
34
|
+
const limits = { timeoutMs: 5000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 };
|
|
35
|
+
|
|
36
|
+
const result = await executor.execute(code, limits, context);
|
|
37
|
+
|
|
38
|
+
expect(result.exitCode).toBe(0);
|
|
39
|
+
expect(result.stderr).toContain('Error message');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should timeout on long execution', async () => {
|
|
43
|
+
const code = 'while(true) {}'; // Infinite loop
|
|
44
|
+
const context = new ExecutionContext({ logger });
|
|
45
|
+
const limits = { timeoutMs: 100, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 };
|
|
46
|
+
|
|
47
|
+
const result = await executor.execute(code, limits, context);
|
|
48
|
+
|
|
49
|
+
expect(result.error).toBeDefined();
|
|
50
|
+
expect(result.error?.code).toBe(-32008); // RequestTimeout
|
|
51
|
+
}, 10000);
|
|
52
|
+
|
|
53
|
+
it('should expose tools.$raw for calling tools', async () => {
|
|
54
|
+
gatewayService.callTool.mockResolvedValue({
|
|
55
|
+
result: { message: 'Tool called!' }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const code = `
|
|
59
|
+
const result = await tools.$raw('test__hello', { arg: 1 });
|
|
60
|
+
console.log(JSON.stringify(result));
|
|
61
|
+
`;
|
|
62
|
+
const context = new ExecutionContext({ logger });
|
|
63
|
+
const limits = { timeoutMs: 5000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 };
|
|
64
|
+
|
|
65
|
+
const result = await executor.execute(code, limits, context);
|
|
66
|
+
|
|
67
|
+
expect(result.exitCode).toBe(0);
|
|
68
|
+
expect(gatewayService.callTool).toHaveBeenCalledWith('test__hello', { arg: 1 }, context);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should use injected SDK script for typed access', async () => {
|
|
72
|
+
gatewayService.callTool.mockResolvedValue({
|
|
73
|
+
result: { content: 'typed result' }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const sdkScript = `
|
|
77
|
+
const tools = {
|
|
78
|
+
mock: {
|
|
79
|
+
async hello(args) {
|
|
80
|
+
const res = await __callTool('mock__hello', JSON.stringify(args));
|
|
81
|
+
return JSON.parse(res);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const code = `
|
|
88
|
+
await tools.mock.hello({ name: 'Typed' });
|
|
89
|
+
console.log('Typed call done');
|
|
90
|
+
`;
|
|
91
|
+
const context = new ExecutionContext({ logger });
|
|
92
|
+
const limits = { timeoutMs: 5000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 };
|
|
93
|
+
|
|
94
|
+
const result = await executor.execute(code, limits, context, { sdkCode: sdkScript });
|
|
95
|
+
|
|
96
|
+
expect(result.exitCode).toBe(0);
|
|
97
|
+
expect(gatewayService.callTool).toHaveBeenCalledWith('mock__hello', { name: 'Typed' }, context);
|
|
98
|
+
expect(result.stdout).toContain('Typed call done');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { DenoExecutor } from '../src/executors/deno.executor.js';
|
|
3
|
+
import { PyodideExecutor } from '../src/executors/pyodide.executor.js';
|
|
4
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
5
|
+
import { ConduitError } from '../src/core/request.controller.js';
|
|
6
|
+
import pino from 'pino';
|
|
7
|
+
|
|
8
|
+
const logger = pino({ level: 'silent' });
|
|
9
|
+
|
|
10
|
+
describe('LogLimitExceeded Verification', () => {
|
|
11
|
+
let context: ExecutionContext;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
context = new ExecutionContext({ logger });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('DenoExecutor', () => {
|
|
18
|
+
it('should return LogLimitExceeded when log entry limit breached', async () => {
|
|
19
|
+
const executor = new DenoExecutor();
|
|
20
|
+
const result = await executor.execute('for(let i=0; i<100; i++) console.log(i)', {
|
|
21
|
+
timeoutMs: 5000,
|
|
22
|
+
memoryLimitMb: 128,
|
|
23
|
+
maxOutputBytes: 1024 * 1024,
|
|
24
|
+
maxLogEntries: 10,
|
|
25
|
+
}, context);
|
|
26
|
+
|
|
27
|
+
expect(result.error?.code).toBe(ConduitError.LogLimitExceeded);
|
|
28
|
+
expect(result.error?.message).toContain('Log entry limit exceeded');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('PyodideExecutor', () => {
|
|
33
|
+
let pyExecutor: PyodideExecutor;
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
pyExecutor = new PyodideExecutor();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await pyExecutor.shutdown();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return LogLimitExceeded when log entry limit breached', async () => {
|
|
44
|
+
const result = await pyExecutor.execute('for i in range(100): print(i)', {
|
|
45
|
+
timeoutMs: 10000,
|
|
46
|
+
memoryLimitMb: 128,
|
|
47
|
+
maxOutputBytes: 1024 * 1024,
|
|
48
|
+
maxLogEntries: 10,
|
|
49
|
+
}, context);
|
|
50
|
+
|
|
51
|
+
expect(result.error?.code).toBe(ConduitError.LogLimitExceeded);
|
|
52
|
+
expect(result.error?.message).toContain('Log entry limit exceeded');
|
|
53
|
+
}, 20000);
|
|
54
|
+
});
|
|
55
|
+
});
|