@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/index.js CHANGED
@@ -3083,7 +3083,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
3083
3083
  if (outputLen !== expectedLen)
3084
3084
  throw new Error(`hash.outputLen must be ${expectedLen}, got ${outputLen}`);
3085
3085
  }
3086
- const randomBytes12 = opts.randomBytes === void 0 ? randomBytes4 : opts.randomBytes;
3086
+ const randomBytes13 = opts.randomBytes === void 0 ? randomBytes4 : opts.randomBytes;
3087
3087
  const adjustScalarBytes3 = opts.adjustScalarBytes === void 0 ? (bytes) => bytes : opts.adjustScalarBytes;
3088
3088
  const domain = opts.domain === void 0 ? (data, ctx, phflag) => {
3089
3089
  abool2(phflag, "phflag");
@@ -3169,7 +3169,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
3169
3169
  seed: _size
3170
3170
  };
3171
3171
  function randomSecretKey(seed) {
3172
- seed = seed === void 0 ? randomBytes12(lengths.seed) : seed;
3172
+ seed = seed === void 0 ? randomBytes13(lengths.seed) : seed;
3173
3173
  return abytes3(seed, lengths.seed, "seed");
3174
3174
  }
3175
3175
  function isValidSecretKey(key) {
@@ -6418,7 +6418,7 @@ function ecdsa(Point, hash, ecdsaOpts = {}) {
6418
6418
  bits2int_modN: "function"
6419
6419
  });
6420
6420
  ecdsaOpts = Object.assign({}, ecdsaOpts);
6421
- const randomBytes12 = ecdsaOpts.randomBytes === void 0 ? randomBytes4 : ecdsaOpts.randomBytes;
6421
+ const randomBytes13 = ecdsaOpts.randomBytes === void 0 ? randomBytes4 : ecdsaOpts.randomBytes;
6422
6422
  const hmac5 = ecdsaOpts.hmac === void 0 ? (key, msg) => hmac(hash_, key, msg) : ecdsaOpts.hmac;
6423
6423
  const { Fp: Fp3, Fn: Fn3 } = Point;
6424
6424
  const { ORDER: CURVE_ORDER, BITS: fnBits } = Fn3;
@@ -6564,7 +6564,7 @@ function ecdsa(Point, hash, ecdsaOpts = {}) {
6564
6564
  throw new Error("invalid private key");
6565
6565
  const seedArgs = [int2octets(d), int2octets(h1int)];
6566
6566
  if (extraEntropy != null && extraEntropy !== false) {
6567
- const e = extraEntropy === true ? randomBytes12(lengths.secretKey) : extraEntropy;
6567
+ const e = extraEntropy === true ? randomBytes13(lengths.secretKey) : extraEntropy;
6568
6568
  seedArgs.push(abytes3(e, void 0, "extraEntropy"));
6569
6569
  }
6570
6570
  const seed = concatBytes4(...seedArgs);
@@ -18415,8 +18415,8 @@ function sameRef(a, b) {
18415
18415
  // ../connect-sdk-ts/dist/groupkey/own-removal.js
18416
18416
  async function probeOwnRemoval(mgr, commitBytes) {
18417
18417
  try {
18418
- const own = mgr.state?.privatePath?.leafIndex;
18419
- if (typeof own !== "number")
18418
+ const own2 = mgr.state?.privatePath?.leafIndex;
18419
+ if (typeof own2 !== "number")
18420
18420
  return null;
18421
18421
  if (!hoistedTsMlsMatchesCrypto(mgr))
18422
18422
  return null;
@@ -18441,7 +18441,7 @@ async function probeOwnRemoval(mgr, commitBytes) {
18441
18441
  committer = typeof incoming.senderLeafIndex === "number" ? Number(incoming.senderLeafIndex) : null;
18442
18442
  for (const p of incoming.proposals) {
18443
18443
  const prop = p.proposal;
18444
- if (prop.proposalType === "remove" && Number(prop.remove?.removed) === own)
18444
+ if (prop.proposalType === "remove" && Number(prop.remove?.removed) === own2)
18445
18445
  removesOwn = true;
18446
18446
  }
18447
18447
  }
@@ -18449,7 +18449,7 @@ async function probeOwnRemoval(mgr, commitBytes) {
18449
18449
  }, cs);
18450
18450
  if (!sawCommit || !removesOwn)
18451
18451
  return null;
18452
- return { ownLeafIndex: own, committerLeafIndex: committer };
18452
+ return { ownLeafIndex: own2, committerLeafIndex: committer };
18453
18453
  } catch {
18454
18454
  return null;
18455
18455
  }
@@ -19311,6 +19311,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19311
19311
  }
19312
19312
  return { ownLeafIndex: this.ownLeafIndex(mgr), leaves };
19313
19313
  }
19314
+ /**
19315
+ * MCP client (bridge Task 16; audit H-2, round 2 H-D): a SYNCHRONOUS, read-only snapshot of the WARM in-memory
19316
+ * manager for `channelId`: its group id, its epoch and EVERY non-blank leaf, reported exactly as
19317
+ * {@link leafTreeSummary} reports them (non-basic credentials included, identity null, #771 HIGH-B). All three are
19318
+ * read from ONE `state` object (MLSGroupManager replaces `state` whole on every commit), with no await, so they belong
19319
+ * to the same epoch and no commit can land between them.
19320
+ *
19321
+ * `null` when no manager is warm: it never loads from the store, never takes the channel lock, never touches the
19322
+ * network and never mutates. A caller treating `null` as "cannot tell" fails closed.
19323
+ */
19324
+ leafSnapshotCached(channelId) {
19325
+ const state = this.managers.get(channelId)?.state;
19326
+ const tree = state?.ratchetTree;
19327
+ if (!state || !tree)
19328
+ return null;
19329
+ const leaves = [];
19330
+ const numLeaves = Math.floor((tree.length + 1) / 2);
19331
+ for (let i = 0; i < numLeaves; i++) {
19332
+ const node = tree[i * 2];
19333
+ if (!node || node.nodeType !== "leaf")
19334
+ continue;
19335
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(i));
19336
+ const basic = cred.credentialType === "basic";
19337
+ leaves.push({ leafIndex: i, basic, identity: basic ? new TextDecoder().decode(cred.identity) : null });
19338
+ }
19339
+ return { groupId: new TextDecoder().decode(state.groupContext.groupId), epoch: Number(state.groupContext.epoch), leaves };
19340
+ }
19314
19341
  async fetchDeviceLeafCredential(deviceId) {
19315
19342
  const kps = await this.o.backend.fetchKeyPackages([deviceId]);
19316
19343
  const hex = kps[deviceId];
@@ -26644,10 +26671,12 @@ function openSchema(db) {
26644
26671
  ensureRuntimeReplyIntent(db);
26645
26672
  ensureRuntimeReplyIntentEnvelopeGroup(db);
26646
26673
  ensureRuntimeReplyIntentCredit(db);
26674
+ ensureRuntimeReplyIntentMcpTurn(db);
26647
26675
  ensureGroupLink(db);
26648
26676
  ensureMlsCustody(db);
26649
26677
  ensureToolGrantTables(db);
26650
26678
  ensureToolGrantAppliedTable(db);
26679
+ ensureToolGrantMcpSeenTable(db);
26651
26680
  ensureRoomSearchCapTable(db);
26652
26681
  ensureVoiceChannelParentTable(db);
26653
26682
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
@@ -26737,6 +26766,19 @@ function ensureRuntimeReplyIntentCredit(db) {
26737
26766
  throw e;
26738
26767
  }
26739
26768
  }
26769
+ function ensureRuntimeReplyIntentMcpTurn(db) {
26770
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "mcp_turn");
26771
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
26772
+ if (cols.length === 0 || cols.some((c) => c.name === "mcp_turn"))
26773
+ return;
26774
+ try {
26775
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN mcp_turn INTEGER NOT NULL DEFAULT 0");
26776
+ } catch (e) {
26777
+ if (hasColumn())
26778
+ return;
26779
+ throw e;
26780
+ }
26781
+ }
26740
26782
  var RECALL_STATE_COLUMNS = [
26741
26783
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
26742
26784
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -26848,6 +26890,15 @@ function ensureToolGrantAppliedTable(db) {
26848
26890
  );
26849
26891
  `);
26850
26892
  }
26893
+ function ensureToolGrantMcpSeenTable(db) {
26894
+ db.exec(`
26895
+ CREATE TABLE IF NOT EXISTS tool_grant_mcp_seen (
26896
+ identity_id TEXT PRIMARY KEY,
26897
+ sealed_state BLOB NOT NULL,
26898
+ updated_at INTEGER NOT NULL
26899
+ );
26900
+ `);
26901
+ }
26851
26902
  function ensureVoiceChannelParentTable(db) {
26852
26903
  db.exec(`
26853
26904
  CREATE TABLE IF NOT EXISTS voice_channel_parent (
@@ -27788,8 +27839,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27788
27839
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
27789
27840
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
27790
27841
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27791
- sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
27792
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
27842
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit, mcp_turn)
27843
+ 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);
27793
27844
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
27794
27845
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
27795
27846
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -27799,7 +27850,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27799
27850
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
27800
27851
  }
27801
27852
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27802
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27853
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
27803
27854
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
27804
27855
  if (!row)
27805
27856
  throw new Error("reply intent vanished after claim");
@@ -27808,7 +27859,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27808
27859
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
27809
27860
  async listReplyIntents(inReplyTo) {
27810
27861
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27811
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27862
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
27812
27863
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
27813
27864
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
27814
27865
  const out = [];
@@ -27819,7 +27870,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27819
27870
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
27820
27871
  async getReplyIntent(scope, part) {
27821
27872
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27822
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27873
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit, mcp_turn
27823
27874
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
27824
27875
  return row ? this.openReplyIntent(row) : null;
27825
27876
  }
@@ -27894,7 +27945,9 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27894
27945
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
27895
27946
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
27896
27947
  createdAt: Number(row.created_at),
27897
- replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
27948
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null,
27949
+ // Any value other than exactly 1 (0, NULL, a missing column) is false; the bridge's MAC decides what a 1 means.
27950
+ mcpTurn: Number(row.mcp_turn ?? 0) === 1
27898
27951
  };
27899
27952
  }
27900
27953
  async recordMemory(input) {
@@ -28466,10 +28519,10 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28466
28519
  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);
28467
28520
  if (r && Number(r.b) >= 0 && Number(r.n) >= 0)
28468
28521
  return { items: Number(r.n), bytes: Number(r.b) };
28469
- const own = this.scopeLoad("i.scope_kind='owner_dm'");
28522
+ const own2 = this.scopeLoad("i.scope_kind='owner_dm'");
28470
28523
  this.ensureStateRow();
28471
- this.db.prepare("UPDATE agent_memory_state SET owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(own.bytes, own.items, this.id);
28472
- return own;
28524
+ this.db.prepare("UPDATE agent_memory_state SET owner_size_bytes=?, owner_item_count=? WHERE identity_id=?").run(own2.bytes, own2.items, this.id);
28525
+ return own2;
28473
28526
  }
28474
28527
  /** 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. */
