@suveren/gateway 0.10.1 → 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.
- package/README.md +36 -0
- package/bin/suveren-gateway.js +117 -2
- package/content/integrations/crm.json +2 -1
- package/content/integrations/erp.json +2 -1
- package/content/integrations/mail.json +2 -1
- package/dist/control-plane/index.mjs +8 -0
- package/dist/mcp-server/http.mjs +130 -53
- package/dist/ui/assets/{index-sq564t5H.js → index-Bqb9azUL.js} +21 -21
- package/dist/ui/assets/index-DBdWDq4V.css +1 -0
- package/dist/ui/index.html +2 -2
- package/lib/config.d.mts +4 -0
- package/lib/config.mjs +16 -0
- package/package.json +1 -1
- package/server.js +8 -1
- package/dist/ui/assets/index-CV9Mbbkd.css +0 -1
package/README.md
CHANGED
|
@@ -185,6 +185,42 @@ See `suveren-gateway config help` for the full command reference.
|
|
|
185
185
|
|
|
186
186
|
---
|
|
187
187
|
|
|
188
|
+
## Simulation mode — block every real system
|
|
189
|
+
|
|
190
|
+
A mandate is bound to a *profile* (e.g. "sales"), not to a specific connector. If a real
|
|
191
|
+
connector (e.g. a live ERP account) and a simulated one (the built-in ERP/CRM/email
|
|
192
|
+
simulators) share a profile on one gateway, a mandate meant only for testing also
|
|
193
|
+
authorizes the real connector — nothing about the mandate says which one it's for.
|
|
194
|
+
|
|
195
|
+
Simulation mode closes that gap gateway-wide: turn it on and **every connector without a
|
|
196
|
+
manifest `simulation` marker is refused to even start** (the built-in ERP, CRM, and email
|
|
197
|
+
simulators all declare one; a connected Gmail or live ERP account does not), and every
|
|
198
|
+
connector that does declare one has its mode forced to `simulation`, overriding whatever
|
|
199
|
+
credential value is on file. Mandates and profiles are completely unaffected, and the
|
|
200
|
+
Authority Server never learns this is on — it's a purely local, gateway-side switch.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
# Start fresh with real systems blocked:
|
|
204
|
+
suveren-gateway start --simulation
|
|
205
|
+
|
|
206
|
+
# Or flip it on an existing install (takes effect on the next start/restart):
|
|
207
|
+
suveren-gateway simulation on
|
|
208
|
+
suveren-gateway restart
|
|
209
|
+
|
|
210
|
+
# Check the mode (the SAVED setting and, if the gateway is running, the LIVE one):
|
|
211
|
+
suveren-gateway simulation status
|
|
212
|
+
|
|
213
|
+
# Turning it off makes real systems reachable again, so it requires typed
|
|
214
|
+
# confirmation (or --confirm live for scripts) — turning it ON does not:
|
|
215
|
+
suveren-gateway simulation off
|
|
216
|
+
suveren-gateway restart
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`suveren-gateway status` also shows the current mode. See `suveren-gateway simulation help`
|
|
220
|
+
for the full command reference.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
188
224
|
## Technical Documentation
|
|
189
225
|
|
|
190
226
|
| Document | Contents |
|
package/bin/suveren-gateway.js
CHANGED
|
@@ -17,7 +17,8 @@ import { homedir, platform, userInfo } from 'node:os';
|
|
|
17
17
|
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { buildLaunchAgentPlist, buildMacLauncher, buildSystemdUnit, buildWindowsTaskXml } from '../lib/autostart-templates.mjs';
|
|
20
|
-
import { DEFAULT_AS_URL, readConfig, writeConfig, validateAsUrl, validateCaFile, validatePinTls, resolveAsUrl, resolvePinTls, resolvePinTlsExpectedFingerprint } from '../lib/config.mjs';
|
|
20
|
+
import { DEFAULT_AS_URL, readConfig, writeConfig, validateAsUrl, validateCaFile, validatePinTls, resolveAsUrl, resolvePinTls, resolvePinTlsExpectedFingerprint, resolveSimulation } from '../lib/config.mjs';
|
|
21
|
+
import { createInterface } from 'node:readline/promises';
|
|
21
22
|
import { readPairing as readAsPairing, recordTlsPin, formatFingerprint, normalizeFingerprint } from '../lib/as-pairing.mjs';
|
|
22
23
|
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -210,6 +211,13 @@ async function start(args) {
|
|
|
210
211
|
pinTlsToSave = true;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// --simulation: the safe direction (turning OFF real systems), so — unlike
|
|
215
|
+
// `simulation off` — this needs no confirmation. Only ever turns it ON;
|
|
216
|
+
// there is no `start --no-simulation` (use `simulation off` for that, which
|
|
217
|
+
// DOES require confirmation, since that direction makes real systems
|
|
218
|
+
// reachable again).
|
|
219
|
+
const simulationFlag = args.includes('--simulation');
|
|
220
|
+
|
|
213
221
|
if (await isAlreadyRunning()) {
|
|
214
222
|
console.error(`suveren-gateway is already running (pid ${readPid()}). Use \`suveren-gateway stop\` first or \`suveren-gateway restart\`.`);
|
|
215
223
|
process.exit(1);
|
|
@@ -269,6 +277,10 @@ async function start(args) {
|
|
|
269
277
|
: ` Staged — the first sign-in's challenge must match it (refused otherwise); it then becomes the permanent pin.`,
|
|
270
278
|
);
|
|
271
279
|
}
|
|
280
|
+
if (simulationFlag) {
|
|
281
|
+
writeConfig(DATA_DIR, { simulation: true });
|
|
282
|
+
console.log(`Simulation mode: ON (saved) — every connector without a manifest "simulation" marker will be blocked.`);
|
|
283
|
+
}
|
|
272
284
|
|
|
273
285
|
if (detach) {
|
|
274
286
|
const out = openSync(LOG_FILE, 'a');
|
|
@@ -414,6 +426,7 @@ async function status() {
|
|
|
414
426
|
console.log('suveren-gateway: running (managed by the login service)');
|
|
415
427
|
console.log(` UI: http://localhost:${SUVEREN_PORT}`);
|
|
416
428
|
console.log(` Vault: ${body.vaultUnlocked ? 'unlocked' : 'locked'}`);
|
|
429
|
+
console.log(` Simulation: ${body.simulation ? 'on — real systems are blocked' : 'off'}`);
|
|
417
430
|
console.log(` Version: ${body.version ?? 'unknown'} (running)`);
|
|
418
431
|
console.log(` Service: suveren-gateway service status`);
|
|
419
432
|
return;
|
|
@@ -438,6 +451,7 @@ async function status() {
|
|
|
438
451
|
console.log(`suveren-gateway: running (pid ${pid})`);
|
|
439
452
|
console.log(` UI: http://localhost:${SUVEREN_PORT}`);
|
|
440
453
|
console.log(` Vault: ${body.vaultUnlocked ? 'unlocked' : 'locked'}`);
|
|
454
|
+
console.log(` Simulation: ${body.simulation ? 'on — real systems are blocked' : 'off'}`);
|
|
441
455
|
console.log(` Version: ${body.version ?? 'unknown'} (running)`);
|
|
442
456
|
if (CLI_VERSION) console.log(` ${CLI_VERSION} (installed CLI)`);
|
|
443
457
|
if (CLI_VERSION && body.version && body.version !== CLI_VERSION && body.version !== 'dev') {
|
|
@@ -634,6 +648,102 @@ async function config(args) {
|
|
|
634
648
|
if (sub !== undefined && sub !== 'help' && sub !== '--help' && sub !== '-h') process.exit(2);
|
|
635
649
|
}
|
|
636
650
|
|
|
651
|
+
// ─── simulation: gateway-wide switch that blocks every real system ──────
|
|
652
|
+
//
|
|
653
|
+
// A mandate is bound to a PROFILE, not a connector — see suveren-gateway's
|
|
654
|
+
// tool-proxy.ts (`profileMatches`). Put a real connector and a simulated one
|
|
655
|
+
// on the same profile, and one mandate authorizes both. Simulation mode
|
|
656
|
+
// closes that generically: ON blocks every connector whose manifest carries
|
|
657
|
+
// no `simulation` marker, and forces every connector that DOES have one into
|
|
658
|
+
// its simulated mode, regardless of what credential value is on file.
|
|
659
|
+
// Mandates and profiles are unaffected; the Authority Server never learns
|
|
660
|
+
// about this — it is a purely local, gateway-side switch. See
|
|
661
|
+
// apps/mcp-server/src/lib/simulation-mode.ts for enforcement.
|
|
662
|
+
|
|
663
|
+
function printSimulationHelp() {
|
|
664
|
+
console.log(`suveren-gateway simulation — gateway-wide switch that blocks every real system
|
|
665
|
+
|
|
666
|
+
Usage:
|
|
667
|
+
suveren-gateway simulation on Block every real connector (no confirmation — the safe direction)
|
|
668
|
+
suveren-gateway simulation off Make real systems reachable again (requires confirmation)
|
|
669
|
+
[--confirm live] Non-interactive confirmation
|
|
670
|
+
suveren-gateway simulation status Show the saved setting and (if running) the live one
|
|
671
|
+
|
|
672
|
+
Changes take effect on the next \`suveren-gateway start\` / \`restart\`.
|
|
673
|
+
`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function printSimulationStatus() {
|
|
677
|
+
const saved = resolveSimulation(DATA_DIR);
|
|
678
|
+
console.log(`Saved: ${saved ? 'on' : 'off'}`);
|
|
679
|
+
try {
|
|
680
|
+
const res = await fetch(`http://localhost:${SUVEREN_PORT}/health`, { signal: AbortSignal.timeout(3000) });
|
|
681
|
+
if (res.ok) {
|
|
682
|
+
const body = await res.json();
|
|
683
|
+
const running = body.simulation === true;
|
|
684
|
+
console.log(`Running: ${running ? 'on' : 'off'}${running !== saved ? ' (restart to apply the saved value)' : ''}`);
|
|
685
|
+
}
|
|
686
|
+
} catch {
|
|
687
|
+
// Not reachable — the saved value is all there is to report.
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Read one line from stdin, or undefined if there is no TTY to prompt on
|
|
692
|
+
* (so a non-interactive caller gets a clean abort instead of a hang). */
|
|
693
|
+
async function promptLine(question) {
|
|
694
|
+
if (!process.stdin.isTTY) return undefined;
|
|
695
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
696
|
+
try {
|
|
697
|
+
return (await rl.question(question)).trim();
|
|
698
|
+
} finally {
|
|
699
|
+
rl.close();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function simulation(args) {
|
|
704
|
+
const sub = args[0] ?? 'status';
|
|
705
|
+
|
|
706
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') { printSimulationHelp(); return; }
|
|
707
|
+
|
|
708
|
+
if (sub === 'status') {
|
|
709
|
+
await printSimulationStatus();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (sub === 'on') {
|
|
714
|
+
// The safe direction — blocking real systems needs no confirmation.
|
|
715
|
+
writeConfig(DATA_DIR, { simulation: true });
|
|
716
|
+
console.log('Simulation mode: ON (saved) — every connector without a manifest "simulation" marker will be blocked.');
|
|
717
|
+
console.log('Restart to pick it up: suveren-gateway restart');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (sub === 'off') {
|
|
722
|
+
console.error('Turning simulation mode OFF makes every real system reachable again: any connector');
|
|
723
|
+
console.error('without a manifest "simulation" marker (e.g. a live Gmail or ERP account) will spawn');
|
|
724
|
+
console.error('and execute for real the next time this gateway starts.');
|
|
725
|
+
console.error('');
|
|
726
|
+
let typed = flagValue(args, '--confirm');
|
|
727
|
+
if (typed === undefined) {
|
|
728
|
+
typed = await promptLine('Type "live" to confirm, or anything else to abort: ');
|
|
729
|
+
}
|
|
730
|
+
if (typed !== 'live') {
|
|
731
|
+
console.error(typed === undefined
|
|
732
|
+
? 'No TTY to confirm on and no --confirm given — aborted. Simulation mode unchanged.'
|
|
733
|
+
: 'Aborted — simulation mode unchanged.');
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
writeConfig(DATA_DIR, { simulation: false });
|
|
737
|
+
console.log('Simulation mode: OFF (saved) — real systems become reachable on next restart.');
|
|
738
|
+
console.log('Restart to pick it up: suveren-gateway restart');
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
console.error(`Unknown: simulation ${sub}\n`);
|
|
743
|
+
printSimulationHelp();
|
|
744
|
+
process.exit(2);
|
|
745
|
+
}
|
|
746
|
+
|
|
637
747
|
// ─── service: install autostart-on-login (survives reboot) ──────────────
|
|
638
748
|
//
|
|
639
749
|
// Keeps the gateway PROCESS always running (starts on login, restarts on
|
|
@@ -1139,9 +1249,10 @@ Usage:
|
|
|
1139
1249
|
[--as-url <url>] Authority Server URL (saved for next time)
|
|
1140
1250
|
[--ca-file <path>] Internal CA bundle (saved for next time)
|
|
1141
1251
|
[--pin-tls --expect-fingerprint <hex>] Pin the AS TLS certificate (saved; https:// only — see below)
|
|
1252
|
+
[--simulation] Block every real system (saved — see \`simulation help\`)
|
|
1142
1253
|
suveren-gateway stop Stop a detached gateway
|
|
1143
1254
|
suveren-gateway restart Stop, then start --detach
|
|
1144
|
-
suveren-gateway status Show running state + health
|
|
1255
|
+
suveren-gateway status Show running state + health (incl. simulation mode)
|
|
1145
1256
|
suveren-gateway logs [--tail] Print or tail ~/.suveren/gateway.log
|
|
1146
1257
|
suveren-gateway service <cmd> Run as a login service that survives reboot
|
|
1147
1258
|
(install | uninstall | status)
|
|
@@ -1152,6 +1263,8 @@ Usage:
|
|
|
1152
1263
|
Pin the Authority Server's TLS certificate
|
|
1153
1264
|
(see \`suveren-gateway config help\` for the fingerprint check)
|
|
1154
1265
|
suveren-gateway config set pin-tls off Stop enforcing the TLS certificate pin
|
|
1266
|
+
suveren-gateway simulation on|off|status Block (or unblock) every real system
|
|
1267
|
+
(see \`suveren-gateway simulation help\`)
|
|
1155
1268
|
suveren-gateway help Print this help
|
|
1156
1269
|
|
|
1157
1270
|
Environment:
|
|
@@ -1159,6 +1272,7 @@ Environment:
|
|
|
1159
1272
|
SUVEREN_MCP_PORT MCP server port (default 3430)
|
|
1160
1273
|
SUVEREN_DATA_DIR Data directory (default ~/.suveren)
|
|
1161
1274
|
SUVEREN_AS_URL Authority Server URL — overrides the saved as-url
|
|
1275
|
+
SUVEREN_SIMULATION 1 to force simulation mode for this run — overrides the saved setting
|
|
1162
1276
|
|
|
1163
1277
|
Authority Server resolution order: --as-url flag > SUVEREN_AS_URL env >
|
|
1164
1278
|
saved as-url (\`config set as-url\`) > default (${DEFAULT_AS_URL}).
|
|
@@ -1181,6 +1295,7 @@ async function main() {
|
|
|
1181
1295
|
case 'logs': await logs(argv.slice(1)); break;
|
|
1182
1296
|
case 'service': await service(argv.slice(1)); break;
|
|
1183
1297
|
case 'config': await config(argv.slice(1)); break;
|
|
1298
|
+
case 'simulation': await simulation(argv.slice(1)); break;
|
|
1184
1299
|
case 'help':
|
|
1185
1300
|
case '--help':
|
|
1186
1301
|
case '-h':
|
|
@@ -3545,6 +3545,11 @@ function selectDenials(records, opts = {}) {
|
|
|
3545
3545
|
return { count, records: out };
|
|
3546
3546
|
}
|
|
3547
3547
|
|
|
3548
|
+
// src/lib/simulation-mode.ts
|
|
3549
|
+
function isSimulationMode() {
|
|
3550
|
+
return process.env.SUVEREN_SIMULATION === "1";
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3548
3553
|
// src/index.ts
|
|
3549
3554
|
(function migrateDataDir() {
|
|
3550
3555
|
if (process.env.SUVEREN_DATA_DIR) return;
|
|
@@ -4159,6 +4164,9 @@ app.get("/health", async (req, res) => {
|
|
|
4159
4164
|
updateAvailable: update.updateAvailable,
|
|
4160
4165
|
installMethod: INSTALL_METHOD,
|
|
4161
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(),
|
|
4162
4170
|
session: buildSessionHealth(vault),
|
|
4163
4171
|
security: {
|
|
4164
4172
|
note: "Gateway secures tool execution. Agent host isolation is the user's responsibility."
|
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -2599,10 +2599,62 @@ function clampAgeFloor(requested, maxAgeDays, nowMs, format = "iso") {
|
|
|
2599
2599
|
}
|
|
2600
2600
|
|
|
2601
2601
|
// src/lib/tool-proxy.ts
|
|
2602
|
-
import { getProfile as getProfile3, ContentBindingError } from "@hap/core";
|
|
2602
|
+
import { getProfile as getProfile3, ContentBindingError, boundActionTypes as boundActionTypes2 } from "@hap/core";
|
|
2603
2603
|
import { readFile } from "fs/promises";
|
|
2604
2604
|
import { extname } from "path";
|
|
2605
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
|
|
2606
2658
|
var IMAGE_MIME = {
|
|
2607
2659
|
".jpg": "image/jpeg",
|
|
2608
2660
|
".jpeg": "image/jpeg",
|
|
@@ -2662,6 +2714,38 @@ function applyMapping(m, value, execution) {
|
|
|
2662
2714
|
function profileMatches(profileId, shortName) {
|
|
2663
2715
|
return profileId === shortName || profileId.includes("/" + shortName + "@") || profileId.endsWith("/" + shortName);
|
|
2664
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
|
+
}
|
|
2665
2749
|
function parseFirstJson(result) {
|
|
2666
2750
|
const text = result.content?.find((c) => c.type === "text")?.text;
|
|
2667
2751
|
if (typeof text !== "string") return void 0;
|
|
@@ -2687,6 +2771,15 @@ function denyRead(state2, tool, reason, detail, target) {
|
|
|
2687
2771
|
return { content: [{ type: "text", text: `Read blocked by Gatekeeper: ${detail}` }], isError: true };
|
|
2688
2772
|
}
|
|
2689
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
|
+
}
|
|
2690
2783
|
const gated = createGatedToolHandlerInner(tool, integrationManager2, state2);
|
|
2691
2784
|
const inner = async (args) => gated(normalizeIncomingArgs(tool, args));
|
|
2692
2785
|
const blocked = tool.gating?.blockedArgs ?? [];
|
|
@@ -2962,7 +3055,10 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
|
|
|
2962
3055
|
bounds: candidate.bounds,
|
|
2963
3056
|
context: candidate.context
|
|
2964
3057
|
});
|
|
2965
|
-
|
|
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) {
|
|
2966
3062
|
passers.push(candidate);
|
|
2967
3063
|
} else {
|
|
2968
3064
|
const reasons = result.errors.map((e) => {
|
|
@@ -3998,9 +4094,10 @@ function createMcpServer(state2, integrationManager2) {
|
|
|
3998
4094
|
const description = buildProxiedToolDescription(tool, state2);
|
|
3999
4095
|
registered.update({ description });
|
|
4000
4096
|
if (tool.gating?.profile) {
|
|
4001
|
-
const
|
|
4097
|
+
const matchingAuths = auths.filter(
|
|
4002
4098
|
(a) => a.complete && profileMatches(a.profileId, tool.gating.profile)
|
|
4003
4099
|
);
|
|
4100
|
+
const hasAuth = matchingAuths.length > 0 && toolIsAuthorizedForDisplay(tool, matchingAuths);
|
|
4004
4101
|
if (hasAuth) registered.enable();
|
|
4005
4102
|
else registered.disable();
|
|
4006
4103
|
} else {
|
|
@@ -4041,16 +4138,16 @@ function verifyGateContentHashes(content, auth) {
|
|
|
4041
4138
|
}
|
|
4042
4139
|
|
|
4043
4140
|
// src/lib/integration-registry.ts
|
|
4044
|
-
import { mkdirSync as mkdirSync9, readFileSync as
|
|
4141
|
+
import { mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync9, existsSync as existsSync12 } from "fs";
|
|
4045
4142
|
import { homedir as homedir8 } from "os";
|
|
4046
|
-
import { dirname as dirname9, join as
|
|
4047
|
-
var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ??
|
|
4143
|
+
import { dirname as dirname9, join as join12 } from "path";
|
|
4144
|
+
var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join12(homedir8(), ".suveren");
|
|
4048
4145
|
var IntegrationRegistry = class {
|
|
4049
4146
|
integrations = /* @__PURE__ */ new Map();
|
|
4050
4147
|
filePath;
|
|
4051
4148
|
constructor(dataDir2) {
|
|
4052
4149
|
const dir = dataDir2 ?? DEFAULT_DATA_DIR;
|
|
4053
|
-
this.filePath =
|
|
4150
|
+
this.filePath = join12(dir, "integrations.json");
|
|
4054
4151
|
this.load();
|
|
4055
4152
|
}
|
|
4056
4153
|
// ─── CRUD ───────────────────────────────────────────────────────────────
|
|
@@ -4081,12 +4178,12 @@ var IntegrationRegistry = class {
|
|
|
4081
4178
|
}
|
|
4082
4179
|
// ─── Load / Save ────────────────────────────────────────────────────────
|
|
4083
4180
|
load() {
|
|
4084
|
-
if (!
|
|
4181
|
+
if (!existsSync12(this.filePath)) {
|
|
4085
4182
|
mkdirSync9(dirname9(this.filePath), { recursive: true });
|
|
4086
4183
|
return;
|
|
4087
4184
|
}
|
|
4088
4185
|
try {
|
|
4089
|
-
const raw =
|
|
4186
|
+
const raw = readFileSync13(this.filePath, "utf-8");
|
|
4090
4187
|
const data = JSON.parse(raw);
|
|
4091
4188
|
const migrated = data.integrations.map((i) => {
|
|
4092
4189
|
if ("toolGating" in i && !("profile" in i)) {
|
|
@@ -4129,47 +4226,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
4129
4226
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4130
4227
|
import { getProfile as getProfile5 } from "@hap/core";
|
|
4131
4228
|
|
|
4132
|
-
// src/lib/manifest-loader.ts
|
|
4133
|
-
import { readFileSync as readFileSync13, existsSync as existsSync12 } from "fs";
|
|
4134
|
-
import { join as join12, resolve } from "path";
|
|
4135
|
-
var manifests = /* @__PURE__ */ new Map();
|
|
4136
|
-
function loadManifests(integrationsDir) {
|
|
4137
|
-
const dir = resolve(
|
|
4138
|
-
integrationsDir ?? process.env.SUVEREN_MANIFESTS_DIR ?? join12(import.meta.dirname ?? __dirname, "..", "..", "..", "..", "content", "integrations")
|
|
4139
|
-
);
|
|
4140
|
-
const indexPath = join12(dir, "index.json");
|
|
4141
|
-
if (!existsSync12(indexPath)) {
|
|
4142
|
-
console.error(`[ManifestLoader] No index.json found at ${indexPath}, skipping manifest loading`);
|
|
4143
|
-
return 0;
|
|
4144
|
-
}
|
|
4145
|
-
let index;
|
|
4146
|
-
try {
|
|
4147
|
-
index = JSON.parse(readFileSync13(indexPath, "utf-8"));
|
|
4148
|
-
} catch (err) {
|
|
4149
|
-
console.error(`[ManifestLoader] Failed to parse ${indexPath}:`, err);
|
|
4150
|
-
return 0;
|
|
4151
|
-
}
|
|
4152
|
-
let loaded = 0;
|
|
4153
|
-
for (const [id, relativePath] of Object.entries(index.integrations)) {
|
|
4154
|
-
const manifestPath = join12(dir, relativePath);
|
|
4155
|
-
try {
|
|
4156
|
-
const manifest = JSON.parse(readFileSync13(manifestPath, "utf-8"));
|
|
4157
|
-
manifests.set(id, manifest);
|
|
4158
|
-
loaded++;
|
|
4159
|
-
} catch (err) {
|
|
4160
|
-
console.error(`[ManifestLoader] Failed to load manifest ${id} from ${manifestPath}:`, err);
|
|
4161
|
-
}
|
|
4162
|
-
}
|
|
4163
|
-
console.error(`[ManifestLoader] Loaded ${loaded} integration manifest(s) from ${dir}`);
|
|
4164
|
-
return loaded;
|
|
4165
|
-
}
|
|
4166
|
-
function getManifest(id) {
|
|
4167
|
-
return manifests.get(id);
|
|
4168
|
-
}
|
|
4169
|
-
function getAllManifests() {
|
|
4170
|
-
return Array.from(manifests.values());
|
|
4171
|
-
}
|
|
4172
|
-
|
|
4173
4229
|
// src/lib/remote-auth-preflight.ts
|
|
4174
4230
|
var AUTH_HEADER_RE = /^authorization:\s*(.+)$/i;
|
|
4175
4231
|
function remotePreflightTarget(command, args) {
|
|
@@ -4477,10 +4533,21 @@ var IntegrationManager = class {
|
|
|
4477
4533
|
if (opts.skipIfRunning) return existing.tools;
|
|
4478
4534
|
await this.stopIntegrationLocked(config.id);
|
|
4479
4535
|
}
|
|
4536
|
+
if (isSimulationMode() && !manifestIsSimulated(getManifest(config.id))) {
|
|
4537
|
+
throw new Error(`${config.id}: ${SIMULATION_BLOCK_REASON}`);
|
|
4538
|
+
}
|
|
4480
4539
|
if (config.npmPackage) {
|
|
4481
4540
|
await this.ensureInstalled(config.npmPackage);
|
|
4482
4541
|
}
|
|
4483
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
|
+
}
|
|
4484
4551
|
const interpolate = (s) => s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => env[name] ?? process.env[name] ?? "");
|
|
4485
4552
|
const args = config.args.map(interpolate);
|
|
4486
4553
|
const remote = remotePreflightTarget(config.command, args);
|
|
@@ -4655,7 +4722,15 @@ var IntegrationManager = class {
|
|
|
4655
4722
|
name: config.name,
|
|
4656
4723
|
running: false,
|
|
4657
4724
|
toolCount: 0,
|
|
4658
|
-
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
|
|
4659
4734
|
});
|
|
4660
4735
|
}
|
|
4661
4736
|
}
|
|
@@ -4749,7 +4824,8 @@ var IntegrationManager = class {
|
|
|
4749
4824
|
read: ext.read,
|
|
4750
4825
|
readGovernance: ext.readGovernance,
|
|
4751
4826
|
readGovernanceReason: ext.readGovernanceReason,
|
|
4752
|
-
blockedArgs: ext.blockedArgs
|
|
4827
|
+
blockedArgs: ext.blockedArgs,
|
|
4828
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
4753
4829
|
};
|
|
4754
4830
|
}
|
|
4755
4831
|
return {
|
|
@@ -4763,7 +4839,8 @@ var IntegrationManager = class {
|
|
|
4763
4839
|
contentField: ext.contentField,
|
|
4764
4840
|
blockedArgs: ext.blockedArgs,
|
|
4765
4841
|
argEncoding: ext.argEncoding,
|
|
4766
|
-
argNormalization: ext.argNormalization
|
|
4842
|
+
argNormalization: ext.argNormalization,
|
|
4843
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
4767
4844
|
};
|
|
4768
4845
|
}
|
|
4769
4846
|
return {
|