@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/index.js CHANGED
@@ -28638,7 +28638,7 @@ function assertManagedName(name) {
28638
28638
  }
28639
28639
  function isLocalOnlyName(name) {
28640
28640
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28641
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28641
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
28642
28642
  }
28643
28643
  function atomicWrite(path, body, deps) {
28644
28644
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28697,6 +28697,26 @@ function lineHash(line) {
28697
28697
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28698
28698
  }
28699
28699
  var LOCAL_REPORT_FILE = "local-config.json";
28700
+ var SECRET_PATH_COUNT_MAX = 1e4;
28701
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
28702
+ function parseSecretPathsReport(raw) {
28703
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
28704
+ const r = raw;
28705
+ const f = r.outside_by_field;
28706
+ 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;
28707
+ return {
28708
+ mode: r.mode,
28709
+ inside: r.inside,
28710
+ allowlisted: r.allowlisted,
28711
+ outside: r.outside,
28712
+ outside_by_field: {
28713
+ headers_file: f.headers_file,
28714
+ postgres_url_file: f.postgres_url_file,
28715
+ search_key_file: f.search_key_file,
28716
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
28717
+ }
28718
+ };
28719
+ }
28700
28720
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28701
28721
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28702
28722
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28765,7 +28785,8 @@ function readLocalReport(home, identityId) {
28765
28785
  persona,
28766
28786
  has_search_key: hasSearchKey,
28767
28787
  has_llm_key: hasLlmKey,
28768
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
28788
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
28789
+ secret_paths: parseSecretPathsReport(doc.secret_paths)
28769
28790
  };
28770
28791
  }
28771
28792
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -28939,6 +28960,29 @@ var StatusGate = class {
28939
28960
  }
28940
28961
  };
28941
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
+
28942
28986
  // src/bridge-agent.ts
28943
28987
  function logSendSupersededAdoption(kind) {
28944
28988
  switch (kind) {
@@ -28987,11 +29031,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
28987
29031
  Object.freeze({ tool: "current_time", mode: "read" }),
28988
29032
  Object.freeze({ tool: "web_search", mode: "read" })
28989
29033
  ]);
29034
+ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
29035
+ Object.freeze({ tool: "current_time", mode: "read" })
29036
+ ]);
28990
29037
  var NO_TOOL_GRANTS = Object.freeze([]);
28991
29038
  function ownerDmDefaultGrantsEnabled(env = process.env) {
28992
29039
  const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
28993
29040
  return v === void 0 || v === "on";
28994
29041
  }
29042
+ function roomDefaultGrantsEnabled(env = process.env) {
29043
+ const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
29044
+ return v === void 0 || v === "on";
29045
+ }
28995
29046
  var VAULT_REF_BARE_TEXT = "[Files link]";
28996
29047
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28997
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.";
@@ -29170,6 +29221,19 @@ var SendForbiddenError = class extends Error {
29170
29221
  this.name = "SendForbiddenError";
29171
29222
  }
