@securecoms/agent-bridge 0.26.4 → 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
@@ -28790,7 +28790,7 @@ function assertManagedName(name) {
28790
28790
  }
28791
28791
  function isLocalOnlyName(name) {
28792
28792
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28793
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28793
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
28794
28794
  }
28795
28795
  function atomicWrite(path, body, deps) {
28796
28796
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28849,6 +28849,26 @@ function lineHash(line) {
28849
28849
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28850
28850
  }
28851
28851
  var LOCAL_REPORT_FILE = "local-config.json";
28852
+ var SECRET_PATH_COUNT_MAX = 1e4;
28853
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
28854
+ function parseSecretPathsReport(raw) {
28855
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
28856
+ const r = raw;
28857
+ const f = r.outside_by_field;
28858
+ if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
28859
+ return {
28860
+ mode: r.mode,
28861
+ inside: r.inside,
28862
+ allowlisted: r.allowlisted,
28863
+ outside: r.outside,
28864
+ outside_by_field: {
28865
+ headers_file: f.headers_file,
28866
+ postgres_url_file: f.postgres_url_file,
28867
+ search_key_file: f.search_key_file,
28868
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
28869
+ }
28870
+ };
28871
+ }
28852
28872
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28853
28873
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28854
28874
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28917,7 +28937,8 @@ function readLocalReport(home, identityId) {
28917
28937
  persona,
28918
28938
  has_search_key: hasSearchKey,
28919
28939
  has_llm_key: hasLlmKey,
28920
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
28940
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
28941
+ secret_paths: parseSecretPathsReport(doc.secret_paths)
28921
28942
  };
28922
28943
  }
28923
28944
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -29091,6 +29112,29 @@ var StatusGate = class {
29091
29112
  }
29092
29113
  };
29093
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
+
29094
29138
  // src/bridge-agent.ts
29095
29139
  function logSendSupersededAdoption(kind) {
29096
29140
  switch (kind) {
@@ -29139,11 +29183,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
29139
29183
  Object.freeze({ tool: "current_time", mode: "read" }),
29140
29184
  Object.freeze({ tool: "web_search", mode: "read" })
29141
29185
  ]);
29186
+ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
29187
+ Object.freeze({ tool: "current_time", mode: "read" })
29188
+ ]);
29142
29189
  var NO_TOOL_GRANTS = Object.freeze([]);
29143
29190
  function ownerDmDefaultGrantsEnabled(env = process.env) {
29144
29191
  const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
29145
29192
  return v === void 0 || v === "on";
29146
29193
  }
29194
+ function roomDefaultGrantsEnabled(env = process.env) {
29195
+ const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
29196
+ return v === void 0 || v === "on";
29197
+ }
29147
29198
  var VAULT_REF_BARE_TEXT = "[Files link]";
29148
29199
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
29149
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.";
@@ -29322,6 +29373,19 @@ var SendForbiddenError = class extends Error {
29322
29373
  this.name = "SendForbiddenError";
29323
29374
  }
