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