@vellumai/vellum-gateway 0.7.2 → 0.8.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.
Files changed (49) hide show
  1. package/ARCHITECTURE.md +20 -21
  2. package/Dockerfile +2 -1
  3. package/README.md +6 -6
  4. package/bun.lock +8 -1
  5. package/knip.json +1 -0
  6. package/package.json +2 -1
  7. package/src/__tests__/config-file-watcher.test.ts +1 -1
  8. package/src/__tests__/contact-prompt-submit.test.ts +349 -0
  9. package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
  10. package/src/__tests__/ipc-route-policy.test.ts +43 -0
  11. package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
  12. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
  13. package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
  14. package/src/__tests__/slack-display-name.test.ts +6 -2
  15. package/src/__tests__/slack-normalize.test.ts +36 -56
  16. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
  17. package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
  18. package/src/__tests__/twilio-webhooks.test.ts +2 -6
  19. package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
  20. package/src/auth/guardian-bootstrap.ts +49 -0
  21. package/src/auth/ipc-route-policy.ts +24 -0
  22. package/src/db/contact-store.ts +27 -1
  23. package/src/email/register-callback.test.ts +4 -4
  24. package/src/email/register-callback.ts +12 -16
  25. package/src/feature-flag-registry.json +13 -5
  26. package/src/handlers/handle-inbound.ts +1 -0
  27. package/src/http/routes/contact-prompt.ts +134 -23
  28. package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
  29. package/src/http/routes/ipc-runtime-proxy.ts +18 -0
  30. package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
  31. package/src/http/routes/twilio-voice-webhook.ts +53 -0
  32. package/src/index.ts +11 -2
  33. package/src/ipc/server.ts +113 -46
  34. package/src/ipc/velay-handlers.ts +31 -0
  35. package/src/remote-feature-flag-sync.ts +10 -8
  36. package/src/risk/bash-risk-classifier.test.ts +82 -0
  37. package/src/risk/bash-risk-classifier.ts +19 -15
  38. package/src/risk/command-registry/commands/assistant.ts +1 -0
  39. package/src/risk/shell-parser.test.ts +159 -0
  40. package/src/risk/shell-parser.ts +150 -19
  41. package/src/risk/skill-risk-classifier.ts +12 -3
  42. package/src/runtime/client.ts +14 -12
  43. package/src/slack/normalize.test.ts +3 -3
  44. package/src/slack/normalize.ts +6 -69
  45. package/src/slack/socket-mode.ts +1 -5
  46. package/src/telegram/webhook-manager.ts +9 -13
  47. package/src/velay/client.ts +27 -16
  48. package/src/verification/contact-helpers.ts +6 -3
  49. package/src/verification/voice-approval-sync.ts +107 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Lint test: every daemon route whose HTTP-side policy is gateway-only
