@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/index.js CHANGED
@@ -28960,6 +28960,29 @@ var StatusGate = class {
28960
28960
  }
28961
28961
  };
28962
28962
 
28963
+ // src/room-tools.ts
28964
+ var TOOL_ROOM_CLASSES = Object.freeze({
28965
+ current_time: Object.freeze(["room_safe_read"]),
28966
+ echo: Object.freeze(["room_safe_read"]),
28967
+ web_search: Object.freeze(["owner_credential"]),
28968
+ fetch_url: Object.freeze(["egress"]),
28969
+ sql_query: Object.freeze(["owner_credential"]),
28970
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
28971
+ });
28972
+ var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
28973
+ function isLiveRow(r) {
28974
+ return (r.status === void 0 || LIVE_MEMBER_STATUSES.has(r.status)) && r.left_but_in_group !== true;
28975
+ }
28976
+ function roomIsCrossOrg(rows) {
28977
+ if (rows.length === 0) return null;
28978
+ let unknown = false;
28979
+ for (const r of rows) {
28980
+ if (r.is_same_org_as_agent_owner === false) return true;
28981
+ if (r.is_same_org_as_agent_owner !== true) unknown = true;
28982
+ }
28983
+ return unknown ? null : false;
28984
+ }
28985
+
28963
28986
  // src/bridge-agent.ts
28964
28987
  function logSendSupersededAdoption(kind) {
28965
28988
  switch (kind) {
@@ -29008,11 +29031,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
29008
29031
  Object.freeze({ tool: "current_time", mode: "read" }),
29009
29032
  Object.freeze({ tool: "web_search", mode: "read" })
29010
29033
  ]);
29034
+ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
29035
+ Object.freeze({ tool: "current_time", mode: "read" })
29036
+ ]);
29011
29037
  var NO_TOOL_GRANTS = Object.freeze([]);
29012
29038
  function ownerDmDefaultGrantsEnabled(env = process.env) {
29013
29039
  const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
29014
29040
  return v === void 0 || v === "on";
29015
29041
  }
29042
+ function roomDefaultGrantsEnabled(env = process.env) {
29043
+ const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
29044
+ return v === void 0 || v === "on";
29045
+ }
29016
29046
  var VAULT_REF_BARE_TEXT = "[Files link]";
29017
29047
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
29018
29048
  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.";
@@ -29191,6 +29221,19 @@ var SendForbiddenError = class extends Error {
29191
29221
  this.name = "SendForbiddenError";
29192
29222
  }
29193
29223
  };
