@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.
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/package.json +1 -1
- package/src/feature-flag-registry.json +35 -5
- package/src/risk/bash-risk-classifier.test.ts +34 -0
- package/src/risk/command-registry/commands/assistant.ts +37 -0
- package/src/risk/command-registry.test.ts +21 -0
|
@@ -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);
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -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 (
|
|
97
|
-
|
|
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 (
|
|
97
|
-
|
|
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/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts
CHANGED
|
@@ -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,
|
|
@@ -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
|
-
*
|
|
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 =
|
|
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 (
|
|
97
|
-
|
|
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
|
@@ -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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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
|
});
|