@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.
Files changed (87) hide show
  1. package/.env.example +13 -0
  2. package/.github/workflows/ci.yml +88 -0
  3. package/.github/workflows/pr-checks.yml +90 -0
  4. package/.tool-versions +2 -0
  5. package/README.md +177 -0
  6. package/conduit.yaml.test +3 -0
  7. package/docs/ARCHITECTURE.md +35 -0
  8. package/docs/CODE_MODE.md +33 -0
  9. package/docs/SECURITY.md +52 -0
  10. package/logo.png +0 -0
  11. package/package.json +74 -0
  12. package/src/assets/deno-shim.ts +93 -0
  13. package/src/assets/python-shim.py +21 -0
  14. package/src/core/asset.utils.ts +42 -0
  15. package/src/core/concurrency.service.ts +70 -0
  16. package/src/core/config.service.ts +147 -0
  17. package/src/core/execution.context.ts +37 -0
  18. package/src/core/execution.service.ts +209 -0
  19. package/src/core/interfaces/app.config.ts +17 -0
  20. package/src/core/interfaces/executor.interface.ts +31 -0
  21. package/src/core/interfaces/middleware.interface.ts +12 -0
  22. package/src/core/interfaces/url.validator.interface.ts +3 -0
  23. package/src/core/logger.ts +64 -0
  24. package/src/core/metrics.service.ts +112 -0
  25. package/src/core/middleware/auth.middleware.ts +56 -0
  26. package/src/core/middleware/error.middleware.ts +21 -0
  27. package/src/core/middleware/logging.middleware.ts +25 -0
  28. package/src/core/middleware/middleware.builder.ts +24 -0
  29. package/src/core/middleware/ratelimit.middleware.ts +31 -0
  30. package/src/core/network.policy.service.ts +106 -0
  31. package/src/core/ops.server.ts +74 -0
  32. package/src/core/otel.service.ts +41 -0
  33. package/src/core/policy.service.ts +77 -0
  34. package/src/core/registries/executor.registry.ts +26 -0
  35. package/src/core/request.controller.ts +297 -0
  36. package/src/core/security.service.ts +68 -0
  37. package/src/core/session.manager.ts +44 -0
  38. package/src/core/types.ts +47 -0
  39. package/src/executors/deno.executor.ts +342 -0
  40. package/src/executors/isolate.executor.ts +281 -0
  41. package/src/executors/pyodide.executor.ts +327 -0
  42. package/src/executors/pyodide.worker.ts +195 -0
  43. package/src/gateway/auth.service.ts +104 -0
  44. package/src/gateway/gateway.service.ts +345 -0
  45. package/src/gateway/schema.cache.ts +46 -0
  46. package/src/gateway/upstream.client.ts +244 -0
  47. package/src/index.ts +92 -0
  48. package/src/sdk/index.ts +2 -0
  49. package/src/sdk/sdk-generator.ts +245 -0
  50. package/src/sdk/tool-binding.ts +86 -0
  51. package/src/transport/socket.transport.ts +203 -0
  52. package/tests/__snapshots__/assets.test.ts.snap +97 -0
  53. package/tests/assets.test.ts +50 -0
  54. package/tests/auth.service.test.ts +78 -0
  55. package/tests/code-mode-lite-execution.test.ts +84 -0
  56. package/tests/code-mode-lite-gateway.test.ts +150 -0
  57. package/tests/concurrency.service.test.ts +50 -0
  58. package/tests/concurrency.test.ts +41 -0
  59. package/tests/config.service.test.ts +70 -0
  60. package/tests/contract.test.ts +43 -0
  61. package/tests/deno.executor.test.ts +68 -0
  62. package/tests/deno_hardening.test.ts +45 -0
  63. package/tests/dynamic.tool.test.ts +237 -0
  64. package/tests/e2e_stdio_upstream.test.ts +197 -0
  65. package/tests/fixtures/stdio-server.ts +42 -0
  66. package/tests/gateway.manifest.test.ts +82 -0
  67. package/tests/gateway.service.test.ts +58 -0
  68. package/tests/gateway.strict.unit.test.ts +74 -0
  69. package/tests/gateway.validation.unit.test.ts +89 -0
  70. package/tests/gateway_validation.test.ts +86 -0
  71. package/tests/hardening.test.ts +139 -0
  72. package/tests/hardening_v1.test.ts +72 -0
  73. package/tests/isolate.executor.test.ts +100 -0
  74. package/tests/log-limit.test.ts +55 -0
  75. package/tests/middleware.test.ts +106 -0
  76. package/tests/ops.server.test.ts +65 -0
  77. package/tests/policy.service.test.ts +90 -0
  78. package/tests/pyodide.executor.test.ts +101 -0
  79. package/tests/reference_mcp.ts +40 -0
  80. package/tests/remediation.test.ts +119 -0
  81. package/tests/routing.test.ts +148 -0
  82. package/tests/schema.cache.test.ts +27 -0
  83. package/tests/sdk/sdk-generator.test.ts +205 -0
  84. package/tests/socket.transport.test.ts +182 -0
  85. package/tests/stdio_upstream.test.ts +54 -0
  86. package/tsconfig.json +25 -0
  87. package/tsup.config.ts +22 -0