29224
+ var RoomClassChangedSendError = class extends SendForbiddenError {
29225
+ constructor(channelId) {
29226
+ super(channelId);
29227
+ this.name = "RoomClassChangedSendError";
29228
+ this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
29229
+ }
29230
+ };
29231
+ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
29232
+ return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
29233
+ }
29234
+ var RoomGrantEpochMovedError = class extends Error {
29235
+ };
29236
+ var ROOM_GRANT_TRACK_MAX = 4096;
29194
29237
  var GroupSupersededSendError = class extends Error {
29195
29238
  constructor(channelId, activeGroupId) {
29196
29239
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -29681,6 +29724,21 @@ var BridgeAgent = class _BridgeAgent {
29681
29724
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29682
29725
  /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29683
29726
  ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
29727
+ /** Read once at startup; see roomDefaultGrantsEnabled. */
29728
+ roomDefaultGrants = roomDefaultGrantsEnabled();
29729
+ /**
29730
+ * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
29731
+ * cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
29732
+ */
29733
+ ownerDmCycleFresh = /* @__PURE__ */ new Set();
29734
+ /**
29735
+ * Audit M-B1: room-granted turns, keyed by the inbound message id, with the
29736
+ * channel, MLS group and epoch the room was classified at (review I-1: an
29737
+ * epoch NUMBER alone repeats across channels and across a supersede). A
29738
+ * reply to one is sealed only there, or re-classified once
29739
+ * (publishBodyForReply). Forgotten at the runtime's ack.
29740
+ */
29741
+ roomGrantEpochs = /* @__PURE__ */ new Map();
29684
29742
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29685
29743
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29686
29744
  /**
@@ -30992,7 +31050,7 @@ var BridgeAgent = class _BridgeAgent {
30992
31050
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
30993
31051
  * there so a retry can resend the exact bytes).
30994
31052
  */
30995
- async publishBody(channelId, body, id, onEnvelope) {
31053
+ async publishBody(channelId, body, id, onEnvelope, sealCheck) {
30996
31054
  this.enforceBodyRateFloor(channelId, body);
30997
31055
  this.statusGate.replyStarted(channelId);
30998
31056
  const joined = await this.reconcileJoin(channelId);
@@ -31001,33 +31059,36 @@ var BridgeAgent = class _BridgeAgent {
31001
31059
  await this.sendWithSelfHealTerminal(
31002
31060
  this.provider,
31003
31061
  channelId,
31004
- (epoch) => this.latency.time(
31005
- "send.encrypt",
31006
- () => buildOutboundEnvelope({
31007
- custody: this.custody,
31008
- groupKey: this.provider,
31009
- messageId: id,
31010
- channelId,
31011
- enclaveId: this.resolveEnclave(channelId),
31012
- senderId: this.cfg.identityId,
31013
- senderType: "agent",
31014
- type: body.kind === "text" ? "text" : "a2a",
31015
- bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
31016
- type: "a2a",
31017
- a2a_version: body.a2a.a2a_version,
31018
- kind: body.a2a.kind,
31019
- object: body.a2a.object
31020
- }),
31021
- bindingId: "",
31022
- bindingVersion: "0.0.0",
31023
- agentSkillId: null,
31024
- epoch,
31025
- traceparent: ZERO_TRACEPARENT,
31026
- references: [],
31027
- stage5Outcome: "allow",
31028
- stage5Evaluators: []
31029
- })
31030
- ),
31062
+ (epoch) => {
31063
+ sealCheck?.(epoch);
31064
+ return this.latency.time(
31065
+ "send.encrypt",
31066
+ () => buildOutboundEnvelope({
31067
+ custody: this.custody,
31068
+ groupKey: this.provider,
31069
+ messageId: id,
31070
+ channelId,
31071
+ enclaveId: this.resolveEnclave(channelId),
31072
+ senderId: this.cfg.identityId,
31073
+ senderType: "agent",
31074
+ type: body.kind === "text" ? "text" : "a2a",
31075
+ bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
31076
+ type: "a2a",
31077
+ a2a_version: body.a2a.a2a_version,
31078
+ kind: body.a2a.kind,
31079
+ object: body.a2a.object
31080
+ }),
31081
+ bindingId: "",
31082
+ bindingVersion: "0.0.0",
31083
+ agentSkillId: null,
31084
+ epoch,
31085
+ traceparent: ZERO_TRACEPARENT,
31086
+ references: [],
31087
+ stage5Outcome: "allow",
31088
+ stage5Evaluators: []
31089
+ })
31090
+ );
31091
+ },
31031
31092
  async (envelope) => {
31032
31093
  if (onEnvelope) await onEnvelope(envelope);
31033
31094
  return this.publishEnvelope(
@@ -31038,6 +31099,71 @@ var BridgeAgent = class _BridgeAgent {
31038
31099
  );
31039
31100
  this.latency.record("send.total", performance.now() - sendStart);
31040
31101
  }
31102
+ /**
31103
+ * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
31104
+ * only at the epoch the room was classified at. On a move, the room is
31105
+ * re-classified ONCE from a roster fetched at the new epoch: still internal,
31106
+ * the reply is sealed at that epoch; anything else, it is dropped. The check
31107
+ * throws before an envelope is built, so nothing is stored or posted.
31108
+ *
31109
+ * `inReplyTo` is the STORED intent's inbound id, not the send's options, so
31110
+ * the redelivery replay (replayReplyIntents, which has no in_reply_to in its
31111
+ * options) is checked the same as a live send.
31112
+ */
31113
+ async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
31114
+ const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
31115
+ if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
31116
+ if (channelId !== grant.channelId) {
31117
+ writeServiceLog2("warn", "reply.room_class_changed");
31118
+ throw new RoomClassChangedSendError(channelId);
31119
+ }
31120
+ let reclassified = false;
31121
+ for (; ; ) {
31122
+ try {
31123
+ return await this.publishBody(
31124
+ channelId,
31125
+ body,
31126
+ id,
31127
+ // Review I-1: the group the envelope was ACTUALLY sealed under
31128
+ // (captured with the key, inside the channel lock), checked before
31129
+ // the durable save and before the post. An equal epoch number in a
31130
+ // new group (the supersede replay) is a move.
31131
+ async (envelope) => {
31132
+ if (sealedGroupIdOf(envelope) !== grant.groupId) throw new RoomGrantEpochMovedError();
31133
+ if (onEnvelope) await onEnvelope(envelope);
31134
+ },
31135
+ (epoch) => {
31136
+ if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
31137
+ }
31138
+ );
31139
+ } catch (e) {
31140
+ if (!(e instanceof RoomGrantEpochMovedError)) throw e;
31141
+ if (reclassified || !await this.reclassifyForReply(channelId, grant)) {
31142
+ writeServiceLog2("warn", "reply.room_class_changed");
31143
+ throw new RoomClassChangedSendError(channelId);
31144
+ }
31145
+ reclassified = true;
31146
+ }
31147
+ }
31148
+ }
31149
+ /**
31150
+ * Re-read the room at the agent's current group and epoch; true only if it
31151
+ * is still internal there. Review I-1: the entry must come from THIS re-read
31152
+ * (a fail-soft refresh leaves an older entry whose epoch label can repeat
31153
+ * across a supersede), and the group must not move around the reads.
31154
+ */
31155
+ async reclassifyForReply(channelId, grant) {
31156
+ const startedAt = Date.now();
31157
+ await this.refreshChannelRoster(channelId);
31158
+ const held = await this.heldPosition(channelId);
31159
+ const hit = this.freshRosterEntry(channelId);
31160
+ if (hit === void 0 || hit.at < startedAt || !rosterFreshAt(hit, held.epoch, held.groupId) || hit.crossOrg !== false || held.epoch === null || held.groupId === null) {
31161
+ return false;
31162
+ }
31163
+ grant.groupId = held.groupId;
31164
+ grant.epoch = held.epoch;
31165
+ return true;
31166
+ }
31041
31167
  enforceBodyRateFloor(channelId, body) {
31042
31168
  if (body.kind === "a2a") {
31043
31169
  this.enforceRateFloor("a2a", channelId);
@@ -31163,7 +31289,7 @@ var BridgeAgent = class _BridgeAgent {
31163
31289
  const bytes = intent.body;
31164
31290
  const body = decodeReplyBody(bytes);
31165
31291
  try {
31166
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
31292
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
31167
31293
  } catch (e) {
31168
31294
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
31169
31295
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -31218,12 +31344,12 @@ var BridgeAgent = class _BridgeAgent {
31218
31344
  * failure. Either way the intent stays pending, so the redelivery
31219
31345
  * republishes the same reply.
31220
31346
  */
31221
- async publishIntentWithRetry(channelId, body, id, key) {
31347
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
31222
31348
  let rateLimited = 0;
31223
31349
  let inFlight = 0;
31224
31350
  for (; ; ) {
31225
31351
  try {
31226
- await this.publishIntentOnce(channelId, body, id, key);
31352
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
31227
31353
  return;
31228
31354
  } catch (e) {
31229
31355
  if (e instanceof ReplyInFlightError) {
@@ -31294,7 +31420,7 @@ var BridgeAgent = class _BridgeAgent {
31294
31420
  * original ack; a conflict can only mean the slot belongs to another
31295
31421
  * message (for example an old reply whose intent was pruned).
31296
31422
  */
31297
- async publishIntentOnce(channelId, body, id, key) {
31423
+ async publishIntentOnce(channelId, body, id, key, inReplyTo) {
31298
31424
  const current = await this.store.getReplyIntent(key.scope, key.part);
31299
31425
  try {
31300
31426
  if (current?.envelope) {
@@ -31335,10 +31461,11 @@ var BridgeAgent = class _BridgeAgent {
31335
31461
  }
31336
31462
  }
31337
31463
  try {
31338
- await this.publishBody(
31464
+ await this.publishBodyForReply(
31339
31465
  channelId,
31340
31466
  body,
31341
31467
  id,
31468
+ inReplyTo,
31342
31469
  (envelope) => this.store.saveReplyIntentEnvelope(
31343
31470
  key.scope,
31344
31471
  key.part,
@@ -32169,10 +32296,33 @@ var BridgeAgent = class _BridgeAgent {
32169
32296
  }
32170
32297
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
32171
32298
  freshRoster(channelId) {
32299
+ return this.freshRosterEntry(channelId)?.members;
32300
+ }
32301
+ /** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
32302
+ freshRosterEntry(channelId) {
32172
32303
  const hit = this.channelRoster.get(channelId);
32173
- if (!hit) return void 0;
32174
- if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32175
- return hit.members;
32304
+ if (!hit || Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32305
+ return hit;
32306
+ }
32307
+ /** The agent's held MLS group id for a channel; null when none (fail closed). */
32308
+ async heldGroupFor(channelId) {
32309
+ return this.provider.heldGroupIdFor(channelId).catch(() => null);
32310
+ }
32311
+ /**
32312
+ * Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
32313
+ * The group is read on BOTH sides of the epoch read, so the epoch provably
32314
+ * belongs to it (a supersede in between installs a new group whose epoch
32315
+ * counter restarts); a group that moved, or none held, is null (fail closed).
32316
+ */
32317
+ async heldPosition(channelId) {
32318
+ const before = await this.heldGroupFor(channelId);
32319
+ const epoch = await this.heldEpochFor(channelId);
32320
+ const after = await this.heldGroupFor(channelId);
32321
+ return { groupId: before !== null && after === before ? before : null, epoch };
32322
+ }
32323
+ /** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
32324
+ async heldEpochFor(channelId) {
32325
+ return this.provider.currentEpoch(channelId).catch(() => null);
32176
32326
  }
32177
32327
  /**
32178
32328
  * Resolve one channel's roster (members + display names + is-agent) from the
@@ -32183,16 +32333,18 @@ var BridgeAgent = class _BridgeAgent {
32183
32333
  */
32184
32334
  async refreshChannelRoster(channelId) {
32185
32335
  try {
32336
+ const { epoch, groupId } = await this.heldPosition(channelId);
32186
32337
  const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
32187
32338
  channelId
32188
- )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
32339
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
32189
32340
  const res = await this.fetchBounded("channel_roster", "read", url, {
32190
32341
  headers: { authorization: `Bearer ${this.cfg.apiKey}` }
32191
32342
  });
32192
32343
  if (!res.ok) return;
32193
32344
  const rows = await res.json();
32194
32345
  const members = /* @__PURE__ */ new Map();
32195
- for (const r of rows) {
32346
+ const liveRows = rows.filter(isLiveRow);
32347
+ for (const r of liveRows) {
32196
32348
  members.set(r.identity_id, {
32197
32349
  name: r.display_name,
32198
32350
  is_agent: r.is_agent,
@@ -32201,13 +32353,45 @@ var BridgeAgent = class _BridgeAgent {
32201
32353
  is_relay: r.is_relay === true
32202
32354
  });
32203
32355
  }
32204
- this.channelRoster.set(channelId, { members, at: Date.now() });
32356
+ this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
32205
32357
  const self = members.get(this.cfg.identityId);
32206
32358
  if (self?.name) this.lastOwnDisplayName = self.name;
32207
- await this.resolveAndPinOwner(channelId, rows);
32359
+ await this.resolveAndPinOwner(channelId, liveRows);
32208
32360
  } catch {
32209
32361
  }
32210
32362
  }
32363
+ /**
32364
+ * S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
32365
+ * channel, re-read the roster when the TTL lapsed OR the epoch moved since
32366
+ * the fetch, then record which DMs are epoch-fresh for the rest of this
32367
+ * cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
32368
+ */
32369
+ async refreshRostersForCycle(channelIds) {
32370
+ this.ownerDmCycleFresh.clear();
32371
+ for (const channelId of new Set(channelIds)) {
32372
+ const held = await this.heldPosition(channelId);
32373
+ const hit = this.freshRosterEntry(channelId);
32374
+ if (hit === void 0 || hit.epoch !== held.epoch || hit.groupId !== held.groupId) {
32375
+ await this.refreshChannelRoster(channelId);
32376
+ }
32377
+ const now = this.freshRosterEntry(channelId);
32378
+ if (rosterFreshAt(now, held.epoch, held.groupId) && this.freshMeta(channelId)?.is_dm === true) {
32379
+ this.ownerDmCycleFresh.add(channelId);
32380
+ }
32381
+ }
32382
+ }
32383
+ /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
32384
+ async authorizeTurn(channelId, messageId, senderId, relaySpeakerId) {
32385
+ const held = await this.heldPosition(channelId);
32386
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId);
32387
+ if (auth.tool_grants !== ROOM_DEFAULT_READ_GRANTS) return auth;
32388
+ if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
32389
+ writeServiceLog2("warn", "room_grant.untracked");
32390
+ return Object.freeze({ ...auth, tool_grants: NO_TOOL_GRANTS });
32391
+ }
32392
+ this.roomGrantEpochs.set(messageId, { channelId, groupId: held.groupId, epoch: held.epoch });
32393
+ return auth;
32394
+ }
32211
32395
  /**
32212
32396
  * Classify the sender only from server-authenticated channel/roster data.
32213
32397
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
@@ -32225,6 +32409,13 @@ var BridgeAgent = class _BridgeAgent {
32225
32409
  * relayed `speaker_identity_id` (passed by the caller only for relay
32226
32410
  * turns) is that same pinned owner, whose roster row is the agent's
32227
32411
  * owner. A speaker claim from any non-relay sender gets nothing.
32412
+ * Both need the roster fetched at `heldEpoch` (the agent's current MLS
32413
+ * epoch for the channel), or the DM marked epoch-fresh at this ingest
32414
+ * cycle's refresh (audit M-B4).
32415
+ * PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
32416
+ * per-tool consumer is attached, the roster was fetched at `heldEpoch`
32417
+ * (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
32418
+ * over every row, leaf rows included) and the audience is team_room.
32228
32419
  * Everything else gets the frozen empty list.
32229
32420
  *
32230
32421
  * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
@@ -32236,9 +32427,10 @@ var BridgeAgent = class _BridgeAgent {
32236
32427
  * connector turns grants into its full toolset. Binding the owner identity
32237
32428
  * once, outside server control, is an open design follow-up.
32238
32429
  */
32239
- authorizationFor(channelId, senderId, relaySpeakerId) {
32430
+ authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
32240
32431
  const meta = this.freshMeta(channelId);
32241
- const roster = this.freshRoster(channelId);
32432
+ const entry = this.freshRosterEntry(channelId);
32433
+ const roster = entry?.members;
32242
32434
  const sender = roster?.get(senderId);
32243
32435
  let audience = "unknown";
32244
32436
  if (meta && sender) {
@@ -32249,7 +32441,10 @@ var BridgeAgent = class _BridgeAgent {
32249
32441
  else audience = "team_room";
32250
32442
  }
32251
32443
  let tool_grants = NO_TOOL_GRANTS;
32252
- if (this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32444
+ let relaySpeaker;
32445
+ const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
32446
+ const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
32447
+ if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32253
32448
  const pinned = this.channelOwner.get(channelId);
32254
32449
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32255
32450
  if (ownerId !== void 0) {
@@ -32257,14 +32452,20 @@ var BridgeAgent = class _BridgeAgent {
32257
32452
  const owner = roster?.get(ownerId);
32258
32453
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32259
32454
  if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32455
+ if (ownerRelayed) relaySpeaker = "owner";
32260
32456
  }
32261
32457
  }
32262
- this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length);
32458
+ if (this.roomDefaultGrants && this.perToolConsumer !== null && epochFresh && meta?.is_dm === false && audience === "team_room" && entry.crossOrg === false) {
32459
+ tool_grants = ROOM_DEFAULT_READ_GRANTS;
32460
+ }
32461
+ const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
32462
+ this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh);
32263
32463
  return Object.freeze({
32264
32464
  version: 1,
32265
32465
  audience,
32266
32466
  sender_membership: sender ? "active" : "unknown",
32267
- tool_grants
32467
+ tool_grants,
32468
+ ...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
32268
32469
  });
32269
32470
  }
32270
32471
  /**
@@ -32283,8 +32484,15 @@ var BridgeAgent = class _BridgeAgent {
32283
32484
  * startup: `on` = default grants enabled
32284
32485
  * meta_fresh / roster_fresh whether the channel meta / roster caches were
32285
32486
  * fresh (either stale forces audience=unknown)
32487
+ * room_class the room's class from the server roster (`na`
32488
+ * for a DM): internal | cross_org | unknown
32489
+ * epoch_fresh the roster was fetched at the agent's current
32490
+ * MLS epoch for the channel
32491
+ * room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
32492
+ * per_tool_consumer a consumer that declared per-tool grant
32493
+ * enforcement is attached
32286
32494
  */
32287
- logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
32495
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
32288
32496
  const meta = this.freshMeta(channelId);
32289
32497
  const roster = this.freshRoster(channelId);
32290
32498
  const sender = roster?.get(senderId);
@@ -32312,7 +32520,11 @@ var BridgeAgent = class _BridgeAgent {
32312
32520
  is_dm: meta?.is_dm === true,
32313
32521
  kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
32314
32522
  meta_fresh: meta !== void 0,
32315
- roster_fresh: roster !== void 0
32523
+ roster_fresh: roster !== void 0,
32524
+ room_class: ServiceLogEnum.of(roomClass, ["na", "internal", "cross_org", "unknown"]),
32525
+ epoch_fresh: epochFresh,
32526
+ room_kill_switch: ServiceLogEnum.of(this.roomDefaultGrants ? "on" : "off", ["on", "off"]),
32527
+ per_tool_consumer: this.perToolConsumer !== null
32316
32528
  });
32317
32529
  }
32318
32530
  /**
@@ -32898,14 +33110,7 @@ var BridgeAgent = class _BridgeAgent {
32898
33110
  await this.refreshChannelMeta();
32899
33111
  metaRefreshed = true;
32900
33112
  }
32901
- const rosterRefreshed = /* @__PURE__ */ new Set();
32902
- for (const m of entries) {
32903
- if (rosterRefreshed.has(m.channelId)) continue;
32904
- rosterRefreshed.add(m.channelId);
32905
- if (this.freshRoster(m.channelId) === void 0) {
32906
- await this.refreshChannelRoster(m.channelId);
32907
- }
32908
- }
33113
+ await this.refreshRostersForCycle(entries.map((m) => m.channelId));
32909
33114
  const relayMissRefreshed = /* @__PURE__ */ new Set();
32910
33115
  const acked = [];
32911
33116
  for (const m of entries) {
@@ -32988,7 +33193,7 @@ var BridgeAgent = class _BridgeAgent {
32988
33193
  // ignores vault_refs.
32989
33194
  text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32990
33195
  ts: m.receivedAt,
32991
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33196
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
32992
33197
  sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32993
33198
  sender_is_agent: senderIsAgent,
32994
33199
  vault_refs: [toVaultRefEntry(ref)],
@@ -33044,7 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
33044
33249
  sender_id: m.envelope.sender_id,
33045
33250
  text: typeof parsed.text === "string" ? parsed.text : "",
33046
33251
  ts: m.receivedAt,
33047
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33252
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33048
33253
  sender_name: senderName,
33049
33254
  sender_is_agent: senderIsAgent,
33050
33255
  attachments: [attachment],
@@ -33104,8 +33309,9 @@ var BridgeAgent = class _BridgeAgent {
33104
33309
  const senderName = this.freshRoster(m.channelId)?.get(
33105
33310
  m.envelope.sender_id
33106
33311
  )?.name;
33107
- const turnAuthorization = this.authorizationFor(
33312
+ const turnAuthorization = await this.authorizeTurn(
33108
33313
  m.channelId,
33314
+ m.messageId,
33109
33315
  m.envelope.sender_id,
33110
33316
  speaker?.identityId
33111
33317
  );
@@ -33676,6 +33882,7 @@ var BridgeAgent = class _BridgeAgent {
33676
33882
  }
33677
33883
  /** Complete the durable handoff only after the runtime explicitly acks. */
33678
33884
  ackRuntimeMessages(messageIds) {
33885
+ for (const id of messageIds) this.roomGrantEpochs.delete(id);
33679
33886
  for (const messageId of messageIds) {
33680
33887
  this.store.ackRuntimeInbox(messageId);
33681
33888
  this.runtimePendingIds.delete(messageId);
@@ -33805,6 +34012,24 @@ var BridgeAgent = class _BridgeAgent {
33805
34012
  harnessReport() {
33806
34013
  return this.harness;
33807
34014
  }
34015
+ /**
34016
+ * Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
34017
+ * enforcement (its hello carried "per_tool_grants" and no harness report),
34018
+ * or null. authorizationFor issues a room grant only while one is attached
34019
+ * (Task R2); every delivery path also strips room grants for any other
34020
+ * consumer (consumer-grants.ts). The token is the socket's session object.
34021
+ */
34022
+ perToolConsumer = null;
34023
+ notePerToolConsumer(token) {
34024
+ this.perToolConsumer = token;
34025
+ }
34026
+ /** Forget `token` when its socket closes; a newer consumer's token is kept. */
34027
+ clearPerToolConsumer(token) {
34028
+ if (this.perToolConsumer === token) this.perToolConsumer = null;
34029
+ }
34030
+ perToolConsumerAttached() {
34031
+ return this.perToolConsumer !== null;
34032
+ }
33808
34033
  health() {
33809
34034
  let joined = 0;
33810
34035
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -34158,6 +34383,16 @@ function wsErrorFrame(code, detail) {
34158
34383
  return { type: "error", ...errorBody(code, detail) };
34159
34384
  }
34160
34385
 
34386
+ // src/consumer-grants.ts
34387
+ var NO_GRANTS = Object.freeze([]);
34388
+ var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
34389
+ function withConsumerGrants(m, perToolConsumer) {
34390
+ if (perToolConsumer) return m;
34391
+ const a = m.authorization;
34392
+ if (!a || a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner") return m;
34393
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
34394
+ }
34395
+
34161
34396
  // src/http-server.ts
34162
34397
  function writeSse(res, msgs, onWriteError) {
34163
34398
  for (let i = 0; i < msgs.length; i++) {
@@ -34177,6 +34412,12 @@ data: ${JSON.stringify(msgs[i])}
34177
34412
  }
34178
34413
  return [];
34179
34414
  }
34415
+ function deliverSseBatch(res, batch, agent, onWriteError) {
34416
+ const left3 = writeSse(res, batch.map((m) => withConsumerGrants(m, false)), onWriteError);
34417
+ const leftIds = new Set(left3.map((message) => message.message_id));
34418
+ agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34419
+ if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
34420
+ }
34180
34421
  function sseWriteFailureReporter() {
34181
34422
  let warned = false;
34182
34423
  return (error) => {
@@ -34439,7 +34680,7 @@ function createHttpServer(agent, opts) {
34439
34680
  if (method === "GET" && path === "/v1/messages") {
34440
34681
  const channel = url.searchParams.get("channel_id") ?? void 0;
34441
34682
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
34442
- return json(res, 200, await agent.drain(channel, wait));
34683
+ return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
34443
34684
  }
34444
34685
  if (method === "GET" && path === "/v1/events") {
34445
34686
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -34451,13 +34692,7 @@ function createHttpServer(agent, opts) {
34451
34692
  });
34452
34693
  res.write(":connected\n\n");
34453
34694
  const onWriteError = sseWriteFailureReporter();
34454
- const drainToClient = () => {
34455
- const batch = agent.takeBuffered(channel);
34456
- const left3 = writeSse(res, batch, onWriteError);
34457
- const leftIds = new Set(left3.map((message) => message.message_id));
34458
- agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34459
- if (left3.length) agent.requeueFront(left3);
34460
- };
34695
+ const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
34461
34696
  const unsubscribe = agent.subscribe(drainToClient);
34462
34697
  const keepalive = setInterval(() => {
34463
34698
  try {
@@ -34573,7 +34808,7 @@ function attachAgentWebSocket(server, agent, opts) {
34573
34808
  });
34574
34809
  const inflight = /* @__PURE__ */ new Map();
34575
34810
  const delivered = /* @__PURE__ */ new Map();
34576
- const session = { consumer: "unknown" };
34811
+ const session = { consumer: "unknown", perToolGrants: false };
34577
34812
  const sweepStale = () => {
34578
34813
  const at = now();
34579
34814
  for (const [id, d] of delivered) {
@@ -34611,7 +34846,7 @@ function attachAgentWebSocket(server, agent, opts) {
34611
34846
  retired = true;
34612
34847
  continue;
34613
34848
  }
34614
- const frame = { type: "message", ...batch[i] };
34849
+ const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
34615
34850
  try {
34616
34851
  sock.send(JSON.stringify(frame));
34617
34852
  inflight.set(batch[i].message_id, batch[i]);
@@ -34641,6 +34876,7 @@ function attachAgentWebSocket(server, agent, opts) {
34641
34876
  delivered.clear();
34642
34877
  if (current?.sock === sock) current = null;
34643
34878
  if (session.harness) agent.clearHarnessReport(session.harness);
34879
+ if (session.perToolGrants) agent.clearPerToolConsumer(session);
34644
34880
  };
34645
34881
  current = { sock, cleanup, sweepStale };
34646
34882
  sock.on("close", cleanup);
@@ -34708,6 +34944,15 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34708
34944
  session.harness = report;
34709
34945
  agent.recordHarnessReport(report);
34710
34946
  } else session.consumer = "runtime";
34947
+ if (m.harness !== void 0) session.harnessClaimed = true;
34948
+ const perTool = session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(PER_TOOL_GRANTS_FEATURE);
34949
+ if (perTool && !session.perToolGrants) {
34950
+ session.perToolGrants = true;
34951
+ agent.notePerToolConsumer(session);
34952
+ } else if (!perTool && session.perToolGrants) {
34953
+ session.perToolGrants = false;
34954
+ agent.clearPerToolConsumer(session);
34955
+ }
34711
34956
  sendJson(sock, { type: "hello", features: ["nack"] });
34712
34957
  return;
34713
34958
  }
@@ -0,0 +1,23 @@
1
+ export type RoomToolClass = "room_safe_read" | "owner_credential" | "egress" | "side_effect";
2
+ /** §7A.1a, verbatim. A tool carries every class that applies; the most
3
+ * restrictive governs. `READ_ONLY_TOOLS` in the runtime is NOT this table:
4
+ * it says whether an implementation writes, not whose credential it spends
5
+ * or where it sends data (audit H-4). */
6
+ export declare const TOOL_ROOM_CLASSES: Readonly<Record<string, readonly RoomToolClass[]>>;
7
+ /** Edge statuses whose rows are channel MEMBERS for attribution and the
8
+ * existing roster consumers (today's set). The wider rows feed
9
+ * classification only. */
10
+ export declare const LIVE_MEMBER_STATUSES: ReadonlySet<string>;
11
+ /** A row that is a channel member: a live status (or an older backend with no
12
+ * status) and not a leaf row (audit M-B2: a departed member still holding a
13
+ * leaf counts for the class, never as a member). */
14
+ export declare function isLiveRow(r: {
15
+ status?: string;
16
+ left_but_in_group?: boolean | null;
17
+ }): boolean;
18
+ /** §7A.4 (1): a room is cross-org if ANY row's org differs from the agent
19
+ * owner's, leaf rows included. A row with no claim (an older backend, a
20
+ * member with no edge) makes the class unknown, which grants nothing (§7A.5). */
21
+ export declare function roomIsCrossOrg(rows: ReadonlyArray<{
22
+ is_same_org_as_agent_owner?: boolean | null;
23
+ }>): boolean | null;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.26.5";
1
+ export declare const BRIDGE_VERSION = "0.26.6";