28475
28528
  deleteItemGroup(firstItemId) {
@@ -28643,8 +28696,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28643
28696
  const o2 = this.overCap();
28644
28697
  if (!o2.overItems && !o2.overBytes)
28645
28698
  break;
28646
- const own = this.ownerLoad();
28647
- const protectOwner = (!o2.overItems || own.items < this.itemCap * OWNER_DM_FLOOR_SHARE) && (!o2.overBytes || own.bytes < this.byteCap * OWNER_DM_FLOOR_SHARE);
28699
+ const own2 = this.ownerLoad();
28700
+ const protectOwner = (!o2.overItems || own2.items < this.itemCap * OWNER_DM_FLOOR_SHARE) && (!o2.overBytes || own2.bytes < this.byteCap * OWNER_DM_FLOOR_SHARE);
28648
28701
  const v = (protectOwner ? this.oldestAutoGroup({ notOwnerDm: true }) : void 0) ?? this.oldestAutoGroup({});
28649
28702
  if (v === void 0 || !drop(v))
28650
28703
  break;
@@ -28671,8 +28724,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28671
28724
  FROM agent_memory_item i WHERE identity_id=?`).get(this.id);
28672
28725
  const tk = this.db.prepare("SELECT COUNT(*) AS n FROM agent_memory_token WHERE identity_id=?").get(this.id);
28673
28726
  const size = Number(it.b) + Number(it.rows) * ITEM_ROW_OVERHEAD_BYTES + Number(tk.n) * TOKEN_ROW_OVERHEAD_BYTES;
28674
- const own = this.scopeLoad("i.scope_kind='owner_dm'");
28675
- 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);
28727
+ const own2 = this.scopeLoad("i.scope_kind='owner_dm'");
28728
+ 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);
28676
28729
  }
28677
28730
  /** Inside a transaction: delete every unified row of the identity and move each channel's content epoch. */
28678
28731
  deleteAllItems() {
@@ -29488,9 +29541,40 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
29488
29541
  dropToolGrantAppliedExcept(pinKeyB64) {
29489
29542
  return Number(this.db.prepare("DELETE FROM tool_grant_applied WHERE identity_id=? AND pin_key_b64<>?").run(this.id, pinKeyB64).changes);
29490
29543
  }
29544
+ // MCP client Phase 1, Task 14 (OQ-3 ruling item 3): the sticky R-3 flag, per agent identity.
29545
+ /**
29546
+ * False when never written. A row that does not open, or opens to anything but this identity's own record, THROWS:
29547
+ * the bridge then holds R-3 (fail closed). Never returns false for an unreadable row.
29548
+ */
29549
+ async loadMcpSeen() {
29550
+ const row = this.db.prepare("SELECT sealed_state FROM tool_grant_mcp_seen WHERE identity_id=?").get(this.id);
29551
+ if (!row)
29552
+ return false;
29553
+ const v = await this.unsealJson(row.sealed_state);
29554
+ if (v === null || typeof v !== "object" || v.identity_id !== this.id || typeof v.seen !== "boolean") {
29555
+ throw new MlsStoreSealError("tool_grant_mcp_seen: unexpected record");
29556
+ }
29557
+ return v.seen;
29558
+ }
29559
+ /** The sealed value names its identity, so a blob copied to another agent's row does not read as that agent's. */
29560
+ async saveMcpSeen(seen) {
29561
+ const sealed = await this.sealJson({ identity_id: this.id, seen });
29562
+ this.db.prepare(`INSERT INTO tool_grant_mcp_seen (identity_id, sealed_state, updated_at) VALUES (?, ?, ?)
29563
+ ON CONFLICT(identity_id) DO UPDATE SET sealed_state = excluded.sealed_state, updated_at = excluded.updated_at`).run(this.id, sealed, Date.now());
29564
+ }
29491
29565
  hasOwnerMessage(channelId, messageId) {
29492
29566
  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;
29493
29567
  }
29568
+ /**
29569
+ * MCP client Phase 1, Task 18 (audit M-3): the owner replay row for a verified owner message that is NEVER staged for
29570
+ * the runtime (an `mcp_catalog_request`), so a redelivery of it is classed `replay`, after a restart too. The same row
29571
+ * stageRuntimeInbox writes in its staging transaction; insert-if-absent; pruned by pruneOwnerReplay like any other.
29572
+ */
29573
+ recordOwnerMessage(channelId, messageId, groupId, epoch, processedAt = Date.now()) {
29574
+ this.db.prepare(`INSERT OR IGNORE INTO owner_replay
29575
+ (identity_id, channel_id, message_id, group_id, epoch, processed_at)
29576
+ VALUES (?, ?, ?, ?, ?, ?)`).run(this.id, channelId, messageId, groupId, epoch, processedAt);
29577
+ }
29494
29578
  isOwnerEpochClosed(channelId, groupId, epoch) {
29495
29579
  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;
29496
29580
  }
@@ -29664,8 +29748,9 @@ function decodeReplyBody(bytes) {
29664
29748
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
29665
29749
  throw new Error("reply intent body is malformed");
29666
29750
  }
29667
- function replyBodyMac(key, replyId, channelId, body, credit) {
29668
- 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");
29751
+ function replyBodyMac(key, replyId, channelId, body, credit, mcpTurn) {
29752
+ const label = mcpTurn === true ? ["reply-body", replyId, channelId, credit ?? null, "mcp_turn"] : credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit];
29753
+ return createHmac("sha256", key).update(JSON.stringify(label)).update("\0").update(body).digest("hex");
29669
29754
  }
29670
29755
  function partOrdinal(part) {
29671
29756
  const m = /^p:(\d+)$/.exec(part);
@@ -31380,8 +31465,8 @@ var MembershipPurgeSweeper = class {
31380
31465
  const dms = /* @__PURE__ */ new Set();
31381
31466
  for (const c of this.d.conversations()) {
31382
31467
  if (stopping()) return { purged: [], held: 0, absent: 0 };
31383
- const own = this.d.enclaveOf(c);
31384
- const channels = own === void 0 ? void 0 : listed.get(own);
31468
+ const own2 = this.d.enclaveOf(c);
31469
+ const channels = own2 === void 0 ? void 0 : listed.get(own2);
31385
31470
  if (channels === void 0) continue;
31386
31471
  if (channels.has(c)) {
31387
31472
  this.d.clearAbsence(c);
@@ -34928,7 +35013,7 @@ function assertManagedName(name) {
34928
35013
  }
34929
35014
  function isLocalOnlyName(name) {
34930
35015
  const n = name.toLowerCase().replace(/[. ]+$/, "");
34931
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets" || n === MANIFEST_POLICY_FILE;
35016
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets" || n === MANIFEST_POLICY_FILE || n === MCP_POLICY_FILE;
34932
35017
  }
34933
35018
  var MANIFEST_POLICY_FILE = "manifest-policy.json";
34934
35019
  function removeManagedLlmSource(home, identityId, deps = REAL_FS) {
@@ -34947,6 +35032,28 @@ function writeManifestPolicy(home, identityId, policy, deps = REAL_FS) {
34947
35032
  }
34948
35033
  atomicWrite(join16(dir, MANIFEST_POLICY_FILE), JSON.stringify(policy) + "\n", deps);
34949
35034
  }
35035
+ var MCP_POLICY_FILE = "mcp-policy.json";
35036
+ var MCP_POLICY_TOP_STATES = Object.freeze([
35037
+ "ok",
35038
+ "not_entitled",
35039
+ "kill_switch",
35040
+ "suspended",
35041
+ "no_config",
35042
+ "switch_off",
35043
+ "no_manifest",
35044
+ "malformed",
35045
+ "no_capability",
35046
+ "section_absent"
35047
+ ]);
35048
+ function writeMcpPolicy(home, identityId, doc, deps = REAL_FS) {
35049
+ const parent3 = join16(home, ".securecoms", "runtime-config");
35050
+ const dir = runtimeConfigDir(home, identityId);
35051
+ for (const d of [join16(home, ".securecoms"), parent3, dir]) {
35052
+ deps.mkdir(d, 448);
35053
+ deps.chmod(d, 448);
35054
+ }
35055
+ atomicWrite(join16(dir, MCP_POLICY_FILE), JSON.stringify(doc) + "\n", deps);
35056
+ }
34950
35057
  function atomicWrite(path, body, deps) {
34951
35058
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
34952
35059
  deps.writeFile(tmp, body, 384);
@@ -35304,6 +35411,193 @@ function loadOwnerGrantPin(opts) {
35304
35411
  return { state: "ok", pin: one };
35305
35412
  }
35306
35413
 
35414
+ // src/grants/mcp-section.ts
35415
+ var MCP_MAX_SERVERS = 8;
35416
+ var MCP_MAX_TOOLS_PER_SERVER = 64;
35417
+ var MCP_MAX_SIGNED_TOOLS = 160;
35418
+ var MCP_MAX_REMOVED = 8;
35419
+ var SECURECOMS_CIMD_CLIENT_ID = "https://app.securecoms.ai/oauth/securecoms-agent-client.json";
35420
+ var MCP_SERVER_ID = /^[a-z0-9-]{1,24}$/;
35421
+ var MCP_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
35422
+ var VENDOR_KEY = /^[a-z0-9_]{1,32}$/;
35423
+ var MCP_RESERVED_NAMES = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
35424
+ var HEX64 = /^[0-9a-f]{64}$/;
35425
+ var PRINTABLE_ASCII = /^[\x21-\x7e]{1,256}$/;
35426
+ var MCP_GRANT = /^mcp:([a-z0-9-]{1,24})\/([A-Za-z0-9_.-]{1,64})$/;
35427
+ var isBag = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
35428
+ var keysExactly = (o, keys) => {
35429
+ const have = Object.keys(o);
35430
+ return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(o, k));
35431
+ };
35432
+ function canonicalHttpsUrl(v) {
35433
+ if (typeof v !== "string" || !PRINTABLE_ASCII.test(v) || !URL.canParse(v)) return false;
35434
+ const u = new URL(v);
35435
+ return u.protocol === "https:" && u.username === "" && u.password === "" && u.href === v;
35436
+ }
35437
+ function httpsIssuer(v) {
35438
+ if (typeof v !== "string" || !PRINTABLE_ASCII.test(v) || !URL.canParse(v)) return false;
35439
+ const u = new URL(v);
35440
+ return u.protocol === "https:" && u.username === "" && u.password === "" && u.search === "" && u.hash === "" && (u.href === v || u.href === `${v}/`);
35441
+ }
35442
+ var MCP_AUTH_KEYS = Object.freeze({
35443
+ oauth_securecoms_cimd: Object.freeze(["mode", "issuer", "client"]),
35444
+ oauth_customer: Object.freeze(["mode", "issuer", "client", "client_id"]),
35445
+ key: Object.freeze(["mode"])
35446
+ });
35447
+ function parseAuth(v) {
35448
+ if (!isBag(v)) return null;
35449
+ if (v.mode === "key") return keysExactly(v, MCP_AUTH_KEYS.key) ? Object.freeze({ mode: "key" }) : null;
35450
+ if (v.mode !== "oauth" || !httpsIssuer(v.issuer)) return null;
35451
+ if (v.client === "securecoms_cimd") {
35452
+ if (!keysExactly(v, MCP_AUTH_KEYS.oauth_securecoms_cimd)) return null;
35453
+ return Object.freeze({ mode: "oauth", issuer: v.issuer, client: "securecoms_cimd" });
35454
+ }
35455
+ if (v.client === "customer") {
35456
+ if (!keysExactly(v, MCP_AUTH_KEYS.oauth_customer)) return null;
35457
+ if (typeof v.client_id !== "string" || !PRINTABLE_ASCII.test(v.client_id)) return null;
35458
+ return Object.freeze({ mode: "oauth", issuer: v.issuer, client: "customer", client_id: v.client_id });
35459
+ }
35460
+ return null;
35461
+ }
35462
+ function parseTools(v) {
35463
+ if (!isBag(v)) return null;
35464
+ const entries = Object.entries(v);
35465
+ if (entries.length > MCP_MAX_TOOLS_PER_SERVER) return null;
35466
+ const out = /* @__PURE__ */ Object.create(null);
35467
+ for (const [name, t] of entries) {
35468
+ if (!MCP_TOOL_NAME.test(name) || MCP_RESERVED_NAMES.has(name) || !isBag(t) || !keysExactly(t, ["sha256", "class"])) return null;
35469
+ if (typeof t.sha256 !== "string" || !HEX64.test(t.sha256) || t.class !== "read") return null;
35470
+ out[name] = Object.freeze({ sha256: t.sha256, class: "read" });
35471
+ }
35472
+ return out;
35473
+ }
35474
+ function parseServer(v) {
35475
+ if (!isBag(v) || !keysExactly(v, ["vendor", "url", "auth", "tools"])) return null;
35476
+ if (typeof v.vendor !== "string" || !VENDOR_KEY.test(v.vendor) || !canonicalHttpsUrl(v.url)) return null;
35477
+ const auth = parseAuth(v.auth);
35478
+ const tools = parseTools(v.tools);
35479
+ if (auth === null || tools === null) return null;
35480
+ return Object.freeze({ vendor: v.vendor, url: v.url, auth, tools: Object.freeze(tools) });
35481
+ }
35482
+ function parseRemoved(v, servers) {
35483
+ if (v === "*") return Object.keys(servers).length === 0 ? "*" : null;
35484
+ if (!Array.isArray(v) || v.length < 1 || v.length > MCP_MAX_REMOVED) return null;
35485
+ const seen = /* @__PURE__ */ new Set();
35486
+ for (const id of v) {
35487
+ if (typeof id !== "string" || !MCP_SERVER_ID.test(id) || MCP_RESERVED_NAMES.has(id)) return null;
35488
+ if (seen.has(id) || Object.prototype.hasOwnProperty.call(servers, id)) return null;
35489
+ seen.add(id);
35490
+ }
35491
+ return Object.freeze([...seen]);
35492
+ }
35493
+ var NOT_REMOVED = Object.freeze([]);
35494
+ function parseMcp(v) {
35495
+ if (!isBag(v) || !(keysExactly(v, ["servers"]) || keysExactly(v, ["servers", "removed"])) || !isBag(v.servers)) return null;
35496
+ const entries = Object.entries(v.servers);
35497
+ if (entries.length > MCP_MAX_SERVERS) return null;
35498
+ const servers = /* @__PURE__ */ Object.create(null);
35499
+ let signedTools = 0;
35500
+ for (const [id, raw] of entries) {
35501
+ if (!MCP_SERVER_ID.test(id) || MCP_RESERVED_NAMES.has(id)) return null;
35502
+ const s = parseServer(raw);
35503
+ if (s === null) return null;
35504
+ signedTools += Object.keys(s.tools).length;
35505
+ if (signedTools > MCP_MAX_SIGNED_TOOLS) return null;
35506
+ servers[id] = s;
35507
+ }
35508
+ let removed = NOT_REMOVED;
35509
+ if (Object.prototype.hasOwnProperty.call(v, "removed")) {
35510
+ const r = parseRemoved(v.removed, servers);
35511
+ if (r === null) return null;
35512
+ removed = r;
35513
+ }
35514
+ return Object.freeze({ servers: Object.freeze(servers), removed });
35515
+ }
35516
+ function mcpServerCatalogRow(s, catalog) {
35517
+ if (!Object.prototype.hasOwnProperty.call(catalog, s.vendor)) return null;
35518
+ const row = catalog[s.vendor];
35519
+ if (s.url !== row.readOnlyUrl) return null;
35520
+ if (s.auth.mode === "oauth" && s.auth.issuer !== row.issuer) return null;
35521
+ return row;
35522
+ }
35523
+ function parseMcpGrant(name) {
35524
+ const g = MCP_GRANT.exec(name);
35525
+ return g === null ? null : { serverId: g[1], tool: g[2] };
35526
+ }
35527
+ function mcpGrantWithinCeiling(name, mcp, catalog) {
35528
+ const g = parseMcpGrant(name);
35529
+ if (g === null || mcp === null || !Object.prototype.hasOwnProperty.call(mcp.servers, g.serverId)) return false;
35530
+ const s = mcp.servers[g.serverId];
35531
+ if (!Object.prototype.hasOwnProperty.call(s.tools, g.tool)) return false;
35532
+ const row = mcpServerCatalogRow(s, catalog);
35533
+ return row !== null && row.readTools.includes(g.tool);
35534
+ }
35535
+
35536
+ // src/grants/mcp-catalog.ts
35537
+ var LINEAR = Object.freeze({
35538
+ displayName: "Linear",
35539
+ host: "mcp.linear.app",
35540
+ readOnlyUrl: "https://mcp.linear.app/mcp/readonly",
35541
+ readTools: Object.freeze([
35542
+ "get_attachment",
35543
+ "list_agent_skills",
35544
+ "get_agent_skill",
35545
+ "list_comments",
35546
+ "list_cycles",
35547
+ "list_custom_views",
35548
+ "get_document",
35549
+ "list_documents",
35550
+ "extract_images",
35551
+ "get_issue",
35552
+ "list_issue_statuses",
35553
+ "get_issue_status",
35554
+ "list_issue_labels",
35555
+ "list_projects",
35556
+ "get_project",
35557
+ "list_project_labels",
35558
+ "list_release_pipelines",
35559
+ "list_releases",
35560
+ "get_release",
35561
+ "list_release_notes",
35562
+ "get_release_note",
35563
+ "list_milestones",
35564
+ "get_milestone",
35565
+ "get_notifications",
35566
+ "list_teams",
35567
+ "get_team",
35568
+ "get_triage_responsibility",
35569
+ "list_templates",
35570
+ "get_template",
35571
+ "list_users",
35572
+ "get_user",
35573
+ "get_workspace",
35574
+ "search_documentation",
35575
+ "get_diff",
35576
+ "list_diffs",
35577
+ "get_diff_threads",
35578
+ "list_initiatives",
35579
+ "get_initiative",
35580
+ "list_initiative_labels",
35581
+ "get_status_updates"
35582
+ ]),
35583
+ scopes: Object.freeze(["read"]),
35584
+ issuer: "https://mcp.linear.app",
35585
+ authorizationEndpoint: "https://mcp.linear.app/authorize",
35586
+ tokenEndpoint: "https://mcp.linear.app/token",
35587
+ revocationEndpoint: "https://mcp.linear.app/token",
35588
+ docs: Object.freeze({
35589
+ host: "https://linear.app/docs/mcp",
35590
+ readOnlyUrl: "https://linear.app/docs/mcp",
35591
+ readTools: "https://linear.app/docs/mcp",
35592
+ scopes: "https://linear.app/docs/mcp",
35593
+ issuer: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35594
+ authorizationEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35595
+ tokenEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server",
35596
+ revocationEndpoint: "https://mcp.linear.app/.well-known/oauth-authorization-server"
35597
+ })
35598
+ });
35599
+ var MCP_CATALOG = Object.freeze({ linear: LINEAR });
35600
+
35307
35601
  // src/grants/manifest.ts
35308
35602
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
35309
35603
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
@@ -35329,9 +35623,9 @@ function wellTyped(v) {
35329
35623
  return false;
35330
35624
  }
35331
35625
  var WRAPPER_KEYS = ["algo", "manifest", "signature"];
35332
- var HEX64 = /^[0-9a-f]{64}$/;
35333
- var PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
35334
- var absolutePath = (v) => typeof v === "string" && v.startsWith("/") && PRINTABLE_ASCII.test(v);
35626
+ var HEX642 = /^[0-9a-f]{64}$/;
35627
+ var PRINTABLE_ASCII2 = /^[\x21-\x7e]+$/;
35628
+ var absolutePath = (v) => typeof v === "string" && v.startsWith("/") && PRINTABLE_ASCII2.test(v);
35335
35629
  var exactKeys = (o, required, optional = []) => required.every((k) => k in o) && Object.keys(o).every((k) => required.includes(k) || optional.includes(k));
35336
35630
  var isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
35337
35631
  function parseEndpoints(v) {
@@ -35343,7 +35637,7 @@ function parseEndpoints(v) {
35343
35637
  const e = { ...raw };
35344
35638
  if (e.headersFile === "") delete e.headersFile;
35345
35639
  if (!exactKeys(e, ["baseUrl"], ["headersFile"])) return null;
35346
- if (typeof e.baseUrl !== "string" || e.baseUrl === "" || !PRINTABLE_ASCII.test(e.baseUrl)) return null;
35640
+ if (typeof e.baseUrl !== "string" || e.baseUrl === "" || !PRINTABLE_ASCII2.test(e.baseUrl)) return null;
35347
35641
  let url;
35348
35642
  try {
35349
35643
  url = new URL(e.baseUrl);
@@ -35374,8 +35668,8 @@ function parseDatabases(v) {
35374
35668
  function parseConfig(v) {
35375
35669
  if (!isPlainObject(v)) return null;
35376
35670
  if (!exactKeys(v, ["persona_sha256", "flags", "memory", "legacy_assign_sha256"], ["llm", "search", "tools_allow"])) return null;
35377
- if (typeof v.persona_sha256 !== "string" || !HEX64.test(v.persona_sha256)) return null;
35378
- if (typeof v.legacy_assign_sha256 !== "string" || !HEX64.test(v.legacy_assign_sha256)) return null;
35671
+ if (typeof v.persona_sha256 !== "string" || !HEX642.test(v.persona_sha256)) return null;
35672
+ if (typeof v.legacy_assign_sha256 !== "string" || !HEX642.test(v.legacy_assign_sha256)) return null;
35379
35673
  if (!isStringArray(v.flags)) return null;
35380
35674
  if ("tools_allow" in v && !isStringArray(v.tools_allow)) return null;
35381
35675
  if ("llm" in v) {
@@ -35419,7 +35713,7 @@ function verifyToolGrantManifest(raw, ctx) {
35419
35713
  const m = raw.manifest;
35420
35714
  if (!isPlainObject(m)) return { state: "malformed" };
35421
35715
  if (m.type !== "securecoms.tool-grants" || m.v !== 1) return { state: "malformed" };
35422
- if (!wellTyped(m)) return { state: "malformed" };
35716
+ if (!wellTyped(Object.fromEntries(Object.entries(m).filter(([k]) => k !== "mcp")))) return { state: "malformed" };
35423
35717
  if (typeof m.agent_identity_id !== "string" || typeof m.owner_identity_id !== "string") return { state: "malformed" };
35424
35718
  for (const k of ["owner_dm_channel_id", "owner_dm_relay_identity_id"]) {
35425
35719
  if (k in m && typeof m[k] !== "string") return { state: "malformed" };
@@ -35449,6 +35743,11 @@ function verifyToolGrantManifest(raw, ctx) {
35449
35743
  if (counter === ctx.storedCounter) return { state: "unchanged" };
35450
35744
  if (counter < ctx.storedCounter) return { state: "rollback" };
35451
35745
  }
35746
+ const rawOwnerDm = m.grants.owner_dm;
35747
+ const signedMcpPresent = Array.isArray(rawOwnerDm) && rawOwnerDm.some((t) => t.startsWith("mcp:"));
35748
+ const mcpKey = "mcp" in m ? "present" : "absent";
35749
+ const mcp = mcpKey === "present" ? parseMcp(m.mcp) : null;
35750
+ const catalog = ctx.mcpCatalog ?? MCP_CATALOG;
35452
35751
  let droppedUnknown = false;
35453
35752
  const grants = {};
35454
35753
  for (const [audience, list] of Object.entries(m.grants)) {
@@ -35457,7 +35756,7 @@ function verifyToolGrantManifest(raw, ctx) {
35457
35756
  continue;
35458
35757
  }
35459
35758
  const known = MANIFEST_KNOWN_TOOLS[audience];
35460
- const kept = list.filter((t) => known.includes(t));
35759
+ const kept = list.filter((t) => known.includes(t) || audience === "owner_dm" && mcpGrantWithinCeiling(t, mcp, catalog));
35461
35760
  if (kept.length !== list.length) droppedUnknown = true;
35462
35761
  grants[audience] = Object.freeze(kept);
35463
35762
  }
@@ -35472,7 +35771,10 @@ function verifyToolGrantManifest(raw, ctx) {
35472
35771
  grants: Object.freeze(grants),
35473
35772
  endpoints: Object.freeze(endpoints),
35474
35773
  databases: Object.freeze(databases),
35475
- config
35774
+ config,
35775
+ mcp,
35776
+ mcpKey,
35777
+ signedMcpPresent
35476
35778
  });
35477
35779
  return { state: "ok", manifest, droppedUnknown, wrapper: raw };
35478
35780
  }
@@ -35793,21 +36095,32 @@ var AGENT_TOTAL_KEY = "agent-total";
35793
36095
  // src/grants/effective.ts
35794
36096
  var TOOL_GRANT_CODE_CEILING = MANIFEST_KNOWN_TOOLS;
35795
36097
  var CURRENT_TIME = Object.freeze({ tool: "current_time", mode: "read" });
36098
+ var NO_WITHHELD = Object.freeze([]);
35796
36099
  function effectiveToolGrants(i) {
35797
- if (i.base.length === 0) return { grants: i.base, source: "none", withheldBySearchCap: false };
35798
- if (i.manifest === null) return { grants: i.base, source: "constant", withheldBySearchCap: false };
36100
+ if (i.base.length === 0) return { grants: i.base, source: "none", withheldBySearchCap: false, withheld: NO_WITHHELD };
36101
+ if (i.manifest === null) return { grants: i.base, source: "constant", withheldBySearchCap: false, withheld: NO_WITHHELD };
35799
36102
  const ceiling = TOOL_GRANT_CODE_CEILING[i.audience];
35800
36103
  const listed = new Set(i.manifest.grants[i.audience] ?? []);
35801
36104
  const signed = ceiling.filter((t) => listed.has(t));
35802
36105
  if (!i.eligible || !i.manifestSwitchOn) {
35803
36106
  const fallback = i.base.filter((g) => g.tool !== "current_time" && signed.includes(g.tool));
35804
- return { grants: Object.freeze([CURRENT_TIME, ...fallback]), source: "fallback", withheldBySearchCap: false };
36107
+ return { grants: Object.freeze([CURRENT_TIME, ...fallback]), source: "fallback", withheldBySearchCap: false, withheld: NO_WITHHELD };
35805
36108
  }
35806
36109
  let withheld = false;
36110
+ const dropped = [];
35807
36111
  const tools = signed.filter((t) => {
35808
- if (i.disabled.has(t)) return false;
35809
- if (t === "http_request" && i.signedTargets.endpoints === 0) return false;
35810
- if (t === "sql_query" && i.signedTargets.databases === 0) return false;
36112
+ if (i.disabled.has(t)) {
36113
+ dropped.push(Object.freeze({ tool: t, why: "disabled_by_owner" }));
36114
+ return false;
36115
+ }
36116
+ if (t === "http_request" && i.signedTargets.endpoints === 0) {
36117
+ dropped.push(Object.freeze({ tool: t, why: "no_endpoint" }));
36118
+ return false;
36119
+ }
36120
+ if (t === "sql_query" && i.signedTargets.databases === 0) {
36121
+ dropped.push(Object.freeze({ tool: t, why: "no_database" }));
36122
+ return false;
36123
+ }
35811
36124
  if (i.audience === "team_room" && t === "web_search" && i.roomSearchCapped) {
35812
36125
  withheld = true;
35813
36126
  return false;
@@ -35817,7 +36130,8 @@ function effectiveToolGrants(i) {
35817
36130
  return {
35818
36131
  grants: Object.freeze([CURRENT_TIME, ...tools.map((tool) => Object.freeze({ tool, mode: "read", source: "manifest" }))]),
35819
36132
  source: "manifest",
35820
- withheldBySearchCap: withheld
36133
+ withheldBySearchCap: withheld,
36134
+ withheld: Object.freeze(dropped)
35821
36135
  };
35822
36136
  }
35823
36137
 
@@ -36122,6 +36436,200 @@ function classifyVoiceFrame(f) {
36122
36436
  return b.text.trim() === VOICE_CALL_START_MARKER ? "greeting" : "drop";
36123
36437
  }
36124
36438
 
36439
+ // src/grants/tool-status.ts
36440
+ var TOOL_STATUS_ALLOWLIST = [
36441
+ "current_time",
36442
+ "web_search",
36443
+ "web_extract",
36444
+ "fetch_url",
36445
+ "http_request",
36446
+ "sql_query",
36447
+ "weather_forecast",
36448
+ "currency_convert"
36449
+ ];
36450
+ var ALLOW2 = new Set(TOOL_STATUS_ALLOWLIST);
36451
+ var TOOL_STATUS_BUCKET_MS = 9e5;
36452
+ function bucketStartAt(ms) {
36453
+ return new Date(Math.floor(ms / TOOL_STATUS_BUCKET_MS) * TOOL_STATUS_BUCKET_MS).toISOString().slice(0, 16) + "Z";
36454
+ }
36455
+ function bucketEndMs(at) {
36456
+ return Date.parse(at) + TOOL_STATUS_BUCKET_MS;
36457
+ }
36458
+ var SIGNATURE_FAILURES = /* @__PURE__ */ new Set(["bad_signature", "malformed", "unknown_algo", "no_signature"]);
36459
+ function ownerDmCause(i) {
36460
+ try {
36461
+ const v = i.verdict;
36462
+ if (v.kind === "failed" && v.reason === "replay") return null;
36463
+ if (v.kind !== "verified" && v.kind !== "constants_only" && v.kind !== "failed") return null;
36464
+ const failedReason = v.kind === "failed" ? v.reason : void 0;
36465
+ const blocked = i.killSwitchOff || i.pinMissingOrAnomalous || i.senderNotOwner || i.stale || v.kind === "failed";
36466
+ const eligible = v.kind === "verified" && i.signedDmChannel === i.channelId && i.boundNow && i.consumerAttached;
36467
+ const source = blocked ? "none" : !i.manifestPresent ? "constant" : !eligible || i.manifestSwitchOff ? "fallback" : "manifest";
36468
+ const at = (cause, sub) => sub === void 0 ? { source, cause } : { source, cause, sub };
36469
+ if (i.killSwitchOff) return at("owner_dm_unavailable", "kill_switch");
36470
+ if (i.manifestSwitchOff) return at("manifest_switch_off");
36471
+ if (i.pinMissingOrAnomalous) return at("owner_dm_unavailable", "pin");
36472
+ if (i.senderNotOwner) return at("owner_dm_unavailable", "sender_not_owner");
36473
+ if (i.stale) return at("owner_dm_unavailable", "stale");
36474
+ if (failedReason === "not_owner_dm_leaves") return at("unexpected_member_in_dm");
36475
+ if (failedReason !== void 0 && SIGNATURE_FAILURES.has(failedReason)) return at("owner_turn_failed");
36476
+ if (failedReason === "verify_error") return at("agent_state_error");
36477
+ if (failedReason === "wrong_dm_channel" || v.kind === "verified" && i.manifestPresent && i.signedDmChannel !== void 0 && i.signedDmChannel !== i.channelId) {
36478
+ return at("dm_channel_mismatch");
36479
+ }
36480
+ if (failedReason !== void 0) return null;
36481
+ if (i.manifestPresent && i.signedDmChannel === void 0) return at("dm_not_linked");
36482
+ if (v.kind === "constants_only") return at("unsigned_relay_in_dm");
36483
+ if (!i.manifestPresent || !i.boundNow) return at("manifest_not_bound");
36484
+ if (!i.consumerAttached) return at("no_runtime_consumer");
36485
+ return source === "manifest" ? { source } : null;
36486
+ } catch {
36487
+ return null;
36488
+ }
36489
+ }
36490
+ function sameTuple(a, b) {
36491
+ if (!a || !b) return false;
36492
+ 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);
36493
+ }
36494
+ function sanitizeIssued(names) {
36495
+ return [...new Set(names.filter((n) => ALLOW2.has(n)))].sort();
36496
+ }
36497
+
36498
+ // src/tool-status-sender.ts
36499
+ var TOOL_STATUS_JITTER_MS = 6e4;
36500
+ var RETRY_AFTER_FLOOR_MS = 6e4;
36501
+ function defaultToolStatusSetTimer(fn, ms) {
36502
+ const t = setTimeout(fn, ms);
36503
+ t.unref?.();
36504
+ return t;
36505
+ }
36506
+ function defaultToolStatusClearTimer(h) {
36507
+ clearTimeout(h);
36508
+ }
36509
+ function retryAfterMs(raw) {
36510
+ if (typeof raw !== "string" || !/^\d{1,9}$/.test(raw.trim())) return RETRY_AFTER_FLOOR_MS;
36511
+ return Math.max(RETRY_AFTER_FLOOR_MS, Number(raw.trim()) * 1e3);
36512
+ }
36513
+ var ToolStatusSender = class {
36514
+ postFn;
36515
+ now;
36516
+ setTimer;
36517
+ clearTimer;
36518
+ random;
36519
+ onFailure;
36520
+ /** The latest noted record while its bucket is still open. */
36521
+ open = null;
36522
+ /** The latest noted record whose bucket has closed: the only record an attempt or carry() may send. */
36523
+ closed = null;
36524
+ lastPosted = null;
36525
+ terminalFor = null;
36526
+ notBefore = Number.NEGATIVE_INFINITY;
36527
+ timer = null;
36528
+ inFlight = false;
36529
+ shut = false;
36530
+ constructor(o) {
36531
+ this.postFn = o.post;
36532
+ this.now = o.now ?? (() => Date.now());
36533
+ this.setTimer = o.setTimer ?? defaultToolStatusSetTimer;
36534
+ this.clearTimer = o.clearTimer ?? defaultToolStatusClearTimer;
36535
+ this.random = o.random ?? Math.random;
36536
+ this.onFailure = o.onFailure ?? (() => {
36537
+ });
36538
+ }
36539
+ note(rec) {
36540
+ if (this.shut) return;
36541
+ try {
36542
+ this.promote(this.now());
36543
+ this.open = rec;
36544
+ this.schedule();
36545
+ } catch {
36546
+ }
36547
+ }
36548
+ /** Ruling M-2: what the apply-time report carries. The closed record only; an open bucket is never revealed early. */
36549
+ carry() {
36550
+ try {
36551
+ this.promote(this.now());
36552
+ } catch {
36553
+ }
36554
+ return this.closed;
36555
+ }
36556
+ /** R2-M1: the record belongs to an owner who is no longer the manifest's; forget it and what was posted. */
36557
+ drop() {
36558
+ this.open = null;
36559
+ this.closed = null;
36560
+ this.lastPosted = null;
36561
+ this.terminalFor = null;
36562
+ this.cancelTimer();
36563
+ }
36564
+ close() {
36565
+ this.shut = true;
36566
+ this.cancelTimer();
36567
+ }
36568
+ cancelTimer() {
36569
+ if (this.timer !== null) this.clearTimer(this.timer);
36570
+ this.timer = null;
36571
+ }
36572
+ promote(t) {
36573
+ if (this.open !== null && bucketEndMs(this.open.at) <= t) {
36574
+ this.closed = this.open;
36575
+ this.open = null;
36576
+ }
36577
+ }
36578
+ pending() {
36579
+ return this.closed !== null && !sameTuple(this.closed, this.lastPosted) && !sameTuple(this.closed, this.terminalFor);
36580
+ }
36581
+ schedule() {
36582
+ if (this.shut || this.inFlight || this.timer !== null) return;
36583
+ if (this.open === null && !this.pending()) return;
36584
+ const t = this.now();
36585
+ let b = (Math.floor(t / TOOL_STATUS_BUCKET_MS) + 1) * TOOL_STATUS_BUCKET_MS;
36586
+ while (b < this.notBefore) b += TOOL_STATUS_BUCKET_MS;
36587
+ const jitter = Math.floor(this.random() * TOOL_STATUS_JITTER_MS);
36588
+ this.timer = this.setTimer(() => {
36589
+ this.timer = null;
36590
+ this.fire();
36591
+ }, b - t + jitter);
36592
+ }
36593
+ fire() {
36594
+ try {
36595
+ if (this.shut) return;
36596
+ this.promote(this.now());
36597
+ if (this.pending()) {
36598
+ void this.attempt(this.closed);
36599
+ return;
36600
+ }
36601
+ this.schedule();
36602
+ } catch {
36603
+ }
36604
+ }
36605
+ async attempt(rec) {
36606
+ this.inFlight = true;
36607
+ const attemptAt = this.now();
36608
+ let result;
36609
+ try {
36610
+ result = await this.postFn(rec);
36611
+ } catch {
36612
+ result = "network";
36613
+ }
36614
+ this.inFlight = false;
36615
+ try {
36616
+ if (typeof result === "object" && result.status >= 200 && result.status < 300) {
36617
+ this.lastPosted = rec;
36618
+ } else if (typeof result === "object" && result.status === 429) {
36619
+ this.notBefore = attemptAt + retryAfterMs(result.retryAfter);
36620
+ this.onFailure("http_error");
36621
+ } else if (typeof result === "object" && result.status >= 400 && result.status < 500) {
36622
+ this.terminalFor = rec;
36623
+ this.onFailure("http_error");
36624
+ } else {
36625
+ this.onFailure(typeof result === "object" ? "http_error" : result);
36626
+ }
36627
+ this.schedule();
36628
+ } catch {
36629
+ }
36630
+ }
36631
+ };
36632
+
36125
36633
  // src/grants/owner-turn.ts
36126
36634
  import { ed25519 as ed255196 } from "@noble/curves/ed25519";
36127
36635
  var HEX = /^(?:[0-9a-fA-F]{2})+$/;
@@ -36165,6 +36673,310 @@ function classifyOwnerDmLeaves(leaves, allowed) {
36165
36673
  }
36166
36674
  return "foreign";
36167
36675
  }
36676
+ function ownerDmLeavesRelayFree(leaves, ids) {
36677
+ let owner = false;
36678
+ let agent = false;
36679
+ for (const l of leaves) {
36680
+ if (!l.basic || l.identity === null) return false;
36681
+ if (l.identity === ids.ownerIdentityId) owner = true;
36682
+ else if (l.identity === ids.agentIdentityId) agent = true;
36683
+ else return false;
36684
+ }
36685
+ return owner && agent;
36686
+ }
36687
+ function relayLeafFromPhase0Relay(relay) {
36688
+ return relay !== "none";
36689
+ }
36690
+ function ownerDmRelayOf(leafClass, leaves, ids) {
36691
+ if (leafClass === "unsigned_relay") return "unsigned";
36692
+ if (ownerDmLeavesRelayFree(leaves, ids)) return "none";
36693
+ if (ids.relayIdentityId !== void 0 && leaves.some((l) => l.identity === ids.relayIdentityId)) return "signed";
36694
+ return void 0;
36695
+ }
36696
+
36697
+ // src/grants/mcp-policy.ts
36698
+ var MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36699
+ function mcpEntitlementReading(mcpEntitled, mcpCause) {
36700
+ if (mcpEntitled === true && mcpCause === "entitled") return "entitled";
36701
+ if (mcpEntitled === false && mcpCause === "not_entitled") return "not_entitled";
36702
+ return "suspended";
36703
+ }
36704
+ function mcpGrantsHostSwitch(env, log = writeServiceLog2) {
36705
+ const v = env.SECURECOMS_MCP_GRANTS;
36706
+ if (v === void 0 || v === "on") return "on";
36707
+ if (v === "off") return "off";
36708
+ log("warn", "config.mcp_grants_switch_suspended", { state: ServiceLogEnum.of("suspended", ["suspended"]) });
36709
+ return "suspended";
36710
+ }
36711
+ var empty = (state, r3Hold) => Object.freeze({ v: 1, state, r3_hold: r3Hold, servers: Object.freeze({}), remove: NO_REMOVE });
36712
+ var NO_REMOVE = Object.freeze([]);
36713
+ function policyAuth(s) {
36714
+ if (s.auth.mode === "key") return Object.freeze({ mode: "key" });
36715
+ const clientId = s.auth.client === "customer" ? s.auth.client_id : SECURECOMS_CIMD_CLIENT_ID;
36716
+ return Object.freeze({ mode: "oauth", issuer: s.auth.issuer, client: s.auth.client, client_id: clientId });
36717
+ }
36718
+ function mcpPolicyFor(i) {
36719
+ const m = i.manifest;
36720
+ const r3Hold = m?.signedMcpPresent === true || i.mcpSeen;
36721
+ if (i.hostSwitch === "off") return empty("kill_switch", r3Hold);
36722
+ if (i.hostSwitch !== "on") return empty("suspended", r3Hold);
36723
+ if (!i.config.fetched) return empty("no_config", r3Hold);
36724
+ const ent = mcpEntitlementReading(i.config.mcp_entitled, i.config.mcp_cause);
36725
+ if (ent !== "entitled") return empty(ent, r3Hold);
36726
+ if (!i.ownerDmManifestSwitchOn) return empty("switch_off", r3Hold);
36727
+ if (m === null) return empty("no_manifest", r3Hold);
36728
+ if (m.mcpKey === "absent") return empty("section_absent", r3Hold);
36729
+ if (m.mcp === null) return empty("malformed", r3Hold);
36730
+ if (!i.capability) return empty("no_capability", r3Hold);
36731
+ const catalog = i.catalog ?? MCP_CATALOG;
36732
+ const granted = /* @__PURE__ */ new Map();
36733
+ for (const name of m.grants.owner_dm ?? []) {
36734
+ const g = parseMcpGrant(name);
36735
+ if (g === null) continue;
36736
+ if (!granted.has(g.serverId)) granted.set(g.serverId, /* @__PURE__ */ new Set());
36737
+ granted.get(g.serverId).add(g.tool);
36738
+ }
36739
+ const servers = {};
36740
+ for (const id of Object.keys(m.mcp.servers).sort()) {
36741
+ const s = m.mcp.servers[id];
36742
+ const row = mcpServerCatalogRow(s, catalog);
36743
+ if (row === null) {
36744
+ servers[id] = Object.freeze({ state: "refused" });
36745
+ continue;
36746
+ }
36747
+ const tools = {};
36748
+ for (const t of Object.keys(s.tools).sort()) {
36749
+ if (granted.get(id)?.has(t) === true && row.readTools.includes(t)) tools[t] = Object.freeze({ sha256: s.tools[t].sha256, class: "read" });
36750
+ }
36751
+ servers[id] = Object.freeze({ state: "ok", vendor: s.vendor, url: row.readOnlyUrl, auth: policyAuth(s), tools: Object.freeze(tools) });
36752
+ }
36753
+ const remove = m.mcp.removed === "*" ? "*" : Object.freeze([...m.mcp.removed]);
36754
+ return Object.freeze({ v: 1, state: "ok", r3_hold: r3Hold, servers: Object.freeze(servers), remove });
36755
+ }
36756
+
36757
+ // src/grants/mcp-issuance.ts
36758
+ var NONE = Object.freeze([]);
36759
+ var own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
36760
+ function mcpPolicyAllows(policy, serverId, tool) {
36761
+ if (policy === null || policy.state !== "ok" || !own(policy.servers, serverId)) return false;
36762
+ const s = policy.servers[serverId];
36763
+ return s.state === "ok" && own(s.tools, tool);
36764
+ }
36765
+ function mcpIssuance(i) {
36766
+ if (i.source !== "manifest") return NONE;
36767
+ if (i.verdict?.kind !== "verified" || i.verdict.relayLeaf !== false) return NONE;
36768
+ if (i.entitlement !== "entitled" || i.hostSwitch !== "on" || !i.ownerDmManifestSwitchOn) return NONE;
36769
+ if (i.manifest === null || i.policy === null || i.policy.state !== "ok") return NONE;
36770
+ const out = [];
36771
+ for (const name of i.manifest.grants.owner_dm ?? []) {
36772
+ const g = parseMcpGrant(name);
36773
+ if (g === null || i.disabled.has(name) || !mcpPolicyAllows(i.policy, g.serverId, g.tool)) continue;
36774
+ out.push(Object.freeze({ tool: name, mode: "read", source: "manifest" }));
36775
+ }
36776
+ return out.length === 0 ? NONE : Object.freeze(out);
36777
+ }
36778
+
36779
+ // src/consumer-grants.ts
36780
+ var NO_GRANTS = Object.freeze([]);
36781
+ var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
36782
+ function withConsumerGrants(m, perToolConsumer) {
36783
+ if (perToolConsumer) return m;
36784
+ const a = m.authorization;
36785
+ if (!a) return m;
36786
+ const stripGrants = !(a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner");
36787
+ const stripMemory = "memory_write" in a;
36788
+ if (!stripGrants && !stripMemory) return m;
36789
+ const { memory_write: _dropped, ...rest } = a;
36790
+ return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
36791
+ }
36792
+ var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
36793
+ var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
36794
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
36795
+ var MCP_CLIENT_FEATURE = "mcp_client_v1";
36796
+ var CONSTANT_TOOL_NAMES = Object.freeze({
36797
+ owner_dm: Object.freeze(["current_time", "web_search"]),
36798
+ team_room: Object.freeze(["current_time"])
36799
+ });
36800
+ var isManifestGrant = (g) => g.mode === "read" && g.source === "manifest";
36801
+ function withManifestCapability(m, manifestConsumer) {
36802
+ if (manifestConsumer) return m;
36803
+ const a = m.authorization;
36804
+ if (!a || !a.tool_grants.some(isManifestGrant)) return m;
36805
+ const constants5 = new Set(a.audience === "owner_dm" || a.audience === "team_room" ? CONSTANT_TOOL_NAMES[a.audience] : []);
36806
+ const narrowed = a.tool_grants.flatMap((g) => {
36807
+ if (!isManifestGrant(g)) return [g];
36808
+ return constants5.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
36809
+ });
36810
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(narrowed) }) };
36811
+ }
36812
+ function withHarnessGrants(m, withheld) {
36813
+ if (!withheld) return m;
36814
+ const a = m.authorization;
36815
+ if (!a || a.tool_grants.length === 0) return m;
36816
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
36817
+ }
36818
+ function withSearchCapability(m, consumerHasFeature, signedProvider) {
36819
+ if (consumerHasFeature) return m;
36820
+ const a = m.authorization;
36821
+ if (!a) return m;
36822
+ const stripSearch = signedProvider !== void 0 && signedProvider !== "brave";
36823
+ const kept = a.tool_grants.filter((g) => g.tool !== "web_extract" && !(stripSearch && g.tool === "web_search"));
36824
+ if (kept.length === a.tool_grants.length) return m;
36825
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
36826
+ }
36827
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
36828
+ function withUtilityCapability(m, consumerHasFeature) {
36829
+ if (consumerHasFeature) return m;
36830
+ const a = m.authorization;
36831
+ if (!a) return m;
36832
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
36833
+ if (kept.length === a.tool_grants.length) return m;
36834
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
36835
+ }
36836
+ var MCP_GRANT_PREFIX = "mcp:";
36837
+ var isMcpGrant = (g) => g.tool.startsWith(MCP_GRANT_PREFIX);
36838
+ function withMcpCapability(m, consumerHasFeature) {
36839
+ const a = m.authorization;
36840
+ if (!a) return m;
36841
+ const { relay_free: _relayFree, ...rest } = a;
36842
+ if (!a.tool_grants.some(isMcpGrant)) return "relay_free" in a ? { ...m, authorization: Object.freeze(rest) } : m;
36843
+ if (consumerHasFeature && a.audience === "owner_dm" && a.relay_speaker === void 0) return m;
36844
+ const kept = a.tool_grants.filter((g) => !isMcpGrant(g));
36845
+ return { ...m, authorization: Object.freeze({ ...rest, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
36846
+ }
36847
+ var R3_KEEP_TOOLS = /* @__PURE__ */ new Set(["current_time", "web_search", "weather_forecast", "currency_convert"]);
36848
+ function withR3Holds(m, hold) {
36849
+ if (!hold) return m;
36850
+ const a = m.authorization;
36851
+ if (!a || !(a.audience === "owner_dm" || a.relay_speaker === "owner")) return m;
36852
+ const kept = a.tool_grants.filter((g) => R3_KEEP_TOOLS.has(g.tool) || isMcpGrant(g));
36853
+ if (kept.length === a.tool_grants.length) return m;
36854
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
36855
+ }
36856
+
36857
+ // src/mcp-catalog-message.ts
36858
+ var MCP_CATALOG_MAX_BYTES = 384 * 1024;
36859
+ var MCP_CATALOG_MAX_TOOLS = 64;
36860
+ var MCP_CATALOG_LIST_TIMEOUT_MS = 5e4;
36861
+ var REQUEST_ID = /^[0-9a-f]{32}$/;
36862
+ var SERVER_ID = /^[a-z0-9-]{1,24}$/;
36863
+ var TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
36864
+ var RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
36865
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
36866
+ var MCP_CATALOG_REASONS = /* @__PURE__ */ new Set(["catalog", "write_name", "hint", "schema", "header_param", "too_large"]);
36867
+ var isBag2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36868
+ var keysExactly2 = (o, keys) => {
36869
+ const k = Object.keys(o);
36870
+ return k.length === keys.length && keys.every((x) => Object.prototype.hasOwnProperty.call(o, x));
36871
+ };
36872
+ function parseMcpCatalogRequest(body) {
36873
+ if (!isBag2(body) || !keysExactly2(body, ["v", "type", "server_id", "request_id"])) return null;
36874
+ if (body.v !== 1 || body.type !== "mcp_catalog_request") return null;
36875
+ const { server_id: serverId, request_id: requestId } = body;
36876
+ if (typeof serverId !== "string" || !SERVER_ID.test(serverId) || RESERVED.has(serverId)) return null;
36877
+ if (typeof requestId !== "string" || !REQUEST_ID.test(requestId)) return null;
36878
+ return Object.freeze({ serverId, requestId });
36879
+ }
36880
+ var PROJECTION_KEYS = /* @__PURE__ */ new Set(["name", "title", "description", "inputSchema", "annotations"]);
36881
+ function validProjection(p, toolName) {
36882
+ if (!Object.keys(p).every((k) => PROJECTION_KEYS.has(k))) return false;
36883
+ if (p.name !== toolName || typeof p.description !== "string" || !isBag2(p.inputSchema)) return false;
36884
+ if (Object.prototype.hasOwnProperty.call(p, "title") && typeof p.title !== "string") return false;
36885
+ if (Object.prototype.hasOwnProperty.call(p, "annotations") && !isBag2(p.annotations)) return false;
36886
+ return true;
36887
+ }
36888
+ function validTool(t) {
36889
+ if (!isBag2(t) || typeof t.name !== "string" || typeof t.grantable !== "boolean") return null;
36890
+ if (t.grantable) {
36891
+ if (!TOOL_NAME.test(t.name) || t.reason !== null || !isBag2(t.projection) || !validProjection(t.projection, t.name)) return null;
36892
+ return { name: t.name, projection: t.projection, grantable: true, reason: null };
36893
+ }
36894
+ if (t.projection !== void 0 || typeof t.reason !== "string" || !MCP_CATALOG_REASONS.has(t.reason)) return null;
36895
+ if (t.name.length < 1 || t.name.length > 64 || !/^[\x20-\x7e]+$/.test(t.name)) return null;
36896
+ return { name: t.name, grantable: false, reason: t.reason };
36897
+ }
36898
+ function validateCatalogListReply(frame, serverId, vendor) {
36899
+ if (!isBag2(frame) || frame.ok !== true || frame.server_id !== serverId || frame.vendor !== vendor) return null;
36900
+ if (typeof frame.listed_at !== "string" || !ISO_TIME.test(frame.listed_at) || typeof frame.truncated !== "boolean") return null;
36901
+ if (!Array.isArray(frame.tools) || frame.tools.length > MCP_CATALOG_MAX_TOOLS) return null;
36902
+ const tools = [];
36903
+ const names = /* @__PURE__ */ new Set();
36904
+ for (const t of frame.tools) {
36905
+ const v = validTool(t);
36906
+ if (v === null) return null;
36907
+ if (names.has(v.name)) return null;
36908
+ names.add(v.name);
36909
+ tools.push(v);
36910
+ }
36911
+ return { listedAt: frame.listed_at, truncated: frame.truncated, tools };
36912
+ }
36913
+ var utf8 = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
36914
+ var mcpCatalogBodyBytes = (b) => utf8(b);
36915
+ function buildMcpCatalogBody(i) {
36916
+ 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 };
36917
+ const full = { ...head, tools: [...i.reply.tools], truncated: i.reply.truncated };
36918
+ if (mcpCatalogBodyBytes(full) <= MCP_CATALOG_MAX_BYTES) return full;
36919
+ const stub = (t) => ({ name: t.name, grantable: false, reason: "too_large" });
36920
+ const tools = i.reply.tools.map(stub);
36921
+ const body = { ...head, tools, truncated: true };
36922
+ let size = mcpCatalogBodyBytes(body);
36923
+ for (let k = 0; k < tools.length; k++) {
36924
+ const want = i.reply.tools[k];
36925
+ const delta = utf8(want) - utf8(tools[k]);
36926
+ if (size + delta <= MCP_CATALOG_MAX_BYTES) {
36927
+ tools[k] = want;
36928
+ size += delta;
36929
+ }
36930
+ }
36931
+ return body;
36932
+ }
36933
+
36934
+ // src/mcp-catalog-rate.ts
36935
+ var MINUTE_MS = 6e4;
36936
+ var HOUR_MS = 60 * MINUTE_MS;
36937
+ var MCP_CATALOG_PER_SERVER_MINUTE = 3;
36938
+ var MCP_CATALOG_PER_SERVER_HOUR = 20;
36939
+ var MCP_CATALOG_PER_AGENT_HOUR = 40;
36940
+ var McpCatalogRateLimiter = class {
36941
+ now;
36942
+ onRateLimited;
36943
+ inFlight = /* @__PURE__ */ new Set();
36944
+ perServer = /* @__PURE__ */ new Map();
36945
+ agent = [];
36946
+ /** Per scope key: no further log until this time (the end of the window that was full when it last logged). */
36947
+ quietUntil = /* @__PURE__ */ new Map();
36948
+ constructor(deps = {}) {
36949
+ this.now = deps.now ?? Date.now;
36950
+ this.onRateLimited = deps.onRateLimited ?? (() => {
36951
+ });
36952
+ }
36953
+ /** "ok" admits (and counts) the request and marks the server in flight; the caller MUST call release(serverId). */
36954
+ admit(serverId) {
36955
+ if (this.inFlight.has(serverId)) return "in_flight";
36956
+ const t = this.now();
36957
+ const server = (this.perServer.get(serverId) ?? []).filter((x) => t - x < HOUR_MS);
36958
+ this.perServer.set(serverId, server);
36959
+ this.agent = this.agent.filter((x) => t - x < HOUR_MS);
36960
+ const lastMinute = server.filter((x) => t - x < MINUTE_MS);
36961
+ if (lastMinute.length >= MCP_CATALOG_PER_SERVER_MINUTE) return this.limited(`m:${serverId}`, "server_minute", lastMinute[0] + MINUTE_MS, t);
36962
+ if (server.length >= MCP_CATALOG_PER_SERVER_HOUR) return this.limited(`h:${serverId}`, "server_hour", server[0] + HOUR_MS, t);
36963
+ if (this.agent.length >= MCP_CATALOG_PER_AGENT_HOUR) return this.limited("a", "agent_hour", this.agent[0] + HOUR_MS, t);
36964
+ server.push(t);
36965
+ this.agent.push(t);
36966
+ this.inFlight.add(serverId);
36967
+ return "ok";
36968
+ }
36969
+ release(serverId) {
36970
+ this.inFlight.delete(serverId);
36971
+ }
36972
+ limited(key, scope, windowEnd, t) {
36973
+ if (t >= (this.quietUntil.get(key) ?? -Infinity)) {
36974
+ this.quietUntil.set(key, windowEnd);
36975
+ this.onRateLimited(scope);
36976
+ }
36977
+ return "rate_limited";
36978
+ }
36979
+ };
36168
36980
 
36169
36981
  // src/bridge-agent.ts
36170
36982
  function logSendSupersededAdoption(kind2) {
@@ -36304,6 +37116,7 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
36304
37116
  }
36305
37117
  return VOICE_MODE_GUIDANCE;
36306
37118
  }
37119
+ var MCP_INERT_STATES = /* @__PURE__ */ new Set(["suspended", "no_config", "switch_off", "no_manifest", "section_absent", "malformed", "no_capability"]);
36307
37120
  var VOICE_COALESCE_WINDOW_MS = 0;
36308
37121
  var VOICE_COALESCE_WINDOW_MAX_MS = 5e3;
36309
37122
  function voiceCoalesceWindowFromEnv(env = process.env) {
@@ -36354,10 +37167,10 @@ var ChannelPendingError = class extends Error {
36354
37167
  }
36355
37168
  };
36356
37169
  var RateLimitedError = class extends Error {
36357
- constructor(channelId, retryAfterMs) {
36358
- super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
37170
+ constructor(channelId, retryAfterMs2) {
37171
+ super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs2}ms)`);
36359
37172
  this.channelId = channelId;
