@securecoms/agent-bridge 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -3084,7 +3084,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
3084
3084
  if (outputLen !== expectedLen)
3085
3085
  throw new Error(`hash.outputLen must be ${expectedLen}, got ${outputLen}`);
3086
3086
  }
3087
- const randomBytes13 = opts.randomBytes === void 0 ? randomBytes4 : opts.randomBytes;
3087
+ const randomBytes14 = opts.randomBytes === void 0 ? randomBytes4 : opts.randomBytes;
3088
3088
  const adjustScalarBytes3 = opts.adjustScalarBytes === void 0 ? (bytes) => bytes : opts.adjustScalarBytes;
3089
3089
  const domain = opts.domain === void 0 ? (data, ctx, phflag) => {
3090
3090
  abool2(phflag, "phflag");
@@ -3170,7 +3170,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
3170
3170
  seed: _size
3171
3171
  };
3172
3172
  function randomSecretKey(seed) {
3173
- seed = seed === void 0 ? randomBytes13(lengths.seed) : seed;
3173
+ seed = seed === void 0 ? randomBytes14(lengths.seed) : seed;
3174
3174
  return abytes3(seed, lengths.seed, "seed");
3175
3175
  }
3176
3176
  function isValidSecretKey(key) {
@@ -6419,7 +6419,7 @@ function ecdsa(Point, hash, ecdsaOpts = {}) {
6419
6419
  bits2int_modN: "function"
6420
6420
  });
6421
6421
  ecdsaOpts = Object.assign({}, ecdsaOpts);
6422
- const randomBytes13 = ecdsaOpts.randomBytes === void 0 ? randomBytes4 : ecdsaOpts.randomBytes;
6422
+ const randomBytes14 = ecdsaOpts.randomBytes === void 0 ? randomBytes4 : ecdsaOpts.randomBytes;
6423
6423
  const hmac5 = ecdsaOpts.hmac === void 0 ? (key, msg) => hmac(hash_, key, msg) : ecdsaOpts.hmac;
6424
6424
  const { Fp: Fp3, Fn: Fn3 } = Point;
6425
6425
  const { ORDER: CURVE_ORDER, BITS: fnBits } = Fn3;
