@suveren/gateway 0.16.0 → 0.17.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.
@@ -40,6 +40,8 @@ var Vault = class {
40
40
  * disk, gone the moment the process restarts or the vault locks.
41
41
  */
42
42
  spSessionExpiresAt = null;
43
+ /** Who the current AS session belongs to (from the login response). Never persisted. */
44
+ sessionUser = null;
43
45
  /** Set by lockExpired(); cleared by any fresh unseal (a new login). */
44
46
  lockedReason = null;
45
47
  constructor(dataDir) {
@@ -97,6 +99,7 @@ var Vault = class {
97
99
  this.spSessionCookie = null;
98
100
  this.unsealedBy = null;
99
101
  this.spSessionExpiresAt = null;
102
+ this.sessionUser = null;
100
103
  this.lockedReason = null;
101
104
  }
102
105
  /**
@@ -149,6 +152,13 @@ var Vault = class {
149
152
  getSessionExpiresAt() {
150
153
  return this.spSessionExpiresAt;
151
154
  }
155
+ /** The signed-in person, set at login from the AS session response; cleared with the session. */
156
+ setSessionUser(user) {
157
+ this.sessionUser = user;
158
+ }
159
+ getSessionUser() {
160
+ return this.sessionUser;
161
+ }
152
162
  /**
153
163
  * Check if the vault was created by a different API key.
154
164
  * Tries to decrypt the first credential — if it fails, keys don't match.
@@ -414,6 +424,11 @@ async function runCommittedProposals() {
414
424
  headers: internalHeaders()
415
425
  });
416
426
  }
427
+ async function getToolDisplay() {
428
+ const res = await fetch(`${MCP_BASE}/internal/tool-display`, { headers: internalHeaders() });
429
+ if (!res.ok) throw new Error(`MCP tool-display failed: ${res.status}`);
430
+ return res.json();
431
+ }
417
432
  async function getIntegrations() {
418
433
  const res = await fetch(`${MCP_BASE}/internal/integrations`, {
419
434
  headers: internalHeaders()
@@ -1726,6 +1741,10 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
1726
1741
  }
1727
1742
  const sessionExpiresAt = typeof data.sessionExpiresAt === "number" ? data.sessionExpiresAt : null;
1728
1743
  vault2.setSessionExpiresAt(sessionExpiresAt);
1744
+ const sessionUser = data.user;
1745
+ vault2.setSessionUser(
1746
+ typeof sessionUser?.id === "string" && typeof sessionUser?.did === "string" ? { id: sessionUser.id, did: sessionUser.did } : null
1747
+ );
1729
1748
  onSessionEstablished?.();
1730
1749
  if (sessionCookie) {
1731
1750
  try {
@@ -3087,7 +3106,26 @@ function notify(title, message, platform = process.platform, url) {
3087
3106
 
3088
3107
  // src/routes/encrypt-intent.ts
3089
3108
  import { Router as Router7 } from "express";
3109
+
3110
+ // src/lib/intent-encryption.ts
3090
3111
  import { computeIntentDisclosureHash } from "@hap/core";
3112
+ async function encryptIntentForRecipients(intent, recipients) {
3113
+ const parsed = recipients.map(({ userId, publicKey }) => {
3114
+ const bytes = new Uint8Array(Buffer.from(publicKey, "base64"));
3115
+ if (bytes.length !== 32) throw new Error(`publicKey for userId "${userId}" must be 32 bytes (X25519); got ${bytes.length}`);
3116
+ return { userId, publicKey: bytes };
3117
+ });
3118
+ const encrypted = await encryptForRecipients(intent, parsed);
3119
+ const encryptedKeys = {};
3120
+ for (const [userId, wrap] of Object.entries(encrypted.encryptedKeys)) {
3121
+ encryptedKeys[userId] = { ct: Buffer.from(wrap.ct).toString("base64"), enc: Buffer.from(wrap.enc).toString("base64") };
3122
+ }
3123
+ const intentCiphertext = Buffer.from(encrypted.intentCiphertext).toString("base64");
3124
+ const approversFrozen = parsed.map((r) => r.userId);
3125
+ return { intentCiphertext, encryptedKeys, approversFrozen, intentDisclosureHash: computeIntentDisclosureHash(intentCiphertext, approversFrozen) };
3126
+ }
3127
+
3128
+ // src/routes/encrypt-intent.ts
3091
3129
  function createEncryptIntentRouter() {
3092
3130
  const router = Router7();
3093
3131
  router.post("/", async (req, res) => {
@@ -3125,23 +3163,10 @@ function createEncryptIntentRouter() {
3125
3163
  parsed.push({ userId, publicKey: pubBytes });
3126
3164
  }
3127
3165
  try {
3128
- const encrypted = await encryptForRecipients(intent, parsed);
3129
- const encryptedKeys = {};
3130
- for (const [userId, wrap] of Object.entries(encrypted.encryptedKeys)) {
3131
- encryptedKeys[userId] = {
3132
- ct: Buffer.from(wrap.ct).toString("base64"),
3133
- enc: Buffer.from(wrap.enc).toString("base64")
3134
- };
3135
- }
3136
- const intentCiphertextB64 = Buffer.from(encrypted.intentCiphertext).toString("base64");
3137
- const approversFrozen = parsed.map((r) => r.userId);
3138
- const intentDisclosureHash = computeIntentDisclosureHash(intentCiphertextB64, approversFrozen);
3139
- res.json({
3140
- intentCiphertext: intentCiphertextB64,
3141
- encryptedKeys,
3142
- approversFrozen,
3143
- intentDisclosureHash
3144
- });
3166
+ res.json(await encryptIntentForRecipients(
3167
+ intent,
3168
+ parsed.map((r) => ({ userId: r.userId, publicKey: Buffer.from(r.publicKey).toString("base64") }))
3169
+ ));
3145
3170
  } catch (err) {
3146
3171
  console.error("[Control Plane] encrypt-intent error:", err);
3147
3172
  res.status(500).json({ error: err instanceof Error ? err.message : "Encryption failed" });
@@ -3757,6 +3782,201 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
3757
3782
  return router;
3758
3783
  }
3759
3784
 
3785
+ // src/routes/internal-mandate.ts
3786
+ import { Router as Router15 } from "express";
3787
+ import { timingSafeEqual as timingSafeEqual3, randomUUID } from "crypto";
3788
+
3789
+ // src/lib/mandate-ceremony.ts
3790
+ import {
3791
+ allowedCommitmentModes,
3792
+ computeBoundsHash,
3793
+ computeContextHash,
3794
+ computeIntentHash,
3795
+ isCommitmentModeAllowed,
3796
+ validateBoundsParams,
3797
+ validateContextParams
3798
+ } from "@hap/core";
3799
+ var MandateRefused = class extends Error {
3800
+ };
3801
+ var INTENT_MAX = 2e3;
3802
+ var shortOf = (id) => id.replace(/@.*$/, "").split("/").pop() ?? id;
3803
+ var versionOf = (id) => id.split("@")[1] ?? "";
3804
+ function refuse(msg) {
3805
+ throw new MandateRefused(msg);
3806
+ }
3807
+ async function resolveProfile(as, requested) {
3808
+ let id = requested.trim();
3809
+ if (!id.includes("@")) {
3810
+ const list = await as("GET", "/api/profiles");
3811
+ const matches = (list.body?.profiles ?? []).filter((p) => shortOf(p.id) === id);
3812
+ if (matches.length === 0) refuse(`Unknown profile "${requested}".`);
3813
+ matches.sort((a, b) => versionOf(b.id).localeCompare(versionOf(a.id), void 0, { numeric: true }));
3814
+ id = matches[0].id;
3815
+ }
3816
+ const res = await as("GET", `/api/profiles/${encodeURIComponent(id)}`);
3817
+ if (res.status !== 200 || !res.body?.id) refuse(`Unknown profile "${requested}".`);
3818
+ return res.body;
3819
+ }
3820
+ function scalarRecord(label, value) {
3821
+ if (value === void 0 || value === null) return {};
3822
+ if (typeof value !== "object" || Array.isArray(value)) refuse(`\`${label}\` must be an object.`);
3823
+ const out = {};
3824
+ for (const [k, v] of Object.entries(value)) {
3825
+ if (typeof v !== "string" && typeof v !== "number") refuse(`\`${label}.${k}\` must be a string or a number.`);
3826
+ out[k] = v;
3827
+ }
3828
+ return out;
3829
+ }
3830
+ function approverPubkeys(body) {
3831
+ const map = body?.pubkeys;
3832
+ if (!map || typeof map !== "object" || Array.isArray(map)) return [];
3833
+ return Object.entries(map).filter((e) => typeof e[1] === "string" && e[1].length > 0).map(([userId, publicKey]) => ({ userId, publicKey }));
3834
+ }
3835
+ async function planMandate(req, deps) {
3836
+ const user = deps.user;
3837
+ if (!user) refuse("The gateway is not signed in.");
3838
+ const groupsRes = await deps.as("GET", "/api/groups");
3839
+ const groups = groupsRes.body?.groups ?? [];
3840
+ const group = req.team ? groups.find((g) => !g.isPersonal && (g.id === req.team || g.name === req.team)) : groups.find((g) => g.isPersonal);
3841
+ if (!group) refuse(req.team ? `You are not a member of a team "${req.team}".` : "No personal workspace found.");
3842
+ const isPersonal = !!group.isPersonal;
3843
+ const profile = await resolveProfile(deps.as, req.profile);
3844
+ const name = profile.name ?? shortOf(profile.id);
3845
+ let approvers = [];
3846
+ if (!isPersonal) {
3847
+ const cfg = await deps.as("GET", `/api/groups/${encodeURIComponent(group.id)}/profile-config/${encodeURIComponent(profile.id)}`);
3848
+ approvers = (cfg.status === 200 ? cfg.body?.config?.approvers : void 0) ?? [];
3849
+ if (approvers.length === 0) refuse(`${name} is not enabled in "${group.name}": a team admin must name who may give ${name} mandates.`);
3850
+ if (!approvers.includes(user.id)) refuse(`Only ${name}'s approvers in "${group.name}" can give this mandate, and the signed-in person is not one of them.`);
3851
+ }
3852
+ const bounds = { ...scalarRecord("limits", req.limits), profile: profile.id };
3853
+ const bv = validateBoundsParams(bounds, profile);
3854
+ const errors = [...bv.errors];
3855
+ for (const [field, value] of Object.entries(bounds)) {
3856
+ const bt = profile.boundsSchema?.fields[field]?.boundType;
3857
+ if (bt?.kind === "enum" && Array.isArray(bt.values) && !bt.values.includes(String(value))) {
3858
+ errors.push(`Field "${field}" must be one of ${bt.values.join(", ")}.`);
3859
+ }
3860
+ }
3861
+ const context = scalarRecord("scope", req.scope);
3862
+ errors.push(...validateContextParams(context, profile).errors);
3863
+ if (errors.length) refuse(`The mandate does not fit profile ${profile.id}: ${errors.join(" ")}`);
3864
+ if (!isCommitmentModeAllowed(profile, req.mode)) {
3865
+ refuse(`Profile ${profile.id} allows mode ${allowedCommitmentModes(profile).join(" or ") || "none"}, not "${req.mode}".`);
3866
+ }
3867
+ const ttlSeconds = req.durationHours === void 0 ? profile.ttl.default : Math.round(req.durationHours * 3600);
3868
+ if (!(ttlSeconds > 0)) refuse("`duration_hours` must be positive.");
3869
+ if (ttlSeconds > profile.ttl.max) refuse(`At most ${profile.ttl.max / 3600} hours for profile ${profile.id}.`);
3870
+ const intent = typeof req.intent === "string" ? req.intent.trim() : "";
3871
+ if (!intent) refuse("`intent` is required \u2014 the why, goal and watch-outs, in the person's words.");
3872
+ if (intent.length > INTENT_MAX) refuse(`\`intent\` is ${intent.length} characters; the limit is ${INTENT_MAX}.`);
3873
+ return {
3874
+ groupId: group.id,
3875
+ groupName: group.name,
3876
+ isPersonal,
3877
+ domain: isPersonal ? "owner" : user.id,
3878
+ profile,
3879
+ bounds,
3880
+ context,
3881
+ intent,
3882
+ mode: req.mode,
3883
+ ttlSeconds,
3884
+ title: (req.title ?? "").trim() || `${name} (proposed by AI)`,
3885
+ approvers
3886
+ };
3887
+ }
3888
+ async function createMandate(req, deps) {
3889
+ const plan = await planMandate(req, deps);
3890
+ const user = deps.user;
3891
+ const boundsHash = computeBoundsHash(plan.bounds, plan.profile);
3892
+ const contextHash = computeContextHash(plan.context, plan.profile);
3893
+ const intentHash = computeIntentHash(plan.intent);
3894
+ const ecHash = computeIntentHash(JSON.stringify({ profile: plan.profile.id, domain: plan.domain, group: plan.groupId }));
3895
+ let enc;
3896
+ if (!plan.isPersonal) {
3897
+ const keys = await deps.as(
3898
+ "GET",
3899
+ `/api/groups/${encodeURIComponent(plan.groupId)}/profile-config/${encodeURIComponent(plan.profile.id)}/approvers/pubkeys`
3900
+ );
3901
+ const recipients = approverPubkeys(keys.body);
3902
+ if (recipients.length > 0) enc = await deps.encrypt(plan.intent, recipients);
3903
+ }
3904
+ const authorizationId = deps.newAuthorizationId();
3905
+ const res = await deps.as("POST", "/api/as/attest", {
3906
+ authorization_id: authorizationId,
3907
+ profile_id: plan.profile.id,
3908
+ bounds: plan.bounds,
3909
+ bounds_hash: boundsHash,
3910
+ context_hash: contextHash,
3911
+ domain: plan.domain,
3912
+ did: user.did,
3913
+ gate_content_hashes: { intent: intentHash },
3914
+ execution_context_hash: ecHash,
3915
+ group_id: plan.groupId,
3916
+ ttl: plan.ttlSeconds,
3917
+ commitment_mode: plan.mode,
3918
+ title: plan.title,
3919
+ intent_ciphertext: enc?.intentCiphertext,
3920
+ encrypted_keys: enc?.encryptedKeys,
3921
+ approvers_frozen: enc?.approversFrozen,
3922
+ intent_disclosure_hash: enc?.intentDisclosureHash
3923
+ });
3924
+ if (res.status >= 300) {
3925
+ const why = res.body?.message ?? res.body?.detail ?? res.body?.error ?? `status ${res.status}`;
3926
+ refuse(`The Authority Server refused the mandate: ${why}`);
3927
+ }
3928
+ try {
3929
+ await deps.deliverGateContent({ authorizationId, boundsHash, contextHash, context: plan.context, gateContent: { intent: plan.intent } });
3930
+ } catch (err) {
3931
+ await deps.as("POST", `/api/authorizations/${encodeURIComponent(authorizationId)}/revoke`, {
3932
+ reason: "Auto-revoked: gate content delivery failed"
3933
+ }).catch(() => {
3934
+ });
3935
+ refuse(`The mandate was signed but its intent could not be delivered to the gateway, so it was revoked. (${err instanceof Error ? err.message : String(err)})`);
3936
+ }
3937
+ return { authorizationId, plan };
3938
+ }
3939
+
3940
+ // src/routes/internal-mandate.ts
3941
+ function secretMatches2(provided, expected) {
3942
+ if (!expected || !provided) return false;
3943
+ const a = Buffer.from(provided);
3944
+ const b = Buffer.from(expected);
3945
+ return a.length === b.length && timingSafeEqual3(a, b);
3946
+ }
3947
+ function createInternalMandateRouter(getSecret, deps) {
3948
+ const router = Router15();
3949
+ router.post("/mandate", async (req, res) => {
3950
+ if (!secretMatches2(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
3951
+ res.status(401).json({ error: "unauthorized" });
3952
+ return;
3953
+ }
3954
+ const { dryRun, request } = req.body ?? {};
3955
+ if (!request || typeof request !== "object") {
3956
+ res.status(400).json({ error: "missing request" });
3957
+ return;
3958
+ }
3959
+ const d = { ...deps(), newAuthorizationId: () => `authz_${randomUUID()}` };
3960
+ try {
3961
+ if (dryRun === true) {
3962
+ const plan2 = await planMandate(request, d);
3963
+ res.json({ ok: true, groupName: plan2.groupName, profileId: plan2.profile.id, mode: plan2.mode, ttlSeconds: plan2.ttlSeconds });
3964
+ return;
3965
+ }
3966
+ const { authorizationId, plan } = await createMandate(request, d);
3967
+ res.json({ ok: true, authorizationId, profileId: plan.profile.id, groupName: plan.groupName, mode: plan.mode, ttlSeconds: plan.ttlSeconds });
3968
+ } catch (err) {
3969
+ if (err instanceof MandateRefused) {
3970
+ res.status(422).json({ error: "mandate_refused", message: err.message });
3971
+ return;
3972
+ }
3973
+ console.error("[Control Plane] /internal/mandate failed:", err);
3974
+ res.status(500).json({ error: "internal", message: err instanceof Error ? err.message : String(err) });
3975
+ }
3976
+ });
3977
+ return router;
3978
+ }
3979
+
3760
3980
  // src/lib/notification-dispatcher.ts
3761
3981
  var TRIGGER_EVENTS = ["proposal-added", "action-approval-needed"];
3762
3982
  var TITLE = "Suveren";
@@ -4273,6 +4493,33 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
4273
4493
  });
4274
4494
  app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
4275
4495
  app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
4496
+ app.use("/internal", jsonParser, createInternalMandateRouter(() => internalSecret2, () => ({
4497
+ user: vault.getSessionUser(),
4498
+ as: async (method, path, body) => {
4499
+ const cookie = vault.getSpCookie();
4500
+ if (!cookie) return { status: 401, body: { error: "not signed in" } };
4501
+ const pin = readPairing(DATA_DIR);
4502
+ try {
4503
+ const { res } = await fetchAs(
4504
+ `${SP_URL}${path}`,
4505
+ {
4506
+ method,
4507
+ headers: { Cookie: cookie, ...body !== void 0 ? { "Content-Type": "application/json" } : {} },
4508
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
4509
+ signal: AbortSignal.timeout(15e3)
4510
+ },
4511
+ { enforce: resolvePinTls(DATA_DIR), pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0 }
4512
+ );
4513
+ if (res.status === 401 && vault.isUnlocked()) lockExpiredSession();
4514
+ return { status: res.status, body: await res.json().catch(() => ({})) };
4515
+ } catch (err) {
4516
+ if (err instanceof AsTlsMismatchError) lockAsTlsMismatch();
4517
+ throw err;
4518
+ }
4519
+ },
4520
+ encrypt: encryptIntentForRecipients,
4521
+ deliverGateContent: (args) => pushGateContent(args)
4522
+ })));
4276
4523
  app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
4277
4524
  app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
4278
4525
  app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
@@ -4466,6 +4713,14 @@ app.post("/gate-content", jsonParser, authGuard, async (req, res) => {
4466
4713
  res.status(500).json({ error: "Failed to forward gate content to MCP server" });
4467
4714
  }
4468
4715
  });
4716
+ app.get("/tool-display", authGuard, async (_req, res) => {
4717
+ try {
4718
+ res.json(await getToolDisplay());
4719
+ } catch (err) {
4720
+ console.error("[Control Plane] tool-display error:", err);
4721
+ res.status(502).json({ error: "MCP server unavailable" });
4722
+ }
4723
+ });
4469
4724
  app.post("/resync-gates", authGuard, async (_req, res) => {
4470
4725
  try {
4471
4726
  const data = await resyncGates();
@@ -2984,7 +2984,7 @@ function createGatedToolHandler(tool, integrationManager2, state2) {
2984
2984
  const gateable = !!tool.gating?.profile && tool.gating.category !== "disabled";
2985
2985
  const inner = async (args) => {
2986
2986
  const normalized = normalizeIncomingArgs(tool, args);
2987
- const refusal = gateable ? integrationManager2.precheckBuiltin?.(tool, normalized) : null;
2987
+ const refusal = gateable ? await integrationManager2.precheckBuiltin?.(tool, normalized) : null;
2988
2988
  if (refusal) return { content: [{ type: "text", text: refusal }], isError: true };
2989
2989
  return gated(normalized);
2990
2990
  };
@@ -5238,6 +5238,28 @@ var IntegrationManager = class {
5238
5238
  console.error(`[IntegrationManager] Built-in ${def.id}: ${tools.length} tool(s) under profile ${def.profile}`);
5239
5239
  this.onToolsChanged?.();
5240
5240
  }
5241
+ /** The built-in groups, for the mandate picker — never part of the connector status list. */
5242
+ getBuiltins() {
5243
+ return Array.from(this.builtins.values()).map(({ def }) => ({
5244
+ id: def.id,
5245
+ name: def.name,
5246
+ description: def.description ?? "",
5247
+ profile: def.profile,
5248
+ available: !def.simulationOnly || isSimulationMode()
5249
+ }));
5250
+ }
5251
+ /**
5252
+ * What the approval screen needs to show a proposal for each tool: its argument
5253
+ * schema (labels from the descriptions) and the optional approvalView hints.
5254
+ * Keyed by the namespaced tool name a proposal carries.
5255
+ */
5256
+ getToolDisplay() {
5257
+ const out = {};
5258
+ for (const t of this.getAllTools()) {
5259
+ out[t.namespacedName] = { inputSchema: t.inputSchema, ...t.gating?.approvalView ? { approvalView: t.gating.approvalView } : {} };
5260
+ }
5261
+ return out;
5262
+ }
5241
5263
  /** Whether `id` names a built-in (in-process) integration. */
5242
5264
  isBuiltin(id) {
5243
5265
  return this.builtins.has(id);
@@ -5256,14 +5278,14 @@ var IntegrationManager = class {
5256
5278
  * no ticket): `simulationOnly` outside simulation mode, then the tool's `validate`.
5257
5279
  * Null for connector tools and for calls that may proceed.
5258
5280
  */
5259
- precheckBuiltin(tool, args) {
5281
+ async precheckBuiltin(tool, args) {
5260
5282
  const builtin = this.builtins.get(tool.integrationId);
5261
5283
  if (!builtin) return null;
5262
5284
  if (builtin.def.simulationOnly && !isSimulationMode()) {
5263
5285
  return `Refused: "${tool.namespacedName}" is not available. No ticket was requested.`;
5264
5286
  }
5265
5287
  const def = builtin.def.tools.find((t) => t.name === tool.originalName);
5266
- const refusal = def?.validate?.(args);
5288
+ const refusal = await def?.validate?.(args);
5267
5289
  return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
5268
5290
  }
5269
5291
  /**
@@ -5474,7 +5496,8 @@ var IntegrationManager = class {
5474
5496
  readGovernance: ext.readGovernance,
5475
5497
  readGovernanceReason: ext.readGovernanceReason,
5476
5498
  blockedArgs: ext.blockedArgs,
5477
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5499
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5500
+ approvalView: ext.approvalView
5478
5501
  };
5479
5502
  }
5480
5503
  return {
@@ -5489,7 +5512,8 @@ var IntegrationManager = class {
5489
5512
  blockedArgs: ext.blockedArgs,
5490
5513
  argEncoding: ext.argEncoding,
5491
5514
  argNormalization: ext.argNormalization,
5492
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5515
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5516
+ approvalView: ext.approvalView
5493
5517
  };
5494
5518
  }
5495
5519
  return {
@@ -5596,6 +5620,27 @@ function builtinText(text) {
5596
5620
  return { content: [{ type: "text", text }] };
5597
5621
  }
5598
5622
 
5623
+ // src/lib/cp-mandate.ts
5624
+ var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
5625
+ var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
5626
+ async function controlPlaneMandate(dryRun, request) {
5627
+ const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
5628
+ if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
5629
+ try {
5630
+ const res = await fetch(`${CP_BASE2}/internal/mandate`, {
5631
+ method: "POST",
5632
+ headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
5633
+ body: JSON.stringify({ dryRun, request }),
5634
+ signal: AbortSignal.timeout(3e4)
5635
+ });
5636
+ const body = await res.json().catch(() => ({}));
5637
+ if (res.ok) return { ok: true, ...body };
5638
+ return { ok: false, message: typeof body.message === "string" ? body.message : `control plane answered ${res.status}` };
5639
+ } catch (err) {
5640
+ return { ok: false, message: `the control plane could not be reached (${err instanceof Error ? err.message : String(err)}).` };
5641
+ }
5642
+ }
5643
+
5599
5644
  // src/lib/builtins/setup.ts
5600
5645
  var DELEGATION_PROFILE = "github.com/humanagencyprotocol/hap-profiles/delegation@0.1";
5601
5646
  function briefRefusal(args) {
@@ -5608,6 +5653,7 @@ function setupBuiltin(_deps) {
5608
5653
  return {
5609
5654
  id: "setup",
5610
5655
  name: "Test setup",
5656
+ description: "Your AI proposes its own setup for a test \u2014 its agent brief and its mandates. Every proposal waits for your approval. Simulation mode only.",
5611
5657
  profile: DELEGATION_PROFILE,
5612
5658
  simulation: true,
5613
5659
  simulationOnly: true,
@@ -5616,7 +5662,24 @@ function setupBuiltin(_deps) {
5616
5662
  set_agent_brief: {
5617
5663
  executionMapping: {},
5618
5664
  staticExecution: { action_type: "brief" },
5619
- hideUnlessAuthorized: true
5665
+ hideUnlessAuthorized: true,
5666
+ approvalView: { content: { label: "New agent brief", kind: "markdown" } }
5667
+ },
5668
+ create_mandate: {
5669
+ executionMapping: {},
5670
+ staticExecution: { action_type: "mandate" },
5671
+ hideUnlessAuthorized: true,
5672
+ // Shown like the mandate screen: limits and scope by the profile's own names.
5673
+ approvalView: {
5674
+ team: { label: "Team" },
5675
+ profile: { label: "Profile", kind: "profile" },
5676
+ limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
5677
+ scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
5678
+ intent: { label: "Intent", kind: "markdown" },
5679
+ mode: { label: "Mode" },
5680
+ duration_hours: { label: "Valid for (hours)" },
5681
+ title: { label: "Title" }
5682
+ }
5620
5683
  }
5621
5684
  }
5622
5685
  },
@@ -5645,10 +5708,56 @@ function setupBuiltin(_deps) {
5645
5708
  `Agent brief replaced (${Buffer.byteLength(args.content, "utf-8")} bytes). It applies from the next agent session.`
5646
5709
  );
5647
5710
  }
5711
+ },
5712
+ {
5713
+ name: "create_mandate",
5714
+ description: "Simulation mode only: propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Create only.",
5715
+ inputSchema: {
5716
+ type: "object",
5717
+ properties: {
5718
+ team: { type: "string", description: "Team name or id. Omit for the personal workspace." },
5719
+ profile: { type: "string", description: 'Profile, e.g. "sales" (newest version) or a full profile id.' },
5720
+ limits: { type: "object", description: "The limits (bounds) by field name, as the profile defines them." },
5721
+ scope: { type: "object", description: "The scope (context) by field name, as the profile defines it." },
5722
+ intent: { type: "string", description: "Why, goal and watch-outs \u2014 signed with the mandate (at most 2000 characters)." },
5723
+ mode: { type: "string", enum: ["review", "automatic"], description: "review = each action needs approval; automatic = within the limits." },
5724
+ duration_hours: { type: "number", description: "How long the mandate is valid, in hours. Omit for the profile default." },
5725
+ title: { type: "string", description: "A short name for the mandate." },
5726
+ receipt_id: {
5727
+ type: "string",
5728
+ description: "Authorization reference for this call, set by the governing gateway \u2014 agents do not set this."
5729
+ }
5730
+ },
5731
+ required: ["profile", "limits", "intent", "mode"]
5732
+ },
5733
+ validate: async (args) => {
5734
+ const check = await controlPlaneMandate(true, mandateRequest(args));
5735
+ return check.ok ? void 0 : check.message;
5736
+ },
5737
+ handler: async (args) => {
5738
+ if (!isSimulationMode()) return { ...builtinText("Refused: not available outside simulation mode."), isError: true };
5739
+ const r = await controlPlaneMandate(false, mandateRequest(args));
5740
+ if (!r.ok) return { ...builtinText(`Refused: ${r.message}`), isError: true };
5741
+ return builtinText(
5742
+ `Mandate created: ${r.authorizationId} \u2014 profile ${r.profileId}, ${r.groupName}, mode ${r.mode}, valid for ${Math.round((r.ttlSeconds ?? 0) / 3600)} h. It is active now.`
5743
+ );
5744
+ }
5648
5745
  }
5649
5746
  ]
5650
5747
  };
5651
5748
  }
5749
+ function mandateRequest(args) {
5750
+ return {
5751
+ team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
5752
+ profile: args.profile,
5753
+ limits: args.limits,
5754
+ scope: args.scope,
5755
+ intent: args.intent,
5756
+ mode: args.mode,
5757
+ durationHours: args.duration_hours,
5758
+ title: args.title
5759
+ };
5760
+ }
5652
5761
 
5653
5762
  // src/lib/builtins/index.ts
5654
5763
  var BUILTIN_FACTORIES = [setupBuiltin];
@@ -6134,9 +6243,15 @@ app.get("/health", (_req, res) => {
6134
6243
  activeSessions: activeSessions.size,
6135
6244
  storedGates: state.gateStore.getAll().length,
6136
6245
  serviceCredentials: Array.from(serviceCredentials.keys()),
6137
- integrations: integrationManager.getStatus(integrationRegistry.getAll())
6246
+ integrations: integrationManager.getStatus(integrationRegistry.getAll()),
6247
+ // The gateway's own tool groups (built-ins) — not connectors; the mandate
6248
+ // picker offers them so a person can give a mandate for them.
6249
+ builtins: integrationManager.getBuiltins()
6138
6250
  });
6139
6251
  });
6252
+ app.get("/internal/tool-display", internalOnly, (_req, res) => {
6253
+ res.json({ tools: integrationManager.getToolDisplay() });
6254
+ });
6140
6255
  app.get("/internal/gate-content", internalOnly, (req, res) => {
6141
6256
  const path = req.query.path;
6142
6257
  const gates = state.gateStore.getAll();