@suveren/gateway 0.25.1 → 0.27.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.
@@ -154,7 +154,7 @@ import { dirname, join as join3 } from "path";
154
154
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
155
155
  import { join as join2 } from "path";
156
156
  import { spawnSync } from "child_process";
157
- var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod"];
157
+ var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod", "Team"];
158
158
  var DEFAULT_REGISTRY_BASE_KEY = "SOFTWARE\\Policies\\Suveren\\Gateway";
159
159
  function registryKeyPath(hive) {
160
160
  const baseKey = process.env.SUVEREN_POLICY_REGISTRY_KEY || DEFAULT_REGISTRY_BASE_KEY;
@@ -190,6 +190,11 @@ function validateCaFile(candidate) {
190
190
  if (!existsSync2(trimmed)) return { ok: false, error: `No file at "${trimmed}".` };
191
191
  return { ok: true, path: trimmed };
192
192
  }
193
+ function validateTeamId(candidate) {
194
+ const trimmed = candidate.trim();
195
+ if (!trimmed) return { ok: false, error: "The team id is empty." };
196
+ return { ok: true, id: trimmed };
197
+ }
193
198
  function validateProxyUrl(candidate) {
194
199
  const trimmed = candidate.trim();
195
200
  if (!trimmed) return { ok: false, error: "The proxy URL is empty." };
@@ -363,6 +368,14 @@ function readPolicy() {
363
368
  policy.installMethod = "managed";
364
369
  locked.add("installMethod");
365
370
  }
371
+ if (merged.Team !== void 0) {
372
+ const v = validateTeamId(String(merged.Team));
373
+ if (!v.ok) {
374
+ throw new Error(`Invalid policy Team ("${String(merged.Team)}") from ${sourceOf.Team}: ${v.error}`);
375
+ }
376
+ policy.team = v.id;
377
+ locked.add("team");
378
+ }
366
379
  cached = { policy, locked };
367
380
  return cached;
368
381
  }
@@ -704,6 +717,42 @@ function describeFetchError(err) {
704
717
  return String(err);
705
718
  }
706
719
 
720
+ // src/lib/cp-content-client.ts
721
+ var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
722
+ var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
723
+ var TIMEOUT_MS2 = 1e4;
724
+ async function postInternal(path, body) {
725
+ const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
726
+ if (!secret) {
727
+ throw new Error("No internal secret configured between the MCP server and the control plane.");
728
+ }
729
+ const controller = new AbortController();
730
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
731
+ try {
732
+ const res = await fetch(`${CP_BASE2}${path}`, {
733
+ method: "POST",
734
+ headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
735
+ body: JSON.stringify(body),
736
+ signal: controller.signal
737
+ });
738
+ const data = await res.json().catch(() => ({}));
739
+ if (!res.ok) {
740
+ throw new Error(data.error ?? `${path} failed: ${res.status}`);
741
+ }
742
+ return data;
743
+ } finally {
744
+ clearTimeout(timer);
745
+ }
746
+ }
747
+ async function cpEncryptContent(toolArgs, recipientUserIds) {
748
+ const data = await postInternal("/internal/encrypt-content", { toolArgs, recipientUserIds });
749
+ return data;
750
+ }
751
+ async function cpDecryptContent(wire) {
752
+ const data = await postInternal("/internal/decrypt-content", { ciphertext: wire.ciphertext, keys: wire.keys });
753
+ return data.plaintext;
754
+ }
755
+
707
756
  // src/lib/sp-client.ts
708
757
  var SPReceiptError = class extends Error {
709
758
  constructor(message, statusCode, body) {
@@ -861,6 +910,32 @@ var SPClient = class {
861
910
  if (!res.ok) throw new Error(`SP compat request failed: ${res.status}`);
862
911
  return res.json();
863
912
  }
913
+ /**
914
+ * WS3 defence-in-depth (work-plan "Added 2026-10-11 — an account is
915
+ * Personal OR in one team"): the account's active TEAM id, or null when
916
+ * it currently has none (Personal mode) — `GET /api/groups/me`. Used only
917
+ * by AttestationCache.refreshActiveWorkspace, never on the hot tool-call
918
+ * path (it makes a live AS call).
919
+ */
920
+ async getActiveTeamGroupId() {
921
+ const res = await this.fetch("/api/groups/me");
922
+ if (!res.ok) throw new Error(`SP groups/me request failed: ${res.status}`);
923
+ const data = await res.json();
924
+ return data.group?.id ?? null;
925
+ }
926
+ /**
927
+ * The account's own Personal group id — `GET /api/groups`, the entry
928
+ * with `isPersonal`. Same caller/purpose as {@link getActiveTeamGroupId}:
929
+ * together they resolve "the active workspace" (the active team if any,
930
+ * else Personal) without the gateway ever guessing at an id shape.
931
+ */
932
+ async getPersonalGroupId() {
933
+ const res = await this.fetch("/api/groups");
934
+ if (!res.ok) throw new Error(`SP groups request failed: ${res.status}`);
935
+ const data = await res.json();
936
+ const personal = (data.groups ?? []).find((g) => g.isPersonal);
937
+ return personal?.id ?? null;
938
+ }
864
939
  /**
865
940
  * Get all mandates for an authorization id.
866
941
  *
@@ -905,7 +980,11 @@ var SPClient = class {
905
980
  * Errors throw SPReceiptError with the structured `errors` array in `body`.
906
981
  */
907
982
  async postReceipt(data) {
908
- const body = JSON.stringify(data);
983
+ const { proposalContentHash, ...rest } = data;
984
+ const body = JSON.stringify({
985
+ ...rest,
986
+ ...proposalContentHash ? { proposal_content_hash: proposalContentHash } : {}
987
+ });
909
988
  const maxAttempts = data.idempotencyKey || data.proposalId ? this.receiptRetry.maxAttempts : 1;
910
989
  let lastError;
911
990
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -945,8 +1024,20 @@ var SPClient = class {
945
1024
  /**
946
1025
  * Submit a proposal for deferred commitment review.
947
1026
  * Phase 6: accepts optional pendingApprovers and createdBy for above-cap routing.
1027
+ *
1028
+ * PE1: toolArgs is encrypted (via the control plane's vault + the AS's
1029
+ * registered public keys — cp-content-client.ts) before it ever reaches
1030
+ * this method's request body; only the sealed `content` is sent, never
1031
+ * `tool_args`. `recipientUserIds` are who besides the submitter (added by
1032
+ * the control plane itself) must be able to read it back: the mandate
1033
+ * owner, every pending approver (above-cap path), or every active member
1034
+ * holding a pending domain (deferred-domain path) — decision 3 of the
1035
+ * PE1 brief. `unreadableFor` is G4: anyone in that set with no registered
1036
+ * end-to-end key — the proposal is still created for everyone who CAN
1037
+ * read it; the caller reports who cannot.
948
1038
  */
949
1039
  async submitProposal(data) {
1040
+ const { content, unreadableFor } = await cpEncryptContent(data.toolArgs, data.recipientUserIds);
950
1041
  const res = await this.fetch("/api/proposals", {
951
1042
  method: "POST",
952
1043
  body: JSON.stringify({
@@ -955,7 +1046,7 @@ var SPClient = class {
955
1046
  path: data.path,
956
1047
  pending_domains: data.pendingDomains,
957
1048
  tool: data.tool,
958
- tool_args: data.toolArgs,
1049
+ content,
959
1050
  execution_context: data.executionContext,
960
1051
  // Phase 6
961
1052
  ...data.pendingApprovers && data.pendingApprovers.length > 0 ? { pending_approvers: data.pendingApprovers } : {},
@@ -963,11 +1054,12 @@ var SPClient = class {
963
1054
  })
964
1055
  });
965
1056
  if (!res.ok) {
966
- const body = await res.json().catch(() => ({}));
967
- throw new Error(body.error ?? `SP proposal submission failed: ${res.status}`);
1057
+ const body2 = await res.json().catch(() => ({}));
1058
+ throw new Error(body2.error ?? `SP proposal submission failed: ${res.status}`);
968
1059
  }
969
1060
  void notifyControlPlane("proposal-added");
970
- return res.json();
1061
+ const body = await res.json();
1062
+ return { proposal: body.proposal, unreadableFor };
971
1063
  }
972
1064
  /**
973
1065
  * Phase 6: Fetch the authorization summary by its per-ceremony id.
@@ -1066,6 +1158,48 @@ var AttestationCache = class {
1066
1158
  * this fact to begin with.
1067
1159
  */
1068
1160
  reapprovalNeeded = /* @__PURE__ */ new Set();
1161
+ /**
1162
+ * WS3 defence-in-depth (work-plan "Added 2026-10-11 — an account is
1163
+ * Personal OR in one team"): the account's current active workspace's
1164
+ * group id — its active team, else its Personal group. `null` = not yet
1165
+ * known (before the first {@link refreshActiveWorkspace}, or every
1166
+ * lookup it tried failed) — filtering is then SKIPPED rather than
1167
+ * refusing everything, because the Authority Server itself is the
1168
+ * fail-closed authority at ticket time (decision 6, WS2); this is a
1169
+ * second, narrower check for a window a buggy/stale cache entry could
1170
+ * otherwise slip through in, not a replacement for that enforcement.
1171
+ */
1172
+ activeWorkspaceGroupId = null;
1173
+ /**
1174
+ * Re-resolve {@link activeWorkspaceGroupId} from the Authority Server.
1175
+ * Called by the resync-gates flow (http.ts's `/internal/resync-gates`),
1176
+ * never on the hot tool-call path — it makes up to two AS calls. Leaves
1177
+ * the previous value in place on any failure (network error, unexpected
1178
+ * shape): losing a known-good value on a transient blip would turn this
1179
+ * defence-in-depth check into a false refusal of every cached mandate,
1180
+ * which is worse than leaving it momentarily stale.
1181
+ */
1182
+ async refreshActiveWorkspace() {
1183
+ try {
1184
+ const activeTeam = await this.spClient.getActiveTeamGroupId();
1185
+ if (activeTeam) {
1186
+ this.activeWorkspaceGroupId = activeTeam;
1187
+ return;
1188
+ }
1189
+ const personalId = await this.spClient.getPersonalGroupId();
1190
+ if (personalId) this.activeWorkspaceGroupId = personalId;
1191
+ } catch {
1192
+ }
1193
+ }
1194
+ /**
1195
+ * True only when `groupId` is KNOWN (not undefined) and the active
1196
+ * workspace is ALSO known (not null) and they differ. Either side
1197
+ * unknown ⇒ false: this check only ever narrows what gets selected, it
1198
+ * never substitutes for the Authority Server's own enforcement.
1199
+ */
1200
+ isForeignWorkspace(groupId) {
1201
+ return groupId !== void 0 && this.activeWorkspaceGroupId !== null && groupId !== this.activeWorkspaceGroupId;
1202
+ }
1069
1203
  /**
1070
1204
  * Get the SP public key, fetching from SP if not cached or expired, then —
1071
1205
  * when a pin exists for this exact AS URL — verifying the live key still
@@ -1114,6 +1248,10 @@ var AttestationCache = class {
1114
1248
  getAuthorization(path) {
1115
1249
  const auth = this.authorizations.get(path);
1116
1250
  if (!auth) return null;
1251
+ if (this.isForeignWorkspace(auth.groupId)) {
1252
+ this.authorizations.delete(path);
1253
+ return null;
1254
+ }
1117
1255
  const now = Math.floor(Date.now() / 1e3);
1118
1256
  const hasValid = auth.attestations.some((a) => a.expiresAt > now);
1119
1257
  if (!hasValid) {
@@ -1138,6 +1276,13 @@ var AttestationCache = class {
1138
1276
  );
1139
1277
  }
1140
1278
  const bounds = result.bounds ?? result.frame ?? {};
1279
+ let groupId;
1280
+ try {
1281
+ const summary = await this.spClient.getAuthorizationSummary(authorizationId);
1282
+ groupId = summary?.group_id ?? void 0;
1283
+ } catch {
1284
+ groupId = void 0;
1285
+ }
1141
1286
  let signedCommitmentMode;
1142
1287
  let subjects;
1143
1288
  const firstBlob = result.attestations[0]?.blob;
@@ -1172,7 +1317,8 @@ var AttestationCache = class {
1172
1317
  deferredCommitmentDomains: result.deferred_commitment_domains ?? [],
1173
1318
  signedCommitmentMode,
1174
1319
  subjects,
1175
- complete: result.complete
1320
+ complete: result.complete,
1321
+ groupId
1176
1322
  };
1177
1323
  this.authorizations.set(auth.authorizationId, auth);
1178
1324
  return auth;
@@ -1184,6 +1330,10 @@ var AttestationCache = class {
1184
1330
  const now = Math.floor(Date.now() / 1e3);
1185
1331
  const results = [];
1186
1332
  for (const [path, auth] of this.authorizations) {
1333
+ if (this.isForeignWorkspace(auth.groupId)) {
1334
+ this.authorizations.delete(path);
1335
+ continue;
1336
+ }
1187
1337
  const hasValid = auth.attestations.some((a) => a.expiresAt > now);
1188
1338
  if (hasValid) {
1189
1339
  results.push(auth);
@@ -3179,6 +3329,11 @@ function asUrlChangedNotice(what) {
3179
3329
 
3180
3330
  TELL THE USER: open ${uiUrl()} and sign in again to pair with the new Authority Server.`;
3181
3331
  }
3332
+ function managedWrongTeamNotice(what) {
3333
+ return `${what} Suveren gateway is LOCKED because it is set up by IT for one specific team, and the signed-in account is no longer in that team (it left, or was removed). This is NOT an ended sign-in \u2014 signing in again will be refused until the account is back in that team.
3334
+
3335
+ TELL THE USER: ask your IT administrator, or sign in with an account that is in the team this gateway is set up for, at ${uiUrl()}. Nothing ran while unsigned.`;
3336
+ }
3182
3337
  function lockedNotice(action, reason = "restart") {
3183
3338
  const what = action ? `Cannot ${action}: the` : "The";
3184
3339
  switch (reason) {
@@ -3190,6 +3345,8 @@ function lockedNotice(action, reason = "restart") {
3190
3345
  return asTlsMismatchNotice(what);
3191
3346
  case "as-url-changed":
3192
3347
  return asUrlChangedNotice(what);
3348
+ case "managed-wrong-team":
3349
+ return managedWrongTeamNotice(what);
3193
3350
  default:
3194
3351
  return bootLockedNotice(what);
3195
3352
  }
@@ -3643,6 +3800,20 @@ async function verifyTicket(cache, receipt, expected) {
3643
3800
  }
3644
3801
  }
3645
3802
 
3803
+ // src/lib/who-label.ts
3804
+ var UNKNOWN_DECIDER = "the person";
3805
+ async function resolveWhoLabel(archive, domainKey, userId) {
3806
+ if (!archive) return UNKNOWN_DECIDER;
3807
+ try {
3808
+ const dir = await getIdentityDirectory(archive);
3809
+ const did = dir.domainDids.get(domainKey) ?? dir.domainDids.get(userId);
3810
+ const name = did ? dir.names.get(did) : void 0;
3811
+ return name ?? UNKNOWN_DECIDER;
3812
+ } catch {
3813
+ return UNKNOWN_DECIDER;
3814
+ }
3815
+ }
3816
+
3646
3817
  // src/lib/arg-encoding.ts
3647
3818
  function rfc2047Encode(value) {
3648
3819
  if (!/[^\x00-\x7F]/.test(value)) return value;
@@ -3962,6 +4133,14 @@ async function attachImagePreview(args) {
3962
4133
  return args;
3963
4134
  }
3964
4135
  }
4136
+ async function unreadableForNote(unreadableFor, state2) {
4137
+ if (unreadableFor.length === 0) return "";
4138
+ const labels = await Promise.all(
4139
+ unreadableFor.map((id) => resolveWhoLabel(state2.receiptArchive, id, id))
4140
+ );
4141
+ return `
4142
+ Note: ${labels.join(", ")} cannot read this proposal's content yet \u2014 no device of theirs has registered an end-to-end key.`;
4143
+ }
3965
4144
  function applyMapping(m, value, execution) {
3966
4145
  if (m.divisor) {
3967
4146
  const numValue = typeof value === "number" ? value : Number(value);
@@ -4536,14 +4715,22 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
4536
4715
  try {
4537
4716
  const enrichedArgs = await attachImagePreview(args);
4538
4717
  const wireExecCtx = wireExecutionContext(auth.profileId, execution);
4539
- const { proposal } = await state2.spClient.submitProposal({
4718
+ let recipientUserIds = [...auth.deferredCommitmentDomains];
4719
+ try {
4720
+ const summary = await state2.spClient.getAuthorizationSummary(authzId);
4721
+ if (summary?.created_by) recipientUserIds = [summary.created_by, ...recipientUserIds];
4722
+ } catch {
4723
+ }
4724
+ recipientUserIds = [...new Set(recipientUserIds)];
4725
+ const { proposal, unreadableFor } = await state2.spClient.submitProposal({
4540
4726
  authorizationId: authzId,
4541
4727
  profileId: auth.profileId,
4542
4728
  path: auth.path,
4543
4729
  pendingDomains: auth.deferredCommitmentDomains,
4544
4730
  tool: tool.namespacedName,
4545
4731
  toolArgs: enrichedArgs,
4546
- executionContext: wireExecCtx
4732
+ executionContext: wireExecCtx,
4733
+ recipientUserIds
4547
4734
  });
4548
4735
  const previewHash = await computeSubmissionPreviewHash(
4549
4736
  integrationManager2,
@@ -4564,7 +4751,7 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
4564
4751
  content: [{
4565
4752
  type: "text",
4566
4753
  text: `Awaiting commitment from domain${auth.deferredCommitmentDomains.length > 1 ? "s" : ""} "${auth.deferredCommitmentDomains.join('", "')}" for tool ${tool.originalName}.
4567
- Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").`
4754
+ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").` + await unreadableForNote(unreadableFor ?? [], state2)
4568
4755
  }]
4569
4756
  };
4570
4757
  } catch (err) {
@@ -4714,7 +4901,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
4714
4901
  try {
4715
4902
  const enrichedArgs = await attachImagePreview(args);
4716
4903
  const wireExecCtx = wireExecutionContext(auth.profileId, execution);
4717
- const { proposal } = await state2.spClient.submitProposal({
4904
+ const { proposal, unreadableFor } = await state2.spClient.submitProposal({
4718
4905
  authorizationId: authzId,
4719
4906
  profileId: auth.profileId,
4720
4907
  path: auth.path,
@@ -4722,7 +4909,8 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
4722
4909
  tool: tool.namespacedName,
4723
4910
  toolArgs: enrichedArgs,
4724
4911
  executionContext: wireExecCtx,
4725
- pendingApprovers: uniqueApprovers
4912
+ pendingApprovers: uniqueApprovers,
4913
+ recipientUserIds: uniqueApprovers
4726
4914
  });
4727
4915
  const previewHash = await computeSubmissionPreviewHash(
4728
4916
  integrationManager2,
@@ -4743,7 +4931,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
4743
4931
  content: [{
4744
4932
  type: "text",
4745
4933
  text: `Action exceeds team cap. Approval required from ${uniqueApprovers.length} reviewer(s).
4746
- Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.`
4934
+ Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.` + await unreadableForNote(unreadableFor ?? [], state2)
4747
4935
  }]
4748
4936
  };
4749
4937
  } catch (proposalErr) {
@@ -5310,17 +5498,54 @@ function listIntegrationsHandler(state2, integrationManager2) {
5310
5498
  // src/tools/commitments.ts
5311
5499
  import { ContentBindingError as ContentBindingError2 } from "@hap/core";
5312
5500
 
5313
- // src/lib/who-label.ts
5314
- var UNKNOWN_DECIDER = "the person";
5315
- async function resolveWhoLabel(archive, domainKey, userId) {
5316
- if (!archive) return UNKNOWN_DECIDER;
5501
+ // src/lib/proposal-content.ts
5502
+ import { createHash as createHash3 } from "crypto";
5503
+ import { canonicalize } from "@hap/core";
5504
+ var ProposalContentError = class extends Error {
5505
+ };
5506
+ function computeProposalContentHash(toolArgs) {
5507
+ return `sha256:${createHash3("sha256").update(canonicalize(toolArgs)).digest("hex")}`;
5508
+ }
5509
+ async function resolveProposalToolArgs(proposal) {
5510
+ if (proposal.toolArgs) return proposal.toolArgs;
5511
+ if (!proposal.content) {
5512
+ throw new ProposalContentError(`Proposal ${proposal.id} carries neither tool_args nor content \u2014 cannot execute.`);
5513
+ }
5514
+ let plaintext;
5317
5515
  try {
5318
- const dir = await getIdentityDirectory(archive);
5319
- const did = dir.domainDids.get(domainKey) ?? dir.domainDids.get(userId);
5320
- const name = did ? dir.names.get(did) : void 0;
5321
- return name ?? UNKNOWN_DECIDER;
5516
+ plaintext = await cpDecryptContent(proposal.content);
5517
+ } catch (err) {
5518
+ throw new ProposalContentError(`Could not decrypt the approved content on this device: ${err instanceof Error ? err.message : String(err)}`);
5519
+ }
5520
+ let parsed;
5521
+ try {
5522
+ parsed = JSON.parse(plaintext);
5322
5523
  } catch {
5323
- return UNKNOWN_DECIDER;
5524
+ throw new ProposalContentError(`Proposal ${proposal.id}: decrypted content is not valid JSON.`);
5525
+ }
5526
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5527
+ throw new ProposalContentError(`Proposal ${proposal.id}: decrypted content is not a JSON object.`);
5528
+ }
5529
+ const toolArgs = parsed;
5530
+ if (proposal.content.hash) {
5531
+ const actual = computeProposalContentHash(toolArgs);
5532
+ if (actual !== proposal.content.hash) {
5533
+ throw new ProposalContentError(
5534
+ `Proposal ${proposal.id}: decrypted content does not match its own fingerprint \u2014 refusing to execute.`
5535
+ );
5536
+ }
5537
+ }
5538
+ return toolArgs;
5539
+ }
5540
+ async function decryptRejectionNote(by) {
5541
+ if (!by) return void 0;
5542
+ if (typeof by.note === "string") return by.note;
5543
+ if (!by.noteEncrypted) return void 0;
5544
+ try {
5545
+ return await cpDecryptContent(by.noteEncrypted);
5546
+ } catch (err) {
5547
+ console.error("[Suveren MCP] Failed to decrypt a change-needed note:", err);
5548
+ return "(could not decrypt this note on this device)";
5324
5549
  }
5325
5550
  }
5326
5551
 
@@ -5332,6 +5557,7 @@ var installedExecutor = null;
5332
5557
  function installCommittedExecutor(executor) {
5333
5558
  installedExecutor = executor;
5334
5559
  }
5560
+ var warnedSkippedProposalIds = /* @__PURE__ */ new Set();
5335
5561
  function runCommitted(proposal, state2, integrationManager2) {
5336
5562
  return installedExecutor ? installedExecutor.execute(proposal) : executeCommitted(proposal, state2, integrationManager2);
5337
5563
  }
@@ -5355,15 +5581,28 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5355
5581
  const cachedAuth = state2.cache.getAllAuthorizations().find((a) => a.authorizationId === proposal.authorizationId);
5356
5582
  const submitted = state2.proposalSubmissions.get(proposal.id);
5357
5583
  if (!submitted) {
5358
- console.error(
5359
- `[Suveren MCP] Proposal ${proposal.id} is committed but has no local submission record on this gateway \u2014 leaving it for whichever gateway submitted it (this is normal for a different device of the same operator, or the AS lists a proposal we never submitted).`
5360
- );
5361
- return { text: buildSkippedProposalNote(proposal.id) };
5584
+ if (!warnedSkippedProposalIds.has(proposal.id)) {
5585
+ warnedSkippedProposalIds.add(proposal.id);
5586
+ console.error(
5587
+ `[Suveren MCP] Proposal ${proposal.id} is committed but has no local submission record on this gateway \u2014 leaving it for whichever gateway submitted it (this is normal for a different device of the same operator, or the AS lists a proposal we never submitted).`
5588
+ );
5589
+ }
5590
+ return { text: buildSkippedProposalNote(proposal.id), skipped: true };
5591
+ }
5592
+ let toolArgs;
5593
+ try {
5594
+ toolArgs = await resolveProposalToolArgs(proposal);
5595
+ } catch (err) {
5596
+ const msg = err instanceof ProposalContentError ? err.message : `Could not resolve the approved content on this device: ${err instanceof Error ? err.message : String(err)}`;
5597
+ return {
5598
+ text: `Proposal ${proposal.id}: blocked \u2014 ${msg} The action was not executed.`,
5599
+ isError: true
5600
+ };
5362
5601
  }
5363
5602
  {
5364
5603
  const mismatches = [];
5365
5604
  if (submitted.tool !== proposal.tool) mismatches.push("tool");
5366
- if (submitted.toolArgsHash !== hashToolArgs(proposal.toolArgs)) mismatches.push("arguments");
5605
+ if (submitted.toolArgsHash !== hashToolArgs(toolArgs)) mismatches.push("arguments");
5367
5606
  if (submitted.executionContextHash !== hashToolArgs(proposal.executionContext)) mismatches.push("executionContext");
5368
5607
  if (submitted.authorizationId !== proposal.authorizationId) mismatches.push("authorizationId");
5369
5608
  if (submitted.profileId !== proposal.profileId) mismatches.push("profileId");
@@ -5381,7 +5620,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5381
5620
  const binding = computeContentBinding(
5382
5621
  proposal.profileId,
5383
5622
  discovered,
5384
- proposal.toolArgs,
5623
+ toolArgs,
5385
5624
  proposalActionType
5386
5625
  );
5387
5626
  const { receipt, idempotent } = await state2.spClient.postReceipt({
@@ -5395,7 +5634,12 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5395
5634
  executionContext: proposal.executionContext,
5396
5635
  amount: typeof proposal.executionContext.amount === "number" ? proposal.executionContext.amount : void 0,
5397
5636
  proposalId: proposal.id,
5398
- toolArgs: proposal.toolArgs,
5637
+ toolArgs,
5638
+ // PE1 — the fingerprint of what THIS gateway resolved (decrypted, or
5639
+ // read plain on a legacy row) and is about to execute. The AS compares
5640
+ // it against its own stored content_hash and refuses without echoing
5641
+ // content on a mismatch.
5642
+ proposalContentHash: computeProposalContentHash(toolArgs),
5399
5643
  // Deliberately NO idempotencyKey: the spec has review-mode commits carry
5400
5644
  // a proposalId INSTEAD of a key — the proposal's committed→executed
5401
5645
  // transition is their replay protection, and the AS replays the ORIGINAL
@@ -5429,7 +5673,11 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5429
5673
  attestations: cachedAuth?.attestations,
5430
5674
  // What the human actually approved, kept next to the receipt that cites
5431
5675
  // it — otherwise `proposalId` is a pointer to a server we may not have.
5432
- proposal,
5676
+ // PE1: the archived copy carries the RESOLVED (decrypted on an
5677
+ // encrypted row) toolArgs, never the sealed `content` — this archive
5678
+ // is this gateway's own local evidence and has always stored it
5679
+ // readable, same as every legacy row.
5680
+ proposal: { ...proposal, toolArgs },
5433
5681
  boundContent: binding?.boundContent
5434
5682
  });
5435
5683
  } catch (err) {
@@ -5488,7 +5736,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5488
5736
  ticketId: receiptId,
5489
5737
  proposalId: proposal.id,
5490
5738
  tool: proposal.tool,
5491
- argsHash: hashToolArgs(proposal.toolArgs)
5739
+ argsHash: hashToolArgs(toolArgs)
5492
5740
  });
5493
5741
  if (!begun.ok) {
5494
5742
  const prior = begun.existing;
@@ -5513,7 +5761,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5513
5761
  integrationManager2,
5514
5762
  integrationId,
5515
5763
  toolName,
5516
- proposal.toolArgs,
5764
+ toolArgs,
5517
5765
  submitted.previewHash
5518
5766
  );
5519
5767
  if (changed) {
@@ -5525,7 +5773,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
5525
5773
  }
5526
5774
  }
5527
5775
  try {
5528
- let outgoingArgs = proposal.toolArgs;
5776
+ let outgoingArgs = toolArgs;
5529
5777
  if (discovered && receiptId) {
5530
5778
  if (shouldAttachFooter()) {
5531
5779
  outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId, cachedAuth?.subjects?.[0]);
@@ -5631,10 +5879,11 @@ Result: ${JSON.stringify(match.executionResult, null, 2)}` : " The action ran; t
5631
5879
  by.userId
5632
5880
  ) : "a person";
5633
5881
  if (outcome === "change_needed") {
5882
+ const note = await decryptRejectionNote(by);
5634
5883
  return {
5635
5884
  content: [{
5636
5885
  type: "text",
5637
- text: `Proposal ${match.id} was SENT BACK FOR A CHANGE by ${who}: "${by?.note ?? ""}". The action did not run. You may adjust it and propose again \u2014 a new proposal the person approves again. Do not resend the same values.`
5886
+ text: `Proposal ${match.id} was SENT BACK FOR A CHANGE by ${who}: "${note ?? ""}". The action did not run. You may adjust it and propose again \u2014 a new proposal the person approves again. Do not resend the same values.`
5638
5887
  }]
5639
5888
  };
5640
5889
  }
@@ -7719,13 +7968,13 @@ var reportBuiltin = (deps) => ({
7719
7968
  });
7720
7969
 
7721
7970
  // src/lib/cp-mandate.ts
7722
- var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
7723
- var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
7971
+ var CP_PORT3 = process.env.SUVEREN_CP_PORT ?? "3402";
7972
+ var CP_BASE3 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT3}`;
7724
7973
  async function controlPlaneMandate(dryRun, request) {
7725
7974
  const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
7726
7975
  if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
7727
7976
  try {
7728
- const res = await fetch(`${CP_BASE2}/internal/mandate`, {
7977
+ const res = await fetch(`${CP_BASE3}/internal/mandate`, {
7729
7978
  method: "POST",
7730
7979
  headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
7731
7980
  body: JSON.stringify({ dryRun, request }),
@@ -8033,7 +8282,6 @@ function resolveTargetProfile(input) {
8033
8282
  var CREATE_MANDATE_INPUT_SCHEMA = {
8034
8283
  type: "object",
8035
8284
  properties: {
8036
- team: { type: "string", description: 'Team name or id, or "personal". Omit to use the workspace of your Delegation mandate.' },
8037
8285
  // Short name only (e.g. "sales") — a full profile id is refused here, at input
8038
8286
  // validation, before the tool ever runs (DL1 Interface contract).
8039
8287
  profile: { type: "string", pattern: "^[a-z][a-z0-9-]*$", description: 'Profile short name, e.g. "sales" (newest version).' },
@@ -8123,7 +8371,6 @@ function setupBuiltin(deps) {
8123
8371
  hideUnlessAuthorized: true,
8124
8372
  // Shown like the mandate screen: limits and scope by the profile's own names.
8125
8373
  approvalView: {
8126
- team: { label: "Team" },
8127
8374
  profile: { label: "Profile", kind: "profile" },
8128
8375
  limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
8129
8376
  scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
@@ -8132,7 +8379,7 @@ function setupBuiltin(deps) {
8132
8379
  duration_hours: { label: "Valid for (hours)" },
8133
8380
  title: { label: "Title" }
8134
8381
  },
8135
- preview: { tool: "describe_mandate_proposal", args: { team: "team", profile: "profile", limits: "limits", scope: "scope", intent: "intent", mode: "mode", duration_hours: "duration_hours", title: "title" } }
8382
+ preview: { tool: "describe_mandate_proposal", args: { profile: "profile", limits: "limits", scope: "scope", intent: "intent", mode: "mode", duration_hours: "duration_hours", title: "title" } }
8136
8383
  },
8137
8384
  // Internal preview tools (DL1): never listed, never callable by an
8138
8385
  // agent (BuiltinTool.internal — enforced in index.ts and
@@ -8205,7 +8452,7 @@ ${body}`);
8205
8452
  },
8206
8453
  {
8207
8454
  name: "create_mandate",
8208
- description: "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. Outside simulation mode, the profile and mode you may propose are themselves limited by your Delegation mandate's scope \u2014 Delegation itself can never be proposed. Create only.",
8455
+ description: "Propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). Always goes to the person's current workspace (their team, or Personal if they are not in one) \u2014 there is no way to name a different one. 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. Outside simulation mode, the profile and mode you may propose are themselves limited by your Delegation mandate's scope \u2014 Delegation itself can never be proposed. Create only.",
8209
8456
  inputSchema: CREATE_MANDATE_INPUT_SCHEMA,
8210
8457
  validate: async (args) => {
8211
8458
  const fixed = delegationItselfRefusal(args);
@@ -8251,8 +8498,8 @@ ${body}`);
8251
8498
  }
8252
8499
  const { profile, shortName: targetShortName } = resolved;
8253
8500
  const displayName = profile.name ?? targetShortName;
8254
- const rawTeam = typeof args.team === "string" ? args.team.trim() : "";
8255
- const workspace = rawTeam && rawTeam.toLowerCase() !== "personal" ? rawTeam : "Personal";
8501
+ const check = await controlPlaneMandate(true, mandateRequest(args));
8502
+ const workspace = check.ok && check.groupName ? check.groupName : "Personal";
8256
8503
  const mode = typeof args.mode === "string" ? args.mode : "";
8257
8504
  const structured = {
8258
8505
  proposal: `New mandate \xB7 ${displayName}`,
@@ -8291,7 +8538,6 @@ ${body}`);
8291
8538
  }
8292
8539
  function mandateRequest(args) {
8293
8540
  return {
8294
- team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
8295
8541
  profile: args.profile,
8296
8542
  limits: args.limits,
8297
8543
  scope: args.scope,
@@ -8324,6 +8570,10 @@ import { decodeMandateBlob as decodeMandateBlob3 } from "@hap/core";
8324
8570
  // src/lib/committed-executor.ts
8325
8571
  import { existsSync as existsSync20, mkdirSync as mkdirSync15, readFileSync as readFileSync21, unlinkSync as unlinkSync7, writeFileSync as writeFileSync15 } from "fs";
8326
8572
  import { dirname as dirname15, join as join20 } from "path";
8573
+ function describeAutoExecutionLog(proposalId, tool, result) {
8574
+ if (result.skipped) return null;
8575
+ return result.isError ? `[Suveren MCP] Auto-exec proposal ${proposalId}: ${result.text}` : `[Suveren MCP] Auto-executed proposal ${proposalId}: ${tool}`;
8576
+ }
8327
8577
  var CommittedExecutor = class {
8328
8578
  constructor(run) {
8329
8579
  this.run = run;
@@ -8524,7 +8774,7 @@ app.post("/internal/configure", internalOnly, (req, res) => {
8524
8774
  });
8525
8775
  app.post("/internal/clear-session", internalOnly, (req, res) => {
8526
8776
  const reason = req.body?.reason;
8527
- const validReason = reason === "as-key-mismatch" || reason === "as-tls-mismatch" || reason === "as-url-changed" ? reason : void 0;
8777
+ const validReason = reason === "as-key-mismatch" || reason === "as-tls-mismatch" || reason === "as-url-changed" || reason === "managed-wrong-team" ? reason : void 0;
8528
8778
  state.spClient.clearSession(validReason);
8529
8779
  console.error(`[Suveren MCP] Session cleared by control-plane (${validReason ?? "expired"})`);
8530
8780
  res.json({ ok: true });
@@ -8598,6 +8848,7 @@ app.post("/internal/run-committed", internalOnly, (_req, res) => {
8598
8848
  res.json({ ok: true });
8599
8849
  });
8600
8850
  app.post("/internal/resync-gates", internalOnly, async (_req, res) => {
8851
+ await state.cache.refreshActiveWorkspace();
8601
8852
  const gates = state.gateStore.getAll();
8602
8853
  if (gates.length === 0) {
8603
8854
  res.json({ ok: true, synced: 0 });
@@ -9179,7 +9430,13 @@ app.listen(port, bindHost, () => {
9179
9430
  "domain" in by ? by.domain : by.userId,
9180
9431
  by.userId
9181
9432
  );
9182
- const entry = outcomeEntryFor(proposal, submittedHere, who, Date.now());
9433
+ const note = await decryptRejectionNote(by);
9434
+ const proposalWithDecryptedNote = {
9435
+ ...proposal,
9436
+ rejectedBy: proposal.rejectedBy ? { ...proposal.rejectedBy, note } : null,
9437
+ approverRejectedBy: proposal.approverRejectedBy ? { ...proposal.approverRejectedBy, note } : void 0
9438
+ };
9439
+ const entry = outcomeEntryFor(proposalWithDecryptedNote, submittedHere, who, Date.now());
9183
9440
  if (entry) state.outcomeOutbox.recordIfNew(entry);
9184
9441
  }
9185
9442
  } catch {
@@ -9195,10 +9452,9 @@ app.listen(port, bindHost, () => {
9195
9452
  const committed = await state.spClient.getCommittedProposals();
9196
9453
  for (const proposal of committed) {
9197
9454
  try {
9198
- const { text, isError } = await committedExecutor.execute(proposal);
9199
- console.error(
9200
- isError ? `[Suveren MCP] Auto-exec proposal ${proposal.id}: ${text}` : `[Suveren MCP] Auto-executed proposal ${proposal.id}: ${proposal.tool}`
9201
- );
9455
+ const result = await committedExecutor.execute(proposal);
9456
+ const line = describeAutoExecutionLog(proposal.id, proposal.tool, result);
9457
+ if (line) console.error(line);
9202
9458
  } catch (err) {
9203
9459
  console.error(`[Suveren MCP] Failed to execute proposal ${proposal.id}:`, err);
9204
9460
  }