@strixgov/mcp-proxy 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,715 @@
1
+ /**
2
+ * End-to-end proxy test using two InMemoryTransport pairs:
3
+ *
4
+ * [Test Client] ─in-memory─▶ [Proxy as Server]
5
+ * [Proxy as Client] ─in-memory─▶ [Fake Upstream MCP Server]
6
+ *
7
+ * Validates the full pipeline — incoming MCP request → governance
8
+ * evaluation → upstream call → response — without spawning real
9
+ * subprocesses. The same code paths run in production; only the
10
+ * transports are swapped.
11
+ */
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
22
+ import {
23
+ CallToolRequestSchema,
24
+ ListToolsRequestSchema,
25
+ } from "@modelcontextprotocol/sdk/types.js";
26
+
27
+ import { startProxy } from "../src/index.mjs";
28
+
29
+ const SAMPLE_CAPS = [
30
+ { id: "mcp.demo.read_thing", name: "read_thing", risk: "LOW", mode: "READ" },
31
+ { id: "mcp.demo.write_thing", name: "write_thing", risk: "MEDIUM", mode: "WRITE" },
32
+ { id: "mcp.demo.delete_thing", name: "delete_thing", risk: "CRITICAL", mode: "WRITE" },
33
+ ];
34
+
35
+ /**
36
+ * Build a fake upstream MCP server + a Client pre-connected to it via
37
+ * an InMemoryTransport pair. The proxy treats this client as if it
38
+ * were a real subprocess-spawned upstream.
39
+ */
40
+ async function buildFakeUpstream(toolCallLog) {
41
+ const upstreamServer = new Server(
42
+ { name: "fake-upstream", version: "0.0.1" },
43
+ { capabilities: { tools: {} } },
44
+ );
45
+
46
+ upstreamServer.setRequestHandler(ListToolsRequestSchema, async () => ({
47
+ tools: [
48
+ { name: "read_thing", description: "Read a thing", inputSchema: { type: "object" } },
49
+ { name: "write_thing", description: "Write a thing", inputSchema: { type: "object" } },
50
+ { name: "delete_thing", description: "Delete a thing", inputSchema: { type: "object" } },
51
+ ],
52
+ }));
53
+ upstreamServer.setRequestHandler(CallToolRequestSchema, async (request) => {
54
+ toolCallLog.push({ name: request.params.name, args: request.params.arguments });
55
+ return {
56
+ content: [{ type: "text", text: `executed ${request.params.name}` }],
57
+ };
58
+ });
59
+
60
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
61
+ await upstreamServer.connect(serverSide);
62
+
63
+ const upstreamClient = new Client(
64
+ { name: "test-upstream-client", version: "0.0.1" },
65
+ { capabilities: {} },
66
+ );
67
+ await upstreamClient.connect(clientSide);
68
+
69
+ return { upstreamServer, upstreamClient };
70
+ }
71
+
72
+ /** Build a test MCP client paired to the proxy's server-side transport. */
73
+ async function buildConsumerClient() {
74
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
75
+ const consumerClient = new Client(
76
+ { name: "test-consumer", version: "0.0.1" },
77
+ { capabilities: {} },
78
+ );
79
+ return { consumerClient, clientSide, serverSide };
80
+ }
81
+
82
+ async function startTestProxy({
83
+ allowList,
84
+ capabilities = SAMPLE_CAPS,
85
+ riskOverrides,
86
+ storagePath,
87
+ } = {}) {
88
+ const upstreamCallLog = [];
89
+ const auditLog = [];
90
+ const receipts = [];
91
+ const upstream = await buildFakeUpstream(upstreamCallLog);
92
+ const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
93
+
94
+ const rules = {};
95
+ if (allowList) for (const id of allowList) rules[id] = "ALLOW";
96
+
97
+ // Default to a per-test tmpdir so we never pollute the dev's home dir with
98
+ // ~/.strix-mcp-proxy/demo/. Callers can override (incl. passing `null` to
99
+ // exercise the explicit ephemeral opt-out path).
100
+ const resolvedStoragePath =
101
+ storagePath === undefined
102
+ ? await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-test-"))
103
+ : storagePath;
104
+
105
+ const handle = await startProxy({
106
+ serverId: "demo",
107
+ capabilities,
108
+ policy: { rules, riskOverrides, default: "DENY" },
109
+ storagePath: resolvedStoragePath,
110
+ onReceipt: (r) => receipts.push(r),
111
+ onAudit: (e) => auditLog.push(e),
112
+ transport: {
113
+ kind: "test",
114
+ upstreamClient: upstream.upstreamClient,
115
+ serverTransport: serverSide,
116
+ },
117
+ });
118
+ await consumerClient.connect(clientSide);
119
+
120
+ return {
121
+ consumer: consumerClient,
122
+ proxy: handle,
123
+ upstream,
124
+ upstreamCallLog,
125
+ receipts,
126
+ auditLog,
127
+ storagePath: resolvedStoragePath,
128
+ cleanup: async () => {
129
+ try { await consumerClient.close(); } catch { /* */ }
130
+ try { await handle.stop(); } catch { /* */ }
131
+ try { await upstream.upstreamClient.close(); } catch { /* */ }
132
+ try { await upstream.upstreamServer.close(); } catch { /* */ }
133
+ if (resolvedStoragePath && resolvedStoragePath.startsWith(os.tmpdir())) {
134
+ try { await fs.rm(resolvedStoragePath, { recursive: true, force: true }); } catch { /* */ }
135
+ }
136
+ },
137
+ };
138
+ }
139
+
140
+ test("listTools surfaces the upstream's tool descriptors verbatim (governance does not reshape tools)", async () => {
141
+ const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
142
+ try {
143
+ const result = await ctx.consumer.listTools();
144
+ const names = result.tools.map((t) => t.name).sort();
145
+ assert.deepEqual(names, ["delete_thing", "read_thing", "write_thing"]);
146
+ // Description preserved end-to-end.
147
+ const readTool = result.tools.find((t) => t.name === "read_thing");
148
+ assert.equal(readTool.description, "Read a thing");
149
+ } finally {
150
+ await ctx.cleanup();
151
+ }
152
+ });
153
+
154
+ test("ALLOW: callTool reaches the upstream and the response round-trips", async () => {
155
+ const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
156
+ try {
157
+ const result = await ctx.consumer.callTool({
158
+ name: "read_thing",
159
+ arguments: { id: "x" },
160
+ });
161
+ assert.equal(result.content[0].text, "executed read_thing");
162
+ assert.equal(ctx.upstreamCallLog.length, 1);
163
+ assert.deepEqual(ctx.upstreamCallLog[0], { name: "read_thing", args: { id: "x" } });
164
+ } finally {
165
+ await ctx.cleanup();
166
+ }
167
+ });
168
+
169
+ test("DENY: callTool fails AND the upstream is never invoked AND a signed receipt is still emitted", async () => {
170
+ const ctx = await startTestProxy({}); // no allowlist → DENY default
171
+ try {
172
+ await assert.rejects(
173
+ () => ctx.consumer.callTool({ name: "delete_thing", arguments: { id: "x" } }),
174
+ /denied/i,
175
+ );
176
+ assert.equal(ctx.upstreamCallLog.length, 0, "upstream must not be invoked on DENY");
177
+ assert.equal(ctx.receipts.length, 1);
178
+ assert.equal(ctx.receipts[0].decision, "DENY");
179
+ assert.ok(ctx.receipts[0].signature, "denied calls still produce a signed receipt");
180
+ } finally {
181
+ await ctx.cleanup();
182
+ }
183
+ });
184
+
185
+ test("companion-pack canonical id is used in the receipt (not heuristic-derived)", async () => {
186
+ const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
187
+ try {
188
+ await ctx.consumer.callTool({ name: "read_thing", arguments: {} });
189
+ assert.equal(ctx.receipts[0].capabilityId, "mcp.demo.read_thing");
190
+ assert.equal(ctx.receipts[0].decision, "ALLOW");
191
+ } finally {
192
+ await ctx.cleanup();
193
+ }
194
+ });
195
+
196
+ test("actor context from _meta is forwarded to the receipt", async () => {
197
+ const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
198
+ try {
199
+ await ctx.consumer.callTool({
200
+ name: "read_thing",
201
+ arguments: { id: "x" },
202
+ _meta: { strix_actor_id: "agent-demo-1" },
203
+ });
204
+ assert.equal(ctx.receipts[0].actorId, "agent-demo-1");
205
+ } finally {
206
+ await ctx.cleanup();
207
+ }
208
+ });
209
+
210
+ test("audit events fire for lifecycle and for denied calls", async () => {
211
+ const ctx = await startTestProxy({});
212
+ try {
213
+ await assert.rejects(() => ctx.consumer.callTool({ name: "delete_thing", arguments: {} }));
214
+ const kinds = ctx.auditLog.map((e) => e.kind);
215
+ assert.ok(kinds.includes("upstream.connected"));
216
+ assert.ok(kinds.includes("proxy.listening"));
217
+ assert.ok(kinds.includes("call.denied"));
218
+ } finally {
219
+ await ctx.cleanup();
220
+ }
221
+ });
222
+
223
+ test("riskOverrides gate the whole CRITICAL surface in one rule", async () => {
224
+ const ctx = await startTestProxy({
225
+ riskOverrides: { LOW: "ALLOW", MEDIUM: "DENY", CRITICAL: "DENY" },
226
+ });
227
+ try {
228
+ await ctx.consumer.callTool({ name: "read_thing", arguments: {} }); // LOW → ALLOW
229
+ await assert.rejects(() => ctx.consumer.callTool({ name: "write_thing", arguments: {} })); // MEDIUM → DENY
230
+ await assert.rejects(() => ctx.consumer.callTool({ name: "delete_thing", arguments: {} })); // CRITICAL → DENY
231
+ const decisions = ctx.receipts.map((r) => r.decision);
232
+ assert.deepEqual(decisions, ["ALLOW", "DENY", "DENY"]);
233
+ } finally {
234
+ await ctx.cleanup();
235
+ }
236
+ });
237
+
238
+ test("startProxy validates required opts.upstream.command in stdio mode", async () => {
239
+ await assert.rejects(
240
+ () => startProxy({}),
241
+ /opts\.upstream\.command is required/,
242
+ );
243
+ });
244
+
245
+ test("startProxy validates transport.serverTransport in test mode", async () => {
246
+ const upstream = await buildFakeUpstream([]);
247
+ try {
248
+ await assert.rejects(
249
+ () => startProxy({
250
+ serverId: "demo",
251
+ transport: { kind: "test", upstreamClient: upstream.upstreamClient },
252
+ policy: { default: "DENY" },
253
+ // storagePath: null so this purely-validation test doesn't write a
254
+ // signing key to the dev's home directory before throwing.
255
+ storagePath: null,
256
+ }),
257
+ /serverTransport/,
258
+ );
259
+ } finally {
260
+ await upstream.upstreamClient.close();
261
+ await upstream.upstreamServer.close();
262
+ }
263
+ });
264
+
265
+ // ─── Persistent signing key (when storagePath is set) ─────────────────
266
+ //
267
+ // Pre-0.1.2 startProxy used an in-memory ephemeral signing key for
268
+ // every run — meaning receipts written to JSONL on disk could never
269
+ // be verified after the proxy exited, because the key that signed them
270
+ // was discarded with the process. These tests pin the persistent-key
271
+ // path: when storagePath is set, the keypair is written to
272
+ // `<storagePath>/keys/` so the SAME kid keeps signing receipts across
273
+ // restarts, and an external verifier can be pointed at
274
+ // `<storagePath>/keys/public-jwk.json`.
275
+
276
+ async function tempDir(label) {
277
+ return await fs.mkdtemp(path.join(os.tmpdir(), `strix-proxy-${label}-`));
278
+ }
279
+
280
+ async function startProxyWithStorage(storagePath) {
281
+ const upstreamCallLog = [];
282
+ const upstream = await buildFakeUpstream(upstreamCallLog);
283
+ const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
284
+
285
+ const handle = await startProxy({
286
+ serverId: "demo",
287
+ capabilities: SAMPLE_CAPS,
288
+ policy: { rules: { "mcp.demo.read_thing": "ALLOW" }, default: "DENY" },
289
+ storagePath,
290
+ transport: {
291
+ kind: "test",
292
+ upstreamClient: upstream.upstreamClient,
293
+ serverTransport: serverSide,
294
+ },
295
+ });
296
+ await consumerClient.connect(clientSide);
297
+
298
+ return {
299
+ consumer: consumerClient,
300
+ proxy: handle,
301
+ upstream,
302
+ cleanup: async () => {
303
+ try { await consumerClient.close(); } catch { /* */ }
304
+ try { await handle.stop(); } catch { /* */ }
305
+ try { await upstream.upstreamClient.close(); } catch { /* */ }
306
+ try { await upstream.upstreamServer.close(); } catch { /* */ }
307
+ },
308
+ };
309
+ }
310
+
311
+ test("storagePath causes signing key + public JWK to be persisted to disk", async () => {
312
+ const dir = await tempDir("persist");
313
+ const ctx = await startProxyWithStorage(dir);
314
+ try {
315
+ const keyDir = path.join(dir, "keys");
316
+ const pemPath = path.join(keyDir, "signing-key.pem");
317
+ const jwkPath = path.join(keyDir, "public-jwk.json");
318
+
319
+ // Both files must exist after startProxy returns.
320
+ await fs.access(pemPath);
321
+ await fs.access(jwkPath);
322
+
323
+ // Public JWK is well-formed and uses the expected kid convention.
324
+ const jwk = JSON.parse(await fs.readFile(jwkPath, "utf8"));
325
+ assert.equal(jwk.kty, "OKP");
326
+ assert.equal(jwk.crv, "Ed25519");
327
+ assert.equal(jwk.kid, "strix-demo");
328
+ assert.equal(typeof jwk.x, "string");
329
+ assert.ok(jwk.x.length > 0);
330
+
331
+ // Private key file content is PKCS8 PEM (cheap shape check — full
332
+ // crypto round-trip is exercised by tool-gateway's own tests).
333
+ const pem = await fs.readFile(pemPath, "utf8");
334
+ assert.match(pem, /^-----BEGIN PRIVATE KEY-----/);
335
+ } finally {
336
+ await ctx.cleanup();
337
+ await fs.rm(dir, { recursive: true, force: true });
338
+ }
339
+ });
340
+
341
+ test("persistent signing kid survives a proxy restart (the demo-story invariant)", async () => {
342
+ const dir = await tempDir("restart");
343
+ try {
344
+ // First start — writes the keypair.
345
+ const ctx1 = await startProxyWithStorage(dir);
346
+ const kid1 = ctx1.proxy.signingKey.kid;
347
+ const pubX1 = ctx1.proxy.signingKey.publicKeyJwk.x;
348
+ await ctx1.cleanup();
349
+
350
+ // Second start in the same storagePath — must reuse, not regenerate.
351
+ const ctx2 = await startProxyWithStorage(dir);
352
+ const kid2 = ctx2.proxy.signingKey.kid;
353
+ const pubX2 = ctx2.proxy.signingKey.publicKeyJwk.x;
354
+ await ctx2.cleanup();
355
+
356
+ assert.equal(kid1, "strix-demo");
357
+ assert.equal(kid2, "strix-demo");
358
+ // Same public key bytes ⇒ same private key on disk was reused.
359
+ assert.equal(pubX1, pubX2, "second start must reuse the persisted key");
360
+ } finally {
361
+ await fs.rm(dir, { recursive: true, force: true });
362
+ }
363
+ });
364
+
365
+ test("caller-supplied opts.signingKey wins over storagePath persistence", async () => {
366
+ // Caller-supplied keys bypass disk persistence entirely. This pins the
367
+ // ordering documented in proxy.mjs §3a — operators integrating with a
368
+ // KMS or secrets manager shouldn't be surprised by a key file showing
369
+ // up on disk because storagePath happens to be set.
370
+ const { generateSigningKey } = await import("@strixgov/tool-gateway");
371
+ const explicitKey = generateSigningKey("custom-kid");
372
+ const dir = await tempDir("explicit-wins");
373
+ const upstream = await buildFakeUpstream([]);
374
+ const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
375
+ try {
376
+ const handle = await startProxy({
377
+ serverId: "demo",
378
+ capabilities: SAMPLE_CAPS,
379
+ policy: { default: "DENY" },
380
+ storagePath: dir,
381
+ signingKey: explicitKey,
382
+ transport: {
383
+ kind: "test",
384
+ upstreamClient: upstream.upstreamClient,
385
+ serverTransport: serverSide,
386
+ },
387
+ });
388
+ await consumerClient.connect(clientSide);
389
+ try {
390
+ assert.equal(handle.signingKey.kid, "custom-kid");
391
+ // No keys/ subdirectory should have been created — disk persistence
392
+ // is skipped when the caller supplied a key explicitly.
393
+ await assert.rejects(
394
+ () => fs.access(path.join(dir, "keys", "public-jwk.json")),
395
+ /ENOENT/,
396
+ );
397
+ } finally {
398
+ await consumerClient.close();
399
+ await handle.stop();
400
+ }
401
+ } finally {
402
+ await upstream.upstreamClient.close();
403
+ await upstream.upstreamServer.close();
404
+ await fs.rm(dir, { recursive: true, force: true });
405
+ }
406
+ });
407
+
408
+ test("storagePath unset ⇒ defaults to ~/.strix-mcp-proxy/<serverId> with persistent key", async () => {
409
+ // v0.1.4: leaving storagePath undefined no longer falls back to ephemeral.
410
+ // The proxy now defaults storagePath to a per-serverId persistent path so
411
+ // receipts on disk are always verifiable across restarts. Use a tmpdir
412
+ // override here instead of HOME so the test stays hermetic.
413
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-default-"));
414
+ try {
415
+ const ctx = await startTestProxy({
416
+ allowList: ["mcp.demo.read_thing"],
417
+ storagePath: dir,
418
+ });
419
+ try {
420
+ assert.ok(ctx.proxy.signingKey, "signingKey must be present by default");
421
+ assert.equal(ctx.proxy.signingKey.kid, "strix-demo");
422
+ // The key file must exist on disk where the verifier expects it.
423
+ const jwkPath = path.join(dir, "keys", "public-jwk.json");
424
+ const jwk = JSON.parse(await fs.readFile(jwkPath, "utf8"));
425
+ assert.equal(jwk.kid, "strix-demo");
426
+ assert.equal(jwk.kty, "OKP");
427
+ } finally {
428
+ await ctx.cleanup();
429
+ }
430
+ } finally {
431
+ await fs.rm(dir, { recursive: true, force: true });
432
+ }
433
+ });
434
+
435
+ test("storagePath: null ⇒ explicit ephemeral opt-out (no disk writes)", async () => {
436
+ // Documented escape hatch for callers who really do want an in-memory
437
+ // ephemeral key (CI smoke runs, demos). Receipts in this mode are chain-
438
+ // coherent within the session but unverifiable across restarts; that's
439
+ // the trade-off the caller is opting into.
440
+ const ctx = await startTestProxy({
441
+ allowList: ["mcp.demo.read_thing"],
442
+ storagePath: null,
443
+ });
444
+ try {
445
+ assert.ok(ctx.proxy.signingKey, "signingKey must still be present (ephemeral)");
446
+ assert.equal(ctx.proxy.signingKey.kid, "strix-demo");
447
+ } finally {
448
+ await ctx.cleanup();
449
+ }
450
+ });
451
+
452
+ // ─── Approval resolution (JSON-config friendly approver types) ────────
453
+ //
454
+ // Pre-0.1.3 startProxy only accepted `approval.prompt` as a function —
455
+ // which made the JSON-config path useless for headless approval, since
456
+ // JSON can't carry a function. 0.1.3 adds string-typed approvers
457
+ // (`type: "file" | "auto" | "terminal"`) that the proxy translates to
458
+ // the appropriate @strixgov/tool-gateway primitive. These tests pin the
459
+ // resolution rules.
460
+
461
+ async function startProxyWithApproval(approval, extraOpts = {}) {
462
+ const upstreamCallLog = [];
463
+ const upstream = await buildFakeUpstream(upstreamCallLog);
464
+ const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
465
+
466
+ // v0.1.4: storagePath now defaults to ~/.strix-mcp-proxy/<serverId>, so
467
+ // approval-config tests would pollute the dev's home dir unless we route
468
+ // to a tmpdir. Approval-resolution tests don't care about the path —
469
+ // they only check that the approver function is wired correctly — so a
470
+ // tmpdir override is the right boundary here. Callers can still pass
471
+ // their own `storagePath` via extraOpts.
472
+ const tmpStoragePath =
473
+ extraOpts.storagePath === undefined
474
+ ? await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-approval-"))
475
+ : extraOpts.storagePath;
476
+
477
+ const handle = await startProxy({
478
+ serverId: "demo",
479
+ capabilities: SAMPLE_CAPS,
480
+ // write_thing is MEDIUM. With `MEDIUM → APPROVAL_REQUIRED`, every
481
+ // write_thing call should hit the approval path so these tests can
482
+ // observe whether the approver was wired correctly.
483
+ policy: {
484
+ rules: { "mcp.demo.read_thing": "ALLOW" },
485
+ riskOverrides: { MEDIUM: "APPROVAL_REQUIRED" },
486
+ default: "DENY",
487
+ },
488
+ approval,
489
+ transport: {
490
+ kind: "test",
491
+ upstreamClient: upstream.upstreamClient,
492
+ serverTransport: serverSide,
493
+ },
494
+ ...extraOpts,
495
+ storagePath: tmpStoragePath,
496
+ });
497
+ await consumerClient.connect(clientSide);
498
+
499
+ return {
500
+ consumer: consumerClient,
501
+ proxy: handle,
502
+ upstream,
503
+ upstreamCallLog,
504
+ storagePath: tmpStoragePath,
505
+ cleanup: async () => {
506
+ try { await consumerClient.close(); } catch { /* */ }
507
+ try { await handle.stop(); } catch { /* */ }
508
+ try { await upstream.upstreamClient.close(); } catch { /* */ }
509
+ try { await upstream.upstreamServer.close(); } catch { /* */ }
510
+ if (tmpStoragePath && tmpStoragePath.startsWith(os.tmpdir())) {
511
+ try { await fs.rm(tmpStoragePath, { recursive: true, force: true }); } catch { /* */ }
512
+ }
513
+ },
514
+ };
515
+ }
516
+
517
+ test("approval.type: 'auto' auto-approves MEDIUM calls and they reach the upstream", async () => {
518
+ const ctx = await startProxyWithApproval({ enabled: true, type: "auto" });
519
+ try {
520
+ const result = await ctx.consumer.callTool({
521
+ name: "write_thing",
522
+ arguments: { id: "x" },
523
+ });
524
+ assert.equal(result.content[0].text, "executed write_thing");
525
+ assert.equal(ctx.upstreamCallLog.length, 1, "auto-approved write must reach the upstream");
526
+ } finally {
527
+ await ctx.cleanup();
528
+ }
529
+ });
530
+
531
+ test("approval.autoApprove: true (long form) behaves like type: 'auto'", async () => {
532
+ // Pin that the long form continues to work — operators who already
533
+ // wrote `autoApprove: true` shouldn't get broken by the new resolver.
534
+ const ctx = await startProxyWithApproval({ enabled: true, autoApprove: true });
535
+ try {
536
+ await ctx.consumer.callTool({ name: "write_thing", arguments: {} });
537
+ assert.equal(ctx.upstreamCallLog.length, 1);
538
+ } finally {
539
+ await ctx.cleanup();
540
+ }
541
+ });
542
+
543
+ test("approval.type: 'file' wires up a fileApprover and writes a request file to disk", async () => {
544
+ const requestDir = await tempDir("file-approver");
545
+ const ctx = await startProxyWithApproval(
546
+ {
547
+ enabled: true,
548
+ type: "file",
549
+ requestDir,
550
+ timeoutMs: 250, // short timeout so the test doesn't hang
551
+ },
552
+ );
553
+ try {
554
+ // No out-of-band approval response is written → expect timeout → DENY.
555
+ // What we're verifying here is structural: the proxy did the request-file
556
+ // write, which means the fileApprover was constructed and called.
557
+ await assert.rejects(
558
+ () => ctx.consumer.callTool({ name: "write_thing", arguments: { id: "x" } }),
559
+ /denied|timeout/i,
560
+ );
561
+ assert.equal(ctx.upstreamCallLog.length, 0, "denied call must not reach upstream");
562
+ } finally {
563
+ await ctx.cleanup();
564
+ await fs.rm(requestDir, { recursive: true, force: true });
565
+ }
566
+ });
567
+
568
+ test("approval.type: 'file' with no requestDir defaults to <storagePath>/approvals", async () => {
569
+ const storagePath = await tempDir("file-approver-default");
570
+ const ctx = await startProxyWithApproval(
571
+ { enabled: true, type: "file", timeoutMs: 250 },
572
+ { storagePath },
573
+ );
574
+ try {
575
+ await assert.rejects(
576
+ () => ctx.consumer.callTool({ name: "write_thing", arguments: {} }),
577
+ /denied|timeout/i,
578
+ );
579
+ // fileApprover cleans up request/response files on each call, so we
580
+ // can't assert their persistence. But the approvals directory itself
581
+ // is created on first request — that's enough structural evidence.
582
+ const dirExists = await fs
583
+ .access(path.join(storagePath, "approvals"))
584
+ .then(() => true)
585
+ .catch(() => false);
586
+ assert.ok(dirExists, "<storagePath>/approvals directory must be created");
587
+ } finally {
588
+ await ctx.cleanup();
589
+ await fs.rm(storagePath, { recursive: true, force: true });
590
+ }
591
+ });
592
+
593
+ test("approval.type: 'file' with ~/ requestDir expands to os.homedir()", async () => {
594
+ // We don't want to actually write to the developer's home directory in
595
+ // a test, so use a custom HOME via os.tmpdir trick: temporarily replace
596
+ // os.homedir's return value via env. node's os.homedir respects $HOME
597
+ // on Unix and $USERPROFILE on Windows; setting both keeps the test
598
+ // platform-agnostic.
599
+ const fakeHome = await tempDir("fake-home");
600
+ const origHome = process.env.HOME;
601
+ const origUserProfile = process.env.USERPROFILE;
602
+ process.env.HOME = fakeHome;
603
+ process.env.USERPROFILE = fakeHome;
604
+ try {
605
+ const ctx = await startProxyWithApproval({
606
+ enabled: true,
607
+ type: "file",
608
+ requestDir: "~/strix-approvals-test",
609
+ timeoutMs: 250,
610
+ });
611
+ try {
612
+ await assert.rejects(() => ctx.consumer.callTool({ name: "write_thing", arguments: {} }));
613
+ const exists = await fs
614
+ .access(path.join(fakeHome, "strix-approvals-test"))
615
+ .then(() => true)
616
+ .catch(() => false);
617
+ assert.ok(exists, "~/ in approval.requestDir must expand to homedir");
618
+ } finally {
619
+ await ctx.cleanup();
620
+ }
621
+ } finally {
622
+ process.env.HOME = origHome;
623
+ process.env.USERPROFILE = origUserProfile;
624
+ await fs.rm(fakeHome, { recursive: true, force: true });
625
+ }
626
+ });
627
+
628
+ test("approval.type: 'terminal' is the explicit legacy default (passes through; denies in non-TTY)", async () => {
629
+ // Under `node --test` neither stdio end is a TTY, so terminalApprove's
630
+ // "non-TTY → PROMPT_FAILED" guard fires immediately and nothing is written
631
+ // to stdout. That guard used to be unreachable (it compared `isTTY ===
632
+ // false`, but Node leaves `isTTY` undefined on a pipe rather than setting
633
+ // it false), so this test instead fell through to a real prompt banner
634
+ // written into the runner's own serialized stdout channel — which is what
635
+ // made this FILE intermittently fail with "Unable to deserialize cloned
636
+ // data", ~1% of runs and much more often under CPU load. See
637
+ // tool-gateway's approval.test.mjs for the guard's own regression pins.
638
+ //
639
+ // We're pinning "type: 'terminal' is passed through to the gateway and
640
+ // denies headlessly", not the specific deny reason — hence the loose regex.
641
+ // timeoutMs stays low so a future regression that DOES reach the prompt
642
+ // path fails fast instead of sitting for 60s.
643
+ const ctx = await startProxyWithApproval({
644
+ enabled: true,
645
+ type: "terminal",
646
+ timeoutMs: 250,
647
+ });
648
+ try {
649
+ await assert.rejects(
650
+ () => ctx.consumer.callTool({ name: "write_thing", arguments: {} }),
651
+ /denied|timeout/i,
652
+ );
653
+ assert.equal(ctx.upstreamCallLog.length, 0);
654
+ } finally {
655
+ await ctx.cleanup();
656
+ }
657
+ });
658
+
659
+ test("approval.type: unknown ⇒ startProxy throws at startup (fail-fast, not silently fail-closed)", async () => {
660
+ // The whole point of typed config is to surface misconfiguration BEFORE
661
+ // the proxy starts denying calls in production. Operators who type
662
+ // `"type": "filee"` should see an immediate startup error, not a silent
663
+ // deny loop.
664
+ const upstream = await buildFakeUpstream([]);
665
+ const { serverSide } = await buildConsumerClient();
666
+ try {
667
+ await assert.rejects(
668
+ () =>
669
+ startProxy({
670
+ serverId: "demo",
671
+ capabilities: SAMPLE_CAPS,
672
+ policy: { default: "DENY" },
673
+ approval: { enabled: true, type: "websocket-pony" },
674
+ transport: {
675
+ kind: "test",
676
+ upstreamClient: upstream.upstreamClient,
677
+ serverTransport: serverSide,
678
+ },
679
+ // storagePath: null so this fail-fast validation test doesn't
680
+ // write a signing key to the dev's home directory before the
681
+ // approval.type error throws.
682
+ storagePath: null,
683
+ }),
684
+ /unknown approval\.type/,
685
+ );
686
+ } finally {
687
+ await upstream.upstreamClient.close();
688
+ await upstream.upstreamServer.close();
689
+ }
690
+ });
691
+
692
+ test("approval.prompt (function) wins over approval.type (programmatic caller priority)", async () => {
693
+ // If a programmatic caller (e.g. embedded SDK use) passes a prompt
694
+ // function directly, the proxy's string-to-function translator must
695
+ // NOT overwrite it. Verifies the resolution-order docstring's claim
696
+ // that prompt-fn is rule #1.
697
+ let promptCallCount = 0;
698
+ const customPrompt = async () => {
699
+ promptCallCount++;
700
+ return { approved: true, reason: "USER_APPROVED", approvedBy: "custom" };
701
+ };
702
+
703
+ const ctx = await startProxyWithApproval({
704
+ enabled: true,
705
+ type: "auto", // would normally autoApprove without ever calling prompt
706
+ prompt: customPrompt, // but this should win
707
+ });
708
+ try {
709
+ await ctx.consumer.callTool({ name: "write_thing", arguments: {} });
710
+ assert.equal(promptCallCount, 1, "custom prompt must be called, not autoApprove path");
711
+ assert.equal(ctx.upstreamCallLog.length, 1);
712
+ } finally {
713
+ await ctx.cleanup();
714
+ }
715
+ });