@suveren/gateway 0.16.1 → 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
  };
@@ -5248,6 +5248,18 @@ var IntegrationManager = class {
5248
5248
  available: !def.simulationOnly || isSimulationMode()
5249
5249
  }));
5250
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
+ }
5251
5263
  /** Whether `id` names a built-in (in-process) integration. */
5252
5264
  isBuiltin(id) {
5253
5265
  return this.builtins.has(id);
@@ -5266,14 +5278,14 @@ var IntegrationManager = class {
5266
5278
  * no ticket): `simulationOnly` outside simulation mode, then the tool's `validate`.
5267
5279
  * Null for connector tools and for calls that may proceed.
5268
5280
  */
5269
- precheckBuiltin(tool, args) {
5281
+ async precheckBuiltin(tool, args) {
5270
5282
  const builtin = this.builtins.get(tool.integrationId);
5271
5283
  if (!builtin) return null;
5272
5284
  if (builtin.def.simulationOnly && !isSimulationMode()) {
5273
5285
  return `Refused: "${tool.namespacedName}" is not available. No ticket was requested.`;
5274
5286
  }
5275
5287
  const def = builtin.def.tools.find((t) => t.name === tool.originalName);
5276
- const refusal = def?.validate?.(args);
5288
+ const refusal = await def?.validate?.(args);
5277
5289
  return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
5278
5290
  }
5279
5291
  /**
@@ -5484,7 +5496,8 @@ var IntegrationManager = class {
5484
5496
  readGovernance: ext.readGovernance,
5485
5497
  readGovernanceReason: ext.readGovernanceReason,
5486
5498
  blockedArgs: ext.blockedArgs,
5487
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5499
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5500
+ approvalView: ext.approvalView
5488
5501
  };
5489
5502
  }
5490
5503
  return {
@@ -5499,7 +5512,8 @@ var IntegrationManager = class {
5499
5512
  blockedArgs: ext.blockedArgs,
5500
5513
  argEncoding: ext.argEncoding,
5501
5514
  argNormalization: ext.argNormalization,
5502
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5515
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5516
+ approvalView: ext.approvalView
5503
5517
  };
5504
5518
  }
5505
5519
  return {
@@ -5606,6 +5620,27 @@ function builtinText(text) {
5606
5620
  return { content: [{ type: "text", text }] };
5607
5621
  }
5608
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
+
5609
5644
  // src/lib/builtins/setup.ts
5610
5645
  var DELEGATION_PROFILE = "github.com/humanagencyprotocol/hap-profiles/delegation@0.1";
5611
5646
  function briefRefusal(args) {
@@ -5618,7 +5653,7 @@ function setupBuiltin(_deps) {
5618
5653
  return {
5619
5654
  id: "setup",
5620
5655
  name: "Test setup",
5621
- description: "Your AI proposes its own setup for a test \u2014 for now its agent brief. Every proposal waits for your approval. Simulation mode only.",
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.",
5622
5657
  profile: DELEGATION_PROFILE,
5623
5658
  simulation: true,
5624
5659
  simulationOnly: true,
@@ -5627,7 +5662,24 @@ function setupBuiltin(_deps) {
5627
5662
  set_agent_brief: {
5628
5663
  executionMapping: {},
5629
5664
  staticExecution: { action_type: "brief" },
5630
- 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
+ }
5631
5683
  }
5632
5684
  }
5633
5685
  },
@@ -5656,10 +5708,56 @@ function setupBuiltin(_deps) {
5656
5708
  `Agent brief replaced (${Buffer.byteLength(args.content, "utf-8")} bytes). It applies from the next agent session.`
5657
5709
  );
5658
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
+ }
5659
5745
  }
5660
5746
  ]
5661
5747
  };
5662
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
+ }
5663
5761
 
5664
5762
  // src/lib/builtins/index.ts
5665
5763
  var BUILTIN_FACTORIES = [setupBuiltin];
@@ -6151,6 +6249,9 @@ app.get("/health", (_req, res) => {
6151
6249
  builtins: integrationManager.getBuiltins()
6152
6250
  });
6153
6251
  });
6252
+ app.get("/internal/tool-display", internalOnly, (_req, res) => {
6253
+ res.json({ tools: integrationManager.getToolDisplay() });
6254
+ });
6154
6255
  app.get("/internal/gate-content", internalOnly, (req, res) => {
6155
6256
  const path = req.query.path;
6156
6257
  const gates = state.gateStore.getAll();