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