@vellumai/vellum-gateway 0.5.4 → 0.5.5

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/AGENTS.md CHANGED
@@ -31,6 +31,16 @@ All assistant API requests from clients, CLI, skills, and user-facing tooling **
31
31
 
32
32
  **SKILL.md gateway URL pattern:** For gateway control-plane writes/actions that are not exposed through a CLI read command, use `$INTERNAL_GATEWAY_BASE_URL` (injected by `bash` and `host_bash`). Do not hardcode `localhost`/ports in skill examples, and do not instruct users/agents to manually export the variable from Settings. For public ingress URLs (e.g. OAuth redirect URIs, webhook registration), use `assistant config get ingress.publicBaseUrl` or load the `public-ingress` skill — do not inject public URLs as environment variables.
33
33
 
34
+ ### Trust Management in Docker Mode
35
+
36
+ In Docker mode, the gateway is the sole owner of trust rule storage. Trust files (`trust.json`, `actor-token-signing-key`) live on the gateway security volume (`/gateway-security`), configured via `GATEWAY_SECURITY_DIR`. No other container has access to this volume.
37
+
38
+ The assistant reads and writes trust rules via the gateway's HTTP trust API instead of accessing the filesystem directly. This ensures the security boundary is enforced at the container level — even if the assistant container is compromised, it cannot tamper with trust rules without going through the gateway's API.
39
+
40
+ ### Credential Access in Docker Mode
41
+
42
+ In Docker mode, the gateway accesses stored credentials via the CES HTTP API (`CES_CREDENTIAL_URL`), authenticated with `CES_SERVICE_TOKEN`. The gateway does not have direct filesystem access to credential encryption keys (`keys.enc`, `store.key`), which reside on the CES security volume.
43
+
34
44
  ### Channel Identity Vocabulary
35
45
 
36
46
  Gateway inbound events use a channel-discriminated union model (`GatewayInboundEvent`) with explicit identity fields:
package/Dockerfile CHANGED
@@ -30,6 +30,8 @@ RUN groupadd --system --gid 1001 gateway && \
30
30
 
31
31
  COPY --from=builder --chown=gateway:gateway /app /app
32
32
 
33
+ RUN mkdir -p /gateway-security && chown gateway:gateway /gateway-security
34
+
33
35
  USER gateway
34
36
 
35
37
  EXPOSE 7830
package/bun.lock CHANGED
@@ -6,11 +6,14 @@
6
6
  "name": "vellum-gateway",
7
7
  "dependencies": {
8
8
  "file-type": "^21.3.0",
9
+ "minimatch": "^10.2.4",
9
10
  "pino": "^9.6.0",
10
11
  "pino-pretty": "^13.1.3",
12
+ "uuid": "^13.0.0",
11
13
  },
12
14
  "devDependencies": {
13
15
  "@types/bun": "^1.2.4",
16
+ "@types/uuid": "^11.0.0",
14
17
  "eslint": "^10.0.0",
15
18
  "knip": "^5.83.1",
16
19
  "prettier": "^3.8.1",
@@ -118,6 +121,8 @@
118
121
 
119
122
  "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
120
123
 
124
+ "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
125
+
121
126
  "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
122
127
 
123
128
  "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="],
@@ -262,7 +267,7 @@
262
267
 
263
268
  "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
264
269
 
265
- "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
270
+ "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
266
271
 
267
272
  "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
268
273
 
@@ -362,6 +367,8 @@
362
367
 
363
368
  "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
364
369
 
370
+ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
371
+
365
372
  "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
366
373
 
367
374
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -376,10 +383,14 @@
376
383
 
377
384
  "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
378
385
 
386
+ "@eslint/config-array/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
387
+
379
388
  "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
380
389
 
381
390
  "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
382
391
 
392
+ "eslint/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
393
+
383
394
  "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
384
395
 
385
396
  "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -23,11 +23,14 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "file-type": "^21.3.0",
26
+ "minimatch": "^10.2.4",
26
27
  "pino": "^9.6.0",
27
- "pino-pretty": "^13.1.3"
28
+ "pino-pretty": "^13.1.3",
29
+ "uuid": "^13.0.0"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@types/bun": "^1.2.4",
33
+ "@types/uuid": "^11.0.0",
31
34
  "eslint": "^10.0.0",
32
35
  "knip": "^5.83.1",
33
36
  "prettier": "^3.8.1",
@@ -2,7 +2,8 @@
2
2
  * JWT token service for the gateway's auth system.
3
3
  *
4
4
  * Mirrors the assistant's token-service but manages its own signing key
5
- * using the same loadOrCreateSigningKey pattern, reading from
5
+ * using the same loadOrCreateSigningKey pattern. When GATEWAY_SECURITY_DIR
6
+ * is set the key is read from that directory; otherwise falls back to
6
7
  * ~/.vellum/protected/actor-token-signing-key.
