@vellumai/credential-executor 0.4.55

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 (42) hide show
  1. package/Dockerfile +55 -0
  2. package/bun.lock +37 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/command-executor.test.ts +1333 -0
  5. package/src/__tests__/command-validator.test.ts +708 -0
  6. package/src/__tests__/command-workspace.test.ts +997 -0
  7. package/src/__tests__/grant-store.test.ts +467 -0
  8. package/src/__tests__/http-executor.test.ts +1251 -0
  9. package/src/__tests__/http-policy.test.ts +970 -0
  10. package/src/__tests__/local-materializers.test.ts +826 -0
  11. package/src/__tests__/managed-materializers.test.ts +961 -0
  12. package/src/__tests__/toolstore.test.ts +539 -0
  13. package/src/__tests__/transport.test.ts +388 -0
  14. package/src/audit/store.ts +188 -0
  15. package/src/commands/auth-adapters.ts +169 -0
  16. package/src/commands/executor.ts +840 -0
  17. package/src/commands/output-scan.ts +157 -0
  18. package/src/commands/profiles.ts +282 -0
  19. package/src/commands/validator.ts +438 -0
  20. package/src/commands/workspace.ts +512 -0
  21. package/src/grants/index.ts +17 -0
  22. package/src/grants/persistent-store.ts +247 -0
  23. package/src/grants/rpc-handlers.ts +269 -0
  24. package/src/grants/temporary-store.ts +219 -0
  25. package/src/http/audit.ts +84 -0
  26. package/src/http/executor.ts +540 -0
  27. package/src/http/path-template.ts +179 -0
  28. package/src/http/policy.ts +256 -0
  29. package/src/http/response-filter.ts +233 -0
  30. package/src/index.ts +106 -0
  31. package/src/main.ts +263 -0
  32. package/src/managed-main.ts +420 -0
  33. package/src/materializers/local.ts +300 -0
  34. package/src/materializers/managed-platform.ts +270 -0
  35. package/src/paths.ts +137 -0
  36. package/src/server.ts +636 -0
  37. package/src/subjects/local.ts +177 -0
  38. package/src/subjects/managed.ts +290 -0
  39. package/src/toolstore/integrity.ts +94 -0
  40. package/src/toolstore/manifest.ts +154 -0
  41. package/src/toolstore/publish.ts +342 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,970 @@