29172
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;
29173
29237
  var GroupSupersededSendError = class extends Error {
29174
29238
  constructor(channelId, activeGroupId) {
29175
29239
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -29660,6 +29724,21 @@ var BridgeAgent = class _BridgeAgent {
29660
29724
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29661
29725
  /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29662
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();
29663
29742
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29664
29743
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29665
29744
  /**
@@ -29963,7 +30042,11 @@ var BridgeAgent = class _BridgeAgent {
29963
30042
  // Inert legacy files only. `readLocalReport` revalidates the current
29964
30043
  // regular-file bytes and strips every local path before this leaves the
29965
30044
  // machine.
29966
- legacy_memory: local?.legacy_memory ?? []
30045
+ legacy_memory: local?.legacy_memory ?? [],
30046
+ // §B.4: counts of server-named secret paths inside / allowlisted /
30047
+ // outside the secrets directory, and the runtime's mode. Numbers and
30048
+ // one enum only; readLocalReport rebuilds it so no path rides along.
30049
+ secret_paths: local?.secret_paths ?? null
29967
30050
  };
29968
30051
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
29969
30052
  method: "POST",
@@ -30967,7 +31050,7 @@ var BridgeAgent = class _BridgeAgent {
30967
31050
  * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
30968
31051
  * there so a retry can resend the exact bytes).
30969
31052
  */
30970
- async publishBody(channelId, body, id, onEnvelope) {
31053
+ async publishBody(channelId, body, id, onEnvelope, sealCheck) {
30971
31054
  this.enforceBodyRateFloor(channelId, body);
30972
31055
  this.statusGate.replyStarted(channelId);
30973
31056
  const joined = await this.reconcileJoin(channelId);
@@ -30976,33 +31059,36 @@ var BridgeAgent = class _BridgeAgent {
30976
31059
  await this.sendWithSelfHealTerminal(
30977
31060
  this.provider,
30978
31061
  channelId,
30979
- (epoch) => this.latency.time(
30980
- "send.encrypt",
30981
- () => buildOutboundEnvelope({
30982
- custody: this.custody,
30983
- groupKey: this.provider,
30984
- messageId: id,
30985
- channelId,
30986
- enclaveId: this.resolveEnclave(channelId),
30987
- senderId: this.cfg.identityId,
30988
- senderType: "agent",
30989
- type: body.kind === "text" ? "text" : "a2a",
30990
- bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
30991
- type: "a2a",
30992
- a2a_version: body.a2a.a2a_version,
30993
- kind: body.a2a.kind,
30994
- object: body.a2a.object
30995
- }),
30996
- bindingId: "",
30997
- bindingVersion: "0.0.0",
30998
- agentSkillId: null,
30999
- epoch,
31000
- traceparent: ZERO_TRACEPARENT,
31001
- references: [],
31002
- stage5Outcome: "allow",
31003
- stage5Evaluators: []
31004
- })
31005
- ),
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
+ },
31006
31092
  async (envelope) => {
31007
31093
  if (onEnvelope) await onEnvelope(envelope);
31008
31094
  return this.publishEnvelope(
@@ -31013,6 +31099,71 @@ var BridgeAgent = class _BridgeAgent {
31013
31099
  );
31014
31100
  this.latency.record("send.total", performance.now() - sendStart);
31015
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
+ }
31016
31167
  enforceBodyRateFloor(channelId, body) {
31017
31168
  if (body.kind === "a2a") {
31018
31169
  this.enforceRateFloor("a2a", channelId);
@@ -31138,7 +31289,7 @@ var BridgeAgent = class _BridgeAgent {
31138
31289
  const bytes = intent.body;
31139
31290
  const body = decodeReplyBody(bytes);
31140
31291
  try {
31141
- await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
31292
+ await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
31142
31293
  } catch (e) {
31143
31294
  if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
31144
31295
  if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
@@ -31193,12 +31344,12 @@ var BridgeAgent = class _BridgeAgent {
31193
31344
  * failure. Either way the intent stays pending, so the redelivery
31194
31345
  * republishes the same reply.
31195
31346
  */
31196
- async publishIntentWithRetry(channelId, body, id, key) {
31347
+ async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
31197
31348
  let rateLimited = 0;
31198
31349
  let inFlight = 0;
31199
31350
  for (; ; ) {
31200
31351
  try {
31201
- await this.publishIntentOnce(channelId, body, id, key);
31352
+ await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
31202
31353
  return;
31203
31354
  } catch (e) {
31204
31355
  if (e instanceof ReplyInFlightError) {
@@ -31269,7 +31420,7 @@ var BridgeAgent = class _BridgeAgent {
31269
31420
  * original ack; a conflict can only mean the slot belongs to another
31270
31421
  * message (for example an old reply whose intent was pruned).
31271
31422
  */
31272
- async publishIntentOnce(channelId, body, id, key) {
31423
+ async publishIntentOnce(channelId, body, id, key, inReplyTo) {
31273
31424
  const current = await this.store.getReplyIntent(key.scope, key.part);
31274
31425
  try {
31275
31426
  if (current?.envelope) {
@@ -31310,10 +31461,11 @@ var BridgeAgent = class _BridgeAgent {
31310
31461
  }
31311
31462
  }
31312
31463
  try {
31313
- await this.publishBody(
31464
+ await this.publishBodyForReply(
31314
31465
  channelId,
31315
31466
  body,
31316
31467
  id,
31468
+ inReplyTo,
31317
31469
  (envelope) => this.store.saveReplyIntentEnvelope(
31318
31470
  key.scope,
31319
31471
  key.part,
@@ -32144,10 +32296,33 @@ var BridgeAgent = class _BridgeAgent {
32144
32296
  }
32145
32297
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
32146
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) {
32147
32303
  const hit = this.channelRoster.get(channelId);
32148
- if (!hit) return void 0;
32149
- if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
32150
- 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);
32151
32326
  }
32152
32327
  /**
32153
32328
  * Resolve one channel's roster (members + display names + is-agent) from the
@@ -32158,16 +32333,18 @@ var BridgeAgent = class _BridgeAgent {
32158
32333
  */
32159
32334
  async refreshChannelRoster(channelId) {
32160
32335
  try {
32336
+ const { epoch, groupId } = await this.heldPosition(channelId);
32161
32337
  const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
32162
32338
  channelId
32163
- )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
32339
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
32164
32340
  const res = await this.fetchBounded("channel_roster", "read", url, {
32165
32341
  headers: { authorization: `Bearer ${this.cfg.apiKey}` }
32166
32342
  });
32167
32343
  if (!res.ok) return;
32168
32344
  const rows = await res.json();
32169
32345
  const members = /* @__PURE__ */ new Map();
32170
- for (const r of rows) {
32346
+ const liveRows = rows.filter(isLiveRow);
32347
+ for (const r of liveRows) {
32171
32348
  members.set(r.identity_id, {
32172
32349
  name: r.display_name,
32173
32350
  is_agent: r.is_agent,
@@ -32176,13 +32353,45 @@ var BridgeAgent = class _BridgeAgent {
32176
32353
  is_relay: r.is_relay === true
32177
32354
  });
32178
32355
  }
32179
- this.channelRoster.set(channelId, { members, at: Date.now() });
32356
+ this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
32180
32357
  const self = members.get(this.cfg.identityId);
32181
32358
  if (self?.name) this.lastOwnDisplayName = self.name;
32182
- await this.resolveAndPinOwner(channelId, rows);
32359
+ await this.resolveAndPinOwner(channelId, liveRows);
32183
32360
  } catch {
32184
32361
  }
32185
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
+ }
32186
32395
  /**
32187
32396
  * Classify the sender only from server-authenticated channel/roster data.
32188
32397
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
@@ -32191,18 +32400,37 @@ var BridgeAgent = class _BridgeAgent {
32191
32400
  *
32192
32401
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32193
32402
  * the kill switch is on, the channel is a DM, and EITHER
32194
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32195
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32196
- * alone gets nothing; OR
32403
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
32404
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
32405
+ * `is_agent_owner` on a member who is NOT the DM's single human
32406
+ * counterpart gets nothing; on the counterpart it is trusted (see
32407
+ * LIMIT below); OR
32197
32408
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32198
32409
  * relayed `speaker_identity_id` (passed by the caller only for relay
32199
32410
  * turns) is that same pinned owner, whose roster row is the agent's
32200
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.
32201
32419
  * Everything else gets the frozen empty list.
32420
+ *
32421
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
32422
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
32423
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
32424
+ * separates Bob from the real owner is only the server's `is_agent_owner`
32425
+ * flag (connect_channels.py: counterpart user == agent's creator). A
32426
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
32427
+ * connector turns grants into its full toolset. Binding the owner identity
32428
+ * once, outside server control, is an open design follow-up.
32202
32429
  */
32203
- authorizationFor(channelId, senderId, relaySpeakerId) {
32430
+ authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
32204
32431
  const meta = this.freshMeta(channelId);
32205
- const roster = this.freshRoster(channelId);
32432
+ const entry = this.freshRosterEntry(channelId);
32433
+ const roster = entry?.members;
32206
32434
  const sender = roster?.get(senderId);
32207
32435
  let audience = "unknown";
32208
32436
  if (meta && sender) {
@@ -32213,7 +32441,10 @@ var BridgeAgent = class _BridgeAgent {
32213
32441
  else audience = "team_room";
32214
32442
  }
32215
32443
  let tool_grants = NO_TOOL_GRANTS;
32216
- 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) {
32217
32448
  const pinned = this.channelOwner.get(channelId);
32218
32449
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32219
32450
  if (ownerId !== void 0) {
@@ -32221,14 +32452,20 @@ var BridgeAgent = class _BridgeAgent {
32221
32452
  const owner = roster?.get(ownerId);
32222
32453
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32223
32454
  if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32455
+ if (ownerRelayed) relaySpeaker = "owner";
32224
32456
  }
32225
32457
  }
32226
- 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);
32227
32463
  return Object.freeze({
32228
32464
  version: 1,
32229
32465
  audience,
32230
32466
  sender_membership: sender ? "active" : "unknown",
32231
- tool_grants
32467
+ tool_grants,
32468
+ ...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
32232
32469
  });
32233
32470
  }
32234
32471
  /**
@@ -32247,8 +32484,15 @@ var BridgeAgent = class _BridgeAgent {
32247
32484
  * startup: `on` = default grants enabled
32248
32485
  * meta_fresh / roster_fresh whether the channel meta / roster caches were
32249
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
32250
32494
  */
32251
- logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
32495
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
32252
32496
  const meta = this.freshMeta(channelId);
32253
32497
  const roster = this.freshRoster(channelId);
32254
32498
  const sender = roster?.get(senderId);
@@ -32276,7 +32520,11 @@ var BridgeAgent = class _BridgeAgent {
32276
32520
  is_dm: meta?.is_dm === true,
32277
32521
  kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
32278
32522
  meta_fresh: meta !== void 0,
32279
- 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
32280
32528
  });
32281
32529
  }
32282
32530
  /**
@@ -32862,14 +33110,7 @@ var BridgeAgent = class _BridgeAgent {
32862
33110
  await this.refreshChannelMeta();
32863
33111
  metaRefreshed = true;
32864
33112
  }
32865
- const rosterRefreshed = /* @__PURE__ */ new Set();
32866
- for (const m of entries) {
32867
- if (rosterRefreshed.has(m.channelId)) continue;
32868
- rosterRefreshed.add(m.channelId);
32869
- if (this.freshRoster(m.channelId) === void 0) {
32870
- await this.refreshChannelRoster(m.channelId);
32871
- }
32872
- }
33113
+ await this.refreshRostersForCycle(entries.map((m) => m.channelId));
32873
33114
  const relayMissRefreshed = /* @__PURE__ */ new Set();
32874
33115
  const acked = [];
32875
33116
  for (const m of entries) {
@@ -32952,7 +33193,7 @@ var BridgeAgent = class _BridgeAgent {
32952
33193
  // ignores vault_refs.
32953
33194
  text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32954
33195
  ts: m.receivedAt,
32955
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33196
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
32956
33197
  sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32957
33198
  sender_is_agent: senderIsAgent,
32958
33199
  vault_refs: [toVaultRefEntry(ref)],
@@ -33008,7 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
33008
33249
  sender_id: m.envelope.sender_id,
33009
33250
  text: typeof parsed.text === "string" ? parsed.text : "",
33010
33251
  ts: m.receivedAt,
33011
- authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
33252
+ authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
33012
33253
  sender_name: senderName,
33013
33254
  sender_is_agent: senderIsAgent,
33014
33255
  attachments: [attachment],
@@ -33068,8 +33309,9 @@ var BridgeAgent = class _BridgeAgent {
33068
33309
  const senderName = this.freshRoster(m.channelId)?.get(
33069
33310
  m.envelope.sender_id
33070
33311
  )?.name;
33071
- const turnAuthorization = this.authorizationFor(
33312
+ const turnAuthorization = await this.authorizeTurn(
33072
33313
  m.channelId,
33314
+ m.messageId,
33073
33315
  m.envelope.sender_id,
33074
33316
  speaker?.identityId
33075
33317
  );
@@ -33640,6 +33882,7 @@ var BridgeAgent = class _BridgeAgent {
33640
33882
  }
33641
33883
  /** Complete the durable handoff only after the runtime explicitly acks. */
33642
33884
  ackRuntimeMessages(messageIds) {
33885
+ for (const id of messageIds) this.roomGrantEpochs.delete(id);
33643
33886
  for (const messageId of messageIds) {
33644
33887
  this.store.ackRuntimeInbox(messageId);
33645
33888
  this.runtimePendingIds.delete(messageId);
@@ -33744,6 +33987,49 @@ var BridgeAgent = class _BridgeAgent {
33744
33987
  noteSessionEvicted() {
33745
33988
  this.evictions++;
33746
33989
  }
33990
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
33991
+ harness = null;
33992
+ /**
33993
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
33994
+ * each time the attached harness turns to an inactive veto: such a harness
33995
+ * refuses every turn without a tool grant, so its rooms go quiet.
33996
+ */
33997
+ recordHarnessReport(report) {
33998
+ const wasInactive = this.harness?.tool_veto === "inactive";
33999
+ this.harness = report;
34000
+ if (report.tool_veto === "inactive" && !wasInactive) {
34001
+ writeServiceLog2("warn", "harness.tool_veto_inactive", {
34002
+ harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
34003
+ });
34004
+ }
34005
+ }
34006
+ /** Forget `report` when the connector that sent it disconnects. A report
34007
+ * from a newer connector (a different object) is kept. */
34008
+ clearHarnessReport(report) {
34009
+ if (this.harness === report) this.harness = null;
34010
+ }
34011
+ /** The latest harness report, or null (GET /v1/status). */
34012
+ harnessReport() {
34013
+ return this.harness;
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
+ }
33747
34033
  health() {
33748
34034
  let joined = 0;
33749
34035
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33813,6 +34099,7 @@ var BridgeAgent = class _BridgeAgent {
33813
34099
  delivery_pings: this.pingsReceived,
33814
34100
  agent_sessions: this.authSessions,
33815
34101
  evictions: this.evictions,
34102
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33816
34103
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33817
34104
  version: BRIDGE_VERSION,
33818
34105
  ...this.memory.stats?.() ?? {}
@@ -34096,6 +34383,16 @@ function wsErrorFrame(code, detail) {
34096
34383
  return { type: "error", ...errorBody(code, detail) };
34097
34384
  }
34098
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
+
34099
34396
  // src/http-server.ts
34100
34397
  function writeSse(res, msgs, onWriteError) {
34101
34398
  for (let i = 0; i < msgs.length; i++) {
@@ -34115,6 +34412,12 @@ data: ${JSON.stringify(msgs[i])}
34115
34412
  }
34116
34413
  return [];
34117
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
+ }
34118
34421
  function sseWriteFailureReporter() {
34119
34422
  let warned = false;
34120
34423
  return (error) => {
@@ -34345,6 +34648,9 @@ function createHttpServer(agent, opts) {
34345
34648
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34346
34649
  return json(res, 200, r);
34347
34650
  }
34651
+ if (method === "GET" && path === "/v1/status") {
34652
+ return json(res, 200, { harness: agent.harnessReport() });
34653
+ }
34348
34654
  if (method === "POST" && path === "/v1/status") {
34349
34655
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34350
34656
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34374,7 +34680,7 @@ function createHttpServer(agent, opts) {
34374
34680
  if (method === "GET" && path === "/v1/messages") {
34375
34681
  const channel = url.searchParams.get("channel_id") ?? void 0;
34376
34682
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
34377
- return json(res, 200, await agent.drain(channel, wait));
34683
+ return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
34378
34684
  }
34379
34685
  if (method === "GET" && path === "/v1/events") {
34380
34686
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -34386,13 +34692,7 @@ function createHttpServer(agent, opts) {
34386
34692
  });
34387
34693
  res.write(":connected\n\n");
34388
34694
  const onWriteError = sseWriteFailureReporter();
34389
- const drainToClient = () => {
34390
- const batch = agent.takeBuffered(channel);
34391
- const left3 = writeSse(res, batch, onWriteError);
34392
- const leftIds = new Set(left3.map((message) => message.message_id));
34393
- agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
34394
- if (left3.length) agent.requeueFront(left3);
34395
- };
34695
+ const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
34396
34696
  const unsubscribe = agent.subscribe(drainToClient);
34397
34697
  const keepalive = setInterval(() => {
34398
34698
  try {
@@ -34434,6 +34734,17 @@ function createHttpServer(agent, opts) {
34434
34734
  return { server, closeAllSseStreams: () => closeSse() };
34435
34735
  }
34436
34736
 
34737
+ // src/harness-report.ts
34738
+ var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
34739
+ function parseHarnessReport(raw) {
34740
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
34741
+ const r = raw;
34742
+ const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
34743
+ const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
34744
+ const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
34745
+ return Object.freeze({ name, version, tool_veto });
34746
+ }
34747
+
34437
34748
  // src/ws-server.ts
34438
34749
  import { WebSocketServer, WebSocket } from "ws";
34439
34750
  var PING_INTERVAL_MS = 3e4;
@@ -34497,7 +34808,7 @@ function attachAgentWebSocket(server, agent, opts) {
34497
34808
  });
34498
34809
  const inflight = /* @__PURE__ */ new Map();
34499
34810
  const delivered = /* @__PURE__ */ new Map();
34500
- const session = { consumer: "unknown" };
34811
+ const session = { consumer: "unknown", perToolGrants: false };
34501
34812
  const sweepStale = () => {
34502
34813
  const at = now();
34503
34814
  for (const [id, d] of delivered) {
@@ -34535,7 +34846,7 @@ function attachAgentWebSocket(server, agent, opts) {
34535
34846
  retired = true;
34536
34847
  continue;
34537
34848
  }
34538
- const frame = { type: "message", ...batch[i] };
34849
+ const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
34539
34850
  try {
34540
34851
  sock.send(JSON.stringify(frame));
34541
34852
  inflight.set(batch[i].message_id, batch[i]);
@@ -34564,6 +34875,8 @@ function attachAgentWebSocket(server, agent, opts) {
34564
34875
  }
34565
34876
  delivered.clear();
34566
34877
  if (current?.sock === sock) current = null;
34878
+ if (session.harness) agent.clearHarnessReport(session.harness);
34879
+ if (session.perToolGrants) agent.clearPerToolConsumer(session);
34567
34880
  };
34568
34881
  current = { sock, cleanup, sweepStale };
34569
34882
  sock.on("close", cleanup);
@@ -34626,7 +34939,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34626
34939
  return;
34627
34940
  }
34628
34941
  if (m.type === "hello") {
34629
- session.consumer = "runtime";
34942
+ const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
34943
+ if (report) {
34944
+ session.harness = report;
34945
+ agent.recordHarnessReport(report);
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
+ }
34630
34956
  sendJson(sock, { type: "hello", features: ["nack"] });
34631
34957
  return;
34632
34958
  }