@securecoms/agent-bridge 0.26.5 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -29112,6 +29112,29 @@ var StatusGate = class {
29112
29112
  }
29113
29113
  };
29114
29114
 
29115
+ // src/room-tools.ts
29116
+ var TOOL_ROOM_CLASSES = Object.freeze({
29117
+ current_time: Object.freeze(["room_safe_read"]),
29118
+ echo: Object.freeze(["room_safe_read"]),
29119
+ web_search: Object.freeze(["owner_credential"]),
29120
+ fetch_url: Object.freeze(["egress"]),
29121
+ sql_query: Object.freeze(["owner_credential"]),
29122
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
29123
+ });
29124
+ var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
29125
+ function isLiveRow(r) {
29126
+ return (r.status === void 0 || LIVE_MEMBER_STATUSES.has(r.status)) && r.left_but_in_group !== true;
29127
+ }
29128
+ function roomIsCrossOrg(rows) {
29129
+ if (rows.length === 0) return null;
29130
+ let unknown = false;
29131
+ for (const r of rows) {
29132
+ if (r.is_same_org_as_agent_owner === false) return true;
29133
+ if (r.is_same_org_as_agent_owner !== true) unknown = true;
29134
+ }
29135
+ return unknown ? null : false;
29136
+ }
29137
+
29115
29138
  // src/bridge-agent.ts
29116
29139
  function logSendSupersededAdoption(kind) {
29117
29140
  switch (kind) {
@@ -29160,11 +29183,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
29160
29183
  Object.freeze({ tool: "current_time", mode: "read" }),
29161
29184
  Object.freeze({ tool: "web_search", mode: "read" })
29162
29185
  ]);
29186
+ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
29187
+ Object.freeze({ tool: "current_time", mode: "read" })
29188
+ ]);
29163
29189
  var NO_TOOL_GRANTS = Object.freeze([]);
29164
29190
  function ownerDmDefaultGrantsEnabled(env = process.env) {
29165
29191
  const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
29166
29192
  return v === void 0 || v === "on";
29167
29193
  }
29194
+ function roomDefaultGrantsEnabled(env = process.env) {
29195
+ const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
29196
+ return v === void 0 || v === "on";
29197
+ }
29168
29198
  var VAULT_REF_BARE_TEXT = "[Files link]";
29169
29199
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
29170
29200
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
@@ -29343,6 +29373,19 @@ var SendForbiddenError = class extends Error {
29343
29373
  this.name = "SendForbiddenError";
29344
29374
  }
29345
29375
  };
