@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.
- package/AGENTS.md +10 -0
- package/Dockerfile +4 -2
- package/bun.lock +8 -1
- package/knip.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/contact-prompt-submit.test.ts +5 -5
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
- package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
- package/src/__tests__/edge-auth.test.ts +253 -0
- package/src/__tests__/edge-guardian-auth.test.ts +198 -0
- package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
- package/src/__tests__/ipc-route-policy.test.ts +43 -0
- package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
- package/src/__tests__/live-voice-websocket.test.ts +1 -1
- package/src/__tests__/slack-normalize.test.ts +132 -0
- package/src/auth/guardian-bootstrap.ts +3 -1
- package/src/auth/ipc-route-policy.ts +34 -0
- package/src/db/assistant-db-proxy.ts +76 -7
- package/src/db/contact-store.ts +767 -1
- package/src/db/schema.ts +29 -0
- package/src/feature-flag-registry.json +17 -17
- package/src/handlers/handle-inbound.ts +9 -23
- package/src/http/middleware/auth.ts +193 -40
- package/src/http/router.ts +26 -4
- package/src/http/routes/channel-verification-session-proxy.ts +53 -6
- package/src/http/routes/contact-prompt.ts +44 -15
- package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
- package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
- package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
- package/src/http/routes/ipc-runtime-proxy.ts +2 -2
- package/src/http/routes/log-export.test.ts +1 -0
- package/src/http/routes/log-export.ts +9 -2
- package/src/http/routes/log-tail.test.ts +10 -9
- package/src/http/routes/log-tail.ts +5 -2
- package/src/http/routes/pair.ts +8 -0
- package/src/http/routes/twilio-voice-webhook.ts +11 -2
- package/src/index.ts +98 -13
- package/src/ipc/assistant-client.test.ts +67 -15
- package/src/ipc/assistant-client.ts +12 -118
- package/src/ipc/risk-classification-handlers.test.ts +76 -0
- package/src/ipc/risk-classification-handlers.ts +20 -9
- package/src/ipc/server.ts +113 -46
- package/src/logger.ts +71 -17
- package/src/post-assistant-ready.ts +9 -3
- package/src/risk/bash-risk-classifier.test.ts +106 -0
- package/src/risk/bash-risk-classifier.ts +19 -15
- package/src/risk/command-registry/commands/assistant.ts +75 -26
- package/src/risk/command-registry.test.ts +3 -1
- package/src/risk/shell-parser.test.ts +159 -0
- package/src/risk/shell-parser.ts +150 -19
- package/src/runtime/client.ts +0 -11
- package/src/schema.ts +32 -0
- package/src/slack/normalize.test.ts +5 -0
- package/src/slack/normalize.ts +7 -0
- package/src/velay/bridge-utils.ts +10 -0
- package/src/velay/client.test.ts +156 -0
- package/src/velay/client.ts +83 -0
- package/src/velay/http-bridge.test.ts +29 -0
- package/src/velay/http-bridge.ts +7 -0
- package/src/velay/protocol.ts +12 -1
- package/src/verification/outbound-voice-verification-sync.ts +171 -0
- 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
|
|
362
|
-
//
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
95
|
-
{ stream:
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
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", () => {
|