@vellumai/vellum-gateway 0.7.0 → 0.7.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.
Files changed (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -324,6 +324,11 @@ export interface SuggestTrustRuleRequest {
324
324
  directoryScopeOptions?: DirectoryScopeOption[];
325
325
  currentThreshold: string; // "low" | "medium" | "high"
326
326
  intent: "auto_approve" | "escalate";
327
+ existingRule?: {
328
+ id: string;
329
+ pattern: string;
330
+ risk: string;
331
+ };
327
332
  }
328
333
 
329
334
  export interface SuggestTrustRuleResponse {
@@ -343,10 +348,9 @@ export interface SuggestTrustRuleResponse {
343
348
  export async function ipcSuggestTrustRule(
344
349
  params: SuggestTrustRuleRequest,
345
350
  ): Promise<SuggestTrustRuleResponse> {
346
- const result = await ipcCallAssistant(
347
- "suggest_trust_rule",
348
- { body: params } as unknown as Record<string, unknown>,
349
- );
351
+ const result = await ipcCallAssistant("suggest_trust_rule", {
352
+ body: params,
353
+ } as unknown as Record<string, unknown>);
350
354
  if (!result || typeof result !== "object" || Array.isArray(result)) {
351
355
  throw new Error("ipcSuggestTrustRule: unexpected response shape");
352
356
  }
@@ -1,15 +1,23 @@
1
1
  /**
2
- * IPC route definitions for contact reads.
2
+ * IPC route definitions for contact reads and writes.
3
3
  *
4
- * Exposes gateway-owned contact data (auth/authz) to the assistant
5
- * daemon over the IPC socket. All methods are read-only.
4
+ * Read methods expose gateway-owned contact data to the assistant daemon.
5
+ * The `create_contact` write method upserts a contact+channel via the
6
+ * assistant DB proxy (raw SQL), so the gateway owns the write path.
6
7
  */
7
8
 
8
9
  import { z } from "zod";
9
10
 
11
+ import {
12
+ assistantDbQuery,
13
+ assistantDbRun,
14
+ } from "../db/assistant-db-proxy.js";
10
15
  import { ContactStore } from "../db/contact-store.js";
16
+ import { getLogger } from "../logger.js";
11
17
  import type { IpcRoute } from "./server.js";
12
18
 
19
+ const log = getLogger("contact-handlers");
20
+
13
21
  let store: ContactStore | null = null;
14
22
 
15
23
  function getStore(): ContactStore {
@@ -19,6 +27,13 @@ function getStore(): ContactStore {
19
27
  return store;
20
28
  }
21
29
 
30
+ const CreateContactParamsSchema = z.object({
31
+ channelType: z.string().min(1),
32
+ address: z.string().min(1),
33
+ role: z.enum(["guardian", "trusted-contact", "unknown"]).optional(),
34
+ displayName: z.string().optional(),
35
+ });
36
+
22
37
  const GetContactParamsSchema = z.object({
23
38
  contactId: z.string(),
24
39
  });
@@ -64,4 +79,74 @@ export const contactRoutes: IpcRoute[] = [
64
79
  return getStore().getChannelsForContact(contactId);
65
80
  },
66
81
  },
82
+ {
83
+ method: "create_contact",
84
+ schema: CreateContactParamsSchema,
85
+ handler: async (params?: Record<string, unknown>) => {
86
+ const { channelType, address, role, displayName } =
87
+ CreateContactParamsSchema.parse(params);
88
+
89
+ const normalizedAddress = address.toLowerCase().trim();
90
+ const effectiveDisplayName = displayName ?? normalizedAddress;
91
+ // Map prompt roles to valid ContactRole values ("guardian" | "contact").
92
+ const effectiveRole: string =
93
+ role === "guardian" ? "guardian" : "contact";
94
+ const now = Date.now();
95
+
96
+ // Check if a channel with this (type, address) already exists.
97
+ const existing = await assistantDbQuery<{
98
+ channelId: string;
99
+ contactId: string;
100
+ }>(
101
+ `SELECT cc.id AS channelId, cc.contact_id AS contactId
102
+ FROM contact_channels cc
103
+ WHERE cc.type = ? AND cc.address = ?
104
+ LIMIT 1`,
105
+ [channelType, normalizedAddress],
106
+ );
107
+
108
+ if (existing.length > 0) {
109
+ const { channelId, contactId } = existing[0];
110
+ log.info(
111
+ { channelType, address: normalizedAddress, contactId, channelId },
112
+ "create_contact: channel already exists, returning existing record",
113
+ );
114
+ return { contactId, channelId };
115
+ }
116
+
117
+ // Create a new contact + channel.
118
+ // Two separate INSERTs — use a compensating DELETE on channel failure.
119
+ const contactId = crypto.randomUUID();
120
+ const channelId = crypto.randomUUID();
121
+
122
+ await assistantDbRun(
123
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
124
+ VALUES (?, ?, ?, 'human', ?, ?)`,
125
+ [contactId, effectiveDisplayName, effectiveRole, now, now],
126
+ );
127
+
128
+ try {
129
+ await assistantDbRun(
130
+ `INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
131
+ VALUES (?, ?, ?, ?, 1, 'unverified', 'allow', 0, ?, ?)`,
132
+ [channelId, contactId, channelType, normalizedAddress, now, now],
133
+ );
134
+ } catch (channelErr) {
135
+ // Compensating delete — remove the orphaned contact row.
136
+ log.error(
137
+ { channelErr, contactId, channelType, address: normalizedAddress },
138
+ "create_contact: channel INSERT failed, rolling back contact row",
139
+ );
140
+ await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
141
+ throw channelErr;
142
+ }
143
+
144
+ log.info(
145
+ { channelType, address: normalizedAddress, contactId, channelId, role: effectiveRole },
146
+ "create_contact: created new contact + channel",
147
+ );
148
+
149
+ return { contactId, channelId };
150
+ },
151
+ },
67
152
  ];
@@ -18,6 +18,7 @@ import type { IpcRoute } from "./server.js";
18
18
  const GLOBAL_DEFAULTS = {
19
19
  interactive: "medium",
20
20
  autonomous: "low",
21
+ headless: "none",
21
22
  };
22
23
 
23
24
  const GetConversationThresholdSchema = z.object({
@@ -45,6 +46,7 @@ export const thresholdRoutes: IpcRoute[] = [
45
46
  return {
46
47
  interactive: row.interactive,
47
48
  autonomous: row.autonomous,
49
+ headless: row.headless,
48
50
  };
49
51
  },
50
52
  },
@@ -8,7 +8,9 @@
8
8
  * assistant DB doesn't exist yet.
9
9
  *
10
10
  * This module polls the assistant IPC health route and, once the assistant
11
- * is ready, runs all deferred startup tasks.
11
+ * is ready, runs data migrations and other deferred tasks. It is awaited
12
+ * during startup — the HTTP server does not start until this completes,
13
+ * preventing auth traffic from racing with data migrations.
12
14
  */
13
15
 
14
16
  import type { Database } from "bun:sqlite";
@@ -28,7 +30,7 @@ function getRawDb(drizzleDb: GatewayDb): Database {
28
30
  return (drizzleDb as unknown as { $client: Database }).$client;
29
31
  }
30
32
 
31
- async function waitForAssistant(): Promise<boolean> {
33
+ export async function waitForAssistant(): Promise<boolean> {
32
34
  const deadline = Date.now() + MAX_WAIT_MS;
33
35
 
34
36
  while (Date.now() < deadline) {
@@ -50,7 +52,7 @@ async function waitForAssistant(): Promise<boolean> {
50
52
 
51
53
  /**
52
54
  * Wait for the assistant runtime to become healthy, then run deferred
53
- * startup tasks. Fire-and-forget from the main startup path.
55
+ * startup tasks. Awaited at startup blocks Bun.serve().
54
56
  */
55
57
  export async function runPostAssistantReady(): Promise<void> {
56
58
  const ready = await waitForAssistant();
@@ -765,13 +765,13 @@ describe("unknown commands", () => {
765
765
  expect(result.matchType).toBe("registry");
766
766
  });
767
767
 
768
- test("unknown command with --help → low risk", async () => {
768
+ test("unknown command with --help → unknown risk", async () => {
769
769
  const result = await classifier.classify({
770
770
  command: "mycustomtool --help",
771
771
  toolName: "bash",
772
772
  });
773
- expect(result.riskLevel).toBe("low");
774
- expect(result.matchType).toBe("registry");
773
+ expect(result.riskLevel).toBe("unknown");
774
+ expect(result.matchType).toBe("unknown");
775
775
  });
776
776
 
777
777
  test("--help after -- is positional, not help mode", async () => {
@@ -781,6 +781,38 @@ describe("unknown commands", () => {
781
781
  });
782
782
  expect(result.riskLevel).toBe("high");
783
783
  });
784
+
785
+ test("value-taking flags do not trigger help shortcut", async () => {
786
+ const result = await classifier.classify({
787
+ command: "tar -f --help /etc/passwd",
788
+ toolName: "bash",
789
+ });
790
+ expect(result.riskLevel).toBe("medium");
791
+ });
792
+
793
+ test("commands without arg schema do not use help shortcut", async () => {
794
+ const result = await classifier.classify({
795
+ command: "bash --rcfile --help -c 'rm -rf /'",
796
+ toolName: "bash",
797
+ });
798
+ expect(result.riskLevel).toBe("high");
799
+ });
800
+
801
+ test("bundled short flags with value flag do not trigger help shortcut", async () => {
802
+ const result = await classifier.classify({
803
+ command: "tar -xf --help /etc/passwd",
804
+ toolName: "bash",
805
+ });
806
+ expect(result.riskLevel).toBe("medium");
807
+ });
808
+
809
+ test("known command without argSchema + --help uses base risk", async () => {
810
+ const result = await classifier.classify({
811
+ command: "kill --help",
812
+ toolName: "bash",
813
+ });
814
+ expect(result.riskLevel).toBe("high");
815
+ });
784
816
  });
785
817
 
786
818
  // ── Variable expansion escalation ────────────────────────────────────────────
@@ -923,30 +955,6 @@ describe("assistant subcommand classification", () => {
923
955
  expect(result.riskLevel).toBe("high");
924
956
  });
925
957
 
926
- test("assistant trust remove → high", async () => {
927
- const result = await classifier.classify({
928
- command: "assistant trust remove",
929
- toolName: "bash",
930
- });
931
- expect(result.riskLevel).toBe("high");
932
- });
933
-
934
- test("assistant trust add → high", async () => {
935
- const result = await classifier.classify({
936
- command: "assistant trust add",
937
- toolName: "bash",
938
- });
939
- expect(result.riskLevel).toBe("high");
940
- });
941
-
942
- test("assistant trust update → high", async () => {
943
- const result = await classifier.classify({
944
- command: "assistant trust update",
945
- toolName: "bash",
946
- });
947
- expect(result.riskLevel).toBe("high");
948
- });
949
-
950
958
  test("assistant config set → low", async () => {
951
959
  const result = await classifier.classify({
952
960
  command: "assistant config set key value",
@@ -189,14 +189,48 @@ const RM_BENIGN_FLAGS = new Set([
189
189
  ]);
190
190
 
191
191
  /**
192
- * Returns true when the command arguments include a top-level `--help` flag.
192
+ * Returns true when args contain a top-level help option that is not consumed
193
+ * as a value by another flag.
193
194
  *
194
- * This intentionally ignores any `--help` token that appears after `--`
195
- * because those are positional arguments, not options.
195
+ * This intentionally ignores:
196
+ * - any `--help` token that appears after `--` (positional mode), and
197
+ * - any `--help` token consumed as the value of a known value-taking flag.
198
+ *
199
+ * Help-mode shortcuts are only enabled for commands with an arg schema.
196
200
  */
197
- function hasHelpFlag(args: string[]): boolean {
198
- for (const arg of args) {
201
+ function hasStandaloneHelpFlag(args: string[], schema?: ArgSchema): boolean {
202
+ if (!schema) return false;
203
+
204
+ const valueFlags = new Set(schema.valueFlags ?? []);
205
+ for (let i = 0; i < args.length; i++) {
206
+ const arg = args[i]!;
199
207
  if (arg === "--") return false;
208
+
209
+ // Value-taking flags consume the next token (if present), so that token
210
+ // must not be interpreted as a standalone top-level option.
211
+ if (valueFlags.has(arg)) {
212
+ if (i + 1 < args.length) i++;
213
+ continue;
214
+ }
215
+
216
+ // Short-flag bundles: -xf where -f is a value flag. POSIX convention
217
+ // places the value-consuming flag last in a bundle, so check the final
218
+ // character. If it matches a known short value flag, the next token is
219
+ // its value and must be skipped.
220
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 2) {
221
+ const lastChar = arg[arg.length - 1];
222
+ if (lastChar && valueFlags.has(`-${lastChar}`)) {
223
+ if (i + 1 < args.length) i++;
224
+ continue;
225
+ }
226
+ }
227
+
228
+ if (arg.startsWith("-") && arg.includes("=")) {
229
+ const eqIdx = arg.indexOf("=");
230
+ const flagPart = arg.slice(0, eqIdx);
231
+ if (valueFlags.has(flagPart)) continue;
232
+ }
233
+
200
234
  if (arg === "--help" || arg.startsWith("--help=")) return true;
201
235
  }
202
236
  return false;
@@ -280,14 +314,6 @@ export function classifySegment(
280
314
  }
281
315
 
282
316
  if (!spec) {
283
- // Unknown command with --help is just viewing help → low risk
284
- if (hasHelpFlag(segment.args)) {
285
- return {
286
- risk: "low",
287
- reason: `${segment.program} help output`,
288
- matchType: "registry",
289
- };
290
- }
291
317
  return {
292
318
  risk: "unknown",
293
319
  reason: `Unknown command: ${segment.program}`,
@@ -299,7 +325,11 @@ export function classifySegment(
299
325
  // Commands WITH subcommands (e.g. `assistant`) skip this — their
300
326
  // subcommand resolution may assign elevated risk that --help must
301
327
  // not bypass.
302
- if (!spec.subcommands && !spec.isWrapper && hasHelpFlag(segment.args)) {
328
+ if (
329
+ !spec.subcommands &&
330
+ !spec.isWrapper &&
331
+ hasStandaloneHelpFlag(segment.args, spec.argSchema)
332
+ ) {
303
333
  return {
304
334
  risk: "low",
305
335
  reason: `${segment.program} help output`,
@@ -73,6 +73,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
73
73
  "channel-verification-sessions cancel",
74
74
  "channel-verification-sessions revoke",
75
75
  "clients",
76
+ "clients disconnect",
76
77
  "clients list",
77
78
  "completions",
78
79
  "config",
@@ -85,6 +86,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
85
86
  "contacts list",
86
87
  "contacts get",
87
88
  "contacts merge",
89
+ "contacts prompt",
88
90
  "contacts upsert",
89
91
  "contacts channels",
90
92
  "contacts channels update-status",
@@ -108,6 +110,8 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
108
110
  "credential-execution",
109
111
  "credential-execution grants",
110
112
  "credential-execution grants list",
113
+ "pending",
114
+ "pending list",
111
115
  "credential-execution grants revoke",
112
116
  "credential-execution audit",
113
117
  "credential-execution audit list",
@@ -118,6 +122,9 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
118
122
  "credentials delete",
119
123
  "credentials inspect",
120
124
  "credentials reveal",
125
+ "gateway",
126
+ "gateway logs",
127
+ "gateway logs tail",
121
128
  "image-generation",
122
129
  "image-generation generate",
123
130
  "inference",
@@ -212,9 +219,6 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
212
219
  "task queue run",
213
220
  "trust",
214
221
  "trust list",
215
- "trust add",
216
- "trust update",
217
- "trust remove",
218
222
  "tts",
219
223
  "tts synthesize",
220
224
  "ui",
@@ -336,22 +340,6 @@ const riskOverrides: AssistantRiskOverride[] = [
336
340
  risk: "high",
337
341
  reason: "Deletes API key material",
338
342
  },
339
- {
340
- path: "trust add",
341
- risk: "high",
342
- reason: "Creates trust rule that affects auto-approve behavior",
343
- },
344
- {
345
- path: "trust update",
346
- risk: "high",
347
- reason: "Modifies trust rule risk level or description",
348
- },
349
- {
350
- path: "trust remove",
351
- risk: "high",
352
- reason: "Removes trust rule",
353
- },
354
-
355
343
  // Destructive assistant-state operations
356
344
  {
357
345
  path: "backup restore",
@@ -390,6 +378,7 @@ const riskOverrides: AssistantRiskOverride[] = [
390
378
  { path: "channel-verification-sessions revoke", risk: "low" },
391
379
  { path: "config set", risk: "low" },
392
380
  { path: "contacts merge", risk: "medium" },
381
+ { path: "contacts prompt", risk: "medium" },
393
382
  { path: "contacts upsert", risk: "medium" },
394
383
  { path: "contacts channels update-status", risk: "medium" },
395
384
  { path: "contacts invites create", risk: "high" },
@@ -474,18 +474,6 @@ describe("command-registry", () => {
474
474
  test("assistant trust (bare) is low risk", () => {
475
475
  expect(trustSpec.baseRisk).toBe("low");
476
476
  });
477
-
478
- test("assistant trust remove is high risk", () => {
479
- expect(trustSpec.subcommands!.remove.baseRisk).toBe("high");
480
- });
481
-
482
- test("assistant trust add is high risk", () => {
483
- expect(trustSpec.subcommands!.add.baseRisk).toBe("high");
484
- });
485
-
486
- test("assistant trust update is high risk", () => {
487
- expect(trustSpec.subcommands!.update.baseRisk).toBe("high");
488
- });
489
477
  });
490
478
 
491
479
  // ── low-risk subcommands (no further subcommands) ────────────────────
@@ -545,9 +533,6 @@ describe("command-registry", () => {
545
533
  assistantSpec.subcommands!.trust.subcommands!,
546
534
  );
547
535
  expect(trustSubs).toContain("list");
548
- expect(trustSubs).toContain("add");
549
- expect(trustSubs).toContain("update");
550
- expect(trustSubs).toContain("remove");
551
536
  });
552
537
 
553
538
  test("covers expanded top-level assistant command groups", () => {
@@ -51,7 +51,7 @@ const BASH_TEST_CASES: Array<[string, RiskLevel]> = [
51
51
  ["command -v rm", RiskLevel.Low],
52
52
  ["command -V sudo", RiskLevel.Low],
53
53
  ["rm --help", RiskLevel.Low],
54
- ["mycustomtool --help", RiskLevel.Low],
54
+ ["mycustomtool --help", RiskLevel.Medium],
55
55
 
56
56
  // Medium risk
57
57
  ["git push origin main", RiskLevel.Medium],
@@ -70,8 +70,6 @@ const BASH_TEST_CASES: Array<[string, RiskLevel]> = [
70
70
  ["chgrp group file.txt", RiskLevel.High],
71
71
  ['eval "ls"', RiskLevel.High],
72
72
  ['bash -c "echo hi"', RiskLevel.High],
73
- ["assistant trust add", RiskLevel.High],
74
- ["assistant trust update", RiskLevel.High],
75
73
  ["sudo rm -rf /", RiskLevel.High],
76
74
  ["rm -rf /tmp/stuff", RiskLevel.High],
77
75
  ["rm -r directory", RiskLevel.High],
@@ -2,8 +2,13 @@ import {
2
2
  buildUpstreamUrl,
3
3
  createTimeoutController,
4
4
  } from "@vellumai/assistant-client";
5
+ import {
6
+ normalizePublicBaseUrl,
7
+ TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER,
8
+ } from "@vellumai/service-contracts/twilio-ingress";
5
9
 
6
10
  import type { ChannelId, InterfaceId } from "../channels/types.js";
11
+ import type { ConfigFileCache } from "../config-file-cache.js";
7
12
  import {
8
13
  mintIngressToken,
9
14
  mintRelayToken,
@@ -494,14 +499,46 @@ export type TwilioForwardResponse = {
494
499
  */
495
500
  const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
496
501
 
502
+ /**
503
+ * Resolve the public base URL as a WebSocket URL (`wss://…`).
504
+ *
505
+ * Sources (in priority order):
506
+ * 1. `VELAY_BASE_URL` — present on platform-hosted gateway sidecars.
507
+ * When Velay is the tunnel provider, Twilio WebSocket connections
508
+ * are routed through the Velay service URL.
509
+ * 2. `ingress.publicBaseUrl` from the config file — written by Velay
510
+ * on tunnel registration, or set manually for self-hosted.
511
+ *
512
+ * Returns `undefined` when no source provides a value — the placeholder
513
+ * will remain in TwiML and Twilio will fail to connect, which is the
514
+ * correct behavior since without a public URL the assistant is unreachable.
515
+ */
516
+ export function resolvePublicBaseWssUrl(
517
+ config: GatewayConfig,
518
+ configFile?: ConfigFileCache,
519
+ platformAssistantId?: string,
520
+ ): string | undefined {
521
+ if (config.velayBaseUrl && platformAssistantId) {
522
+ const withPath =
523
+ config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
524
+ const normalized = normalizePublicBaseUrl(withPath);
525
+ if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
526
+ }
527
+ const raw = configFile?.getString("ingress", "publicBaseUrl");
528
+ const normalized = normalizePublicBaseUrl(raw);
529
+ if (!normalized) return undefined;
530
+ return normalized.replace(/^http(s?)/, "ws$1");
531
+ }
532
+
497
533
  /**
498
534
  * Forward a validated Twilio voice webhook payload to the runtime.
499
535
  * The gateway sends the parsed form params as JSON; the runtime's internal
500
536
  * endpoint reconstructs what it needs.
501
537
  *
502
- * After receiving the TwiML response, the gateway replaces the relay token
503
- * placeholder with a freshly minted JWT so the daemon never needs the
504
- * signing key for voice webhook responses.
538
+ * After receiving the TwiML response, the gateway:
539
+ * 1. Replaces the relay token placeholder with a freshly minted JWT.
540
+ * 2. Replaces the public base URL placeholder with the real public
541
+ * WebSocket URL so the assistant never needs `ingress.publicBaseUrl`.
505
542
  *
506
543
  * For inbound calls, `assistantId` is resolved by the gateway from the "To"
507
544
  * phone number and forwarded so the runtime knows which assistant to bootstrap.
@@ -510,6 +547,7 @@ export async function forwardTwilioVoiceWebhook(
510
547
  config: GatewayConfig,
511
548
  params: Record<string, string>,
512
549
  originalUrl: string,
550
+ publicBaseWssUrl?: string,
513
551
  ): Promise<TwilioForwardResponse> {
514
552
  cbBeforeRequest();
515
553
 
@@ -547,6 +585,23 @@ export async function forwardTwilioVoiceWebhook(
547
585
  body = body.replaceAll(TWILIO_RELAY_TOKEN_PLACEHOLDER, mintRelayToken());
548
586
  }
549
587
 
588
+ // Replace the public base URL placeholder with the real WebSocket URL.
589
+ // The assistant emits wss://__VELLUM_PUBLIC_BASE_URL__/… so it never
590
+ // needs ingress.publicBaseUrl — the gateway owns URL resolution.
591
+ if (body.includes(TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER)) {
592
+ if (publicBaseWssUrl) {
593
+ body = body.replaceAll(
594
+ TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER,
595
+ publicBaseWssUrl.replace(/\/+$/, ""),
596
+ );
597
+ } else {
598
+ log.error(
599
+ "TwiML contains public URL placeholder but no public base URL is configured. " +
600
+ "Twilio will fail to connect. Set VELAY_BASE_URL or ingress.publicBaseUrl.",
601
+ );
602
+ }
603
+ }
604
+
550
605
  if (response.status >= 500) cbOnFailure();
551
606
  else cbOnSuccess();
552
607
  return { status: response.status, body, headers };