29376
+ var RoomClassChangedSendError = class extends SendForbiddenError {
29377
+ constructor(channelId) {
29378
+ super(channelId);
29379
+ this.name = "RoomClassChangedSendError";
29380
+ this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
29381
+ }
29382
+ };
29383
+ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
29384
+ return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
29385
+ }
29386
+ var RoomGrantEpochMovedError = class extends Error {
29387
+ };
29388
+ var ROOM_GRANT_TRACK_MAX = 4096;
29346
29389
  var GroupSupersededSendError = class extends Error {
29347
29390
  constructor(channelId, activeGroupId) {
29348
29391
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -29833,6 +29876,21 @@ var BridgeAgent = class _BridgeAgent {
29833
29876
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29834
29877
  /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29835
29878
  ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
29879
+ /** Read once at startup; see roomDefaultGrantsEnabled. */
29880
+ roomDefaultGrants = roomDefaultGrantsEnabled();
29881
+ /**
29882
+ * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
29883
+ * cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
29884
+ */
29885
+ ownerDmCycleFresh = /* @__PURE__ */ new Set();
29886
+ /**
29887
+ * Audit M-B1: room-granted turns, keyed by the inbound message id, with the
29888
+ * channel, MLS group and epoch the room was classified at (review I-1: an
29889
+ * epoch NUMBER alone repeats across channels and across a supersede). A
29890
+ * reply to one is sealed only there, or re-classified once
29891
+ * (publishBodyForReply). Forgotten at the runtime's ack.
29892
+ */
29893
+ roomGrantEpochs = /* @__PURE__ */ new Map();
29836
29894
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29837
29895
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29838
29896
  /**
@@ -31144,7 +31202,7 @@ var BridgeAgent = class _BridgeAgent {
31144
31202
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
31145
31203
  * there so a retry can resend the exact bytes).
31146
31204
  */
31147
- async publishBody(channelId, body, id, onEnvelope) {
31205
+ async publishBody(channelId, body, id, onEnvelope, sealCheck) {
31148
31206
  this.enforceBodyRateFloor(channelId, body);
31149
31207
  this.statusGate.replyStarted(channelId);
31150
31208
  const joined = await this.reconcileJoin(channelId);
@@ -31153,33 +31211,36 @@ var BridgeAgent = class _BridgeAgent {
31153
31211
  await this.sendWithSelfHealTerminal(
31154
31212
  this.provider,
31155
31213
  channelId,
31156
- (epoch) => this.latency.time(
31157
- "send.encrypt",
31158
- () => buildOutboundEnvelope({
31159
- custody: this.custody,
31160
- groupKey: this.provider,
31161
- messageId: id,
31162
- channelId,
31163
- enclaveId: this.resolveEnclave(channelId),
31164
- senderId: this.cfg.identityId,
31165
- senderType: "agent",
31166
- type: body.kind === "text" ? "text" : "a2a",
31167
- bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
31168
- type: "a2a",
31169
- a2a_version: body.a2a.a2a_version,
31170
- kind: body.a2a.kind,
31171
- object: body.a2a.object
31172
- }),
31173
- bindingId: "",
31174
- bindingVersion: "0.0.0",
31175
- agentSkillId: null,
31176
- epoch,
31177
- traceparent: ZERO_TRACEPARENT,
31178
- references: [],
31179
- stage5Outcome: "allow",
31180
- stage5Evaluators: []
31181
- })
31182
- ),
31214
+ (epoch) => {
31215
+ sealCheck?.(epoch);
31216
+ return this.latency.time(
31217
+ "send.encrypt",
31218
+ () => buildOutboundEnvelope({
31219
+ custody: this.custody,
31220
+ groupKey: this.provider,
31221
+ messageId: id,
31222
+ channelId,
31223
+ enclaveId: this.resolveEnclave(channelId),
31224
+ senderId: this.cfg.identityId,
31225
+ senderType: "agent",
31226
+ type: body.kind === "text" ? "text" : "a2a",
31227
+ bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
31228
+ type: "a2a",
31229
+ a2a_version: body.a2a.a2a_version,
31230
+ kind: body.a2a.kind,
31231
+ object: body.a2a.object
31232
+ }),
31233
+ bindingId: "",
31234
+ bindingVersion: "0.0.0",
31235
+ agentSkillId: null,
31236
+ epoch,
31237
+ traceparent: ZERO_TRACEPARENT,
31238
+ references: [],
31239
+ stage5Outcome: "allow",
31240
+ stage5Evaluators: []
31241
+ })
31242
+ );
31243
+ },
31183
31244
  async (envelope) => {
31184
31245
  if (onEnvelope) await onEnvelope(envelope);
31185
31246
  return this.publishEnvelope(
@@ -31190,6 +31251,71 @@ var BridgeAgent = class _BridgeAgent {
31190
31251
  );
31191
31252
  this.latency.record("send.total", performance.now() - sendStart);
31192
31253
  }
31254
+ /**
31255
+ * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
31256
+ * only at the epoch the room was classified at. On a move, the room is
31257
+ * re-classified ONCE from a roster fetched at the new epoch: still internal,
31258
+ * the reply is sealed at that epoch; anything else, it is dropped. The check
31259
+ * throws before an envelope is built, so nothing is stored or posted.
31260
+ *
31261
+ * `inReplyTo` is the STORED intent's inbound id, not the send's options, so
31262
+ * the redelivery replay (replayReplyIntents, which has no in_reply_to in its
31263
+ * options) is checked the same as a live send.
31264
+ */
31265
+ async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
31266
+ const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
31267
+ if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
31268
+ if (channelId !== grant.channelId) {
31269
+ writeServiceLog("warn", "reply.room_class_changed");
31270
+ throw new RoomClassChangedSendError(channelId);
31271
+ }
31272
+ let reclassified = false;
31273
+ for (; ; ) {
31274
+ try {
31275
+ return await this.publishBody(
31276
+ channelId,
31277
+ body,
31278
+ id,
31279
+ // Review I-1: the group the envelope was ACTUALLY sealed under
31280
+ // (captured with the key, inside the channel lock), checked before
31281
+ // the durable save and before the post. An equal epoch number in a
31282
+ // new group (the supersede replay) is a move.
31283
+ async (envelope) => {
31284
+ if (sealedGroupIdOf(envelope) !== grant.groupId) throw new RoomGrantEpochMovedError();
31285
+ if (onEnvelope) await onEnvelope(envelope);
31286
+ },
31287
+ (epoch) => {
31288
+ if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
31289
+ }
31290
+ );
31291
+ } catch (e) {
31292
+ if (!(e instanceof RoomGrantEpochMovedError)) throw e;
31293
+ if (reclassified || !await this.reclassifyForReply(channelId, grant)) {
31294
+ writeServiceLog("warn", "reply.room_class_changed");
31295
+ throw new RoomClassChangedSendError(channelId);
31296
+ }
31297
+ reclassified = true;
31298
+ }
31299
+ }
31300
+ }
31301
+ /**
31302
+ * Re-read the room at the agent's current group and epoch; true only if it
31303
+ * is still internal there. Review I-1: the entry must come from THIS re-read
31304
+ * (a fail-soft refresh leaves an older entry whose epoch label can repeat
31305
+ * across a supersede), and the group must not move around the reads.
31306
+ */
31307
+ async reclassifyForReply(channelId, grant) {
31308
+ const startedAt = Date.now();
31309
+ await this.refreshChannelRoster(channelId);
31310
+ const held = await this.heldPosition(channelId);
31311
+ const hit = this.freshRosterEntry(channelId);
31312
+ if (hit === void 0 || hit.at < startedAt || !rosterFreshAt(hit, held.epoch, held.groupId) || hit.crossOrg !== false || held.epoch === null || held.groupId === null) {
31313
+ return false;
31314
+ }
31315
+ grant.groupId = held.groupId;
31316
+ grant.epoch = held.epoch;
31317
+ return true;
31318
+ }
31193
31319
  enforceBodyRateFloor(channelId, body) {
31194
31320
  if (body.kind === "a2a") {
31195
31321
  this.enforceRateFloor("a2a", channelId);
@@ -31315,7 +31441,7 @@ var BridgeAgent = class _BridgeAgent {
31315
31441
  const bytes = intent.body;
31316
31442
  const body = decodeReplyBody(bytes);
31317
31443
  try {
31318
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
31444
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
31319
31445
  } catch (e) {
31320
31446
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
31321
31447
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -31370,12 +31496,12 @@ var BridgeAgent = class _BridgeAgent {
31370
31496
  * failure. Either way the intent stays pending, so the redelivery
31371
31497
  * republishes the same reply.
31372
31498
  */
31373
- async publishIntentWithRetry(channelId, body, id, key) {
31499
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
31374
31500
  let rateLimited = 0;
31375
31501
  let inFlight = 0;
31376
31502
  for (; ; ) {
31377
31503
  try {
31378
- await this.publishIntentOnce(channelId, body, id, key);
31504
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
31379
31505
  return;
31380
31506
  } catch (e) {
31381
31507
  if (e instanceof ReplyInFlightError) {
@@ -31446,7 +31572,7 @@ var BridgeAgent = class _BridgeAgent {
31446
31572
  * original ack; a conflict can only mean the slot belongs to another
31447
31573
  * message (for example an old reply whose intent was pruned).
31448
31574
  */
31449
- async publishIntentOnce(channelId, body, id, key) {
31575
+ async publishIntentOnce(channelId, body, id, key, inReplyTo) {
31450
31576
  const current = await this.store.getReplyIntent(key.scope, key.part);
31451
31577
  try {
31452
31578
  if (current?.envelope) {
@@ -31487,10 +31613,11 @@ var BridgeAgent = class _BridgeAgent {
31487
31613
  }
31488
31614
  }
31489
31615
  try {
31490
- await this.publishBody(
31616
+ await this.publishBodyForReply(
31491
31617
  channelId,
31492
31618
  body,
31493
31619
  id,
31620
+ inReplyTo,
31494
31621
  (envelope) => this.store.saveReplyIntentEnvelope(
31495
31622
  key.scope,
31496
31623
  key.part,
@@ -32321,10 +32448,33 @@ var BridgeAgent = class _BridgeAgent {
32321
32448
  }
32322
32449
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
32323
32450
  freshRoster(channelId) {
32451
+ return this.freshRosterEntry(channelId)?.members;
32452
+ }
32453
+ /** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
32454
+ freshRosterEntry(channelId) {
32324
32455
  const hit = this.channelRoster.get(channelId);
32325
- if (!hit) return void 0;
32326
- if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32327
- return hit.members;
32456
+ if (!hit || Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32457
+ return hit;
32458
+ }
32459
+ /** The agent's held MLS group id for a channel; null when none (fail closed). */
32460
+ async heldGroupFor(channelId) {
32461
+ return this.provider.heldGroupIdFor(channelId).catch(() => null);
32462
+ }
32463
+ /**
32464
+ * Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
32465
+ * The group is read on BOTH sides of the epoch read, so the epoch provably
32466
+ * belongs to it (a supersede in between installs a new group whose epoch
32467
+ * counter restarts); a group that moved, or none held, is null (fail closed).
32468
+ */
32469
+ async heldPosition(channelId) {
32470
+ const before = await this.heldGroupFor(channelId);
32471
+ const epoch = await this.heldEpochFor(channelId);
32472
+ const after = await this.heldGroupFor(channelId);
32473
+ return { groupId: before !== null && after === before ? before : null, epoch };
32474
+ }
32475
+ /** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
32476
+ async heldEpochFor(channelId) {
32477
+ return this.provider.currentEpoch(channelId).catch(() => null);
32328
32478
  }
32329
32479
  /**
32330
32480
  * Resolve one channel's roster (members + display names + is-agent) from the
@@ -32335,16 +32485,18 @@ var BridgeAgent = class _BridgeAgent {
32335
32485
  */
32336
32486
  async refreshChannelRoster(channelId) {
32337
32487
  try {
32488
+ const { epoch, groupId } = await this.heldPosition(channelId);
32338
32489
  const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
32339
32490
  channelId
32340
- )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
32491
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
32341
32492
  const res = await this.fetchBounded("channel_roster", "read", url, {
32342
32493
  headers: { authorization: `Bearer ${this.cfg.apiKey}` }
32343
32494
  });
32344
32495
  if (!res.ok) return;
32345
32496
  const rows = await res.json();
32346
32497
  const members = /* @__PURE__ */ new Map();
32347
- for (const r of rows) {
32498
+ const liveRows = rows.filter(isLiveRow);
32499
+ for (const r of liveRows) {
32348
32500
  members.set(r.identity_id, {
32349
32501
  name: r.display_name,
32350
32502
  is_agent: r.is_agent,
@@ -32353,13 +32505,45 @@ var BridgeAgent = class _BridgeAgent {
32353
32505
  is_relay: r.is_relay === true
32354
32506
  });
32355
32507
  }
32356
- this.channelRoster.set(channelId, { members, at: Date.now() });
32508
+ this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
32357
32509
  const self = members.get(this.cfg.identityId);
32358
32510
  if (self?.name) this.lastOwnDisplayName = self.name;
32359
- await this.resolveAndPinOwner(channelId, rows);
32511
+ await this.resolveAndPinOwner(channelId, liveRows);
32360
32512
  } catch {
32361
32513
  }
32362
32514
  }
32515
+ /**
32516
+ * S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
32517
+ * channel, re-read the roster when the TTL lapsed OR the epoch moved since
32518
+ * the fetch, then record which DMs are epoch-fresh for the rest of this
32519
+ * cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
32520
+ */
32521
+ async refreshRostersForCycle(channelIds) {
32522
+ this.ownerDmCycleFresh.clear();
32523
+ for (const channelId of new Set(channelIds)) {
32524
+ const held = await this.heldPosition(channelId);
32525
+ const hit = this.freshRosterEntry(channelId);
32526
+ if (hit === void 0 || hit.epoch !== held.epoch || hit.groupId !== held.groupId) {
32527
+ await this.refreshChannelRoster(channelId);
32528
+ }
32529
+ const now = this.freshRosterEntry(channelId);
32530
+ if (rosterFreshAt(now, held.epoch, held.groupId) && this.freshMeta(channelId)?.is_dm === true) {
32531
+ this.ownerDmCycleFresh.add(channelId);
32532
+ }
32533
+ }
32534
+ }
32535
+ /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
32536
+ async authorizeTurn(channelId, messageId, senderId, relaySpeakerId) {
32537
+ const held = await this.heldPosition(channelId);
32538
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId);
32539
+ if (auth.tool_grants !== ROOM_DEFAULT_READ_GRANTS) return auth;
32540
+ if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
32541
+ writeServiceLog("warn", "room_grant.untracked");
32542
+ return Object.freeze({ ...auth, tool_grants: NO_TOOL_GRANTS });
32543
+ }
32544
+ this.roomGrantEpochs.set(messageId, { channelId, groupId: held.groupId, epoch: held.epoch });
32545
+ return auth;
32546
+ }
32363
32547
  /**
32364
32548
  * Classify the sender only from server-authenticated channel/roster data.
32365
32549
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
@@ -32377,6 +32561,13 @@ var BridgeAgent = class _BridgeAgent {
32377
32561
  * relayed `speaker_identity_id` (passed by the caller only for relay
32378
32562
  * turns) is that same pinned owner, whose roster row is the agent's
32379
32563
  * owner. A speaker claim from any non-relay sender gets nothing.
32564
+ * Both need the roster fetched at `heldEpoch` (the agent's current MLS
32565
+ * epoch for the channel), or the DM marked epoch-fresh at this ingest
32566
+ * cycle's refresh (audit M-B4).
32567
+ * PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
32568
+ * per-tool consumer is attached, the roster was fetched at `heldEpoch`
32569
+ * (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
32570
+ * over every row, leaf rows included) and the audience is team_room.
32380
32571
  * Everything else gets the frozen empty list.
32381
32572
  *
32382
32573
  * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
@@ -32388,9 +32579,10 @@ var BridgeAgent = class _BridgeAgent {
32388
32579
  * connector turns grants into its full toolset. Binding the owner identity
32389
32580
  * once, outside server control, is an open design follow-up.
32390
32581
  */
32391
- authorizationFor(channelId, senderId, relaySpeakerId) {
32582
+ authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
32392
32583
  const meta = this.freshMeta(channelId);
32393
- const roster = this.freshRoster(channelId);
32584
+ const entry = this.freshRosterEntry(channelId);
32585
+ const roster = entry?.members;
32394
32586
  const sender = roster?.get(senderId);
32395
32587
  let audience = "unknown";
32396
32588
  if (meta && sender) {
@@ -32401,7 +32593,10 @@ var BridgeAgent = class _BridgeAgent {
32401
32593
  else audience = "team_room";
32402
32594
  }
32403
32595
  let tool_grants = NO_TOOL_GRANTS;
32404
- if (this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32596
+ let relaySpeaker;
32597
+ const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
32598
+ const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
32599
+ if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32405
32600
  const pinned = this.channelOwner.get(channelId);
32406
32601
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32407
32602
  if (ownerId !== void 0) {
@@ -32409,14 +32604,20 @@ var BridgeAgent = class _BridgeAgent {
32409
32604
  const owner = roster?.get(ownerId);
32410
32605
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32411
32606
  if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32607
+ if (ownerRelayed) relaySpeaker = "owner";
32412
32608
  }
32413
32609
  }
32414
- this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length);
32610
+ if (this.roomDefaultGrants && this.perToolConsumer !== null && epochFresh && meta?.is_dm === false && audience === "team_room" && entry.crossOrg === false) {
32611
+ tool_grants = ROOM_DEFAULT_READ_GRANTS;
32612
+ }
32613
+ const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
32614
+ this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh);
32415
32615
  return Object.freeze({
32416
32616
  version: 1,
32417
32617
  audience,
32418
32618
  sender_membership: sender ? "active" : "unknown",
32419
- tool_grants
32619
+ tool_grants,
32620
+ ...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
32420
32621
  });
32421
32622
  }
32422
32623
  /**
@@ -32435,8 +32636,15 @@ var BridgeAgent = class _BridgeAgent {
32435
32636
  * startup: `on` = default grants enabled
32436
32637
  * meta_fresh / roster_fresh whether the channel meta / roster caches were
32437
32638
  * fresh (either stale forces audience=unknown)
32639
+ * room_class the room's class from the server roster (`na`
32640
+ * for a DM): internal | cross_org | unknown
32641
+ * epoch_fresh the roster was fetched at the agent's current
32642
+ * MLS epoch for the channel
32643
+ * room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
32644
+ * per_tool_consumer a consumer that declared per-tool grant
32645
+ * enforcement is attached
32438
32646
  */
32439
- logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
32647
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
32440
32648
  const meta = this.freshMeta(channelId);
32441
32649
  const roster = this.freshRoster(channelId);
32442
32650
  const sender = roster?.get(senderId);
@@ -32464,7 +32672,11 @@ var BridgeAgent = class _BridgeAgent {
32464
32672
  is_dm: meta?.is_dm === true,
32465
32673
  kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
32466
32674
  meta_fresh: meta !== void 0,
32467
- roster_fresh: roster !== void 0
32675
+ roster_fresh: roster !== void 0,
32676
+ room_class: ServiceLogEnum.of(roomClass, ["na", "internal", "cross_org", "unknown"]),
32677
+ epoch_fresh: epochFresh,
32678
+ room_kill_switch: ServiceLogEnum.of(this.roomDefaultGrants ? "on" : "off", ["on", "off"]),
32679
+ per_tool_consumer: this.perToolConsumer !== null
32468
32680
  });
32469
32681
  }
32470
32682
  /**
@@ -33050,14 +33262,7 @@ var BridgeAgent = class _BridgeAgent {
33050
33262
  await this.refreshChannelMeta();
33051
33263
  metaRefreshed = true;
33052
33264
  }
33053
- const rosterRefreshed = /* @__PURE__ */ new Set();
33054
- for (const m of entries) {
33055
- if (rosterRefreshed.has(m.channelId)) continue;
33056
- rosterRefreshed.add(m.channelId);
33057
- if (this.freshRoster(m.channelId) === void 0) {
33058
- await this.refreshChannelRoster(m.channelId);
33059
- }
33060
- }
33265
+ await this.refreshRostersForCycle(entries.map((m) => m.channelId));
33061
33266
  const relayMissRefreshed = /* @__PURE__ */ new Set();
33062
33267
  const acked = [];
33063
33268
  for (const m of entries) {
@@ -33140,7 +33345,7 @@ var BridgeAgent = class _BridgeAgent {
33140
33345
  // ignores vault_refs.
33141
33346
  text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
33142
33347
  ts: m.receivedAt,
33143
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33348
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33144
33349
  sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
33145
33350
  sender_is_agent: senderIsAgent,
33146
33351
  vault_refs: [toVaultRefEntry(ref)],
@@ -33196,7 +33401,7 @@ var BridgeAgent = class _BridgeAgent {
33196
33401
  sender_id: m.envelope.sender_id,
33197
33402
  text: typeof parsed.text === "string" ? parsed.text : "",
33198
33403
  ts: m.receivedAt,
33199
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33404
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33200
33405
  sender_name: senderName,
33201
33406
  sender_is_agent: senderIsAgent,
33202
33407
  attachments: [attachment],
@@ -33256,8 +33461,9 @@ var BridgeAgent = class _BridgeAgent {
33256
33461
  const senderName = this.freshRoster(m.channelId)?.get(
33257
33462
  m.envelope.sender_id
33258
33463
  )?.name;
33259
- const turnAuthorization = this.authorizationFor(
33464
+ const turnAuthorization = await this.authorizeTurn(
33260
33465
  m.channelId,
33466
+ m.messageId,
33261
33467
  m.envelope.sender_id,
33262
33468
  speaker?.identityId
33263
33469
  );
@@ -33828,6 +34034,7 @@ var BridgeAgent = class _BridgeAgent {
33828
34034
  }
33829
34035
  /** Complete the durable handoff only after the runtime explicitly acks. */
33830
34036
  ackRuntimeMessages(messageIds) {
34037
+ for (const id of messageIds) this.roomGrantEpochs.delete(id);
33831
34038
  for (const messageId of messageIds) {
33832
34039
  this.store.ackRuntimeInbox(messageId);
33833
34040
  this.runtimePendingIds.delete(messageId);
@@ -33957,6 +34164,24 @@ var BridgeAgent = class _BridgeAgent {
33957
34164
  harnessReport() {
33958
34165
  return this.harness;
33959
34166
  }
34167
+ /**
34168
+ * Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
34169
+ * enforcement (its hello carried "per_tool_grants" and no harness report),
34170
+ * or null. authorizationFor issues a room grant only while one is attached
34171
+ * (Task R2); every delivery path also strips room grants for any other
34172
+ * consumer (consumer-grants.ts). The token is the socket's session object.
34173
+ */
34174
+ perToolConsumer = null;
34175
+ notePerToolConsumer(token) {
34176
+ this.perToolConsumer = token;
34177
+ }
34178
+ /** Forget `token` when its socket closes; a newer consumer's token is kept. */
34179
+ clearPerToolConsumer(token) {
34180
+ if (this.perToolConsumer === token) this.perToolConsumer = null;
34181
+ }
34182
+ perToolConsumerAttached() {
34183
+ return this.perToolConsumer !== null;
34184
+ }
33960
34185
  health() {
33961
34186
  let joined = 0;
33962
34187
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -34202,6 +34427,16 @@ function wsErrorFrame(code, detail) {
34202
34427
  return { type: "error", ...errorBody(code, detail) };
34203
34428
  }
34204
34429
 
34430
+ // src/consumer-grants.ts
34431
+ var NO_GRANTS = Object.freeze([]);
34432
+ var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
34433
+ function withConsumerGrants(m, perToolConsumer) {
34434
+ if (perToolConsumer) return m;
34435
+ const a = m.authorization;
34436
+ if (!a || a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner") return m;
34437
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
34438
+ }
34439
+
34205
34440
  // src/http-server.ts
34206
34441
  function writeSse(res, msgs, onWriteError) {
34207
34442
  for (let i = 0; i < msgs.length; i++) {
@@ -34221,6 +34456,12 @@ data: ${JSON.stringify(msgs[i])}
34221
34456
  }
34222
34457
  return [];
34223
34458
  }
34459
+ function deliverSseBatch(res, batch, agent, onWriteError) {
34460
+ const left3 = writeSse(res, batch.map((m) => withConsumerGrants(m, false)), onWriteError);
34461
+ const leftIds = new Set(left3.map((message) => message.message_id));
34462
+ agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34463
+ if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
34464
+ }
34224
34465
  function sseWriteFailureReporter() {
34225
34466
  let warned = false;
34226
34467
  return (error) => {
@@ -34483,7 +34724,7 @@ function createHttpServer(agent, opts) {
34483
34724
  if (method === "GET" && path === "/v1/messages") {
34484
34725
  const channel = url.searchParams.get("channel_id") ?? void 0;
34485
34726
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
34486
- return json(res, 200, await agent.drain(channel, wait));
34727
+ return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
34487
34728
  }
34488
34729
  if (method === "GET" && path === "/v1/events") {
34489
34730
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -34495,13 +34736,7 @@ function createHttpServer(agent, opts) {
34495
34736
  });
34496
34737
  res.write(":connected\n\n");
34497
34738
  const onWriteError = sseWriteFailureReporter();
34498
- const drainToClient = () => {
34499
- const batch = agent.takeBuffered(channel);
34500
- const left3 = writeSse(res, batch, onWriteError);
34501
- const leftIds = new Set(left3.map((message) => message.message_id));
34502
- agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34503
- if (left3.length) agent.requeueFront(left3);
34504
- };
34739
+ const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
34505
34740
  const unsubscribe = agent.subscribe(drainToClient);
34506
34741
  const keepalive = setInterval(() => {
34507
34742
  try {
@@ -34617,7 +34852,7 @@ function attachAgentWebSocket(server, agent, opts) {
34617
34852
  });
34618
34853
  const inflight = /* @__PURE__ */ new Map();
34619
34854
  const delivered = /* @__PURE__ */ new Map();
34620
- const session = { consumer: "unknown" };
34855
+ const session = { consumer: "unknown", perToolGrants: false };
34621
34856
  const sweepStale = () => {
34622
34857
  const at = now();
34623
34858
  for (const [id, d] of delivered) {
@@ -34655,7 +34890,7 @@ function attachAgentWebSocket(server, agent, opts) {
34655
34890
  retired = true;
34656
34891
  continue;
34657
34892
  }
34658
- const frame = { type: "message", ...batch[i] };
34893
+ const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
34659
34894
  try {
34660
34895
  sock.send(JSON.stringify(frame));
34661
34896
  inflight.set(batch[i].message_id, batch[i]);
@@ -34685,6 +34920,7 @@ function attachAgentWebSocket(server, agent, opts) {
34685
34920
  delivered.clear();
34686
34921
  if (current?.sock === sock) current = null;
34687
34922
  if (session.harness) agent.clearHarnessReport(session.harness);
34923
+ if (session.perToolGrants) agent.clearPerToolConsumer(session);
34688
34924
  };
34689
34925
  current = { sock, cleanup, sweepStale };
34690
34926
  sock.on("close", cleanup);
@@ -34752,6 +34988,15 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34752
34988
  session.harness = report;
34753
34989
  agent.recordHarnessReport(report);
34754
34990
  } else session.consumer = "runtime";
34991
+ if (m.harness !== void 0) session.harnessClaimed = true;
34992
+ const perTool = session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(PER_TOOL_GRANTS_FEATURE);
34993
+ if (perTool && !session.perToolGrants) {
34994
+ session.perToolGrants = true;
34995
+ agent.notePerToolConsumer(session);
34996
+ } else if (!perTool && session.perToolGrants) {
34997
+ session.perToolGrants = false;
34998
+ agent.clearPerToolConsumer(session);
34999
+ }
34755
35000
  sendJson(sock, { type: "hello", features: ["nack"] });
34756
35001
  return;
34757
35002
  }