7
8
  */
8
9
 
@@ -32,6 +33,10 @@ const log = getLogger("auth-token-service");
32
33
  let signingKey: Buffer | null = null;
33
34
 
34
35
  function getSigningKeyPath(): string {
36
+ const securityDir = process.env.GATEWAY_SECURITY_DIR;
37
+ if (securityDir) {
38
+ return join(securityDir, "actor-token-signing-key");
39
+ }
35
40
  return join(getRootDir(), "protected", "actor-token-signing-key");
36
41
  }
37
42
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { getRootDir } from "./credential-reader.js";
11
+ import { getWorkspaceDir } from "./credential-reader.js";
12
12
 
13
13
  const DEFAULT_TTL_MS = 1000;
14
14
 
@@ -55,7 +55,7 @@ export class ConfigFileCache {
55
55
 
56
56
  constructor(opts?: { ttlMs?: number }) {
57
57
  this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
58
- this.configPath = join(getRootDir(), "workspace", "config.json");
58
+ this.configPath = join(getWorkspaceDir(), "config.json");
59
59
  }
60
60
 
61
61
  /** Read the config file if the cached snapshot is stale or force is set. */
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { getLogger, type LogFileConfig } from "./logger.js";
5
- import { getRootDir } from "./credential-reader.js";
5
+ import { getRootDir, getWorkspaceDir } from "./credential-reader.js";
6
6
 
7
7
  const log = getLogger("config");
8
8
 
@@ -43,7 +43,7 @@ type RoutingEntry = {
43
43
  */
44
44
  function readWorkspaceConfig(): Record<string, unknown> {
45
45
  try {
46
- const configPath = join(getRootDir(), "workspace", "config.json");
46
+ const configPath = join(getWorkspaceDir(), "config.json");
47
47
  if (!existsSync(configPath)) return {};
48
48
  const raw = readFileSync(configPath, "utf-8");
49
49
  const data = JSON.parse(raw);
@@ -54,6 +54,19 @@ function readWorkspaceConfig(): Record<string, unknown> {
54
54
  }
55
55
  }
56
56
 
57
+ /**
58
+ * Directory containing gateway security files (trust.json, actor-token-signing-key).
59
+ *
60
+ * In Docker, this is a dedicated volume mounted at /gateway-security via the
61
+ * GATEWAY_SECURITY_DIR env var. In local (non-Docker) mode, falls back to
62
+ * ~/.vellum/protected/ for backwards compatibility.
63
+ */
64
+ export function getGatewaySecurityDir(): string {
65
+ const override = process.env.GATEWAY_SECURITY_DIR?.trim();
66
+ if (override) return override;
67
+ return join(getRootDir(), "protected");
68
+ }
69
+
57
70
  function parseRoutingEntries(raw: unknown): RoutingEntry[] {
58
71
  if (!Array.isArray(raw)) return [];
59
72
  const entries: RoutingEntry[] = [];
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Read-only reader for the assistant's credential stores.
3
3
  *
4
- * Tries the keychain broker (UDS) first, then falls back to the
5
- * encrypted-at-rest file (~/.vellum/protected/keys.enc).
4
+ * Resolution order:
5
+ * 1. CES HTTP API (when CES_CREDENTIAL_URL is set — containerized mode)
6
+ * 2. Keychain broker (UDS — native macOS/Linux)
7
+ * 3. Encrypted-at-rest file (~/.vellum/protected/keys.enc)
6
8
  */
7
9
 
8
10
  import { createDecipheriv, pbkdf2Sync, randomUUID } from "node:crypto";
@@ -137,18 +139,25 @@ export function getRootDir(): string {
137
139
  );
138
140
  }
139
141
 
142
+ /**
143
+ * Returns the workspace root for user-facing state.
144
+ *
145
+ * When the WORKSPACE_DIR env var is set, returns that value (used in
146
+ * containerized deployments where the workspace is a separate volume).
147
+ * Otherwise falls back to ~/.vellum/workspace.
148
+ */
149
+ export function getWorkspaceDir(): string {
150
+ const override = process.env.WORKSPACE_DIR?.trim();
151
+ if (override) return override;
152
+ return join(getRootDir(), "workspace");
153
+ }
154
+
140
155
  export function getEncryptedStorePath(): string {
141
156
  return join(getRootDir(), "protected", "keys.enc");
142
157
  }
143
158
 
144
159
  export function getMetadataPath(): string {
145
- return join(
146
- getRootDir(),
147
- "workspace",
148
- "data",
149
- "credentials",
150
- "metadata.json",
151
- );
160
+ return join(getWorkspaceDir(), "data", "credentials", "metadata.json");
152
161
  }
153
162
 
154
163
  // ---------------------------------------------------------------------------
@@ -308,19 +317,94 @@ async function readBrokerCredential(
308
317
  }
309
318
 
310
319
  // ---------------------------------------------------------------------------
311
- // Public credential reader tries broker, then encrypted store
320
+ // CES HTTP credential reader (containerized mode)
321
+ // ---------------------------------------------------------------------------
322
+
323
+ const CES_HTTP_TIMEOUT_MS = 5_000;
324
+
325
+ /**
326
+ * Try to read a credential from the CES managed service over HTTP.
327
+ *
328
+ * Activated when `CES_CREDENTIAL_URL` is set (e.g. `http://ces-host:8090`).
329
+ * Requires `CES_SERVICE_TOKEN` for bearer auth.
330
+ *
331
+ * Returns `undefined` if the env vars are not set, the CES is unreachable,
332
+ * or the credential doesn't exist (404).
333
+ */
334
+ async function readCesCredential(
335
+ account: string,
336
+ ): Promise<string | undefined> {
337
+ const baseUrl = process.env.CES_CREDENTIAL_URL?.trim();
338
+ if (!baseUrl) return undefined;
339
+
340
+ const serviceToken = process.env.CES_SERVICE_TOKEN?.trim();
341
+ if (!serviceToken) {
342
+ log.warn("CES_CREDENTIAL_URL is set but CES_SERVICE_TOKEN is missing");
343
+ return undefined;
344
+ }
345
+
346
+ const url = `${baseUrl}/v1/credentials/${encodeURIComponent(account)}`;
347
+
348
+ try {
349
+ const controller = new AbortController();
350
+ const timer = setTimeout(() => controller.abort(), CES_HTTP_TIMEOUT_MS);
351
+
352
+ const resp = await fetch(url, {
353
+ method: "GET",
354
+ headers: {
355
+ Authorization: `Bearer ${serviceToken}`,
356
+ Accept: "application/json",
357
+ },
358
+ signal: controller.signal,
359
+ });
360
+
361
+ clearTimeout(timer);
362
+
363
+ if (resp.status === 404) return undefined;
364
+
365
+ if (!resp.ok) {
366
+ log.warn(
367
+ { account, status: resp.status },
368
+ "CES credential read returned non-OK status",
369
+ );
370
+ return undefined;
371
+ }
372
+
373
+ const body = (await resp.json()) as { account?: string; value?: string };
374
+ if (typeof body.value === "string") return body.value;
375
+
376
+ log.debug({ account }, "CES credential response missing 'value' field");
377
+ return undefined;
378
+ } catch (err) {
379
+ log.debug({ err, account }, "Failed to read credential from CES");
380
+ return undefined;
381
+ }
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Public credential reader — tries CES, broker, then encrypted store
312
386
  // ---------------------------------------------------------------------------
313
387
 
314
388
  /**
315
389
  * Read a single credential by account key.
316
- * Tries the keychain broker first (when available), then falls back
317
- * to the encrypted-at-rest store.
390
+ *
391
+ * Resolution order:
392
+ * 1. CES HTTP API (when CES_CREDENTIAL_URL is set)
393
+ * 2. Keychain broker (UDS)
394
+ * 3. Encrypted-at-rest store (keys.enc)
318
395
  */
319
396
  export async function readCredential(
320
397
  account: string,
321
398
  ): Promise<string | undefined> {
399
+ // CES HTTP backend (containerized mode)
400
+ const cesValue = await readCesCredential(account);
401
+ if (cesValue !== undefined) return cesValue;
402
+
403
+ // Keychain broker (native mode)
322
404
  const brokerValue = await readBrokerCredential(account);
323
405
  if (brokerValue !== undefined) return brokerValue;
406
+
407
+ // Encrypted file fallback
324
408
  return readEncryptedCredential(account);
325
409
  }
326
410
 
@@ -288,6 +288,14 @@
288
288
  "label": "Inline Skill Command Expansion",
289
289
  "description": "Enable secure inline skill command expansion via !`command` syntax, with version-pinned approval and sandboxed execution at skill load time",
290
290
  "defaultEnabled": true
291
+ },
292
+ {
293
+ "id": "channel-voice-transcription",
294
+ "scope": "assistant",
295
+ "key": "feature_flags.channel-voice-transcription.enabled",
296
+ "label": "Channel Voice Transcription",
297
+ "description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
298
+ "defaultEnabled": true
291
299
  }
292
300
  ]
293
301
  }
@@ -150,7 +150,8 @@ export function createChannelVerificationSessionProxyHandler(
150
150
  req: Request,
151
151
  clientIp?: string,
152
152
  ): Promise<Response> {
153
- const lockPath = join(getRootDir(), "guardian-init.lock");
153
+ const lockDir = process.env.GATEWAY_SECURITY_DIR || getRootDir();
154
+ const lockPath = join(lockDir, "guardian-init.lock");
154
155
  if (existsSync(lockPath) || guardianInitInFlight) {
155
156
  log.warn("Guardian init rejected — already bootstrapped");
156
157
  return Response.json(