@@ -6565,7 +6565,7 @@ function ecdsa(Point, hash, ecdsaOpts = {}) {
6565
6565
  throw new Error("invalid private key");
6566
6566
  const seedArgs = [int2octets(d), int2octets(h1int)];
6567
6567
  if (extraEntropy != null && extraEntropy !== false) {
6568
- const e = extraEntropy === true ? randomBytes13(lengths.secretKey) : extraEntropy;
6568
+ const e = extraEntropy === true ? randomBytes14(lengths.secretKey) : extraEntropy;
6569
6569
  seedArgs.push(abytes3(e, void 0, "extraEntropy"));
6570
6570
  }
6571
6571
  const seed = concatBytes4(...seedArgs);
@@ -7555,7 +7555,7 @@ __export(import_bundle_exports, {
7555
7555
  ModelImportError: () => ModelImportError,
7556
7556
  importModelBundle: () => importModelBundle
7557
7557
  });
7558
- import { createHash as createHash6, randomBytes as randomBytes12 } from "node:crypto";
7558
+ import { createHash as createHash6, randomBytes as randomBytes13 } from "node:crypto";
7559
7559
  import { readFileSync as readFileSync16, statSync as statSync7, writeFileSync as writeFileSync4 } from "node:fs";
7560
7560
  import { join as join25 } from "node:path";
7561
7561
  async function importModelBundle(srcDir, root3, m) {
@@ -7585,7 +7585,7 @@ async function importModelBundle(srcDir, root3, m) {
7585
7585
  for (const { f, bytes } of verified) {
7586
7586
  try {
7587
7587
  const pdir = plainDirChain(root3, [".partial"], { create: true, fromModelsDir: true, mode: 448 });
7588
- const partial = join25(pdir, randomBytes12(12).toString("hex"));
7588
+ const partial = join25(pdir, randomBytes13(12).toString("hex"));
7589
7589
  writeFileSync4(partial, bytes, { mode: 384, flag: "wx" });
7590
7590
  installVerified(root3, m.modelId, f, partial, { replace: true });
7591
7591
  } catch (e) {
@@ -19161,8 +19161,8 @@ function sameRef(a, b) {
19161
19161
  // ../connect-sdk-ts/dist/groupkey/own-removal.js
19162
19162
  async function probeOwnRemoval(mgr, commitBytes) {
19163
19163
  try {
19164
- const own = mgr.state?.privatePath?.leafIndex;
19165
- if (typeof own !== "number")
19164
+ const own2 = mgr.state?.privatePath?.leafIndex;
19165
+ if (typeof own2 !== "number")
19166
19166
  return null;
19167
19167
  if (!hoistedTsMlsMatchesCrypto(mgr))
19168
19168
  return null;
@@ -19187,7 +19187,7 @@ async function probeOwnRemoval(mgr, commitBytes) {
19187
19187
  committer = typeof incoming.senderLeafIndex === "number" ? Number(incoming.senderLeafIndex) : null;
19188
19188
  for (const p of incoming.proposals) {
19189
19189
  const prop = p.proposal;
19190
- if (prop.proposalType === "remove" && Number(prop.remove?.removed) === own)
19190
+ if (prop.proposalType === "remove" && Number(prop.remove?.removed) === own2)
19191
19191
  removesOwn = true;
19192
19192
  }
19193
19193
  }
@@ -19195,7 +19195,7 @@ async function probeOwnRemoval(mgr, commitBytes) {
19195
19195
  }, cs);
19196
19196
  if (!sawCommit || !removesOwn)
19197
19197
  return null;
19198
- return { ownLeafIndex: own, committerLeafIndex: committer };
19198
+ return { ownLeafIndex: own2, committerLeafIndex: committer };
19199
19199
  } catch {
19200
19200
  return null;
19201
19201
  }
@@ -20057,6 +20057,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20057
20057
  }
20058
20058
  return { ownLeafIndex: this.ownLeafIndex(mgr), leaves };
20059
20059
  }
20060
+ /**
20061
+ * MCP client (bridge Task 16; audit H-2, round 2 H-D): a SYNCHRONOUS, read-only snapshot of the WARM in-memory
20062
+ * manager for `channelId`: its group id, its epoch and EVERY non-blank leaf, reported exactly as
20063
+ * {@link leafTreeSummary} reports them (non-basic credentials included, identity null, #771 HIGH-B). All three are
20064
+ * read from ONE `state` object (MLSGroupManager replaces `state` whole on every commit), with no await, so they belong
20065
+ * to the same epoch and no commit can land between them.
20066
+ *
20067
+ * `null` when no manager is warm: it never loads from the store, never takes the channel lock, never touches the
20068
+ * network and never mutates. A caller treating `null` as "cannot tell" fails closed.
20069
+ */
20070
+ leafSnapshotCached(channelId) {
20071
+ const state = this.managers.get(channelId)?.state;
20072
+ const tree = state?.ratchetTree;
20073
+ if (!state || !tree)
20074
+ return null;
20075
+ const leaves = [];
20076
+ const numLeaves = Math.floor((tree.length + 1) / 2);
20077
+ for (let i = 0; i < numLeaves; i++) {
20078
+ const node = tree[i * 2];
20079
+ if (!node || node.nodeType !== "leaf")
20080
+ continue;
20081
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(i));
20082
+ const basic = cred.credentialType === "basic";
20083
+ leaves.push({ leafIndex: i, basic, identity: basic ? new TextDecoder().decode(cred.identity) : null });
20084
+ }
20085
+ return { groupId: new TextDecoder().decode(state.groupContext.groupId), epoch: Number(state.groupContext.epoch), leaves };
20086
+ }
20060
20087
  async fetchDeviceLeafCredential(deviceId) {
20061
20088
  const kps = await this.o.backend.fetchKeyPackages([deviceId]);
20062
20089
  const hex = kps[deviceId];
@@ -27390,10 +27417,12 @@ function openSchema(db) {
27390
27417
  ensureRuntimeReplyIntent(db);
27391
27418
  ensureRuntimeReplyIntentEnvelopeGroup(db);
27392
27419
  ensureRuntimeReplyIntentCredit(db);
27420
+ ensureRuntimeReplyIntentMcpTurn(db);
27393
27421
  ensureGroupLink(db);
27394
27422
  ensureMlsCustody(db);
27395
27423
  ensureToolGrantTables(db);
27396
27424
  ensureToolGrantAppliedTable(db);
27425
+ ensureToolGrantMcpSeenTable(db);
27397
27426
  ensureRoomSearchCapTable(db);
27398
27427
  ensureVoiceChannelParentTable(db);
27399
27428
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
@@ -27483,6 +27512,19 @@ function ensureRuntimeReplyIntentCredit(db) {
27483
27512
  throw e;
27484
27513
  }
27485
27514
  }
27515
+ function ensureRuntimeReplyIntentMcpTurn(db) {
27516
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "mcp_turn");
27517
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
27518
+ if (cols.length === 0 || cols.some((c) => c.name === "mcp_turn"))
27519
+ return;
27520
+ try {
27521
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN mcp_turn INTEGER NOT NULL DEFAULT 0");
27522
+ } catch (e) {
27523
+ if (hasColumn())
27524
+ return;
27525
+ throw e;
27526
+ }
27527
+ }
27486
27528
  var RECALL_STATE_COLUMNS = [
27487
27529
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
27488
27530
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -27594,6 +27636,15 @@ function ensureToolGrantAppliedTable(db) {
27594
27636
  );
27595
27637
  `);
27596
27638
  }
27639
+ function ensureToolGrantMcpSeenTable(db) {
27640
+ db.exec(`
27641
+ CREATE TABLE IF NOT EXISTS tool_grant_mcp_seen (
27642
+ identity_id TEXT PRIMARY KEY,
27643
+ sealed_state BLOB NOT NULL,
27644
+ updated_at INTEGER NOT NULL
27645
+ );
27646
+ `);
27647
+ }
27597
27648
  function ensureVoiceChannelParentTable(db) {
27598
27649
  db.exec(`
27599
27650
  CREATE TABLE IF NOT EXISTS voice_channel_parent (
@@ -28534,8 +28585,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28534
28585
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
28535
28586
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
28536
28587
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28537
- sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
28538
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
28588
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit, mcp_turn)
28589
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null, input.mcpTurn === true ? 1 : 0);
28539
28590
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
28540
28591
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
28541
28592
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -28545,7 +28596,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28545
28596
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
28546
28597
  }
28547
28598
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28548
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28599
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
28549
28600
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
28550
28601
  if (!row)
28551
28602
  throw new Error("reply intent vanished after claim");
@@ -28554,7 +28605,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28554
28605
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
28555
28606
  async listReplyIntents(inReplyTo) {
28556
28607
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28557
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28608
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
28558
28609
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
28559
28610
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
28560
28611
  const out = [];
@@ -28565,7 +28616,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28565
28616
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
28566
28617
  async getReplyIntent(scope, part) {
28567
28618
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28568
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28619
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
28569
28620
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
28570
28621
  return row ? this.openReplyIntent(row) : null;
28571
28622
  }
@@ -28640,7 +28691,9 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28640
28691
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
28641
28692
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
28642
28693
  createdAt: Number(row.created_at),
28643
- replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
28694
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null,
28695
+ // Any value other than exactly 1 (0, NULL, a missing column) is false; the bridge's MAC decides what a 1 means.
28696
+ mcpTurn: Number(row.mcp_turn ?? 0) === 1
28644
28697
  };
28645
28698
  }
28646
28699
  async recordMemory(input) {
@@ -29212,10 +29265,10 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
29212
29265
  const r = this.db.prepare("SELECT owner_size_bytes AS b, owner_item_count AS n FROM agent_memory_state WHERE identity_id=?").get(this.id);
29213
29266
  if (r && Number(r.b) >= 0 && Number(r.n) >= 0)
29214
29267
  return { items: Number(r.n), bytes: Number(r.b) };
29215
- const own = this.scopeLoad("i.scope_kind='owner_dm'");
29268
+ const own2 = this.scopeLoad("i.scope_kind='owner_dm'");
29216
29269
  this.ensureStateRow();
29217
- this.db.prepare("UPDATE agent_memory_state SET owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(own.bytes, own.items, this.id);
29218
- return own;
29270
+ this.db.prepare("UPDATE agent_memory_state SET owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(own2.bytes, own2.items, this.id);
29271
+ return own2;
29219
29272
  }
29220
29273
  /** Inside a transaction: delete one origin group (the row, or the parent and its chunks) and its token rows; returns the ids and the accounts it held. */
29221
29274
  deleteItemGroup(firstItemId) {
@@ -29389,8 +29442,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
29389
29442
  const o2 = this.overCap();
29390
29443
  if (!o2.overItems && !o2.overBytes)
29391
29444
  break;
29392
- const own = this.ownerLoad();
29393
- const protectOwner = (!o2.overItems || own.items < this.itemCap * OWNER_DM_FLOOR_SHARE) && (!o2.overBytes || own.bytes < this.byteCap * OWNER_DM_FLOOR_SHARE);
29445
+ const own2 = this.ownerLoad();
29446
+ const protectOwner = (!o2.overItems || own2.items < this.itemCap * OWNER_DM_FLOOR_SHARE) && (!o2.overBytes || own2.bytes < this.byteCap * OWNER_DM_FLOOR_SHARE);
29394
29447
  const v = (protectOwner ? this.oldestAutoGroup({ notOwnerDm: true }) : void 0) ?? this.oldestAutoGroup({});
29395
29448
  if (v === void 0 || !drop(v))
29396
29449
  break;
@@ -29417,8 +29470,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
29417
29470
  FROM agent_memory_item i WHERE identity_id=?`).get(this.id);
29418
29471
  const tk = this.db.prepare("SELECT COUNT(*) AS n FROM agent_memory_token WHERE identity_id=?").get(this.id);
29419
29472
  const size = Number(it.b) + Number(it.rows) * ITEM_ROW_OVERHEAD_BYTES + Number(tk.n) * TOKEN_ROW_OVERHEAD_BYTES;
29420
- const own = this.scopeLoad("i.scope_kind='owner_dm'");
29421
- this.db.prepare("UPDATE agent_memory_state SET size_bytes=?, item_count=?, owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(size, Number(it.n), own.bytes, own.items, this.id);
29473
+ const own2 = this.scopeLoad("i.scope_kind='owner_dm'");
29474
+ this.db.prepare("UPDATE agent_memory_state SET size_bytes=?, item_count=?, owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(size, Number(it.n), own2.bytes, own2.items, this.id);
29422
29475
  }
29423
29476
  /** Inside a transaction: delete every unified row of the identity and move each channel's content epoch. */
29424
29477
  deleteAllItems() {
@@ -30234,9 +30287,40 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
30234
30287
  dropToolGrantAppliedExcept(pinKeyB64) {
30235
30288
  return Number(this.db.prepare("DELETE FROM tool_grant_applied WHERE identity_id=? AND pin_key_b64<>?").run(this.id, pinKeyB64).changes);
30236
30289
  }
30290
+ // MCP client Phase 1, Task 14 (OQ-3 ruling item 3): the sticky R-3 flag, per agent identity.
30291
+ /**
30292
+ * False when never written. A row that does not open, or opens to anything but this identity's own record, THROWS:
30293
+ * the bridge then holds R-3 (fail closed). Never returns false for an unreadable row.
30294
+ */
30295
+ async loadMcpSeen() {
30296
+ const row = this.db.prepare("SELECT sealed_state FROM tool_grant_mcp_seen WHERE identity_id=?").get(this.id);
30297
+ if (!row)
30298
+ return false;
30299
+ const v = await this.unsealJson(row.sealed_state);
30300
+ if (v === null || typeof v !== "object" || v.identity_id !== this.id || typeof v.seen !== "boolean") {
30301
+ throw new MlsStoreSealError("tool_grant_mcp_seen: unexpected record");
30302
+ }
30303
+ return v.seen;
30304
+ }
30305
+ /** The sealed value names its identity, so a blob copied to another agent's row does not read as that agent's. */
30306
+ async saveMcpSeen(seen) {
30307
+ const sealed = await this.sealJson({ identity_id: this.id, seen });
30308
+ this.db.prepare(`INSERT INTO tool_grant_mcp_seen (identity_id, sealed_state, updated_at) VALUES (?, ?, ?)
30309
+ ON CONFLICT(identity_id) DO UPDATE SET sealed_state = excluded.sealed_state, updated_at = excluded.updated_at`).run(this.id, sealed, Date.now());
30310
+ }
30237
30311
  hasOwnerMessage(channelId, messageId) {
30238
30312
  return this.db.prepare("SELECT 1 FROM owner_replay WHERE identity_id=? AND channel_id=? AND message_id=?").get(this.id, channelId, messageId) !== void 0;
30239
30313
  }
30314
+ /**
30315
+ * MCP client Phase 1, Task 18 (audit M-3): the owner replay row for a verified owner message that is NEVER staged for
30316
+ * the runtime (an `mcp_catalog_request`), so a redelivery of it is classed `replay`, after a restart too. The same row
30317
+ * stageRuntimeInbox writes in its staging transaction; insert-if-absent; pruned by pruneOwnerReplay like any other.
30318
+ */
30319
+ recordOwnerMessage(channelId, messageId, groupId, epoch, processedAt = Date.now()) {
30320
+ this.db.prepare(`INSERT OR IGNORE INTO owner_replay
30321
+ (identity_id, channel_id, message_id, group_id, epoch, processed_at)
30322
+ VALUES (?, ?, ?, ?, ?, ?)`).run(this.id, channelId, messageId, groupId, epoch, processedAt);
30323
+ }
30240
30324
  isOwnerEpochClosed(channelId, groupId, epoch) {
30241
30325
  return this.db.prepare("SELECT 1 FROM owner_replay_closed_epoch WHERE identity_id=? AND channel_id=? AND group_id=? AND epoch=?").get(this.id, channelId, groupId, epoch) !== void 0;
30242
30326
  }
@@ -30410,8 +30494,9 @@ function decodeReplyBody(bytes) {
30410
30494
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
30411
30495
  throw new Error("reply intent body is malformed");
30412
30496
  }
30413
- function replyBodyMac(key, replyId, channelId, body, credit) {
30414
- return createHmac("sha256", key).update(JSON.stringify(credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit])).update("\0").update(body).digest("hex");
30497
+ function replyBodyMac(key, replyId, channelId, body, credit, mcpTurn) {
30498
+ const label = mcpTurn === true ? ["reply-body", replyId, channelId, credit ?? null, "mcp_turn"] : credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit];
30499
+ return createHmac("sha256", key).update(JSON.stringify(label)).update("\0").update(body).digest("hex");
30415
30500
  }
30416
30501
  function partOrdinal(part) {
30417
30502
  const m = /^p:(\d+)$/.exec(part);
@@ -32117,8 +32202,8 @@ var MembershipPurgeSweeper = class {
32117
32202
  const dms = /* @__PURE__ */ new Set();
32118
32203
  for (const c of this.d.conversations()) {
32119
32204
  if (stopping()) return { purged: [], held: 0, absent: 0 };
32120
- const own = this.d.enclaveOf(c);
32121
- const channels = own === void 0 ? void 0 : listed.get(own);
32205
+ const own2 = this.d.enclaveOf(c);
32206
+ const channels = own2 === void 0 ? void 0 : listed.get(own2);
32122
32207
  if (channels === void 0) continue;
32123
32208
  if (channels.has(c)) {
32124
32209
  this.d.clearAbsence(c);
@@ -35293,7 +35378,7 @@ function assertManagedName(name) {
35293
35378
  }
35294
35379
  function isLocalOnlyName(name) {
35295
35380
  const n = name.toLowerCase().replace(/[. ]+$/, "");
35296
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets" || n === MANIFEST_POLICY_FILE;
35381
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets" || n === MANIFEST_POLICY_FILE || n === MCP_POLICY_FILE;
35297
35382
  }
35298
35383
  var MANIFEST_POLICY_FILE = "manifest-policy.json";
35299
35384
  function removeManagedLlmSource(home, identityId, deps = REAL_FS) {
@@ -35312,6 +35397,28 @@ function writeManifestPolicy(home, identityId, policy, deps = REAL_FS) {
35312
35397
  }
35313
35398
  atomicWrite(join19(dir, MANIFEST_POLICY_FILE), JSON.stringify(policy) + "\n", deps);
35314
35399
  }
35400
+ var MCP_POLICY_FILE = "mcp-policy.json";
35401
+ var MCP_POLICY_TOP_STATES = Object.freeze([
35402
+ "ok",
35403
+ "not_entitled",
35404
+ "kill_switch",
35405
+ "suspended",
35406
+ "no_config",
35407
+ "switch_off",
35408
+ "no_manifest",
35409
+ "malformed",
35410
+ "no_capability",
35411
+ "section_absent"
35412
+ ]);
35413
+ function writeMcpPolicy(home, identityId, doc, deps = REAL_FS) {
35414
+ const parent3 = join19(home, ".securecoms", "runtime-config");
35415
+ const dir = runtimeConfigDir(home, identityId);
35416
+ for (const d of [join19(home, ".securecoms"), parent3, dir]) {
35417
+ deps.mkdir(d, 448);
35418
+ deps.chmod(d, 448);
35419
+ }
35420
+ atomicWrite(join19(dir, MCP_POLICY_FILE), JSON.stringify(doc) + "\n", deps);
35421
+ }
35315
35422
  function atomicWrite(path, body, deps) {
35316
35423
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
35317
35424
  deps.writeFile(tmp, body, 384);
@@ -35589,6 +35696,195 @@ import { sha256 as sha25611 } from "@noble/hashes/sha256";
35589
35696
 
35590
35697
  // src/grants/manifest.ts
35591
35698
  import { ed25519 as ed255195 } from "@noble/curves/ed25519";
35699
+
35700
+ // src/grants/mcp-section.ts
35701
+ var MCP_MAX_SERVERS = 8;
35702
+ var MCP_MAX_TOOLS_PER_SERVER = 64;
35703
+ var MCP_MAX_SIGNED_TOOLS = 160;
35704
+ var MCP_MAX_REMOVED = 8;
35705
+ var SECURECOMS_CIMD_CLIENT_ID = "https://app.securecoms.ai/oauth/securecoms-agent-client.json";
35706
+ var MCP_SERVER_ID = /^[a-z0-9-]{1,24}$/;
35707
+ var MCP_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
35708
+ var VENDOR_KEY = /^[a-z0-9_]{1,32}$/;
35709
+ var MCP_RESERVED_NAMES = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
35710
+ var HEX64 = /^[0-9a-f]{64}$/;
35711
+ var PRINTABLE_ASCII = /^[\x21-\x7e]{1,256}$/;
35712
+ var MCP_GRANT = /^mcp:([a-z0-9-]{1,24})\/([A-Za-z0-9_.-]{1,64})$/;
35713
+ var isBag = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
35714
+ var keysExactly = (o, keys) => {
35715
+ const have = Object.keys(o);
35716
+ return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(o, k));
35717
+ };
35718
+ function canonicalHttpsUrl(v) {
35719
+ if (typeof v !== "string" || !PRINTABLE_ASCII.test(v) || !URL.canParse(v)) return false;
35720
+ const u = new URL(v);
35721
+ return u.protocol === "https:" && u.username === "" && u.password === "" && u.href === v;
35722
+ }
35723
+ function httpsIssuer(v) {
35724
+ if (typeof v !== "string" || !PRINTABLE_ASCII.test(v) || !URL.canParse(v)) return false;
35725
+ const u = new URL(v);
35726
+ return u.protocol === "https:" && u.username === "" && u.password === "" && u.search === "" && u.hash === "" && (u.href === v || u.href === `${v}/`);
35727
+ }
35728
+ var MCP_AUTH_KEYS = Object.freeze({
35729
+ oauth_securecoms_cimd: Object.freeze(["mode", "issuer", "client"]),
35730
+ oauth_customer: Object.freeze(["mode", "issuer", "client", "client_id"]),
35731
+ key: Object.freeze(["mode"])
35732
+ });
35733
+ function parseAuth(v) {
35734
+ if (!isBag(v)) return null;
35735
+ if (v.mode === "key") return keysExactly(v, MCP_AUTH_KEYS.key) ? Object.freeze({ mode: "key" }) : null;
35736
+ if (v.mode !== "oauth" || !httpsIssuer(v.issuer)) return null;
35737
+ if (v.client === "securecoms_cimd") {
35738
+ if (!keysExactly(v, MCP_AUTH_KEYS.oauth_securecoms_cimd)) return null;
35739
+ return Object.freeze({ mode: "oauth", issuer: v.issuer, client: "securecoms_cimd" });
35740
+ }
35741
+ if (v.client === "customer") {
35742
+ if (!keysExactly(v, MCP_AUTH_KEYS.oauth_customer)) return null;
35743
+ if (typeof v.client_id !== "string" || !PRINTABLE_ASCII.test(v.client_id)) return null;
35744
+ return Object.freeze({ mode: "oauth", issuer: v.issuer, client: "customer", client_id: v.client_id });
35745
+ }
35746
+ return null;
35747
+ }
35748
+ function parseTools(v) {
35749
+ if (!isBag(v)) return null;
35750
+ const entries = Object.entries(v);
35751
+ if (entries.length > MCP_MAX_TOOLS_PER_SERVER) return null;
35752
+ const out = /* @__PURE__ */ Object.create(null);
35753
+ for (const [name, t] of entries) {
35754
+ if (!MCP_TOOL_NAME.test(name) || MCP_RESERVED_NAMES.has(name) || !isBag(t) || !keysExactly(t, ["sha256", "class"])) return null;
35755
+ if (typeof t.sha256 !== "string" || !HEX64.test(t.sha256) || t.class !== "read") return null;
35756
+ out[name] = Object.freeze({ sha256: t.sha256, class: "read" });
35757
+ }
35758
+ return out;
35759
+ }
35760
+ function parseServer(v) {
35761
+ if (!isBag(v) || !keysExactly(v, ["vendor", "url", "auth", "tools"])) return null;
35762
+ if (typeof v.vendor !== "string" || !VENDOR_KEY.test(v.vendor) || !canonicalHttpsUrl(v.url)) return null;
35763
+ const auth = parseAuth(v.auth);
35764
+ const tools = parseTools(v.tools);
35765
+ if (auth === null || tools === null) return null;
35766
+ return Object.freeze({ vendor: v.vendor, url: v.url, auth, tools: Object.freeze(tools) });
35767
+ }
35768
+ function parseRemoved(v, servers) {
35769
+ if (v === "*") return Object.keys(servers).length === 0 ? "*" : null;
35770
+ if (!Array.isArray(v) || v.length < 1 || v.length > MCP_MAX_REMOVED) return null;
35771
+ const seen = /* @__PURE__ */ new Set();
35772
+ for (const id of v) {
35773
+ if (typeof id !== "string" || !MCP_SERVER_ID.test(id) || MCP_RESERVED_NAMES.has(id)) return null;
35774
+ if (seen.has(id) || Object.prototype.hasOwnProperty.call(servers, id)) return null;
35775
+ seen.add(id);
35776
+ }
35777
+ return Object.freeze([...seen]);
35778
+ }
35779
+ var NOT_REMOVED = Object.freeze([]);
35780
+ function parseMcp(v) {
35781
+ if (!isBag(v) || !(keysExactly(v, ["servers"]) || keysExactly(v, ["servers", "removed"])) || !isBag(v.servers)) return null;
35782
+ const entries = Object.entries(v.servers);
35783
+ if (entries.length > MCP_MAX_SERVERS) return null;
35784
+ const servers = /* @__PURE__ */ Object.create(null);
35785
+ let signedTools = 0;
35786
+ for (const [id, raw] of entries) {
35787
+ if (!MCP_SERVER_ID.test(id) || MCP_RESERVED_NAMES.has(id)) return null;
35788
+ const s = parseServer(raw);
35789
+ if (s === null) return null;
35790
+ signedTools += Object.keys(s.tools).length;
35791
+ if (signedTools > MCP_MAX_SIGNED_TOOLS) return null;
35792
+ servers[id] = s;
35793
+ }
35794
+ let removed = NOT_REMOVED;
35795
+ if (Object.prototype.hasOwnProperty.call(v, "removed")) {
35796
+ const r = parseRemoved(v.removed, servers);
35797
+ if (r === null) return null;
35798
+ removed = r;
35799
+ }
35800
+ return Object.freeze({ servers: Object.freeze(servers), removed });
35801
+ }
35802
+ function mcpServerCatalogRow(s, catalog) {
35803
+ if (!Object.prototype.hasOwnProperty.call(catalog, s.vendor)) return null;
35804
+ const row = catalog[s.vendor];
35805
+ if (s.url !== row.readOnlyUrl) return null;
35806
+ if (s.auth.mode === "oauth" && s.auth.issuer !== row.issuer) return null;
35807
+ return row;
35808
+ }
35809
+ function parseMcpGrant(name) {
35810
+ const g = MCP_GRANT.exec(name);
35811
+ return g === null ? null : { serverId: g[1], tool: g[2] };
35812
+ }
35813
+ function mcpGrantWithinCeiling(name, mcp, catalog) {
35814
+ const g = parseMcpGrant(name);
35815
+ if (g === null || mcp === null || !Object.prototype.hasOwnProperty.call(mcp.servers, g.serverId)) return false;
35816
+ const s = mcp.servers[g.serverId];
35817
+ if (!Object.prototype.hasOwnProperty.call(s.tools, g.tool)) return false;
35818
+ const row = mcpServerCatalogRow(s, catalog);
35819
+ return row !== null && row.readTools.includes(g.tool);
35820
+ }
35821
+
35822
+ // src/grants/mcp-catalog.ts
35823
+ var LINEAR = Object.freeze({
35824
+ displayName: "Linear",
35825
+ host: "mcp.linear.app",
35826
+ readOnlyUrl: "https://mcp.linear.app/mcp/readonly",
35827
+ readTools: Object.freeze([
35828
+ "get_attachment",
35829
+ "list_agent_skills",
35830
+ "get_agent_skill",
35831
+ "list_comments",
35832
+ "list_cycles",
35833
+ "list_custom_views",
35834
+ "get_document",
35835
+ "list_documents",
35836
+ "extract_images",
35837
+ "get_issue",
35838
+ "list_issue_statuses",
35839
+ "get_issue_status",
35840
+ "list_issue_labels",
35841
+ "list_projects",
35842
+ "get_project",
35843
+ "list_project_labels",
35844
+ "list_release_pipelines",
35845
+ "list_releases",
35846
+ "get_release",
35847
+ "list_release_notes",
35848
+ "get_release_note",
35849
+ "list_milestones",
35850
+ "get_milestone",
35851
+ "get_notifications",
35852
+ "list_teams",
35853
+ "get_team",
35854
+ "get_triage_responsibility",
35855
+ "list_templates",
35856
+ "get_template",
35857
+ "list_users",
35858
+ "get_user",
35859
+ "get_workspace",
35860
+ "search_documentation",
35861
+ "get_diff",
35862
+ "list_diffs",
35863
+ "get_diff_threads",
35864
+ "list_initiatives",
35865
+ "get_initiative",
35866
+ "list_initiative_labels",
35867
+ "get_status_updates"
35868
+ ]),
35869
+ scopes: Object.freeze(["read"]),
35870
+ issuer: "https://mcp.linear.app",
35871
+ authorizationEndpoint: "https://mcp.linear.app/authorize",
35872
+ tokenEndpoint: "https://mcp.linear.app/token",
35873
+ revocationEndpoint: "https://mcp.linear.app/token",
35874
+ docs: Object.freeze({
35875
+ host: "https://linear.app/docs/mcp",
35876
+ readOnlyUrl: "https://linear.app/docs/mcp",
35877
+ readTools: "https://linear.app/docs/mcp",
35878
+ scopes: "https://linear.app/docs/mcp",
35879
+ issuer: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35880
+ authorizationEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35881
+ tokenEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35882
+ revocationEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server"
35883
+ })
35884
+ });
35885
+ var MCP_CATALOG = Object.freeze({ linear: LINEAR });
35886
+
35887
+ // src/grants/manifest.ts
35592
35888
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
35593
35889
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
35594
35890
  owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
@@ -35613,9 +35909,9 @@ function wellTyped(v) {
35613
35909
  return false;
35614
35910
  }
35615
35911
  var WRAPPER_KEYS = ["algo", "manifest", "signature"];
35616
- var HEX64 = /^[0-9a-f]{64}$/;
35617
- var PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
35618
- var absolutePath = (v) => typeof v === "string" && v.startsWith("/") && PRINTABLE_ASCII.test(v);
35912
+ var HEX642 = /^[0-9a-f]{64}$/;
35913
+ var PRINTABLE_ASCII2 = /^[\x21-\x7e]+$/;
35914
+ var absolutePath = (v) => typeof v === "string" && v.startsWith("/") && PRINTABLE_ASCII2.test(v);
35619
35915
  var exactKeys = (o, required, optional = []) => required.every((k) => k in o) && Object.keys(o).every((k) => required.includes(k) || optional.includes(k));
35620
35916
  var isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
35621
35917
  function parseEndpoints(v) {
@@ -35627,7 +35923,7 @@ function parseEndpoints(v) {
35627
35923
  const e = { ...raw };
35628
35924
  if (e.headersFile === "") delete e.headersFile;
35629
35925
  if (!exactKeys(e, ["baseUrl"], ["headersFile"])) return null;
35630
- if (typeof e.baseUrl !== "string" || e.baseUrl === "" || !PRINTABLE_ASCII.test(e.baseUrl)) return null;
35926
+ if (typeof e.baseUrl !== "string" || e.baseUrl === "" || !PRINTABLE_ASCII2.test(e.baseUrl)) return null;
35631
35927
  let url;
35632
35928
  try {
35633
35929
  url = new URL(e.baseUrl);
@@ -35658,8 +35954,8 @@ function parseDatabases(v) {
35658
35954
  function parseConfig(v) {
35659
35955
  if (!isPlainObject(v)) return null;
35660
35956
  if (!exactKeys(v, ["persona_sha256", "flags", "memory", "legacy_assign_sha256"], ["llm", "search", "tools_allow"])) return null;
35661
- if (typeof v.persona_sha256 !== "string" || !HEX64.test(v.persona_sha256)) return null;
35662
- if (typeof v.legacy_assign_sha256 !== "string" || !HEX64.test(v.legacy_assign_sha256)) return null;
35957
+ if (typeof v.persona_sha256 !== "string" || !HEX642.test(v.persona_sha256)) return null;
35958
+ if (typeof v.legacy_assign_sha256 !== "string" || !HEX642.test(v.legacy_assign_sha256)) return null;
35663
35959
  if (!isStringArray(v.flags)) return null;
35664
35960
  if ("tools_allow" in v && !isStringArray(v.tools_allow)) return null;
35665
35961
  if ("llm" in v) {
@@ -35703,7 +35999,7 @@ function verifyToolGrantManifest(raw, ctx) {
35703
35999
  const m = raw.manifest;
35704
36000
  if (!isPlainObject(m)) return { state: "malformed" };
35705
36001
  if (m.type !== "securecoms.tool-grants" || m.v !== 1) return { state: "malformed" };
35706
- if (!wellTyped(m)) return { state: "malformed" };
36002
+ if (!wellTyped(Object.fromEntries(Object.entries(m).filter(([k]) => k !== "mcp")))) return { state: "malformed" };
35707
36003
  if (typeof m.agent_identity_id !== "string" || typeof m.owner_identity_id !== "string") return { state: "malformed" };
35708
36004
  for (const k of ["owner_dm_channel_id", "owner_dm_relay_identity_id"]) {
35709
36005
  if (k in m && typeof m[k] !== "string") return { state: "malformed" };
@@ -35733,6 +36029,11 @@ function verifyToolGrantManifest(raw, ctx) {
35733
36029
  if (counter === ctx.storedCounter) return { state: "unchanged" };
35734
36030
  if (counter < ctx.storedCounter) return { state: "rollback" };
35735
36031
  }
36032
+ const rawOwnerDm = m.grants.owner_dm;
36033
+ const signedMcpPresent = Array.isArray(rawOwnerDm) && rawOwnerDm.some((t) => t.startsWith("mcp:"));
36034
+ const mcpKey = "mcp" in m ? "present" : "absent";
36035
+ const mcp = mcpKey === "present" ? parseMcp(m.mcp) : null;
36036
+ const catalog = ctx.mcpCatalog ?? MCP_CATALOG;
35736
36037
  let droppedUnknown = false;
35737
36038
  const grants = {};
35738
36039
  for (const [audience, list] of Object.entries(m.grants)) {
@@ -35741,7 +36042,7 @@ function verifyToolGrantManifest(raw, ctx) {
35741
36042
  continue;
35742
36043
  }
35743
36044
  const known = MANIFEST_KNOWN_TOOLS[audience];
35744
- const kept = list.filter((t) => known.includes(t));
36045
+ const kept = list.filter((t) => known.includes(t) || audience === "owner_dm" && mcpGrantWithinCeiling(t, mcp, catalog));
35745
36046
  if (kept.length !== list.length) droppedUnknown = true;
35746
36047
  grants[audience] = Object.freeze(kept);
35747
36048
  }
@@ -35756,7 +36057,10 @@ function verifyToolGrantManifest(raw, ctx) {
35756
36057
  grants: Object.freeze(grants),
35757
36058
  endpoints: Object.freeze(endpoints),
35758
36059
  databases: Object.freeze(databases),
35759
- config
36060
+ config,
36061
+ mcp,
36062
+ mcpKey,
36063
+ signedMcpPresent
35760
36064
  });
35761
36065
  return { state: "ok", manifest, droppedUnknown, wrapper: raw };
35762
36066
  }
@@ -36077,21 +36381,32 @@ var AGENT_TOTAL_KEY = "agent-total";
36077
36381
  // src/grants/effective.ts
36078
36382
  var TOOL_GRANT_CODE_CEILING = MANIFEST_KNOWN_TOOLS;
36079
36383
  var CURRENT_TIME = Object.freeze({ tool: "current_time", mode: "read" });
36384
+ var NO_WITHHELD = Object.freeze([]);
36080
36385
  function effectiveToolGrants(i) {
36081
- if (i.base.length === 0) return { grants: i.base, source: "none", withheldBySearchCap: false };
36082
- if (i.manifest === null) return { grants: i.base, source: "constant", withheldBySearchCap: false };
36386
+ if (i.base.length === 0) return { grants: i.base, source: "none", withheldBySearchCap: false, withheld: NO_WITHHELD };
36387
+ if (i.manifest === null) return { grants: i.base, source: "constant", withheldBySearchCap: false, withheld: NO_WITHHELD };
36083
36388
  const ceiling = TOOL_GRANT_CODE_CEILING[i.audience];
36084
36389
  const listed = new Set(i.manifest.grants[i.audience] ?? []);
36085
36390
  const signed = ceiling.filter((t) => listed.has(t));
36086
36391
  if (!i.eligible || !i.manifestSwitchOn) {
36087
36392
  const fallback = i.base.filter((g) => g.tool !== "current_time" && signed.includes(g.tool));
36088
- return { grants: Object.freeze([CURRENT_TIME, ...fallback]), source: "fallback", withheldBySearchCap: false };
36393
+ return { grants: Object.freeze([CURRENT_TIME, ...fallback]), source: "fallback", withheldBySearchCap: false, withheld: NO_WITHHELD };
36089
36394
  }
36090
36395
  let withheld = false;
36396
+ const dropped = [];
36091
36397
  const tools = signed.filter((t) => {
36092
- if (i.disabled.has(t)) return false;
36093
- if (t === "http_request" && i.signedTargets.endpoints === 0) return false;
36094
- if (t === "sql_query" && i.signedTargets.databases === 0) return false;
36398
+ if (i.disabled.has(t)) {
36399
+ dropped.push(Object.freeze({ tool: t, why: "disabled_by_owner" }));
36400
+ return false;
36401
+ }
36402
+ if (t === "http_request" && i.signedTargets.endpoints === 0) {
36403
+ dropped.push(Object.freeze({ tool: t, why: "no_endpoint" }));
36404
+ return false;
36405
+ }
36406
+ if (t === "sql_query" && i.signedTargets.databases === 0) {
36407
+ dropped.push(Object.freeze({ tool: t, why: "no_database" }));
36408
+ return false;
36409
+ }
36095
36410
  if (i.audience === "team_room" && t === "web_search" && i.roomSearchCapped) {
36096
36411
  withheld = true;
36097
36412
  return false;
@@ -36101,7 +36416,8 @@ function effectiveToolGrants(i) {
36101
36416
  return {
36102
36417
  grants: Object.freeze([CURRENT_TIME, ...tools.map((tool) => Object.freeze({ tool, mode: "read", source: "manifest" }))]),
36103
36418
  source: "manifest",
36104
- withheldBySearchCap: withheld
36419
+ withheldBySearchCap: withheld,
36420
+ withheld: Object.freeze(dropped)
36105
36421
  };
36106
36422
  }
36107
36423
 
@@ -36406,6 +36722,200 @@ function classifyVoiceFrame(f) {
36406
36722
  return b.text.trim() === VOICE_CALL_START_MARKER ? "greeting" : "drop";
36407
36723
  }
36408
36724
 
36725
+ // src/grants/tool-status.ts
36726
+ var TOOL_STATUS_ALLOWLIST = [
36727
+ "current_time",
36728
+ "web_search",
36729
+ "web_extract",
36730
+ "fetch_url",
36731
+ "http_request",
36732
+ "sql_query",
36733
+ "weather_forecast",
36734
+ "currency_convert"
36735
+ ];
36736
+ var ALLOW2 = new Set(TOOL_STATUS_ALLOWLIST);
36737
+ var TOOL_STATUS_BUCKET_MS = 9e5;
36738
+ function bucketStartAt(ms) {
36739
+ return new Date(Math.floor(ms / TOOL_STATUS_BUCKET_MS) * TOOL_STATUS_BUCKET_MS).toISOString().slice(0, 16) + "Z";
36740
+ }
36741
+ function bucketEndMs(at) {
36742
+ return Date.parse(at) + TOOL_STATUS_BUCKET_MS;
36743
+ }
36744
+ var SIGNATURE_FAILURES = /* @__PURE__ */ new Set(["bad_signature", "malformed", "unknown_algo", "no_signature"]);
36745
+ function ownerDmCause(i) {
36746
+ try {
36747
+ const v = i.verdict;
36748
+ if (v.kind === "failed" && v.reason === "replay") return null;
36749
+ if (v.kind !== "verified" && v.kind !== "constants_only" && v.kind !== "failed") return null;
36750
+ const failedReason = v.kind === "failed" ? v.reason : void 0;
36751
+ const blocked = i.killSwitchOff || i.pinMissingOrAnomalous || i.senderNotOwner || i.stale || v.kind === "failed";
36752
+ const eligible = v.kind === "verified" && i.signedDmChannel === i.channelId && i.boundNow && i.consumerAttached;
36753
+ const source = blocked ? "none" : !i.manifestPresent ? "constant" : !eligible || i.manifestSwitchOff ? "fallback" : "manifest";
36754
+ const at = (cause, sub) => sub === void 0 ? { source, cause } : { source, cause, sub };
36755
+ if (i.killSwitchOff) return at("owner_dm_unavailable", "kill_switch");
36756
+ if (i.manifestSwitchOff) return at("manifest_switch_off");
36757
+ if (i.pinMissingOrAnomalous) return at("owner_dm_unavailable", "pin");
36758
+ if (i.senderNotOwner) return at("owner_dm_unavailable", "sender_not_owner");
36759
+ if (i.stale) return at("owner_dm_unavailable", "stale");
36760
+ if (failedReason === "not_owner_dm_leaves") return at("unexpected_member_in_dm");
36761
+ if (failedReason !== void 0 && SIGNATURE_FAILURES.has(failedReason)) return at("owner_turn_failed");
36762
+ if (failedReason === "verify_error") return at("agent_state_error");
36763
+ if (failedReason === "wrong_dm_channel" || v.kind === "verified" && i.manifestPresent && i.signedDmChannel !== void 0 && i.signedDmChannel !== i.channelId) {
36764
+ return at("dm_channel_mismatch");
36765
+ }
36766
+ if (failedReason !== void 0) return null;
36767
+ if (i.manifestPresent && i.signedDmChannel === void 0) return at("dm_not_linked");
36768
+ if (v.kind === "constants_only") return at("unsigned_relay_in_dm");
36769
+ if (!i.manifestPresent || !i.boundNow) return at("manifest_not_bound");
36770
+ if (!i.consumerAttached) return at("no_runtime_consumer");
36771
+ return source === "manifest" ? { source } : null;
36772
+ } catch {
36773
+ return null;
36774
+ }
36775
+ }
36776
+ function sameTuple(a, b) {
36777
+ if (!a || !b) return false;
36778
+ return a.source === b.source && a.cause === b.cause && a.sub === b.sub && a.relay === b.relay && a.mcp_issued === b.mcp_issued && a.counter === b.counter && a.issued.length === b.issued.length && a.issued.every((t, k) => t === b.issued[k]) && a.withheld.length === b.withheld.length && a.withheld.every((w, k) => w.tool === b.withheld[k].tool && w.why === b.withheld[k].why);
36779
+ }
36780
+ function sanitizeIssued(names) {
36781
+ return [...new Set(names.filter((n) => ALLOW2.has(n)))].sort();
36782
+ }
36783
+
36784
+ // src/tool-status-sender.ts
36785
+ var TOOL_STATUS_JITTER_MS = 6e4;
36786
+ var RETRY_AFTER_FLOOR_MS = 6e4;
36787
+ function defaultToolStatusSetTimer(fn, ms) {
36788
+ const t = setTimeout(fn, ms);
36789
+ t.unref?.();
36790
+ return t;
36791
+ }
36792
+ function defaultToolStatusClearTimer(h) {
36793
+ clearTimeout(h);
36794
+ }
36795
+ function retryAfterMs(raw) {
36796
+ if (typeof raw !== "string" || !/^\d{1,9}$/.test(raw.trim())) return RETRY_AFTER_FLOOR_MS;
36797
+ return Math.max(RETRY_AFTER_FLOOR_MS, Number(raw.trim()) * 1e3);
36798
+ }
36799
+ var ToolStatusSender = class {
36800
+ postFn;
36801
+ now;
36802
+ setTimer;
36803
+ clearTimer;
36804
+ random;
36805
+ onFailure;
36806
+ /** The latest noted record while its bucket is still open. */
36807
+ open = null;
36808
+ /** The latest noted record whose bucket has closed: the only record an attempt or carry() may send. */
36809
+ closed = null;
36810
+ lastPosted = null;
36811
+ terminalFor = null;
36812
+ notBefore = Number.NEGATIVE_INFINITY;
36813
+ timer = null;
36814
+ inFlight = false;
36815
+ shut = false;
36816
+ constructor(o) {
36817
+ this.postFn = o.post;
36818
+ this.now = o.now ?? (() => Date.now());
36819
+ this.setTimer = o.setTimer ?? defaultToolStatusSetTimer;
36820
+ this.clearTimer = o.clearTimer ?? defaultToolStatusClearTimer;
36821
+ this.random = o.random ?? Math.random;
36822
+ this.onFailure = o.onFailure ?? (() => {
36823
+ });
36824
+ }
36825
+ note(rec) {
36826
+ if (this.shut) return;
36827
+ try {
36828
+ this.promote(this.now());
36829
+ this.open = rec;
36830
+ this.schedule();
36831
+ } catch {
36832
+ }
36833
+ }
36834
+ /** Ruling M-2: what the apply-time report carries. The closed record only; an open bucket is never revealed early. */
36835
+ carry() {
36836
+ try {
36837
+ this.promote(this.now());
36838
+ } catch {
36839
+ }
36840
+ return this.closed;
36841
+ }
36842
+ /** R2-M1: the record belongs to an owner who is no longer the manifest's; forget it and what was posted. */
36843
+ drop() {
36844
+ this.open = null;
36845
+ this.closed = null;
36846
+ this.lastPosted = null;
36847
+ this.terminalFor = null;
36848
+ this.cancelTimer();
36849
+ }
36850
+ close() {
36851
+ this.shut = true;
36852
+ this.cancelTimer();
36853
+ }
36854
+ cancelTimer() {
36855
+ if (this.timer !== null) this.clearTimer(this.timer);
36856
+ this.timer = null;
36857
+ }
36858
+ promote(t) {
36859
+ if (this.open !== null && bucketEndMs(this.open.at) <= t) {
36860
+ this.closed = this.open;
36861
+ this.open = null;
36862
+ }
36863
+ }
36864
+ pending() {
36865
+ return this.closed !== null && !sameTuple(this.closed, this.lastPosted) && !sameTuple(this.closed, this.terminalFor);
36866
+ }
36867
+ schedule() {
36868
+ if (this.shut || this.inFlight || this.timer !== null) return;
36869
+ if (this.open === null && !this.pending()) return;
36870
+ const t = this.now();
36871
+ let b = (Math.floor(t / TOOL_STATUS_BUCKET_MS) + 1) * TOOL_STATUS_BUCKET_MS;
36872
+ while (b < this.notBefore) b += TOOL_STATUS_BUCKET_MS;
36873
+ const jitter = Math.floor(this.random() * TOOL_STATUS_JITTER_MS);
36874
+ this.timer = this.setTimer(() => {
36875
+ this.timer = null;
36876
+ this.fire();
36877
+ }, b - t + jitter);
36878
+ }
36879
+ fire() {
36880
+ try {
36881
+ if (this.shut) return;
36882
+ this.promote(this.now());
36883
+ if (this.pending()) {
36884
+ void this.attempt(this.closed);
36885
+ return;
36886
+ }
36887
+ this.schedule();
36888
+ } catch {
36889
+ }
36890
+ }
36891
+ async attempt(rec) {
36892
+ this.inFlight = true;
36893
+ const attemptAt = this.now();
36894
+ let result;
36895
+ try {
36896
+ result = await this.postFn(rec);
36897
+ } catch {
36898
+ result = "network";
36899
+ }
36900
+ this.inFlight = false;
36901
+ try {
36902
+ if (typeof result === "object" && result.status >= 200 && result.status < 300) {
36903
+ this.lastPosted = rec;
36904
+ } else if (typeof result === "object" && result.status === 429) {
36905
+ this.notBefore = attemptAt + retryAfterMs(result.retryAfter);
36906
+ this.onFailure("http_error");
36907
+ } else if (typeof result === "object" && result.status >= 400 && result.status < 500) {
36908
+ this.terminalFor = rec;
36909
+ this.onFailure("http_error");
36910
+ } else {
36911
+ this.onFailure(typeof result === "object" ? "http_error" : result);
36912
+ }
36913
+ this.schedule();
36914
+ } catch {
36915
+ }
36916
+ }
36917
+ };
36918
+
36409
36919
  // src/grants/owner-turn.ts
36410
36920
  import { ed25519 as ed255196 } from "@noble/curves/ed25519";
36411
36921
  var HEX = /^(?:[0-9a-fA-F]{2})+$/;
@@ -36449,6 +36959,310 @@ function classifyOwnerDmLeaves(leaves, allowed) {
36449
36959
  }
36450
36960
  return "foreign";
36451
36961
  }
36962
+ function ownerDmLeavesRelayFree(leaves, ids) {
36963
+ let owner = false;
36964
+ let agent = false;
36965
+ for (const l of leaves) {
36966
+ if (!l.basic || l.identity === null) return false;
36967
+ if (l.identity === ids.ownerIdentityId) owner = true;
36968
+ else if (l.identity === ids.agentIdentityId) agent = true;
36969
+ else return false;
36970
+ }
36971
+ return owner && agent;
36972
+ }
36973
+ function relayLeafFromPhase0Relay(relay) {
36974
+ return relay !== "none";
36975
+ }
36976
+ function ownerDmRelayOf(leafClass, leaves, ids) {
36977
+ if (leafClass === "unsigned_relay") return "unsigned";
36978
+ if (ownerDmLeavesRelayFree(leaves, ids)) return "none";
36979
+ if (ids.relayIdentityId !== void 0 && leaves.some((l) => l.identity === ids.relayIdentityId)) return "signed";
36980
+ return void 0;
36981
+ }
36982
+
36983
+ // src/grants/mcp-policy.ts
36984
+ var MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36985
+ function mcpEntitlementReading(mcpEntitled, mcpCause) {
36986
+ if (mcpEntitled === true && mcpCause === "entitled") return "entitled";
36987
+ if (mcpEntitled === false && mcpCause === "not_entitled") return "not_entitled";
36988
+ return "suspended";
36989
+ }
36990
+ function mcpGrantsHostSwitch(env, log = writeServiceLog) {
36991
+ const v = env.SECURECOMS_MCP_GRANTS;
36992
+ if (v === void 0 || v === "on") return "on";
36993
+ if (v === "off") return "off";
36994
+ log("warn", "config.mcp_grants_switch_suspended", { state: ServiceLogEnum.of("suspended", ["suspended"]) });
36995
+ return "suspended";
36996
+ }
36997
+ var empty = (state, r3Hold) => Object.freeze({ v: 1, state, r3_hold: r3Hold, servers: Object.freeze({}), remove: NO_REMOVE });
36998
+ var NO_REMOVE = Object.freeze([]);
36999
+ function policyAuth(s) {
37000
+ if (s.auth.mode === "key") return Object.freeze({ mode: "key" });
37001
+ const clientId = s.auth.client === "customer" ? s.auth.client_id : SECURECOMS_CIMD_CLIENT_ID;
37002
+ return Object.freeze({ mode: "oauth", issuer: s.auth.issuer, client: s.auth.client, client_id: clientId });
37003
+ }
37004
+ function mcpPolicyFor(i) {
37005
+ const m = i.manifest;
37006
+ const r3Hold = m?.signedMcpPresent === true || i.mcpSeen;
37007
+ if (i.hostSwitch === "off") return empty("kill_switch", r3Hold);
37008
+ if (i.hostSwitch !== "on") return empty("suspended", r3Hold);
37009
+ if (!i.config.fetched) return empty("no_config", r3Hold);
37010
+ const ent = mcpEntitlementReading(i.config.mcp_entitled, i.config.mcp_cause);
37011
+ if (ent !== "entitled") return empty(ent, r3Hold);
37012
+ if (!i.ownerDmManifestSwitchOn) return empty("switch_off", r3Hold);
37013
+ if (m === null) return empty("no_manifest", r3Hold);
37014
+ if (m.mcpKey === "absent") return empty("section_absent", r3Hold);
37015
+ if (m.mcp === null) return empty("malformed", r3Hold);
37016
+ if (!i.capability) return empty("no_capability", r3Hold);
37017
+ const catalog = i.catalog ?? MCP_CATALOG;
37018
+ const granted = /* @__PURE__ */ new Map();
37019
+ for (const name of m.grants.owner_dm ?? []) {
37020
+ const g = parseMcpGrant(name);
37021
+ if (g === null) continue;
37022
+ if (!granted.has(g.serverId)) granted.set(g.serverId, /* @__PURE__ */ new Set());
37023
+ granted.get(g.serverId).add(g.tool);
37024
+ }
37025
+ const servers = {};
37026
+ for (const id of Object.keys(m.mcp.servers).sort()) {
37027
+ const s = m.mcp.servers[id];
37028
+ const row = mcpServerCatalogRow(s, catalog);
37029
+ if (row === null) {
37030
+ servers[id] = Object.freeze({ state: "refused" });
37031
+ continue;
37032
+ }
37033
+ const tools = {};
37034
+ for (const t of Object.keys(s.tools).sort()) {
37035
+ if (granted.get(id)?.has(t) === true && row.readTools.includes(t)) tools[t] = Object.freeze({ sha256: s.tools[t].sha256, class: "read" });
37036
+ }
37037
+ servers[id] = Object.freeze({ state: "ok", vendor: s.vendor, url: row.readOnlyUrl, auth: policyAuth(s), tools: Object.freeze(tools) });
37038
+ }
37039
+ const remove = m.mcp.removed === "*" ? "*" : Object.freeze([...m.mcp.removed]);
37040
+ return Object.freeze({ v: 1, state: "ok", r3_hold: r3Hold, servers: Object.freeze(servers), remove });
37041
+ }
37042
+
37043
+ // src/grants/mcp-issuance.ts
37044
+ var NONE = Object.freeze([]);
37045
+ var own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
37046
+ function mcpPolicyAllows(policy, serverId, tool) {
37047
+ if (policy === null || policy.state !== "ok" || !own(policy.servers, serverId)) return false;
37048
+ const s = policy.servers[serverId];
37049
+ return s.state === "ok" && own(s.tools, tool);
37050
+ }
37051
+ function mcpIssuance(i) {
37052
+ if (i.source !== "manifest") return NONE;
37053
+ if (i.verdict?.kind !== "verified" || i.verdict.relayLeaf !== false) return NONE;
37054
+ if (i.entitlement !== "entitled" || i.hostSwitch !== "on" || !i.ownerDmManifestSwitchOn) return NONE;
37055
+ if (i.manifest === null || i.policy === null || i.policy.state !== "ok") return NONE;
37056
+ const out = [];
37057
+ for (const name of i.manifest.grants.owner_dm ?? []) {
37058
+ const g = parseMcpGrant(name);
37059
+ if (g === null || i.disabled.has(name) || !mcpPolicyAllows(i.policy, g.serverId, g.tool)) continue;
37060
+ out.push(Object.freeze({ tool: name, mode: "read", source: "manifest" }));
37061
+ }
37062
+ return out.length === 0 ? NONE : Object.freeze(out);
37063
+ }
37064
+
37065
+ // src/consumer-grants.ts
37066
+ var NO_GRANTS = Object.freeze([]);
37067
+ var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
37068
+ function withConsumerGrants(m, perToolConsumer) {
37069
+ if (perToolConsumer) return m;
37070
+ const a = m.authorization;
37071
+ if (!a) return m;
37072
+ const stripGrants = !(a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner");
37073
+ const stripMemory = "memory_write" in a;
37074
+ if (!stripGrants && !stripMemory) return m;
37075
+ const { memory_write: _dropped, ...rest } = a;
37076
+ return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
37077
+ }
37078
+ var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
37079
+ var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
37080
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
37081
+ var MCP_CLIENT_FEATURE = "mcp_client_v1";
37082
+ var CONSTANT_TOOL_NAMES = Object.freeze({
37083
+ owner_dm: Object.freeze(["current_time", "web_search"]),
37084
+ team_room: Object.freeze(["current_time"])
37085
+ });
37086
+ var isManifestGrant = (g) => g.mode === "read" && g.source === "manifest";
37087
+ function withManifestCapability(m, manifestConsumer) {
37088
+ if (manifestConsumer) return m;
37089
+ const a = m.authorization;
37090
+ if (!a || !a.tool_grants.some(isManifestGrant)) return m;
37091
+ const constants5 = new Set(a.audience === "owner_dm" || a.audience === "team_room" ? CONSTANT_TOOL_NAMES[a.audience] : []);
37092
+ const narrowed = a.tool_grants.flatMap((g) => {
37093
+ if (!isManifestGrant(g)) return [g];
37094
+ return constants5.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
37095
+ });
37096
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(narrowed) }) };
37097
+ }
37098
+ function withHarnessGrants(m, withheld) {
37099
+ if (!withheld) return m;
37100
+ const a = m.authorization;
37101
+ if (!a || a.tool_grants.length === 0) return m;
37102
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
37103
+ }
37104
+ function withSearchCapability(m, consumerHasFeature, signedProvider) {
37105
+ if (consumerHasFeature) return m;
37106
+ const a = m.authorization;
37107
+ if (!a) return m;
37108
+ const stripSearch = signedProvider !== void 0 && signedProvider !== "brave";
37109
+ const kept = a.tool_grants.filter((g) => g.tool !== "web_extract" && !(stripSearch && g.tool === "web_search"));
37110
+ if (kept.length === a.tool_grants.length) return m;
37111
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
37112
+ }
37113
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
37114
+ function withUtilityCapability(m, consumerHasFeature) {
37115
+ if (consumerHasFeature) return m;
37116
+ const a = m.authorization;
37117
+ if (!a) return m;
37118
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
37119
+ if (kept.length === a.tool_grants.length) return m;
37120
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
37121
+ }
37122
+ var MCP_GRANT_PREFIX = "mcp:";
37123
+ var isMcpGrant = (g) => g.tool.startsWith(MCP_GRANT_PREFIX);
37124
+ function withMcpCapability(m, consumerHasFeature) {
37125
+ const a = m.authorization;
37126
+ if (!a) return m;
37127
+ const { relay_free: _relayFree, ...rest } = a;
37128
+ if (!a.tool_grants.some(isMcpGrant)) return "relay_free" in a ? { ...m, authorization: Object.freeze(rest) } : m;
37129
+ if (consumerHasFeature && a.audience === "owner_dm" && a.relay_speaker === void 0) return m;
37130
+ const kept = a.tool_grants.filter((g) => !isMcpGrant(g));
37131
+ return { ...m, authorization: Object.freeze({ ...rest, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
37132
+ }
37133
+ var R3_KEEP_TOOLS = /* @__PURE__ */ new Set(["current_time", "web_search", "weather_forecast", "currency_convert"]);
37134
+ function withR3Holds(m, hold) {
37135
+ if (!hold) return m;
37136
+ const a = m.authorization;
37137
+ if (!a || !(a.audience === "owner_dm" || a.relay_speaker === "owner")) return m;
37138
+ const kept = a.tool_grants.filter((g) => R3_KEEP_TOOLS.has(g.tool) || isMcpGrant(g));
37139
+ if (kept.length === a.tool_grants.length) return m;
37140
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
37141
+ }
37142
+
37143
+ // src/mcp-catalog-message.ts
37144
+ var MCP_CATALOG_MAX_BYTES = 384 * 1024;
37145
+ var MCP_CATALOG_MAX_TOOLS = 64;
37146
+ var MCP_CATALOG_LIST_TIMEOUT_MS = 5e4;
37147
+ var REQUEST_ID = /^[0-9a-f]{32}$/;
37148
+ var SERVER_ID = /^[a-z0-9-]{1,24}$/;
37149
+ var TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
37150
+ var RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
37151
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
37152
+ var MCP_CATALOG_REASONS = /* @__PURE__ */ new Set(["catalog", "write_name", "hint", "schema", "header_param", "too_large"]);
37153
+ var isBag2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
37154
+ var keysExactly2 = (o, keys) => {
37155
+ const k = Object.keys(o);
37156
+ return k.length === keys.length && keys.every((x) => Object.prototype.hasOwnProperty.call(o, x));
37157
+ };
37158
+ function parseMcpCatalogRequest(body) {
37159
+ if (!isBag2(body) || !keysExactly2(body, ["v", "type", "server_id", "request_id"])) return null;
37160
+ if (body.v !== 1 || body.type !== "mcp_catalog_request") return null;
37161
+ const { server_id: serverId, request_id: requestId } = body;
37162
+ if (typeof serverId !== "string" || !SERVER_ID.test(serverId) || RESERVED.has(serverId)) return null;
37163
+ if (typeof requestId !== "string" || !REQUEST_ID.test(requestId)) return null;
37164
+ return Object.freeze({ serverId, requestId });
37165
+ }
37166
+ var PROJECTION_KEYS = /* @__PURE__ */ new Set(["name", "title", "description", "inputSchema", "annotations"]);
37167
+ function validProjection(p, toolName) {
37168
+ if (!Object.keys(p).every((k) => PROJECTION_KEYS.has(k))) return false;
37169
+ if (p.name !== toolName || typeof p.description !== "string" || !isBag2(p.inputSchema)) return false;
37170
+ if (Object.prototype.hasOwnProperty.call(p, "title") && typeof p.title !== "string") return false;
37171
+ if (Object.prototype.hasOwnProperty.call(p, "annotations") && !isBag2(p.annotations)) return false;
37172
+ return true;
37173
+ }
37174
+ function validTool(t) {
37175
+ if (!isBag2(t) || typeof t.name !== "string" || typeof t.grantable !== "boolean") return null;
37176
+ if (t.grantable) {
37177
+ if (!TOOL_NAME.test(t.name) || t.reason !== null || !isBag2(t.projection) || !validProjection(t.projection, t.name)) return null;
37178
+ return { name: t.name, projection: t.projection, grantable: true, reason: null };
37179
+ }
37180
+ if (t.projection !== void 0 || typeof t.reason !== "string" || !MCP_CATALOG_REASONS.has(t.reason)) return null;
37181
+ if (t.name.length < 1 || t.name.length > 64 || !/^[\x20-\x7e]+$/.test(t.name)) return null;
37182
+ return { name: t.name, grantable: false, reason: t.reason };
37183
+ }
37184
+ function validateCatalogListReply(frame, serverId, vendor) {
37185
+ if (!isBag2(frame) || frame.ok !== true || frame.server_id !== serverId || frame.vendor !== vendor) return null;
37186
+ if (typeof frame.listed_at !== "string" || !ISO_TIME.test(frame.listed_at) || typeof frame.truncated !== "boolean") return null;
37187
+ if (!Array.isArray(frame.tools) || frame.tools.length > MCP_CATALOG_MAX_TOOLS) return null;
37188
+ const tools = [];
37189
+ const names = /* @__PURE__ */ new Set();
37190
+ for (const t of frame.tools) {
37191
+ const v = validTool(t);
37192
+ if (v === null) return null;
37193
+ if (names.has(v.name)) return null;
37194
+ names.add(v.name);
37195
+ tools.push(v);
37196
+ }
37197
+ return { listedAt: frame.listed_at, truncated: frame.truncated, tools };
37198
+ }
37199
+ var utf8 = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
37200
+ var mcpCatalogBodyBytes = (b) => utf8(b);
37201
+ function buildMcpCatalogBody(i) {
37202
+ const head = { v: 1, type: "mcp_catalog", server_id: i.serverId, request_id: i.requestId, vendor: i.vendor, issuer: i.issuer, listed_at: i.reply.listedAt };
37203
+ const full = { ...head, tools: [...i.reply.tools], truncated: i.reply.truncated };
37204
+ if (mcpCatalogBodyBytes(full) <= MCP_CATALOG_MAX_BYTES) return full;
37205
+ const stub = (t) => ({ name: t.name, grantable: false, reason: "too_large" });
37206
+ const tools = i.reply.tools.map(stub);
37207
+ const body = { ...head, tools, truncated: true };
37208
+ let size = mcpCatalogBodyBytes(body);
37209
+ for (let k = 0; k < tools.length; k++) {
37210
+ const want = i.reply.tools[k];
37211
+ const delta = utf8(want) - utf8(tools[k]);
37212
+ if (size + delta <= MCP_CATALOG_MAX_BYTES) {
37213
+ tools[k] = want;
37214
+ size += delta;
37215
+ }
37216
+ }
37217
+ return body;
37218
+ }
37219
+
37220
+ // src/mcp-catalog-rate.ts
37221
+ var MINUTE_MS = 6e4;
37222
+ var HOUR_MS = 60 * MINUTE_MS;
37223
+ var MCP_CATALOG_PER_SERVER_MINUTE = 3;
37224
+ var MCP_CATALOG_PER_SERVER_HOUR = 20;
37225
+ var MCP_CATALOG_PER_AGENT_HOUR = 40;
37226
+ var McpCatalogRateLimiter = class {
37227
+ now;
37228
+ onRateLimited;
37229
+ inFlight = /* @__PURE__ */ new Set();
37230
+ perServer = /* @__PURE__ */ new Map();
37231
+ agent = [];
37232
+ /** Per scope key: no further log until this time (the end of the window that was full when it last logged). */
37233
+ quietUntil = /* @__PURE__ */ new Map();
37234
+ constructor(deps = {}) {
37235
+ this.now = deps.now ?? Date.now;
37236
+ this.onRateLimited = deps.onRateLimited ?? (() => {
37237
+ });
37238
+ }
37239
+ /** "ok" admits (and counts) the request and marks the server in flight; the caller MUST call release(serverId). */
37240
+ admit(serverId) {
37241
+ if (this.inFlight.has(serverId)) return "in_flight";
37242
+ const t = this.now();
37243
+ const server = (this.perServer.get(serverId) ?? []).filter((x) => t - x < HOUR_MS);
37244
+ this.perServer.set(serverId, server);
37245
+ this.agent = this.agent.filter((x) => t - x < HOUR_MS);
37246
+ const lastMinute = server.filter((x) => t - x < MINUTE_MS);
37247
+ if (lastMinute.length >= MCP_CATALOG_PER_SERVER_MINUTE) return this.limited(`m:${serverId}`, "server_minute", lastMinute[0] + MINUTE_MS, t);
37248
+ if (server.length >= MCP_CATALOG_PER_SERVER_HOUR) return this.limited(`h:${serverId}`, "server_hour", server[0] + HOUR_MS, t);
37249
+ if (this.agent.length >= MCP_CATALOG_PER_AGENT_HOUR) return this.limited("a", "agent_hour", this.agent[0] + HOUR_MS, t);
37250
+ server.push(t);
37251
+ this.agent.push(t);
37252
+ this.inFlight.add(serverId);
37253
+ return "ok";
37254
+ }
37255
+ release(serverId) {
37256
+ this.inFlight.delete(serverId);
37257
+ }
37258
+ limited(key, scope, windowEnd, t) {
37259
+ if (t >= (this.quietUntil.get(key) ?? -Infinity)) {
37260
+ this.quietUntil.set(key, windowEnd);
37261
+ this.onRateLimited(scope);
37262
+ }
37263
+ return "rate_limited";
37264
+ }
37265
+ };
36452
37266
 
36453
37267
  // src/bridge-agent.ts
36454
37268
  function logSendSupersededAdoption(kind2) {
@@ -36588,6 +37402,7 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
36588
37402
  }
36589
37403
  return VOICE_MODE_GUIDANCE;
36590
37404
  }
37405
+ var MCP_INERT_STATES = /* @__PURE__ */ new Set(["suspended", "no_config", "switch_off", "no_manifest", "section_absent", "malformed", "no_capability"]);
36591
37406
  var VOICE_COALESCE_WINDOW_MS = 0;
36592
37407
  var VOICE_COALESCE_WINDOW_MAX_MS = 5e3;
36593
37408
  function voiceCoalesceWindowFromEnv(env = process.env) {
@@ -36638,10 +37453,10 @@ var ChannelPendingError = class extends Error {
36638
37453
  }
36639
37454
  };
36640
37455
  var RateLimitedError = class extends Error {
36641
- constructor(channelId, retryAfterMs) {
36642
- super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
37456
+ constructor(channelId, retryAfterMs2) {
37457
+ super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs2}ms)`);
36643
37458
  this.channelId = channelId;
36644
- this.retryAfterMs = retryAfterMs;
37459
+ this.retryAfterMs = retryAfterMs2;
36645
37460
  this.name = "RateLimitedError";
36646
37461
  }
36647
37462
  };
@@ -36769,6 +37584,31 @@ var SendForbiddenError = class extends Error {
36769
37584
  this.name = "SendForbiddenError";
36770
37585
  }
36771
37586
  };
