@vellumai/vellum-gateway 0.7.3 → 0.8.1

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 (63) hide show
  1. package/AGENTS.md +10 -0
  2. package/Dockerfile +4 -2
  3. package/bun.lock +8 -1
  4. package/knip.json +1 -0
  5. package/package.json +2 -1
  6. package/src/__tests__/contact-prompt-submit.test.ts +5 -5
  7. package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
  8. package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
  9. package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
  10. package/src/__tests__/edge-auth.test.ts +253 -0
  11. package/src/__tests__/edge-guardian-auth.test.ts +198 -0
  12. package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
  13. package/src/__tests__/ipc-route-policy.test.ts +43 -0
  14. package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
  15. package/src/__tests__/live-voice-websocket.test.ts +1 -1
  16. package/src/__tests__/slack-normalize.test.ts +132 -0
  17. package/src/auth/guardian-bootstrap.ts +3 -1
  18. package/src/auth/ipc-route-policy.ts +34 -0
  19. package/src/db/assistant-db-proxy.ts +76 -7
  20. package/src/db/contact-store.ts +767 -1
  21. package/src/db/schema.ts +29 -0
  22. package/src/feature-flag-registry.json +17 -17
  23. package/src/handlers/handle-inbound.ts +9 -23
  24. package/src/http/middleware/auth.ts +193 -40
  25. package/src/http/router.ts +26 -4
  26. package/src/http/routes/channel-verification-session-proxy.ts +53 -6
  27. package/src/http/routes/contact-prompt.ts +44 -15
  28. package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
  29. package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
  30. package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
  31. package/src/http/routes/ipc-runtime-proxy.ts +2 -2
  32. package/src/http/routes/log-export.test.ts +1 -0
  33. package/src/http/routes/log-export.ts +9 -2
  34. package/src/http/routes/log-tail.test.ts +10 -9
  35. package/src/http/routes/log-tail.ts +5 -2
  36. package/src/http/routes/pair.ts +8 -0
  37. package/src/http/routes/twilio-voice-webhook.ts +11 -2
  38. package/src/index.ts +98 -13
  39. package/src/ipc/assistant-client.test.ts +67 -15
  40. package/src/ipc/assistant-client.ts +12 -118
  41. package/src/ipc/risk-classification-handlers.test.ts +76 -0
  42. package/src/ipc/risk-classification-handlers.ts +20 -9
  43. package/src/ipc/server.ts +113 -46
  44. package/src/logger.ts +71 -17
  45. package/src/post-assistant-ready.ts +9 -3
  46. package/src/risk/bash-risk-classifier.test.ts +106 -0
  47. package/src/risk/bash-risk-classifier.ts +19 -15
  48. package/src/risk/command-registry/commands/assistant.ts +75 -26
  49. package/src/risk/command-registry.test.ts +3 -1
  50. package/src/risk/shell-parser.test.ts +159 -0
  51. package/src/risk/shell-parser.ts +150 -19
  52. package/src/runtime/client.ts +0 -11
  53. package/src/schema.ts +32 -0
  54. package/src/slack/normalize.test.ts +5 -0
  55. package/src/slack/normalize.ts +7 -0
  56. package/src/velay/bridge-utils.ts +10 -0
  57. package/src/velay/client.test.ts +156 -0
  58. package/src/velay/client.ts +83 -0
  59. package/src/velay/http-bridge.test.ts +29 -0
  60. package/src/velay/http-bridge.ts +7 -0
  61. package/src/velay/protocol.ts +12 -1
  62. package/src/verification/outbound-voice-verification-sync.ts +171 -0
  63. package/src/verification/voice-approval-sync.ts +107 -0
@@ -349,6 +349,82 @@ describe("skill classification", () => {
349
349
  });
350
350
  });
351
351
 