36360
- this.retryAfterMs = retryAfterMs;
37173
+ this.retryAfterMs = retryAfterMs2;
36361
37174
  this.name = "RateLimitedError";
36362
37175
  }
36363
37176
  };
@@ -36485,6 +37298,31 @@ var SendForbiddenError = class extends Error {
36485
37298
  this.name = "SendForbiddenError";
36486
37299
  }
36487
37300
  };
37301
+ var MCP_POLICY_RETRY_DELAYS_MS = Object.freeze([1e3, 5e3, 3e4, 12e4, 3e5]);
37302
+ function logMcpCatalogRequest(state) {
37303
+ writeServiceLog2(state === "sent" ? "info" : "warn", "mcp.catalog_request", { state: ServiceLogEnum.of(state, [
37304
+ "malformed",
37305
+ "not_owner_dm",
37306
+ "not_verified",
37307
+ "replay",
37308
+ "relay_leaf",
37309
+ "not_ready",
37310
+ "in_flight",
37311
+ "runtime_timeout",
37312
+ "runtime_refused",
37313
+ "withheld",
37314
+ "send_failed",
37315
+ "failed",
37316
+ "sent"
37317
+ ]) });
37318
+ }
37319
+ var McpReplyWithheldError = class extends SendForbiddenError {
37320
+ constructor(channelId) {
37321
+ super(channelId);
37322
+ this.name = "McpReplyWithheldError";
37323
+ this.message = "reply withheld: the chat is no longer private to the owner and the agent; no retry";
37324
+ }
37325
+ };
36488
37326
  var RoomClassChangedSendError = class extends SendForbiddenError {
36489
37327
  constructor(channelId) {
36490
37328
  super(channelId);
@@ -36504,6 +37342,7 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
36504
37342
  var RoomGrantEpochMovedError = class extends Error {
36505
37343
  };
36506
37344
  var ROOM_GRANT_TRACK_MAX = 4096;
37345
+ var MCP_DELIVERED_TRACK_MAX = 4096;
36507
37346
  var VOICE_FRAME_PARENT_MAX = 4096;
36508
37347
  var SessionSupersededError = class extends Error {
36509
37348
  constructor() {
@@ -36655,8 +37494,12 @@ var BridgeAgent = class _BridgeAgent {
36655
37494
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
36656
37495
  });
36657
37496
  this.home = deps.home ?? homedir4();
37497
+ this.mcpCatalog = deps.mcpCatalog;
37498
+ this.mcpDeliveredTrackMax = deps.mcpDeliveredTrackMax ?? MCP_DELIVERED_TRACK_MAX;
37499
+ this.mcpPolicyRetryDelaysMs = deps.mcpPolicyRetryDelaysMs?.length ? deps.mcpPolicyRetryDelaysMs : MCP_POLICY_RETRY_DELAYS_MS;
36658
37500
  this.grantWriters = {
36659
37501
  writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
37502
+ writeMcpPolicy: deps.grantWriters?.writeMcpPolicy ?? writeMcpPolicy,
36660
37503
  writeManagedFiles: deps.grantWriters?.writeManagedFiles ?? writeManagedFiles,
36661
37504
  bindRuntimeConfig: deps.grantWriters?.bindRuntimeConfig ?? bindRuntimeConfig,
36662
37505
  removeManagedLlmSource: deps.grantWriters?.removeManagedLlmSource ?? removeManagedLlmSource
@@ -37142,6 +37985,39 @@ var BridgeAgent = class _BridgeAgent {
37142
37985
  roomDefaultGrants = roomDefaultGrantsEnabled();
37143
37986
  /** Owner grants (spec §7.4): the two manifest switches, read once at construction. */
37144
37987
  ownerDmManifestGrants = ownerDmManifestGrantsEnabled();
37988
+ /**
37989
+ * MCP client Task 16 (OQ-16, coordinator ruling D-7): SECURECOMS_MCP_GRANTS, read ONCE at construction. "off" writes
37990
+ * kill_switch (deleting); any value other than unset/"on"/"off" is suspended (non-deleting, one content-free warn).
37991
+ */
37992
+ mcpHostSwitch = mcpGrantsHostSwitch(process.env);
37993
+ /** MCP Task 16 test seam (BridgeAgentDeps.mcpCatalog); undefined in production, so MCP_CATALOG decides. */
37994
+ mcpCatalog;
37995
+ /**
37996
+ * MCP Task 16 (OQ-1; PR-B1 audit M-2): the two raw /files fields from the LAST fetch. Any failed or rejected fetch
37997
+ * sets `fetched: false` (no_config, non-deleting): the last values are never kept to keep an `ok` document alive.
37998
+ */
37999
+ mcpConfig = Object.freeze({ fetched: false });
38000
+ /**
38001
+ * MCP Task 16: the mcp-policy.json document this process last WROTE (the file the runtime reads), or null before the
38002
+ * first write and after a failed one (fail closed: issuance reads only a document known to be on disk).
38003
+ */
38004
+ mcpPolicyWritten = null;
38005
+ mcpPolicyWrittenJson = null;
38006
+ /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
38007
+ mcpPolicyRetryDelaysMs;
38008
+ mcpPolicyRetryTimer = null;
38009
+ mcpPolicyRetryAttempt = 0;
38010
+ /** MCP Task 18: the loopback catalog listers of the attached runtime sockets that declared mcp_client_v1 (ws-server.ts). */
38011
+ mcpCatalogListers = /* @__PURE__ */ new Map();
38012
+ /** MCP Task 18 (OQ-15): per server 1 in flight, 3 / 60 s, 20 / hour; per agent 40 / hour. Memory only. */
38013
+ mcpCatalogRate = new McpCatalogRateLimiter({
38014
+ onRateLimited: (scope) => writeServiceLog2("warn", "mcp.catalog_request", {
38015
+ state: ServiceLogEnum.of("catalog_rate_limited", ["catalog_rate_limited"]),
38016
+ scope: ServiceLogEnum.of(scope, ["server_minute", "server_hour", "agent_hour"])
38017
+ })
38018
+ });
38019
+ /** MCP Task 18: answers in progress (outside the ingest cycle). Observable by tests; close() does not wait on them. */
38020
+ mcpCatalogWork = /* @__PURE__ */ new Set();
37145
38021
  roomManifestGrants = roomManifestGrantsEnabled();
37146
38022
  /**
37147
38023
  * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
@@ -37152,6 +38028,39 @@ var BridgeAgent = class _BridgeAgent {
37152
38028
  // RECORDED here and in the sealed store; no grant code reads it in step 1.
37153
38029
  toolGrantManifest = null;
37154
38030
  toolGrantCounter = null;
38031
+ /**
38032
+ * Tool status Phase 0 (spec §3.1, §3.2; plan Task 8): the latest owner-DM last-turn record, in memory only (no store
38033
+ * table: after a restart there is none until the next typed owner-DM turn). ADVISORY: nothing reads it to decide a
38034
+ * grant. Content-free: enums, booleans, one 15-minute bucket `at` (ruling M-2), one counter, built-in names from the
38035
+ * allowlist. This is the latest record, open bucket included; what leaves the host is the sender's closed record.
38036
+ */
38037
+ ownerDmLastTurn = null;
38038
+ /**
38039
+ * Test seams (Task 8; ruling M-2): the cause function, the bridge clock the record and the sender read, and the
38040
+ * sender's timer and jitter source.
38041
+ */
38042
+ toolStatusCause = ownerDmCause;
38043
+ toolStatusNow = () => Date.now();
38044
+ toolStatusSetTimer = defaultToolStatusSetTimer;
38045
+ toolStatusClearTimer = defaultToolStatusClearTimer;
38046
+ toolStatusRandom = Math.random;
38047
+ /**
38048
+ * 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
38049
+ * UTC bucket boundary, and only a record whose bucket has closed.
38050
+ */
38051
+ toolStatusSender = new ToolStatusSender({
38052
+ post: (rec) => this.postToolStatus(rec),
38053
+ now: () => this.toolStatusNow(),
38054
+ setTimer: (fn, ms) => this.toolStatusSetTimer(fn, ms),
38055
+ clearTimer: (h) => this.toolStatusClearTimer(h),
38056
+ random: () => this.toolStatusRandom(),
38057
+ onFailure: (kind2) => writeServiceLog2("warn", "tool_status.post_failed", { status: ServiceLogEnum.of(kind2, ["http_error", "timeout", "network"]) })
38058
+ });
38059
+ /**
38060
+ * MCP Phase 1 Task 14 (OQ-3 ruling item 3): the sticky R-3 flag, mirrored from the sealed store. Starts false and is
38061
+ * set true at start() by the store (or by a failed read, fail closed). Only applyToolGrantManifest changes it after.
38062
+ */
38063
+ mcpSeen = false;
37155
38064
  manifestState = "no_manifest";
37156
38065
  /** Failed owner-turn checks since the last successful runtime-config report (§7.7). */
37157
38066
  ownerVerifyFailures = 0;
@@ -37617,10 +38526,12 @@ var BridgeAgent = class _BridgeAgent {
37617
38526
  const payload = await res.json();
37618
38527
  if (!payload) {
37619
38528
  writeServiceLog2("warn", "runtime_config.empty_response");
38529
+ this.noteMcpConfigUnfetched();
37620
38530
  return;
37621
38531
  }
37622
38532
  if (typeof payload.version !== "number") {
37623
38533
  writeServiceLog2("warn", "runtime_config.invalid_version");
38534
+ this.noteMcpConfigUnfetched();
37624
38535
  return;
37625
38536
  }
37626
38537
  const version = payload.version;
@@ -37634,6 +38545,9 @@ var BridgeAgent = class _BridgeAgent {
37634
38545
  manifestStepThrew = true;
37635
38546
  writeServiceLog2("warn", "authz.config_binding", { state: ServiceLogEnum.of("bind_failed", ["bind_failed"]) }, e);
37636
38547
  }
38548
+ const raw = payload;
38549
+ this.mcpConfig = Object.freeze({ fetched: true, mcp_entitled: raw.mcp_entitled, mcp_cause: raw.mcp_cause });
38550
+ this.recomputeMcpPolicy();
37637
38551
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
37638
38552
  const inForce = pin !== null ? this.toolGrantManifest : null;
37639
38553
  if (inForce !== null && pin !== null) {
@@ -37700,8 +38614,81 @@ var BridgeAgent = class _BridgeAgent {
37700
38614
  await this.postRuntimeConfigReport(base, version, skillsJson);
37701
38615
  } catch (e) {
37702
38616
  writeServiceLog2("warn", "runtime_config.refetch_failed", {}, e);
38617
+ this.noteMcpConfigUnfetched();
37703
38618
  }
37704
38619
  }
38620
+ /** MCP Task 16 (OQ-1): the bridge's in-force entitlement reading; not fetched (or failed) is suspended. */
38621
+ mcpEntitlementInForce() {
38622
+ return this.mcpConfig.fetched ? mcpEntitlementReading(this.mcpConfig.mcp_entitled, this.mcpConfig.mcp_cause) : "suspended";
38623
+ }
38624
+ /** PR-B1 audit M-2: forget the last /files fields and rewrite mcp-policy.json (no_config: no grants, no deletion). */
38625
+ noteMcpConfigUnfetched() {
38626
+ this.mcpConfig = Object.freeze({ fetched: false });
38627
+ this.recomputeMcpPolicy();
38628
+ }
38629
+ /** MCP Task 16: the live inputs of mcp-policy.json (the pinned manifest in force only). */
38630
+ mcpPolicyInput() {
38631
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
38632
+ return {
38633
+ hostSwitch: this.mcpHostSwitch,
38634
+ config: this.mcpConfig,
38635
+ ownerDmManifestSwitchOn: this.ownerDmManifestGrants,
38636
+ manifest: pin !== null ? this.toolGrantManifest : null,
38637
+ capability: this.mcpClientCapable(),
38638
+ mcpSeen: this.mcpSeen,
38639
+ ...this.mcpCatalog ? { catalog: this.mcpCatalog } : {}
38640
+ };
38641
+ }
38642
+ /**
38643
+ * MCP Task 16 (spec §12.1): recompute mcp-policy.json and write it when it changed. The written document is what MCP
38644
+ * issuance and release-time re-narrowing read, so it is cleared BEFORE the write and set only after it lands: a failed
38645
+ * write leaves no document in force here (no MCP grant), and the next trigger tries again. Never throws.
38646
+ */
38647
+ recomputeMcpPolicy() {
38648
+ const doc = mcpPolicyFor(this.mcpPolicyInput());
38649
+ const json2 = JSON.stringify(doc);
38650
+ if (json2 === this.mcpPolicyWrittenJson) return;
38651
+ if (MCP_INERT_STATES.has(doc.state) && !doc.r3_hold && Object.keys(doc.servers).length === 0 && !existsSync5(join18(runtimeConfigDir(this.home, this.cfg.identityId), MCP_POLICY_FILE))) {
38652
+ this.mcpPolicyWritten = doc;
38653
+ this.mcpPolicyWrittenJson = json2;
38654
+ this.clearMcpPolicyRetry();
38655
+ return;
38656
+ }
38657
+ this.mcpPolicyWritten = null;
38658
+ this.mcpPolicyWrittenJson = null;
38659
+ try {
38660
+ this.grantWriters.writeMcpPolicy(this.home, this.cfg.identityId, doc);
38661
+ } catch (e) {
38662
+ writeServiceLog2("warn", "authz.mcp_policy", { state: ServiceLogEnum.of("write_failed", ["write_failed"]) }, e);
38663
+ this.scheduleMcpPolicyRetry();
38664
+ return;
38665
+ }
38666
+ this.mcpPolicyWritten = doc;
38667
+ this.mcpPolicyWrittenJson = json2;
38668
+ this.clearMcpPolicyRetry();
38669
+ }
38670
+ /** A document is in force: no pending retry, and the next failure starts the backoff from the first delay. */
38671
+ clearMcpPolicyRetry() {
38672
+ this.mcpPolicyRetryAttempt = 0;
38673
+ if (this.mcpPolicyRetryTimer !== null) clearTimeout(this.mcpPolicyRetryTimer);
38674
+ this.mcpPolicyRetryTimer = null;
38675
+ }
38676
+ /**
38677
+ * PR-B2 follow-up: one pending retry at a time. The retry recomputes from the state THEN (never re-writes a stale
38678
+ * document); any other trigger that writes successfully meanwhile cancels it. Nothing is in force on the bridge side
38679
+ * until a write lands (mcpPolicyWritten stays null).
38680
+ */
38681
+ scheduleMcpPolicyRetry() {
38682
+ if (this.closingDown || this.mcpPolicyRetryTimer !== null) return;
38683
+ const d = this.mcpPolicyRetryDelaysMs;
38684
+ const delay = d[Math.min(this.mcpPolicyRetryAttempt, d.length - 1)];
38685
+ this.mcpPolicyRetryAttempt++;
38686
+ this.mcpPolicyRetryTimer = setTimeout(() => {
38687
+ this.mcpPolicyRetryTimer = null;
38688
+ if (!this.closingDown) this.recomputeMcpPolicy();
38689
+ }, delay);
38690
+ this.mcpPolicyRetryTimer.unref?.();
38691
+ }
37705
38692
  /**
37706
38693
  * Plan 4 Task 11: the refetch under a manifest in force. Returns false when the binding failed (H-4),
37707
38694
  * after the memory was applied; the caller then writes nothing else. Throws only from a file write, with
@@ -37793,6 +38780,7 @@ var BridgeAgent = class _BridgeAgent {
37793
38780
  const local = readLocalReport(this.home, this.cfg.identityId);
37794
38781
  const reportedSearchProviders = this.searchProvidersCapable();
37795
38782
  const reportedUtilityTools = this.utilityToolsCapable();
38783
+ const carriedOwnerDmTurn = this.toolStatusSender.carry();
37796
38784
  const report = {
37797
38785
  applied_version: version,
37798
38786
  paths: checkConfiguredPaths(skillsJson),
@@ -37862,8 +38850,17 @@ var BridgeAgent = class _BridgeAgent {
37862
38850
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
37863
38851
  search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
37864
38852
  // Weather/currency §8 item 5: a separate key, false with no runtime attached.
37865
- utility_tools_v1: reportedUtilityTools
37866
- }
38853
+ utility_tools_v1: reportedUtilityTools,
38854
+ // MCP Task 19 (spec §12.3): separate from `capability`; content-free enums and pattern tokens only.
38855
+ mcp_capability: this.reportedMcpCapability(),
38856
+ mcp_servers: this.reportedMcpServers(),
38857
+ // Tool status Phase 0 (spec §3.5, round 2 H-5): an updated bridge, with or without a record yet.
38858
+ tool_status_v1: true
38859
+ },
38860
+ // Tool status Phase 0 (spec §3.5): the record rides every apply-time report, so record_report's wholesale replace
38861
+ // never erases it. Ruling M-2: the sender's CLOSED record, never the latest one, so a report sent inside a bucket
38862
+ // never reveals that bucket's change early. Advisory and content-free.
38863
+ ...carriedOwnerDmTurn ? { owner_dm_last_turn: carriedOwnerDmTurn } : {}
37867
38864
  };
37868
38865
  const reportedVerifyFailures = this.ownerVerifyFailures;
37869
38866
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
@@ -37961,6 +38958,7 @@ var BridgeAgent = class _BridgeAgent {
37961
38958
  }
37962
38959
  await this.restoreRuntimeInbox();
37963
38960
  await this.loadToolGrantState();
38961
+ this.recomputeMcpPolicy();
37964
38962
  this.recomputeManifestPolicy(NO_FEATURES);
37965
38963
  try {
37966
38964
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
@@ -38978,7 +39976,9 @@ var BridgeAgent = class _BridgeAgent {
38978
39976
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
38979
39977
  * there so a retry can resend the exact bytes).
38980
39978
  */
38981
- async publishBody(channelId, body, id, onEnvelope, sealCheck) {
39979
+ async publishBody(channelId, body, id, onEnvelope, sealCheck, inReplyTo, mcpTurn) {
39980
+ const mcpGuard = mcpTurn === true || this.mcpReplyGuarded(channelId, inReplyTo ?? void 0);
39981
+ let attempt = 0;
38982
39982
  this.enforceBodyRateFloor(channelId, body);
38983
39983
  this.statusGate.replyStarted(channelId);
38984
39984
  const joined = await this.reconcileJoin(channelId);
@@ -39018,6 +40018,8 @@ var BridgeAgent = class _BridgeAgent {
39018
40018
  );
39019
40019
  },
39020
40020
  async (envelope) => {
40021
+ attempt++;
40022
+ if (mcpGuard) this.assertMcpReplyRelayFree(channelId, envelope, attempt);
39021
40023
  if (onEnvelope) await onEnvelope(envelope);
39022
40024
  return this.publishEnvelope(
39023
40025
  channelId,
@@ -39027,6 +40029,77 @@ var BridgeAgent = class _BridgeAgent {
39027
40029
  );
39028
40030
  this.latency.record("send.total", performance.now() - sendStart);
39029
40031
  }
40032
+ /**
40033
+ * MCP Task 16 (round 2 H-D): message ids whose DELIVERED authorization (after release-time re-narrowing) carried an
40034
+ * `mcp:` grant, with their channel and whether the runtime acked them. Recorded at the buffer exit, the one place
40035
+ * every consumer's copy leaves from (over-recording a turn a non-capable consumer then received without the grant only
40036
+ * adds a check). Kept after the ack, so a late frame citing the turn is still checked. Bounded: the oldest ACKED entry
40037
+ * goes first.
40038
+ */
40039
+ mcpDeliveredTurns = /* @__PURE__ */ new Map();
40040
+ mcpDeliveredTrackMax;
40041
+ /**
40042
+ * Record `m` when its authorization carries an `mcp:` grant. Returns false ONLY when `m` is a new turn, the record is
40043
+ * at its bound and no entry is acked: the caller (the buffer exit) must then issue no mcp: grant (audit M-2, fail
40044
+ * closed). `rebuild` (PR-B2 audit C-1 (b): a restored runtime-inbox row, or a replayed turn) records unconditionally:
40045
+ * those grants were issued before, so the record may exceed its bound by at most the runtime inbox's size.
40046
+ */
40047
+ noteMcpDelivered(m, rebuild = false) {
40048
+ if (!m.authorization?.tool_grants.some((g) => g.tool.startsWith(MCP_GRANT_PREFIX))) return true;
40049
+ if (!this.mcpDeliveredTurns.has(m.message_id) && this.mcpDeliveredTurns.size >= this.mcpDeliveredTrackMax) {
40050
+ let victim;
40051
+ for (const [id, r] of this.mcpDeliveredTurns) if (r.acked) {
40052
+ victim = id;
40053
+ break;
40054
+ }
40055
+ if (victim !== void 0) this.mcpDeliveredTurns.delete(victim);
40056
+ else if (!rebuild) return false;
40057
+ }
40058
+ this.mcpDeliveredTurns.set(m.message_id, { channelId: m.channel_id, acked: false });
40059
+ return true;
40060
+ }
40061
+ /**
40062
+ * H-D: whether a frame to `channelId` must pass the relay-free check: it answers a recorded MCP turn (any channel:
40063
+ * a frame of that turn sent elsewhere is checked against ITS channel), or it cites no turn while an MCP turn is
40064
+ * unacked in the same channel.
40065
+ */
40066
+ mcpReplyGuarded(channelId, inReplyTo) {
40067
+ if (inReplyTo !== void 0 && this.mcpDeliveredTurns.has(inReplyTo)) return true;
40068
+ if (inReplyTo !== void 0) return false;
40069
+ for (const r of this.mcpDeliveredTurns.values()) if (!r.acked && r.channelId === channelId) return true;
40070
+ return false;
40071
+ }
40072
+ /**
40073
+ * H-D, synchronous: read the channel's local leaves from the warm manager in one step and tie the read to the
40074
+ * envelope's SEALED group and epoch, so the leaf set is exactly the membership that can open these bytes. Owner and
40075
+ * agent only: pass. Anyone else: withhold. The tree moved past the seal on the first attempt: StaleEpochError, which
40076
+ * sendWithSelfHeal answers with its one converge-and-re-seal (the existing semantics); still unprovable on the retry,
40077
+ * or no warm manager, no pin, or any throw: withhold (fail closed). Read-only; never touches the group.
40078
+ *
40079
+ * Scope (PR-B2 audit item 5): this guards the `send` path (publishBody) only. `status` detail, the `action_*` run
40080
+ * frames, `/v1/send-attachment` and summary frames are NOT checked; they are safe for MCP turns only because the
40081
+ * runtime emits none of them (pinned by connect-agent-runtime/src/outbound-frames.arch.test.ts). A runtime that starts
40082
+ * emitting any of them needs this guard extended to that frame first.
40083
+ */
40084
+ assertMcpReplyRelayFree(channelId, envelope, attempt) {
40085
+ let verdict = "unknown";
40086
+ try {
40087
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
40088
+ const snap = this.provider.leafSnapshotCached(channelId);
40089
+ const sealedGroup = sealedGroupIdOf(envelope);
40090
+ if (pin !== null && snap !== null && sealedGroup !== void 0 && snap.groupId === sealedGroup) {
40091
+ if (snap.epoch === envelope.mls_group_epoch) {
40092
+ verdict = ownerDmLeavesRelayFree(snap.leaves, { ownerIdentityId: pin.ownerIdentityId, agentIdentityId: this.cfg.identityId }) ? "free" : "relay";
40093
+ } else if (snap.epoch > envelope.mls_group_epoch) verdict = "moved";
40094
+ }
40095
+ } catch {
40096
+ verdict = "unknown";
40097
+ }
40098
+ if (verdict === "free") return;
40099
+ if (verdict === "moved" && attempt === 1) throw new StaleEpochError(envelope.mls_group_epoch, envelope.mls_group_epoch + 1);
40100
+ writeServiceLog2("warn", "mcp.reply_withheld", { state: ServiceLogEnum.of(verdict, ["relay", "moved", "unknown"]) });
40101
+ throw new McpReplyWithheldError(channelId);
40102
+ }
39030
40103
  /**
39031
40104
  * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
39032
40105
  * only at the epoch the room was classified at. On a move, the room is
@@ -39038,9 +40111,9 @@ var BridgeAgent = class _BridgeAgent {
39038
40111
  * the redelivery replay (replayReplyIntents, which has no in_reply_to in its
39039
40112
  * options) is checked the same as a live send.
39040
40113
  */
39041
- async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
40114
+ async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope, mcpTurn) {
39042
40115
  const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
39043
- if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
40116
+ if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope, void 0, inReplyTo, mcpTurn);
39044
40117
  if (channelId !== grant.channelId) {
39045
40118
  writeServiceLog2("warn", "reply.room_class_changed");
39046
40119
  throw new RoomClassChangedSendError(channelId);
@@ -39062,7 +40135,9 @@ var BridgeAgent = class _BridgeAgent {
39062
40135
  },
39063
40136
  (epoch) => {
39064
40137
  if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
39065
- }
40138
+ },
40139
+ inReplyTo,
40140
+ mcpTurn
39066
40141
  );
39067
40142
  } catch (e) {
39068
40143
  if (!(e instanceof RoomGrantEpochMovedError)) throw e;
@@ -39197,14 +40272,15 @@ var BridgeAgent = class _BridgeAgent {
39197
40272
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
39198
40273
  if (!k) throw new Error("idempotent send without a key");
39199
40274
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39200
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39201
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
39202
- if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
40275
+ const mcpTurn = this.mcpReplyGuarded(channelId, opts.inReplyTo);
40276
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit, mcpTurn);
40277
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null, mcpTurn });
40278
+ if (intent.bodyMac !== replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit, intent.mcpTurn) || intent.channelId !== channelId || intent.state === "skipped") {
39203
40279
  throw new IdempotencyConflictError();
39204
40280
  }
39205
40281
  return k;
39206
40282
  }
39207
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
40283
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null, false);
39208
40284
  });