37587
+ var MCP_POLICY_RETRY_DELAYS_MS = Object.freeze([1e3, 5e3, 3e4, 12e4, 3e5]);
37588
+ function logMcpCatalogRequest(state) {
37589
+ writeServiceLog(state === "sent" ? "info" : "warn", "mcp.catalog_request", { state: ServiceLogEnum.of(state, [
37590
+ "malformed",
37591
+ "not_owner_dm",
37592
+ "not_verified",
37593
+ "replay",
37594
+ "relay_leaf",
37595
+ "not_ready",
37596
+ "in_flight",
37597
+ "runtime_timeout",
37598
+ "runtime_refused",
37599
+ "withheld",
37600
+ "send_failed",
37601
+ "failed",
37602
+ "sent"
37603
+ ]) });
37604
+ }
37605
+ var McpReplyWithheldError = class extends SendForbiddenError {
37606
+ constructor(channelId) {
37607
+ super(channelId);
37608
+ this.name = "McpReplyWithheldError";
37609
+ this.message = "reply withheld: the chat is no longer private to the owner and the agent; no retry";
37610
+ }
37611
+ };
36772
37612
  var RoomClassChangedSendError = class extends SendForbiddenError {
36773
37613
  constructor(channelId) {
36774
37614
  super(channelId);
@@ -36788,6 +37628,7 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
36788
37628
  var RoomGrantEpochMovedError = class extends Error {
36789
37629
  };
36790
37630
  var ROOM_GRANT_TRACK_MAX = 4096;
37631
+ var MCP_DELIVERED_TRACK_MAX = 4096;
36791
37632
  var VOICE_FRAME_PARENT_MAX = 4096;
36792
37633
  var SessionSupersededError = class extends Error {
36793
37634
  constructor() {
@@ -36939,8 +37780,12 @@ var BridgeAgent = class _BridgeAgent {
36939
37780
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
36940
37781
  });
36941
37782
  this.home = deps.home ?? homedir4();
37783
+ this.mcpCatalog = deps.mcpCatalog;
37784
+ this.mcpDeliveredTrackMax = deps.mcpDeliveredTrackMax ?? MCP_DELIVERED_TRACK_MAX;
37785
+ this.mcpPolicyRetryDelaysMs = deps.mcpPolicyRetryDelaysMs?.length ? deps.mcpPolicyRetryDelaysMs : MCP_POLICY_RETRY_DELAYS_MS;
36942
37786
  this.grantWriters = {
36943
37787
  writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
37788
+ writeMcpPolicy: deps.grantWriters?.writeMcpPolicy ?? writeMcpPolicy,
36944
37789
  writeManagedFiles: deps.grantWriters?.writeManagedFiles ?? writeManagedFiles,
36945
37790
  bindRuntimeConfig: deps.grantWriters?.bindRuntimeConfig ?? bindRuntimeConfig,
36946
37791
  removeManagedLlmSource: deps.grantWriters?.removeManagedLlmSource ?? removeManagedLlmSource
@@ -37426,6 +38271,39 @@ var BridgeAgent = class _BridgeAgent {
37426
38271
  roomDefaultGrants = roomDefaultGrantsEnabled();
37427
38272
  /** Owner grants (spec §7.4): the two manifest switches, read once at construction. */
37428
38273
  ownerDmManifestGrants = ownerDmManifestGrantsEnabled();
38274
+ /**
38275
+ * MCP client Task 16 (OQ-16, coordinator ruling D-7): SECURECOMS_MCP_GRANTS, read ONCE at construction. "off" writes
38276
+ * kill_switch (deleting); any value other than unset/"on"/"off" is suspended (non-deleting, one content-free warn).
38277
+ */
38278
+ mcpHostSwitch = mcpGrantsHostSwitch(process.env);
38279
+ /** MCP Task 16 test seam (BridgeAgentDeps.mcpCatalog); undefined in production, so MCP_CATALOG decides. */
38280
+ mcpCatalog;
38281
+ /**
38282
+ * MCP Task 16 (OQ-1; PR-B1 audit M-2): the two raw /files fields from the LAST fetch. Any failed or rejected fetch
38283
+ * sets `fetched: false` (no_config, non-deleting): the last values are never kept to keep an `ok` document alive.
38284
+ */
38285
+ mcpConfig = Object.freeze({ fetched: false });
38286
+ /**
38287
+ * MCP Task 16: the mcp-policy.json document this process last WROTE (the file the runtime reads), or null before the
38288
+ * first write and after a failed one (fail closed: issuance reads only a document known to be on disk).
38289
+ */
38290
+ mcpPolicyWritten = null;
38291
+ mcpPolicyWrittenJson = null;
38292
+ /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
38293
+ mcpPolicyRetryDelaysMs;
38294
+ mcpPolicyRetryTimer = null;
38295
+ mcpPolicyRetryAttempt = 0;
38296
+ /** MCP Task 18: the loopback catalog listers of the attached runtime sockets that declared mcp_client_v1 (ws-server.ts). */
38297
+ mcpCatalogListers = /* @__PURE__ */ new Map();
38298
+ /** MCP Task 18 (OQ-15): per server 1 in flight, 3 / 60 s, 20 / hour; per agent 40 / hour. Memory only. */
38299
+ mcpCatalogRate = new McpCatalogRateLimiter({
38300
+ onRateLimited: (scope) => writeServiceLog("warn", "mcp.catalog_request", {
38301
+ state: ServiceLogEnum.of("catalog_rate_limited", ["catalog_rate_limited"]),
38302
+ scope: ServiceLogEnum.of(scope, ["server_minute", "server_hour", "agent_hour"])
38303
+ })
38304
+ });
38305
+ /** MCP Task 18: answers in progress (outside the ingest cycle). Observable by tests; close() does not wait on them. */
38306
+ mcpCatalogWork = /* @__PURE__ */ new Set();
37429
38307
  roomManifestGrants = roomManifestGrantsEnabled();
37430
38308
  /**
37431
38309
  * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
@@ -37436,6 +38314,39 @@ var BridgeAgent = class _BridgeAgent {
37436
38314
  // RECORDED here and in the sealed store; no grant code reads it in step 1.
37437
38315
  toolGrantManifest = null;
37438
38316
  toolGrantCounter = null;
38317
+ /**
38318
+ * Tool status Phase 0 (spec §3.1, §3.2; plan Task 8): the latest owner-DM last-turn record, in memory only (no store
38319
+ * table: after a restart there is none until the next typed owner-DM turn). ADVISORY: nothing reads it to decide a
38320
+ * grant. Content-free: enums, booleans, one 15-minute bucket `at` (ruling M-2), one counter, built-in names from the
38321
+ * allowlist. This is the latest record, open bucket included; what leaves the host is the sender's closed record.
38322
+ */
38323
+ ownerDmLastTurn = null;
38324
+ /**
38325
+ * Test seams (Task 8; ruling M-2): the cause function, the bridge clock the record and the sender read, and the
38326
+ * sender's timer and jitter source.
38327
+ */
38328
+ toolStatusCause = ownerDmCause;
38329
+ toolStatusNow = () => Date.now();
38330
+ toolStatusSetTimer = defaultToolStatusSetTimer;
38331
+ toolStatusClearTimer = defaultToolStatusClearTimer;
38332
+ toolStatusRandom = Math.random;
38333
+ /**
38334
+ * Phase 0 Task 9 (spec §3.5), ruling M-2: the post of the record to the narrow route, only just after a 15-minute
38335
+ * UTC bucket boundary, and only a record whose bucket has closed.
38336
+ */
38337
+ toolStatusSender = new ToolStatusSender({
38338
+ post: (rec) => this.postToolStatus(rec),
38339
+ now: () => this.toolStatusNow(),
38340
+ setTimer: (fn, ms) => this.toolStatusSetTimer(fn, ms),
38341
+ clearTimer: (h) => this.toolStatusClearTimer(h),
38342
+ random: () => this.toolStatusRandom(),
38343
+ onFailure: (kind2) => writeServiceLog("warn", "tool_status.post_failed", { status: ServiceLogEnum.of(kind2, ["http_error", "timeout", "network"]) })
38344
+ });
38345
+ /**
38346
+ * MCP Phase 1 Task 14 (OQ-3 ruling item 3): the sticky R-3 flag, mirrored from the sealed store. Starts false and is
38347
+ * set true at start() by the store (or by a failed read, fail closed). Only applyToolGrantManifest changes it after.
38348
+ */
38349
+ mcpSeen = false;
37439
38350
  manifestState = "no_manifest";
37440
38351
  /** Failed owner-turn checks since the last successful runtime-config report (§7.7). */
37441
38352
  ownerVerifyFailures = 0;
@@ -37901,10 +38812,12 @@ var BridgeAgent = class _BridgeAgent {
37901
38812
  const payload = await res.json();
37902
38813
  if (!payload) {
37903
38814
  writeServiceLog("warn", "runtime_config.empty_response");
38815
+ this.noteMcpConfigUnfetched();
37904
38816
  return;
37905
38817
  }
37906
38818
  if (typeof payload.version !== "number") {
37907
38819
  writeServiceLog("warn", "runtime_config.invalid_version");
38820
+ this.noteMcpConfigUnfetched();
37908
38821
  return;
37909
38822
  }
37910
38823
  const version = payload.version;
@@ -37918,6 +38831,9 @@ var BridgeAgent = class _BridgeAgent {
37918
38831
  manifestStepThrew = true;
37919
38832
  writeServiceLog("warn", "authz.config_binding", { state: ServiceLogEnum.of("bind_failed", ["bind_failed"]) }, e);
37920
38833
  }
38834
+ const raw = payload;
38835
+ this.mcpConfig = Object.freeze({ fetched: true, mcp_entitled: raw.mcp_entitled, mcp_cause: raw.mcp_cause });
38836
+ this.recomputeMcpPolicy();
37921
38837
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
37922
38838
  const inForce = pin !== null ? this.toolGrantManifest : null;
37923
38839
  if (inForce !== null && pin !== null) {
@@ -37984,8 +38900,81 @@ var BridgeAgent = class _BridgeAgent {
37984
38900
  await this.postRuntimeConfigReport(base, version, skillsJson);
37985
38901
  } catch (e) {
37986
38902
  writeServiceLog("warn", "runtime_config.refetch_failed", {}, e);
38903
+ this.noteMcpConfigUnfetched();
37987
38904
  }
37988
38905
  }
38906
+ /** MCP Task 16 (OQ-1): the bridge's in-force entitlement reading; not fetched (or failed) is suspended. */
38907
+ mcpEntitlementInForce() {
38908
+ return this.mcpConfig.fetched ? mcpEntitlementReading(this.mcpConfig.mcp_entitled, this.mcpConfig.mcp_cause) : "suspended";
38909
+ }
38910
+ /** PR-B1 audit M-2: forget the last /files fields and rewrite mcp-policy.json (no_config: no grants, no deletion). */
38911
+ noteMcpConfigUnfetched() {
38912
+ this.mcpConfig = Object.freeze({ fetched: false });
38913
+ this.recomputeMcpPolicy();
38914
+ }
38915
+ /** MCP Task 16: the live inputs of mcp-policy.json (the pinned manifest in force only). */
38916
+ mcpPolicyInput() {
38917
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
38918
+ return {
38919
+ hostSwitch: this.mcpHostSwitch,
38920
+ config: this.mcpConfig,
38921
+ ownerDmManifestSwitchOn: this.ownerDmManifestGrants,
38922
+ manifest: pin !== null ? this.toolGrantManifest : null,
38923
+ capability: this.mcpClientCapable(),
38924
+ mcpSeen: this.mcpSeen,
38925
+ ...this.mcpCatalog ? { catalog: this.mcpCatalog } : {}
38926
+ };
38927
+ }
38928
+ /**
38929
+ * MCP Task 16 (spec §12.1): recompute mcp-policy.json and write it when it changed. The written document is what MCP
38930
+ * issuance and release-time re-narrowing read, so it is cleared BEFORE the write and set only after it lands: a failed
38931
+ * write leaves no document in force here (no MCP grant), and the next trigger tries again. Never throws.
38932
+ */
38933
+ recomputeMcpPolicy() {
38934
+ const doc = mcpPolicyFor(this.mcpPolicyInput());
38935
+ const json2 = JSON.stringify(doc);
38936
+ if (json2 === this.mcpPolicyWrittenJson) return;
38937
+ if (MCP_INERT_STATES.has(doc.state) && !doc.r3_hold && Object.keys(doc.servers).length === 0 && !existsSync5(join20(runtimeConfigDir(this.home, this.cfg.identityId), MCP_POLICY_FILE))) {
38938
+ this.mcpPolicyWritten = doc;
38939
+ this.mcpPolicyWrittenJson = json2;
38940
+ this.clearMcpPolicyRetry();
38941
+ return;
38942
+ }
38943
+ this.mcpPolicyWritten = null;
38944
+ this.mcpPolicyWrittenJson = null;
38945
+ try {
38946
+ this.grantWriters.writeMcpPolicy(this.home, this.cfg.identityId, doc);
38947
+ } catch (e) {
38948
+ writeServiceLog("warn", "authz.mcp_policy", { state: ServiceLogEnum.of("write_failed", ["write_failed"]) }, e);
38949
+ this.scheduleMcpPolicyRetry();
38950
+ return;
38951
+ }
38952
+ this.mcpPolicyWritten = doc;
38953
+ this.mcpPolicyWrittenJson = json2;
38954
+ this.clearMcpPolicyRetry();
38955
+ }
38956
+ /** A document is in force: no pending retry, and the next failure starts the backoff from the first delay. */
38957
+ clearMcpPolicyRetry() {
38958
+ this.mcpPolicyRetryAttempt = 0;
38959
+ if (this.mcpPolicyRetryTimer !== null) clearTimeout(this.mcpPolicyRetryTimer);
38960
+ this.mcpPolicyRetryTimer = null;
38961
+ }
38962
+ /**
38963
+ * PR-B2 follow-up: one pending retry at a time. The retry recomputes from the state THEN (never re-writes a stale
38964
+ * document); any other trigger that writes successfully meanwhile cancels it. Nothing is in force on the bridge side
38965
+ * until a write lands (mcpPolicyWritten stays null).
38966
+ */
38967
+ scheduleMcpPolicyRetry() {
38968
+ if (this.closingDown || this.mcpPolicyRetryTimer !== null) return;
38969
+ const d = this.mcpPolicyRetryDelaysMs;
38970
+ const delay = d[Math.min(this.mcpPolicyRetryAttempt, d.length - 1)];
38971
+ this.mcpPolicyRetryAttempt++;
38972
+ this.mcpPolicyRetryTimer = setTimeout(() => {
38973
+ this.mcpPolicyRetryTimer = null;
38974
+ if (!this.closingDown) this.recomputeMcpPolicy();
38975
+ }, delay);
38976
+ this.mcpPolicyRetryTimer.unref?.();
38977
+ }
37989
38978
  /**
37990
38979
  * Plan 4 Task 11: the refetch under a manifest in force. Returns false when the binding failed (H-4),
37991
38980
  * after the memory was applied; the caller then writes nothing else. Throws only from a file write, with
@@ -38077,6 +39066,7 @@ var BridgeAgent = class _BridgeAgent {
38077
39066
  const local = readLocalReport(this.home, this.cfg.identityId);
38078
39067
  const reportedSearchProviders = this.searchProvidersCapable();
38079
39068
  const reportedUtilityTools = this.utilityToolsCapable();
39069
+ const carriedOwnerDmTurn = this.toolStatusSender.carry();
38080
39070
  const report = {
38081
39071
  applied_version: version,
38082
39072
  paths: checkConfiguredPaths(skillsJson),
@@ -38146,8 +39136,17 @@ var BridgeAgent = class _BridgeAgent {
38146
39136
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
38147
39137
  search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
38148
39138
  // Weather/currency §8 item 5: a separate key, false with no runtime attached.
38149
- utility_tools_v1: reportedUtilityTools
38150
- }
39139
+ utility_tools_v1: reportedUtilityTools,
39140
+ // MCP Task 19 (spec §12.3): separate from `capability`; content-free enums and pattern tokens only.
39141
+ mcp_capability: this.reportedMcpCapability(),
39142
+ mcp_servers: this.reportedMcpServers(),
39143
+ // Tool status Phase 0 (spec §3.5, round 2 H-5): an updated bridge, with or without a record yet.
39144
+ tool_status_v1: true
39145
+ },
39146
+ // Tool status Phase 0 (spec §3.5): the record rides every apply-time report, so record_report's wholesale replace
39147
+ // never erases it. Ruling M-2: the sender's CLOSED record, never the latest one, so a report sent inside a bucket
39148
+ // never reveals that bucket's change early. Advisory and content-free.
39149
+ ...carriedOwnerDmTurn ? { owner_dm_last_turn: carriedOwnerDmTurn } : {}
38151
39150
  };
38152
39151
  const reportedVerifyFailures = this.ownerVerifyFailures;
38153
39152
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
@@ -38245,6 +39244,7 @@ var BridgeAgent = class _BridgeAgent {
38245
39244
  }
38246
39245
  await this.restoreRuntimeInbox();
38247
39246
  await this.loadToolGrantState();
39247
+ this.recomputeMcpPolicy();
38248
39248
  this.recomputeManifestPolicy(NO_FEATURES);
38249
39249
  try {
38250
39250
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
@@ -39262,7 +40262,9 @@ var BridgeAgent = class _BridgeAgent {
39262
40262
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
39263
40263
  * there so a retry can resend the exact bytes).
39264
40264
  */
39265
- async publishBody(channelId, body, id, onEnvelope, sealCheck) {
40265
+ async publishBody(channelId, body, id, onEnvelope, sealCheck, inReplyTo, mcpTurn) {
40266
+ const mcpGuard = mcpTurn === true || this.mcpReplyGuarded(channelId, inReplyTo ?? void 0);
40267
+ let attempt = 0;
39266
40268
  this.enforceBodyRateFloor(channelId, body);
39267
40269
  this.statusGate.replyStarted(channelId);
39268
40270
  const joined = await this.reconcileJoin(channelId);
@@ -39302,6 +40304,8 @@ var BridgeAgent = class _BridgeAgent {
39302
40304
  );
39303
40305
  },
39304
40306
  async (envelope) => {
40307
+ attempt++;
40308
+ if (mcpGuard) this.assertMcpReplyRelayFree(channelId, envelope, attempt);
39305
40309
  if (onEnvelope) await onEnvelope(envelope);
39306
40310
  return this.publishEnvelope(
39307
40311
  channelId,
@@ -39311,6 +40315,77 @@ var BridgeAgent = class _BridgeAgent {
39311
40315
  );
39312
40316
  this.latency.record("send.total", performance.now() - sendStart);
39313
40317
  }
40318
+ /**
40319
+ * MCP Task 16 (round 2 H-D): message ids whose DELIVERED authorization (after release-time re-narrowing) carried an
40320
+ * `mcp:` grant, with their channel and whether the runtime acked them. Recorded at the buffer exit, the one place
40321
+ * every consumer's copy leaves from (over-recording a turn a non-capable consumer then received without the grant only
40322
+ * adds a check). Kept after the ack, so a late frame citing the turn is still checked. Bounded: the oldest ACKED entry
40323
+ * goes first.
40324
+ */
40325
+ mcpDeliveredTurns = /* @__PURE__ */ new Map();
40326
+ mcpDeliveredTrackMax;
40327
+ /**
40328
+ * Record `m` when its authorization carries an `mcp:` grant. Returns false ONLY when `m` is a new turn, the record is
40329
+ * at its bound and no entry is acked: the caller (the buffer exit) must then issue no mcp: grant (audit M-2, fail
40330
+ * closed). `rebuild` (PR-B2 audit C-1 (b): a restored runtime-inbox row, or a replayed turn) records unconditionally:
40331
+ * those grants were issued before, so the record may exceed its bound by at most the runtime inbox's size.
40332
+ */
40333
+ noteMcpDelivered(m, rebuild = false) {
40334
+ if (!m.authorization?.tool_grants.some((g) => g.tool.startsWith(MCP_GRANT_PREFIX))) return true;
40335
+ if (!this.mcpDeliveredTurns.has(m.message_id) && this.mcpDeliveredTurns.size >= this.mcpDeliveredTrackMax) {
40336
+ let victim;
40337
+ for (const [id, r] of this.mcpDeliveredTurns) if (r.acked) {
40338
+ victim = id;
40339
+ break;
40340
+ }
40341
+ if (victim !== void 0) this.mcpDeliveredTurns.delete(victim);
40342
+ else if (!rebuild) return false;
40343
+ }
40344
+ this.mcpDeliveredTurns.set(m.message_id, { channelId: m.channel_id, acked: false });
40345
+ return true;
40346
+ }
40347
+ /**
40348
+ * H-D: whether a frame to `channelId` must pass the relay-free check: it answers a recorded MCP turn (any channel:
40349
+ * a frame of that turn sent elsewhere is checked against ITS channel), or it cites no turn while an MCP turn is
40350
+ * unacked in the same channel.
40351
+ */
40352
+ mcpReplyGuarded(channelId, inReplyTo) {
40353
+ if (inReplyTo !== void 0 && this.mcpDeliveredTurns.has(inReplyTo)) return true;
40354
+ if (inReplyTo !== void 0) return false;
40355
+ for (const r of this.mcpDeliveredTurns.values()) if (!r.acked && r.channelId === channelId) return true;
40356
+ return false;
40357
+ }
40358
+ /**
40359
+ * H-D, synchronous: read the channel's local leaves from the warm manager in one step and tie the read to the
40360
+ * envelope's SEALED group and epoch, so the leaf set is exactly the membership that can open these bytes. Owner and
40361
+ * agent only: pass. Anyone else: withhold. The tree moved past the seal on the first attempt: StaleEpochError, which
40362
+ * sendWithSelfHeal answers with its one converge-and-re-seal (the existing semantics); still unprovable on the retry,
40363
+ * or no warm manager, no pin, or any throw: withhold (fail closed). Read-only; never touches the group.
40364
+ *
40365
+ * Scope (PR-B2 audit item 5): this guards the `send` path (publishBody) only. `status` detail, the `action_*` run
40366
+ * frames, `/v1/send-attachment` and summary frames are NOT checked; they are safe for MCP turns only because the
40367
+ * runtime emits none of them (pinned by connect-agent-runtime/src/outbound-frames.arch.test.ts). A runtime that starts
40368
+ * emitting any of them needs this guard extended to that frame first.
40369
+ */
40370
+ assertMcpReplyRelayFree(channelId, envelope, attempt) {
40371
+ let verdict = "unknown";
40372
+ try {
40373
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
40374
+ const snap = this.provider.leafSnapshotCached(channelId);
40375
+ const sealedGroup = sealedGroupIdOf(envelope);
40376
+ if (pin !== null && snap !== null && sealedGroup !== void 0 && snap.groupId === sealedGroup) {
40377
+ if (snap.epoch === envelope.mls_group_epoch) {
40378
+ verdict = ownerDmLeavesRelayFree(snap.leaves, { ownerIdentityId: pin.ownerIdentityId, agentIdentityId: this.cfg.identityId }) ? "free" : "relay";
40379
+ } else if (snap.epoch > envelope.mls_group_epoch) verdict = "moved";
40380
+ }
40381
+ } catch {
40382
+ verdict = "unknown";
40383
+ }
40384
+ if (verdict === "free") return;
40385
+ if (verdict === "moved" && attempt === 1) throw new StaleEpochError(envelope.mls_group_epoch, envelope.mls_group_epoch + 1);
40386
+ writeServiceLog("warn", "mcp.reply_withheld", { state: ServiceLogEnum.of(verdict, ["relay", "moved", "unknown"]) });
40387
+ throw new McpReplyWithheldError(channelId);
40388
+ }
39314
40389
  /**
39315
40390
  * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
39316
40391
  * only at the epoch the room was classified at. On a move, the room is
@@ -39322,9 +40397,9 @@ var BridgeAgent = class _BridgeAgent {
39322
40397
  * the redelivery replay (replayReplyIntents, which has no in_reply_to in its
39323
40398
  * options) is checked the same as a live send.
39324
40399
  */
39325
- async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
40400
+ async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope, mcpTurn) {
39326
40401
  const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
39327
- if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
40402
+ if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope, void 0, inReplyTo, mcpTurn);
39328
40403
  if (channelId !== grant.channelId) {
39329
40404
  writeServiceLog("warn", "reply.room_class_changed");
39330
40405
  throw new RoomClassChangedSendError(channelId);
@@ -39346,7 +40421,9 @@ var BridgeAgent = class _BridgeAgent {
39346
40421
  },
39347
40422
  (epoch) => {
39348
40423
  if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
39349
- }
40424
+ },
40425
+ inReplyTo,
40426
+ mcpTurn
39350
40427
  );
39351
40428
  } catch (e) {
39352
40429
  if (!(e instanceof RoomGrantEpochMovedError)) throw e;
@@ -39481,14 +40558,15 @@ var BridgeAgent = class _BridgeAgent {
39481
40558
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
39482
40559
  if (!k) throw new Error("idempotent send without a key");
39483
40560
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39484
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39485
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
39486
- if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
40561
+ const mcpTurn = this.mcpReplyGuarded(channelId, opts.inReplyTo);
40562
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit, mcpTurn);
40563
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null, mcpTurn });
40564
+ if (intent.bodyMac !== replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit, intent.mcpTurn) || intent.channelId !== channelId || intent.state === "skipped") {
39487
40565
  throw new IdempotencyConflictError();
39488
40566
  }
39489
40567
  return k;
39490
40568
  }
39491
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
40569
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null, false);
39492
40570
  });