29324
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;
29325
29389
  var GroupSupersededSendError = class extends Error {
29326
29390
  constructor(channelId, activeGroupId) {
29327
29391
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -29812,6 +29876,21 @@ var BridgeAgent = class _BridgeAgent {
29812
29876
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29813
29877
  /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29814
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();
29815
29894
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29816
29895
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29817
29896
  /**
@@ -30115,7 +30194,11 @@ var BridgeAgent = class _BridgeAgent {
30115
30194
  // Inert legacy files only. `readLocalReport` revalidates the current
30116
30195
  // regular-file bytes and strips every local path before this leaves the
30117
30196
  // machine.
30118
- legacy_memory: local?.legacy_memory ?? []
30197
+ legacy_memory: local?.legacy_memory ?? [],
30198
+ // §B.4: counts of server-named secret paths inside / allowlisted /
30199
+ // outside the secrets directory, and the runtime's mode. Numbers and
30200
+ // one enum only; readLocalReport rebuilds it so no path rides along.
30201
+ secret_paths: local?.secret_paths ?? null
30119
30202
  };
30120
30203
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
30121
30204
  method: "POST",
@@ -31119,7 +31202,7 @@ var BridgeAgent = class _BridgeAgent {
31119
31202
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
31120
31203
  * there so a retry can resend the exact bytes).
31121
31204
  */
31122
- async publishBody(channelId, body, id, onEnvelope) {
31205
+ async publishBody(channelId, body, id, onEnvelope, sealCheck) {
31123
31206
  this.enforceBodyRateFloor(channelId, body);
31124
31207
  this.statusGate.replyStarted(channelId);
31125
31208
  const joined = await this.reconcileJoin(channelId);
@@ -31128,33 +31211,36 @@ var BridgeAgent = class _BridgeAgent {
31128
31211
  await this.sendWithSelfHealTerminal(
31129
31212
  this.provider,
31130
31213
  channelId,
31131
- (epoch) => this.latency.time(
31132
- "send.encrypt",
31133
- () => buildOutboundEnvelope({
31134
- custody: this.custody,
31135
- groupKey: this.provider,
31136
- messageId: id,
31137
- channelId,
31138
- enclaveId: this.resolveEnclave(channelId),
31139
- senderId: this.cfg.identityId,
31140
- senderType: "agent",
31141
- type: body.kind === "text" ? "text" : "a2a",
31142
- bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
31143
- type: "a2a",
31144
- a2a_version: body.a2a.a2a_version,
31145
- kind: body.a2a.kind,
31146
- object: body.a2a.object
31147
- }),
31148
- bindingId: "",
31149
- bindingVersion: "0.0.0",
31150
- agentSkillId: null,
31151
- epoch,
31152
- traceparent: ZERO_TRACEPARENT,
31153
- references: [],
31154
- stage5Outcome: "allow",
31155
- stage5Evaluators: []
31156
- })
31157
- ),
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
+ },
31158
31244
  async (envelope) => {
31159
31245
  if (onEnvelope) await onEnvelope(envelope);
31160
31246
  return this.publishEnvelope(
@@ -31165,6 +31251,71 @@ var BridgeAgent = class _BridgeAgent {
31165
31251
  );
31166
31252
  this.latency.record("send.total", performance.now() - sendStart);
31167
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
+ }
31168
31319
  enforceBodyRateFloor(channelId, body) {
31169
31320
  if (body.kind === "a2a") {
31170
31321
  this.enforceRateFloor("a2a", channelId);
@@ -31290,7 +31441,7 @@ var BridgeAgent = class _BridgeAgent {
31290
31441
  const bytes = intent.body;
31291
31442
  const body = decodeReplyBody(bytes);
31292
31443
  try {
31293
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
31444
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
31294
31445
  } catch (e) {
31295
31446
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
31296
31447
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -31345,12 +31496,12 @@ var BridgeAgent = class _BridgeAgent {
31345
31496
  * failure. Either way the intent stays pending, so the redelivery
31346
31497
  * republishes the same reply.
31347
31498
  */
31348
- async publishIntentWithRetry(channelId, body, id, key) {
31499
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
31349
31500
  let rateLimited = 0;
31350
31501
  let inFlight = 0;
31351
31502
  for (; ; ) {
31352
31503
  try {
31353
- await this.publishIntentOnce(channelId, body, id, key);
31504
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
31354
31505
  return;
31355
31506
  } catch (e) {
31356
31507
  if (e instanceof ReplyInFlightError) {
@@ -31421,7 +31572,7 @@ var BridgeAgent = class _BridgeAgent {
31421
31572
  * original ack; a conflict can only mean the slot belongs to another
31422
31573
  * message (for example an old reply whose intent was pruned).
31423
31574
  */
31424
- async publishIntentOnce(channelId, body, id, key) {
31575
+ async publishIntentOnce(channelId, body, id, key, inReplyTo) {
31425
31576
  const current = await this.store.getReplyIntent(key.scope, key.part);
31426
31577
  try {
31427
31578
  if (current?.envelope) {
@@ -31462,10 +31613,11 @@ var BridgeAgent = class _BridgeAgent {
31462
31613
  }
31463
31614
  }
31464
31615
  try {
31465
- await this.publishBody(
31616
+ await this.publishBodyForReply(
31466
31617
  channelId,
31467
31618
  body,
31468
31619
  id,
31620
+ inReplyTo,
31469
31621
  (envelope) => this.store.saveReplyIntentEnvelope(
31470
31622
  key.scope,
31471
31623
  key.part,
@@ -32296,10 +32448,33 @@ var BridgeAgent = class _BridgeAgent {
32296
32448
  }
32297
32449
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
32298
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) {
32299
32455
  const hit = this.channelRoster.get(channelId);
32300
- if (!hit) return void 0;
32301
- if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32302
- 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);
32303
32478
  }
32304
32479
  /**
32305
32480
  * Resolve one channel's roster (members + display names + is-agent) from the
@@ -32310,16 +32485,18 @@ var BridgeAgent = class _BridgeAgent {
32310
32485
  */
32311
32486
  async refreshChannelRoster(channelId) {
32312
32487
  try {
32488
+ const { epoch, groupId } = await this.heldPosition(channelId);
32313
32489
  const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
32314
32490
  channelId
32315
- )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
32491
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
32316
32492
  const res = await this.fetchBounded("channel_roster", "read", url, {
32317
32493
  headers: { authorization: `Bearer ${this.cfg.apiKey}` }
32318
32494
  });
32319
32495
  if (!res.ok) return;
32320
32496
  const rows = await res.json();
32321
32497
  const members = /* @__PURE__ */ new Map();
32322
- for (const r of rows) {
32498
+ const liveRows = rows.filter(isLiveRow);
32499
+ for (const r of liveRows) {
32323
32500
  members.set(r.identity_id, {
32324
32501
  name: r.display_name,
32325
32502
  is_agent: r.is_agent,
@@ -32328,13 +32505,45 @@ var BridgeAgent = class _BridgeAgent {
32328
32505
  is_relay: r.is_relay === true
32329
32506
  });
32330
32507
  }
32331
- this.channelRoster.set(channelId, { members, at: Date.now() });
32508
+ this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
32332
32509
  const self = members.get(this.cfg.identityId);
32333
32510
  if (self?.name) this.lastOwnDisplayName = self.name;
32334
- await this.resolveAndPinOwner(channelId, rows);
32511
+ await this.resolveAndPinOwner(channelId, liveRows);
32335
32512
  } catch {
32336
32513
  }
32337
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
+ }
32338
32547
  /**
32339
32548
  * Classify the sender only from server-authenticated channel/roster data.
32340
32549
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
@@ -32343,18 +32552,37 @@ var BridgeAgent = class _BridgeAgent {
32343
32552
  *
32344
32553
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32345
32554
  * the kill switch is on, the channel is a DM, and EITHER
32346
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32347
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32348
- * alone gets nothing; OR
32555
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
32556
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
32557
+ * `is_agent_owner` on a member who is NOT the DM's single human
32558
+ * counterpart gets nothing; on the counterpart it is trusted (see
32559
+ * LIMIT below); OR
32349
32560
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32350
32561
  * relayed `speaker_identity_id` (passed by the caller only for relay
32351
32562
  * turns) is that same pinned owner, whose roster row is the agent's
32352
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.
32353
32571
  * Everything else gets the frozen empty list.
32572
+ *
32573
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
32574
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
32575
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
32576
+ * separates Bob from the real owner is only the server's `is_agent_owner`
32577
+ * flag (connect_channels.py: counterpart user == agent's creator). A
32578
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
32579
+ * connector turns grants into its full toolset. Binding the owner identity
32580
+ * once, outside server control, is an open design follow-up.
32354
32581
  */
32355
- authorizationFor(channelId, senderId, relaySpeakerId) {
32582
+ authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
32356
32583
  const meta = this.freshMeta(channelId);
32357
- const roster = this.freshRoster(channelId);
32584
+ const entry = this.freshRosterEntry(channelId);
32585
+ const roster = entry?.members;
32358
32586
  const sender = roster?.get(senderId);
32359
32587
  let audience = "unknown";
32360
32588
  if (meta && sender) {
@@ -32365,7 +32593,10 @@ var BridgeAgent = class _BridgeAgent {
32365
32593
  else audience = "team_room";
32366
32594
  }
32367
32595
  let tool_grants = NO_TOOL_GRANTS;
32368
- 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) {
32369
32600
  const pinned = this.channelOwner.get(channelId);
32370
32601
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32371
32602
  if (ownerId !== void 0) {
@@ -32373,14 +32604,20 @@ var BridgeAgent = class _BridgeAgent {
32373
32604
  const owner = roster?.get(ownerId);
32374
32605
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32375
32606
  if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32607
+ if (ownerRelayed) relaySpeaker = "owner";
32376
32608
  }
32377
32609
  }
32378
- 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);
32379
32615
  return Object.freeze({
32380
32616
  version: 1,
32381
32617
  audience,
32382
32618
  sender_membership: sender ? "active" : "unknown",
32383
- tool_grants
32619
+ tool_grants,
32620
+ ...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
32384
32621
  });
32385
32622
  }
32386
32623
  /**
@@ -32399,8 +32636,15 @@ var BridgeAgent = class _BridgeAgent {
32399
32636
  * startup: `on` = default grants enabled
32400
32637
  * meta_fresh / roster_fresh whether the channel meta / roster caches were
32401
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
32402
32646
  */
32403
- logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
32647
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
32404
32648
  const meta = this.freshMeta(channelId);
32405
32649
  const roster = this.freshRoster(channelId);
32406
32650
  const sender = roster?.get(senderId);
@@ -32428,7 +32672,11 @@ var BridgeAgent = class _BridgeAgent {
32428
32672
  is_dm: meta?.is_dm === true,
32429
32673
  kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
32430
32674
  meta_fresh: meta !== void 0,
32431
- 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
32432
32680
  });
32433
32681
  }
32434
32682
  /**
@@ -33014,14 +33262,7 @@ var BridgeAgent = class _BridgeAgent {
33014
33262
  await this.refreshChannelMeta();
33015
33263
  metaRefreshed = true;
33016
33264
  }
33017
- const rosterRefreshed = /* @__PURE__ */ new Set();
33018
- for (const m of entries) {
33019
- if (rosterRefreshed.has(m.channelId)) continue;
33020
- rosterRefreshed.add(m.channelId);
33021
- if (this.freshRoster(m.channelId) === void 0) {
33022
- await this.refreshChannelRoster(m.channelId);
33023
- }
33024
- }
33265
+ await this.refreshRostersForCycle(entries.map((m) => m.channelId));
33025
33266
  const relayMissRefreshed = /* @__PURE__ */ new Set();
33026
33267
  const acked = [];
33027
33268
  for (const m of entries) {
@@ -33104,7 +33345,7 @@ var BridgeAgent = class _BridgeAgent {
33104
33345
  // ignores vault_refs.
33105
33346
  text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
33106
33347
  ts: m.receivedAt,
33107
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33348
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33108
33349
  sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
33109
33350
  sender_is_agent: senderIsAgent,
33110
33351
  vault_refs: [toVaultRefEntry(ref)],
@@ -33160,7 +33401,7 @@ var BridgeAgent = class _BridgeAgent {
33160
33401
  sender_id: m.envelope.sender_id,
33161
33402
  text: typeof parsed.text === "string" ? parsed.text : "",
33162
33403
  ts: m.receivedAt,
33163
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33404
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33164
33405
  sender_name: senderName,
33165
33406
  sender_is_agent: senderIsAgent,
33166
33407
  attachments: [attachment],
@@ -33220,8 +33461,9 @@ var BridgeAgent = class _BridgeAgent {
33220
33461
  const senderName = this.freshRoster(m.channelId)?.get(
33221
33462
  m.envelope.sender_id
33222
33463
  )?.name;
33223
- const turnAuthorization = this.authorizationFor(
33464
+ const turnAuthorization = await this.authorizeTurn(
33224
33465
  m.channelId,
33466
+ m.messageId,
33225
33467
  m.envelope.sender_id,
33226
33468
  speaker?.identityId
33227
33469
  );
@@ -33792,6 +34034,7 @@ var BridgeAgent = class _BridgeAgent {
33792
34034
  }
33793
34035
  /** Complete the durable handoff only after the runtime explicitly acks. */
33794
34036
  ackRuntimeMessages(messageIds) {
34037
+ for (const id of messageIds) this.roomGrantEpochs.delete(id);
33795
34038
  for (const messageId of messageIds) {
33796
34039
  this.store.ackRuntimeInbox(messageId);
33797
34040
  this.runtimePendingIds.delete(messageId);
@@ -33896,6 +34139,49 @@ var BridgeAgent = class _BridgeAgent {
33896
34139
  noteSessionEvicted() {
33897
34140
  this.evictions++;
33898
34141
  }
34142
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
34143
+ harness = null;
34144
+ /**
34145
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
34146
+ * each time the attached harness turns to an inactive veto: such a harness
34147
+ * refuses every turn without a tool grant, so its rooms go quiet.
34148
+ */
34149
+ recordHarnessReport(report) {
34150
+ const wasInactive = this.harness?.tool_veto === "inactive";
34151
+ this.harness = report;
34152
+ if (report.tool_veto === "inactive" && !wasInactive) {
34153
+ writeServiceLog("warn", "harness.tool_veto_inactive", {
34154
+ harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
34155
+ });
34156
+ }
34157
+ }
34158
+ /** Forget `report` when the connector that sent it disconnects. A report
34159
+ * from a newer connector (a different object) is kept. */
34160
+ clearHarnessReport(report) {
34161
+ if (this.harness === report) this.harness = null;
34162
+ }
34163
+ /** The latest harness report, or null (GET /v1/status). */
34164
+ harnessReport() {
34165
+ return this.harness;
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
+ }
33899
34185
  health() {
33900
34186
  let joined = 0;
33901
34187
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33965,6 +34251,7 @@ var BridgeAgent = class _BridgeAgent {
33965
34251
  delivery_pings: this.pingsReceived,
33966
34252
  agent_sessions: this.authSessions,
33967
34253
  evictions: this.evictions,
34254
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33968
34255
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33969
34256
  version: BRIDGE_VERSION,
33970
34257
  ...this.memory.stats?.() ?? {}
@@ -34140,6 +34427,16 @@ function wsErrorFrame(code, detail) {
34140
34427
  return { type: "error", ...errorBody(code, detail) };
34141
34428
  }
34142
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
+
34143
34440
  // src/http-server.ts
34144
34441
  function writeSse(res, msgs, onWriteError) {
34145
34442
  for (let i = 0; i < msgs.length; i++) {
@@ -34159,6 +34456,12 @@ data: ${JSON.stringify(msgs[i])}
34159
34456
  }
34160
34457
  return [];
34161
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
+ }
34162
34465
  function sseWriteFailureReporter() {
34163
34466
  let warned = false;
34164
34467
  return (error) => {
@@ -34389,6 +34692,9 @@ function createHttpServer(agent, opts) {
34389
34692
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34390
34693
  return json(res, 200, r);
34391
34694
  }
34695
+ if (method === "GET" && path === "/v1/status") {
34696
+ return json(res, 200, { harness: agent.harnessReport() });
34697
+ }
34392
34698
  if (method === "POST" && path === "/v1/status") {
34393
34699
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34394
34700
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34418,7 +34724,7 @@ function createHttpServer(agent, opts) {
34418
34724
  if (method === "GET" && path === "/v1/messages") {
34419
34725
  const channel = url.searchParams.get("channel_id") ?? void 0;
34420
34726
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
34421
- return json(res, 200, await agent.drain(channel, wait));
34727
+ return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
34422
34728
  }
34423
34729
  if (method === "GET" && path === "/v1/events") {
34424
34730
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -34430,13 +34736,7 @@ function createHttpServer(agent, opts) {
34430
34736
  });
34431
34737
  res.write(":connected\n\n");
34432
34738
  const onWriteError = sseWriteFailureReporter();
34433
- const drainToClient = () => {
34434
- const batch = agent.takeBuffered(channel);
34435
- const left3 = writeSse(res, batch, onWriteError);
34436
- const leftIds = new Set(left3.map((message) => message.message_id));
34437
- agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34438
- if (left3.length) agent.requeueFront(left3);
34439
- };
34739
+ const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
34440
34740
  const unsubscribe = agent.subscribe(drainToClient);
34441
34741
  const keepalive = setInterval(() => {
34442
34742
  try {
@@ -34478,6 +34778,17 @@ function createHttpServer(agent, opts) {
34478
34778
  return { server, closeAllSseStreams: () => closeSse() };
34479
34779
  }
34480
34780
 
34781
+ // src/harness-report.ts
34782
+ var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
34783
+ function parseHarnessReport(raw) {
34784
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
34785
+ const r = raw;
34786
+ const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
34787
+ const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
34788
+ const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
34789
+ return Object.freeze({ name, version, tool_veto });
34790
+ }
34791
+
34481
34792
  // src/ws-server.ts
34482
34793
  import { WebSocketServer, WebSocket } from "ws";
34483
34794
  var PING_INTERVAL_MS = 3e4;
@@ -34541,7 +34852,7 @@ function attachAgentWebSocket(server, agent, opts) {
34541
34852
  });
34542
34853
  const inflight = /* @__PURE__ */ new Map();
34543
34854
  const delivered = /* @__PURE__ */ new Map();
34544
- const session = { consumer: "unknown" };
34855
+ const session = { consumer: "unknown", perToolGrants: false };
34545
34856
  const sweepStale = () => {
34546
34857
  const at = now();
34547
34858
  for (const [id, d] of delivered) {
@@ -34579,7 +34890,7 @@ function attachAgentWebSocket(server, agent, opts) {
34579
34890
  retired = true;
34580
34891
  continue;
34581
34892
  }
34582
- const frame = { type: "message", ...batch[i] };
34893
+ const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
34583
34894
  try {
34584
34895
  sock.send(JSON.stringify(frame));
34585
34896
  inflight.set(batch[i].message_id, batch[i]);
@@ -34608,6 +34919,8 @@ function attachAgentWebSocket(server, agent, opts) {
34608
34919
  }
34609
34920
  delivered.clear();
34610
34921
  if (current?.sock === sock) current = null;
34922
+ if (session.harness) agent.clearHarnessReport(session.harness);
34923
+ if (session.perToolGrants) agent.clearPerToolConsumer(session);
34611
34924
  };
34612
34925
  current = { sock, cleanup, sweepStale };
34613
34926
  sock.on("close", cleanup);
@@ -34670,7 +34983,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34670
34983
  return;
34671
34984
  }
34672
34985
  if (m.type === "hello") {
34673
- session.consumer = "runtime";
34986
+ const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
34987
+ if (report) {
34988
+ session.harness = report;
34989
+ agent.recordHarnessReport(report);
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
+ }
34674
35000
  sendJson(sock, { type: "hello", features: ["nack"] });
34675
35001
  return;
34676
35002
  }