39209
40285
  return this.publishIntent(key, opts);
39210
40286
  }
@@ -39213,25 +40289,26 @@ var BridgeAgent = class _BridgeAgent {
39213
40289
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
39214
40290
  * after a slot collision: the body must go to a NEW part.
39215
40291
  */
39216
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
40292
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit, inheritedMcpTurn) {
39217
40293
  const credit = replyCredit ?? void 0;
40294
+ const mcpTurn = inheritedMcpTurn || this.mcpReplyGuarded(channelId, inReplyTo);
39218
40295
  const scope = `reply:${inReplyTo}`;
39219
40296
  let latest = null;
39220
40297
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
39221
40298
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
39222
40299
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
39223
40300
  }
39224
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
40301
+ 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)) {
39225
40302
  return { scope, part: latest.intent.part, inReplyTo };
39226
40303
  }
39227
40304
  let ordinal = latest ? latest.ordinal + 1 : 0;
39228
40305
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
39229
40306
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
39230
40307
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39231
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39232
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
40308
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit, mcpTurn);
40309
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit, mcpTurn });
39233
40310
  if (created) return k;
39234
- if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
40311
+ if (matchRetry && intent.state !== "skipped" && intent.channelId === channelId && intent.bodyMac === replyBodyMac(this.replyIdKey, id, channelId, bytes, credit, intent.mcpTurn)) return k;
39235
40312
  writeServiceLog2("info", "reply.part_slot_taken", { attempt: i + 1 });
