@vellumai/vellum-gateway 0.12.0-staging.1 → 0.12.0

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.
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ botProviderForChannel,
4
5
  CHANNEL_BOT_PROVIDER,
5
6
  CHANNEL_IDS,
7
+ channelForBotProvider,
6
8
  isChannelBotProvider,
7
9
  isChannelUserIntegration,
8
10
  isChannelId,
@@ -41,6 +43,35 @@ describe("isChannelId", () => {
41
43
  });
42
44
  });
43
45
 
46
+ describe("botProviderForChannel and channelForBotProvider", () => {
47
+ test("are inverses over every entry of the map", () => {
48
+ for (const [channelId, botProviderKey] of Object.entries(
49
+ CHANNEL_BOT_PROVIDER,
50
+ )) {
51
+ expect(botProviderForChannel(channelId)).toBe(botProviderKey);
52
+ expect(channelForBotProvider(botProviderKey)).toBe(
53
+ channelId as keyof typeof CHANNEL_BOT_PROVIDER,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
+ expect(botProviderForChannel("phone")).toBeUndefined();
60
+ expect(botProviderForChannel("vellum")).toBeUndefined();
61
+ expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
+ // `slack` is the person's integration standing beside the bot's key.
63
+ expect(channelForBotProvider("slack")).toBeUndefined();
64
+ expect(channelForBotProvider("google")).toBeUndefined();
65
+ });
66
+
67
+ test("never resolve an inherited object property as a channel or a key", () => {
68
+ for (const name of ["constructor", "toString", "__proto__"]) {
69
+ expect(botProviderForChannel(name)).toBeUndefined();
70
+ expect(channelForBotProvider(name)).toBeUndefined();
71
+ }
72
+ });
73
+ });
74
+
44
75
  describe("isChannelUserIntegration", () => {
45
76
  test("names the grant standing beside a bot of the same brand", () => {
46
77
  expect(isChannelUserIntegration("slack")).toBe(true);
@@ -93,9 +93,34 @@ export const CHANNEL_BOT_PROVIDER = {
93
93
  * connected" question, for any provider key.
94
94
  */
95
95
  export function isChannelBotProvider(providerKey: string): boolean {
96
- return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
97
- providerKey,
98
- );
96
+ return channelForBotProvider(providerKey) !== undefined;
97
+ }
98
+
99
+ /**
100
+ * The provider key holding a channel's bot credential, or `undefined` when
101
+ * the channel has none, or is not a channel at all. Walks the map's own
102
+ * entries, so an inherited object property name is never a channel.
103
+ */
104
+ export function botProviderForChannel(channelId: string): string | undefined {
105
+ return Object.entries(CHANNEL_BOT_PROVIDER).find(
106
+ ([id]) => id === channelId,
107
+ )?.[1];
108
+ }
109
+
110
+ /**
111
+ * The channel whose bot credential a provider key holds, or `undefined` when
112
+ * the key is not a bot's. The inverse of `botProviderForChannel`, for a
113
+ * surface that has only the provider key in hand and needs to speak in the
114
+ * channel's vocabulary.
115
+ */
116
+ export function channelForBotProvider(
117
+ providerKey: string,
118
+ ): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
119
+ return (
120
+ Object.entries(CHANNEL_BOT_PROVIDER) as Array<
121
+ [keyof typeof CHANNEL_BOT_PROVIDER, string]
122
+ >
123
+ ).find(([, key]) => key === providerKey)?.[0];
99
124
  }
100
125
 
101
126
  /**
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ botProviderForChannel,
4
5
  CHANNEL_BOT_PROVIDER,
5
6
  CHANNEL_IDS,
7
+ channelForBotProvider,
6
8
  isChannelBotProvider,
7
9
  isChannelUserIntegration,
8
10
  isChannelId,
@@ -41,6 +43,35 @@ describe("isChannelId", () => {
41
43
  });
42
44
  });
43
45
 
46
+ describe("botProviderForChannel and channelForBotProvider", () => {
47
+ test("are inverses over every entry of the map", () => {
48
+ for (const [channelId, botProviderKey] of Object.entries(
49
+ CHANNEL_BOT_PROVIDER,
50
+ )) {
51
+ expect(botProviderForChannel(channelId)).toBe(botProviderKey);
52
+ expect(channelForBotProvider(botProviderKey)).toBe(
53
+ channelId as keyof typeof CHANNEL_BOT_PROVIDER,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
+ expect(botProviderForChannel("phone")).toBeUndefined();
60
+ expect(botProviderForChannel("vellum")).toBeUndefined();
61
+ expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
+ // `slack` is the person's integration standing beside the bot's key.
63
+ expect(channelForBotProvider("slack")).toBeUndefined();
64
+ expect(channelForBotProvider("google")).toBeUndefined();
65
+ });
66
+
67
+ test("never resolve an inherited object property as a channel or a key", () => {
68
+ for (const name of ["constructor", "toString", "__proto__"]) {
69
+ expect(botProviderForChannel(name)).toBeUndefined();
70
+ expect(channelForBotProvider(name)).toBeUndefined();
71
+ }
72
+ });
73
+ });
74
+
44
75
  describe("isChannelUserIntegration", () => {
45
76
  test("names the grant standing beside a bot of the same brand", () => {
46
77
  expect(isChannelUserIntegration("slack")).toBe(true);
@@ -93,9 +93,34 @@ export const CHANNEL_BOT_PROVIDER = {
93
93
  * connected" question, for any provider key.
94
94
  */
95
95
  export function isChannelBotProvider(providerKey: string): boolean {
96
- return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
97
- providerKey,
98
- );
96
+ return channelForBotProvider(providerKey) !== undefined;
97
+ }
98
+
99
+ /**
100
+ * The provider key holding a channel's bot credential, or `undefined` when
101
+ * the channel has none, or is not a channel at all. Walks the map's own
102
+ * entries, so an inherited object property name is never a channel.
103
+ */
104
+ export function botProviderForChannel(channelId: string): string | undefined {
105
+ return Object.entries(CHANNEL_BOT_PROVIDER).find(
106
+ ([id]) => id === channelId,
107
+ )?.[1];
108
+ }
109
+
110
+ /**
111
+ * The channel whose bot credential a provider key holds, or `undefined` when
112
+ * the key is not a bot's. The inverse of `botProviderForChannel`, for a
113
+ * surface that has only the provider key in hand and needs to speak in the
114
+ * channel's vocabulary.
115
+ */
116
+ export function channelForBotProvider(
117
+ providerKey: string,
118
+ ): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
119
+ return (
120
+ Object.entries(CHANNEL_BOT_PROVIDER) as Array<
121
+ [keyof typeof CHANNEL_BOT_PROVIDER, string]
122
+ >
123
+ ).find(([, key]) => key === providerKey)?.[0];
99
124
  }
100
125
 
101
126
  /**
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import {
4
4
  ACCESS_DENIED_NOT_APPROVED_REPLY,
5
5
  PLUGIN_ADMISSION_DENIED_NOTICE_PATH,
6
+ PLUGIN_NOTICES_ROUTE_PREFIX,
6
7
  PluginAdmissionDeniedNoticeSchema,
7
8
  } from "../plugin-admission-denied-contract.js";
8
9
 
@@ -19,6 +20,15 @@ describe("plugin admission-denied contract", () => {
19
20
  );
20
21
  });
21
22
 
23
+ test("the notice lives under the gateway-only notices prefix", () => {
24
+ // The runtime reserves this prefix for the gateway's service principal,
25
+ // so a notice posted anywhere else would be reachable by actor clients.
26
+ expect(PLUGIN_NOTICES_ROUTE_PREFIX).toBe("notices");
27
+ expect(PLUGIN_ADMISSION_DENIED_NOTICE_PATH).toStartWith(
28
+ `${PLUGIN_NOTICES_ROUTE_PREFIX}/`,
29
+ );
30
+ });
31
+
22
32
  test("round-trips a floor-deny notice", () => {
23
33
  const notice = {
24
34
  reason: "admission_floor" as const,
@@ -104,6 +104,7 @@ export type {
104
104
  export {
105
105
  ACCESS_DENIED_NOT_APPROVED_REPLY,
106
106
  PLUGIN_ADMISSION_DENIED_NOTICE_PATH,
107
+ PLUGIN_NOTICES_ROUTE_PREFIX,
107
108
  PluginAdmissionDeniedNoticeSchema,
108
109
  } from "./plugin-admission-denied-contract.js";
109
110
 
@@ -22,10 +22,23 @@ export const ACCESS_DENIED_NOT_APPROVED_REPLY =
22
22
  "Sorry, you haven't been approved to message this assistant.";
23
23
 
24
24
  /**
25
- * Plugin route the gateway posts the notice to, under
25
+ * Prefix, under `/x/plugins/<plugin>/`, of the routes reserved for notices
26
+ * the gateway posts to a plugin.
27
+ *
28
+ * The runtime serves every path under it to the gateway's service principal
29
+ * only (`assistant/src/runtime/routes/user-routes.ts`). A notice is the
30
+ * gateway's own verified decision, and a plugin acts on it without running a
31
+ * turn, so an authenticated client must not be able to post one: a forged
32
+ * notice would have the plugin spend its vendor credentials on a recipient
33
+ * and a message the client chose.
34
+ */
35
+ export const PLUGIN_NOTICES_ROUTE_PREFIX = "notices";
36
+
37
+ /**
38
+ * Plugin route the gateway posts the admission-denied notice to, under
26
39
  * `/v1/x/plugins/<plugin>/`. Not a public ingress path.
27
40
  */
28
- export const PLUGIN_ADMISSION_DENIED_NOTICE_PATH = "notices/admission-denied";
41
+ export const PLUGIN_ADMISSION_DENIED_NOTICE_PATH = `${PLUGIN_NOTICES_ROUTE_PREFIX}/admission-denied`;
29
42
 
30
43
  export const PluginAdmissionDeniedNoticeSchema = z.object({
31
44
  reason: z.literal("admission_floor"),
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ botProviderForChannel,
4
5
  CHANNEL_BOT_PROVIDER,
5
6
  CHANNEL_IDS,
7
+ channelForBotProvider,
6
8
  isChannelBotProvider,
7
9
  isChannelUserIntegration,
8
10
  isChannelId,
@@ -41,6 +43,35 @@ describe("isChannelId", () => {
41
43
  });
42
44
  });
43
45
 
46
+ describe("botProviderForChannel and channelForBotProvider", () => {
47
+ test("are inverses over every entry of the map", () => {
48
+ for (const [channelId, botProviderKey] of Object.entries(
49
+ CHANNEL_BOT_PROVIDER,
50
+ )) {
51
+ expect(botProviderForChannel(channelId)).toBe(botProviderKey);
52
+ expect(channelForBotProvider(botProviderKey)).toBe(
53
+ channelId as keyof typeof CHANNEL_BOT_PROVIDER,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
+ expect(botProviderForChannel("phone")).toBeUndefined();
60
+ expect(botProviderForChannel("vellum")).toBeUndefined();
61
+ expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
+ // `slack` is the person's integration standing beside the bot's key.
63
+ expect(channelForBotProvider("slack")).toBeUndefined();
64
+ expect(channelForBotProvider("google")).toBeUndefined();
65
+ });
66
+
67
+ test("never resolve an inherited object property as a channel or a key", () => {
68
+ for (const name of ["constructor", "toString", "__proto__"]) {
69
+ expect(botProviderForChannel(name)).toBeUndefined();
70
+ expect(channelForBotProvider(name)).toBeUndefined();
71
+ }
72
+ });
73
+ });
74
+
44
75
  describe("isChannelUserIntegration", () => {
45
76
  test("names the grant standing beside a bot of the same brand", () => {
46
77
  expect(isChannelUserIntegration("slack")).toBe(true);
@@ -93,9 +93,34 @@ export const CHANNEL_BOT_PROVIDER = {
93
93
  * connected" question, for any provider key.
94
94
  */
95
95
  export function isChannelBotProvider(providerKey: string): boolean {
96
- return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
97
- providerKey,
98
- );
96
+ return channelForBotProvider(providerKey) !== undefined;
97
+ }
98
+
99
+ /**
100
+ * The provider key holding a channel's bot credential, or `undefined` when
101
+ * the channel has none, or is not a channel at all. Walks the map's own
102
+ * entries, so an inherited object property name is never a channel.
103
+ */
104
+ export function botProviderForChannel(channelId: string): string | undefined {
105
+ return Object.entries(CHANNEL_BOT_PROVIDER).find(
106
+ ([id]) => id === channelId,
107
+ )?.[1];
108
+ }
109
+
110
+ /**
111
+ * The channel whose bot credential a provider key holds, or `undefined` when
112
+ * the key is not a bot's. The inverse of `botProviderForChannel`, for a
113
+ * surface that has only the provider key in hand and needs to speak in the
114
+ * channel's vocabulary.
115
+ */
116
+ export function channelForBotProvider(
117
+ providerKey: string,
118
+ ): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
119
+ return (
120
+ Object.entries(CHANNEL_BOT_PROVIDER) as Array<
121
+ [keyof typeof CHANNEL_BOT_PROVIDER, string]
122
+ >
123
+ ).find(([, key]) => key === providerKey)?.[0];
99
124
  }
100
125
 
101
126
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.12.0-staging.1",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,10 @@
16
16
  "label": "In-Chat Onboarding Tour",
17
17
  "description": "Multivariate experiment for the eyes-led in-chat onboarding tour. control = straight to chat after research onboarding (current behavior); tour = the extended tour auto-plays on first workspace entry. Desktop-only: phone-width viewports and the native shell get neither the tour nor its telemetry (not even the exposure event), so the funnel measures desktop sessions exclusively. Targeted via LaunchDarkly (70% tour / 30% control).",
18
18
  "defaultEnabled": "control",
19
- "values": ["control", "tour"]
19
+ "values": [
20
+ "control",
21
+ "tour"
22
+ ]
20
23
  },
21
24
  {
22
25
  "id": "user-hosted-enabled",
@@ -33,7 +36,10 @@
33
36
  "label": "Proactive Tips",
34
37
  "description": "Gates the dismissible proactive tip card in the web sidebar. String-valued so future A/B arms can be added as new values.",
35
38
  "defaultEnabled": "off",
36
- "values": ["off", "on"]
39
+ "values": [
40
+ "off",
41
+ "on"
42
+ ]
37
43
  },
38
44
  {
39
45
  "id": "vision-mode",
@@ -42,7 +48,10 @@
42
48
  "label": "Vision Mode",
43
49
  "description": "Gates hold-to-Live ambient frame sampling in the voice room camera. String-valued so future A/B arms can be added as new values.",
44
50
  "defaultEnabled": "off",
45
- "values": ["off", "on"]
51
+ "values": [
52
+ "off",
53
+ "on"
54
+ ]
46
55
  },
47
56
  {
48
57
  "id": "assistant-initiated-threads",
@@ -51,7 +60,10 @@
51
60
  "label": "Assistant-Initiated Threads",
52
61
  "description": "Gates the sidebar section collecting the conversations the assistant started on its own: rows stamped `source='assistant_initiated'` at creation by producers that opt a thread in (heartbeat realizations worth the user's time; nothing stamps it yet, so the section starts empty and holds only new threads by construction). Transactional notification trails keep `source='notification'` and stay in Chats and the bell. When on, the daemon emits an `assistant` row from the section index and withholds member conversations from the Chats bucket; when off, they stay in Chats and no `assistant` row is emitted, which is the shipped behavior. Deliberately daemon-scope only: the index row is self-describing, so clients render the section on its presence rather than on a flag of their own. A second client-side gate could only hide a section whose rows the daemon has already withheld from Chats, which would lose those threads instead of reverting the feature. String-valued so future A/B arms can be added as new values.",
53
62
  "defaultEnabled": "off",
54
- "values": ["off", "on"]
63
+ "values": [
64
+ "off",
65
+ "on"
66
+ ]
55
67
  },
56
68
  {
57
69
  "id": "experiment-activation-flow-2026-06-03",
@@ -60,7 +72,11 @@
60
72
  "label": "Activation Flow Experiment 2026-06-03",
61
73
  "description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail; personal-page = new sign-up-page variant (front-end only). Targeted via LaunchDarkly.",
62
74
  "defaultEnabled": "control",
63
- "values": ["control", "variant-a", "personal-page"]
75
+ "values": [
76
+ "control",
77
+ "variant-a",
78
+ "personal-page"
79
+ ]
64
80
  },
65
81
  {
66
82
  "id": "local-docker-enabled",
@@ -486,6 +502,20 @@
486
502
  "description": "Platform pods resolve webhook callback URLs from the Velay-published ingress URL instead of registering platform callback routes. Falls back to platform callback registration while no tunnel URL is published.",
487
503
  "defaultEnabled": false
488
504
  },
505
+ {
506
+ "id": "experiment-activation-checklist-2026-09-10",
507
+ "scope": "client",
508
+ "key": "experiment-activation-checklist-2026-09-10",
509
+ "label": "Experiment: Activation Checklist (2026-09-10)",
510
+ "description": "A/B test of the post-onboarding activation checklist (welcome modal, suggestions pill, inspiration list). off = control, the standard chat landing with no checklist; smb / parent / general = the checklist with that persona's task list. Any other value hides the checklist. Percentages per arm are set in LaunchDarkly; the readout keys a user on their first-ever assignment, so a new round gets a new key.",
511
+ "defaultEnabled": "off",
512
+ "values": [
513
+ "off",
514
+ "smb",
515
+ "parent",
516
+ "general"
517
+ ]
518
+ },
489
519
  {
490
520
  "id": "send-user-message",
491
521
  "scope": "both",
@@ -914,6 +914,40 @@ describe("assistant subcommand classification", () => {
914
914
  expect(result.riskLevel).toBe("medium");
915
915
  });
916
916
 
917
+ // The bot-identity door can produce any effect the bot's API allows, reads
918
+ // and writes alike, so the whole command is high: the rating follows what
919
+ // the command can do, not what a caller means to use it for.
920
+ test("assistant channels request → high, whatever the endpoint", async () => {
921
+ for (const command of [
922
+ "assistant channels request slack /conversations.history",
923
+ "assistant channels request slack -X POST -d '{}' /chat.postMessage",
924
+ "assistant channels request discord /users/@me",
925
+ ]) {
926
+ const result = await classifier.classify({ command, toolName: "bash" });
927
+ expect(result.riskLevel).toBe("high");
928
+ }
929
+ });
930
+
931
+ // The OAuth request door reaches the same bot when --provider names a bot
932
+ // credential, so that form is high too; a person's integration stays at
933
+ // the door's own rating.
934
+ test("assistant oauth request as a channel bot → high; as a person → medium", async () => {
935
+ for (const command of [
936
+ "assistant oauth request --provider slack_channel /conversations.history",
937
+ "assistant oauth request --provider=discord_channel /users/@me",
938
+ "assistant oauth request /getMe --provider telegram",
939
+ ]) {
940
+ const result = await classifier.classify({ command, toolName: "bash" });
941
+ expect(result.riskLevel).toBe("high");
942
+ }
943
+ const person = await classifier.classify({
944
+ command:
945
+ "assistant oauth request --provider google /gmail/v1/users/me/messages",
946
+ toolName: "bash",
947
+ });
948
+ expect(person.riskLevel).toBe("medium");
949
+ });
950
+
917
951
  test("assistant oauth connect → low", async () => {
918
952
  const result = await classifier.classify({
919
953
  command: "assistant oauth connect",
@@ -1,3 +1,5 @@
1
+ import { CHANNEL_BOT_PROVIDER } from "@vellumai/service-contracts/channels";
2
+
1
3
  import type {
2
4
  ArgRule,
3
5
  CommandRiskSpec,
@@ -77,6 +79,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
77
79
  "channels",
78
80
  "channels list",
79
81
  "channels get",
82
+ "channels request",
80
83
  "clients",
81
84
  "clients disconnect",
82
85
  "clients list",
@@ -830,6 +833,12 @@ const riskOverrides: AssistantRiskOverride[] = [
830
833
  risk: "medium",
831
834
  reason: "Makes authenticated OAuth request",
832
835
  },
836
+ {
837
+ path: "channels request",
838
+ risk: "high",
839
+ reason:
840
+ "Acts as the channel's bot with any effect the bot's API allows (sends, edits, deletes, uploads, reactions, as well as reads); the effect is the endpoint's, which the command cannot tell apart",
841
+ },
833
842
  {
834
843
  path: "oauth connect",
835
844
  risk: "low",
@@ -986,6 +995,34 @@ const oauthModeArgRules: ArgRule[] = [
986
995
  ];
987
996
  getExistingPath(spec, "oauth mode").argRules = oauthModeArgRules;
988
997
 
998
+ // `oauth request` is medium-risk as an authenticated request through a
999
+ // person's OAuth integration, but the same door reaches a channel's bot when
1000
+ // `--provider` names a bot credential, and acting as the bot carries every
1001
+ // effect the bot's API allows. That form is high, like `channels request`.
1002
+ // The bot provider keys come from the channel contract, never a list kept
1003
+ // here, so a channel that gains a bot credential is covered by joining the
1004
+ // contract's map.
1005
+ const botProviderKeyPattern = Object.values(CHANNEL_BOT_PROVIDER)
1006
+ .map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
1007
+ .join("|");
1008
+ const oauthRequestArgRules: ArgRule[] = [
1009
+ {
1010
+ id: "assistant-oauth-request:bot-provider",
1011
+ flags: ["--provider"],
1012
+ valuePattern: `^(${botProviderKeyPattern})$`,
1013
+ risk: "high",
1014
+ reason:
1015
+ "Acts as a channel's bot through the OAuth request door, with every effect the bot's API allows",
1016
+ },
1017
+ ];
1018
+ const oauthRequestNode = getExistingPath(spec, "oauth request");
1019
+ oauthRequestNode.argRules = oauthRequestArgRules;
1020
+ // `--provider` consumes the next token as a value; so do the account
1021
+ // selectors, so the arg parser pairs every value flag correctly.
1022
+ oauthRequestNode.argSchema = {
1023
+ valueFlags: ["--provider", "--account", "--client-id"],
1024
+ };
1025
+
989
1026
  const assistantBashArgRules: ArgRule[] = [
990
1027
  {
991
1028
  id: "assistant-bash:command",
@@ -1,3 +1,4 @@
1
+ import { CHANNEL_BOT_PROVIDER } from "@vellumai/service-contracts/channels";
1
2
  import { describe, expect, test } from "bun:test";
2
3
 
3
4
  import { DEFAULT_COMMAND_REGISTRY } from "./command-registry/index.js";
@@ -420,6 +421,26 @@ describe("command-registry", () => {
420
421
  expect(oauthSpec.subcommands!.request.baseRisk).toBe("medium");
421
422
  });
422
423
 
424
+ test("assistant oauth request as a channel bot escalates to high, keyed on the contract's map", () => {
425
+ const requestSpec = oauthSpec.subcommands!.request;
426
+ const botRule = requestSpec.argRules!.find(
427
+ (r) => r.id === "assistant-oauth-request:bot-provider",
428
+ );
429
+ expect(botRule).toBeDefined();
430
+ expect(botRule!.flags).toEqual(["--provider"]);
431
+ expect(botRule!.risk).toBe("high");
432
+ expect(requestSpec.argSchema?.valueFlags).toContain("--provider");
433
+ // Every bot provider the contract names matches; a person's
434
+ // integration does not. The rule is derived from the map, so a
435
+ // channel that gains a bot credential is covered without a list here.
436
+ const pattern = new RegExp(botRule!.valuePattern!);
437
+ for (const key of Object.values(CHANNEL_BOT_PROVIDER)) {
438
+ expect(pattern.test(key)).toBe(true);
439
+ }
440
+ expect(pattern.test("google")).toBe(false);
441
+ expect(pattern.test("slack")).toBe(false);
442
+ });
443
+
423
444
  test("assistant oauth connect is low risk", () => {
424
445
  expect(oauthSpec.subcommands!.connect.baseRisk).toBe("low");
425
446
  });