@mhingston5/conduit 1.1.7 → 1.1.8
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 -1
- package/dist/index.js +124 -33
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/core/middleware/auth.middleware.ts +1 -2
- package/src/core/security.service.ts +8 -8
- package/src/executors/isolate.executor.ts +39 -12
- package/src/gateway/upstream.client.ts +95 -7
- package/src/index.ts +5 -1
- package/tests/middleware.test.ts +16 -13
- package/tests/routing.test.ts +1 -0
- package/tests/upstream.transports.test.ts +40 -1
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ npx conduit auth \
|
|
|
108
108
|
--auth-url <url> \
|
|
109
109
|
--token-url <url> \
|
|
110
110
|
--scopes <scopes>
|
|
111
|
+
```
|
|
111
112
|
|
|
112
113
|
For Atlassian (3LO), include `offline_access` and set the audience:
|
|
113
114
|
|
|
@@ -119,7 +120,6 @@ npx conduit auth \
|
|
|
119
120
|
--token-url "https://auth.atlassian.com/oauth/token" \
|
|
120
121
|
--scopes "offline_access,read:me"
|
|
121
122
|
```
|
|
122
|
-
```
|
|
123
123
|
|
|
124
124
|
This will start a temporary local server, open your browser for authorization, and print the generated `credentials` block for your `conduit.yaml`.
|
|
125
125
|
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
+
import { createRequire } from "module";
|
|
5
6
|
|
|
6
7
|
// src/core/config.service.ts
|
|
7
8
|
import { z } from "zod";
|
|
@@ -1083,6 +1084,9 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
1083
1084
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1084
1085
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
1085
1086
|
import { z as z2 } from "zod";
|
|
1087
|
+
import dns from "dns";
|
|
1088
|
+
import net2 from "net";
|
|
1089
|
+
import { Agent } from "undici";
|
|
1086
1090
|
var UpstreamClient = class {
|
|
1087
1091
|
logger;
|
|
1088
1092
|
info;
|
|
@@ -1091,6 +1095,9 @@ var UpstreamClient = class {
|
|
|
1091
1095
|
mcpClient;
|
|
1092
1096
|
transport;
|
|
1093
1097
|
connected = false;
|
|
1098
|
+
// Pinned-IP dispatchers per upstream origin (defends against DNS rebinding)
|
|
1099
|
+
dispatcherCache = /* @__PURE__ */ new Map();
|
|
1100
|
+
pinned;
|
|
1094
1101
|
constructor(logger, info, authService, urlValidator) {
|
|
1095
1102
|
this.logger = logger.child({ upstreamId: info.id });
|
|
1096
1103
|
this.info = info;
|
|
@@ -1116,7 +1123,9 @@ var UpstreamClient = class {
|
|
|
1116
1123
|
return;
|
|
1117
1124
|
}
|
|
1118
1125
|
if (this.info.type === "streamableHttp") {
|
|
1119
|
-
|
|
1126
|
+
const upstreamUrl = new URL(this.info.url);
|
|
1127
|
+
this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
|
|
1128
|
+
this.transport = new StreamableHTTPClientTransport(upstreamUrl, {
|
|
1120
1129
|
fetch: this.createAuthedFetch()
|
|
1121
1130
|
});
|
|
1122
1131
|
this.mcpClient = new Client({
|
|
@@ -1128,6 +1137,8 @@ var UpstreamClient = class {
|
|
|
1128
1137
|
return;
|
|
1129
1138
|
}
|
|
1130
1139
|
if (this.info.type === "sse") {
|
|
1140
|
+
const upstreamUrl = new URL(this.info.url);
|
|
1141
|
+
this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
|
|
1131
1142
|
this.mcpClient = new Client({
|
|
1132
1143
|
name: "conduit-gateway",
|
|
1133
1144
|
version: "1.0.0"
|
|
@@ -1136,16 +1147,65 @@ var UpstreamClient = class {
|
|
|
1136
1147
|
});
|
|
1137
1148
|
}
|
|
1138
1149
|
}
|
|
1150
|
+
getDispatcher(origin, hostname, resolvedIp) {
|
|
1151
|
+
const existing = this.dispatcherCache.get(origin);
|
|
1152
|
+
if (existing && existing.resolvedIp === resolvedIp) {
|
|
1153
|
+
return existing.agent;
|
|
1154
|
+
}
|
|
1155
|
+
if (existing) {
|
|
1156
|
+
try {
|
|
1157
|
+
existing.agent.close();
|
|
1158
|
+
} catch {
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const agent = new Agent({
|
|
1162
|
+
connect: {
|
|
1163
|
+
lookup: (lookupHostname, options, callback) => {
|
|
1164
|
+
if (lookupHostname === hostname) {
|
|
1165
|
+
callback(null, resolvedIp, net2.isIP(resolvedIp));
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
dns.lookup(lookupHostname, options, callback);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
this.dispatcherCache.set(origin, { resolvedIp, agent });
|
|
1173
|
+
return agent;
|
|
1174
|
+
}
|
|
1139
1175
|
createAuthedFetch() {
|
|
1140
1176
|
const creds = this.info.credentials;
|
|
1141
|
-
|
|
1177
|
+
const pinned = this.pinned;
|
|
1178
|
+
const baseFetch = fetch;
|
|
1142
1179
|
return async (input, init = {}) => {
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1180
|
+
const requestUrlStr = (() => {
|
|
1181
|
+
if (typeof input === "string") return input;
|
|
1182
|
+
if (input instanceof URL) return input.toString();
|
|
1183
|
+
if (input instanceof Request) return input.url;
|
|
1184
|
+
return String(input);
|
|
1185
|
+
})();
|
|
1186
|
+
const requestUrl = pinned ? new URL(requestUrlStr, pinned.origin) : new URL(requestUrlStr);
|
|
1187
|
+
if (pinned && requestUrl.origin !== pinned.origin) {
|
|
1188
|
+
throw new Error(`Forbidden upstream redirect/origin: ${requestUrl.origin}`);
|
|
1147
1189
|
}
|
|
1148
|
-
|
|
1190
|
+
if (pinned && !pinned.resolvedIp) {
|
|
1191
|
+
const securityResult = await this.urlValidator.validateUrl(pinned.origin);
|
|
1192
|
+
if (!securityResult.valid) {
|
|
1193
|
+
throw new Error(securityResult.message || "Forbidden URL");
|
|
1194
|
+
}
|
|
1195
|
+
pinned.resolvedIp = securityResult.resolvedIp;
|
|
1196
|
+
}
|
|
1197
|
+
const headers = new Headers((input instanceof Request ? input.headers : void 0) || void 0);
|
|
1198
|
+
const initHeaders = new Headers(init.headers || {});
|
|
1199
|
+
for (const [k, v] of initHeaders.entries()) headers.set(k, v);
|
|
1200
|
+
if (creds) {
|
|
1201
|
+
const authHeaders = await this.authService.getAuthHeaders(creds);
|
|
1202
|
+
for (const [k, v] of Object.entries(authHeaders)) {
|
|
1203
|
+
headers.set(k, v);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const request = input instanceof Request ? new Request(input, { ...init, headers, redirect: init.redirect ?? "manual" }) : new Request(requestUrl.toString(), { ...init, headers, redirect: init.redirect ?? "manual" });
|
|
1207
|
+
const dispatcher = pinned && pinned.resolvedIp ? this.getDispatcher(pinned.origin, pinned.hostname, pinned.resolvedIp) : void 0;
|
|
1208
|
+
return baseFetch(request, dispatcher ? { dispatcher } : void 0);
|
|
1149
1209
|
};
|
|
1150
1210
|
}
|
|
1151
1211
|
async ensureConnected() {
|
|
@@ -1166,6 +1226,9 @@ var UpstreamClient = class {
|
|
|
1166
1226
|
this.logger.error({ url: this.info.url }, "Blocked upstream URL (SSRF)");
|
|
1167
1227
|
throw new Error(securityResult.message || "Forbidden URL");
|
|
1168
1228
|
}
|
|
1229
|
+
if (this.pinned) {
|
|
1230
|
+
this.pinned.resolvedIp = securityResult.resolvedIp;
|
|
1231
|
+
}
|
|
1169
1232
|
}
|
|
1170
1233
|
try {
|
|
1171
1234
|
this.logger.debug("Connecting to upstream transport...");
|
|
@@ -1935,8 +1998,8 @@ var GatewayService = class {
|
|
|
1935
1998
|
};
|
|
1936
1999
|
|
|
1937
2000
|
// src/core/network.policy.service.ts
|
|
1938
|
-
import
|
|
1939
|
-
import
|
|
2001
|
+
import dns2 from "dns/promises";
|
|
2002
|
+
import net3 from "net";
|
|
1940
2003
|
import { LRUCache as LRUCache2 } from "lru-cache";
|
|
1941
2004
|
var NetworkPolicyService = class {
|
|
1942
2005
|
logger;
|
|
@@ -1977,9 +2040,9 @@ var NetworkPolicyService = class {
|
|
|
1977
2040
|
return { valid: false, message: "Access denied: private network access forbidden" };
|
|
1978
2041
|
}
|
|
1979
2042
|
}
|
|
1980
|
-
if (!
|
|
2043
|
+
if (!net3.isIP(hostname)) {
|
|
1981
2044
|
try {
|
|
1982
|
-
const lookup = await
|
|
2045
|
+
const lookup = await dns2.lookup(hostname, { all: true });
|
|
1983
2046
|
const resolvedIps = [];
|
|
1984
2047
|
for (const address of lookup) {
|
|
1985
2048
|
let ip = address.address;
|
|
@@ -2080,13 +2143,14 @@ var SecurityService = class {
|
|
|
2080
2143
|
checkRateLimit(key) {
|
|
2081
2144
|
return this.networkPolicy.checkRateLimit(key);
|
|
2082
2145
|
}
|
|
2083
|
-
|
|
2084
|
-
if (!this.ipcToken)
|
|
2085
|
-
return true;
|
|
2086
|
-
}
|
|
2146
|
+
isMasterToken(token) {
|
|
2147
|
+
if (!this.ipcToken) return true;
|
|
2087
2148
|
const expected = Buffer.from(this.ipcToken);
|
|
2088
|
-
const actual = Buffer.from(token);
|
|
2089
|
-
|
|
2149
|
+
const actual = Buffer.from(token || "");
|
|
2150
|
+
return expected.length === actual.length && crypto2.timingSafeEqual(expected, actual);
|
|
2151
|
+
}
|
|
2152
|
+
validateIpcToken(token) {
|
|
2153
|
+
if (this.isMasterToken(token)) {
|
|
2090
2154
|
return true;
|
|
2091
2155
|
}
|
|
2092
2156
|
return !!this.sessionManager.getSession(token);
|
|
@@ -2709,26 +2773,30 @@ var IsolateExecutor = class {
|
|
|
2709
2773
|
const jail = ctx.global;
|
|
2710
2774
|
let currentLogBytes = 0;
|
|
2711
2775
|
let currentErrorBytes = 0;
|
|
2776
|
+
let totalLogEntries = 0;
|
|
2712
2777
|
await jail.set("__log", new ivm.Callback((msg) => {
|
|
2778
|
+
if (totalLogEntries + 1 > limits.maxLogEntries) {
|
|
2779
|
+
throw new Error("[LIMIT_LOG_ENTRIES]");
|
|
2780
|
+
}
|
|
2713
2781
|
if (currentLogBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
2714
2782
|
throw new Error("[LIMIT_LOG]");
|
|
2715
2783
|
}
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
}
|
|
2784
|
+
totalLogEntries++;
|
|
2785
|
+
logs.push(msg);
|
|
2786
|
+
currentLogBytes += msg.length + 1;
|
|
2720
2787
|
}));
|
|
2721
2788
|
await jail.set("__error", new ivm.Callback((msg) => {
|
|
2789
|
+
if (totalLogEntries + 1 > limits.maxLogEntries) {
|
|
2790
|
+
throw new Error("[LIMIT_LOG_ENTRIES]");
|
|
2791
|
+
}
|
|
2722
2792
|
if (currentErrorBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
2723
2793
|
throw new Error("[LIMIT_OUTPUT]");
|
|
2724
2794
|
}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
}
|
|
2795
|
+
totalLogEntries++;
|
|
2796
|
+
errors.push(msg);
|
|
2797
|
+
currentErrorBytes += msg.length + 1;
|
|
2729
2798
|
}));
|
|
2730
2799
|
let requestIdCounter = 0;
|
|
2731
|
-
const pendingToolCalls = /* @__PURE__ */ new Map();
|
|
2732
2800
|
await jail.set("__dispatchToolCall", new ivm.Callback((nameStr, argsStr) => {
|
|
2733
2801
|
const requestId = ++requestIdCounter;
|
|
2734
2802
|
const name = nameStr;
|
|
@@ -2864,6 +2932,28 @@ var IsolateExecutor = class {
|
|
|
2864
2932
|
}
|
|
2865
2933
|
};
|
|
2866
2934
|
}
|
|
2935
|
+
if (message.includes("[LIMIT_LOG_ENTRIES]")) {
|
|
2936
|
+
return {
|
|
2937
|
+
stdout: logs.join("\n"),
|
|
2938
|
+
stderr: errors.join("\n"),
|
|
2939
|
+
exitCode: null,
|
|
2940
|
+
error: {
|
|
2941
|
+
code: -32014 /* LogLimitExceeded */,
|
|
2942
|
+
message: "Log entry limit exceeded"
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
if (message.includes("[LIMIT_LOG]") || message.includes("[LIMIT_OUTPUT]")) {
|
|
2947
|
+
return {
|
|
2948
|
+
stdout: logs.join("\n"),
|
|
2949
|
+
stderr: errors.join("\n"),
|
|
2950
|
+
exitCode: null,
|
|
2951
|
+
error: {
|
|
2952
|
+
code: -32013 /* OutputLimitExceeded */,
|
|
2953
|
+
message: "Output limit exceeded"
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2867
2957
|
this.logger.error({ err }, "Isolate execution failed");
|
|
2868
2958
|
return {
|
|
2869
2959
|
stdout: logs.join("\n"),
|
|
@@ -3276,13 +3366,13 @@ var ExecutionService = class {
|
|
|
3276
3366
|
const packages = await this.gatewayService.listToolPackages();
|
|
3277
3367
|
const allBindings = [];
|
|
3278
3368
|
this.logger.debug({ packageCount: packages.length, packages: packages.map((p) => p.id) }, "Fetching tool bindings");
|
|
3279
|
-
for (const
|
|
3369
|
+
for (const pkg2 of packages) {
|
|
3280
3370
|
try {
|
|
3281
|
-
const stubs = await this.gatewayService.listToolStubs(
|
|
3282
|
-
this.logger.debug({ packageId:
|
|
3371
|
+
const stubs = await this.gatewayService.listToolStubs(pkg2.id, context);
|
|
3372
|
+
this.logger.debug({ packageId: pkg2.id, stubCount: stubs.length }, "Got stubs from package");
|
|
3283
3373
|
allBindings.push(...stubs.map((s) => toToolBinding(s.id, void 0, s.description)));
|
|
3284
3374
|
} catch (err) {
|
|
3285
|
-
this.logger.warn({ packageId:
|
|
3375
|
+
this.logger.warn({ packageId: pkg2.id, err: err.message }, "Failed to list stubs for package");
|
|
3286
3376
|
}
|
|
3287
3377
|
}
|
|
3288
3378
|
this.logger.info({ totalBindings: allBindings.length }, "Tool bindings ready for SDK generation");
|
|
@@ -3377,8 +3467,7 @@ var AuthMiddleware = class {
|
|
|
3377
3467
|
}
|
|
3378
3468
|
async handle(request, context, next) {
|
|
3379
3469
|
const providedToken = request.auth?.bearerToken || "";
|
|
3380
|
-
const
|
|
3381
|
-
const isMaster = !masterToken || providedToken === masterToken;
|
|
3470
|
+
const isMaster = this.securityService.isMasterToken(providedToken);
|
|
3382
3471
|
const isSession = !isMaster && this.securityService.validateIpcToken(providedToken);
|
|
3383
3472
|
if (!isMaster && !isSession) {
|
|
3384
3473
|
return {
|
|
@@ -3639,7 +3728,9 @@ async function handleAuth(options) {
|
|
|
3639
3728
|
|
|
3640
3729
|
// src/index.ts
|
|
3641
3730
|
var program = new Command();
|
|
3642
|
-
|
|
3731
|
+
var require2 = createRequire(import.meta.url);
|
|
3732
|
+
var pkg = require2("../package.json");
|
|
3733
|
+
program.name("conduit").description("A secure Code Mode execution substrate for MCP agents").version(pkg.version || "0.0.0");
|
|
3643
3734
|
program.command("serve", { isDefault: true }).description("Start the Conduit server").option("--stdio", "Use stdio transport").option("--config <path>", "Path to config file").action(async (options) => {
|
|
3644
3735
|
try {
|
|
3645
3736
|
await startServer(options);
|