39236
40313
  }
39237
40314
  throw new Error("could not claim a reply part");
@@ -39283,7 +40360,7 @@ var BridgeAgent = class _BridgeAgent {
39283
40360
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
39284
40361
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
39285
40362
  try {
39286
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
40363
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo, intent.mcpTurn);
39287
40364
  } catch (e) {
39288
40365
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
39289
40366
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -39310,7 +40387,7 @@ var BridgeAgent = class _BridgeAgent {
39310
40387
  JSON.stringify(["scope", key.scope]),
39311
40388
  () => (
39312
40389
  // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39313
- this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
40390
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null, intent.mcpTurn)
39314
40391
  )
39315
40392
  );
39316
40393
  return this.publishIntent(next, opts, advances + 1);
@@ -39332,7 +40409,7 @@ var BridgeAgent = class _BridgeAgent {
39332
40409
  if (credit !== null && !isReplyCredit(credit)) return false;
39333
40410
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
39334
40411
  if (id !== intent.replyMessageId) return false;
39335
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
40412
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0, intent.mcpTurn === true) === intent.bodyMac;
39336
40413
  }
39337
40414
  /**
39338
40415
  * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
@@ -39353,12 +40430,12 @@ var BridgeAgent = class _BridgeAgent {
39353
40430
  * failure. Either way the intent stays pending, so the redelivery
39354
40431
  * republishes the same reply.
39355
40432
  */
