@vellumai/assistant 0.10.5-staging.1 → 0.10.5-staging.2

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.
@@ -205,13 +205,13 @@ describe("enforceIngressAcl — verdict-sourced member resolution", () => {
205
205
  expect(findContactChannelCalls.length).toBe(0);
206
206
  });
207
207
 
208
- test("member-less guardian verdict is admitted and never fires an access request", async () => {
209
- // A guardian classified by principal reaches this channel with NO
210
- // per-channel member row: trustClass is "guardian" but the verdict
211
- // carries no contactId/channelId/status/policy. The guardian
212
- // short-circuit must admit them instead of routing them into the
213
- // stranger branch, which fires notifyGuardianOfAccessRequest at the
214
- // guardian themselves.
208
+ test("member-less guardian verdict fails safe: denied with no stranger-lane side effects", async () => {
209
+ // ATL-958 regression: the gateway proves guardian identity via a
210
+ // same-channel member row, so every guardian verdict carries one. A
211
+ // guardian verdict with NO member row is contradictory (e.g. a forged
212
+ // cross-channel classification) and must not be admitted but it must
213
+ // not be routed through the stranger lane either, which would fire an
214
+ // access request carrying the claimed guardian identity.
215
215
  const result = await enforceIngressAcl(
216
216
  makeParams({
217
217
  sourceMetadata: withVerdict({
@@ -222,10 +222,15 @@ describe("enforceIngressAcl — verdict-sourced member resolution", () => {
222
222
  }),
223
223
  );
224
224
 
225
- expect(result.earlyResponse).toBeUndefined();
225
+ expect(result.earlyResponse).toMatchObject({
226
+ accepted: true,
227
+ denied: true,
228
+ reason: "not_a_member",
229
+ });
226
230
  expect(result.resolvedMember).toBeNull();
227
231
  expect(accessRequestCalls.length).toBe(0);
228
232
  expect(deliverReplyCalls.length).toBe(0);
233
+ expect(createOutboundSessionCalls.length).toBe(0);
229
234
  });
230
235
 
231
236
  test("guardian verdict with an inactive (pending) member row is still admitted", async () => {
@@ -257,20 +257,35 @@ export async function enforceIngressAcl(
257
257
  }
258
258
 
259
259
  // ── Guardian short-circuit ──
260
- // A verdict classified `guardian` is admitted even when it carries no
261
- // per-channel member row (`resolvedMember` null) or an inactive one. The
262
- // gateway classifies guardians by principal, so a guardian speaking on a
263
- // channel where they hold no same-channel binding must not fall through the
264
- // member-vs-stranger gates below — those would misroute the guardian into
265
- // the stranger lane and fire an access request at the guardian themselves.
260
+ // A verdict classified `guardian` is admitted even when its same-channel
261
+ // member row is inactive (e.g. pending): the gateway classifies guardians
262
+ // by principal, so a guardian speaking on a channel where they hold no
263
+ // active guardian binding must not fall through the member-vs-stranger
264
+ // gates below — those would misroute the guardian into the stranger lane
265
+ // and fire an access request at the guardian themselves.
266
266
  if (verdict.trustClass === "guardian") {
267
+ // The gateway proves guardian identity via a same-channel member row
268
+ // (the active binding address, or a row belonging to the guardian
269
+ // contact), so every guardian verdict carries a resolvable member row.
270
+ // A guardian verdict WITHOUT one is contradictory — cross-channel
271
+ // address collisions are not identity proofs and must never confer
272
+ // guardian capabilities. Fail safe: soft-deny with no stranger-lane
273
+ // side effects.
274
+ if (!resolvedMember) {
275
+ log.warn(
276
+ { sourceChannel, externalUserId: canonicalSenderId },
277
+ "Ingress ACL: guardian verdict without a member row, denying fail-safe",
278
+ );
279
+ return failClosedDeny();
280
+ }
281
+
267
282
  // The gateway never classifies a blocked/revoked same-channel row as
268
283
  // guardian (explicit per-channel governance wins over the principal
269
284
  // check), so a verdict claiming both is contradictory. Fail safe:
270
285
  // soft-deny with no stranger-lane side effects.
271
286
  if (
272
- resolvedMember?.status === "blocked" ||
273
- resolvedMember?.status === "revoked"
287
+ resolvedMember.status === "blocked" ||
288
+ resolvedMember.status === "revoked"
274
289
  ) {
275
290
  log.warn(
276
291
  {
@@ -288,7 +303,7 @@ export async function enforceIngressAcl(
288
303
  // classification. Deny with the accurate policy_deny reason but none of
289
304
  // the stranger-lane side effects — the canned "ask the guardian" reply
290
305
  // would be addressed at the guardian themselves.
291
- if (resolvedMember?.policy === "deny") {
306
+ if (resolvedMember.policy === "deny") {
292
307
  log.info(
293
308
  { sourceChannel, externalUserId: canonicalSenderId },
294
309
  "Ingress ACL: guardian member row carries policy deny, denying",
@@ -140,3 +140,22 @@ describe("serializeConversationSummary · surfaced groupId normalization", () =>
140
140
  );
141
141
  });
142
142
  });
143
+
144
+ describe("serializeConversationSummary · enabledPlugins", () => {
145
+ test("serializes a non-empty plugin scope", () => {
146
+ const summary = serialize(
147
+ makeConversationRow({ enabledPlugins: ["plugin-a", "plugin-b"] }),
148
+ );
149
+ expect(summary.enabledPlugins).toEqual(["plugin-a", "plugin-b"]);
150
+ });
151
+
152
+ test("preserves an explicit empty scope (user cleared all optional plugins)", () => {
153
+ const summary = serialize(makeConversationRow({ enabledPlugins: [] }));
154
+ expect(summary.enabledPlugins).toEqual([]);
155
+ });
156
+
157
+ test("omits the field when there is no per-chat restriction (null)", () => {
158
+ const summary = serialize(makeConversationRow({ enabledPlugins: null }));
159
+ expect("enabledPlugins" in summary).toBe(false);
160
+ });
161
+ });
@@ -215,6 +215,11 @@ export function serializeConversationSummary(params: {
215
215
  ...(conversation.inferenceProfile != null
216
216
  ? { inferenceProfile: conversation.inferenceProfile }
217
217
  : {}),
218
+ // Include when non-null so an explicit `[]` (user cleared all plugins) is
219
+ // preserved; `null`/default is omitted.
220
+ ...(conversation.enabledPlugins != null
221
+ ? { enabledPlugins: conversation.enabledPlugins }
222
+ : {}),
218
223
  isProcessing,
219
224
  };
220
225
  }
@@ -175,6 +175,16 @@ export function publishConversationTitleChanged(
175
175
  );
176
176
  }
177
177
 
178
+ export function publishConversationEnabledPluginsChanged(
179
+ conversationId: string,
180
+ originClientId?: string,
181
+ ): void {
182
+ void publishSyncInvalidation(
183
+ [SYNC_TAGS.conversationsList, conversationMetadataSyncTag(conversationId)],
184
+ originClientId,
185
+ );
186
+ }
187
+
178
188
  export function publishConversationInferenceProfileChanged(
179
189
  params: {
180
190
  conversationId: string;