@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

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