352
+ // ── Credentialed proxied bash ───────────────────────────────────────────────
353
+
354
+ describe("credentialed proxied bash", () => {
355
+ test("credentialed proxied bash returns high risk even for simple curl", async () => {
356
+ const result = await classify({
357
+ tool: "bash",
358
+ command: "curl https://api.example.com",
359
+ networkMode: "proxied",
360
+ credentialRefCount: 1,
361
+ });
362
+ expect(result.risk).toBe("high");
363
+ expect(result.reason).toContain("credential");
364
+ });
365
+
366
+ test("credentialed proxied bash returns high risk for low-risk command", async () => {
367
+ const result = await classify({
368
+ tool: "bash",
369
+ command: "ls",
370
+ networkMode: "proxied",
371
+ credentialRefCount: 2,
372
+ });
373
+ expect(result.risk).toBe("high");
374
+ expect(result.reason).toContain("credential");
375
+ });
376
+
377
+ test("proxied bash without credential refs keeps existing medium cap for high-risk command", async () => {
378
+ const result = await classify({
379
+ tool: "bash",
380
+ command: "rm -rf /",
381
+ networkMode: "proxied",
382
+ });
383
+ // rm -rf / is high risk but gets capped to medium for non-credentialed proxied bash
384
+ expect(result.risk).toBe("medium");
385
+ });
386
+
387
+ test("proxied bash without credential refs keeps low risk for low-risk command", async () => {
388
+ const result = await classify({
389
+ tool: "bash",
390
+ command: "ls",
391
+ networkMode: "proxied",
392
+ });
393
+ expect(result.risk).toBe("low");
394
+ });
395
+
396
+ test("credentialRefCount=0 does not escalate risk", async () => {
397
+ const result = await classify({
398
+ tool: "bash",
399
+ command: "ls",
400
+ networkMode: "proxied",
401
+ credentialRefCount: 0,
402
+ });
403
+ expect(result.risk).toBe("low");
404
+ });
405
+
406
+ test("non-proxied bash with credential refs follows normal risk flow", async () => {
407
+ const result = await classify({
408
+ tool: "bash",
409
+ command: "ls",
410
+ credentialRefCount: 1,
411
+ });
412
+ // Without proxied mode, credential refs don't affect classification
413
+ expect(result.risk).toBe("low");
414
+ });
415
+
416
+ test("host_bash with proxied + credential refs is not affected (host_bash skips proxied cap)", async () => {
417
+ const result = await classify({
418
+ tool: "host_bash",
419
+ command: "rm -rf /",
420
+ networkMode: "proxied",
421
+ credentialRefCount: 1,
422
+ });
423
+ // host_bash is never affected by proxied risk logic
424
+ expect(result.risk).toBe("high");
425
+ });
426
+ });
427
+
352
428
  // ── Unknown tool fallback ───────────────────────────────────────────────────
353
429
 
354
430
  describe("unknown tool fallback", () => {
@@ -73,6 +73,8 @@ const ClassifyRiskSchema = z.object({
73
73
  .optional(),
74
74
  /** Tool registry default risk level for unknown tools. */
75
75
  registryDefaultRisk: z.string().optional(),
76
+ /** Number of credential references attached to this tool invocation. */
77
+ credentialRefCount: z.number().int().nonnegative().optional(),
76
78
  });
77
79
 
78
80
  type ClassifyRiskParams = z.infer<typeof ClassifyRiskSchema>;
@@ -358,17 +360,26 @@ export async function handleClassifyRisk(
358
360
  });
359
361
  }
360
362
 
361
- // Proxied bash risk cap: when running through the credential proxy,
362
- // cap High Medium so proxied commands don't trigger unnecessary prompts.
363
+ // Proxied bash risk classification:
364
+ // - When credentials are attached (credentialRefCount > 0), escalate to
365
+ // high risk regardless of the underlying assessment. Credentialed
366
+ // proxied shell sessions carry elevated risk and must not be
367
+ // downgraded by the general proxied-bash cap.
368
+ // - For non-credentialed proxied bash, cap High → Medium so proxied
369
+ // commands don't trigger unnecessary prompts.
363
370
  // Only applies to sandboxed "bash" — host_bash runs on the host machine
364
371
  // and should not have its risk capped.
365
372
  let finalRisk = assessment.riskLevel;
