@suveren/gateway 0.10.0 → 0.11.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.
@@ -831,9 +831,22 @@ function buildPinnedHttpsAgent(getPinning) {
831
831
  ca: effectiveCa()
832
832
  });
833
833
  }
834
+ var DEFAULT_AS_FETCH_TIMEOUT_MS = 15e3;
835
+ function withBoundedSignal(init) {
836
+ const timeout = AbortSignal.timeout(DEFAULT_AS_FETCH_TIMEOUT_MS);
837
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
838
+ return { ...init, signal };
839
+ }
840
+ function closeAfter(dispatcher, pending) {
841
+ pending.catch(() => {
842
+ }).finally(() => {
843
+ void dispatcher.close().catch(() => {
844
+ });
845
+ });
846
+ }
834
847
  async function fetchAs(url, init, pinning) {
835
848
  if (!pinning.enforce && !pinning.capture) {
836
- const res2 = await fetch(url, init);
849
+ const res2 = await fetch(url, withBoundedSignal(init));
837
850
  return { res: res2 };
838
851
  }
839
852
  if (pinning.enforce && !pinning.capture && !pinning.pinnedSpkiHex) {
@@ -843,8 +856,10 @@ async function fetchAs(url, init, pinning) {
843
856
  }
844
857
  if (pinning.pinnedSpkiHex && (pinning.enforce || !pinning.capture)) {
845
858
  const dispatcher2 = buildPinnedDispatcher(pinning.pinnedSpkiHex);
859
+ const pending2 = undiciFetch(url, { ...withBoundedSignal(init), dispatcher: dispatcher2 });
860
+ closeAfter(dispatcher2, pending2);
846
861
  try {
847
- const res2 = await undiciFetch(url, { ...init, dispatcher: dispatcher2 });
862
+ const res2 = await pending2;
848
863
  return { res: res2 };
849
864
  } catch (err) {
850
865
  if (!isPinCheckError(err)) throw err;
@@ -854,9 +869,11 @@ async function fetchAs(url, init, pinning) {
854
869
  }
855
870
  }
856
871
  const { dispatcher, getCapturedCert } = buildCapturingDispatcher();
872
+ const pending = undiciFetch(url, { ...withBoundedSignal(init), dispatcher });
873
+ closeAfter(dispatcher, pending);
857
874
  let res;
858
875
  try {
859
- res = await undiciFetch(url, { ...init, dispatcher });
876
+ res = await pending;
860
877
  } catch (err) {
861
878
  if (!isPinCheckError(err)) throw err;
862
879
  throw new AsTlsMismatchError(
@@ -1208,7 +1225,12 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
1208
1225
  // identifies itself this way; without it, a 24-hour browser
1209
1226
  // session (see doc at the top of this file).
1210
1227
  ...clientVersionHeaders()
1211
- }
1228
+ },
1229
+ // Belt-and-suspenders on top of fetchAs's own default bound
1230
+ // (as-tls-pin.ts): this is the exact call that hung in
1231
+ // production (2026-10-02) with NO timeout at all — a dead
1232
+ // keep-alive socket left the whole login request stuck forever.
1233
+ signal: AbortSignal.timeout(1e4)
1212
1234
  },
1213
1235
  sessionPinning
1214
1236
  );
@@ -1218,7 +1240,11 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
1218
1240
  res.status(409).json({ error: "as_tls_mismatch", message: err.message });
1219
1241
  return;
1220
1242
  }
