@vellumai/vellum-gateway 0.8.2 → 0.8.4

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.
Files changed (32) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-file-watcher.test.ts +57 -0
  4. package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
  5. package/src/__tests__/route-schema-guard.test.ts +4 -0
  6. package/src/__tests__/slack-display-name.test.ts +218 -0
  7. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
  8. package/src/__tests__/twilio-webhooks.test.ts +47 -0
  9. package/src/auth/ipc-route-policy.ts +6 -0
  10. package/src/channels/inbound-event.ts +8 -2
  11. package/src/channels/types.ts +2 -0
  12. package/src/config-file-watcher.ts +44 -1
  13. package/src/db/slack-store.ts +10 -0
  14. package/src/feature-flag-registry.json +111 -23
  15. package/src/handlers/handle-inbound.ts +6 -4
  16. package/src/http/routes/a2a-routes.test.ts +129 -0
  17. package/src/http/routes/a2a-routes.ts +121 -0
  18. package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
  19. package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
  20. package/src/http/routes/twilio-voice-webhook.ts +10 -2
  21. package/src/index.ts +16 -0
  22. package/src/ipc/slack-thread-handlers.ts +39 -0
  23. package/src/risk/bash-risk-classifier.test.ts +24 -0
  24. package/src/risk/command-registry/commands/assistant.ts +33 -0
  25. package/src/risk/command-registry.test.ts +5 -0
  26. package/src/runtime/client.ts +66 -14
  27. package/src/slack/normalize.ts +78 -26
  28. package/src/slack/socket-mode.ts +2 -2
  29. package/src/twilio/validate-webhook.ts +7 -1
  30. package/src/types.ts +1 -0
  31. package/src/velay/client.test.ts +100 -0
  32. package/src/velay/client.ts +73 -0
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // --- Workspace dir mock -----------------------------------------------------
7
+
8
+ let testWorkspaceDir: string;
9
+
10
+ mock.module("../../credential-reader.js", () => ({
11
+ getWorkspaceDir: () => testWorkspaceDir,
12
+ }));
13
+
14
+ // Import after mocks are registered
15
+ const { createAgentCardHandler } = await import("./a2a-routes.js");
16
+
17
+ // --- Helpers ---------------------------------------------------------------
18
+
19
+ function makeConfigFileCache(overrides?: {
20
+ a2aEnabled?: boolean;
21
+ publicBaseUrl?: string;
22
+ }) {
23
+ const data: Record<string, Record<string, unknown>> = {
24
+ a2a: { enabled: overrides?.a2aEnabled ?? false },
25
+ ingress: {
26
+ publicBaseUrl: overrides?.publicBaseUrl ?? "https://example.com",
27
+ },
28
+ };
29
+
30
+ return {
31
+ getBoolean: (section: string, field: string) => {
32
+ const val = data[section]?.[field];
33
+ return typeof val === "boolean" ? val : undefined;
34
+ },
35
+ getString: (section: string, field: string) => {
36
+ const val = data[section]?.[field];
37
+ return typeof val === "string" ? val : undefined;
38
+ },
39
+ } as import("../../config-file-cache.js").ConfigFileCache;
40
+ }
41
+
42
+ // --- Setup / teardown -------------------------------------------------------
43
+
44
+ beforeEach(() => {
45
+ testWorkspaceDir = mkdtempSync(join(tmpdir(), "a2a-test-"));
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(testWorkspaceDir, { recursive: true, force: true });
50
+ });
51
+
52
+ // --- Tests -----------------------------------------------------------------
53
+
54
+ describe("Agent Card", () => {
55
+ it("returns 404 when A2A is not enabled", async () => {
56
+ const configFile = makeConfigFileCache({ a2aEnabled: false });
57
+ const handler = createAgentCardHandler(configFile);
58
+
59
+ const res = await handler(
60
+ new Request("http://localhost:7830/.well-known/agent-card.json"),
61
+ );
62
+
63
+ expect(res.status).toBe(404);
64
+ const body = (await res.json()) as { error: string };
65
+ expect(body.error).toContain("not enabled");
66
+ });
67
+
68
+ it("serves agent card with fallback name when no IDENTITY.md", async () => {
69
+ const configFile = makeConfigFileCache({
70
+ a2aEnabled: true,
71
+ publicBaseUrl: "https://my-assistant.example.com",
72
+ });
73
+ const handler = createAgentCardHandler(configFile);
74
+
75
+ const res = await handler(
76
+ new Request("http://localhost:7830/.well-known/agent-card.json"),
77
+ );
78
+
79
+ expect(res.status).toBe(200);
80
+ const card = (await res.json()) as {
81
+ name: string;
82
+ supported_interfaces: Array<{ url: string }>;
83
+ capabilities: { push_notifications: boolean };
84
+ };
85
+ expect(card.name).toBe("Vellum Assistant");
86
+ expect(card.supported_interfaces[0].url).toBe(
87
+ "https://my-assistant.example.com/a2a/message:send",
88
+ );
89
+ expect(card.capabilities.push_notifications).toBe(true);
90
+ });
91
+
92
+ it("reads assistant name from IDENTITY.md", async () => {
93
+ const promptsDir = join(testWorkspaceDir, "prompts");
94
+ mkdirSync(promptsDir, { recursive: true });
95
+ writeFileSync(
96
+ join(promptsDir, "IDENTITY.md"),
97
+ "**Name:** Alice\n\nA helpful research assistant.",
98
+ );
99
+
100
+ const configFile = makeConfigFileCache({
101
+ a2aEnabled: true,
102
+ publicBaseUrl: "https://alice.example.com",
103
+ });
104
+ const handler = createAgentCardHandler(configFile);
105
+
106
+ const res = await handler(
107
+ new Request("http://localhost:7830/.well-known/agent-card.json"),
108
+ );
109
+
110
+ expect(res.status).toBe(200);
111
+ const card = (await res.json()) as { name: string; description: string };
112
+ expect(card.name).toBe("Alice");
113
+ expect(card.description).toBe("Alice — a Vellum AI assistant");
114
+ });
115
+
116
+ it("returns 503 when no public base URL is configured", async () => {
117
+ const configFile = makeConfigFileCache({
118
+ a2aEnabled: true,
119
+ publicBaseUrl: "",
120
+ });
121
+ const handler = createAgentCardHandler(configFile);
122
+
123
+ const res = await handler(
124
+ new Request("http://localhost:7830/.well-known/agent-card.json"),
125
+ );
126
+
127
+ expect(res.status).toBe(503);
128
+ });
129
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A2A agent card discovery endpoint:
3
+ * - GET /.well-known/agent-card.json — agent card for peer discovery
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ import type { ConfigFileCache } from "../../config-file-cache.js";
10
+ import { getWorkspaceDir } from "../../credential-reader.js";
11
+ import { getLogger } from "../../logger.js";
12
+
13
+ const log = getLogger("a2a-routes");
14
+
15
+ // ── A2A protocol constants (duplicated to avoid cross-package import) ──
16
+
17
+ const A2A_AGENT_CARD_PATH = "/.well-known/agent-card.json";
18
+
19
+ // ── Agent card builder ──────────────────────────────────────────────
20
+
21
+ interface AgentCard {
22
+ name: string;
23
+ description: string;
24
+ version: string;
25
+ supported_interfaces: Array<{
26
+ url: string;
27
+ protocol_binding: string;
28
+ protocol_version: string;
29
+ }>;
30
+ capabilities: {
31
+ streaming: boolean;
32
+ push_notifications: boolean;
33
+ extended_agent_card: boolean;
34
+ };
35
+ default_input_modes: string[];
36
+ default_output_modes: string[];
37
+ skills: Array<{
38
+ id: string;
39
+ name: string;
40
+ description: string;
41
+ tags: string[];
42
+ }>;
43
+ }
44
+
45
+ function buildAgentCard(baseUrl: string, assistantName: string): AgentCard {
46
+ return {
47
+ name: assistantName,
48
+ description: `${assistantName} — a Vellum AI assistant`,
49
+ version: "1.0.0",
50
+ supported_interfaces: [
51
+ {
52
+ url: `${baseUrl}/a2a/message:send`,
53
+ protocol_binding: "JSONRPC",
54
+ protocol_version: "1.0",
55
+ },
56
+ ],
57
+ capabilities: {
58
+ streaming: false,
59
+ push_notifications: true,
60
+ extended_agent_card: false,
61
+ },
62
+ default_input_modes: ["text/plain"],
63
+ default_output_modes: ["text/plain"],
64
+ skills: [
65
+ {
66
+ id: "conversation",
67
+ name: "General conversation",
68
+ description: "Send a message and receive a response",
69
+ tags: ["chat"],
70
+ },
71
+ ],
72
+ };
73
+ }
74
+
75
+ // ── Identity helpers ───────────────────────────────────────────────
76
+
77
+ function readAssistantName(): string {
78
+ try {
79
+ const wsDir = getWorkspaceDir();
80
+ const identityPath = join(wsDir, "prompts", "IDENTITY.md");
81
+ if (!existsSync(identityPath)) return "Vellum Assistant";
82
+ const content = readFileSync(identityPath, "utf-8");
83
+ const match = content.match(/\*\*Name:\*\*\s*(.+)/);
84
+ return match?.[1]?.trim() || "Vellum Assistant";
85
+ } catch {
86
+ return "Vellum Assistant";
87
+ }
88
+ }
89
+
90
+ // ── Route handler factory ──────────────────────────────────────────
91
+
92
+ export function createAgentCardHandler(configFile: ConfigFileCache) {
93
+ return async (_req: Request): Promise<Response> => {
94
+ const enabled = configFile.getBoolean("a2a", "enabled") ?? false;
95
+ if (!enabled) {
96
+ return Response.json(
97
+ { error: "A2A channel is not enabled" },
98
+ { status: 404 },
99
+ );
100
+ }
101
+
102
+ const publicBaseUrl =
103
+ configFile.getString("ingress", "publicBaseUrl") ?? "";
104
+ if (!publicBaseUrl) {
105
+ log.warn("Agent card requested but no public base URL configured");
106
+ return Response.json(
107
+ { error: "Public ingress URL not configured" },
108
+ { status: 503 },
109
+ );
110
+ }
111
+
112
+ const assistantName = readAssistantName();
113
+ const card = buildAgentCard(publicBaseUrl, assistantName);
114
+
115
+ return Response.json(card, {
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+ };
119
+ }
120
+
121
+ export { A2A_AGENT_CARD_PATH };
@@ -78,14 +78,17 @@ export function createTwilioVoiceVerifyCallbackHandler(
78
78
  { callSid, fromNumber },
79
79
  "No pending verification session found on callback — forwarding to assistant",
80
80
  );
81
- return forwardToAssistant(config, params, req.url, caches);
81
+ return forwardToAssistant(
82
+ config,
83
+ params,
84
+ req.url,
85
+ validation.validatedCandidateUrl,
86
+ caches,
87
+ );
82
88
  }
83
89
 
84
90
  if (!digits) {
85
- log.info(
86
- { callSid, fromNumber },
87
- "No digits entered — re-prompting",
88
- );
91
+ log.info({ callSid, fromNumber }, "No digits entered — re-prompting");
89
92
  const actionUrl = buildActionUrl(url, attempt);
90
93
  return twimlResponse(
91
94
  gatherVerificationTwiml(actionUrl, attempt, session.codeDigits ?? 6),
@@ -113,7 +116,11 @@ export function createTwilioVoiceVerifyCallbackHandler(
113
116
  const nextAttempt = attempt + 1;
114
117
  const actionUrl = buildActionUrl(url, nextAttempt);
115
118
  return twimlResponse(
116
- gatherVerificationTwiml(actionUrl, nextAttempt, session.codeDigits ?? 6),
119
+ gatherVerificationTwiml(
120
+ actionUrl,
121
+ nextAttempt,
122
+ session.codeDigits ?? 6,
123
+ ),
117
124
  );
118
125
  }
119
126
 
@@ -147,7 +154,10 @@ export function createTwilioVoiceVerifyCallbackHandler(
147
154
  );
148
155
 
149
156
  const existingGuardian = existingPhoneGuardians[0];
150
- if (existingGuardian && existingGuardian.externalUserId !== fromNumber) {
157
+ if (
158
+ existingGuardian &&
159
+ existingGuardian.externalUserId !== fromNumber
160
+ ) {
151
161
  log.warn(
152
162
  {
153
163
  callSid,
@@ -209,7 +219,13 @@ export function createTwilioVoiceVerifyCallbackHandler(
209
219
  { callSid, fromNumber },
210
220
  "Voice verification complete — forwarding to assistant for call setup",
211
221
  );
212
- return forwardToAssistant(config, params, req.url, caches);
222
+ return forwardToAssistant(
223
+ config,
224
+ params,
225
+ req.url,
226
+ validation.validatedCandidateUrl,
227
+ caches,
228
+ );
213
229
  };
214
230
  }
215
231
 
@@ -248,13 +264,17 @@ async function revokeExistingPhoneGuardian(): Promise<void> {
248
264
  try {
249
265
  const gwDb = getGatewayDb();
250
266
  for (const id of ids) {
251
- gwDb.update(gwContactChannels)
267
+ gwDb
268
+ .update(gwContactChannels)
252
269
  .set({ status: "revoked", policy: "deny", updatedAt: now })
253
270
  .where(eq(gwContactChannels.id, id))
254
271
  .run();
255
272
  }
256
273
  } catch (gwErr) {
257
- log.warn({ err: gwErr }, "Gateway DB revoke dual-write failed (best-effort)");
274
+ log.warn(
275
+ { err: gwErr },
276
+ "Gateway DB revoke dual-write failed (best-effort)",
277
+ );
258
278
  }
259
279
  }
260
280
 
@@ -276,6 +296,7 @@ async function forwardToAssistant(
276
296
  config: GatewayConfig,
277
297
  params: Record<string, string>,
278
298
  originalUrl: string,
299
+ validatedPublicUrl?: string,
279
300
  caches?: TwilioValidationCaches,
280
301
  ): Promise<Response> {
281
302
  try {
@@ -288,7 +309,12 @@ async function forwardToAssistant(
288
309
  config,
289
310
  params,
290
311
  originalUrl,
291
- resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
312
+ resolvePublicBaseWssUrl(
313
+ config,
314
+ caches?.configFile,
315
+ platformAssistantId,
316
+ validatedPublicUrl,
317
+ ),
292
318
  );
293
319
  return new Response(runtimeResponse.body, {
294
320
  status: runtimeResponse.status,
@@ -304,7 +330,10 @@ async function forwardToAssistant(
304
330
  },
305
331
  );
306
332
  }
307
- log.error({ err }, "Failed to forward voice webhook to runtime after verification");
333
+ log.error(
334
+ { err },
335
+ "Failed to forward voice webhook to runtime after verification",
336
+ );
308
337
  return Response.json({ error: "Internal server error" }, { status: 502 });
309
338
  }
310
339
  }
@@ -352,6 +352,61 @@ describe("resolvePublicBaseWssUrl", () => {
352
352
  );
353
353
  });
354
354
 
355
+ test("uses assistant ID from validated platform callback URL with Velay", () => {
356
+ const config = {
357
+ ...baseConfig,
358
+ velayBaseUrl: "https://velay-staging.vellum.ai",
359
+ };
360
+ const result = resolvePublicBaseWssUrl(
361
+ config,
362
+ undefined,
363
+ undefined,
364
+ "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e",
365
+ );
366
+ expect(result).toBe(
367
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
368
+ );
369
+ });
370
+
371
+ test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => {
372
+ const config = {
373
+ ...baseConfig,
374
+ velayBaseUrl: "https://velay-staging.vellum.ai",
375
+ };
376
+ const mockConfigFile = {
377
+ getString: (section: string, key: string) =>
378
+ section === "ingress" && key === "publicBaseUrl"
379
+ ? "https://stale-tunnel.example.test"
380
+ : undefined,
381
+ } as Parameters<typeof resolvePublicBaseWssUrl>[1];
382
+ const result = resolvePublicBaseWssUrl(
383
+ config,
384
+ mockConfigFile,
385
+ undefined,
386
+ "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1",
387
+ );
388
+ expect(result).toBe(
389
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
390
+ );
391
+ });
392
+
393
+ test("does not use a platform callback URL as the websocket base", () => {
394
+ const config = {
395
+ ...baseConfig,
396
+ velayBaseUrl: "https://velay-staging.vellum.ai",
397
+ };
398
+ const mockConfigFile = {
399
+ getString: (section: string, key: string) =>
400
+ section === "ingress" && key === "publicBaseUrl"
401
+ ? "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e"
402
+ : undefined,
403
+ } as Parameters<typeof resolvePublicBaseWssUrl>[1];
404
+ const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
405
+ expect(result).toBe(
406
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
407
+ );
408
+ });
409
+
355
410
  test("strips trailing slash from velayBaseUrl before joining assistant ID", () => {
356
411
  const config = {
357
412
  ...baseConfig,
@@ -151,7 +151,10 @@ export function createTwilioVoiceWebhookHandler(
151
151
  // The display name is intentionally included: the caller registered
152
152
  // this number themselves, so disclosing their own name is expected.
153
153
  const unverifiedStatuses = new Set(["unverified", "pending"]);
154
- if (callerRecord && unverifiedStatuses.has(callerRecord.channel.status)) {
154
+ if (
155
+ callerRecord &&
156
+ unverifiedStatuses.has(callerRecord.channel.status)
157
+ ) {
155
158
  const isGuardian = callerRecord.contact.role === "guardian";
156
159
  log.info(
157
160
  {
@@ -197,7 +200,12 @@ export function createTwilioVoiceWebhookHandler(
197
200
  config,
198
201
  params,
199
202
  req.url,
200
- resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
203
+ resolvePublicBaseWssUrl(
204
+ config,
205
+ caches?.configFile,
206
+ platformAssistantId,
207
+ validation.validatedCandidateUrl,
208
+ ),
201
209
  );
202
210
  return new Response(runtimeResponse.body, {
203
211
  status: runtimeResponse.status,
package/src/index.ts CHANGED
@@ -118,6 +118,10 @@ import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commi
118
118
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
119
119
  import { createLogExportHandler } from "./http/routes/log-export.js";
120
120
  import { createLogTailHandler } from "./http/routes/log-tail.js";
121
+ import {
122
+ createAgentCardHandler,
123
+ A2A_AGENT_CARD_PATH,
124
+ } from "./http/routes/a2a-routes.js";
121
125
  import {
122
126
  createTrustRulesListHandler,
123
127
  createTrustRulesCreateHandler,
@@ -172,6 +176,7 @@ import {
172
176
  import { GatewayIpcServer } from "./ipc/server.js";
173
177
  import { contactRoutes } from "./ipc/contact-handlers.js";
174
178
  import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
179
+ import { slackThreadRoutes } from "./ipc/slack-thread-handlers.js";
175
180
  import { thresholdRoutes } from "./ipc/threshold-handlers.js";
176
181
 
177
182
  import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
@@ -467,6 +472,8 @@ async function main() {
467
472
  const handleTrustRulesReset = createTrustRulesResetHandler();
468
473
  const handleTrustRulesSuggest = createTrustRulesSuggestHandler();
469
474
 
475
+ const handleAgentCard = createAgentCardHandler(configFileCache);
476
+
470
477
  const audioProxy = createAudioProxyHandler(config);
471
478
 
472
479
  const backupDeps = {
@@ -505,6 +512,13 @@ async function main() {
505
512
  // Auth middleware is applied declaratively per route — no manual
506
513
  // requireEdgeAuth/wrapWithAuthFailureTracking calls needed.
507
514
  const routes: RouteDefinition[] = [
515
+ // ── A2A agent card discovery (read-only, unauthenticated per spec) ──
516
+ {
517
+ path: A2A_AGENT_CARD_PATH,
518
+ method: "GET",
519
+ handler: (req) => handleAgentCard(req),
520
+ },
521
+
508
522
  // ── Webhooks (unauthenticated, validated by provider-specific mechanisms) ──
509
523
  {
510
524
  path: "/webhooks/telegram",
@@ -2104,6 +2118,7 @@ async function main() {
2104
2118
  // Fires on initial credential load and whenever vellum credentials change
2105
2119
  // (key rotation, late provisioning).
2106
2120
  if (changed.has("vellum")) {
2121
+ velayTunnelClient?.refreshCredentials("vellum credentials changed");
2107
2122
  registerEmailCallbackRoute({
2108
2123
  credentials: credentialCache,
2109
2124
  configFile: configFileCache,
@@ -2191,6 +2206,7 @@ async function main() {
2191
2206
  const ipcServer = new GatewayIpcServer([
2192
2207
  ...featureFlagRoutes,
2193
2208
  ...contactRoutes,
2209
+ ...slackThreadRoutes,
2194
2210
  ...thresholdRoutes,
2195
2211
  ...riskClassificationRoutes,
2196
2212
  ...createVelayRoutes(velayTunnelClient),
@@ -0,0 +1,39 @@
1
+ /**
2
+ * IPC route definitions for Slack active-thread listener control.
3
+ *
4
+ * The gateway owns Slack Socket Mode listener state, so assistant-side
5
+ * controls call here instead of writing gateway storage directly.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ import { SlackStore } from "../db/slack-store.js";
11
+ import type { IpcRoute } from "./server.js";
12
+
13
+ let store: SlackStore | null = null;
14
+
15
+ function getStore(): SlackStore {
16
+ if (!store) {
17
+ store = new SlackStore();
18
+ }
19
+ return store;
20
+ }
21
+
22
+ const DetachSlackActiveThreadParamsSchema = z.object({
23
+ channelId: z.string().trim().min(1),
24
+ threadTs: z.string().trim().min(1),
25
+ });
26
+
27
+ export const slackThreadRoutes: IpcRoute[] = [
28
+ {
29
+ method: "detach_slack_active_thread",
30
+ schema: DetachSlackActiveThreadParamsSchema,
31
+ handler: (params?: Record<string, unknown>) => {
32
+ const { channelId, threadTs } = DetachSlackActiveThreadParamsSchema.parse(
33
+ params ?? {},
34
+ );
35
+ const detached = getStore().detachThread(threadTs, channelId);
36
+ return { detached, channelId, threadTs };
37
+ },
38
+ },
39
+ ];
@@ -1035,6 +1035,30 @@ describe("assistant subcommand classification", () => {
1035
1035
  expect(result.riskLevel).toBe("low");
1036
1036
  });
1037
1037
 
1038
+ test("assistant schedules enable → medium", async () => {
1039
+ const result = await classifier.classify({
1040
+ command: "assistant schedules enable schedule-1",
1041
+ toolName: "bash",
1042
+ });
1043
+ expect(result.riskLevel).toBe("medium");
1044
+ });
1045
+
1046
+ test("assistant schedules disable → medium", async () => {
1047
+ const result = await classifier.classify({
1048
+ command: "assistant schedules disable schedule-1",
1049
+ toolName: "bash",
1050
+ });
1051
+ expect(result.riskLevel).toBe("medium");
1052
+ });
1053
+
1054
+ test("assistant schedules cancel → medium", async () => {
1055
+ const result = await classifier.classify({
1056
+ command: "assistant schedules cancel schedule-1",
1057
+ toolName: "bash",
1058
+ });
1059
+ expect(result.riskLevel).toBe("medium");
1060
+ });
1061
+
1038
1062
  test("assistant schedules execute → medium", async () => {
1039
1063
  const result = await classifier.classify({
1040
1064
  command: "assistant schedules execute schedule-1",
@@ -192,6 +192,11 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
192
192
  "schedules",
193
193
  "schedules list",
194
194
  "schedules runs",
195
+ "schedules create",
196
+ "schedules enable",
197
+ "schedules disable",
198
+ "schedules cancel",
199
+ "schedules delete",
195
200
  "schedules execute",
196
201
  "sequence",
197
202
  "sequence list",
@@ -261,6 +266,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
261
266
  "plugins",
262
267
  "plugins install",
263
268
  "plugins list",
269
+ "plugins search",
264
270
  "plugins uninstall",
265
271
  ] as const;
266
272
 
@@ -499,6 +505,33 @@ const riskOverrides: AssistantRiskOverride[] = [
499
505
  { path: "platform connect", risk: "low" },
500
506
  { path: "platform disconnect", risk: "medium" },
501
507
  { path: "platform callback-routes register", risk: "low" },
508
+ {
509
+ path: "schedules create",
510
+ risk: "medium",
511
+ reason:
512
+ "Creates a new recurring schedule that fires assistant-side messages",
513
+ },
514
+ {
515
+ path: "schedules enable",
516
+ risk: "medium",
517
+ reason: "Enables a schedule and mutates assistant schedule state",
518
+ },
519
+ {
520
+ path: "schedules disable",
521
+ risk: "medium",
522
+ reason: "Disables a schedule and mutates assistant schedule state",
523
+ },
524
+ {
525
+ path: "schedules cancel",
526
+ risk: "medium",
527
+ reason: "Cancels a pending schedule and mutates assistant schedule state",
528
+ },
529
+ {
530
+ path: "schedules delete",
531
+ risk: "medium",
532
+ reason:
533
+ "Permanently removes a schedule and its run history from assistant state",
534
+ },
502
535
  {
503
536
  path: "schedules execute",
504
537
  risk: "medium",
@@ -599,6 +599,11 @@ describe("command-registry", () => {
599
599
  expect(getAssistantPath("inference session list").baseRisk).toBe("low");
600
600
  expect(getAssistantPath("schedules list").baseRisk).toBe("low");
601
601
  expect(getAssistantPath("schedules runs").baseRisk).toBe("low");
602
+ expect(getAssistantPath("schedules create").baseRisk).toBe("medium");
603
+ expect(getAssistantPath("schedules enable").baseRisk).toBe("medium");
604
+ expect(getAssistantPath("schedules disable").baseRisk).toBe("medium");
605
+ expect(getAssistantPath("schedules cancel").baseRisk).toBe("medium");
606
+ expect(getAssistantPath("schedules delete").baseRisk).toBe("medium");
602
607
  expect(getAssistantPath("schedules execute").baseRisk).toBe("medium");
603
608
  });
604
609
  });