366
- if (
367
- tool === "bash" &&
368
- params.networkMode === "proxied" &&
369
- finalRisk === "high"
370
- ) {
371
- finalRisk = "medium";
373
+ let finalReason = assessment.reason;
374
+ const credentialRefCount = params.credentialRefCount ?? 0;
375
+ if (tool === "bash" && params.networkMode === "proxied") {
376
+ if (credentialRefCount > 0) {
377
+ finalRisk = "high";
378
+ finalReason =
379
+ "Proxied credential session — shell has access to injected credentials";
380
+ } else if (finalRisk === "high") {
381
+ finalRisk = "medium";
382
+ }
372
383
  }
373
384
 
374
385
  // Collect resolved paths for directory-scoped rule enforcement.
@@ -380,7 +391,7 @@ export async function handleClassifyRisk(
380
391
 
381
392
  return {
382
393
  risk: finalRisk,
383
- reason: assessment.reason,
394
+ reason: finalReason,
384
395
  scopeOptions: assessment.scopeOptions,
385
396
  allowlistOptions: assessment.allowlistOptions,
386
397
  actionKeys,
package/src/ipc/server.ts CHANGED
@@ -10,11 +10,20 @@
10
10
  * The preferred socket path is `{workspaceDir}/gateway.sock` on the shared
11
11
  * volume. On platforms with strict AF_UNIX path limits, the server falls back
12
12
  * to a shorter deterministic path.
13
+ *
14
+ * Resilience: a {@link SocketWatchdog} re-binds the listening socket when its
15
+ * on-disk path entry is removed (e.g. by a tmpfs sweep or rogue cleanup of
16
+ * `/run/*`). Existing connected sockets survive the re-bind because the
17
+ * kernel keeps connection inodes alive independently of the listener path;
18
+ * only new `connect()` calls require the path to exist.
13
19
  */
14
20
 
15
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
21
+ import {
22
+ SocketWatchdog,
23
+ ensureSocketDir,
24
+ } from "@vellumai/ipc-server-utils";
25
+ import { existsSync, unlinkSync } from "node:fs";
16
26
  import { createServer, type Server, type Socket } from "node:net";
17
- import { dirname } from "node:path";
18
27
 
19
28
  import type { z } from "zod";
20
29
 
@@ -55,6 +64,15 @@ export type IpcRoute = {
55
64
  handler: IpcMethodHandler;
56
65
  };
57
66
 
67
+ /** Optional configuration for {@link GatewayIpcServer}. */
68
+ export interface GatewayIpcServerOptions {
69
+ /**
70
+ * How often the socket-file watchdog stats the listening socket path.
71
+ * Set to `0` to disable. Defaults to {@link SocketWatchdog}'s 5000ms.
72
+ */
73
+ watchdogIntervalMs?: number;
74
+ }
75
+
58
76
  // ---------------------------------------------------------------------------
59
77
  // Server
60
78
  // ---------------------------------------------------------------------------
@@ -65,8 +83,15 @@ export class GatewayIpcServer {
65
83
  private methods = new Map<string, IpcMethodHandler>();
66
84
  private schemas = new Map<string, z.ZodType>();
67
85
  private socketPath: string;
68
-
69
- constructor(routes?: IpcRoute[]) {
86
+ private watchdog: SocketWatchdog;
87
+ /**
88
+ * Servers whose listener path has been replaced by a re-bind. Kept around
89
+ * so that already-connected sockets continue to work; closed gracefully
90
+ * once their accept loops drain.
91
+ */
92
+ private legacyServers = new Set<Server>();
93
+
94
+ constructor(routes?: IpcRoute[], options?: GatewayIpcServerOptions) {
70
95
  const resolution = resolveIpcSocketPath("gateway");
71
96
  this.socketPath = resolution.path;
72
97
  log.info(
@@ -81,16 +106,33 @@ export class GatewayIpcServer {
81
106
  }
82
107
  }
83
108
  }
109
+
110
+ this.watchdog = new SocketWatchdog({
111
+ socketPath: this.socketPath,
112
+ intervalMs: options?.watchdogIntervalMs,
113
+ getServer: () => this.server,
114
+ createServer: () => this.createListeningServer(),
115
+ onRebind: (newServer, oldServer) => {
116
+ this.server = newServer;
117
+ // Move the previous listener into the legacy set so already-
118
+ // connected clients keep their accept loop alive. close() stops
119
+ // accepting new connections (which the kernel already won't route
120
+ // here anyway after the path moved) but lets in-flight sockets
121
+ // drain.
122
+ this.legacyServers.add(oldServer);
123
+ oldServer.close(() => {
124
+ this.legacyServers.delete(oldServer);
125
+ });
126
+ },
127
+ log,
128
+ });
84
129
  }
85
130
 
86
131
  /** Start listening on the Unix domain socket. */
87
132
  start(): void {
88
133
  // Ensure the parent directory exists — on a fresh hatch the workspace
89
134
  // dir may not have been created yet when the IPC server starts.
90
- const socketDir = dirname(this.socketPath);
91
- if (!existsSync(socketDir)) {
92
- mkdirSync(socketDir, { recursive: true });
93
- }
135
+ ensureSocketDir(this.socketPath);
94
136
 
95
137
  // Clean up stale socket file from a previous run
96
138
  if (existsSync(this.socketPath)) {
@@ -101,51 +143,18 @@ export class GatewayIpcServer {
101
143
  }
102
144
  }
103
145
 
104
- this.server = createServer((socket) => {
105
- // The assistant maintains a persistent connection for hot-path RPCs
106
- // (classify_risk) alongside short-lived one-shot connections for other
107
- // calls. Track all of them so a new one-shot connection does not tear
108
- // down the persistent socket and reject its in-flight requests.
109
- this.clients.add(socket);
110
- log.debug("IPC client connected");
111
-
112
- let buffer = "";
113
-
114
- socket.on("data", (chunk) => {
115
- buffer += chunk.toString();
116
- // Process complete newline-delimited messages
117
- let newlineIdx: number;
118
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
119
- const line = buffer.slice(0, newlineIdx).trim();
120
- buffer = buffer.slice(newlineIdx + 1);
121
- if (line) {
122
- this.handleMessage(socket, line);
123
- }
124
- }
125
- });
126
-
127
- socket.on("close", () => {
128
- this.clients.delete(socket);
129
- log.debug("IPC client disconnected");
130
- });
131
-
132
- socket.on("error", (err) => {
133
- log.warn({ err }, "IPC client socket error");
134
- this.clients.delete(socket);
135
- });
136
- });
137
-
138
- this.server.on("error", (err) => {
139
- log.error({ err }, "IPC server error");
140
- });
141
-
146
+ this.server = this.createListeningServer();
142
147
  this.server.listen(this.socketPath, () => {
143
148
  log.info({ path: this.socketPath }, "IPC server listening");
144
149
  });
150
+
151
+ this.watchdog.start();
145
152
  }
146
153
 
147
154
  /** Stop the server and disconnect all clients. */
148
155
  stop(): void {
156
+ this.watchdog.stop();
157
+
149
158
  for (const socket of this.clients) {
150
159
  if (!socket.destroyed) {
151
160
  socket.destroy();
@@ -153,6 +162,11 @@ export class GatewayIpcServer {
153
162
  }
154
163
  this.clients.clear();
155
164
 
165
+ for (const legacy of this.legacyServers) {
166
+ legacy.close();
167
+ }
168
+ this.legacyServers.clear();
169
+
156
170
  if (this.server) {
157
171
  this.server.close();
158
172
  this.server = null;
@@ -184,8 +198,61 @@ export class GatewayIpcServer {
184
198
  return this.socketPath;
185
199
  }
186
200
 
201
+ /**
202
+ * Re-bind the listening socket if its path entry is missing on disk.
203
+ *
204
+ * Public for tests so the watchdog can be exercised deterministically
205
+ * without waiting for the interval. Returns `true` when a re-bind was
206
+ * performed, `false` otherwise.
207
+ */
208
+ async rebindIfMissing(): Promise<boolean> {
209
+ return this.watchdog.rebindIfMissing();
210
+ }
211
+
187
212
  // ── Internal ──────────────────────────────────────────────────────────
188
213
 
214
+ private createListeningServer(): Server {
215
+ const server = createServer((socket) => this.handleConnection(socket));
216
+ server.on("error", (err) => {
217
+ log.error({ err }, "IPC server error");
218
+ });
219
+ return server;
220
+ }
221
+
222
+ private handleConnection(socket: Socket): void {
223
+ // The assistant maintains a persistent connection for hot-path RPCs
224
+ // (classify_risk) alongside short-lived one-shot connections for other
225
+ // calls. Track all of them so a new one-shot connection does not tear
226
+ // down the persistent socket and reject its in-flight requests.
227
+ this.clients.add(socket);
228
+ log.debug("IPC client connected");
229
+
230
+ let buffer = "";
231
+
232
+ socket.on("data", (chunk) => {
233
+ buffer += chunk.toString();
234
+ // Process complete newline-delimited messages
235
+ let newlineIdx: number;
236
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
237
+ const line = buffer.slice(0, newlineIdx).trim();
238
+ buffer = buffer.slice(newlineIdx + 1);
239
+ if (line) {
240
+ this.handleMessage(socket, line);
241
+ }
242
+ }
243
+ });
244
+
245
+ socket.on("close", () => {
246
+ this.clients.delete(socket);
247
+ log.debug("IPC client disconnected");
248
+ });
249
+
250
+ socket.on("error", (err) => {
251
+ log.warn({ err }, "IPC client socket error");
252
+ this.clients.delete(socket);
253
+ });
254
+ }
255
+
189
256
  private handleMessage(socket: Socket, line: string): void {
190
257
  let req: IpcRequest;
191
258
  try {
package/src/logger.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import pino from "pino";
10
+ import type { PrettyOptions } from "pino-pretty";
10
11
  import pinoPretty from "pino-pretty";
11
12
  import { logSerializers } from "./log-redact.js";
12
13
 
@@ -15,10 +16,34 @@ export type LogFileConfig = {
15
16
  retentionDays: number;
16
17
  };
17
18
 
19
+ /**
20
+ * Common pino-pretty options. Inlines [module] into the message prefix so the
21
+ * formatted line reads `[runtime-proxy] Upstream returned error` instead of
22
+ * dumping the module field separately. Mirrors the assistant logger so files
23
+ * are human-readable on both sides.
24
+ */
25
+ function prettyOpts(extra?: PrettyOptions): PrettyOptions {
26
+ return {
27
+ messageFormat: "[{module}] {msg}",
28
+ ignore: "module",
29
+ ...extra,
30
+ };
31
+ }
32
+
18
33
  const LOG_FILE_PREFIX = "gateway-";
19
34
  const LOG_FILE_SUFFIX = ".log";
35
+ const LOG_FILE_JSON_SUFFIX = ".jsonl";
36
+
37
+ /** Matches the human-readable pretty log file (default tail target). */
20
38
  export const LOG_FILE_PATTERN = /^gateway-(\d{4}-\d{2}-\d{2})\.log$/;
21
39
 
40
+ /**
41
+ * Matches the structured JSON-lines sidecar used by `gateway/logs/tail` for
42
+ * server-side level/module filtering. Walking pretty multi-line entries is
43
+ * fragile, so we keep a parallel JSONL file solely for that consumer.
44
+ */
45
+ export const LOG_FILE_JSON_PATTERN = /^gateway-(\d{4}-\d{2}-\d{2})\.jsonl$/;
46
+
22
47
  function formatDate(date: Date): string {
23
48
  const y = date.getUTCFullYear();
24
49
  const m = String(date.getUTCMonth() + 1).padStart(2, "0");
@@ -30,6 +55,13 @@ function logFilePathForDate(dir: string, date: Date): string {
30
55
  return join(dir, `${LOG_FILE_PREFIX}${formatDate(date)}${LOG_FILE_SUFFIX}`);
31
56
  }
32
57
 
58
+ function jsonLogFilePathForDate(dir: string, date: Date): string {
59
+ return join(
60
+ dir,
61
+ `${LOG_FILE_PREFIX}${formatDate(date)}${LOG_FILE_JSON_SUFFIX}`,
62
+ );
63
+ }
64
+
33
65
  export function pruneOldLogFiles(dir: string, retentionDays: number): number {
34
66
  if (!existsSync(dir)) return 0;
35
67
 
@@ -39,7 +71,8 @@ export function pruneOldLogFiles(dir: string, retentionDays: number): number {
39
71
 
40
72
  let removed = 0;
41
73
  for (const name of readdirSync(dir)) {
42
- const match = LOG_FILE_PATTERN.exec(name);
74
+ const match =
75
+ LOG_FILE_PATTERN.exec(name) ?? LOG_FILE_JSON_PATTERN.exec(name);
43
76
  if (!match) continue;
44
77
  const fileDate = new Date(match[1] + "T00:00:00Z");
45
78
  if (fileDate < cutoff) {
@@ -58,11 +91,28 @@ let rootLogger: pino.Logger | null = null;
58
91
  let activeLogDate: string | null = null;
59
92
  let activeConfig: LogFileConfig | null = null;
60
93
 
94
+ function openSecureDestination(filePath: string): pino.DestinationStream {
95
+ const dest = pino.destination({
96
+ dest: filePath,
97
+ sync: true,
98
+ mkdir: true,
99
+ mode: 0o600,
100
+ });
101
+ // Tighten permissions on pre-existing log files that may have been created
102
+ // with looser modes.
103
+ try {
104
+ chmodSync(filePath, 0o600);
105
+ } catch {
106
+ /* best-effort */
107
+ }
108
+ return dest;
109
+ }
110
+
61
111
  function buildLogger(config: LogFileConfig | null): pino.Logger {
62
112
  if (!config?.dir) {
63
113
  return pino(
64
114
  { name: "gateway", serializers: logSerializers },
65
- pinoPretty({ destination: 1 }),
115
+ pinoPretty(prettyOpts({ destination: 1 })),
66
116
  );
67
117
  }
68
118
 
@@ -71,19 +121,19 @@ function buildLogger(config: LogFileConfig | null): pino.Logger {
71
121
  }
72
122
 
73
123
  const today = formatDate(new Date());
74
- const filePath = logFilePathForDate(config.dir, new Date());
75
- const fileStream = pino.destination({
76
- dest: filePath,
77
- sync: true,
78
- mkdir: true,
79
- mode: 0o600,
80
- });
81
- // Tighten permissions on pre-existing log files that may have been created with looser modes
82
- try {
83
- chmodSync(filePath, 0o600);
84
- } catch {
85
- /* best-effort */
86
- }
124
+ const now = new Date();
125
+
126
+ // Pretty file: human-readable, default tail target. Mirrors assistant.
127
+ const prettyDest = openSecureDestination(logFilePathForDate(config.dir, now));
128
+ const prettyFileStream = pinoPretty(
129
+ prettyOpts({ destination: prettyDest, colorize: false }),
130
+ );
131
+
132
+ // JSONL sidecar: machine-readable, consumed by `gateway/logs/tail` for
133
+ // server-side level/module filtering. Same content, raw pino records.
134
+ const jsonFileStream = openSecureDestination(
135
+ jsonLogFilePathForDate(config.dir, now),
136
+ );
87
137
 
88
138
  activeLogDate = today;
89
139
  activeConfig = config;
@@ -91,8 +141,12 @@ function buildLogger(config: LogFileConfig | null): pino.Logger {
91
141
  return pino(
92
142
  { name: "gateway", serializers: logSerializers },
93
143
  pino.multistream([
94
- { stream: fileStream, level: "info" as const },
95
- { stream: pinoPretty({ destination: 1 }), level: "info" as const },
144
+ { stream: prettyFileStream, level: "info" as const },
145
+ { stream: jsonFileStream, level: "info" as const },
146
+ {
147
+ stream: pinoPretty(prettyOpts({ destination: 1 })),
148
+ level: "info" as const,
149
+ },
96
150
  ]),
97
151
  );
98
152
  }
@@ -18,7 +18,10 @@ import type { Database } from "bun:sqlite";
18
18
  import { ensureVellumGuardianBinding } from "./auth/guardian-bootstrap.js";
19
19
  import { getGatewayDb, type GatewayDb } from "./db/connection.js";
20
20
  import { runDataMigrations } from "./db/data-migrations/index.js";
21
- import { ipcCallAssistant } from "./ipc/assistant-client.js";
21
+ import {
22
+ IpcTransportError,
23
+ ipcCallAssistant,
24
+ } from "./ipc/assistant-client.js";
22
25
  import { getLogger } from "./logger.js";
23
26
 
24
27
  const log = getLogger("post-assistant-ready");
@@ -34,10 +37,13 @@ export async function waitForAssistant(): Promise<boolean> {
34
37
  const deadline = Date.now() + MAX_WAIT_MS;
35
38
 
36
39
  while (Date.now() < deadline) {
37
- const result = await ipcCallAssistant("health");
38
- if (result !== undefined) {
40
+ try {
41
+ await ipcCallAssistant("health");
39
42
  log.info("Assistant is ready");
40
43
  return true;
44
+ } catch (err) {
45
+ if (!(err instanceof IpcTransportError)) throw err;
46
+ // Transport error during startup is expected — keep polling.
41
47
  }
42
48
 
43
49
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
@@ -995,6 +995,30 @@ describe("assistant subcommand classification", () => {
995
995
  expect(result.riskLevel).toBe("medium");
996
996
  });
997
997
 
998
+ test("assistant inference session open balanced --ttl 30m → low", async () => {
999
+ const result = await classifier.classify({
1000
+ command: "assistant inference session open balanced --ttl 30m",
1001
+ toolName: "bash",
1002
+ });
1003
+ expect(result.riskLevel).toBe("low");
1004
+ });
1005
+
1006
+ test("assistant inference session close → low", async () => {
1007
+ const result = await classifier.classify({
1008
+ command: "assistant inference session close",
1009
+ toolName: "bash",
1010
+ });
1011
+ expect(result.riskLevel).toBe("low");
1012
+ });
1013
+
1014
+ test("assistant inference session list → low", async () => {
1015
+ const result = await classifier.classify({
1016
+ command: "assistant inference session list",
1017
+ toolName: "bash",
1018
+ });
1019
+ expect(result.riskLevel).toBe("low");
1020
+ });
1021
+
998
1022
  test("assistant bash ls → high", async () => {
999
1023
  const result = await classifier.classify({
1000
1024
  command: "assistant bash ls",
@@ -1609,6 +1633,88 @@ describe("generateScopeOptions with parseArgs", () => {
1609
1633
  });
1610
1634
  });
1611
1635
 
1636
+ // ── generateScopeOptions with synthetic segments (parse-recovery) ────────────
1637
+
1638
+ describe("generateScopeOptions with synthetic segments", () => {
1639
+ test("parse-recovery from unquoted parens in path: no wildcards, exact match uses original command", async () => {
1640
+ // Bug: tree-sitter splits `cat /a/(b)/c.txt` into multiple sibling
1641
+ // statements with no separator (parse-recovery). Without the synthetic
1642
+ // filter we'd surface bogus wildcards like `app *` or `/c.txt *`.
1643
+ const parsed = await cachedParse("cat /a/(b)/c.txt");
1644
+ const options = generateScopeOptions(parsed, DEFAULT_COMMAND_REGISTRY);
1645
+ const labels = options.map((o) => o.label);
1646
+
1647
+ // Exact match must be the literal user input — segment reconstruction
1648
+ // would produce something like "cat /a/(b) /c.txt" (extra space).
1649
+ expect(labels[0]).toBe("cat /a/(b)/c.txt");
1650
+
1651
+ // No per-program wildcards should be emitted from the recovery
1652
+ // fragments — they're not real top-level commands.
1653
+ expect(labels).not.toContain("cat *");
1654
+ expect(labels).not.toContain("/c.txt *");
1655
+ // Only the exact match should remain.
1656
+ expect(options).toHaveLength(1);
1657
+ });
1658
+
1659
+ test("parse-recovery in iPhone bug repro: only exact match, no synthetic wildcards", async () => {
1660
+ // The exact command from the user's screenshot. The synthetic-filter
1661
+ // regression: pre-fix, `app *` and `/admin/organizations/[id]/page.tsx *`
1662
+ // both leaked into the trust-rule editor's "Apply to" list.
1663
+ const cmd =
1664
+ "cat /workspace/vellum-assistant-platform/web/src/app/(app)/admin/organizations/[id]/page.tsx | grep -A 30 -B 5 \"credit\\|Credit\" | head -80";
1665
+ const parsed = await cachedParse(cmd);
1666
+ const options = generateScopeOptions(parsed, DEFAULT_COMMAND_REGISTRY);
1667
+ const labels = options.map((o) => o.label);
1668
+
1669
+ expect(labels[0]).toBe(cmd);
1670
+ expect(labels).not.toContain("app *");
1671
+ expect(labels).not.toContain("/admin/organizations/[id]/page.tsx *");
1672
+ // Even legitimate-looking programs that happened to be inside the
1673
+ // recovery fragments (cat/grep/head) are filtered — the parse can't
1674
+ // be trusted to know what was a real top-level command.
1675
+ expect(labels).not.toContain("cat *");
1676
+ expect(labels).not.toContain("grep *");
1677
+ expect(labels).not.toContain("head *");
1678
+ expect(options).toHaveLength(1);
1679
+ });
1680
+
1681
+ test("legitimate pipeline still emits per-program wildcards", async () => {
1682
+ // Regression check: filtering synthetic must not break the common
1683
+ // case of a real pipeline.
1684
+ const parsed = await cachedParse("ls -la | grep foo");
1685
+ const options = generateScopeOptions(parsed, DEFAULT_COMMAND_REGISTRY);
1686
+ const labels = options.map((o) => o.label);
1687
+
1688
+ expect(labels[0]).toBe("ls -la | grep foo");
1689
+ expect(labels).toContain("ls *");
1690
+ expect(labels).toContain("grep *");
1691
+ });
1692
+
1693
+ test("legitimate ;-separated commands: exact match preserves the `;`", async () => {
1694
+ // Pre-fix: `parts.join(" ")` produced "ls rm -rf /tmp/foo" (no `;`).
1695
+ // With originalCommand the exact-match label is the verbatim input.
1696
+ const parsed = await cachedParse("ls; rm -rf /tmp/foo");
1697
+ const options = generateScopeOptions(parsed, DEFAULT_COMMAND_REGISTRY);
1698
+ const labels = options.map((o) => o.label);
1699
+
1700
+ expect(labels[0]).toBe("ls; rm -rf /tmp/foo");
1701
+ expect(labels).toContain("ls *");
1702
+ expect(labels).toContain("rm *");
1703
+ });
1704
+
1705
+ test("subshell content is not surfaced as a top-level wildcard", async () => {
1706
+ // (cd /tmp && ls) — segments are synthetic (nested context), so
1707
+ // per-program wildcards must be filtered out. Only exact match remains.
1708
+ const parsed = await cachedParse("(cd /tmp && ls)");
1709
+ const options = generateScopeOptions(parsed, DEFAULT_COMMAND_REGISTRY);
1710
+ const labels = options.map((o) => o.label);
1711
+
1712
+ expect(labels[0]).toBe("(cd /tmp && ls)");
1713
+ expect(labels).not.toContain("cd *");
1714
+ expect(labels).not.toContain("ls *");
1715
+ });
1716
+ });
1717
+
1612
1718
  // ── scopeOptionsToAllowlistOptions ───────────────────────────────────────────
1613
1719
 
1614
1720
  describe("scopeOptionsToAllowlistOptions", () => {