1221
- throw err;
1243
+ res.status(502).json({
1244
+ error: "as_unreachable",
1245
+ message: `Could not reach the Authority Server at ${SP_URL2} to sign in \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1246
+ });
1247
+ return;
1222
1248
  }
1223
1249
  if (!spRes.ok) {
1224
1250
  const err = await spRes.json().catch(() => ({ error: "Invalid API key" }));
@@ -1366,7 +1392,16 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
1366
1392
  unconfigureSession(),
1367
1393
  cookie ? fetchAs(
1368
1394
  `${SP_URL2}/api/auth/logout`,
1369
- { method: "POST", headers: { cookie, ...clientVersionHeaders() }, redirect: "manual" },
1395
+ {
1396
+ method: "POST",
1397
+ headers: { cookie, ...clientVersionHeaders() },
1398
+ redirect: "manual",
1399
+ // Same reasoning as the session call above — this sat with no
1400
+ // timeout of its own before, which would have hung the
1401
+ // `Promise.allSettled` below (and this handler's response)
1402
+ // forever on a dead connection.
1403
+ signal: AbortSignal.timeout(1e4)
1404
+ },
1370
1405
  logoutPinning
1371
1406
  ).catch((err) => {
1372
1407
  if (err instanceof AsTlsMismatchError) {
@@ -3510,6 +3545,11 @@ function selectDenials(records, opts = {}) {
3510
3545
  return { count, records: out };
3511
3546
  }
3512
3547
 
3548
+ // src/lib/simulation-mode.ts
3549
+ function isSimulationMode() {
3550
+ return process.env.SUVEREN_SIMULATION === "1";
3551
+ }
3552
+
3513
3553
  // src/index.ts
3514
3554
  (function migrateDataDir() {
3515
3555
  if (process.env.SUVEREN_DATA_DIR) return;
@@ -4124,6 +4164,9 @@ app.get("/health", async (req, res) => {
4124
4164
  updateAvailable: update.updateAvailable,
4125
4165
  installMethod: INSTALL_METHOD,
4126
4166
  spUrl: SP_URL,
4167
+ // Purely local — the Authority Server never learns this. UI reads it to
4168
+ // show a gateway-wide banner ("real systems are blocked").
4169
+ simulation: isSimulationMode(),
4127
4170
  session: buildSessionHealth(vault),
4128
4171
  security: {
4129
4172
  note: "Gateway secures tool execution. Agent host isolation is the user's responsibility."
@@ -324,9 +324,22 @@ function buildCapturingDispatcher() {
324
324
  });
325
325
  return { dispatcher, getCapturedCert: () => captured };
326
326
  }
327
+ var DEFAULT_AS_FETCH_TIMEOUT_MS = 15e3;
328
+ function withBoundedSignal(init) {
329
+ const timeout = AbortSignal.timeout(DEFAULT_AS_FETCH_TIMEOUT_MS);
330
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
331
+ return { ...init, signal };
332
+ }
333
+ function closeAfter(dispatcher, pending) {
334
+ pending.catch(() => {
335
+ }).finally(() => {
336
+ void dispatcher.close().catch(() => {
337
+ });
338
+ });
339
+ }
327
340
  async function fetchAs(url, init, pinning) {
328
341
  if (!pinning.enforce && !pinning.capture) {
329
- const res2 = await fetch(url, init);
342
+ const res2 = await fetch(url, withBoundedSignal(init));
330
343
  return { res: res2 };
331
344
  }
332
345
  if (pinning.enforce && !pinning.capture && !pinning.pinnedSpkiHex) {
@@ -336,8 +349,10 @@ async function fetchAs(url, init, pinning) {
336
349
  }
337
350
  if (pinning.pinnedSpkiHex && (pinning.enforce || !pinning.capture)) {
338
351
  const dispatcher2 = buildPinnedDispatcher(pinning.pinnedSpkiHex);
352
+ const pending2 = undiciFetch(url, { ...withBoundedSignal(init), dispatcher: dispatcher2 });
353
+ closeAfter(dispatcher2, pending2);
339
354
  try {
340
- const res2 = await undiciFetch(url, { ...init, dispatcher: dispatcher2 });
355
+ const res2 = await pending2;
341
356
  return { res: res2 };
342
357
  } catch (err) {
343
358
  if (!isPinCheckError(err)) throw err;
@@ -347,9 +362,11 @@ async function fetchAs(url, init, pinning) {
347
362
  }
348
363
  }
349
364
  const { dispatcher, getCapturedCert } = buildCapturingDispatcher();
365
+ const pending = undiciFetch(url, { ...withBoundedSignal(init), dispatcher });
366
+ closeAfter(dispatcher, pending);
350
367
  let res;
351
368
  try {
352
- res = await undiciFetch(url, { ...init, dispatcher });
369
+ res = await pending;
353
370
  } catch (err) {
354
371
  if (!isPinCheckError(err)) throw err;
355
372
  throw new AsTlsMismatchError(
@@ -2582,10 +2599,62 @@ function clampAgeFloor(requested, maxAgeDays, nowMs, format = "iso") {
2582
2599
  }
2583
2600
 
2584
2601
  // src/lib/tool-proxy.ts
2585
- import { getProfile as getProfile3, ContentBindingError } from "@hap/core";
2602
+ import { getProfile as getProfile3, ContentBindingError, boundActionTypes as boundActionTypes2 } from "@hap/core";
2586
2603
  import { readFile } from "fs/promises";
2587
2604
  import { extname } from "path";
2588
2605
  import { randomUUID } from "crypto";
2606
+
2607
+ // src/lib/manifest-loader.ts
2608
+ import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
2609
+ import { join as join11, resolve } from "path";
2610
+ var manifests = /* @__PURE__ */ new Map();
2611
+ function loadManifests(integrationsDir) {
2612
+ const dir = resolve(
2613
+ integrationsDir ?? process.env.SUVEREN_MANIFESTS_DIR ?? join11(import.meta.dirname ?? __dirname, "..", "..", "..", "..", "content", "integrations")
2614
+ );
2615
+ const indexPath = join11(dir, "index.json");
2616
+ if (!existsSync11(indexPath)) {
2617
+ console.error(`[ManifestLoader] No index.json found at ${indexPath}, skipping manifest loading`);
2618
+ return 0;
2619
+ }
2620
+ let index;
2621
+ try {
2622
+ index = JSON.parse(readFileSync12(indexPath, "utf-8"));
2623
+ } catch (err) {
2624
+ console.error(`[ManifestLoader] Failed to parse ${indexPath}:`, err);
2625
+ return 0;
2626
+ }
2627
+ let loaded = 0;
2628
+ for (const [id, relativePath] of Object.entries(index.integrations)) {
2629
+ const manifestPath = join11(dir, relativePath);
2630
+ try {
2631
+ const manifest = JSON.parse(readFileSync12(manifestPath, "utf-8"));
2632
+ manifests.set(id, manifest);
2633
+ loaded++;
2634
+ } catch (err) {
2635
+ console.error(`[ManifestLoader] Failed to load manifest ${id} from ${manifestPath}:`, err);
2636
+ }
2637
+ }
2638
+ console.error(`[ManifestLoader] Loaded ${loaded} integration manifest(s) from ${dir}`);
2639
+ return loaded;
2640
+ }
2641
+ function getManifest(id) {
2642
+ return manifests.get(id);
2643
+ }
2644
+ function getAllManifests() {
2645
+ return Array.from(manifests.values());
2646
+ }
2647
+
2648
+ // src/lib/simulation-mode.ts
2649
+ function isSimulationMode() {
2650
+ return process.env.SUVEREN_SIMULATION === "1";
2651
+ }
2652
+ function manifestIsSimulated(manifest) {
2653
+ return Boolean(manifest?.simulation);
2654
+ }
2655
+ var SIMULATION_BLOCK_REASON = "blocked: simulation mode \u2014 real systems are off";
2656
+
2657
+ // src/lib/tool-proxy.ts
2589
2658
  var IMAGE_MIME = {
2590
2659
  ".jpg": "image/jpeg",
2591
2660
  ".jpeg": "image/jpeg",
@@ -2645,6 +2714,38 @@ function applyMapping(m, value, execution) {
2645
2714
  function profileMatches(profileId, shortName) {
2646
2715
  return profileId === shortName || profileId.includes("/" + shortName + "@") || profileId.endsWith("/" + shortName);
2647
2716
  }
2717
+ function countBoundsFor(profileId, actionType) {
2718
+ const fields = getProfile3(profileId)?.boundsSchema?.fields;
2719
+ if (!fields) return null;
2720
+ return Object.entries(fields).filter(([, def]) => def.boundType?.kind === "cumulative_count").filter(([name, def]) => {
2721
+ const applies = boundActionTypes2(name, def);
2722
+ return applies === void 0 || applies.includes(actionType);
2723
+ }).map(([name]) => name);
2724
+ }
2725
+ function zeroCappedBound(profileId, bounds, actionType) {
2726
+ if (typeof actionType !== "string") return null;
2727
+ const names = countBoundsFor(profileId, actionType);
2728
+ if (!names) return null;
2729
+ for (const n of names) {
2730
+ const v = bounds?.[n];
2731
+ if (v !== void 0 && v !== "" && Number(v) <= 0) return n;
2732
+ }
2733
+ return null;
2734
+ }
2735
+ function toolIsAuthorizedForDisplay(tool, matchingAuths) {
2736
+ if (!tool.gating?.hideUnlessAuthorized) return true;
2737
+ if (matchingAuths.length === 0) return false;
2738
+ const profileId = tool.gating.profile;
2739
+ const actionType = tool.gating.staticExecution?.action_type;
2740
+ if (!profileId || typeof actionType !== "string") return false;
2741
+ const requiredFields = countBoundsFor(profileId, actionType);
2742
+ if (!requiredFields) return false;
2743
+ if (requiredFields.length === 0) return true;
2744
+ return matchingAuths.some((a) => {
2745
+ const bounds = a.bounds ?? a.frame;
2746
+ return requiredFields.every((f) => Number(bounds?.[f] ?? 0) > 0);
2747
+ });
2748
+ }
2648
2749
  function parseFirstJson(result) {
2649
2750
  const text = result.content?.find((c) => c.type === "text")?.text;
2650
2751
  if (typeof text !== "string") return void 0;
@@ -2670,6 +2771,15 @@ function denyRead(state2, tool, reason, detail, target) {
2670
2771
  return { content: [{ type: "text", text: `Read blocked by Gatekeeper: ${detail}` }], isError: true };
2671
2772
  }
2672
2773
  function createGatedToolHandler(tool, integrationManager2, state2) {
2774
+ if (isSimulationMode() && !manifestIsSimulated(getManifest(tool.integrationId))) {
2775
+ return async () => ({
2776
+ content: [{
2777
+ type: "text",
2778
+ text: `Refused: "${tool.namespacedName}" talks to a real system, but this gateway is running in simulation mode (real systems are off). No ticket was requested.`
2779
+ }],
2780
+ isError: true
2781
+ });
2782
+ }
2673
2783
  const gated = createGatedToolHandlerInner(tool, integrationManager2, state2);
2674
2784
  const inner = async (args) => gated(normalizeIncomingArgs(tool, args));
2675
2785
  const blocked = tool.gating?.blockedArgs ?? [];
@@ -2945,7 +3055,10 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
2945
3055
  bounds: candidate.bounds,
2946
3056
  context: candidate.context
2947
3057
  });
2948
- if (result.approved) {
3058
+ const capped = result.approved ? zeroCappedBound(candidate.profileId, candidate.bounds ?? candidate.frame, typeof execution.action_type === "string" ? execution.action_type : void 0) : null;
3059
+ if (result.approved && capped) {
3060
+ errors.push(`${candidate.path}: ${capped} is 0 \u2014 this mandate does not allow "${execution.action_type}"`);
3061
+ } else if (result.approved) {
2949
3062
  passers.push(candidate);
2950
3063
  } else {
2951
3064
  const reasons = result.errors.map((e) => {
@@ -3981,9 +4094,10 @@ function createMcpServer(state2, integrationManager2) {
3981
4094
  const description = buildProxiedToolDescription(tool, state2);
3982
4095
  registered.update({ description });
3983
4096
  if (tool.gating?.profile) {
3984
- const hasAuth = auths.some(
4097
+ const matchingAuths = auths.filter(
3985
4098
  (a) => a.complete && profileMatches(a.profileId, tool.gating.profile)
3986
4099
  );
4100
+ const hasAuth = matchingAuths.length > 0 && toolIsAuthorizedForDisplay(tool, matchingAuths);
3987
4101
  if (hasAuth) registered.enable();
3988
4102
  else registered.disable();
3989
4103
  } else {
@@ -4024,16 +4138,16 @@ function verifyGateContentHashes(content, auth) {
4024
4138
  }
4025
4139
 
4026
4140
  // src/lib/integration-registry.ts
4027
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync9, existsSync as existsSync11 } from "fs";
4141
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync9, existsSync as existsSync12 } from "fs";
4028
4142
  import { homedir as homedir8 } from "os";
4029
- import { dirname as dirname9, join as join11 } from "path";
4030
- var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join11(homedir8(), ".suveren");
4143
+ import { dirname as dirname9, join as join12 } from "path";
4144
+ var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join12(homedir8(), ".suveren");
4031
4145
  var IntegrationRegistry = class {
4032
4146
  integrations = /* @__PURE__ */ new Map();
4033
4147
  filePath;
4034
4148
  constructor(dataDir2) {
4035
4149
  const dir = dataDir2 ?? DEFAULT_DATA_DIR;
4036
- this.filePath = join11(dir, "integrations.json");
4150
+ this.filePath = join12(dir, "integrations.json");
4037
4151
  this.load();
4038
4152
  }
4039
4153
  // ─── CRUD ───────────────────────────────────────────────────────────────
@@ -4064,12 +4178,12 @@ var IntegrationRegistry = class {
4064
4178
  }
4065
4179
  // ─── Load / Save ────────────────────────────────────────────────────────
4066
4180
  load() {
4067
- if (!existsSync11(this.filePath)) {
4181
+ if (!existsSync12(this.filePath)) {
4068
4182
  mkdirSync9(dirname9(this.filePath), { recursive: true });
4069
4183
  return;
4070
4184
  }
4071
4185
  try {
4072
- const raw = readFileSync12(this.filePath, "utf-8");
4186
+ const raw = readFileSync13(this.filePath, "utf-8");
4073
4187
  const data = JSON.parse(raw);
4074
4188
  const migrated = data.integrations.map((i) => {
4075
4189
  if ("toolGating" in i && !("profile" in i)) {
@@ -4112,47 +4226,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4112
4226
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4113
4227
  import { getProfile as getProfile5 } from "@hap/core";
4114
4228
 
4115
- // src/lib/manifest-loader.ts
4116
- import { readFileSync as readFileSync13, existsSync as existsSync12 } from "fs";
4117
- import { join as join12, resolve } from "path";
4118
- var manifests = /* @__PURE__ */ new Map();
4119
- function loadManifests(integrationsDir) {
4120
- const dir = resolve(
4121
- integrationsDir ?? process.env.SUVEREN_MANIFESTS_DIR ?? join12(import.meta.dirname ?? __dirname, "..", "..", "..", "..", "content", "integrations")
4122
- );
4123
- const indexPath = join12(dir, "index.json");
4124
- if (!existsSync12(indexPath)) {
4125
- console.error(`[ManifestLoader] No index.json found at ${indexPath}, skipping manifest loading`);
4126
- return 0;
4127
- }
4128
- let index;
4129
- try {
4130
- index = JSON.parse(readFileSync13(indexPath, "utf-8"));
4131
- } catch (err) {
4132
- console.error(`[ManifestLoader] Failed to parse ${indexPath}:`, err);
4133
- return 0;
4134
- }
4135
- let loaded = 0;
4136
- for (const [id, relativePath] of Object.entries(index.integrations)) {
4137
- const manifestPath = join12(dir, relativePath);
4138
- try {
4139
- const manifest = JSON.parse(readFileSync13(manifestPath, "utf-8"));
4140
- manifests.set(id, manifest);
4141
- loaded++;
4142
- } catch (err) {
4143
- console.error(`[ManifestLoader] Failed to load manifest ${id} from ${manifestPath}:`, err);
4144
- }
4145
- }
4146
- console.error(`[ManifestLoader] Loaded ${loaded} integration manifest(s) from ${dir}`);
4147
- return loaded;
4148
- }
4149
- function getManifest(id) {
4150
- return manifests.get(id);
4151
- }
4152
- function getAllManifests() {
4153
- return Array.from(manifests.values());
4154
- }
4155
-
4156
4229
  // src/lib/remote-auth-preflight.ts
4157
4230
  var AUTH_HEADER_RE = /^authorization:\s*(.+)$/i;
4158
4231
  function remotePreflightTarget(command, args) {
@@ -4460,10 +4533,21 @@ var IntegrationManager = class {
4460
4533
  if (opts.skipIfRunning) return existing.tools;
4461
4534
  await this.stopIntegrationLocked(config.id);
4462
4535
  }
4536
+ if (isSimulationMode() && !manifestIsSimulated(getManifest(config.id))) {
4537
+ throw new Error(`${config.id}: ${SIMULATION_BLOCK_REASON}`);
4538
+ }
4463
4539
  if (config.npmPackage) {
4464
4540
  await this.ensureInstalled(config.npmPackage);
4465
4541
  }
4466
4542
  const env = this.resolveEnvKeys(config);
4543
+ if (isSimulationMode()) {
4544
+ const manifest = getManifest(config.id);
4545
+ const simDecl = manifest?.simulation;
4546
+ if (simDecl) {
4547
+ const envVar = Object.entries(manifest.credentials.envMapping).find(([, credKey]) => credKey === simDecl.field)?.[0];
4548
+ if (envVar) env[envVar] = "simulation";
4549
+ }
4550
+ }
4467
4551
  const interpolate = (s) => s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => env[name] ?? process.env[name] ?? "");
4468
4552
  const args = config.args.map(interpolate);
4469
4553
  const remote = remotePreflightTarget(config.command, args);
@@ -4638,7 +4722,15 @@ var IntegrationManager = class {
4638
4722
  name: config.name,
4639
4723
  running: false,
4640
4724
  toolCount: 0,
4641
- readAgeDays: readAgeOf(config)
4725
+ readAgeDays: readAgeOf(config),
4726
+ // Computed fresh, not cached: true whenever simulation mode is on
4727
+ // AND this connector has no manifest `simulation` marker, which is
4728
+ // exactly the condition startIntegrationLocked refuses under — so
4729
+ // the UI always reflects why a real connector never started,
4730
+ // without needing a separate error-tracking path for this one
4731
+ // reason. Unrelated start failures still surface via the
4732
+ // `warning` string returned by /internal/add-integration.
4733
+ error: isSimulationMode() && !manifestIsSimulated(getManifest(config.id)) ? SIMULATION_BLOCK_REASON : void 0
4642
4734
  });
4643
4735
  }
4644
4736
  }
@@ -4732,7 +4824,8 @@ var IntegrationManager = class {
4732
4824
  read: ext.read,
4733
4825
  readGovernance: ext.readGovernance,
4734
4826
  readGovernanceReason: ext.readGovernanceReason,
4735
- blockedArgs: ext.blockedArgs
4827
+ blockedArgs: ext.blockedArgs,
4828
+ hideUnlessAuthorized: ext.hideUnlessAuthorized
4736
4829
  };
4737
4830
  }
4738
4831
  return {
@@ -4746,7 +4839,8 @@ var IntegrationManager = class {
4746
4839
  contentField: ext.contentField,
4747
4840
  blockedArgs: ext.blockedArgs,
4748
4841
  argEncoding: ext.argEncoding,
4749
- argNormalization: ext.argNormalization
4842
+ argNormalization: ext.argNormalization,
4843
+ hideUnlessAuthorized: ext.hideUnlessAuthorized
4750
4844
  };
4751
4845
  }
4752
4846
  return {