1
+ /**
2
+ * Tests for HTTP policy evaluation, path-template derivation, and
3
+ * response filtering.
4
+ *
5
+ * Covers:
6
+ * - Path template derivation: numeric, UUID, hex placeholder replacement,
7
+ * query/fragment stripping, trailing slash normalisation.
8
+ * - URL-to-template matching.
9
+ * - HTTP policy evaluation: grant matching, forbidden header rejection,
10
+ * proposal generation when no grant matches.
11
+ * - Response filtering: header whitelisting, body clamping, secret scrubbing.
12
+ * - Audit summary generation: token-free output.
13
+ */
14
+
15
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
16
+ import { randomUUID } from "node:crypto";
17
+
18
+ import {
19
+ derivePathTemplate,
20
+ deriveAllowedUrlPatterns,
21
+ urlMatchesTemplate,
22
+ } from "../http/path-template.js";
23
+ import {
24
+ evaluateHttpPolicy,
25
+ detectForbiddenHeaders,
26
+ type HttpPolicyRequest,
27
+ } from "../http/policy.js";
28
+ import {
29
+ filterHttpResponse,
30
+ filterResponseHeaders,
31
+ clampBody,
32
+ scrubSecrets,
33
+ type RawHttpResponse,
34
+ } from "../http/response-filter.js";
35
+ import { generateHttpAuditSummary } from "../http/audit.js";
36
+ import { PersistentGrantStore } from "../grants/persistent-store.js";
37
+ import { TemporaryGrantStore } from "../grants/temporary-store.js";
38
+
39
+ import { mkdirSync, rmSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { tmpdir } from "node:os";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function makeTmpDir(): string {
48
+ const dir = join(tmpdir(), `ces-http-policy-test-${randomUUID()}`);
49
+ mkdirSync(dir, { recursive: true });
50
+ return dir;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Path template derivation
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("derivePathTemplate", () => {
58
+ test("preserves literal path segments", () => {
59
+ const result = derivePathTemplate("https://api.github.com/repos/owner/repo/pulls");
60
+ expect(result).toBe("https://api.github.com/repos/owner/repo/pulls");
61
+ });
62
+
63
+ test("replaces numeric segments with {:num}", () => {
64
+ const result = derivePathTemplate("https://api.example.com/users/42/posts/123");
65
+ expect(result).toBe("https://api.example.com/users/{:num}/posts/{:num}");
66
+ });
67
+
68
+ test("replaces UUID segments with {:uuid}", () => {
69
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
70
+ const result = derivePathTemplate(`https://api.example.com/resources/${uuid}`);
71
+ expect(result).toBe("https://api.example.com/resources/{:uuid}");
72
+ });
73
+
74
+ test("replaces long hex segments with {:hex}", () => {
75
+ const hex = "abcdef0123456789abcdef01";
76
+ const result = derivePathTemplate(`https://api.example.com/commits/${hex}`);
77
+ expect(result).toBe("https://api.example.com/commits/{:hex}");
78
+ });
79
+
80
+ test("does not replace short hex-like segments", () => {
81
+ // "cafe" is only 4 chars — should remain literal
82
+ const result = derivePathTemplate("https://api.example.com/items/cafe");
83
+ expect(result).toBe("https://api.example.com/items/cafe");
84
+ });
85
+
86
+ test("strips query string", () => {
87
+ const result = derivePathTemplate("https://api.example.com/search?q=test&page=1");
88
+ expect(result).toBe("https://api.example.com/search");
89
+ });
90
+
91
+ test("strips fragment", () => {
92
+ const result = derivePathTemplate("https://api.example.com/docs#section-1");
93
+ expect(result).toBe("https://api.example.com/docs");
94
+ });
95
+
96
+ test("strips both query and fragment", () => {
97
+ const result = derivePathTemplate("https://api.example.com/path?q=x#frag");
98
+ expect(result).toBe("https://api.example.com/path");
99
+ });
100
+
101
+ test("normalises trailing slash", () => {
102
+ const result = derivePathTemplate("https://api.example.com/repos/");
103
+ expect(result).toBe("https://api.example.com/repos");
104
+ });
105
+
106
+ test("handles root path", () => {
107
+ const result = derivePathTemplate("https://api.example.com/");
108
+ expect(result).toBe("https://api.example.com/");
109
+ });
110
+
111
+ test("preserves port numbers", () => {
112
+ const result = derivePathTemplate("https://localhost:3000/api/v1/users/42");
113
+ expect(result).toBe("https://localhost:3000/api/v1/users/{:num}");
114
+ });
115
+
116
+ test("handles http scheme", () => {
117
+ const result = derivePathTemplate("http://internal.service/data/123");
118
+ expect(result).toBe("http://internal.service/data/{:num}");
119
+ });
120
+
121
+ test("handles mixed segment types", () => {
122
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
123
+ const hex = "abcdef0123456789abcdef";
124
+ const result = derivePathTemplate(
125
+ `https://api.example.com/orgs/acme/repos/${uuid}/commits/${hex}/files/42`,
126
+ );
127
+ expect(result).toBe(
128
+ "https://api.example.com/orgs/acme/repos/{:uuid}/commits/{:hex}/files/{:num}",
129
+ );
130
+ });
131
+
132
+ test("throws on invalid URL", () => {
133
+ expect(() => derivePathTemplate("not-a-url")).toThrow();
134
+ });
135
+
136
+ test("never produces wildcard or /* patterns", () => {
137
+ // Verify that no possible input produces /* or host wildcards
138
+ const urls = [
139
+ "https://api.example.com/",
140
+ "https://api.example.com/a/b/c",
141
+ "https://api.example.com/users/42",
142
+ ];
143
+ for (const url of urls) {
144
+ const result = derivePathTemplate(url);
145
+ expect(result).not.toContain("/*");
146
+ expect(result).not.toContain("*.");
147
+ }
148
+ });
149
+ });
150
+
151
+ describe("deriveAllowedUrlPatterns", () => {
152
+ test("returns array with single pattern", () => {
153
+ const result = deriveAllowedUrlPatterns("https://api.example.com/users/42");
154
+ expect(result).toHaveLength(1);
155
+ expect(result[0]).toBe("https://api.example.com/users/{:num}");
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // URL-to-template matching
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("urlMatchesTemplate", () => {
164
+ test("matches literal paths exactly", () => {
165
+ expect(
166
+ urlMatchesTemplate(
167
+ "https://api.github.com/repos/owner/repo",
168
+ "https://api.github.com/repos/owner/repo",
169
+ ),
170
+ ).toBe(true);
171
+ });
172
+
173
+ test("matches {:num} placeholder against numeric segments", () => {
174
+ expect(
175
+ urlMatchesTemplate(
176
+ "https://api.example.com/users/42",
177
+ "https://api.example.com/users/{:num}",
178
+ ),
179
+ ).toBe(true);
180
+ });
181
+
182
+ test("rejects non-numeric segment against {:num}", () => {
183
+ expect(
184
+ urlMatchesTemplate(
185
+ "https://api.example.com/users/alice",
186
+ "https://api.example.com/users/{:num}",
187
+ ),
188
+ ).toBe(false);
189
+ });
190
+
191
+ test("matches {:uuid} placeholder against UUID segments", () => {
192
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
193
+ expect(
194
+ urlMatchesTemplate(
195
+ `https://api.example.com/resources/${uuid}`,
196
+ "https://api.example.com/resources/{:uuid}",
197
+ ),
198
+ ).toBe(true);
199
+ });
200
+
201
+ test("rejects non-UUID segment against {:uuid}", () => {
202
+ expect(
203
+ urlMatchesTemplate(
204
+ "https://api.example.com/resources/not-a-uuid",
205
+ "https://api.example.com/resources/{:uuid}",
206
+ ),
207
+ ).toBe(false);
208
+ });
209
+
210
+ test("matches {:hex} placeholder against long hex segments", () => {
211
+ expect(
212
+ urlMatchesTemplate(
213
+ "https://api.example.com/commits/abcdef0123456789abcdef01",
214
+ "https://api.example.com/commits/{:hex}",
215
+ ),
216
+ ).toBe(true);
217
+ });
218
+
219
+ test("rejects short hex against {:hex}", () => {
220
+ expect(
221
+ urlMatchesTemplate(
222
+ "https://api.example.com/commits/abcdef",
223
+ "https://api.example.com/commits/{:hex}",
224
+ ),
225
+ ).toBe(false);
226
+ });
227
+
228
+ test("rejects different path lengths", () => {
229
+ expect(
230
+ urlMatchesTemplate(
231
+ "https://api.example.com/users/42/extra",
232
+ "https://api.example.com/users/{:num}",
233
+ ),
234
+ ).toBe(false);
235
+ });
236
+
237
+ test("rejects different hosts", () => {
238
+ expect(
239
+ urlMatchesTemplate(
240
+ "https://evil.example.com/users/42",
241
+ "https://api.example.com/users/{:num}",
242
+ ),
243
+ ).toBe(false);
244
+ });
245
+
246
+ test("rejects different schemes", () => {
247
+ expect(
248
+ urlMatchesTemplate(
249
+ "http://api.example.com/users/42",
250
+ "https://api.example.com/users/{:num}",
251
+ ),
252
+ ).toBe(false);
253
+ });
254
+
255
+ test("host comparison is case-insensitive", () => {
256
+ expect(
257
+ urlMatchesTemplate(
258
+ "https://API.Example.COM/users/42",
259
+ "https://api.example.com/users/{:num}",
260
+ ),
261
+ ).toBe(true);
262
+ });
263
+
264
+ test("path comparison is case-sensitive for literals", () => {
265
+ expect(
266
+ urlMatchesTemplate(
267
+ "https://api.example.com/Users/42",
268
+ "https://api.example.com/users/{:num}",
269
+ ),
270
+ ).toBe(false);
271
+ });
272
+
273
+ test("ignores query string in the URL being matched", () => {
274
+ expect(
275
+ urlMatchesTemplate(
276
+ "https://api.example.com/users/42?include=profile",
277
+ "https://api.example.com/users/{:num}",
278
+ ),
279
+ ).toBe(true);
280
+ });
281
+
282
+ test("returns false for invalid URL", () => {
283
+ expect(urlMatchesTemplate("not-a-url", "https://example.com/")).toBe(false);
284
+ });
285
+
286
+ test("returns false for invalid template", () => {
287
+ expect(urlMatchesTemplate("https://example.com/", "not-a-url")).toBe(false);
288
+ });
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Forbidden header detection
293
+ // ---------------------------------------------------------------------------
294
+
295
+ describe("detectForbiddenHeaders", () => {
296
+ test("detects Authorization header", () => {
297
+ const result = detectForbiddenHeaders({ Authorization: "Bearer xyz" });
298
+ expect(result).toContain("Authorization");
299
+ });
300
+
301
+ test("detects Cookie header (case-insensitive)", () => {
302
+ const result = detectForbiddenHeaders({ cookie: "session=abc" });
303
+ expect(result).toContain("cookie");
304
+ });
305
+
306
+ test("detects Proxy-Authorization header", () => {
307
+ const result = detectForbiddenHeaders({
308
+ "Proxy-Authorization": "Basic abc",
309
+ });
310
+ expect(result).toContain("Proxy-Authorization");
311
+ });
312
+
313
+ test("detects X-Api-Key header", () => {
314
+ const result = detectForbiddenHeaders({ "X-Api-Key": "secret" });
315
+ expect(result).toContain("X-Api-Key");
316
+ });
317
+
318
+ test("detects X-Auth-Token header", () => {
319
+ const result = detectForbiddenHeaders({ "X-Auth-Token": "token" });
320
+ expect(result).toContain("X-Auth-Token");
321
+ });
322
+
323
+ test("returns empty array for safe headers", () => {
324
+ const result = detectForbiddenHeaders({
325
+ "Content-Type": "application/json",
326
+ Accept: "application/json",
327
+ });
328
+ expect(result).toEqual([]);
329
+ });
330
+
331
+ test("returns empty array for undefined headers", () => {
332
+ expect(detectForbiddenHeaders(undefined)).toEqual([]);
333
+ });
334
+
335
+ test("detects multiple forbidden headers", () => {
336
+ const result = detectForbiddenHeaders({
337
+ Authorization: "Bearer xyz",
338
+ Cookie: "session=abc",
339
+ "Content-Type": "application/json",
340
+ });
341
+ expect(result).toHaveLength(2);
342
+ expect(result).toContain("Authorization");
343
+ expect(result).toContain("Cookie");
344
+ });
345
+ });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // HTTP policy evaluation
349
+ // ---------------------------------------------------------------------------
350
+
351
+ describe("evaluateHttpPolicy", () => {
352
+ let tmpDir: string;
353
+ let persistentStore: PersistentGrantStore;
354
+ let temporaryStore: TemporaryGrantStore;
355
+
356
+ beforeEach(() => {
357
+ tmpDir = makeTmpDir();
358
+ persistentStore = new PersistentGrantStore(tmpDir);
359
+ persistentStore.init();
360
+ temporaryStore = new TemporaryGrantStore();
361
+ });
362
+
363
+ test("blocks requests with forbidden auth headers", () => {
364
+ const request: HttpPolicyRequest = {
365
+ credentialHandle: "local_static:github/api_key",
366
+ method: "GET",
367
+ url: "https://api.github.com/repos/owner/repo",
368
+ headers: { Authorization: "Bearer smuggled-token" },
369
+ purpose: "List repos",
370
+ };
371
+
372
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
373
+ expect(result.allowed).toBe(false);
374
+ if (!result.allowed) {
375
+ expect(result.reason).toBe("forbidden_headers");
376
+ }
377
+ });
378
+
379
+ test("allows request with matching persistent grant", () => {
380
+ persistentStore.add({
381
+ id: "grant-1",
382
+ tool: "http",
383
+ pattern: "GET https://api.github.com/repos/owner/repo",
384
+ scope: "local_static:github/api_key",
385
+ createdAt: Date.now(),
386
+ });
387
+
388
+ const request: HttpPolicyRequest = {
389
+ credentialHandle: "local_static:github/api_key",
390
+ method: "GET",
391
+ url: "https://api.github.com/repos/owner/repo",
392
+ purpose: "List repos",
393
+ };
394
+
395
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
396
+ expect(result.allowed).toBe(true);
397
+ if (result.allowed) {
398
+ expect(result.grantId).toBe("grant-1");
399
+ expect(result.grantSource).toBe("persistent");
400
+ }
401
+ });
402
+
403
+ test("allows request with explicit grantId", () => {
404
+ persistentStore.add({
405
+ id: "explicit-grant",
406
+ tool: "http",
407
+ pattern: "POST https://api.example.com/data",
408
+ scope: "local_static:svc/key",
409
+ createdAt: Date.now(),
410
+ });
411
+
412
+ const request: HttpPolicyRequest = {
413
+ credentialHandle: "local_static:svc/key",
414
+ method: "POST",
415
+ url: "https://api.example.com/data",
416
+ purpose: "Post data",
417
+ grantId: "explicit-grant",
418
+ };
419
+
420
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
421
+ expect(result.allowed).toBe(true);
422
+ if (result.allowed) {
423
+ expect(result.grantId).toBe("explicit-grant");
424
+ }
425
+ });
426
+
427
+ test("matches persistent grant with templated URL patterns", () => {
428
+ persistentStore.add({
429
+ id: "templated-grant",
430
+ tool: "http",
431
+ pattern: "GET https://api.example.com/users/{:num}/posts",
432
+ scope: "local_static:svc/key",
433
+ createdAt: Date.now(),
434
+ });
435
+
436
+ const request: HttpPolicyRequest = {
437
+ credentialHandle: "local_static:svc/key",
438
+ method: "GET",
439
+ url: "https://api.example.com/users/42/posts",
440
+ purpose: "List posts",
441
+ };
442
+
443
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
444
+ expect(result.allowed).toBe(true);
445
+ if (result.allowed) {
446
+ expect(result.grantId).toBe("templated-grant");
447
+ }
448
+ });
449
+
450
+ test("returns approval_required when no grant matches", () => {
451
+ const request: HttpPolicyRequest = {
452
+ credentialHandle: "local_static:github/api_key",
453
+ method: "GET",
454
+ url: "https://api.github.com/repos/owner/repo/pulls/42",
455
+ purpose: "List pull requests",
456
+ };
457
+
458
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
459
+ expect(result.allowed).toBe(false);
460
+ if (!result.allowed && result.reason === "approval_required") {
461
+ expect(result.proposal.type).toBe("http");
462
+ expect(result.proposal.credentialHandle).toBe(
463
+ "local_static:github/api_key",
464
+ );
465
+ expect(result.proposal.method).toBe("GET");
466
+ // Proposal should have allowedUrlPatterns with templated path
467
+ expect(result.proposal.allowedUrlPatterns).toBeDefined();
468
+ expect(result.proposal.allowedUrlPatterns![0]).toBe(
469
+ "https://api.github.com/repos/owner/repo/pulls/{:num}",
470
+ );
471
+ }
472
+ });
473
+
474
+ test("proposal derivation never produces wildcards", () => {
475
+ const request: HttpPolicyRequest = {
476
+ credentialHandle: "local_static:svc/key",
477
+ method: "GET",
478
+ url: "https://api.example.com/resources/42",
479
+ purpose: "Get resource",
480
+ };
481
+
482
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
483
+ expect(result.allowed).toBe(false);
484
+ if (!result.allowed && result.reason === "approval_required") {
485
+ for (const pattern of result.proposal.allowedUrlPatterns ?? []) {
486
+ expect(pattern).not.toContain("/*");
487
+ expect(pattern).not.toContain("*.");
488
+ }
489
+ }
490
+ });
491
+
492
+ test("does not match grant with different credential handle", () => {
493
+ persistentStore.add({
494
+ id: "wrong-cred-grant",
495
+ tool: "http",
496
+ pattern: "GET https://api.example.com/data",
497
+ scope: "local_static:other/key",
498
+ createdAt: Date.now(),
499
+ });
500
+
501
+ const request: HttpPolicyRequest = {
502
+ credentialHandle: "local_static:svc/key",
503
+ method: "GET",
504
+ url: "https://api.example.com/data",
505
+ purpose: "Get data",
506
+ };
507
+
508
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
509
+ expect(result.allowed).toBe(false);
510
+ });
511
+
512
+ test("does not match grant with different HTTP method", () => {
513
+ persistentStore.add({
514
+ id: "get-only-grant",
515
+ tool: "http",
516
+ pattern: "GET https://api.example.com/data",
517
+ scope: "local_static:svc/key",
518
+ createdAt: Date.now(),
519
+ });
520
+
521
+ const request: HttpPolicyRequest = {
522
+ credentialHandle: "local_static:svc/key",
523
+ method: "POST",
524
+ url: "https://api.example.com/data",
525
+ purpose: "Post data",
526
+ };
527
+
528
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
529
+ expect(result.allowed).toBe(false);
530
+ });
531
+
532
+ test("checks forbidden headers before grants", () => {
533
+ // Even with a matching grant, forbidden headers should block
534
+ persistentStore.add({
535
+ id: "valid-grant",
536
+ tool: "http",
537
+ pattern: "GET https://api.example.com/data",
538
+ scope: "local_static:svc/key",
539
+ createdAt: Date.now(),
540
+ });
541
+
542
+ const request: HttpPolicyRequest = {
543
+ credentialHandle: "local_static:svc/key",
544
+ method: "GET",
545
+ url: "https://api.example.com/data",
546
+ headers: { Authorization: "Bearer smuggled" },
547
+ purpose: "Get data",
548
+ };
549
+
550
+ const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
551
+ expect(result.allowed).toBe(false);
552
+ if (!result.allowed) {
553
+ expect(result.reason).toBe("forbidden_headers");
554
+ }
555
+ });
556
+
557
+ // Cleanup
558
+ afterEach(() => {
559
+ rmSync(tmpDir, { recursive: true, force: true });
560
+ });
561
+ });
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Response header filtering
565
+ // ---------------------------------------------------------------------------
566
+
567
+ describe("filterResponseHeaders", () => {
568
+ test("passes through whitelisted headers", () => {
569
+ const result = filterResponseHeaders({
570
+ "Content-Type": "application/json",
571
+ "X-Request-Id": "abc-123",
572
+ ETag: '"abc"',
573
+ });
574
+
575
+ expect(result["content-type"]).toBe("application/json");
576
+ expect(result["x-request-id"]).toBe("abc-123");
577
+ expect(result["etag"]).toBe('"abc"');
578
+ });
579
+
580
+ test("strips set-cookie header", () => {
581
+ const result = filterResponseHeaders({
582
+ "Content-Type": "text/html",
583
+ "Set-Cookie": "session=secret; HttpOnly",
584
+ });
585
+
586
+ expect(result["content-type"]).toBe("text/html");
587
+ expect(result["set-cookie"]).toBeUndefined();
588
+ });
589
+
590
+ test("strips www-authenticate header", () => {
591
+ const result = filterResponseHeaders({
592
+ "WWW-Authenticate": "Bearer realm=api",
593
+ "Content-Type": "application/json",
594
+ });
595
+
596
+ expect(result["www-authenticate"]).toBeUndefined();
597
+ });
598
+
599
+ test("strips arbitrary non-whitelisted headers", () => {
600
+ const result = filterResponseHeaders({
601
+ "X-Custom-Secret": "secret-value",
602
+ "Content-Type": "text/plain",
603
+ });
604
+
605
+ expect(result["x-custom-secret"]).toBeUndefined();
606
+ expect(result["content-type"]).toBe("text/plain");
607
+ });
608
+
609
+ test("lowercases header keys in output", () => {
610
+ const result = filterResponseHeaders({
611
+ "Content-Type": "text/html",
612
+ "Cache-Control": "no-cache",
613
+ });
614
+
615
+ expect(Object.keys(result).every((k) => k === k.toLowerCase())).toBe(true);
616
+ });
617
+ });
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Body clamping
621
+ // ---------------------------------------------------------------------------
622
+
623
+ describe("clampBody", () => {
624
+ test("passes through small bodies unchanged", () => {
625
+ const body = "Hello, world!";
626
+ const result = clampBody(body);
627
+
628
+ expect(result.clampedBody).toBe(body);
629
+ expect(result.truncated).toBe(false);
630
+ expect(result.originalBytes).toBe(Buffer.byteLength(body));
631
+ });
632
+
633
+ test("truncates bodies exceeding max size", () => {
634
+ // Create a body larger than 256KB
635
+ const body = "x".repeat(300 * 1024);
636
+ const result = clampBody(body);
637
+
638
+ expect(result.truncated).toBe(true);
639
+ expect(result.originalBytes).toBe(Buffer.byteLength(body));
640
+ expect(result.clampedBody).toContain("[CES: Response truncated");
641
+ // The clamped body should be smaller than the original
642
+ expect(Buffer.byteLength(result.clampedBody)).toBeLessThan(
643
+ Buffer.byteLength(body),
644
+ );
645
+ });
646
+
647
+ test("reports original byte size", () => {
648
+ const body = "a".repeat(100);
649
+ const result = clampBody(body);
650
+
651
+ expect(result.originalBytes).toBe(100);
652
+ });
653
+ });
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // Secret scrubbing
657
+ // ---------------------------------------------------------------------------
658
+
659
+ describe("scrubSecrets", () => {
660
+ test("replaces exact secret occurrences", () => {
661
+ const secret = "sk-abc123456789xyz";
662
+ const body = `Response: {"api_key": "${secret}", "status": "ok"}`;
663
+ const result = scrubSecrets(body, [secret]);
664
+
665
+ expect(result).not.toContain(secret);
666
+ expect(result).toContain("[CES:REDACTED]");
667
+ });
668
+
669
+ test("replaces multiple occurrences of the same secret", () => {
670
+ const secret = "ghp_1234567890abcdef";
671
+ const body = `token: ${secret}, again: ${secret}`;
672
+ const result = scrubSecrets(body, [secret]);
673
+
674
+ expect(result).not.toContain(secret);
675
+ expect(result.match(/\[CES:REDACTED\]/g)?.length).toBe(2);
676
+ });
677
+
678
+ test("scrubs multiple different secrets", () => {
679
+ const secret1 = "sk-prod-abcdefgh";
680
+ const secret2 = "ghp_testtoken123";
681
+ const body = `key1=${secret1}&key2=${secret2}`;
682
+ const result = scrubSecrets(body, [secret1, secret2]);
683
+
684
+ expect(result).not.toContain(secret1);
685
+ expect(result).not.toContain(secret2);
686
+ });
687
+
688
+ test("skips short secrets to avoid false positives", () => {
689
+ const shortSecret = "abc";
690
+ const body = "abc is a common substring in abcdef";
691
+ const result = scrubSecrets(body, [shortSecret]);
692
+
693
+ // Short secret should not be scrubbed
694
+ expect(result).toBe(body);
695
+ });
696
+
697
+ test("handles empty secrets array", () => {
698
+ const body = "no secrets here";
699
+ const result = scrubSecrets(body, []);
700
+
701
+ expect(result).toBe(body);
702
+ });
703
+
704
+ test("handles body with no matching secrets", () => {
705
+ const body = "clean response body";
706
+ const result = scrubSecrets(body, ["nonexistent-secret-value"]);
707
+
708
+ expect(result).toBe(body);
709
+ });
710
+
711
+ test("handles secrets with regex metacharacters", () => {
712
+ const secret = "secret+value.with$special(chars)";
713
+ const body = `Found: ${secret}`;
714
+ const result = scrubSecrets(body, [secret]);
715
+
716
+ expect(result).not.toContain(secret);
717
+ expect(result).toContain("[CES:REDACTED]");
718
+ });
719
+ });
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Full response filter
723
+ // ---------------------------------------------------------------------------
724
+
725
+ describe("filterHttpResponse", () => {
726
+ test("combines header filtering, body clamping, and secret scrubbing", () => {
727
+ const secret = "sk-live-1234567890abcdef";
728
+ const raw: RawHttpResponse = {
729
+ statusCode: 200,
730
+ headers: {
731
+ "Content-Type": "application/json",
732
+ "Set-Cookie": "session=abc; HttpOnly",
733
+ "X-Request-Id": "req-123",
734
+ },
735
+ body: `{"data": "value", "echo": "${secret}"}`,
736
+ };
737
+
738
+ const result = filterHttpResponse(raw, [secret]);
739
+
740
+ expect(result.statusCode).toBe(200);
741
+ expect(result.headers["content-type"]).toBe("application/json");
742
+ expect(result.headers["x-request-id"]).toBe("req-123");
743
+ expect(result.headers["set-cookie"]).toBeUndefined();
744
+ expect(result.body).not.toContain(secret);
745
+ expect(result.body).toContain("[CES:REDACTED]");
746
+ expect(result.truncated).toBe(false);
747
+ });
748
+
749
+ test("works with no secrets provided", () => {
750
+ const raw: RawHttpResponse = {
751
+ statusCode: 404,
752
+ headers: { "Content-Type": "text/plain" },
753
+ body: "Not found",
754
+ };
755
+
756
+ const result = filterHttpResponse(raw);
757
+
758
+ expect(result.statusCode).toBe(404);
759
+ expect(result.body).toBe("Not found");
760
+ expect(result.truncated).toBe(false);
761
+ });
762
+ });
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Audit summary generation
766
+ // ---------------------------------------------------------------------------
767
+
768
+ describe("generateHttpAuditSummary", () => {
769
+ test("produces token-free summary with templated URL", () => {
770
+ const summary = generateHttpAuditSummary({
771
+ credentialHandle: "local_static:github/api_key",
772
+ grantId: "grant-1",
773
+ sessionId: "sess-1",
774
+ method: "GET",
775
+ url: "https://api.github.com/repos/owner/repo/pulls/42",
776
+ success: true,
777
+ statusCode: 200,
778
+ });
779
+
780
+ expect(summary.auditId).toBeDefined();
781
+ expect(summary.grantId).toBe("grant-1");
782
+ expect(summary.credentialHandle).toBe("local_static:github/api_key");
783
+ expect(summary.toolName).toBe("http");
784
+ expect(summary.sessionId).toBe("sess-1");
785
+ expect(summary.success).toBe(true);
786
+ expect(summary.timestamp).toBeDefined();
787
+
788
+ // Target should use templated path, not raw URL
789
+ expect(summary.target).toContain("GET");
790
+ expect(summary.target).toContain("{:num}");
791
+ expect(summary.target).toContain("-> 200");
792
+ // Must not contain the raw numeric ID
793
+ expect(summary.target).not.toContain("/42");
794
+ });
795
+
796
+ test("includes error message on failure", () => {
797
+ const summary = generateHttpAuditSummary({
798
+ credentialHandle: "local_static:svc/key",
799
+ grantId: "grant-2",
800
+ sessionId: "sess-2",
801
+ method: "POST",
802
+ url: "https://api.example.com/data",
803
+ success: false,
804
+ errorMessage: "Connection refused",
805
+ });
806
+
807
+ expect(summary.success).toBe(false);
808
+ expect(summary.errorMessage).toBe("Connection refused");
809
+ });
810
+
811
+ test("handles invalid URL gracefully in target", () => {
812
+ const summary = generateHttpAuditSummary({
813
+ credentialHandle: "local_static:svc/key",
814
+ grantId: "grant-3",
815
+ sessionId: "sess-3",
816
+ method: "GET",
817
+ url: "not-a-valid-url",
818
+ success: false,
819
+ errorMessage: "Invalid URL",
820
+ });
821
+
822
+ expect(summary.target).toContain("[invalid-url]");
823
+ });
824
+
825
+ test("omits errorMessage when not provided", () => {
826
+ const summary = generateHttpAuditSummary({
827
+ credentialHandle: "local_static:svc/key",
828
+ grantId: "grant-4",
829
+ sessionId: "sess-4",
830
+ method: "GET",
831
+ url: "https://api.example.com/health",
832
+ success: true,
833
+ });
834
+
835
+ expect(summary.errorMessage).toBeUndefined();
836
+ });
837
+
838
+ test("audit summary never contains secret values", () => {
839
+ // Even if someone accidentally passes secret-looking data,
840
+ // the summary only contains metadata fields
841
+ const summary = generateHttpAuditSummary({
842
+ credentialHandle: "local_static:github/api_key",
843
+ grantId: "grant-5",
844
+ sessionId: "sess-5",
845
+ method: "GET",
846
+ url: "https://api.github.com/user",
847
+ success: true,
848
+ statusCode: 200,
849
+ });
850
+
851
+ const serialized = JSON.stringify(summary);
852
+ // The summary should not contain any field that could hold a raw token
853
+ expect(serialized).not.toContain("Bearer");
854
+ expect(serialized).not.toContain("ghp_");
855
+ expect(serialized).not.toContain("sk-");
856
+ });
857
+ });
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Integration: end-to-end policy → filter → audit
861
+ // ---------------------------------------------------------------------------
862
+
863
+ describe("end-to-end: policy evaluation → response filter → audit", () => {
864
+ let tmpDir: string;
865
+
866
+ beforeEach(() => {
867
+ tmpDir = makeTmpDir();
868
+ });
869
+
870
+ afterEach(() => {
871
+ rmSync(tmpDir, { recursive: true, force: true });
872
+ });
873
+
874
+ test("off-grant request is blocked, returns proposal, never reaches network", () => {
875
+ const persistentStore = new PersistentGrantStore(tmpDir);
876
+ persistentStore.init();
877
+ const temporaryStore = new TemporaryGrantStore();
878
+
879
+ const request: HttpPolicyRequest = {
880
+ credentialHandle: "local_static:stripe/api_key",
881
+ method: "POST",
882
+ url: "https://api.stripe.com/v1/charges",
883
+ purpose: "Create a charge",
884
+ };
885
+
886
+ const policyResult = evaluateHttpPolicy(
887
+ request,
888
+ persistentStore,
889
+ temporaryStore,
890
+ );
891
+
892
+ // Must be blocked
893
+ expect(policyResult.allowed).toBe(false);
894
+ if (!policyResult.allowed && policyResult.reason === "approval_required") {
895
+ expect(policyResult.proposal.type).toBe("http");
896
+ expect(policyResult.proposal.credentialHandle).toBe(
897
+ "local_static:stripe/api_key",
898
+ );
899
+ // Must have specific URL pattern, not wildcard
900
+ expect(policyResult.proposal.allowedUrlPatterns).toBeDefined();
901
+ expect(policyResult.proposal.allowedUrlPatterns!.length).toBeGreaterThan(0);
902
+ for (const pattern of policyResult.proposal.allowedUrlPatterns!) {
903
+ expect(pattern).not.toBe("/*");
904
+ expect(pattern).not.toContain("*");
905
+ }
906
+ }
907
+ });
908
+
909
+ test("granted request produces sanitised response and clean audit summary", () => {
910
+ const persistentStore = new PersistentGrantStore(tmpDir);
911
+ persistentStore.init();
912
+ persistentStore.add({
913
+ id: "stripe-charge-grant",
914
+ tool: "http",
915
+ pattern: "GET https://api.stripe.com/v1/charges/{:num}",
916
+ scope: "local_static:stripe/api_key",
917
+ createdAt: Date.now(),
918
+ });
919
+ const temporaryStore = new TemporaryGrantStore();
920
+
921
+ const request: HttpPolicyRequest = {
922
+ credentialHandle: "local_static:stripe/api_key",
923
+ method: "GET",
924
+ url: "https://api.stripe.com/v1/charges/123",
925
+ purpose: "Get charge details",
926
+ };
927
+
928
+ // Policy allows
929
+ const policyResult = evaluateHttpPolicy(
930
+ request,
931
+ persistentStore,
932
+ temporaryStore,
933
+ );
934
+ expect(policyResult.allowed).toBe(true);
935
+
936
+ // Simulate HTTP response filtering
937
+ const secret = "sk_live_abcdefghijklmnop";
938
+ const raw: RawHttpResponse = {
939
+ statusCode: 200,
940
+ headers: {
941
+ "Content-Type": "application/json",
942
+ "Set-Cookie": "stripe_session=xyz",
943
+ },
944
+ body: `{"id": "ch_123", "key_echo": "${secret}"}`,
945
+ };
946
+
947
+ const filtered = filterHttpResponse(raw, [secret]);
948
+ expect(filtered.headers["set-cookie"]).toBeUndefined();
949
+ expect(filtered.body).not.toContain(secret);
950
+
951
+ // Generate audit summary
952
+ if (policyResult.allowed) {
953
+ const audit = generateHttpAuditSummary({
954
+ credentialHandle: request.credentialHandle,
955
+ grantId: policyResult.grantId,
956
+ sessionId: "test-session",
957
+ method: request.method,
958
+ url: request.url,
959
+ success: true,
960
+ statusCode: 200,
961
+ });
962
+
963
+ expect(audit.success).toBe(true);
964
+ expect(audit.target).toContain("{:num}");
965
+ expect(audit.target).not.toContain("123");
966
+ const auditStr = JSON.stringify(audit);
967
+ expect(auditStr).not.toContain(secret);
968
+ }
969
+ });
970
+ });