3
+ * MUST have a matching IPC policy entry, with matching required scopes.
4
+ *
5
+ * Background: the gateway's IPC proxy default-allows operationIds that
6
+ * have no policy entry. Routes restricted to the `svc_gateway` principal
7
+ * on the daemon HTTP path must also be locked down on IPC — otherwise an
8
+ * authenticated edge JWT can reach them by setting
9
+ * `X-Vellum-Proxy-Server: ipc`, bypassing the daemon HTTP router entirely.
10
+ *
11
+ * Symmetrically, the IPC entry's `requiredScopes` must match the daemon's
12
+ * `requiredScopes`. If IPC permits a broader scope than the daemon HTTP
13
+ * path requires, the IPC path is more permissive than the HTTP path —
14
+ * the same scope-bypass class this guard is designed to prevent.
15
+ *
16
+ * This bug class has bitten us multiple times:
17
+ * - PR #29571 (MCP OAuth routes — Codex finding)
18
+ * - PR #29612 (OAuth connect routes — Codex finding)
19
+ *
20
+ * Rather than rely on Codex catching it a third time, this test walks
21
+ * the daemon route source files and the daemon route-policy source file
22
+ * at test time and asserts every gateway-only operationId is registered
23
+ * in the IPC policy table with matching scopes and principals.
24
+ *
25
+ * Implementation notes:
26
+ * - Uses text parsing rather than direct imports because the gateway
27
+ * and assistant packages don't share source-level imports (they
28
+ * communicate through the `@vellumai/service-contracts` package).
29
+ * - Regexes are intentionally loose. False positives (matching too
30
+ * much) only result in extra coverage; false negatives (missing
31
+ * real gateway-only routes) defeat the lint.
32
+ * - Daemon route endpoints may include parameter segments
33
+ * (e.g. `internal/oauth/connect/status/:state`) while the
34
+ * daemon's route-policy keys drop those segments
35
+ * (e.g. `internal/oauth/connect/status`). We normalize by
36
+ * stripping `/:param` segments before matching so parameterized
37
+ * gateway-only routes are not silently excluded.
38
+ */
39
+
40
+ import { describe, expect, test } from "bun:test";
41
+ import { readdirSync, readFileSync, statSync } from "node:fs";
42
+ import { dirname, join } from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+
45
+ import { getIpcRoutePolicy } from "../auth/ipc-route-policy.js";
46
+
47
+ const __dirname = dirname(fileURLToPath(import.meta.url));
48
+
49
+ // gateway/src/__tests__ → repo root → assistant/...
50
+ const ASSISTANT_SRC = join(
51
+ __dirname,
52
+ "..",
53
+ "..",
54
+ "..",
55
+ "assistant",
56
+ "src",
57
+ );
58
+ const ROUTES_DIR = join(ASSISTANT_SRC, "runtime", "routes");
59
+ const ROUTE_POLICY_FILE = join(
60
+ ASSISTANT_SRC,
61
+ "runtime",
62
+ "auth",
63
+ "route-policy.ts",
64
+ );
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Step 1 — Collect every (operationId, endpoint) pair from daemon routes.
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface RoutePair {
71
+ operationId: string;
72
+ endpoint: string;
73
+ sourceFile: string;
74
+ }
75
+
76
+ function collectRouteSourceFiles(dir: string): string[] {
77
+ const out: string[] = [];
78
+ for (const entry of readdirSync(dir)) {
79
+ const full = join(dir, entry);
80
+ const st = statSync(full);
81
+ if (st.isDirectory()) {
82
+ if (entry === "__tests__") continue;
83
+ out.push(...collectRouteSourceFiles(full));
84
+ continue;
85
+ }
86
+ if (!entry.endsWith(".ts")) continue;
87
+ if (entry.endsWith(".test.ts")) continue;
88
+ out.push(full);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * For each `operationId: "..."` literal, find the closest `endpoint: "..."`
95
+ * literal within a 600-character window. The codebase's style writes both
96
+ * fields near the top of each route definition, so 600 chars comfortably
97
+ * covers the longest route block.
98
+ */
99
+ function extractRoutePairs(source: string, sourceFile: string): RoutePair[] {
100
+ const pairs: RoutePair[] = [];
101
+ const opRegex = /operationId:\s*["']([^"']+)["']/g;
102
+ for (const m of source.matchAll(opRegex)) {
103
+ const operationId = m[1]!;
104
+ const start = m.index!;
105
+ const end = Math.min(start + 600, source.length);
106
+ const window = source.slice(start, end);
107
+ const epMatch = window.match(/endpoint:\s*["']([^"']+)["']/);
108
+ if (epMatch) {
109
+ pairs.push({ operationId, endpoint: epMatch[1]!, sourceFile });
110
+ }
111
+ }
112
+ return pairs;
113
+ }
114
+
115
+ function collectAllRoutePairs(): RoutePair[] {
116
+ const out: RoutePair[] = [];
117
+ for (const file of collectRouteSourceFiles(ROUTES_DIR)) {
118
+ out.push(...extractRoutePairs(readFileSync(file, "utf-8"), file));
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Strip `/:param` segments so a route's `endpoint` matches the policy
125
+ * key registered in route-policy.ts. The daemon's HTTP router uses the
126
+ * non-parameterized form as the canonical policy key.
127
+ *
128
+ * Examples:
129
+ * "internal/oauth/connect/status/:state" → "internal/oauth/connect/status"
130
+ * "internal/mcp/auth/status/:serverId" → "internal/mcp/auth/status"
131
+ * "profiler/runs/:runId" → "profiler/runs"
132
+ */
133
+ function normalizeEndpoint(endpoint: string): string {
134
+ return endpoint.replace(/\/:[^/]+/g, "");
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Step 2 — Extract gateway-only endpoints (with required scopes) from
139
+ // daemon's route-policy.ts.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Parse the daemon's route-policy.ts source to find every endpoint
144
+ * registered with `allowedPrincipalTypes: ["svc_gateway"]`. For each,
145
+ * record the `requiredScopes` array so the IPC policy can be cross-checked
146
+ * for scope parity (not just principal parity).
147
+ *
148
+ * Two patterns are supported:
149
+ * 1. Direct: `registerPolicy("endpoint", { requiredScopes: [...], ["svc_gateway"] ... })`
150
+ * 2. Loop: `const X_ENDPOINTS = ["a", "b", ...]; for (const e of X_ENDPOINTS) { registerPolicy(e, { requiredScopes: [...], ["svc_gateway"] ... }) }`
151
+ *
152
+ * Pattern 2 is detected heuristically: when a `const ARRAY = [...]` is
153
+ * followed by a `for...of ARRAY` containing `registerPolicy(...)` and
154
+ * `["svc_gateway"]`, every string in the array is treated as gateway-only
155
+ * and shares the loop body's `requiredScopes`.
156
+ */
157
+ function extractScopes(block: string): string[] | null {
158
+ const m = block.match(/requiredScopes:\s*\[([^\]]*)\]/);
159
+ if (!m) return null;
160
+ const scopes: string[] = [];
161
+ for (const lit of m[1]!.matchAll(/["']([^"']+)["']/g)) {
162
+ scopes.push(lit[1]!);
163
+ }
164
+ return scopes;
165
+ }
166
+
167
+ interface GatewayOnlyEntry {
168
+ requiredScopes: string[];
169
+ }
170
+
171
+ function extractGatewayOnlyEndpoints(): Map<string, GatewayOnlyEntry> {
172
+ const text = readFileSync(ROUTE_POLICY_FILE, "utf-8");
173
+ const out = new Map<string, GatewayOnlyEntry>();
174
+
175
+ // Pattern 1: explicit registerPolicy calls.
176
+ //
177
+ // Split the file into individual `registerPolicy(...)` blocks first
178
+ // (using a non-greedy match up to the next `});`) so the multi-line
179
+ // [\s\S]*? alternation can't accidentally span multiple registrations
180
+ // and pick up a "svc_gateway"-only array from a different policy.
181
+ const blockRegex =
182
+ /registerPolicy\(\s*["']([^"']+)["']\s*,\s*\{[\s\S]*?\}\s*\)\s*;/g;
183
+ for (const m of text.matchAll(blockRegex)) {
184
+ const endpoint = m[1]!;
185
+ const block = m[0]!;
186
+ // Within this single registerPolicy block, require allowedPrincipalTypes
187
+ // to be EXACTLY ["svc_gateway"] — no other principals.
188
+ if (
189
+ !/allowedPrincipalTypes:\s*\[\s*["']svc_gateway["']\s*\]/.test(block)
190
+ )
191
+ continue;
192
+ const scopes = extractScopes(block);
193
+ if (!scopes) continue;
194
+ out.set(endpoint, { requiredScopes: scopes });
195
+ }
196
+
197
+ // Pattern 2: const ARRAY = [...] followed by a for-of loop that
198
+ // registers svc_gateway-only policies for each element. Detected
199
+ // heuristically: when a `const ARRAY = [...]` is followed somewhere
200
+ // in the file by a for-of loop over that array containing both a
201
+ // `registerPolicy(` and a literal `["svc_gateway"]`, every string in
202
+ // the array is treated as gateway-only and shares the loop body's
203
+ // `requiredScopes`.
204
+ const arrayDeclRegex =
205
+ /const\s+([A-Z_][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)\]\s*;/g;
206
+ for (const m of text.matchAll(arrayDeclRegex)) {
207
+ const arrayName = m[1]!;
208
+ const arrayBody = m[2]!;
209
+ // Find a for-of loop over this array. Use a non-greedy body match
210
+ // that stops at the closing `}` of the for-block.
211
+ const loopBlockRegex = new RegExp(
212
+ String.raw`for\s*\(\s*const\s+\w+\s+of\s+` +
213
+ arrayName +
214
+ String.raw`\s*\)\s*\{[\s\S]*?\}`,
215
+ );
216
+ const loopMatch = text.match(loopBlockRegex);
217
+ if (!loopMatch) continue;
218
+ const loopBody = loopMatch[0];
219
+ if (!loopBody.includes("registerPolicy")) continue;
220
+ if (!/\[\s*["']svc_gateway["']\s*\]/.test(loopBody)) continue;
221
+ const scopes = extractScopes(loopBody);
222
+ if (!scopes) continue;
223
+ // Extract every string literal from the array body.
224
+ for (const lit of arrayBody.matchAll(/["']([^"']+)["']/g)) {
225
+ out.set(lit[1]!, { requiredScopes: scopes });
226
+ }
227
+ }
228
+
229
+ return out;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Step 3 — Cross-reference and assert.
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe("ipc-route-policy: gateway-only coverage lint", () => {
237
+ const gatewayOnlyEndpoints = extractGatewayOnlyEndpoints();
238
+ const routePairs = collectAllRoutePairs();
239
+
240
+ // Build the gateway-only operationId set by intersecting
241
+ // (normalized routes) ∩ (policy keys). Preserve the daemon's
242
+ // requiredScopes so the IPC policy can be checked for scope parity.
243
+ const gatewayOnlyRoutes = routePairs
244
+ .map((r) => {
245
+ const normalized = normalizeEndpoint(r.endpoint);
246
+ const entry = gatewayOnlyEndpoints.get(normalized);
247
+ if (!entry) return null;
248
+ return { ...r, normalizedEndpoint: normalized, daemonScopes: entry.requiredScopes };
249
+ })
250
+ .filter(
251
+ (r): r is RoutePair & { normalizedEndpoint: string; daemonScopes: string[] } =>
252
+ r !== null,
253
+ );
254
+
255
+ test("discovery sanity: found gateway-only daemon routes", () => {
256
+ // If the discovery returns zero, we'd silently pass every check
257
+ // below. Fail loud instead.
258
+ expect(gatewayOnlyEndpoints.size).toBeGreaterThan(0);
259
+ expect(gatewayOnlyRoutes.length).toBeGreaterThan(0);
260
+ });
261
+
262
+ // One test case per gateway-only route so the failure message points
263
+ // directly at the specific operationId that's missing coverage.
264
+ for (const route of gatewayOnlyRoutes) {
265
+ const relPath = route.sourceFile.split("/assistant/src/")[1] ?? route.sourceFile;
266
+ test(`${route.operationId} (endpoint=${route.endpoint}) has an IPC policy entry`, () => {
267
+ const policy = getIpcRoutePolicy(route.operationId);
268
+ expect(
269
+ policy,
270
+ `${route.operationId} is registered as a gateway-only daemon ` +
271
+ `route (endpoint=${route.endpoint}, defined in assistant/src/${relPath}) ` +
272
+ `but is missing from gateway/src/auth/ipc-route-policy.ts. ` +
273
+ `Add an entry: ` +
274
+ `["${route.operationId}", ${JSON.stringify(route.daemonScopes)}, ["svc_gateway"]] ` +
275
+ `to match the daemon HTTP policy.`,
276
+ ).toBeDefined();
277
+ expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
278
+ // Scope parity: IPC requiredScopes must match daemon requiredScopes
279
+ // exactly (as a set). Otherwise the IPC path could be reached with
280
+ // a broader/different scope than the daemon HTTP path requires,
281
+ // recreating the scope-bypass class this lint exists to prevent.
282
+ // Compare as plain string[] — Scope is a string union, but the daemon
283
+ // scopes come from text-parsed source so they're already string[].
284
+ const ipcScopes: string[] = [...policy!.requiredScopes].sort();
285
+ const daemonScopes: string[] = [...route.daemonScopes].sort();
286
+ expect(
287
+ ipcScopes,
288
+ `${route.operationId} has IPC requiredScopes=${JSON.stringify(ipcScopes)} ` +
289
+ `but daemon HTTP requires ${JSON.stringify(daemonScopes)}. ` +
290
+ `Scope mismatch makes the IPC path more permissive than the HTTP ` +
291
+ `path, recreating the scope-bypass class this lint prevents. ` +
292
+ `Update the entry in gateway/src/auth/ipc-route-policy.ts to use ` +
293
+ `${JSON.stringify(daemonScopes)}.`,
294
+ ).toEqual(daemonScopes);
295
+ });
296
+ }
297
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { getIpcRoutePolicy } from "../auth/ipc-route-policy.js";
4
+
5
+ describe("ipc-route-policy: gateway-only daemon routes", () => {
6
+ // The gateway IPC proxy default-allows operationIds with no policy entry.
7
+ // Routes that the daemon's HTTP route policy marks as gateway-only
8
+ // (internal.write + svc_gateway) MUST also have a matching IPC policy
9
+ // entry — otherwise an authenticated edge JWT can reach them by setting
10
+ // X-Vellum-Proxy-Server: ipc, bypassing the daemon HTTP router entirely.
11
+ test.each([
12
+ "admin_rollbackmigrations_post",
13
+ "emit_event",
14
+ "internal_mcp_auth_start",
15
+ "internal_mcp_auth_status",
16
+ "internal_mcp_reload",
17
+ "internal_oauth_callback",
18
+ "internal_oauth_connect_start",
19
+ "internal_oauth_connect_status",
20
+ "internal_twilio_connect_action",
21
+ "internal_twilio_status",
22
+ "internal_twilio_voice_webhook",
23
+ "profiler_runs_get",
24
+ "profiler_runs_by_runId_delete",
25
+ "profiler_runs_by_runId_export_post",
26
+ "profiler_runs_by_runId_get",
27
+ "upgrade_broadcast",
28
+ "workspace_commit",
29
+ ])("%s requires internal.write and svc_gateway", (operationId) => {
30
+ const policy = getIpcRoutePolicy(operationId);
31
+ expect(policy).toBeDefined();
32
+ expect(policy!.requiredScopes).toEqual(["internal.write"]);
33
+ expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
34
+ });
35
+
36
+ // channels/inbound uses ingress.write rather than internal.write.
37
+ test("channel_inbound requires ingress.write and svc_gateway", () => {
38
+ const policy = getIpcRoutePolicy("channel_inbound");
39
+ expect(policy).toBeDefined();
40
+ expect(policy!.requiredScopes).toEqual(["ingress.write"]);
41
+ expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
42
+ });
43
+ });
@@ -0,0 +1,189 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
4
+ import { createConnection, type Socket } from "node:net";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ import "./test-preload.js";
9
+
10
+ import { GatewayIpcServer, type IpcRoute } from "../ipc/server.js";
11
+
12
+ // Integration tests for GatewayIpcServer's watchdog wiring. The watchdog's
13
+ // own unit tests (race guards, timer error handling, etc.) live in
14
+ // `@vellumai/ipc-server-utils`. These tests verify that the gateway server
15
+ // correctly wires the watchdog into its own lifecycle and legacy-server
16
+ // bookkeeping.
17
+
18
+ // macOS caps Unix socket paths at sizeof(sun_path)-1 == 103 chars, so the
19
+ // shared test-preload temp dir is too long. Mint our own short path under
20
+ // the system tmpdir for this test.
21
+ const shortRoot = mkdtempSync(join(tmpdir(), "vmw-"));
22
+ const socketPath = join(shortRoot, "g.sock");
23
+
24
+ afterAll(() => {
25
+ try {
26
+ rmSync(shortRoot, { recursive: true, force: true });
27
+ } catch {
28
+ // best-effort
29
+ }
30
+ });
31
+
32
+ function connectClient(path: string): Promise<Socket> {
33
+ return new Promise<Socket>((resolve, reject) => {
34
+ const client: Socket = createConnection(path, () => resolve(client));
35
+ client.on("error", reject);
36
+ });
37
+ }
38
+
39
+ function sendRequest(
40
+ client: Socket,
41
+ method: string,
42
+ params?: Record<string, unknown>,
43
+ ): Promise<{ id: string; result?: unknown; error?: string }> {
44
+ return new Promise((resolve, reject) => {
45
+ const id = randomBytes(4).toString("hex");
46
+ let buffer = "";
47
+
48
+ const onData = (chunk: Buffer) => {
49
+ buffer += chunk.toString();
50
+ const newlineIdx = buffer.indexOf("\n");
51
+ if (newlineIdx !== -1) {
52
+ const line = buffer.slice(0, newlineIdx).trim();
53
+ buffer = buffer.slice(newlineIdx + 1);
54
+ client.off("data", onData);
55
+ try {
56
+ resolve(JSON.parse(line));
57
+ } catch (err) {
58
+ reject(err);
59
+ }
60
+ }
61
+ };
62
+
63
+ client.on("data", onData);
64
+ client.write(JSON.stringify({ id, method, params }) + "\n");
65
+ });
66
+ }
67
+
68
+ const echoRoute: IpcRoute = {
69
+ method: "echo",
70
+ handler: (params) => ({ echoed: params?.value ?? null }),
71
+ };
72
+
73
+ /**
74
+ * Build a server with the test-owned short socket path. The constructor
75
+ * resolves the path via env-var defaults that may not point at our temp
76
+ * dir, so we override the private `socketPath` field directly — same
77
+ * pattern used by `ipc-server-multi-client.test.ts`.
78
+ *
79
+ * Note: the watchdog is constructed in the GatewayIpcServer constructor
80
+ * and captures the original (unmocked) socketPath via closure. Tests that
81
+ * exercise the watchdog must therefore disable the timer-driven path and
82
+ * use the public `rebindIfMissing()` entry point, which reads
83
+ * `this.socketPath` lazily through the watchdog's `socketPath` capture —
84
+ * which we also need to monkeypatch. See {@link buildServer}.
85
+ */
86
+ function buildServer(opts: { watchdogIntervalMs: number }): GatewayIpcServer {
87
+ const server = new GatewayIpcServer([echoRoute], {
88
+ watchdogIntervalMs: opts.watchdogIntervalMs,
89
+ });
90
+ // The watchdog captures socketPath in its constructor, so override both
91
+ // the public field (for start()/stop()) and the watchdog's private copy.
92
+ (server as unknown as { socketPath: string }).socketPath = socketPath;
93
+ const watchdog = (server as unknown as { watchdog: { socketPath: string } })
94
+ .watchdog;
95
+ watchdog.socketPath = socketPath;
96
+ return server;
97
+ }
98
+
99
+ async function waitForListening(path: string, timeoutMs = 1000): Promise<void> {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (!existsSync(path) && Date.now() < deadline) {
102
+ await new Promise((r) => setTimeout(r, 5));
103
+ }
104
+ if (!existsSync(path)) {
105
+ throw new Error(`server did not bind ${path} within ${timeoutMs}ms`);
106
+ }
107
+ }
108
+
109
+ describe("GatewayIpcServer watchdog wiring", () => {
110
+ let server: GatewayIpcServer | undefined;
111
+ const sockets: Socket[] = [];
112
+
113
+ beforeEach(() => {
114
+ server = undefined;
115
+ });
116
+
117
+ afterEach(() => {
118
+ for (const s of sockets) {
119
+ if (!s.destroyed) s.destroy();
120
+ }
121
+ sockets.length = 0;
122
+
123
+ if (server) {
124
+ server.stop();
125
+ server = undefined;
126
+ }
127
+
128
+ if (existsSync(socketPath)) {
129
+ try {
130
+ unlinkSync(socketPath);
131
+ } catch {
132
+ // ignore
133
+ }
134
+ }
135
+ });
136
+
137
+ test("rebindIfMissing restores the listener and accepts new clients end-to-end", async () => {
138
+ server = buildServer({ watchdogIntervalMs: 0 });
139
+ server.start();
140
+ await waitForListening(socketPath);
141
+
142
+ // A baseline client confirms the initial listener is healthy.
143
+ const baseline = await connectClient(socketPath);
144
+ sockets.push(baseline);
145
+ const baselineEcho = await sendRequest(baseline, "echo", { value: "pre" });
146
+ expect(baselineEcho.result).toEqual({ echoed: "pre" });
147
+
148
+ // Simulate the cleanup that wipes /run/* — unlink the socket file
149
+ // while the listening fd is still alive in the kernel.
150
+ unlinkSync(socketPath);
151
+ expect(existsSync(socketPath)).toBe(false);
152
+
153
+ const rebound = await server.rebindIfMissing();
154
+ expect(rebound).toBe(true);
155
+ expect(existsSync(socketPath)).toBe(true);
156
+
157
+ // A new client can connect to the re-bound listener and exercise the
158
+ // route table — proving onRebind correctly installed the new server
159
+ // as the primary.
160
+ const fresh = await connectClient(socketPath);
161
+ sockets.push(fresh);
162
+ const freshEcho = await sendRequest(fresh, "echo", { value: "post" });
163
+ expect(freshEcho.result).toEqual({ echoed: "post" });
164
+
165
+ // The pre-existing client survives the rebind because its connected
166
+ // socket inode lives independently of the listener path.
167
+ expect(baseline.destroyed).toBe(false);
168
+ });
169
+
170
+ test("stop() halts the watchdog so a later unlink does not resurrect the listener", async () => {
171
+ server = buildServer({ watchdogIntervalMs: 10 });
172
+ server.start();
173
+ await waitForListening(socketPath);
174
+
175
+ server.stop();
176
+ expect(existsSync(socketPath)).toBe(false);
177
+
178
+ // Even if something recreated and removed the path again, the watchdog
179
+ // has been stopped and rebindIfMissing returns false because the
180
+ // server reference was nulled.
181
+ const rebound = await server.rebindIfMissing();
182
+ expect(rebound).toBe(false);
183
+ expect(existsSync(socketPath)).toBe(false);
184
+
185
+ // Wait past several timer ticks to confirm no background rebind fires.
186
+ await new Promise((r) => setTimeout(r, 50));
187
+ expect(existsSync(socketPath)).toBe(false);
188
+ });
189
+ });
@@ -499,3 +499,53 @@ describe("graceful fallback when cache not initialized", () => {
499
499
  expect(result.matchType).toBe("registry");
500
500
  });
501
501
  });
502
+
503
+ describe("SkillLoadRiskClassifier inline command risk elevation", () => {
504
+ test("skill with inline expansions is classified as medium risk", async () => {
505
+ const classifier = new SkillLoadRiskClassifier();
506
+ const result = await classifier.classify({
507
+ toolName: "skill_load",
508
+ skillSelector: "my-skill",
509
+ resolvedMetadata: {
510
+ skillId: "my-skill",
511
+ selector: "my-skill",
512
+ versionHash: "abc123",
513
+ transitiveHash: "def456",
514
+ hasInlineExpansions: true,
515
+ isDynamic: true,
516
+ },
517
+ });
518
+
519
+ expect(result.riskLevel).toBe("medium");
520
+ expect(result.reason).toContain("inline command expansions");
521
+ });
522
+
523
+ test("skill without inline expansions is classified as low risk", async () => {
524
+ const classifier = new SkillLoadRiskClassifier();
525
+ const result = await classifier.classify({
526
+ toolName: "skill_load",
527
+ skillSelector: "plain-skill",
528
+ resolvedMetadata: {
529
+ skillId: "plain-skill",
530
+ selector: "plain-skill",
531
+ versionHash: "abc123",
532
+ transitiveHash: undefined,
533
+ hasInlineExpansions: false,
534
+ isDynamic: false,
535
+ },
536
+ });
537
+
538
+ expect(result.riskLevel).toBe("low");
539
+ expect(result.reason).toBe("Skill load (default)");
540
+ });
541
+
542
+ test("skill_load with no resolved metadata defaults to low risk", async () => {
543
+ const classifier = new SkillLoadRiskClassifier();
544
+ const result = await classifier.classify({
545
+ toolName: "skill_load",
546
+ skillSelector: "unknown-skill",
547
+ });
548
+
549
+ expect(result.riskLevel).toBe("low");
550
+ });
551
+ });
@@ -116,13 +116,13 @@ function defaultCredentials(): Record<string, string> {
116
116
  // Setup / teardown
117
117
  // ---------------------------------------------------------------------------
118
118
  const savedVellumPlatformUrl = process.env.VELLUM_PLATFORM_URL;
119
- const savedPlatformInternalApiKey = process.env.PLATFORM_INTERNAL_API_KEY;
119
+ const savedAssistantCredential = process.env.ASSISTANT_API_KEY;
120
120
 
121
121
  beforeEach(() => {
122
122
  // Clear env vars that the production code falls back to, so tests remain
123
123
  // deterministic unless they explicitly set them.
124
124
  delete process.env.VELLUM_PLATFORM_URL;
125
- delete process.env.PLATFORM_INTERNAL_API_KEY;
125
+ delete process.env.ASSISTANT_API_KEY;
126
126
  mkdirSync(protectedDir, { recursive: true });
127
127
  // Write the test registry and point resolution at it
128
128
  writeFileSync(testRegistryPath, JSON.stringify(TEST_REGISTRY, null, 2));
@@ -142,7 +142,7 @@ afterEach(() => {
142
142
  }
143
143
  };
144
144
  restoreEnv("VELLUM_PLATFORM_URL", savedVellumPlatformUrl);
145
- restoreEnv("PLATFORM_INTERNAL_API_KEY", savedPlatformInternalApiKey);
145
+ restoreEnv("ASSISTANT_API_KEY", savedAssistantCredential);
146
146
  try {
147
147
  rmSync(protectedDir, { recursive: true, force: true });
148
148
  mkdirSync(protectedDir, { recursive: true });
@@ -195,7 +195,7 @@ describe("RemoteFeatureFlagSync", () => {
195
195
  );
196
196
  });
197
197
 
198
- test("skips sync when assistant_api_key is missing and no PLATFORM_INTERNAL_API_KEY", async () => {
198
+ test("skips sync when assistant_api_key is missing", async () => {
199
199
  const creds = defaultCredentials();
200
200
  delete creds["credential/vellum/assistant_api_key"];
201
201
 
@@ -208,12 +208,13 @@ describe("RemoteFeatureFlagSync", () => {
208
208
  expect(fetchMock).not.toHaveBeenCalled();
209
209
  });
210
210
 
211
- test("does not use PLATFORM_INTERNAL_API_KEY when assistant_api_key is missing", async () => {
212
- fetchMock = mock(async () => Response.json({ flags: {} }));
213
- process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-123";
211
+ test("syncs when only platformUrl and assistantApiKey are present", async () => {
212
+ fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
214
213
 
215
- const creds = defaultCredentials();
216
- delete creds["credential/vellum/assistant_api_key"];
214
+ const creds = {
215
+ "credential/vellum/platform_base_url": "https://platform.example.com",
216
+ "credential/vellum/assistant_api_key": "test-api-key",
217
+ };
217
218
 
218
219
  const sync = new RemoteFeatureFlagSync({
219
220
  credentials: fakeCredentialCache(creds),
@@ -221,17 +222,15 @@ describe("RemoteFeatureFlagSync", () => {
221
222
  await sync.start();
222
223
  sync.stop();
223
224
 
224
- // PLATFORM_INTERNAL_API_KEY is only for internal gateway endpoints —
225
- // feature flag sync requires assistant_api_key (Api-Key auth).
226
- expect(fetchMock).not.toHaveBeenCalled();
225
+ expect(fetchMock).toHaveBeenCalledTimes(1);
227
226
  });
228
227
 
229
- test("syncs when only platformUrl and assistantApiKey are present", async () => {
228
+ test("falls back to ASSISTANT_API_KEY env var when credential key is missing", async () => {
230
229
  fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
230
+ process.env.ASSISTANT_API_KEY = "env-key";
231
231
 
232
232
  const creds = {
233
233
  "credential/vellum/platform_base_url": "https://platform.example.com",
234
- "credential/vellum/assistant_api_key": "test-api-key",
235
234
  };
236
235
 
237
236
  const sync = new RemoteFeatureFlagSync({
@@ -241,6 +240,9 @@ describe("RemoteFeatureFlagSync", () => {
241
240
  sync.stop();
242
241
 
243
242
  expect(fetchMock).toHaveBeenCalledTimes(1);
243
+ const [, init] = fetchMock.mock.calls[0];
244
+ const headers = init?.headers as Record<string, string>;
245
+ expect(headers.Authorization).toBe("Api-Key env-key");
244
246
  });
245
247
 
246
248
  test("fetches and caches flags on successful response", async () => {