39356
- async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
40433
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo, mcpTurn) {
39357
40434
  let rateLimited = 0;
39358
40435
  let inFlight = 0;
39359
40436
  for (; ; ) {
39360
40437
  try {
39361
- await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
40438
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo, mcpTurn);
39362
40439
  return;
39363
40440
  } catch (e) {
39364
40441
  if (e instanceof ReplyInFlightError) {
@@ -39429,8 +40506,9 @@ var BridgeAgent = class _BridgeAgent {
39429
40506
  * original ack; a conflict can only mean the slot belongs to another
39430
40507
  * message (for example an old reply whose intent was pruned).
39431
40508
  */
39432
- async publishIntentOnce(channelId, body, id, key, inReplyTo) {
40509
+ async publishIntentOnce(channelId, body, id, key, inReplyTo, mcpTurn) {
39433
40510
  const current = await this.store.getReplyIntent(key.scope, key.part);
40511
+ const guardMcp = mcpTurn || current?.mcpTurn === true;
39434
40512
  try {
39435
40513
  if (current?.envelope) {
39436
40514
  const cached = JSON.parse(new TextDecoder().decode(current.envelope));
@@ -39480,7 +40558,8 @@ var BridgeAgent = class _BridgeAgent {
39480
40558
  key.part,
39481
40559
  new TextEncoder().encode(JSON.stringify(envelope)),
39482
40560
  sealedGroupIdOf(envelope)
39483
- )
40561
+ ),
40562
+ guardMcp
39484
40563
  );
39485
40564
  } catch (e) {
39486
40565
  if (publishConflictCode(e) === "message_id_conflict") throw new ReplyIdCollisionError();
@@ -41991,16 +43070,20 @@ var BridgeAgent = class _BridgeAgent {
41991
43070
  }
41992
43071
  if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
41993
43072
  let leafClass = "ok";
43073
+ let relay = void 0;
41994
43074
  if (this.freshMeta(keyChannel)?.is_dm === true) {
41995
43075
  const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
41996
43076
  if (signedDm !== void 0 && signedDm !== keyChannel) return this.ownerTurnFailed(m, "wrong_dm_channel");
41997
- leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
43077
+ const leaves = (await this.provider.leafTreeSummary(m.channelId)).leaves;
43078
+ const relayIdentityId = this.toolGrantManifest?.owner_dm_relay_identity_id;
43079
+ leafClass = classifyOwnerDmLeaves(leaves, {
41998
43080
  ownerIdentityId: pin.ownerIdentityId,
41999
43081
  agentIdentityId: this.cfg.identityId,
42000
- relayIdentityId: this.toolGrantManifest?.owner_dm_relay_identity_id,
43082
+ relayIdentityId,
42001
43083
  isServerRelay: (id) => this.freshRoster(m.channelId)?.get(id)?.is_relay === true
42002
43084
  });
42003
43085
  if (leafClass === "foreign") return this.ownerTurnFailed(m, "not_owner_dm_leaves");
43086
+ relay = ownerDmRelayOf(leafClass, leaves, { ownerIdentityId: pin.ownerIdentityId, agentIdentityId: this.cfg.identityId, relayIdentityId });
42004
43087
  }
42005
43088
  const held = await this.heldPosition(m.channelId);
42006
43089
  if (held.groupId === null) return this.ownerTurnFailed(m, "verify_error");
@@ -42009,7 +43092,9 @@ var BridgeAgent = class _BridgeAgent {
42009
43092
  }
42010
43093
  const replay = Object.freeze({ channelId: m.channelId, groupId: held.groupId, epoch: v.mls_group_epoch });
42011
43094
  this.ownerReplayPending.set(m.messageId, replay);
42012
- return Object.freeze({ kind: leafClass === "unsigned_relay" ? "constants_only" : "verified", replay });
43095
+ const relayField = relay !== void 0 ? { relay } : {};
43096
+ if (leafClass === "unsigned_relay") return Object.freeze({ kind: "constants_only", replay, ...relayField });
43097
+ return Object.freeze({ kind: "verified", replay, ...relayField, relayLeaf: relayLeafFromPhase0Relay(relay) });
42013
43098
  } catch {
42014
43099
  return this.ownerTurnFailed(m, "verify_error");
42015
43100
  }
@@ -42085,6 +43170,12 @@ var BridgeAgent = class _BridgeAgent {
42085
43170
  }
42086
43171
  /** start(): drop other keys' manifest state, re-verify the stored one for the pinned key, and prune. */
42087
43172
  async loadToolGrantState() {
43173
+ try {
43174
+ this.mcpSeen = await this.store.loadMcpSeen();
43175
+ } catch (e) {
43176
+ this.mcpSeen = true;
43177
+ writeServiceLog2("warn", "authz.mcp_seen_load_failed", {}, e);
43178
+ }
42088
43179
  const pin = this.cfg.ownerGrantPin?.pin;
42089
43180
  if (!pin) return;
42090
43181
  try {
@@ -42092,9 +43183,10 @@ var BridgeAgent = class _BridgeAgent {
42092
43183
  this.store.dropToolGrantAppliedExcept(pin.publicKeyB64);
42093
43184
  const saved = await this.store.loadToolGrantManifest(pin.publicKeyB64);
42094
43185
  if (saved) {
42095
- const v = verifyToolGrantManifest(saved.wrapper, { pin, agentIdentityId: this.cfg.identityId, storedCounter: null });
43186
+ const v = verifyToolGrantManifest(saved.wrapper, { pin, agentIdentityId: this.cfg.identityId, storedCounter: null, ...this.mcpCatalog ? { mcpCatalog: this.mcpCatalog } : {} });
42096
43187
  if (v.state === "ok") {
42097
43188
  this.toolGrantManifest = v.manifest;
43189
+ if (v.manifest.signedMcpPresent && !this.mcpSeen) await this.setMcpSeen(true);
42098
43190
  this.toolGrantCounter = v.manifest.counter;
42099
43191
  } else {
42100
43192
  this.logManifest("malformed");
@@ -42119,8 +43211,9 @@ var BridgeAgent = class _BridgeAgent {
42119
43211
  this.logManifest("no_manifest");
42120
43212
  return;
42121
43213
  }
42122
- const v = verifyToolGrantManifest(raw, { pin, agentIdentityId: this.cfg.identityId, storedCounter: this.toolGrantCounter });
43214
+ const v = verifyToolGrantManifest(raw, { pin, agentIdentityId: this.cfg.identityId, storedCounter: this.toolGrantCounter, ...this.mcpCatalog ? { mcpCatalog: this.mcpCatalog } : {} });
42123
43215
  if (v.state === "ok") {
43216
+ if (v.manifest.signedMcpPresent) await this.setMcpSeen(true);
42124
43217
  try {
42125
43218
  await this.store.saveToolGrantManifest(pin.publicKeyB64, v.manifest.counter, v.wrapper);
42126
43219
  } catch {
@@ -42129,13 +43222,34 @@ var BridgeAgent = class _BridgeAgent {
42129
43222
  }
42130
43223
  this.toolGrantManifest = v.manifest;
42131
43224
  this.toolGrantCounter = v.manifest.counter;
43225
+ this.recomputeMcpPolicy();
43226
+ if (v.manifest.mcpKey === "present" && !v.manifest.signedMcpPresent && this.mcpSeen) await this.setMcpSeen(false);
42132
43227
  this.logManifest("ok");
42133
43228
  if (v.droppedUnknown) this.logManifest("unknown_entry");
42134
43229
  return;
42135
43230
  }
42136
43231
  this.logManifest(v.state);
43232
+ if (v.state === "wrong_owner") this.dropOwnerDmLastTurn();
42137
43233
  if (v.state !== "unchanged" && this.toolGrantManifest) this.logManifest("kept_last_good");
42138
43234
  }
43235
+ /**
43236
+ * 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
43237
+ * an owner_dm `mcp:` string, or while the sticky flag is set (an old client's re-sign cannot lift it, audit C-1).
43238
+ */
43239
+ r3Hold() {
43240
+ return this.toolGrantManifest?.signedMcpPresent === true || this.mcpSeen;
43241
+ }
43242
+ /** Persist then mirror. Setting mirrors even on a failed write; clearing mirrors only after the write (fail closed). */
43243
+ async setMcpSeen(seen) {
43244
+ if (seen) this.mcpSeen = true;
43245
+ try {
43246
+ await this.store.saveMcpSeen(seen);
43247
+ } catch (e) {
43248
+ writeServiceLog2("warn", "authz.mcp_seen_save_failed", {}, e);
43249
+ return;
43250
+ }
43251
+ this.mcpSeen = seen;
43252
+ }
42139
43253
  logManifest(state) {
42140
43254
  this.manifestState = state;
42141
43255
  const quiet = state === "ok" || state === "unchanged" || state === "no_pin" || state === "no_manifest";
@@ -42232,6 +43346,7 @@ var BridgeAgent = class _BridgeAgent {
42232
43346
  let tool_grants = NO_TOOL_GRANTS;
42233
43347
  let relaySpeaker;
42234
43348
  let memoryWrite = false;
43349
+ let relayFree = false;
42235
43350
  const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId) && (opts.voice === void 0 || rosterFreshAt(vEntry, opts.voice.heldEpoch, opts.voice.heldGroupId));
42236
43351
  const ownerDmFresh = epochFresh || opts.voice === void 0 && this.ownerDmCycleFresh.has(channelId);
42237
43352
  if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
@@ -42258,6 +43373,7 @@ var BridgeAgent = class _BridgeAgent {
42258
43373
  tool_grants = ROOM_DEFAULT_READ_GRANTS;
42259
43374
  }
42260
43375
  let grantSource = tool_grants.length ? "constant" : "none";
43376
+ let ownerDmEffective = null;
42261
43377
  const manifest = this.cfg.ownerGrantPin?.pin ? this.toolGrantManifest : null;
42262
43378
  const binding = this.manifestBinding;
42263
43379
  const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
@@ -42276,6 +43392,21 @@ var BridgeAgent = class _BridgeAgent {
42276
43392
  });
42277
43393
  tool_grants = r.grants;
42278
43394
  grantSource = r.source;
43395
+ ownerDmEffective = r;
43396
+ const mcp = mcpIssuance({
43397
+ source: r.source,
43398
+ verdict: opts.ownerVerdict,
43399
+ entitlement: this.mcpEntitlementInForce(),
43400
+ hostSwitch: this.mcpHostSwitch,
43401
+ ownerDmManifestSwitchOn: this.ownerDmManifestGrants,
43402
+ disabled: this.payloadToolDeny,
43403
+ manifest,
43404
+ policy: this.mcpPolicyWritten
43405
+ });
43406
+ if (mcp.length > 0) {
43407
+ tool_grants = Object.freeze([...tool_grants, ...mcp]);
43408
+ relayFree = true;
43409
+ }
42279
43410
  } else if (tool_grants === ROOM_DEFAULT_READ_GRANTS) {
42280
43411
  const r = effectiveToolGrants({
42281
43412
  audience: "team_room",
@@ -42293,12 +43424,39 @@ var BridgeAgent = class _BridgeAgent {
42293
43424
  if (opts.ownerVerdict?.kind === "failed") {
42294
43425
  tool_grants = NO_TOOL_GRANTS;
42295
43426
  memoryWrite = false;
43427
+ relayFree = false;
42296
43428
  relaySpeaker = void 0;
42297
43429
  grantSource = "none";
42298
43430
  }
42299
43431
  const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
42300
43432
  if (opts.log !== false) {
42301
43433
  this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh, memoryWrite, grantSource);
43434
+ const pinOwner = this.cfg.ownerGrantPin?.pin?.ownerIdentityId;
43435
+ const verdict = opts.ownerVerdict;
43436
+ 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")) {
43437
+ const pinned = this.channelOwner.get(channelId);
43438
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
43439
+ const ownerDirect = ownerId !== void 0 && audience === "owner_dm" && senderId === ownerId;
43440
+ this.noteOwnerDmTurn({
43441
+ cause: {
43442
+ killSwitchOff: !this.ownerDmDefaultGrants,
43443
+ manifestSwitchOff: !this.ownerDmManifestGrants,
43444
+ pinMissingOrAnomalous: ownerId === void 0,
43445
+ // OQ-5: only from a PRESENT roster entry; a missing (TTL-expired) entry is `stale` below.
43446
+ senderNotOwner: entry !== void 0 && (sender === void 0 || !ownerDirect),
43447
+ stale: !ownerDmFresh || entry === void 0,
43448
+ verdict,
43449
+ manifestPresent: manifest !== null,
43450
+ signedDmChannel: manifest?.owner_dm_channel_id,
43451
+ channelId,
43452
+ boundNow,
43453
+ consumerAttached: this.manifestConsumer !== null
43454
+ },
43455
+ grantSource,
43456
+ toolGrants: tool_grants,
43457
+ withheld: grantSource === "manifest" && ownerDmEffective !== null ? ownerDmEffective.withheld : []
43458
+ });
43459
+ }
42302
43460
  }
42303
43461
  return Object.freeze({
42304
43462
  version: 1,
@@ -42306,9 +43464,78 @@ var BridgeAgent = class _BridgeAgent {
42306
43464
  sender_membership: sender ? "active" : "unknown",
42307
43465
  tool_grants,
42308
43466
  ...relaySpeaker ? { relay_speaker: relaySpeaker } : {},
42309
- ...memoryWrite ? { memory_write: true } : {}
43467
+ ...memoryWrite ? { memory_write: true } : {},
43468
+ ...relayFree ? { relay_free: true } : {}
42310
43469
  });
42311
43470
  }
43471
+ /**
43472
+ * 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
43473
+ * keep the previous one (and its `at`) when the change tuple is the same, so `at` means "last changed". Never throws
43474
+ * into the authorization path. Never logs a channel id, sender id or tool name.
43475
+ */
43476
+ noteOwnerDmTurn(t) {
43477
+ try {
43478
+ const v = t.cause.verdict;
43479
+ const relay = (v.kind === "verified" || v.kind === "constants_only") && v.relay !== void 0 ? v.relay : "unknown";
43480
+ let reason = {};
43481
+ if (t.grantSource !== "manifest") {
43482
+ const c = this.toolStatusCause(t.cause);
43483
+ if (c !== null && c.source === t.grantSource && c.cause !== void 0) {
43484
+ reason = c.sub !== void 0 ? { cause: c.cause, sub: c.sub } : { cause: c.cause };
43485
+ } else {
43486
+ writeServiceLog2("warn", "tool_status.source_mismatch");
43487
+ }
43488
+ } else {
43489
+ const c = this.toolStatusCause(t.cause);
43490
+ if (c === null || c.source !== "manifest") writeServiceLog2("warn", "tool_status.source_mismatch");
43491
+ }
43492
+ const names = t.toolGrants.map((g) => g.tool);
43493
+ const candidate = Object.freeze({
43494
+ at: bucketStartAt(this.toolStatusNow()),
43495
+ // ruling M-2: the 15-minute UTC bucket, never the minute
43496
+ counter: this.toolGrantCounter,
43497
+ source: t.grantSource,
43498
+ ...reason,
43499
+ relay,
43500
+ issued: Object.freeze(sanitizeIssued(names)),
43501
+ withheld: Object.freeze(t.withheld.map((w) => Object.freeze({ tool: w.tool, why: w.why }))),
43502
+ // A count only, never a name (content-free). 0 unless the MCP issuance above added mcp: grants.
43503
+ mcp_issued: Math.min(160, names.filter((n) => n.startsWith("mcp:")).length)
43504
+ });
43505
+ if (sameTuple(this.ownerDmLastTurn, candidate)) return;
43506
+ this.ownerDmLastTurn = candidate;
43507
+ this.toolStatusSender.note(candidate);
43508
+ } catch (e) {
43509
+ writeServiceLog2("warn", "tool_status.record_failed", {}, e);
43510
+ }
43511
+ }
43512
+ /** R2-M1: the served manifest names another owner than the pin; the held record belongs to the old owner. */
43513
+ dropOwnerDmLastTurn() {
43514
+ this.ownerDmLastTurn = null;
43515
+ this.toolStatusSender.drop();
43516
+ }
43517
+ /**
43518
+ * Phase 0 Task 9 (spec §3.5): POST the record, body = the record only, to the narrow agent-key route. The backend
43519
+ * resolves the identity from the device credential; no identity, channel or sender id is in the URL or the body.
43520
+ * Returns the status class only; never throws.
43521
+ */
43522
+ async postToolStatus(rec) {
43523
+ try {
43524
+ const res = await this.fetchBounded("runtime_config_tool_status", "control", `${this.cfg.apiBase}/api/v1/agents/me/runtime-config/tool-status`, {
43525
+ method: "POST",
43526
+ headers: { authorization: `Bearer ${this.cfg.apiKey}`, "content-type": "application/json" },
43527
+ body: JSON.stringify(rec)
43528
+ });
43529
+ const retryAfter = res.headers.get("retry-after");
43530
+ try {
43531
+ await res.body?.cancel();
43532
+ } catch {
43533
+ }
43534
+ return { status: res.status, retryAfter };
43535
+ } catch (e) {
43536
+ return e?.name === "FetchDeadlineError" ? "timeout" : "network";
43537
+ }
43538
+ }
42312
43539
  /**
42313
43540
  * One content-free INFO line per authorization decision (every call site of
42314
43541
  * authorizationFor stages exactly one runtime turn). It says WHY a turn got
@@ -42532,6 +43759,111 @@ var BridgeAgent = class _BridgeAgent {
42532
43759
  * client's own wait already renders "Summary unavailable" — so there is
42533
43760
  * nothing to catch here besides the send itself.
42534
43761
  */
43762
+ /**
43763
+ * 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
43764
+ * 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
43765
+ * that IS the signed owner DM; verifyOwnerTurn says `verified` (the pinned owner's signature, the signed DM, the leaf
43766
+ * check, not a replay) with `relayLeaf === false`; mcp-policy.json as written is `ok` with this server `ok`; a runtime
43767
+ * socket that declared mcp_client_v1 is attached; and the rate limits admit it. The replay row is written HERE
43768
+ * (M-3: the request never reaches stageRuntimeInbox, where it is otherwise written), for every verified request, before
43769
+ * anything is answered; the ownerReplayPending entry is removed in `finally`. A failure to record answers nothing.
43770
+ * Never throws. The answer runs outside the ingest cycle, so the row is acked now.
43771
+ */
43772
+ async acceptMcpCatalogRequest(m, body) {
43773
+ try {
43774
+ const req = parseMcpCatalogRequest(body);
43775
+ if (req === null) return logMcpCatalogRequest("malformed");
43776
+ const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
43777
+ if (this.keyChannelForFrame(m) !== m.channelId || this.freshMeta(m.channelId)?.is_dm !== true || signedDm === void 0 || signedDm !== m.channelId) {
43778
+ return logMcpCatalogRequest("not_owner_dm");
43779
+ }
43780
+ const verdict = await this.verifyOwnerTurn(m);
43781
+ if (verdict.kind !== "verified") return logMcpCatalogRequest(verdict.kind === "failed" && verdict.reason === "replay" ? "replay" : "not_verified");
43782
+ this.store.recordOwnerMessage(verdict.replay.channelId, m.messageId, verdict.replay.groupId, verdict.replay.epoch, Date.now());
43783
+ if (verdict.relayLeaf !== false) return logMcpCatalogRequest("relay_leaf");
43784
+ const target = this.mcpCatalogTarget(req.serverId);
43785
+ const lister = this.mcpClientCapable() ? this.mcpCatalogListers.values().next().value : void 0;
43786
+ if (target === null || lister === void 0) return logMcpCatalogRequest("not_ready");
43787
+ const admitted = this.mcpCatalogRate.admit(req.serverId);
43788
+ if (admitted !== "ok") return admitted === "in_flight" ? logMcpCatalogRequest("in_flight") : void 0;
43789
+ const work = this.outsideIngestCycle(() => this.answerMcpCatalogRequest(m.channelId, req, lister)).finally(() => {
43790
+ this.mcpCatalogRate.release(req.serverId);
43791
+ this.mcpCatalogWork.delete(work);
43792
+ });
43793
+ this.mcpCatalogWork.add(work);
43794
+ } catch {
43795
+ logMcpCatalogRequest("failed");
43796
+ } finally {
43797
+ this.ownerReplayPending.delete(m.messageId);
43798
+ }
43799
+ }
43800
+ /** The signed server's vendor and the bridge catalog row's issuer, when mcp-policy.json as WRITTEN is `ok` with it `ok`. */
43801
+ mcpCatalogTarget(serverId) {
43802
+ const doc = this.mcpPolicyWritten;
43803
+ if (doc === null || doc.state !== "ok" || !Object.prototype.hasOwnProperty.call(doc.servers, serverId)) return null;
43804
+ const s = doc.servers[serverId];
43805
+ if (s.state !== "ok" || s.vendor === void 0) return null;
43806
+ const catalog = this.mcpCatalog ?? MCP_CATALOG;
43807
+ if (!Object.prototype.hasOwnProperty.call(catalog, s.vendor)) return null;
43808
+ return { vendor: s.vendor, issuer: catalog[s.vendor].issuer };
43809
+ }
43810
+ /** Ask the runtime, re-check, build (384 KB cap) and send. Never throws. */
43811
+ async answerMcpCatalogRequest(channelId, req, lister) {
43812
+ try {
43813
+ const target = this.mcpCatalogTarget(req.serverId);
43814
+ if (target === null) return logMcpCatalogRequest("not_ready");
43815
+ const frame = await lister(req.serverId, MCP_CATALOG_LIST_TIMEOUT_MS);
43816
+ if (frame === null || frame === void 0) return logMcpCatalogRequest("runtime_timeout");
43817
+ const listed = validateCatalogListReply(frame, req.serverId, target.vendor);
43818
+ if (listed === null) return logMcpCatalogRequest("runtime_refused");
43819
+ const still = this.mcpCatalogTarget(req.serverId);
43820
+ if (still === null || still.vendor !== target.vendor) return logMcpCatalogRequest("not_ready");
43821
+ await this.sendMcpCatalog(channelId, buildMcpCatalogBody({ serverId: req.serverId, requestId: req.requestId, vendor: still.vendor, issuer: still.issuer, reply: listed }));
43822
+ logMcpCatalogRequest("sent");
43823
+ } catch (e) {
43824
+ logMcpCatalogRequest(e instanceof McpReplyWithheldError ? "withheld" : "send_failed");
43825
+ }
43826
+ }
43827
+ /**
43828
+ * The `mcp_catalog` reply: a system_event envelope with a body-level type (the sendSummary shape), on the shared
43829
+ * self-heal seam and the one publish chokepoint. H-D applies to EVERY attempt (it carries vendor-supplied tool
43830
+ * descriptions): after the seal, before the POST, the leaves at the SEALED group and epoch must be exactly the owner
43831
+ * and the agent, or the frame is withheld. One message id across the retry.
43832
+ */
43833
+ async sendMcpCatalog(channelId, body) {
43834
+ const joined = await this.reconcileJoin(channelId);
43835
+ if (!joined) throw new ChannelPendingError(channelId);
43836
+ const messageId = crypto.randomUUID();
43837
+ let attempt = 0;
43838
+ await this.sendWithSelfHealTerminal(
43839
+ this.provider,
43840
+ channelId,
43841
+ (epoch) => buildOutboundEnvelope({
43842
+ custody: this.custody,
43843
+ groupKey: this.provider,
43844
+ messageId,
43845
+ channelId,
43846
+ enclaveId: this.resolveEnclave(channelId),
43847
+ senderId: this.cfg.identityId,
43848
+ senderType: "agent",
43849
+ type: "system_event",
43850
+ bodyPlaintext: JSON.stringify(body),
43851
+ bindingId: "",
43852
+ bindingVersion: "0.0.0",
43853
+ agentSkillId: null,
43854
+ epoch,
43855
+ traceparent: ZERO_TRACEPARENT,
43856
+ references: [],
43857
+ stage5Outcome: "allow",
43858
+ stage5Evaluators: []
43859
+ }),
43860
+ async (envelope) => {
43861
+ attempt++;
43862
+ this.assertMcpReplyRelayFree(channelId, envelope, attempt);
43863
+ return this.publishEnvelope(channelId, () => this.transport.publish(envelope));
43864
+ }
43865
+ );
43866
+ }
42535
43867
  async handleSummarize(channelId, request) {
42536
43868
  const body = await runSummarize({
42537
43869
  request,
@@ -43042,6 +44374,11 @@ var BridgeAgent = class _BridgeAgent {
43042
44374
  new TextDecoder().decode(m.plaintext)
43043
44375
  );
43044
44376
  if (parsed.type && parsed.type !== "text") {
44377
+ if (parsed.type === "mcp_catalog_request" || parsed.type === "mcp_catalog") {
44378
+ if (parsed.type === "mcp_catalog_request") await this.acceptMcpCatalogRequest(m, parsed);
44379
+ acked.push(m.queueId);
44380
+ continue;
44381
+ }
43045
44382
  if (parsed.type === "summarize") {
43046
44383
  const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog2);
43047
44384
  if (request !== null) {
@@ -43550,7 +44887,11 @@ var BridgeAgent = class _BridgeAgent {
43550
44887
  }
43551
44888
  /** Every buffer exit: re-narrow manifest grants (review H-1), then note the frame's attribution class (review H-3). */
43552
44889
  delivering(m) {
43553
- const out = this.renarrowManifestGrants(m);
44890
+ let out = this.renarrowManifestGrants(m);
44891
+ if (!this.noteMcpDelivered(out)) {
44892
+ out = withMcpCapability(out, false);
44893
+ writeServiceLog2("warn", "mcp.delivered_record_full");
44894
+ }
43554
44895
  this.noteDeliveredFrame(out);
43555
44896
  return out;
43556
44897
  }
@@ -43561,6 +44902,50 @@ var BridgeAgent = class _BridgeAgent {
43561
44902
  * audience that is still signed, else dropped. Only narrows; an unchanged message is returned by reference.
43562
44903
  */
43563
44904
  renarrowManifestGrants(m) {
44905
+ const narrowed = this.renarrowManifestGrantsBase(m);
44906
+ return this.renarrowMcpGrants(narrowed);
44907
+ }
44908
+ /**
44909
+ * MCP Task 16 (audit H-2; round 2 LOW), after the base re-narrowing: a buffered `mcp:` grant survives release only
44910
+ * when, NOW, the turn is still a plain owner-DM turn in the signed DM channel, the host switch reads "on", the
44911
+ * entitlement reading is in force, the name is not in skills.disabled, the mcp-policy.json document last written
44912
+ * lists the server ok with the tool, and a FRESH synchronous read of the DM's local leaves holds exactly the pinned
44913
+ * owner and the agent (MlsGroupKeyProvider.leafSnapshotCached: one `state` object, no await; null fails closed). Any
44914
+ * other mcp: grant is dropped, never kept as a fallback. relay_free is dropped whenever no mcp: grant survives.
44915
+ * Read-only: never touches the MLS group. Only narrows; returns `m` by reference when nothing changes.
44916
+ */
44917
+ renarrowMcpGrants(m) {
44918
+ const a = m.authorization;
44919
+ if (!a) return m;
44920
+ const isMcp = (g) => g.tool.startsWith(MCP_GRANT_PREFIX);
44921
+ if (!a.tool_grants.some(isMcp) && a.relay_free === void 0) return m;
44922
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
44923
+ const manifest = pin !== null ? this.toolGrantManifest : null;
44924
+ 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);
44925
+ const kept = a.tool_grants.filter((g) => {
44926
+ if (!isMcp(g)) return true;
44927
+ const parsed = parseMcpGrant(g.tool);
44928
+ return turnOk && g.mode === "read" && g.source === "manifest" && parsed !== null && !this.payloadToolDeny.has(g.tool) && mcpPolicyAllows(this.mcpPolicyWritten, parsed.serverId, parsed.tool);
44929
+ });
44930
+ const keepRelayFree = a.relay_free === true && kept.some(isMcp);
44931
+ if (kept.length === a.tool_grants.length && keepRelayFree === (a.relay_free === true)) return m;
44932
+ const { relay_free: _relayFree, ...rest } = a;
44933
+ return { ...m, authorization: Object.freeze({ ...rest, tool_grants: Object.freeze(kept), ...keepRelayFree ? { relay_free: true } : {} }) };
44934
+ }
44935
+ /**
44936
+ * 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
44937
+ * owner and this agent. Synchronous (one manager `state`, so the leaves belong to one epoch); a cold manager or any
44938
+ * throw reads as NOT relay-free.
44939
+ */
44940
+ ownerDmRelayFreeNow(channelId, ownerIdentityId) {
44941
+ try {
44942
+ const snap = this.provider.leafSnapshotCached(channelId);
44943
+ return snap !== null && ownerDmLeavesRelayFree(snap.leaves, { ownerIdentityId, agentIdentityId: this.cfg.identityId });
44944
+ } catch {
44945
+ return false;
44946
+ }
44947
+ }
44948
+ renarrowManifestGrantsBase(m) {
43564
44949
  const a = m.authorization;
43565
44950
  if (!a || !a.tool_grants.some((g) => g.mode === "read" && g.source === "manifest")) return m;
43566
44951
  const audience = a.audience === "owner_dm" || a.audience === "team_room" ? a.audience : null;
@@ -43656,6 +45041,7 @@ var BridgeAgent = class _BridgeAgent {
43656
45041
  return;
43657
45042
  }
43658
45043
  if (!this.admitRuntimeDelivery(m)) return;
45044
+ this.noteMcpDelivered(m, true);
43659
45045
  const ch = m.channel_id;
43660
45046
  this.replayingChannels.add(ch);
43661
45047
  this.replyIntentReplays++;
@@ -44006,11 +45392,11 @@ ${channelId}`;
44006
45392
  if (!res.ok) return void 0;
44007
45393
  const rows = await res.json();
44008
45394
  if (!Array.isArray(rows)) return void 0;
44009
- const own = rows.find(
45395
+ const own2 = rows.find(
44010
45396
  (r) => r?.identity_id === this.cfg.identityId
44011
45397
  );
44012
- if (!own) return void 0;
44013
- return typeof own.paused_at === "string" && own.paused_at.length > 0;
45398
+ if (!own2) return void 0;
45399
+ return typeof own2.paused_at === "string" && own2.paused_at.length > 0;
44014
45400
  } catch (e) {
44015
45401
  writeServiceLog2("warn", "pause.probe_failed", {}, e);
44016
45402
  return void 0;
@@ -44043,6 +45429,10 @@ ${channelId}`;
44043
45429
  /** Complete the durable handoff only after the runtime explicitly acks. */
44044
45430
  ackRuntimeMessages(messageIds) {
44045
45431
  for (const id of messageIds) this.roomGrantEpochs.delete(id);
45432
+ for (const id of messageIds) {
45433
+ const r = this.mcpDeliveredTurns.get(id);
45434
+ if (r) r.acked = true;
45435
+ }
44046
45436
  for (const messageId of messageIds) {
44047
45437
  this.store.ackRuntimeInbox(messageId);
44048
45438
  this.runtimePendingIds.delete(messageId);
@@ -44124,6 +45514,7 @@ ${channelId}`;
44124
45514
  const message = rest;
44125
45515
  this.applyStagedMarks(message.message_id, marks);
44126
45516
  this.runtimePendingIds.add(message.message_id);
45517
+ this.noteMcpDelivered(message, true);
44127
45518
  if (this.cfg.ownerGrantPin?.pin && this.isOwnerVerifyFailed(message.message_id)) {
44128
45519
  this.ownerVerifyFailedIds.set(message.message_id, message.channel_id);
44129
45520
  }
@@ -44304,6 +45695,67 @@ ${channelId}`;
44304
45695
  clearSearchProvidersSocket(token) {
44305
45696
  if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44306
45697
  }
45698
+ /** MCP Task 16 (spec §15): the same AND-over-sockets state for mcp_client_v1, kept per socket. */
45699
+ mcpClientSockets = /* @__PURE__ */ new Map();
45700
+ noteMcpClientSocket(token, declared) {
45701
+ this.mcpClientSockets.set(token, declared);
45702
+ this.onCapabilityMaybeChanged();
45703
+ }
45704
+ clearMcpClientSocket(token) {
45705
+ if (this.mcpClientSockets.delete(token)) this.onCapabilityMaybeChanged();
45706
+ }
45707
+ /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
45708
+ noteMcpCatalogLister(token, lister) {
45709
+ if (lister === null) this.mcpCatalogListers.delete(token);
45710
+ else this.mcpCatalogListers.set(token, lister);
45711
+ }
45712
+ /**
45713
+ * MCP Task 19 (spec §12.3): "ok" when every attached runtime socket declared mcp_client_v1 (the AND, which harness
45714
+ * sockets hold false: a harness socket counts in the AND and never declares it); else "harness_consumer" when the
45715
+ * latest hello was a harness; else "missing".
45716
+ */
45717
+ reportedMcpCapability() {
45718
+ if (this.mcpClientCapable()) return "ok";
45719
+ return this.lastConsumerCapability === "harness_consumer" ? "harness_consumer" : "missing";
45720
+ }
45721
+ /**
45722
+ * MCP Task 19 (spec §12.3): at most 8 `{server_id, vendor, state}` for the signed servers of the manifest in force,
45723
+ * from mcp-policy.json as WRITTEN. Only the states the bridge itself knows: top `kill_switch` and `not_entitled` give
45724
+ * that state per server; top `ok` gives `ok` for a server the bridge lists `ok` and `malformed` for one it refused.
45725
+ * Every other top state (no capability, suspended, no config, switch off, section absent, malformed section) gives no
45726
+ * row. The runtime-only states (needs_login, login_expired, definition_changed, server_version_unsupported,
45727
+ * server_unreachable) have no runtime-to-bridge report path in Phase 1. `vendor` is reported only when it is a key of
45728
+ * the bridge's compiled catalog; a signed vendor outside it gives no row. Never a tool name, URL or issuer.
45729
+ */
45730
+ reportedMcpServers() {
45731
+ const doc = this.mcpPolicyWritten;
45732
+ const signed = (this.cfg.ownerGrantPin?.pin ?? null) !== null ? this.toolGrantManifest?.mcp?.servers : void 0;
45733
+ if (doc === null || signed === void 0) return [];
45734
+ const catalog = this.mcpCatalog ?? MCP_CATALOG;
45735
+ const out = [];
45736
+ for (const id of Object.keys(signed).sort()) {
45737
+ if (out.length >= 8) break;
45738
+ const vendor = signed[id].vendor;
45739
+ if (!Object.prototype.hasOwnProperty.call(catalog, vendor)) continue;
45740
+ let state;
45741
+ if (doc.state === "kill_switch" || doc.state === "not_entitled") state = doc.state;
45742
+ else if (doc.state === "ok") state = Object.prototype.hasOwnProperty.call(doc.servers, id) && doc.servers[id].state === "ok" ? "ok" : "malformed";
45743
+ else continue;
45744
+ out.push({ server_id: id, vendor, state });
45745
+ }
45746
+ return out;
45747
+ }
45748
+ /**
45749
+ * MCP Task 16: every attached runtime socket declared mcp_client_v1; no socket attached means absent (false).
45750
+ * PR-B2 follow-up (documented): a HARNESS socket counts in this AND. ws-server notes every socket that said hello,
45751
+ * and a harness hello is always noted `false` (harnessClaimed never resets), so one attached harness connector holds
45752
+ * the capability false for files, the report and mcp-policy.json (top `no_capability`) while it is attached.
45753
+ */
45754
+ mcpClientCapable() {
45755
+ if (this.mcpClientSockets.size === 0) return false;
45756
+ for (const v of this.mcpClientSockets.values()) if (!v) return false;
45757
+ return true;
45758
+ }
44307
45759
  noteUtilityToolsSocket(token, declared) {
44308
45760
  this.utilityToolsSockets.set(token, declared);
44309
45761
  this.onCapabilityMaybeChanged();
@@ -44322,6 +45774,7 @@ ${channelId}`;
44322
45774
  const out = /* @__PURE__ */ new Set();
44323
45775
  if (this.searchProvidersCapable()) out.add("search_providers_v2");
44324
45776
  if (this.utilityToolsCapable()) out.add("utility_tools_v1");
45777
+ if (this.mcpClientCapable()) out.add("mcp_client_v1");
44325
45778
  return out;
44326
45779
  }
44327
45780
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
@@ -44350,11 +45803,12 @@ ${channelId}`;
44350
45803
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
44351
45804
  */
44352
45805
  onCapabilityMaybeChanged() {
44353
- const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
45806
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable(), mcp: this.mcpClientCapable() };
44354
45807
  const prev = this.capabilityState;
44355
- if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
45808
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility && prev.mcp === next.mcp) return;
44356
45809
  this.capabilityState = next;
44357
45810
  this.recomputeManifestPolicy(this.capabilityFeatures());
45811
+ this.recomputeMcpPolicy();
44358
45812
  if (this.closingDown) return;
44359
45813
  if (prev === null && !next.search && !next.utility) return;
44360
45814
  const now = Date.now();
@@ -44600,6 +46054,7 @@ ${channelId}`;
44600
46054
  async closeOnce() {
44601
46055
  this.closingDown = true;
44602
46056
  this.statusGate.close();
46057
+ this.toolStatusSender.close();
44603
46058
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
44604
46059
  this.pauseRecheckTimers.clear();
44605
46060
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -44608,6 +46063,8 @@ ${channelId}`;
44608
46063
  this.redeliveryTimer = null;
44609
46064
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
44610
46065
  this.searchCapabilityRefetchTimer = null;
46066
+ if (this.mcpPolicyRetryTimer !== null) clearTimeout(this.mcpPolicyRetryTimer);
46067
+ this.mcpPolicyRetryTimer = null;
44611
46068
  this.pingHandle?.close();
44612
46069
  if (this.safetyTimer) clearInterval(this.safetyTimer);
44613
46070
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
@@ -44962,63 +46419,6 @@ function wsErrorFrame(code, detail) {
44962
46419
  return { type: "error", ...errorBody(code, detail) };
44963
46420
  }
44964
46421
 
44965
- // src/consumer-grants.ts
44966
- var NO_GRANTS = Object.freeze([]);
44967
- var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
44968
- function withConsumerGrants(m, perToolConsumer) {
44969
- if (perToolConsumer) return m;
44970
- const a = m.authorization;
44971
- if (!a) return m;
44972
- const stripGrants = !(a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner");
44973
- const stripMemory = "memory_write" in a;
44974
- if (!stripGrants && !stripMemory) return m;
44975
- const { memory_write: _dropped, ...rest } = a;
44976
- return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
44977
- }
44978
- var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
44979
- var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
44980
- var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
44981
- var CONSTANT_TOOL_NAMES = Object.freeze({
44982
- owner_dm: Object.freeze(["current_time", "web_search"]),
44983
- team_room: Object.freeze(["current_time"])
44984
- });
44985
- var isManifestGrant = (g) => g.mode === "read" && g.source === "manifest";
44986
- function withManifestCapability(m, manifestConsumer) {
44987
- if (manifestConsumer) return m;
44988
- const a = m.authorization;
44989
- if (!a || !a.tool_grants.some(isManifestGrant)) return m;
44990
- const constants5 = new Set(a.audience === "owner_dm" || a.audience === "team_room" ? CONSTANT_TOOL_NAMES[a.audience] : []);
44991
- const narrowed = a.tool_grants.flatMap((g) => {
44992
- if (!isManifestGrant(g)) return [g];
44993
- return constants5.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
44994
- });
44995
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(narrowed) }) };
44996
- }
44997
- function withHarnessGrants(m, withheld) {
44998
- if (!withheld) return m;
44999
- const a = m.authorization;
45000
- if (!a || a.tool_grants.length === 0) return m;
45001
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
45002
- }
45003
- function withSearchCapability(m, consumerHasFeature, signedProvider) {
45004
- if (consumerHasFeature) return m;
45005
- const a = m.authorization;
45006
- if (!a) return m;
45007
- const stripSearch = signedProvider !== void 0 && signedProvider !== "brave";
45008
- const kept = a.tool_grants.filter((g) => g.tool !== "web_extract" && !(stripSearch && g.tool === "web_search"));
45009
- if (kept.length === a.tool_grants.length) return m;
45010
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45011
- }
45012
- var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
45013
- function withUtilityCapability(m, consumerHasFeature) {
45014
- if (consumerHasFeature) return m;
45015
- const a = m.authorization;
45016
- if (!a) return m;
45017
- const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
45018
- if (kept.length === a.tool_grants.length) return m;
45019
- return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45020
- }
45021
-
45022
46422
  // src/http-server.ts
45023
46423
  function writeSse(res, msgs, onWriteError) {
45024
46424
  for (let i = 0; i < msgs.length; i++) {
@@ -45041,7 +46441,8 @@ data: ${JSON.stringify(msgs[i])}
45041
46441
  function deliverSseBatch(res, batch, agent, onWriteError) {
45042
46442
  const withheld = agent.harnessGrantsWithheld();
45043
46443
  const provider = agent.signedSearchProvider();
45044
- const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
46444
+ const hold = agent.r3Hold();
46445
+ const left3 = writeSse(res, batch.map((m) => withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false), false), hold)), onWriteError);
45045
46446
  const leftIds = new Set(left3.map((message) => message.message_id));
45046
46447
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
45047
46448
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -45318,7 +46719,8 @@ function createHttpServer(agent, opts) {
45318
46719
  const drained = await agent.drain(channel, wait);
45319
46720
  const withheld = agent.harnessGrantsWithheld();
45320
46721
  const provider = agent.signedSearchProvider();
45321
- return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
46722
+ const hold = agent.r3Hold();
46723
+ return json(res, 200, drained.map((m) => withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false), false), hold)));
45322
46724
  }
45323
46725
  if (method === "GET" && path === "/v1/events") {
45324
46726
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -45388,6 +46790,7 @@ function parseHarnessReport(raw) {
45388
46790
  }
45389
46791
 
45390
46792
  // src/ws-server.ts
46793
+ import { randomBytes as randomBytes12 } from "node:crypto";
45391
46794
  import { WebSocketServer, WebSocket } from "ws";
45392
46795
 
45393
46796
  // src/memory/consumer-notes.ts
@@ -45498,6 +46901,8 @@ function attachAgentWebSocket(server, agent, opts) {
45498
46901
  manifestGrants: false,
45499
46902
  searchProviders: false,
45500
46903
  utilityTools: false,
46904
+ mcpClient: false,
46905
+ pendingCatalog: /* @__PURE__ */ new Map(),
45501
46906
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
45502
46907
  deliveryOpen: helloWaitMs <= 0,
45503
46908
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -45551,14 +46956,14 @@ function attachAgentWebSocket(server, agent, opts) {
45551
46956
  continue;
45552
46957
  }
45553
46958
  const out = withConsumerNotes(
45554
- withUtilityCapability(withSearchCapability(
46959
+ withR3Holds(withMcpCapability(withUtilityCapability(withSearchCapability(
45555
46960
  withManifestCapability(
45556
46961
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
45557
46962
  session.manifestGrants
45558
46963
  ),
45559
46964
  session.searchProviders,
45560
46965
  agent.signedSearchProvider()
45561
- ), session.utilityTools),
46966
+ ), session.utilityTools), session.mcpClient), agent.r3Hold()),
45562
46967
  batch[i],
45563
46968
  session,
45564
46969
  agent
@@ -45616,6 +47021,10 @@ function attachAgentWebSocket(server, agent, opts) {
45616
47021
  if (session.manifestGrants) agent.clearManifestConsumer(session);
45617
47022
  agent.clearSearchProvidersSocket(session);
45618
47023
  agent.clearUtilityToolsSocket(session);
47024
+ agent.clearMcpClientSocket(session);
47025
+ agent.noteMcpCatalogLister(session, null);
47026
+ for (const settle of session.pendingCatalog.values()) settle(null);
47027
+ session.pendingCatalog.clear();
45619
47028
  };
45620
47029
  current = { sock, cleanup, sweepStale };
45621
47030
  sock.on("close", cleanup);
@@ -45649,6 +47058,32 @@ function attachAgentWebSocket(server, agent, opts) {
45649
47058
  }
45650
47059
  };
45651
47060
  }
47061
+ var MCP_CATALOG_PENDING_MAX = 8;
47062
+ function mcpCatalogLister(sock, session) {
47063
+ return (serverId, timeoutMs) => new Promise((resolve2) => {
47064
+ if (session.closed || sock.readyState !== WebSocket.OPEN || session.pendingCatalog.size >= MCP_CATALOG_PENDING_MAX) {
47065
+ resolve2(null);
47066
+ return;
47067
+ }
47068
+ const requestId = randomBytes12(16).toString("hex");
47069
+ const timer = setTimeout(() => {
47070
+ session.pendingCatalog.delete(requestId);
47071
+ resolve2(null);
47072
+ }, timeoutMs);
47073
+ timer.unref?.();
47074
+ session.pendingCatalog.set(requestId, (frame) => {
47075
+ clearTimeout(timer);
47076
+ resolve2(frame);
47077
+ });
47078
+ try {
47079
+ sock.send(JSON.stringify({ type: "mcp_catalog_list", request_id: requestId, server_id: serverId }));
47080
+ } catch {
47081
+ session.pendingCatalog.delete(requestId);
47082
+ clearTimeout(timer);
47083
+ resolve2(null);
47084
+ }
47085
+ });
47086
+ }
45652
47087
  function sendJson(sock, body) {
45653
47088
  if (sock.readyState !== WebSocket.OPEN) return;
45654
47089
  try {
@@ -45722,6 +47157,9 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45722
47157
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45723
47158
  session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45724
47159
  agent.noteUtilityToolsSocket(session, session.utilityTools);
47160
+ 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);
47161
+ agent.noteMcpClientSocket(session, session.mcpClient);
47162
+ agent.noteMcpCatalogLister(session, session.mcpClient ? mcpCatalogLister(sock, session) : null);
45725
47163
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
45726
47164
  agent.noteNotesConsumer();
45727
47165
  }
@@ -45729,13 +47167,21 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45729
47167
  if (session.recallConsumer) agent.noteRecallConsumer();
45730
47168
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
45731
47169
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
45732
- 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] });
47170
+ 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] });
45733
47171
  if (!session.deliveryOpen) {
45734
47172
  session.deliveryOpen = true;
45735
47173
  flush();
45736
47174
  }
45737
47175
  return;
45738
47176
  }
47177
+ if (m.type === "mcp_catalog_list_result") {
47178
+ const id = m.request_id;
47179
+ const settle = typeof id === "string" ? session.pendingCatalog.get(id) : void 0;
47180
+ if (settle === void 0) return;
47181
+ session.pendingCatalog.delete(id);
47182
+ settle(m);
47183
+ return;
47184
+ }
45739
47185
  if (m.type === "nack") {
45740
47186
  if (typeof m.message_id !== "string" || !m.message_id) {
45741
47187
  sendJson(sock, wsErrorFrame("invalid_request"));