@@ -0,0 +1,112 @@
1
+ import { metrics as otelMetrics, Counter, Histogram, ObservableGauge, ValueType } from '@opentelemetry/api';
2
+
3
+ export class MetricsService {
4
+ private static instance: MetricsService;
5
+ private meter = otelMetrics.getMeter('conduit');
6
+
7
+ private executionCounter: Counter;
8
+ private cacheHitsCounter: Counter;
9
+ private cacheMissesCounter: Counter;
10
+ private executionLatency: Histogram;
11
+ private toolExecutionDuration: Histogram;
12
+ private requestQueueLength: ObservableGauge;
13
+ private activeExecutionsGauge: ObservableGauge;
14
+
15
+ private activeExecutionsCount = 0;
16
+
17
+ private queueLengthCallback: () => number = () => 0;
18
+
19
+ private constructor() {
20
+ this.executionCounter = this.meter.createCounter('conduit.executions.total', {
21
+ description: 'Total number of executions',
22
+ });
23
+
24
+ this.cacheHitsCounter = this.meter.createCounter('conduit.cache.hits.total', {
25
+ description: 'Total number of schema cache hits',
26
+ });
27
+
28
+ this.cacheMissesCounter = this.meter.createCounter('conduit.cache.misses.total', {
29
+ description: 'Total number of schema cache misses',
30
+ });
31
+
32
+ this.executionLatency = this.meter.createHistogram('conduit.executions.latency', {
33
+ description: 'Execution latency in milliseconds',
34
+ unit: 'ms',
35
+ valueType: ValueType.DOUBLE,
36
+ });
37
+
38
+ this.toolExecutionDuration = this.meter.createHistogram('conduit.tool.execution_duration_seconds', {
39
+ description: 'Duration of tool executions',
40
+ unit: 's',
41
+ valueType: ValueType.DOUBLE,
42
+ });
43
+
44
+ this.requestQueueLength = this.meter.createObservableGauge('conduit.request_queue_length', {
45
+ description: 'Current request queue depth',
46
+ valueType: ValueType.INT,
47
+ });
48
+
49
+ this.activeExecutionsGauge = this.meter.createObservableGauge('conduit.executions.active', {
50
+ description: 'Current number of active executions',
51
+ });
52
+
53
+ this.activeExecutionsGauge.addCallback((result) => {
54
+ result.observe(this.activeExecutionsCount);
55
+ });
56
+
57
+ this.requestQueueLength.addCallback((result) => {
58
+ result.observe(this.queueLengthCallback());
59
+ });
60
+ }
61
+
62
+ static getInstance(): MetricsService {
63
+ if (!MetricsService.instance) {
64
+ MetricsService.instance = new MetricsService();
65
+ }
66
+ return MetricsService.instance;
67
+ }
68
+
69
+ recordExecutionStart() {
70
+ this.activeExecutionsCount++;
71
+ this.executionCounter.add(1);
72
+ }
73
+
74
+ recordExecutionEnd(durationMs: number, toolName?: string) {
75
+ this.activeExecutionsCount = Math.max(0, this.activeExecutionsCount - 1);
76
+ this.executionLatency.record(durationMs, { tool: toolName || 'unknown' });
77
+ }
78
+
79
+ recordToolExecution(durationMs: number, toolName: string, success: boolean) {
80
+ // Convert ms to seconds for the histogram
81
+ this.toolExecutionDuration.record(durationMs / 1000, {
82
+ tool_name: toolName,
83
+ success: String(success)
84
+ });
85
+ }
86
+
87
+ recordCacheHit() {
88
+ this.cacheHitsCounter.add(1);
89
+ }
90
+
91
+ recordCacheMiss() {
92
+ this.cacheMissesCounter.add(1);
93
+ }
94
+
95
+ // This is now handled by OTEL Prometheus exporter,
96
+ // but we can provide a way to get the endpoint data if needed.
97
+
98
+
99
+ getMetrics() {
100
+ return {
101
+ activeExecutions: this.activeExecutionsCount,
102
+ uptime: process.uptime(),
103
+ memory: process.memoryUsage(),
104
+ };
105
+ }
106
+
107
+ registerQueueLengthProvider(provider: () => number) {
108
+ this.queueLengthCallback = provider;
109
+ }
110
+ }
111
+
112
+ export const metrics = MetricsService.getInstance();
@@ -0,0 +1,56 @@
1
+ import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
2
+ import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
3
+ import { ExecutionContext } from '../execution.context.js';
4
+ import { SecurityService } from '../security.service.js';
5
+
6
+ export class AuthMiddleware implements Middleware {
7
+ constructor(private securityService: SecurityService) { }
8
+
9
+ async handle(
10
+ request: JSONRPCRequest,
11
+ context: ExecutionContext,
12
+ next: NextFunction
13
+ ): Promise<JSONRPCResponse> {
14
+ const providedToken = request.auth?.bearerToken || '';
15
+
16
+ const isMaster = providedToken === this.securityService.getIpcToken();
17
+ const isSession = this.securityService.validateIpcToken(providedToken) && !isMaster;
18
+
19
+ if (!isMaster && !isSession) {
20
+ return {
21
+ jsonrpc: '2.0',
22
+ id: request.id,
23
+ error: {
24
+ code: ConduitError.Forbidden,
25
+ message: 'Invalid bearer token'
26
+ }
27
+ };
28
+ }
29
+
30
+ // Strict scoping for session tokens
31
+ if (isSession) {
32
+ const allowedMethods = ['mcp.discoverTools', 'mcp.callTool'];
33
+ if (!allowedMethods.includes(request.method)) {
34
+ return {
35
+ jsonrpc: '2.0',
36
+ id: request.id,
37
+ error: {
38
+ code: ConduitError.Forbidden,
39
+ message: 'Session tokens are restricted to tool discovery and calling only'
40
+ }
41
+ };
42
+ }
43
+
44
+ // Enrich context with session details if needed
45
+ const session = this.securityService.getSession(providedToken);
46
+ if (session?.allowedTools && !context.allowedTools) {
47
+ // If context didn't already have specific tools (e.g. from request params override which shouldn't happen for sessions generally,
48
+ // but usually session allowedTools wins or merges.
49
+ // In generic logic, let's respect session.
50
+ context.allowedTools = session.allowedTools;
51
+ }
52
+ }
53
+
54
+ return next();
55
+ }
56
+ }
@@ -0,0 +1,21 @@
1
+ import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
2
+ import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
3
+ import { ExecutionContext } from '../execution.context.js';
4
+
5
+ export class ErrorHandlingMiddleware implements Middleware {
6
+ async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
7
+ try {
8
+ return await next();
9
+ } catch (err: any) {
10
+ context.logger.error({ err }, 'Error handling request');
11
+ return {
12
+ jsonrpc: '2.0',
13
+ id: request.id,
14
+ error: {
15
+ code: ConduitError.InternalError,
16
+ message: err.message || 'Internal Server Error',
17
+ },
18
+ };
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
2
+ import { JSONRPCRequest, JSONRPCResponse } from '../types.js';
3
+ import { ExecutionContext } from '../execution.context.js';
4
+ import { metrics } from '../metrics.service.js';
5
+
6
+ export class LoggingMiddleware implements Middleware {
7
+ async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
8
+ const { method, id } = request;
9
+ const childLogger = context.logger.child({ method, id });
10
+ context.logger = childLogger; // Update context logger for downstream
11
+
12
+ metrics.recordExecutionStart();
13
+ const startTime = Date.now();
14
+
15
+ try {
16
+ const response = await next();
17
+ metrics.recordExecutionEnd(Date.now() - startTime, method);
18
+ return response;
19
+ } catch (err) {
20
+ // Should be caught by ErrorMiddleware, but just in case
21
+ metrics.recordExecutionEnd(Date.now() - startTime, method);
22
+ throw err;
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * MiddlewareBuilder - Factory for building middleware pipelines
3
+ * Extracted from RequestController per architecture-findings.md
4
+ */
5
+
6
+ import { Middleware } from '../interfaces/middleware.interface.js';
7
+ import { ErrorHandlingMiddleware } from './error.middleware.js';
8
+ import { LoggingMiddleware } from './logging.middleware.js';
9
+ import { AuthMiddleware } from './auth.middleware.js';
10
+ import { RateLimitMiddleware } from './ratelimit.middleware.js';
11
+ import { SecurityService } from '../security.service.js';
12
+
13
+ /**
14
+ * Build the default middleware pipeline used by RequestController.
15
+ * This centralizes middleware configuration outside the controller.
16
+ */
17
+ export function buildDefaultMiddleware(securityService: SecurityService): Middleware[] {
18
+ return [
19
+ new ErrorHandlingMiddleware(),
20
+ new LoggingMiddleware(),
21
+ new AuthMiddleware(securityService),
22
+ new RateLimitMiddleware(securityService),
23
+ ];
24
+ }
@@ -0,0 +1,31 @@
1
+ import { Middleware, NextFunction } from '../interfaces/middleware.interface.js';
2
+ import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
3
+ import { ExecutionContext } from '../execution.context.js';
4
+ import { SecurityService } from '../security.service.js';
5
+
6
+ export class RateLimitMiddleware implements Middleware {
7
+ constructor(private securityService: SecurityService) { }
8
+
9
+ async handle(
10
+ request: JSONRPCRequest,
11
+ context: ExecutionContext,
12
+ next: NextFunction
13
+ ): Promise<JSONRPCResponse> {
14
+ const providedToken = request.auth?.bearerToken;
15
+ // Use token if available, otherwise fallback to remote address from context
16
+ const rateLimitKey = providedToken || context.remoteAddress || 'unknown';
17
+
18
+ if (!this.securityService.checkRateLimit(rateLimitKey)) {
19
+ return {
20
+ jsonrpc: '2.0',
21
+ id: request.id,
22
+ error: {
23
+ code: -32005, // Rate limit exceeded code
24
+ message: 'Rate limit exceeded'
25
+ }
26
+ };
27
+ }
28
+
29
+ return next();
30
+ }
31
+ }
@@ -0,0 +1,106 @@
1
+ import { Logger } from 'pino';
2
+ import dns from 'node:dns/promises';
3
+ import net from 'node:net';
4
+ import { LRUCache } from 'lru-cache';
5
+
6
+ export class NetworkPolicyService {
7
+ private logger: Logger;
8
+
9
+ private readonly privateRanges = [
10
+ /^127\./,
11
+ /^10\./,
12
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
13
+ /^192\.168\./,
14
+ /^169\.254\./, // Link-local
15
+ /^localhost$/i,
16
+ /^0\.0\.0\.0$/,
17
+ /^::1$/, // IPv6 localhost
18
+ /^fc00:/i, // IPv6 private
19
+ /^fe80:/i, // IPv6 link-local
20
+ ];
21
+
22
+ private readonly RATE_LIMIT = 30;
23
+ private readonly WINDOW_MS = 60000;
24
+ // Use LRUCache to prevent unbounded memory growth
25
+ private requestCounts: LRUCache<string, { count: number; resetTime: number }>;
26
+
27
+ constructor(logger: Logger) {
28
+ this.logger = logger;
29
+ this.requestCounts = new LRUCache({
30
+ max: 10000,
31
+ ttl: this.WINDOW_MS,
32
+ });
33
+ }
34
+
35
+ async validateUrl(url: string): Promise<{ valid: boolean; message?: string; resolvedIp?: string }> {
36
+ try {
37
+ const parsed = new URL(url);
38
+ const hostname = parsed.hostname;
39
+
40
+ // Check literal hostname against private ranges
41
+ for (const range of this.privateRanges) {
42
+ if (range.test(hostname)) {
43
+ this.logger.warn({ hostname }, 'SSRF attempt detected: private range access');
44
+ return { valid: false, message: 'Access denied: private network access forbidden' };
45
+ }
46
+ }
47
+
48
+ // DNS resolution check to prevent DNS rebinding and handle tricky hostnames
49
+ if (!net.isIP(hostname)) {
50
+ try {
51
+ const lookup = await dns.lookup(hostname, { all: true });
52
+ // Store resolved IPs to check against blocklist
53
+ const resolvedIps: string[] = [];
54
+
55
+ for (const address of lookup) {
56
+ let ip = address.address;
57
+
58
+ // Fix Sev0: Normalize IPv6-mapped IPv4 addresses
59
+ if (ip.startsWith('::ffff:')) {
60
+ ip = ip.substring(7);
61
+ }
62
+
63
+ for (const range of this.privateRanges) {
64
+ if (range.test(ip)) {
65
+ this.logger.warn({ hostname, ip }, 'SSRF attempt detected: DNS resolves to private IP');
66
+ return { valid: false, message: 'Access denied: hostname resolves to private network' };
67
+ }
68
+ }
69
+ resolvedIps.push(ip);
70
+ }
71
+
72
+ // Fix Sev1: Return the validated IP to prevent DNS rebinding
73
+ // Use the first resolved IP
74
+ return { valid: true, resolvedIp: resolvedIps[0] };
75
+ } catch (err: any) {
76
+ // Strict SSRF protection: block if DNS lookup fails
77
+ this.logger.warn({ hostname, err: err.message }, 'DNS lookup failed during URL validation, blocking request');
78
+ return { valid: false, message: 'Access denied: hostname resolution failed' };
79
+ }
80
+ }
81
+
82
+ // If it was already an IP, it's valid if it passed the range check above
83
+ return { valid: true, resolvedIp: hostname };
84
+ } catch (err: any) {
85
+ return { valid: false, message: `Invalid URL: ${err.message}` };
86
+ }
87
+ }
88
+
89
+ checkRateLimit(key: string): boolean {
90
+ const now = Date.now();
91
+ const record = this.requestCounts.get(key);
92
+
93
+ if (!record || now > record.resetTime) {
94
+ this.requestCounts.set(key, { count: 1, resetTime: now + this.WINDOW_MS });
95
+ return true;
96
+ }
97
+
98
+ if (record.count >= this.RATE_LIMIT) {
99
+ this.logger.warn({ key }, 'Rate limit exceeded');
100
+ return false;
101
+ }
102
+
103
+ record.count++;
104
+ return true;
105
+ }
106
+ }
@@ -0,0 +1,74 @@
1
+ import Fastify from 'fastify';
2
+ import { Logger } from 'pino';
3
+ import { AppConfig } from './interfaces/app.config.js';
4
+ import { GatewayService } from '../gateway/gateway.service.js';
5
+ import { metrics } from './metrics.service.js';
6
+ import { RequestController } from './request.controller.js';
7
+ import axios from 'axios';
8
+
9
+ export class OpsServer {
10
+ private fastify = Fastify();
11
+ private logger: Logger;
12
+ private config: AppConfig;
13
+ private gatewayService: GatewayService;
14
+ private requestController: RequestController;
15
+
16
+ constructor(logger: Logger, config: AppConfig, gatewayService: GatewayService, requestController: RequestController) {
17
+ this.logger = logger;
18
+ this.config = config;
19
+ this.gatewayService = gatewayService;
20
+ this.requestController = requestController;
21
+
22
+ this.setupRoutes();
23
+ }
24
+
25
+ private setupRoutes() {
26
+ this.fastify.get('/health', async (request, reply) => {
27
+ const gatewayHealth = await this.gatewayService.healthCheck();
28
+ const requestHealth = await this.requestController.healthCheck();
29
+
30
+ const overallStatus = gatewayHealth.status === 'ok' && requestHealth.status === 'ok' ? 'ok' : 'error';
31
+
32
+ return reply.status(overallStatus === 'ok' ? 200 : 503).send({
33
+ status: overallStatus,
34
+ version: '1.0.0',
35
+ gateway: gatewayHealth,
36
+ request: requestHealth,
37
+ });
38
+ });
39
+
40
+ this.fastify.get('/metrics', async (request, reply) => {
41
+ try {
42
+ // Proxy from OTEL Prometheus exporter
43
+ // Use ConfigService for metrics URL, default to standard localhost:9464
44
+ const metricsUrl = this.config.metricsUrl || 'http://127.0.0.1:9464/metrics';
45
+ const response = await axios.get(metricsUrl);
46
+ return reply.type('text/plain').send(response.data);
47
+ } catch (err) {
48
+ this.logger.error({ err }, 'Failed to fetch OTEL metrics');
49
+ // Fallback to minimal metrics if OTEL exporter is down
50
+ const fallback = '# Metrics consolidated into OpenTelemetry. Check port 9464.\n' +
51
+ `conduit_uptime_seconds ${process.uptime()}\n` +
52
+ `conduit_memory_rss_bytes ${process.memoryUsage().rss}\n`;
53
+ return reply.type('text/plain').send(fallback);
54
+ }
55
+ });
56
+ }
57
+
58
+ async listen(): Promise<string> {
59
+ // Use explicit opsPort from config
60
+ const port = this.config.opsPort || 3001;
61
+ try {
62
+ const address = await this.fastify.listen({ port, host: '0.0.0.0' });
63
+ this.logger.info({ address }, 'Ops server listening');
64
+ return address;
65
+ } catch (err) {
66
+ this.logger.error({ err }, 'Failed to start Ops server');
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ async close() {
72
+ await this.fastify.close();
73
+ }
74
+ }
@@ -0,0 +1,41 @@
1
+ import { NodeSDK } from '@opentelemetry/sdk-node';
2
+ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
3
+ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
4
+ import { resourceFromAttributes } from '@opentelemetry/resources';
5
+ import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
6
+ import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
7
+
8
+ export class OtelService {
9
+ private sdk: NodeSDK | null = null;
10
+
11
+ constructor(private logger: any) { }
12
+
13
+ async start() {
14
+ this.sdk = new NodeSDK({
15
+ resource: resourceFromAttributes({
16
+ [SemanticResourceAttributes.SERVICE_NAME]: 'conduit',
17
+ }),
18
+ metricReader: new PrometheusExporter({
19
+ port: 9464, // Default prometheus exporter port
20
+ }),
21
+ instrumentations: [
22
+ getNodeAutoInstrumentations(),
23
+ new PinoInstrumentation(),
24
+ ],
25
+ });
26
+
27
+ try {
28
+ await this.sdk.start();
29
+ this.logger.info('OpenTelemetry SDK started');
30
+ } catch (error) {
31
+ this.logger.error({ error }, 'Error starting OpenTelemetry SDK');
32
+ }
33
+ }
34
+
35
+ async shutdown() {
36
+ if (this.sdk) {
37
+ await this.sdk.shutdown();
38
+ this.logger.info('OpenTelemetry SDK shut down');
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * PolicyService - Authorization and tool access control
3
+ * Extracted from GatewayService per architecture-findings.md
4
+ */
5
+
6
+ /**
7
+ * Structured identifier for a tool, replacing fragile `upstream__toolname` strings.
8
+ */
9
+ export interface ToolIdentifier {
10
+ namespace: string; // upstream ID (e.g., "github")
11
+ name: string; // tool name (e.g., "createIssue")
12
+ }
13
+
14
+ export class PolicyService {
15
+ /**
16
+ * Parse a qualified tool name string into a structured ToolIdentifier.
17
+ * @param qualifiedName - e.g., "github__createIssue" or "github__api__listRepos"
18
+ */
19
+ parseToolName(qualifiedName: string): ToolIdentifier {
20
+ const separatorIndex = qualifiedName.indexOf('__');
21
+ if (separatorIndex === -1) {
22
+ // No namespace - treat entire string as name with empty namespace
23
+ return { namespace: '', name: qualifiedName };
24
+ }
25
+ return {
26
+ namespace: qualifiedName.substring(0, separatorIndex),
27
+ name: qualifiedName.substring(separatorIndex + 2)
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Format a ToolIdentifier back to a qualified string.
33
+ */
34
+ formatToolName(tool: ToolIdentifier): string {
35
+ if (!tool.namespace) {
36
+ return tool.name;
37
+ }
38
+ return `${tool.namespace}__${tool.name}`;
39
+ }
40
+
41
+ /**
42
+ * Check if a tool matches any pattern in the allowlist.
43
+ * Supports:
44
+ * - Exact match: "github.createIssue" matches "github__createIssue"
45
+ * - Wildcard: "github.*" matches any tool in the github namespace
46
+ *
47
+ * @param tool - ToolIdentifier or qualified string
48
+ * @param allowedTools - Array of patterns (dot-notation, e.g., "github.*" or "github.createIssue")
49
+ */
50
+ isToolAllowed(tool: ToolIdentifier | string, allowedTools: string[]): boolean {
51
+ const toolId = typeof tool === 'string' ? this.parseToolName(tool) : tool;
52
+ const toolParts = [toolId.namespace, ...toolId.name.split('__')].filter(p => p);
53
+
54
+ return allowedTools.some(pattern => {
55
+ const patternParts = pattern.split('.');
56
+
57
+ // Wildcard pattern: "foo.*" or "foo.bar.*"
58
+ if (patternParts[patternParts.length - 1] === '*') {
59
+ const prefixParts = patternParts.slice(0, -1);
60
+ if (prefixParts.length > toolParts.length) return false;
61
+
62
+ // Check if prefix parts match tool parts exactly
63
+ for (let i = 0; i < prefixParts.length; i++) {
64
+ if (prefixParts[i] !== toolParts[i]) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ // Exact match: pattern parts must equal tool parts
70
+ if (patternParts.length !== toolParts.length) return false;
71
+ for (let i = 0; i < patternParts.length; i++) {
72
+ if (patternParts[i] !== toolParts[i]) return false;
73
+ }
74
+ return true;
75
+ });
76
+ }
77
+ }
@@ -0,0 +1,26 @@
1
+ import { Executor } from '../interfaces/executor.interface.js';
2
+
3
+ export class ExecutorRegistry {
4
+ private executors = new Map<string, Executor>();
5
+
6
+ register(name: string, executor: Executor): void {
7
+ this.executors.set(name, executor);
8
+ }
9
+
10
+ get(name: string): Executor | undefined {
11
+ return this.executors.get(name);
12
+ }
13
+
14
+ has(name: string): boolean {
15
+ return this.executors.has(name);
16
+ }
17
+
18
+ async shutdownAll(): Promise<void> {
19
+ for (const executor of this.executors.values()) {
20
+ if (executor.shutdown) {
21
+ await executor.shutdown();
22
+ }
23
+ }
24
+ this.executors.clear();
25
+ }
26
+ }