39493
40571
  return this.publishIntent(key, opts);
39494
40572
  }
@@ -39497,25 +40575,26 @@ var BridgeAgent = class _BridgeAgent {
39497
40575
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
39498
40576
  * after a slot collision: the body must go to a NEW part.
39499
40577
  */
39500
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
40578
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit, inheritedMcpTurn) {
39501
40579
  const credit = replyCredit ?? void 0;
40580
+ const mcpTurn = inheritedMcpTurn || this.mcpReplyGuarded(channelId, inReplyTo);
39502
40581
  const scope = `reply:${inReplyTo}`;
39503
40582
  let latest = null;
39504
40583
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
39505
40584
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
39506
40585
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
39507
40586
  }
39508
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
40587
+ if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit, latest.intent.mcpTurn)) {
39509
40588
  return { scope, part: latest.intent.part, inReplyTo };
39510
40589
  }
39511
40590
  let ordinal = latest ? latest.ordinal + 1 : 0;
39512
40591
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
39513
40592
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
39514
40593
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39515
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39516
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
40594
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit, mcpTurn);
40595
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit, mcpTurn });
39517
40596
  if (created) return k;
39518
- if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
40597
+ if (matchRetry && intent.state !== "skipped" && intent.channelId === channelId && intent.bodyMac === replyBodyMac(this.replyIdKey, id, channelId, bytes, credit, intent.mcpTurn)) return k;
39519
40598
  writeServiceLog("info", "reply.part_slot_taken", { attempt: i + 1 });
39520
40599
  }
39521
40600
  throw new Error("could not claim a reply part");
@@ -39567,7 +40646,7 @@ var BridgeAgent = class _BridgeAgent {
39567
40646
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
39568
40647
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
39569
40648
  try {
39570
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
40649
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo, intent.mcpTurn);
39571
40650
  } catch (e) {
39572
40651
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
39573
40652
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -39594,7 +40673,7 @@ var BridgeAgent = class _BridgeAgent {
39594
40673
  JSON.stringify(["scope", key.scope]),
39595
40674
  () => (
39596
40675
  // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39597
- this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
40676
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null, intent.mcpTurn)
39598
40677
  )
39599
40678
  );
39600
40679
  return this.publishIntent(next, opts, advances + 1);
@@ -39616,7 +40695,7 @@ var BridgeAgent = class _BridgeAgent {
39616
40695
  if (credit !== null && !isReplyCredit(credit)) return false;
39617
40696
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
39618
40697
  if (id !== intent.replyMessageId) return false;
39619
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
40698
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0, intent.mcpTurn === true) === intent.bodyMac;
39620
40699
  }
39621
40700
  /**
39622
40701
  * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
@@ -39637,12 +40716,12 @@ var BridgeAgent = class _BridgeAgent {
39637
40716
  * failure. Either way the intent stays pending, so the redelivery
39638
40717
  * republishes the same reply.
39639
40718
  */
39640
- async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
40719
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo, mcpTurn) {
39641
40720
  let rateLimited = 0;
39642
40721
  let inFlight = 0;
39643
40722
  for (; ; ) {
39644
40723
  try {
39645
- await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
40724
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo, mcpTurn);
39646
40725
  return;
39647
40726
  } catch (e) {
39648
40727
  if (e instanceof ReplyInFlightError) {
@@ -39713,8 +40792,9 @@ var BridgeAgent = class _BridgeAgent {
39713
40792
  * original ack; a conflict can only mean the slot belongs to another
39714
40793
  * message (for example an old reply whose intent was pruned).
39715
40794
  */
39716
- async publishIntentOnce(channelId, body, id, key, inReplyTo) {
40795
+ async publishIntentOnce(channelId, body, id, key, inReplyTo, mcpTurn) {
39717
40796
  const current = await this.store.getReplyIntent(key.scope, key.part);
40797
+ const guardMcp = mcpTurn || current?.mcpTurn === true;
39718
40798
  try {
39719
40799
  if (current?.envelope) {
39720
40800
  const cached = JSON.parse(new TextDecoder().decode(current.envelope));
@@ -39764,7 +40844,8 @@ var BridgeAgent = class _BridgeAgent {
39764
40844
  key.part,
39765
40845
  new TextEncoder().encode(JSON.stringify(envelope)),
39766
40846
  sealedGroupIdOf(envelope)
39767
- )
40847
+ ),
40848
+ guardMcp
39768
40849
  );
39769
40850
  } catch (e) {
39770
40851
  if (publishConflictCode(e) === "message_id_conflict") throw new ReplyIdCollisionError();
@@ -42275,16 +43356,20 @@ var BridgeAgent = class _BridgeAgent {
42275
43356
  }
42276
43357
  if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
42277
43358
  let leafClass = "ok";
43359
+ let relay = void 0;
42278
43360
  if (this.freshMeta(keyChannel)?.is_dm === true) {
42279
43361
  const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
42280
43362
  if (signedDm !== void 0 && signedDm !== keyChannel) return this.ownerTurnFailed(m, "wrong_dm_channel");
42281
- leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
43363
+ const leaves = (await this.provider.leafTreeSummary(m.channelId)).leaves;
43364
+ const relayIdentityId = this.toolGrantManifest?.owner_dm_relay_identity_id;
43365
+ leafClass = classifyOwnerDmLeaves(leaves, {
42282
43366
  ownerIdentityId: pin.ownerIdentityId,
42283
43367
  agentIdentityId: this.cfg.identityId,
42284
- relayIdentityId: this.toolGrantManifest?.owner_dm_relay_identity_id,
43368
+ relayIdentityId,
42285
43369
  isServerRelay: (id) => this.freshRoster(m.channelId)?.get(id)?.is_relay === true
42286
43370
  });
42287
43371
  if (leafClass === "foreign") return this.ownerTurnFailed(m, "not_owner_dm_leaves");
43372
+ relay = ownerDmRelayOf(leafClass, leaves, { ownerIdentityId: pin.ownerIdentityId, agentIdentityId: this.cfg.identityId, relayIdentityId });
42288
43373
  }
42289
43374
  const held = await this.heldPosition(m.channelId);
42290
43375
  if (held.groupId === null) return this.ownerTurnFailed(m, "verify_error");
@@ -42293,7 +43378,9 @@ var BridgeAgent = class _BridgeAgent {
42293
43378
  }
42294
43379
  const replay = Object.freeze({ channelId: m.channelId, groupId: held.groupId, epoch: v.mls_group_epoch });
42295
43380
  this.ownerReplayPending.set(m.messageId, replay);
42296
- return Object.freeze({ kind: leafClass === "unsigned_relay" ? "constants_only" : "verified", replay });
43381
+ const relayField = relay !== void 0 ? { relay } : {};
43382
+ if (leafClass === "unsigned_relay") return Object.freeze({ kind: "constants_only", replay, ...relayField });
43383
+ return Object.freeze({ kind: "verified", replay, ...relayField, relayLeaf: relayLeafFromPhase0Relay(relay) });
42297
43384
  } catch {
42298
43385
  return this.ownerTurnFailed(m, "verify_error");
42299
43386
  }
@@ -42369,6 +43456,12 @@ var BridgeAgent = class _BridgeAgent {
42369
43456
  }
42370
43457
  /** start(): drop other keys' manifest state, re-verify the stored one for the pinned key, and prune. */
42371
43458
  async loadToolGrantState() {
43459
+ try {
43460
+ this.mcpSeen = await this.store.loadMcpSeen();
43461
+ } catch (e) {
43462
+ this.mcpSeen = true;
43463
+ writeServiceLog("warn", "authz.mcp_seen_load_failed", {}, e);
43464
+ }
42372
43465
  const pin = this.cfg.ownerGrantPin?.pin;
42373
43466
  if (!pin) return;
42374
43467
  try {
@@ -42376,9 +43469,10 @@ var BridgeAgent = class _BridgeAgent {
42376
43469
  this.store.dropToolGrantAppliedExcept(pin.publicKeyB64);
42377
43470
  const saved = await this.store.loadToolGrantManifest(pin.publicKeyB64);
42378
43471
  if (saved) {
42379
- const v = verifyToolGrantManifest(saved.wrapper, { pin, agentIdentityId: this.cfg.identityId, storedCounter: null });
43472
+ const v = verifyToolGrantManifest(saved.wrapper, { pin, agentIdentityId: this.cfg.identityId, storedCounter: null, ...this.mcpCatalog ? { mcpCatalog: this.mcpCatalog } : {} });
42380
43473
  if (v.state === "ok") {
42381
43474
  this.toolGrantManifest = v.manifest;
43475
+ if (v.manifest.signedMcpPresent && !this.mcpSeen) await this.setMcpSeen(true);
42382
43476
  this.toolGrantCounter = v.manifest.counter;
42383
43477
  } else {
42384
43478
  this.logManifest("malformed");
@@ -42403,8 +43497,9 @@ var BridgeAgent = class _BridgeAgent {
42403
43497
  this.logManifest("no_manifest");
42404
43498
  return;
42405
43499
  }
42406
- const v = verifyToolGrantManifest(raw, { pin, agentIdentityId: this.cfg.identityId, storedCounter: this.toolGrantCounter });
43500
+ const v = verifyToolGrantManifest(raw, { pin, agentIdentityId: this.cfg.identityId, storedCounter: this.toolGrantCounter, ...this.mcpCatalog ? { mcpCatalog: this.mcpCatalog } : {} });
42407
43501
  if (v.state === "ok") {
43502
+ if (v.manifest.signedMcpPresent) await this.setMcpSeen(true);
42408
43503
  try {
42409
43504
  await this.store.saveToolGrantManifest(pin.publicKeyB64, v.manifest.counter, v.wrapper);
42410
43505
  } catch {
@@ -42413,13 +43508,34 @@ var BridgeAgent = class _BridgeAgent {
42413
43508
  }
42414
43509
  this.toolGrantManifest = v.manifest;
42415
43510
  this.toolGrantCounter = v.manifest.counter;
43511
+ this.recomputeMcpPolicy();
43512
+ if (v.manifest.mcpKey === "present" && !v.manifest.signedMcpPresent && this.mcpSeen) await this.setMcpSeen(false);
42416
43513
  this.logManifest("ok");
42417
43514
  if (v.droppedUnknown) this.logManifest("unknown_entry");
42418
43515
  return;
42419
43516
  }
42420
43517
  this.logManifest(v.state);
43518
+ if (v.state === "wrong_owner") this.dropOwnerDmLastTurn();
42421
43519
  if (v.state !== "unchanged" && this.toolGrantManifest) this.logManifest("kept_last_good");
42422
43520
  }
43521
+ /**
43522
+ * MCP Task 14: the R-3 hold Task 17 filters on and Task 15 writes as `r3_hold`. True while the manifest in force signs
43523
+ * an owner_dm `mcp:` string, or while the sticky flag is set (an old client's re-sign cannot lift it, audit C-1).
43524
+ */
43525
+ r3Hold() {
43526
+ return this.toolGrantManifest?.signedMcpPresent === true || this.mcpSeen;
43527
+ }
43528
+ /** Persist then mirror. Setting mirrors even on a failed write; clearing mirrors only after the write (fail closed). */
43529
+ async setMcpSeen(seen) {
43530
+ if (seen) this.mcpSeen = true;
43531
+ try {
43532
+ await this.store.saveMcpSeen(seen);
43533
+ } catch (e) {
43534
+ writeServiceLog("warn", "authz.mcp_seen_save_failed", {}, e);
43535
+ return;
43536
+ }
43537
+ this.mcpSeen = seen;
43538
+ }
42423
43539
  logManifest(state) {
42424
43540
  this.manifestState = state;
42425
43541
  const quiet = state === "ok" || state === "unchanged" || state === "no_pin" || state === "no_manifest";
@@ -42516,6 +43632,7 @@ var BridgeAgent = class _BridgeAgent {
42516
43632
  let tool_grants = NO_TOOL_GRANTS;
42517
43633
  let relaySpeaker;
42518
43634
  let memoryWrite = false;
43635
+ let relayFree = false;
42519
43636
  const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId) && (opts.voice === void 0 || rosterFreshAt(vEntry, opts.voice.heldEpoch, opts.voice.heldGroupId));
42520
43637
  const ownerDmFresh = epochFresh || opts.voice === void 0 && this.ownerDmCycleFresh.has(channelId);
42521
43638
  if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
@@ -42542,6 +43659,7 @@ var BridgeAgent = class _BridgeAgent {
42542
43659
  tool_grants = ROOM_DEFAULT_READ_GRANTS;
42543
43660
  }
42544
43661
  let grantSource = tool_grants.length ? "constant" : "none";
43662
+ let ownerDmEffective = null;
42545
43663
  const manifest = this.cfg.ownerGrantPin?.pin ? this.toolGrantManifest : null;
42546
43664
  const binding = this.manifestBinding;
42547
43665
  const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
@@ -42560,6 +43678,21 @@ var BridgeAgent = class _BridgeAgent {
42560
43678
  });
42561
43679
  tool_grants = r.grants;
42562
43680
  grantSource = r.source;
43681
+ ownerDmEffective = r;
43682
+ const mcp = mcpIssuance({
43683
+ source: r.source,
43684
+ verdict: opts.ownerVerdict,
43685
+ entitlement: this.mcpEntitlementInForce(),
43686
+ hostSwitch: this.mcpHostSwitch,
43687
+ ownerDmManifestSwitchOn: this.ownerDmManifestGrants,
43688
+ disabled: this.payloadToolDeny,
43689
+ manifest,
43690
+ policy: this.mcpPolicyWritten
43691
+ });
43692
+ if (mcp.length > 0) {
43693
+ tool_grants = Object.freeze([...tool_grants, ...mcp]);
43694
+ relayFree = true;
43695
+ }
42563
43696
  } else if (tool_grants === ROOM_DEFAULT_READ_GRANTS) {
42564
43697
  const r = effectiveToolGrants({
42565
43698
  audience: "team_room",
@@ -42577,12 +43710,39 @@ var BridgeAgent = class _BridgeAgent {
42577
43710
  if (opts.ownerVerdict?.kind === "failed") {
42578
43711
  tool_grants = NO_TOOL_GRANTS;
42579
43712
  memoryWrite = false;
43713
+ relayFree = false;
42580
43714
  relaySpeaker = void 0;
42581
43715
  grantSource = "none";
42582
43716
  }
42583
43717
  const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
42584
43718
  if (opts.log !== false) {
42585
43719
  this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh, memoryWrite, grantSource);
43720
+ const pinOwner = this.cfg.ownerGrantPin?.pin?.ownerIdentityId;
43721
+ const verdict = opts.ownerVerdict;
43722
+ if (pinOwner !== void 0 && senderId === pinOwner && meta?.is_dm === true && relaySpeakerId === void 0 && opts.voiceScoped !== true && opts.voice === void 0 && verdict !== void 0 && verdict.kind !== "unchecked" && !(verdict.kind === "failed" && verdict.reason === "replay")) {
43723
+ const pinned = this.channelOwner.get(channelId);
43724
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
43725
+ const ownerDirect = ownerId !== void 0 && audience === "owner_dm" && senderId === ownerId;
43726
+ this.noteOwnerDmTurn({
43727
+ cause: {
43728
+ killSwitchOff: !this.ownerDmDefaultGrants,
43729
+ manifestSwitchOff: !this.ownerDmManifestGrants,
43730
+ pinMissingOrAnomalous: ownerId === void 0,
43731
+ // OQ-5: only from a PRESENT roster entry; a missing (TTL-expired) entry is `stale` below.
43732
+ senderNotOwner: entry !== void 0 && (sender === void 0 || !ownerDirect),
43733
+ stale: !ownerDmFresh || entry === void 0,
43734
+ verdict,
43735
+ manifestPresent: manifest !== null,
43736
+ signedDmChannel: manifest?.owner_dm_channel_id,
43737
+ channelId,
43738
+ boundNow,
43739
+ consumerAttached: this.manifestConsumer !== null
43740
+ },
43741
+ grantSource,
43742
+ toolGrants: tool_grants,
43743
+ withheld: grantSource === "manifest" && ownerDmEffective !== null ? ownerDmEffective.withheld : []
43744
+ });
43745
+ }
42586
43746
  }
42587
43747
  return Object.freeze({
42588
43748
  version: 1,
@@ -42590,9 +43750,78 @@ var BridgeAgent = class _BridgeAgent {
42590
43750
  sender_membership: sender ? "active" : "unknown",
42591
43751
  tool_grants,
42592
43752
  ...relaySpeaker ? { relay_speaker: relaySpeaker } : {},
42593
- ...memoryWrite ? { memory_write: true } : {}
43753
+ ...memoryWrite ? { memory_write: true } : {},
43754
+ ...relayFree ? { relay_free: true } : {}
42594
43755
  });
42595
43756
  }
43757
+ /**
43758
+ * Tool status Phase 0 (spec §3.2, §3.5; plan Task 8; round 1 H-2, M-4; round 2 H-4): build the candidate record and
43759
+ * keep the previous one (and its `at`) when the change tuple is the same, so `at` means "last changed". Never throws
43760
+ * into the authorization path. Never logs a channel id, sender id or tool name.
43761
+ */
43762
+ noteOwnerDmTurn(t) {
43763
+ try {
43764
+ const v = t.cause.verdict;
43765
+ const relay = (v.kind === "verified" || v.kind === "constants_only") && v.relay !== void 0 ? v.relay : "unknown";
43766
+ let reason = {};
43767
+ if (t.grantSource !== "manifest") {
43768
+ const c = this.toolStatusCause(t.cause);
43769
+ if (c !== null && c.source === t.grantSource && c.cause !== void 0) {
43770
+ reason = c.sub !== void 0 ? { cause: c.cause, sub: c.sub } : { cause: c.cause };
43771
+ } else {
43772
+ writeServiceLog("warn", "tool_status.source_mismatch");
43773
+ }
43774
+ } else {
43775
+ const c = this.toolStatusCause(t.cause);
43776
+ if (c === null || c.source !== "manifest") writeServiceLog("warn", "tool_status.source_mismatch");
43777
+ }
43778
+ const names = t.toolGrants.map((g) => g.tool);
43779
+ const candidate = Object.freeze({
43780
+ at: bucketStartAt(this.toolStatusNow()),
43781
+ // ruling M-2: the 15-minute UTC bucket, never the minute
43782
+ counter: this.toolGrantCounter,
43783
+ source: t.grantSource,
43784
+ ...reason,
43785
+ relay,
43786
+ issued: Object.freeze(sanitizeIssued(names)),
43787
+ withheld: Object.freeze(t.withheld.map((w) => Object.freeze({ tool: w.tool, why: w.why }))),
43788
+ // A count only, never a name (content-free). 0 unless the MCP issuance above added mcp: grants.
43789
+ mcp_issued: Math.min(160, names.filter((n) => n.startsWith("mcp:")).length)
43790
+ });
43791
+ if (sameTuple(this.ownerDmLastTurn, candidate)) return;
43792
+ this.ownerDmLastTurn = candidate;
43793
+ this.toolStatusSender.note(candidate);
43794
+ } catch (e) {
43795
+ writeServiceLog("warn", "tool_status.record_failed", {}, e);
43796
+ }
43797
+ }
43798
+ /** R2-M1: the served manifest names another owner than the pin; the held record belongs to the old owner. */
43799
+ dropOwnerDmLastTurn() {
43800
+ this.ownerDmLastTurn = null;
43801
+ this.toolStatusSender.drop();
43802
+ }
43803
+ /**
43804
+ * Phase 0 Task 9 (spec §3.5): POST the record, body = the record only, to the narrow agent-key route. The backend
43805
+ * resolves the identity from the device credential; no identity, channel or sender id is in the URL or the body.
43806
+ * Returns the status class only; never throws.
43807
+ */
43808
+ async postToolStatus(rec) {
43809
+ try {
43810
+ const res = await this.fetchBounded("runtime_config_tool_status", "control", `${this.cfg.apiBase}/api/v1/agents/me/runtime-config/tool-status`, {
43811
+ method: "POST",
43812
+ headers: { authorization: `Bearer ${this.cfg.apiKey}`, "content-type": "application/json" },
43813
+ body: JSON.stringify(rec)
43814
+ });
43815
+ const retryAfter = res.headers.get("retry-after");
43816
+ try {
43817
+ await res.body?.cancel();
43818
+ } catch {
43819
+ }
43820
+ return { status: res.status, retryAfter };
43821
+ } catch (e) {
43822
+ return e?.name === "FetchDeadlineError" ? "timeout" : "network";
43823
+ }
43824
+ }
42596
43825
  /**
42597
43826
  * One content-free INFO line per authorization decision (every call site of
42598
43827
  * authorizationFor stages exactly one runtime turn). It says WHY a turn got
@@ -42816,6 +44045,111 @@ var BridgeAgent = class _BridgeAgent {
42816
44045
  * client's own wait already renders "Summary unavailable" — so there is
42817
44046
  * nothing to catch here besides the send itself.
42818
44047
  */
44048
+ /**
44049
+ * MCP client Task 18 (spec §8 item 2; audit M-3, H-3; OQ-2, OQ-15). Accept an `mcp_catalog_request` only when: its
44050
+ * body is exactly the §8 shape; it is a direct frame (never a voice channel's) in a channel the server marks as a DM
44051
+ * that IS the signed owner DM; verifyOwnerTurn says `verified` (the pinned owner's signature, the signed DM, the leaf
44052
+ * check, not a replay) with `relayLeaf === false`; mcp-policy.json as written is `ok` with this server `ok`; a runtime
44053
+ * socket that declared mcp_client_v1 is attached; and the rate limits admit it. The replay row is written HERE
44054
+ * (M-3: the request never reaches stageRuntimeInbox, where it is otherwise written), for every verified request, before
44055
+ * anything is answered; the ownerReplayPending entry is removed in `finally`. A failure to record answers nothing.
44056
+ * Never throws. The answer runs outside the ingest cycle, so the row is acked now.
44057
+ */
44058
+ async acceptMcpCatalogRequest(m, body) {
44059
+ try {
44060
+ const req = parseMcpCatalogRequest(body);
44061
+ if (req === null) return logMcpCatalogRequest("malformed");
44062
+ const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
44063
+ if (this.keyChannelForFrame(m) !== m.channelId || this.freshMeta(m.channelId)?.is_dm !== true || signedDm === void 0 || signedDm !== m.channelId) {
44064
+ return logMcpCatalogRequest("not_owner_dm");
44065
+ }
44066
+ const verdict = await this.verifyOwnerTurn(m);
44067
+ if (verdict.kind !== "verified") return logMcpCatalogRequest(verdict.kind === "failed" && verdict.reason === "replay" ? "replay" : "not_verified");
44068
+ this.store.recordOwnerMessage(verdict.replay.channelId, m.messageId, verdict.replay.groupId, verdict.replay.epoch, Date.now());
44069
+ if (verdict.relayLeaf !== false) return logMcpCatalogRequest("relay_leaf");
44070
+ const target = this.mcpCatalogTarget(req.serverId);
44071
+ const lister = this.mcpClientCapable() ? this.mcpCatalogListers.values().next().value : void 0;
44072
+ if (target === null || lister === void 0) return logMcpCatalogRequest("not_ready");
44073
+ const admitted = this.mcpCatalogRate.admit(req.serverId);
44074
+ if (admitted !== "ok") return admitted === "in_flight" ? logMcpCatalogRequest("in_flight") : void 0;
44075
+ const work = this.outsideIngestCycle(() => this.answerMcpCatalogRequest(m.channelId, req, lister)).finally(() => {
44076
+ this.mcpCatalogRate.release(req.serverId);
44077
+ this.mcpCatalogWork.delete(work);
44078
+ });
44079
+ this.mcpCatalogWork.add(work);
44080
+ } catch {
44081
+ logMcpCatalogRequest("failed");
44082
+ } finally {
44083
+ this.ownerReplayPending.delete(m.messageId);
44084
+ }
44085
+ }
44086
+ /** The signed server's vendor and the bridge catalog row's issuer, when mcp-policy.json as WRITTEN is `ok` with it `ok`. */
44087
+ mcpCatalogTarget(serverId) {
44088
+ const doc = this.mcpPolicyWritten;
44089
+ if (doc === null || doc.state !== "ok" || !Object.prototype.hasOwnProperty.call(doc.servers, serverId)) return null;
44090
+ const s = doc.servers[serverId];
44091
+ if (s.state !== "ok" || s.vendor === void 0) return null;
44092
+ const catalog = this.mcpCatalog ?? MCP_CATALOG;
44093
+ if (!Object.prototype.hasOwnProperty.call(catalog, s.vendor)) return null;
44094
+ return { vendor: s.vendor, issuer: catalog[s.vendor].issuer };
44095
+ }
44096
+ /** Ask the runtime, re-check, build (384 KB cap) and send. Never throws. */
44097
+ async answerMcpCatalogRequest(channelId, req, lister) {
44098
+ try {
44099
+ const target = this.mcpCatalogTarget(req.serverId);
44100
+ if (target === null) return logMcpCatalogRequest("not_ready");
44101
+ const frame = await lister(req.serverId, MCP_CATALOG_LIST_TIMEOUT_MS);
44102
+ if (frame === null || frame === void 0) return logMcpCatalogRequest("runtime_timeout");
44103
+ const listed = validateCatalogListReply(frame, req.serverId, target.vendor);
44104
+ if (listed === null) return logMcpCatalogRequest("runtime_refused");
44105
+ const still = this.mcpCatalogTarget(req.serverId);
44106
+ if (still === null || still.vendor !== target.vendor) return logMcpCatalogRequest("not_ready");
44107
+ await this.sendMcpCatalog(channelId, buildMcpCatalogBody({ serverId: req.serverId, requestId: req.requestId, vendor: still.vendor, issuer: still.issuer, reply: listed }));
44108
+ logMcpCatalogRequest("sent");
44109
+ } catch (e) {
44110
+ logMcpCatalogRequest(e instanceof McpReplyWithheldError ? "withheld" : "send_failed");
44111
+ }
44112
+ }
44113
+ /**
44114
+ * The `mcp_catalog` reply: a system_event envelope with a body-level type (the sendSummary shape), on the shared
44115
+ * self-heal seam and the one publish chokepoint. H-D applies to EVERY attempt (it carries vendor-supplied tool
44116
+ * descriptions): after the seal, before the POST, the leaves at the SEALED group and epoch must be exactly the owner
44117
+ * and the agent, or the frame is withheld. One message id across the retry.
44118
+ */
44119
+ async sendMcpCatalog(channelId, body) {
44120
+ const joined = await this.reconcileJoin(channelId);
44121
+ if (!joined) throw new ChannelPendingError(channelId);
44122
+ const messageId = crypto.randomUUID();
44123
+ let attempt = 0;
44124
+ await this.sendWithSelfHealTerminal(
44125
+ this.provider,
44126
+ channelId,
44127
+ (epoch) => buildOutboundEnvelope({
44128
+ custody: this.custody,
44129
+ groupKey: this.provider,
44130
+ messageId,
44131
+ channelId,
44132
+ enclaveId: this.resolveEnclave(channelId),
44133
+ senderId: this.cfg.identityId,
44134
+ senderType: "agent",
44135
+ type: "system_event",
44136
+ bodyPlaintext: JSON.stringify(body),
44137
+ bindingId: "",
44138
+ bindingVersion: "0.0.0",
44139
+ agentSkillId: null,
44140
+ epoch,
44141
+ traceparent: ZERO_TRACEPARENT,
44142
+ references: [],
44143
+ stage5Outcome: "allow",
44144
+ stage5Evaluators: []
44145
+ }),
44146
+ async (envelope) => {
44147
+ attempt++;
44148
+ this.assertMcpReplyRelayFree(channelId, envelope, attempt);
44149
+ return this.publishEnvelope(channelId, () => this.transport.publish(envelope));
44150
+ }
44151
+ );
44152
+ }
42819
44153
  async handleSummarize(channelId, request) {
42820
44154
  const body = await runSummarize({
42821
44155
  request,
@@ -43326,6 +44660,11 @@ var BridgeAgent = class _BridgeAgent {
43326
44660
  new TextDecoder().decode(m.plaintext)
43327
44661
  );
43328
44662
  if (parsed.type && parsed.type !== "text") {
44663
+ if (parsed.type === "mcp_catalog_request" || parsed.type === "mcp_catalog") {
44664
+ if (parsed.type === "mcp_catalog_request") await this.acceptMcpCatalogRequest(m, parsed);
44665
+ acked.push(m.queueId);
44666
+ continue;
44667
+ }
43329
44668
  if (parsed.type === "summarize") {
43330
44669
  const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog);
43331
44670
  if (request !== null) {
@@ -43834,7 +45173,11 @@ var BridgeAgent = class _BridgeAgent {
43834
45173
  }
43835
45174
  /** Every buffer exit: re-narrow manifest grants (review H-1), then note the frame's attribution class (review H-3). */
43836
45175
  delivering(m) {
43837
- const out = this.renarrowManifestGrants(m);
45176
+ let out = this.renarrowManifestGrants(m);
45177
+ if (!this.noteMcpDelivered(out)) {
45178
+ out = withMcpCapability(out, false);
45179
+ writeServiceLog("warn", "mcp.delivered_record_full");
45180
+ }
43838
45181
  this.noteDeliveredFrame(out);
43839
45182
  return out;
43840
45183
  }
@@ -43845,6 +45188,50 @@ var BridgeAgent = class _BridgeAgent {
43845
45188
  * audience that is still signed, else dropped. Only narrows; an unchanged message is returned by reference.
43846
45189
  */
43847
45190
  renarrowManifestGrants(m) {
45191
+ const narrowed = this.renarrowManifestGrantsBase(m);
45192
+ return this.renarrowMcpGrants(narrowed);
45193
+ }
45194
+ /**
45195
+ * MCP Task 16 (audit H-2; round 2 LOW), after the base re-narrowing: a buffered `mcp:` grant survives release only
45196
+ * when, NOW, the turn is still a plain owner-DM turn in the signed DM channel, the host switch reads "on", the
45197
+ * entitlement reading is in force, the name is not in skills.disabled, the mcp-policy.json document last written
45198
+ * lists the server ok with the tool, and a FRESH synchronous read of the DM's local leaves holds exactly the pinned
45199
+ * owner and the agent (MlsGroupKeyProvider.leafSnapshotCached: one `state` object, no await; null fails closed). Any
45200
+ * other mcp: grant is dropped, never kept as a fallback. relay_free is dropped whenever no mcp: grant survives.
45201
+ * Read-only: never touches the MLS group. Only narrows; returns `m` by reference when nothing changes.
45202
+ */
45203
+ renarrowMcpGrants(m) {
45204
+ const a = m.authorization;
45205
+ if (!a) return m;
45206
+ const isMcp = (g) => g.tool.startsWith(MCP_GRANT_PREFIX);
45207
+ if (!a.tool_grants.some(isMcp) && a.relay_free === void 0) return m;
45208
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
45209
+ const manifest = pin !== null ? this.toolGrantManifest : null;
45210
+ const turnOk = pin !== null && manifest !== null && a.audience === "owner_dm" && a.relay_speaker === void 0 && manifest.owner_dm_channel_id === m.channel_id && this.mcpHostSwitch === "on" && this.ownerDmManifestGrants && this.mcpEntitlementInForce() === "entitled" && this.ownerDmRelayFreeNow(m.channel_id, pin.ownerIdentityId);
45211
+ const kept = a.tool_grants.filter((g) => {
45212
+ if (!isMcp(g)) return true;
45213
+ const parsed = parseMcpGrant(g.tool);
45214
+ return turnOk && g.mode === "read" && g.source === "manifest" && parsed !== null && !this.payloadToolDeny.has(g.tool) && mcpPolicyAllows(this.mcpPolicyWritten, parsed.serverId, parsed.tool);
45215
+ });
45216
+ const keepRelayFree = a.relay_free === true && kept.some(isMcp);
45217
+ if (kept.length === a.tool_grants.length && keepRelayFree === (a.relay_free === true)) return m;
45218
+ const { relay_free: _relayFree, ...rest } = a;
45219
+ return { ...m, authorization: Object.freeze({ ...rest, tool_grants: Object.freeze(kept), ...keepRelayFree ? { relay_free: true } : {} }) };
45220
+ }
45221
+ /**
45222
+ * MCP Task 16 (OQ-2; audit H-2, round 2 H-D): a FRESH read of the channel's local leaf set holds exactly the pinned
45223
+ * owner and this agent. Synchronous (one manager `state`, so the leaves belong to one epoch); a cold manager or any
45224
+ * throw reads as NOT relay-free.
45225
+ */
45226
+ ownerDmRelayFreeNow(channelId, ownerIdentityId) {
45227
+ try {
45228
+ const snap = this.provider.leafSnapshotCached(channelId);
45229
+ return snap !== null && ownerDmLeavesRelayFree(snap.leaves, { ownerIdentityId, agentIdentityId: this.cfg.identityId });
45230
+ } catch {
45231
+ return false;
45232
+ }
45233
+ }
45234
+ renarrowManifestGrantsBase(m) {
43848
45235
  const a = m.authorization;
43849
45236
  if (!a || !a.tool_grants.some((g) => g.mode === "read" && g.source === "manifest")) return m;
43850
45237
  const audience = a.audience === "owner_dm" || a.audience === "team_room" ? a.audience : null;
@@ -43940,6 +45327,7 @@ var BridgeAgent = class _BridgeAgent {
43940
45327
  return;
43941
45328
  }
43942
45329
  if (!this.admitRuntimeDelivery(m)) return;
45330
+ this.noteMcpDelivered(m, true);
43943
45331
  const ch = m.channel_id;
43944
45332
  this.replayingChannels.add(ch);
43945
45333
  this.replyIntentReplays++;
@@ -44290,11 +45678,11 @@ ${channelId}`;
44290
45678
  if (!res.ok) return void 0;
44291
45679
  const rows = await res.json();
44292
45680
  if (!Array.isArray(rows)) return void 0;
44293
- const own = rows.find(
45681
+ const own2 = rows.find(
44294
45682
  (r) => r?.identity_id === this.cfg.identityId
44295
45683
  );
44296
- if (!own) return void 0;
44297
- return typeof own.paused_at === "string" && own.paused_at.length > 0;
45684
+ if (!own2) return void 0;
45685
+ return typeof own2.paused_at === "string" && own2.paused_at.length > 0;
44298
45686
  } catch (e) {
44299
45687
  writeServiceLog("warn", "pause.probe_failed", {}, e);
44300
45688
  return void 0;
@@ -44327,6 +45715,10 @@ ${channelId}`;
44327
45715
  /** Complete the durable handoff only after the runtime explicitly acks. */
44328
45716
  ackRuntimeMessages(messageIds) {
44329
45717
  for (const id of messageIds) this.roomGrantEpochs.delete(id);
45718
+ for (const id of messageIds) {
45719
+ const r = this.mcpDeliveredTurns.get(id);
45720
+ if (r) r.acked = true;
45721
+ }
44330
45722
  for (const messageId of messageIds) {
44331
45723
  this.store.ackRuntimeInbox(messageId);
44332
45724
  this.runtimePendingIds.delete(messageId);
@@ -44408,6 +45800,7 @@ ${channelId}`;
44408
45800
  const message = rest;
44409
45801
  this.applyStagedMarks(message.message_id, marks);
44410
45802
  this.runtimePendingIds.add(message.message_id);
45803
+ this.noteMcpDelivered(message, true);
44411
45804
  if (this.cfg.ownerGrantPin?.pin && this.isOwnerVerifyFailed(message.message_id)) {
44412
45805
  this.ownerVerifyFailedIds.set(message.message_id, message.channel_id);
44413
45806
  }
@@ -44588,6 +45981,67 @@ ${channelId}`;
44588
45981
  clearSearchProvidersSocket(token) {
44589
45982
  if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44590
45983
  }
45984
+ /** MCP Task 16 (spec §15): the same AND-over-sockets state for mcp_client_v1, kept per socket. */
45985
+ mcpClientSockets = /* @__PURE__ */ new Map();
45986
+ noteMcpClientSocket(token, declared) {
45987
+ this.mcpClientSockets.set(token, declared);
45988
+ this.onCapabilityMaybeChanged();
45989
+ }
45990
+ clearMcpClientSocket(token) {
45991
+ if (this.mcpClientSockets.delete(token)) this.onCapabilityMaybeChanged();
45992
+ }
45993
+ /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
45994
+ noteMcpCatalogLister(token, lister) {
45995
+ if (lister === null) this.mcpCatalogListers.delete(token);
45996
+ else this.mcpCatalogListers.set(token, lister);
45997
+ }
45998
+ /**
45999
+ * MCP Task 19 (spec §12.3): "ok" when every attached runtime socket declared mcp_client_v1 (the AND, which harness
46000
+ * sockets hold false: a harness socket counts in the AND and never declares it); else "harness_consumer" when the
46001
+ * latest hello was a harness; else "missing".
46002
+ */
46003
+ reportedMcpCapability() {
46004
+ if (this.mcpClientCapable()) return "ok";
46005
+ return this.lastConsumerCapability === "harness_consumer" ? "harness_consumer" : "missing";
46006
+ }
46007
+ /**
46008
+ * MCP Task 19 (spec §12.3): at most 8 `{server_id, vendor, state}` for the signed servers of the manifest in force,
46009
+ * from mcp-policy.json as WRITTEN. Only the states the bridge itself knows: top `kill_switch` and `not_entitled` give
46010
+ * that state per server; top `ok` gives `ok` for a server the bridge lists `ok` and `malformed` for one it refused.
46011
+ * Every other top state (no capability, suspended, no config, switch off, section absent, malformed section) gives no
46012
+ * row. The runtime-only states (needs_login, login_expired, definition_changed, server_version_unsupported,
46013
+ * server_unreachable) have no runtime-to-bridge report path in Phase 1. `vendor` is reported only when it is a key of
46014
+ * the bridge's compiled catalog; a signed vendor outside it gives no row. Never a tool name, URL or issuer.
46015
+ */
46016
+ reportedMcpServers() {
46017
+ const doc = this.mcpPolicyWritten;
46018
+ const signed = (this.cfg.ownerGrantPin?.pin ?? null) !== null ? this.toolGrantManifest?.mcp?.servers : void 0;
46019
+ if (doc === null || signed === void 0) return [];
46020
+ const catalog = this.mcpCatalog ?? MCP_CATALOG;
46021
+ const out = [];
46022
+ for (const id of Object.keys(signed).sort()) {
46023
+ if (out.length >= 8) break;
46024
+ const vendor = signed[id].vendor;
46025
+ if (!Object.prototype.hasOwnProperty.call(catalog, vendor)) continue;
46026
+ let state;
46027
+ if (doc.state === "kill_switch" || doc.state === "not_entitled") state = doc.state;
46028
+ else if (doc.state === "ok") state = Object.prototype.hasOwnProperty.call(doc.servers, id) && doc.servers[id].state === "ok" ? "ok" : "malformed";
46029
+ else continue;
46030
+ out.push({ server_id: id, vendor, state });
46031
+ }
46032
+ return out;
46033
+ }
46034
+ /**
46035
+ * MCP Task 16: every attached runtime socket declared mcp_client_v1; no socket attached means absent (false).
46036
+ * PR-B2 follow-up (documented): a HARNESS socket counts in this AND. ws-server notes every socket that said hello,
46037
+ * and a harness hello is always noted `false` (harnessClaimed never resets), so one attached harness connector holds
46038
+ * the capability false for files, the report and mcp-policy.json (top `no_capability`) while it is attached.
46039
+ */
46040
+ mcpClientCapable() {
46041
+ if (this.mcpClientSockets.size === 0) return false;
46042
+ for (const v of this.mcpClientSockets.values()) if (!v) return false;
46043
+ return true;
46044
+ }
44591
46045
  noteUtilityToolsSocket(token, declared) {
44592
46046
  this.utilityToolsSockets.set(token, declared);
44593
46047
  this.onCapabilityMaybeChanged();
@@ -44606,6 +46060,7 @@ ${channelId}`;
44606
46060
  const out = /* @__PURE__ */ new Set();
44607
46061
  if (this.searchProvidersCapable()) out.add("search_providers_v2");
44608
46062
  if (this.utilityToolsCapable()) out.add("utility_tools_v1");
46063
+ if (this.mcpClientCapable()) out.add("mcp_client_v1");
44609
46064
  return out;
44610
46065
  }
44611
46066
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
@@ -44634,11 +46089,12 @@ ${channelId}`;
44634
46089
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
44635
46090
  */
44636
46091
  onCapabilityMaybeChanged() {
44637
- const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
46092
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable(), mcp: this.mcpClientCapable() };
44638
46093
  const prev = this.capabilityState;
44639
- if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
46094
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility && prev.mcp === next.mcp) return;
44640
46095
  this.capabilityState = next;
44641
46096
  this.recomputeManifestPolicy(this.capabilityFeatures());
46097
+ this.recomputeMcpPolicy();
44642
46098
  if (this.closingDown) return;
44643
46099
  if (prev === null && !next.search && !next.utility) return;
44644
46100
  const now = Date.now();
@@ -44884,6 +46340,7 @@ ${channelId}`;
44884
46340
  async closeOnce() {
44885
46341
  this.closingDown = true;
44886
46342
  this.statusGate.close();
46343
+ this.toolStatusSender.close();
44887
46344
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
44888
46345
  this.pauseRecheckTimers.clear();
44889
46346
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -44892,6 +46349,8 @@ ${channelId}`;
44892
46349
  this.redeliveryTimer = null;
44893
46350
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
44894
46351
  this.searchCapabilityRefetchTimer = null;
46352
+ if (this.mcpPolicyRetryTimer !== null) clearTimeout(this.mcpPolicyRetryTimer);
46353
+ this.mcpPolicyRetryTimer = null;
44895
46354
  this.pingHandle?.close();
44896
46355
  if (this.safetyTimer) clearInterval(this.safetyTimer);
44897
46356
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
@@ -45100,63 +46559,6 @@ function wsErrorFrame(code, detail) {
45100
46559
  return { type: "error", ...errorBody(code, detail) };
45101
46560
  }
45102
46561
 
45103
- // src/consumer-grants.ts
45104
- var NO_GRANTS = Object.freeze([]);
45105
- var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
45106
- function withConsumerGrants(m, perToolConsumer) {
45107
- if (perToolConsumer) return m;
45108
- const a = m.authorization;
45109
- if (!a) return m;
45110
- const stripGrants = !(a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner");
45111
- const stripMemory = "memory_write" in a;
45112
- if (!stripGrants && !stripMemory) return m;
45113
- const { memory_write: _dropped, ...rest } = a;
45114
- return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
45115
- }
45116
- var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
45117
- var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
45118
- var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
45119
- var CONSTANT_TOOL_NAMES = Object.freeze({
45120
- owner_dm: Object.freeze(["current_time", "web_search"]),
45121
- team_room: Object.freeze(["current_time"])
45122
- });
45123
- var isManifestGrant = (g) => g.mode === "read" && g.source === "manifest";
45124
- function withManifestCapability(m, manifestConsumer) {
45125
- if (manifestConsumer) return m;
45126
- const a = m.authorization;
45127
- if (!a || !a.tool_grants.some(isManifestGrant)) return m;
45128
- const constants5 = new Set(a.audience === "owner_dm" || a.audience === "team_room" ? CONSTANT_TOOL_NAMES[a.audience] : []);
45129
- const narrowed = a.tool_grants.flatMap((g) => {
45130
- if (!isManifestGrant(g)) return [g];
45131
- return constants5.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
45132
- });
45133
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(narrowed) }) };
45134
- }
45135
- function withHarnessGrants(m, withheld) {
45136
- if (!withheld) return m;
45137
- const a = m.authorization;
45138
- if (!a || a.tool_grants.length === 0) return m;
45139
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
45140
- }
45141
- function withSearchCapability(m, consumerHasFeature, signedProvider) {
45142
- if (consumerHasFeature) return m;
45143
- const a = m.authorization;
45144
- if (!a) return m;
45145
- const stripSearch = signedProvider !== void 0 && signedProvider !== "brave";
45146
- const kept = a.tool_grants.filter((g) => g.tool !== "web_extract" && !(stripSearch && g.tool === "web_search"));
45147
- if (kept.length === a.tool_grants.length) return m;
45148
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45149
- }
45150
- var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
45151
- function withUtilityCapability(m, consumerHasFeature) {
45152
- if (consumerHasFeature) return m;
45153
- const a = m.authorization;
45154
- if (!a) return m;
45155
- const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
45156
- if (kept.length === a.tool_grants.length) return m;
45157
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45158
- }
45159
-
45160
46562
  // src/http-server.ts
45161
46563
  function writeSse(res, msgs, onWriteError) {
45162
46564
  for (let i = 0; i < msgs.length; i++) {
@@ -45179,7 +46581,8 @@ data: ${JSON.stringify(msgs[i])}
45179
46581
  function deliverSseBatch(res, batch, agent, onWriteError) {
45180
46582
  const withheld = agent.harnessGrantsWithheld();
45181
46583
  const provider = agent.signedSearchProvider();
45182
- const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
46584
+ const hold = agent.r3Hold();
46585
+ const left3 = writeSse(res, batch.map((m) => withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false), false), hold)), onWriteError);
45183
46586
  const leftIds = new Set(left3.map((message) => message.message_id));
45184
46587
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
45185
46588
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -45456,7 +46859,8 @@ function createHttpServer(agent, opts) {
45456
46859
  const drained = await agent.drain(channel, wait);
45457
46860
  const withheld = agent.harnessGrantsWithheld();
45458
46861
  const provider = agent.signedSearchProvider();
45459
- return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
46862
+ const hold = agent.r3Hold();
46863
+ return json(res, 200, drained.map((m) => withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false), false), hold)));
45460
46864
  }
45461
46865
  if (method === "GET" && path === "/v1/events") {
45462
46866
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -45526,6 +46930,7 @@ function parseHarnessReport(raw) {
45526
46930
  }
45527
46931
 
45528
46932
  // src/ws-server.ts
46933
+ import { randomBytes as randomBytes12 } from "node:crypto";
45529
46934
  import { WebSocketServer, WebSocket } from "ws";
45530
46935
 
45531
46936
  // src/memory/consumer-notes.ts
@@ -45636,6 +47041,8 @@ function attachAgentWebSocket(server, agent, opts) {
45636
47041
  manifestGrants: false,
45637
47042
  searchProviders: false,
45638
47043
  utilityTools: false,
47044
+ mcpClient: false,
47045
+ pendingCatalog: /* @__PURE__ */ new Map(),
45639
47046
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
45640
47047
  deliveryOpen: helloWaitMs <= 0,
45641
47048
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -45689,14 +47096,14 @@ function attachAgentWebSocket(server, agent, opts) {
45689
47096
  continue;
45690
47097
  }
45691
47098
  const out = withConsumerNotes(
45692
- withUtilityCapability(withSearchCapability(
47099
+ withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(
45693
47100
  withManifestCapability(
45694
47101
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
45695
47102
  session.manifestGrants
45696
47103
  ),
45697
47104
  session.searchProviders,
45698
47105
  agent.signedSearchProvider()
45699
- ), session.utilityTools),
47106
+ ), session.utilityTools), session.mcpClient), agent.r3Hold()),
45700
47107
  batch[i],
45701
47108
  session,
45702
47109
  agent
@@ -45754,6 +47161,10 @@ function attachAgentWebSocket(server, agent, opts) {
45754
47161
  if (session.manifestGrants) agent.clearManifestConsumer(session);
45755
47162
  agent.clearSearchProvidersSocket(session);
45756
47163
  agent.clearUtilityToolsSocket(session);
47164
+ agent.clearMcpClientSocket(session);
47165
+ agent.noteMcpCatalogLister(session, null);
47166
+ for (const settle of session.pendingCatalog.values()) settle(null);
47167
+ session.pendingCatalog.clear();
45757
47168
  };
45758
47169
  current = { sock, cleanup, sweepStale };
45759
47170
  sock.on("close", cleanup);
@@ -45787,6 +47198,32 @@ function attachAgentWebSocket(server, agent, opts) {
45787
47198
  }
45788
47199
  };
45789
47200
  }
47201
+ var MCP_CATALOG_PENDING_MAX = 8;
47202
+ function mcpCatalogLister(sock, session) {
47203
+ return (serverId, timeoutMs) => new Promise((resolve5) => {
47204
+ if (session.closed || sock.readyState !== WebSocket.OPEN || session.pendingCatalog.size >= MCP_CATALOG_PENDING_MAX) {
47205
+ resolve5(null);
47206
+ return;
47207
+ }
47208
+ const requestId = randomBytes12(16).toString("hex");
47209
+ const timer = setTimeout(() => {
47210
+ session.pendingCatalog.delete(requestId);
47211
+ resolve5(null);
47212
+ }, timeoutMs);
47213
+ timer.unref?.();
47214
+ session.pendingCatalog.set(requestId, (frame) => {
47215
+ clearTimeout(timer);
47216
+ resolve5(frame);
47217
+ });
47218
+ try {
47219
+ sock.send(JSON.stringify({ type: "mcp_catalog_list", request_id: requestId, server_id: serverId }));
47220
+ } catch {
47221
+ session.pendingCatalog.delete(requestId);
47222
+ clearTimeout(timer);
47223
+ resolve5(null);
47224
+ }
47225
+ });
47226
+ }
45790
47227
  function sendJson(sock, body) {
45791
47228
  if (sock.readyState !== WebSocket.OPEN) return;
45792
47229
  try {
@@ -45860,6 +47297,9 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45860
47297
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45861
47298
  session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45862
47299
  agent.noteUtilityToolsSocket(session, session.utilityTools);
47300
+ session.mcpClient = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(MANIFEST_GRANTS_FEATURE) && m.features.includes(MCP_CLIENT_FEATURE);
47301
+ agent.noteMcpClientSocket(session, session.mcpClient);
47302
+ agent.noteMcpCatalogLister(session, session.mcpClient ? mcpCatalogLister(sock, session) : null);
45863
47303
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
45864
47304
  agent.noteNotesConsumer();
45865
47305
  }
@@ -45867,13 +47307,21 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45867
47307
  if (session.recallConsumer) agent.noteRecallConsumer();
45868
47308
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
45869
47309
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
45870
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE] });
47310
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE, MCP_POLICY_WRITER_FEATURE] });
45871
47311
  if (!session.deliveryOpen) {
45872
47312
  session.deliveryOpen = true;
45873
47313
  flush();
45874
47314
  }
45875
47315
  return;
45876
47316
  }
47317
+ if (m.type === "mcp_catalog_list_result") {
47318
+ const id = m.request_id;
47319
+ const settle = typeof id === "string" ? session.pendingCatalog.get(id) : void 0;
47320
+ if (settle === void 0) return;
47321
+ session.pendingCatalog.delete(id);
47322
+ settle(m);
47323
+ return;
47324
+ }
45877
47325
  if (m.type === "nack") {
45878
47326
  if (typeof m.message_id !== "string" || !m.message_id) {
45879
47327
  sendJson(sock, wsErrorFrame("invalid_request"));