@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,1251 @@
1
+ /**
2
+ * Integration tests for the CES HTTP executor.
3
+ *
4
+ * Covers:
5
+ * 1. Local static secret flow — resolve, grant-check, materialise, execute, filter
6
+ * 2. Local OAuth flow — resolve, grant-check, materialise (with token), execute, filter
7
+ * 3. platform_oauth:<connection_id> flow — resolve, grant-check, materialise via
8
+ * platform, execute, filter
9
+ * 4. Approval-required short-circuit — off-grant requests return a proposal
10
+ * without making any network call
11
+ * 5. Forbidden header rejection — caller-supplied auth headers are blocked
12
+ * 6. Redirect denial — redirect hops that violate grant policy are blocked
13
+ * 7. Filtered response behaviour — secret scrubbing, header stripping, body clamping
14
+ * 8. Audit summaries are token-free
15
+ */
16
+
17
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
18
+ import { randomUUID } from "node:crypto";
19
+ import { mkdirSync, rmSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ import {
24
+ localStaticHandle,
25
+ localOAuthHandle,
26
+ platformOAuthHandle,
27
+ } from "@vellumai/ces-contracts";
28
+ import {
29
+ type OAuthConnectionRecord,
30
+ type SecureKeyBackend,
31
+ type SecureKeyDeleteResult,
32
+ type StaticCredentialRecord,
33
+ StaticCredentialMetadataStore,
34
+ oauthConnectionAccessTokenPath,
35
+ credentialKey,
36
+ } from "@vellumai/credential-storage";
37
+
38
+ import {
39
+ executeAuthenticatedHttpRequest,
40
+ type HttpExecutorDeps,
41
+ } from "../http/executor.js";
42
+ import { PersistentGrantStore } from "../grants/persistent-store.js";
43
+ import { TemporaryGrantStore } from "../grants/temporary-store.js";
44
+ import { LocalMaterialiser } from "../materializers/local.js";
45
+ import type { OAuthConnectionLookup, LocalSubjectResolverDeps } from "../subjects/local.js";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Test helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function makeTmpDir(): string {
52
+ const dir = join(tmpdir(), `ces-http-executor-test-${randomUUID()}`);
53
+ mkdirSync(dir, { recursive: true });
54
+ return dir;
55
+ }
56
+
57
+ /**
58
+ * In-memory secure-key backend for testing.
59
+ */
60
+ function createMemoryBackend(
61
+ initial: Record<string, string> = {},
62
+ ): SecureKeyBackend {
63
+ const store = new Map<string, string>(Object.entries(initial));
64
+ return {
65
+ async get(key: string): Promise<string | undefined> {
66
+ return store.get(key);
67
+ },
68
+ async set(key: string, value: string): Promise<boolean> {
69
+ store.set(key, value);
70
+ return true;
71
+ },
72
+ async delete(key: string): Promise<SecureKeyDeleteResult> {
73
+ if (store.has(key)) {
74
+ store.delete(key);
75
+ return "deleted";
76
+ }
77
+ return "not-found";
78
+ },
79
+ async list(): Promise<string[]> {
80
+ return Array.from(store.keys());
81
+ },
82
+ };
83
+ }
84
+
85
+ function buildStaticRecord(
86
+ overrides: Partial<StaticCredentialRecord> = {},
87
+ ): StaticCredentialRecord {
88
+ return {
89
+ credentialId: overrides.credentialId ?? "cred-uuid-1",
90
+ service: overrides.service ?? "github",
91
+ field: overrides.field ?? "api_key",
92
+ allowedTools: overrides.allowedTools ?? [],
93
+ allowedDomains: overrides.allowedDomains ?? [],
94
+ createdAt: overrides.createdAt ?? Date.now(),
95
+ updatedAt: overrides.updatedAt ?? Date.now(),
96
+ };
97
+ }
98
+
99
+ function buildOAuthConnection(
100
+ overrides: Partial<OAuthConnectionRecord> = {},
101
+ ): OAuthConnectionRecord {
102
+ return {
103
+ id: overrides.id ?? "conn-uuid-1",
104
+ providerKey: overrides.providerKey ?? "integration:google",
105
+ accountInfo: overrides.accountInfo ?? "user@example.com",
106
+ grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
107
+ accessTokenPath: overrides.accessTokenPath ??
108
+ oauthConnectionAccessTokenPath(overrides.id ?? "conn-uuid-1"),
109
+ hasRefreshToken: overrides.hasRefreshToken ?? true,
110
+ expiresAt: overrides.expiresAt ?? null,
111
+ createdAt: overrides.createdAt ?? Date.now(),
112
+ updatedAt: overrides.updatedAt ?? Date.now(),
113
+ };
114
+ }
115
+
116
+ function createOAuthLookup(
117
+ connections: OAuthConnectionRecord[] = [],
118
+ ): OAuthConnectionLookup {
119
+ const byId = new Map(connections.map((c) => [c.id, c]));
120
+ return {
121
+ getById(id: string) {
122
+ return byId.get(id);
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Build a mock fetch that returns a predetermined response.
129
+ */
130
+ function mockFetch(
131
+ statusCode: number,
132
+ body: string,
133
+ headers?: Record<string, string>,
134
+ ): typeof globalThis.fetch {
135
+ return asFetch(async (_url: string | URL | Request, _init?: RequestInit) => {
136
+ const responseHeaders = new Headers(headers ?? {});
137
+ if (!responseHeaders.has("content-type")) {
138
+ responseHeaders.set("content-type", "application/json");
139
+ }
140
+ return new Response(body, {
141
+ status: statusCode,
142
+ headers: responseHeaders,
143
+ });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Build a mock fetch that records requests for inspection.
149
+ */
150
+ function mockFetchRecorder(
151
+ statusCode: number,
152
+ body: string,
153
+ headers?: Record<string, string>,
154
+ ): {
155
+ fetch: typeof globalThis.fetch;
156
+ requests: Array<{ url: string; init?: RequestInit }>;
157
+ } {
158
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
159
+ const fetchFn = asFetch(async (
160
+ url: string | URL | Request,
161
+ init?: RequestInit,
162
+ ) => {
163
+ requests.push({ url: url.toString(), init });
164
+ const responseHeaders = new Headers(headers ?? {});
165
+ if (!responseHeaders.has("content-type")) {
166
+ responseHeaders.set("content-type", "application/json");
167
+ }
168
+ return new Response(body, {
169
+ status: statusCode,
170
+ headers: responseHeaders,
171
+ });
172
+ });
173
+ return { fetch: fetchFn, requests };
174
+ }
175
+
176
+ /**
177
+ * Build a mock fetch that returns a redirect on first call, then a real
178
+ * response on subsequent calls.
179
+ */
180
+ function mockFetchRedirect(
181
+ redirectUrl: string,
182
+ redirectStatus: number,
183
+ finalStatusCode: number,
184
+ finalBody: string,
185
+ ): typeof globalThis.fetch {
186
+ let callCount = 0;
187
+ return asFetch(async (_url: string | URL | Request, _init?: RequestInit) => {
188
+ callCount++;
189
+ if (callCount === 1) {
190
+ return new Response(null, {
191
+ status: redirectStatus,
192
+ headers: { Location: redirectUrl },
193
+ });
194
+ }
195
+ return new Response(finalBody, {
196
+ status: finalStatusCode,
197
+ headers: { "Content-Type": "application/json" },
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Attach a no-op `preconnect` so a plain async function satisfies
204
+ * Bun's `typeof globalThis.fetch` (which includes `preconnect`).
205
+ */
206
+ function asFetch(
207
+ fn: (url: string | URL | Request, init?: RequestInit) => Promise<Response>,
208
+ ): typeof globalThis.fetch {
209
+ return Object.assign(fn, {
210
+ preconnect: (_url: string | URL) => {},
211
+ }) as unknown as typeof globalThis.fetch;
212
+ }
213
+
214
+ /** Silent logger that suppresses output during tests. */
215
+ const silentLogger: Pick<Console, "log" | "warn" | "error"> = {
216
+ log: () => {},
217
+ warn: () => {},
218
+ error: () => {},
219
+ };
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Test fixtures
223
+ // ---------------------------------------------------------------------------
224
+
225
+ interface TestFixture {
226
+ tmpDir: string;
227
+ persistentStore: PersistentGrantStore;
228
+ temporaryStore: TemporaryGrantStore;
229
+ backend: SecureKeyBackend;
230
+ metadataStore: StaticCredentialMetadataStore;
231
+ localMaterialiser: LocalMaterialiser;
232
+ }
233
+
234
+ function createFixture(
235
+ secretEntries: Record<string, string> = {},
236
+ staticRecords: StaticCredentialRecord[] = [],
237
+ oauthConnections: OAuthConnectionRecord[] = [],
238
+ ): TestFixture {
239
+ const tmpDir = makeTmpDir();
240
+ const persistentStore = new PersistentGrantStore(tmpDir);
241
+ persistentStore.init();
242
+ const temporaryStore = new TemporaryGrantStore();
243
+
244
+ const backend = createMemoryBackend(secretEntries);
245
+ const metadataStore = new StaticCredentialMetadataStore(
246
+ join(tmpDir, "credentials.json"),
247
+ );
248
+ for (const record of staticRecords) {
249
+ metadataStore.upsert(record.service, record.field, {
250
+ allowedTools: record.allowedTools,
251
+ allowedDomains: record.allowedDomains,
252
+ });
253
+ }
254
+
255
+ const oauthLookup = createOAuthLookup(oauthConnections);
256
+
257
+ const localMaterialiser = new LocalMaterialiser({
258
+ secureKeyBackend: backend,
259
+ });
260
+
261
+ return {
262
+ tmpDir,
263
+ persistentStore,
264
+ temporaryStore,
265
+ backend,
266
+ metadataStore,
267
+ localMaterialiser,
268
+ };
269
+ }
270
+
271
+ function buildDeps(
272
+ fixture: TestFixture,
273
+ oauthConnections: OAuthConnectionRecord[] = [],
274
+ overrides: Partial<HttpExecutorDeps> = {},
275
+ ): HttpExecutorDeps {
276
+ return {
277
+ persistentGrantStore: fixture.persistentStore,
278
+ temporaryGrantStore: fixture.temporaryStore,
279
+ localMaterialiser: fixture.localMaterialiser,
280
+ localSubjectDeps: {
281
+ metadataStore: fixture.metadataStore,
282
+ oauthConnections: createOAuthLookup(oauthConnections),
283
+ },
284
+ sessionId: "test-session",
285
+ logger: silentLogger,
286
+ ...overrides,
287
+ };
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Tests: Local static secrets
292
+ // ---------------------------------------------------------------------------
293
+
294
+ describe("HTTP executor: local static secrets", () => {
295
+ let fixture: TestFixture;
296
+
297
+ beforeEach(() => {
298
+ const handle = localStaticHandle("github", "api_key");
299
+ const storageKey = credentialKey("github", "api_key");
300
+ fixture = createFixture(
301
+ { [storageKey]: "ghp_testtoken_12345678" },
302
+ [buildStaticRecord({ service: "github", field: "api_key" })],
303
+ );
304
+ });
305
+
306
+ afterEach(() => {
307
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
308
+ });
309
+
310
+ test("successful request with matching grant", async () => {
311
+ const handle = localStaticHandle("github", "api_key");
312
+
313
+ // Add a matching grant
314
+ fixture.persistentStore.add({
315
+ id: "grant-github-repos",
316
+ tool: "http",
317
+ pattern: "GET https://api.github.com/repos/owner/repo",
318
+ scope: handle,
319
+ createdAt: Date.now(),
320
+ });
321
+
322
+ const { fetch: fetchFn, requests } = mockFetchRecorder(
323
+ 200,
324
+ '{"name": "repo", "full_name": "owner/repo"}',
325
+ );
326
+
327
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
328
+ const result = await executeAuthenticatedHttpRequest(
329
+ {
330
+ credentialHandle: handle,
331
+ method: "GET",
332
+ url: "https://api.github.com/repos/owner/repo",
333
+ purpose: "Get repo info",
334
+ },
335
+ deps,
336
+ );
337
+
338
+ expect(result.success).toBe(true);
339
+ expect(result.statusCode).toBe(200);
340
+ expect(result.responseBody).toContain("owner/repo");
341
+ expect(result.auditId).toBeDefined();
342
+
343
+ // Verify the outbound request had auth injected
344
+ expect(requests).toHaveLength(1);
345
+ const outboundHeaders = requests[0].init?.headers as Record<string, string>;
346
+ expect(outboundHeaders?.["Authorization"]).toContain("Bearer");
347
+ expect(outboundHeaders?.["Authorization"]).toContain("ghp_testtoken_12345678");
348
+ });
349
+
350
+ test("response body is scrubbed of secret values", async () => {
351
+ const handle = localStaticHandle("github", "api_key");
352
+
353
+ fixture.persistentStore.add({
354
+ id: "grant-github-user",
355
+ tool: "http",
356
+ pattern: "GET https://api.github.com/user",
357
+ scope: handle,
358
+ createdAt: Date.now(),
359
+ });
360
+
361
+ // Simulate API echoing back the token in response
362
+ const fetchFn = mockFetch(
363
+ 200,
364
+ '{"token_echo": "ghp_testtoken_12345678", "name": "octocat"}',
365
+ );
366
+
367
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
368
+ const result = await executeAuthenticatedHttpRequest(
369
+ {
370
+ credentialHandle: handle,
371
+ method: "GET",
372
+ url: "https://api.github.com/user",
373
+ purpose: "Get user info",
374
+ },
375
+ deps,
376
+ );
377
+
378
+ expect(result.success).toBe(true);
379
+ expect(result.responseBody).not.toContain("ghp_testtoken_12345678");
380
+ expect(result.responseBody).toContain("[CES:REDACTED]");
381
+ expect(result.responseBody).toContain("octocat");
382
+ });
383
+
384
+ test("response headers are filtered (set-cookie stripped)", async () => {
385
+ const handle = localStaticHandle("github", "api_key");
386
+
387
+ fixture.persistentStore.add({
388
+ id: "grant-github-data",
389
+ tool: "http",
390
+ pattern: "GET https://api.github.com/data",
391
+ scope: handle,
392
+ createdAt: Date.now(),
393
+ });
394
+
395
+ const fetchFn = mockFetch(200, '{"ok": true}', {
396
+ "content-type": "application/json",
397
+ "set-cookie": "session=secret; HttpOnly",
398
+ "x-request-id": "req-123",
399
+ });
400
+
401
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
402
+ const result = await executeAuthenticatedHttpRequest(
403
+ {
404
+ credentialHandle: handle,
405
+ method: "GET",
406
+ url: "https://api.github.com/data",
407
+ purpose: "Get data",
408
+ },
409
+ deps,
410
+ );
411
+
412
+ expect(result.success).toBe(true);
413
+ expect(result.responseHeaders).toBeDefined();
414
+ expect(result.responseHeaders!["content-type"]).toBe("application/json");
415
+ expect(result.responseHeaders!["x-request-id"]).toBe("req-123");
416
+ // set-cookie must be stripped
417
+ expect(result.responseHeaders!["set-cookie"]).toBeUndefined();
418
+ });
419
+ });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Tests: Local OAuth
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe("HTTP executor: local OAuth", () => {
426
+ let fixture: TestFixture;
427
+ let connection: OAuthConnectionRecord;
428
+
429
+ beforeEach(() => {
430
+ connection = buildOAuthConnection({
431
+ id: "conn-google-1",
432
+ providerKey: "integration:google",
433
+ expiresAt: Date.now() + 3600000, // valid for 1 hour
434
+ });
435
+
436
+ const accessTokenPath = oauthConnectionAccessTokenPath("conn-google-1");
437
+ fixture = createFixture(
438
+ { [accessTokenPath]: "ya29.test-google-token-abc123" },
439
+ [],
440
+ [connection],
441
+ );
442
+ });
443
+
444
+ afterEach(() => {
445
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
446
+ });
447
+
448
+ test("successful OAuth request with matching grant", async () => {
449
+ const handle = localOAuthHandle("integration:google", "conn-google-1");
450
+
451
+ fixture.persistentStore.add({
452
+ id: "grant-google-calendar",
453
+ tool: "http",
454
+ pattern: "GET https://www.googleapis.com/calendar/v3/calendars/primary/events",
455
+ scope: handle,
456
+ createdAt: Date.now(),
457
+ });
458
+
459
+ const { fetch: fetchFn, requests } = mockFetchRecorder(
460
+ 200,
461
+ '{"items": [{"summary": "Meeting"}]}',
462
+ );
463
+
464
+ const deps = buildDeps(fixture, [connection], { fetch: fetchFn });
465
+ const result = await executeAuthenticatedHttpRequest(
466
+ {
467
+ credentialHandle: handle,
468
+ method: "GET",
469
+ url: "https://www.googleapis.com/calendar/v3/calendars/primary/events",
470
+ purpose: "List calendar events",
471
+ },
472
+ deps,
473
+ );
474
+
475
+ expect(result.success).toBe(true);
476
+ expect(result.statusCode).toBe(200);
477
+ expect(result.responseBody).toContain("Meeting");
478
+ expect(result.auditId).toBeDefined();
479
+
480
+ // Verify Bearer token was injected
481
+ expect(requests).toHaveLength(1);
482
+ const outboundHeaders = requests[0].init?.headers as Record<string, string>;
483
+ expect(outboundHeaders?.["Authorization"]).toBe(
484
+ "Bearer ya29.test-google-token-abc123",
485
+ );
486
+ });
487
+
488
+ test("OAuth token scrubbed from response body", async () => {
489
+ const handle = localOAuthHandle("integration:google", "conn-google-1");
490
+
491
+ fixture.persistentStore.add({
492
+ id: "grant-google-debug",
493
+ tool: "http",
494
+ pattern: "GET https://www.googleapis.com/oauth2/v1/tokeninfo",
495
+ scope: handle,
496
+ createdAt: Date.now(),
497
+ });
498
+
499
+ // Simulate tokeninfo endpoint echoing the token
500
+ const fetchFn = mockFetch(
501
+ 200,
502
+ '{"access_token": "ya29.test-google-token-abc123", "expires_in": 3600}',
503
+ );
504
+
505
+ const deps = buildDeps(fixture, [connection], { fetch: fetchFn });
506
+ const result = await executeAuthenticatedHttpRequest(
507
+ {
508
+ credentialHandle: handle,
509
+ method: "GET",
510
+ url: "https://www.googleapis.com/oauth2/v1/tokeninfo",
511
+ purpose: "Check token info",
512
+ },
513
+ deps,
514
+ );
515
+
516
+ expect(result.success).toBe(true);
517
+ expect(result.responseBody).not.toContain("ya29.test-google-token-abc123");
518
+ expect(result.responseBody).toContain("[CES:REDACTED]");
519
+ });
520
+ });
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Tests: Managed (platform_oauth) handles
524
+ // ---------------------------------------------------------------------------
525
+
526
+ describe("HTTP executor: platform_oauth handles", () => {
527
+ let fixture: TestFixture;
528
+ let tmpDir: string;
529
+
530
+ beforeEach(() => {
531
+ fixture = createFixture();
532
+ tmpDir = fixture.tmpDir;
533
+ });
534
+
535
+ afterEach(() => {
536
+ rmSync(tmpDir, { recursive: true, force: true });
537
+ });
538
+
539
+ test("successful platform OAuth request", async () => {
540
+ const handle = platformOAuthHandle("conn-platform-1");
541
+
542
+ fixture.persistentStore.add({
543
+ id: "grant-platform-api",
544
+ tool: "http",
545
+ pattern: "GET https://api.slack.com/api/conversations.list",
546
+ scope: handle,
547
+ createdAt: Date.now(),
548
+ });
549
+
550
+ // Mock the platform catalog response
551
+ const platformCatalogFetch = asFetch(async (
552
+ url: string | URL | Request,
553
+ _init?: RequestInit,
554
+ ) => {
555
+ const urlStr = url.toString();
556
+
557
+ // Platform catalog
558
+ if (urlStr.includes("/v1/ces/catalog")) {
559
+ return new Response(
560
+ JSON.stringify({
561
+ connections: [
562
+ {
563
+ id: "conn-platform-1",
564
+ provider: "slack",
565
+ account_info: "workspace@slack.com",
566
+ granted_scopes: ["channels:read"],
567
+ status: "active",
568
+ },
569
+ ],
570
+ }),
571
+ { status: 200, headers: { "Content-Type": "application/json" } },
572
+ );
573
+ }
574
+
575
+ // Platform token materialization
576
+ if (urlStr.includes("/v1/ces/connections/conn-platform-1/materialize")) {
577
+ return new Response(
578
+ JSON.stringify({
579
+ access_token: "xoxp-platform-token-12345678",
580
+ token_type: "Bearer",
581
+ expires_in: 3600,
582
+ }),
583
+ { status: 200, headers: { "Content-Type": "application/json" } },
584
+ );
585
+ }
586
+
587
+ // The actual outbound Slack API call
588
+ if (urlStr.includes("api.slack.com")) {
589
+ return new Response(
590
+ JSON.stringify({
591
+ ok: true,
592
+ channels: [{ name: "general" }],
593
+ }),
594
+ { status: 200, headers: { "Content-Type": "application/json" } },
595
+ );
596
+ }
597
+
598
+ return new Response("Not found", { status: 404 });
599
+ });
600
+
601
+ const deps = buildDeps(fixture, [], {
602
+ fetch: platformCatalogFetch,
603
+ managedSubjectOptions: {
604
+ platformBaseUrl: "https://api.vellum.ai",
605
+ assistantApiKey: "test-api-key",
606
+ fetch: platformCatalogFetch,
607
+ },
608
+ managedMaterializerOptions: {
609
+ platformBaseUrl: "https://api.vellum.ai",
610
+ assistantApiKey: "test-api-key",
611
+ fetch: platformCatalogFetch,
612
+ },
613
+ });
614
+
615
+ const result = await executeAuthenticatedHttpRequest(
616
+ {
617
+ credentialHandle: handle,
618
+ method: "GET",
619
+ url: "https://api.slack.com/api/conversations.list",
620
+ purpose: "List Slack channels",
621
+ },
622
+ deps,
623
+ );
624
+
625
+ expect(result.success).toBe(true);
626
+ expect(result.statusCode).toBe(200);
627
+ expect(result.responseBody).toContain("general");
628
+ expect(result.auditId).toBeDefined();
629
+
630
+ // Token should be scrubbed from body
631
+ expect(result.responseBody).not.toContain("xoxp-platform-token-12345678");
632
+ });
633
+
634
+ test("fails when managed mode is not configured", async () => {
635
+ const handle = platformOAuthHandle("conn-platform-1");
636
+
637
+ fixture.persistentStore.add({
638
+ id: "grant-platform-unconfigured",
639
+ tool: "http",
640
+ pattern: "GET https://api.slack.com/data",
641
+ scope: handle,
642
+ createdAt: Date.now(),
643
+ });
644
+
645
+ // No managedSubjectOptions or managedMaterializerOptions
646
+ const deps = buildDeps(fixture, []);
647
+
648
+ const result = await executeAuthenticatedHttpRequest(
649
+ {
650
+ credentialHandle: handle,
651
+ method: "GET",
652
+ url: "https://api.slack.com/data",
653
+ purpose: "Get data",
654
+ },
655
+ deps,
656
+ );
657
+
658
+ expect(result.success).toBe(false);
659
+ expect(result.error).toBeDefined();
660
+ expect(result.error!.code).toBe("MATERIALISATION_FAILED");
661
+ expect(result.error!.message).toContain("not configured");
662
+ });
663
+ });
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Tests: Approval required short-circuit
667
+ // ---------------------------------------------------------------------------
668
+
669
+ describe("HTTP executor: approval-required short-circuit", () => {
670
+ let fixture: TestFixture;
671
+
672
+ beforeEach(() => {
673
+ const storageKey = credentialKey("stripe", "api_key");
674
+ fixture = createFixture(
675
+ { [storageKey]: "sk_test_abcdefghijklmnop" },
676
+ [buildStaticRecord({ service: "stripe", field: "api_key" })],
677
+ );
678
+ });
679
+
680
+ afterEach(() => {
681
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
682
+ });
683
+
684
+ test("off-grant request returns approval_required without network call", async () => {
685
+ const handle = localStaticHandle("stripe", "api_key");
686
+ // No grant added — should be blocked
687
+
688
+ const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
689
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
690
+
691
+ const result = await executeAuthenticatedHttpRequest(
692
+ {
693
+ credentialHandle: handle,
694
+ method: "POST",
695
+ url: "https://api.stripe.com/v1/charges",
696
+ purpose: "Create a charge",
697
+ },
698
+ deps,
699
+ );
700
+
701
+ // Request must be blocked
702
+ expect(result.success).toBe(false);
703
+ expect(result.error).toBeDefined();
704
+ expect(result.error!.code).toBe("APPROVAL_REQUIRED");
705
+ expect(result.error!.details).toBeDefined();
706
+ expect((result.error!.details as Record<string, unknown>).proposal).toBeDefined();
707
+ expect((result.error!.details as Record<string, unknown>).proposalHash).toBeDefined();
708
+
709
+ // No network call should have been made
710
+ expect(requests).toHaveLength(0);
711
+ });
712
+
713
+ test("proposal contains specific URL pattern (no wildcards)", async () => {
714
+ const handle = localStaticHandle("stripe", "api_key");
715
+
716
+ const deps = buildDeps(fixture, []);
717
+
718
+ const result = await executeAuthenticatedHttpRequest(
719
+ {
720
+ credentialHandle: handle,
721
+ method: "GET",
722
+ url: "https://api.stripe.com/v1/charges/123",
723
+ purpose: "Get charge",
724
+ },
725
+ deps,
726
+ );
727
+
728
+ expect(result.success).toBe(false);
729
+ expect(result.error!.code).toBe("APPROVAL_REQUIRED");
730
+
731
+ const proposal = (result.error!.details as Record<string, unknown>)
732
+ .proposal as Record<string, unknown>;
733
+ const allowedPatterns = proposal.allowedUrlPatterns as string[];
734
+ expect(allowedPatterns).toBeDefined();
735
+ expect(allowedPatterns.length).toBeGreaterThan(0);
736
+
737
+ for (const pattern of allowedPatterns) {
738
+ expect(pattern).not.toContain("/*");
739
+ expect(pattern).not.toContain("*.");
740
+ // Should have {:num} for the charge ID
741
+ expect(pattern).toContain("{:num}");
742
+ }
743
+ });
744
+ });
745
+
746
+ // ---------------------------------------------------------------------------
747
+ // Tests: Forbidden header rejection
748
+ // ---------------------------------------------------------------------------
749
+
750
+ describe("HTTP executor: forbidden header rejection", () => {
751
+ let fixture: TestFixture;
752
+
753
+ beforeEach(() => {
754
+ const storageKey = credentialKey("github", "api_key");
755
+ fixture = createFixture(
756
+ { [storageKey]: "ghp_testtoken_12345678" },
757
+ [buildStaticRecord({ service: "github", field: "api_key" })],
758
+ );
759
+ });
760
+
761
+ afterEach(() => {
762
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
763
+ });
764
+
765
+ test("rejects request with Authorization header", async () => {
766
+ const handle = localStaticHandle("github", "api_key");
767
+
768
+ // Even with a matching grant, should be rejected
769
+ fixture.persistentStore.add({
770
+ id: "grant-with-auth",
771
+ tool: "http",
772
+ pattern: "GET https://api.github.com/user",
773
+ scope: handle,
774
+ createdAt: Date.now(),
775
+ });
776
+
777
+ const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
778
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
779
+
780
+ const result = await executeAuthenticatedHttpRequest(
781
+ {
782
+ credentialHandle: handle,
783
+ method: "GET",
784
+ url: "https://api.github.com/user",
785
+ headers: { Authorization: "Bearer smuggled-token" },
786
+ purpose: "Get user info",
787
+ },
788
+ deps,
789
+ );
790
+
791
+ expect(result.success).toBe(false);
792
+ expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
793
+ expect(result.error!.message).toContain("Authorization");
794
+ expect(requests).toHaveLength(0);
795
+ });
796
+
797
+ test("rejects request with Cookie header", async () => {
798
+ const handle = localStaticHandle("github", "api_key");
799
+
800
+ fixture.persistentStore.add({
801
+ id: "grant-with-cookie",
802
+ tool: "http",
803
+ pattern: "GET https://api.github.com/user",
804
+ scope: handle,
805
+ createdAt: Date.now(),
806
+ });
807
+
808
+ const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
809
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
810
+
811
+ const result = await executeAuthenticatedHttpRequest(
812
+ {
813
+ credentialHandle: handle,
814
+ method: "GET",
815
+ url: "https://api.github.com/user",
816
+ headers: { Cookie: "session=hijacked" },
817
+ purpose: "Get user info",
818
+ },
819
+ deps,
820
+ );
821
+
822
+ expect(result.success).toBe(false);
823
+ expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
824
+ expect(requests).toHaveLength(0);
825
+ });
826
+
827
+ test("rejects request with X-Api-Key header", async () => {
828
+ const handle = localStaticHandle("github", "api_key");
829
+
830
+ fixture.persistentStore.add({
831
+ id: "grant-with-xapikey",
832
+ tool: "http",
833
+ pattern: "GET https://api.github.com/user",
834
+ scope: handle,
835
+ createdAt: Date.now(),
836
+ });
837
+
838
+ const deps = buildDeps(fixture, []);
839
+
840
+ const result = await executeAuthenticatedHttpRequest(
841
+ {
842
+ credentialHandle: handle,
843
+ method: "GET",
844
+ url: "https://api.github.com/user",
845
+ headers: { "X-Api-Key": "smuggled-key" },
846
+ purpose: "Get user info",
847
+ },
848
+ deps,
849
+ );
850
+
851
+ expect(result.success).toBe(false);
852
+ expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
853
+ });
854
+ });
855
+
856
+ // ---------------------------------------------------------------------------
857
+ // Tests: Redirect denial
858
+ // ---------------------------------------------------------------------------
859
+
860
+ describe("HTTP executor: redirect denial", () => {
861
+ let fixture: TestFixture;
862
+
863
+ beforeEach(() => {
864
+ const storageKey = credentialKey("github", "api_key");
865
+ fixture = createFixture(
866
+ { [storageKey]: "ghp_testtoken_12345678" },
867
+ [buildStaticRecord({ service: "github", field: "api_key" })],
868
+ );
869
+ });
870
+
871
+ afterEach(() => {
872
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
873
+ });
874
+
875
+ test("blocks redirect to domain not covered by grant", async () => {
876
+ const handle = localStaticHandle("github", "api_key");
877
+
878
+ // Grant only covers api.github.com
879
+ fixture.persistentStore.add({
880
+ id: "grant-github-only",
881
+ tool: "http",
882
+ pattern: "GET https://api.github.com/repos/owner/repo",
883
+ scope: handle,
884
+ createdAt: Date.now(),
885
+ });
886
+
887
+ // Redirect to an evil domain
888
+ const fetchFn = mockFetchRedirect(
889
+ "https://evil.example.com/steal-token",
890
+ 302,
891
+ 200,
892
+ '{"stolen": true}',
893
+ );
894
+
895
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
896
+
897
+ const result = await executeAuthenticatedHttpRequest(
898
+ {
899
+ credentialHandle: handle,
900
+ method: "GET",
901
+ url: "https://api.github.com/repos/owner/repo",
902
+ purpose: "Get repo info",
903
+ },
904
+ deps,
905
+ );
906
+
907
+ expect(result.success).toBe(false);
908
+ expect(result.error!.code).toBe("HTTP_REQUEST_FAILED");
909
+ expect(result.error!.message).toContain("redirect");
910
+ expect(result.error!.message).toContain("denied");
911
+ });
912
+
913
+ test("allows redirect to path covered by same grant", async () => {
914
+ const handle = localStaticHandle("github", "api_key");
915
+
916
+ // Grant covers a template that matches both source and redirect target
917
+ fixture.persistentStore.add({
918
+ id: "grant-github-repos-template",
919
+ tool: "http",
920
+ pattern: "GET https://api.github.com/repos/owner/repo",
921
+ scope: handle,
922
+ createdAt: Date.now(),
923
+ });
924
+
925
+ // Redirect to the same domain/path that matches the grant
926
+ let callCount = 0;
927
+ const fetchFn = asFetch(async (
928
+ _url: string | URL | Request,
929
+ _init?: RequestInit,
930
+ ) => {
931
+ callCount++;
932
+ if (callCount === 1) {
933
+ return new Response(null, {
934
+ status: 301,
935
+ headers: { Location: "https://api.github.com/repos/owner/repo" },
936
+ });
937
+ }
938
+ return new Response('{"name": "repo"}', {
939
+ status: 200,
940
+ headers: { "Content-Type": "application/json" },
941
+ });
942
+ });
943
+
944
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
945
+
946
+ const result = await executeAuthenticatedHttpRequest(
947
+ {
948
+ credentialHandle: handle,
949
+ method: "GET",
950
+ url: "https://api.github.com/repos/owner/repo",
951
+ purpose: "Get repo info",
952
+ },
953
+ deps,
954
+ );
955
+
956
+ expect(result.success).toBe(true);
957
+ expect(result.statusCode).toBe(200);
958
+ });
959
+ });
960
+
961
+ // ---------------------------------------------------------------------------
962
+ // Tests: Filtered response behaviour
963
+ // ---------------------------------------------------------------------------
964
+
965
+ describe("HTTP executor: filtered response behaviour", () => {
966
+ let fixture: TestFixture;
967
+
968
+ beforeEach(() => {
969
+ const storageKey = credentialKey("example", "key");
970
+ fixture = createFixture(
971
+ { [storageKey]: "secret-key-value-12345678" },
972
+ [buildStaticRecord({ service: "example", field: "key" })],
973
+ );
974
+ });
975
+
976
+ afterEach(() => {
977
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
978
+ });
979
+
980
+ test("strips www-authenticate from response headers", async () => {
981
+ const handle = localStaticHandle("example", "key");
982
+
983
+ fixture.persistentStore.add({
984
+ id: "grant-example",
985
+ tool: "http",
986
+ pattern: "GET https://api.example.com/protected",
987
+ scope: handle,
988
+ createdAt: Date.now(),
989
+ });
990
+
991
+ const fetchFn = mockFetch(401, '{"error": "unauthorized"}', {
992
+ "www-authenticate": "Bearer realm=api",
993
+ "content-type": "application/json",
994
+ });
995
+
996
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
997
+
998
+ const result = await executeAuthenticatedHttpRequest(
999
+ {
1000
+ credentialHandle: handle,
1001
+ method: "GET",
1002
+ url: "https://api.example.com/protected",
1003
+ purpose: "Access protected resource",
1004
+ },
1005
+ deps,
1006
+ );
1007
+
1008
+ expect(result.success).toBe(true);
1009
+ expect(result.statusCode).toBe(401);
1010
+ expect(result.responseHeaders!["www-authenticate"]).toBeUndefined();
1011
+ expect(result.responseHeaders!["content-type"]).toBe("application/json");
1012
+ });
1013
+
1014
+ test("passes through safe response headers", async () => {
1015
+ const handle = localStaticHandle("example", "key");
1016
+
1017
+ fixture.persistentStore.add({
1018
+ id: "grant-example-safe",
1019
+ tool: "http",
1020
+ pattern: "GET https://api.example.com/data",
1021
+ scope: handle,
1022
+ createdAt: Date.now(),
1023
+ });
1024
+
1025
+ const fetchFn = mockFetch(200, '{"ok": true}', {
1026
+ "content-type": "application/json",
1027
+ "x-ratelimit-remaining": "42",
1028
+ "x-request-id": "abc-def",
1029
+ "etag": '"v1"',
1030
+ });
1031
+
1032
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
1033
+
1034
+ const result = await executeAuthenticatedHttpRequest(
1035
+ {
1036
+ credentialHandle: handle,
1037
+ method: "GET",
1038
+ url: "https://api.example.com/data",
1039
+ purpose: "Get data",
1040
+ },
1041
+ deps,
1042
+ );
1043
+
1044
+ expect(result.success).toBe(true);
1045
+ expect(result.responseHeaders!["content-type"]).toBe("application/json");
1046
+ expect(result.responseHeaders!["x-ratelimit-remaining"]).toBe("42");
1047
+ expect(result.responseHeaders!["x-request-id"]).toBe("abc-def");
1048
+ expect(result.responseHeaders!["etag"]).toBe('"v1"');
1049
+ });
1050
+ });
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // Tests: Audit summaries are token-free
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ describe("HTTP executor: audit summary integrity", () => {
1057
+ let fixture: TestFixture;
1058
+
1059
+ beforeEach(() => {
1060
+ const storageKey = credentialKey("github", "api_key");
1061
+ fixture = createFixture(
1062
+ { [storageKey]: "ghp_secrettoken_12345678" },
1063
+ [buildStaticRecord({ service: "github", field: "api_key" })],
1064
+ );
1065
+ });
1066
+
1067
+ afterEach(() => {
1068
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
1069
+ });
1070
+
1071
+ test("audit ID is returned on successful request", async () => {
1072
+ const handle = localStaticHandle("github", "api_key");
1073
+
1074
+ fixture.persistentStore.add({
1075
+ id: "grant-audit-test",
1076
+ tool: "http",
1077
+ pattern: "GET https://api.github.com/user",
1078
+ scope: handle,
1079
+ createdAt: Date.now(),
1080
+ });
1081
+
1082
+ const fetchFn = mockFetch(200, '{"login": "octocat"}');
1083
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
1084
+
1085
+ const result = await executeAuthenticatedHttpRequest(
1086
+ {
1087
+ credentialHandle: handle,
1088
+ method: "GET",
1089
+ url: "https://api.github.com/user",
1090
+ purpose: "Get user info",
1091
+ },
1092
+ deps,
1093
+ );
1094
+
1095
+ expect(result.success).toBe(true);
1096
+ expect(result.auditId).toBeDefined();
1097
+ expect(typeof result.auditId).toBe("string");
1098
+ expect(result.auditId!.length).toBeGreaterThan(0);
1099
+ });
1100
+
1101
+ test("audit ID is returned on materialisation failure", async () => {
1102
+ const handle = localStaticHandle("github", "api_key");
1103
+
1104
+ // Grant exists but we'll break materialisation by using a handle that
1105
+ // won't resolve (wrong service)
1106
+ const badHandle = localStaticHandle("nonexistent", "key");
1107
+ fixture.persistentStore.add({
1108
+ id: "grant-bad-cred",
1109
+ tool: "http",
1110
+ pattern: "GET https://api.github.com/user",
1111
+ scope: badHandle,
1112
+ createdAt: Date.now(),
1113
+ });
1114
+
1115
+ const deps = buildDeps(fixture, []);
1116
+
1117
+ const result = await executeAuthenticatedHttpRequest(
1118
+ {
1119
+ credentialHandle: badHandle,
1120
+ method: "GET",
1121
+ url: "https://api.github.com/user",
1122
+ purpose: "Get user info",
1123
+ },
1124
+ deps,
1125
+ );
1126
+
1127
+ expect(result.success).toBe(false);
1128
+ expect(result.error!.code).toBe("MATERIALISATION_FAILED");
1129
+ expect(result.auditId).toBeDefined();
1130
+ });
1131
+ });
1132
+
1133
+ // ---------------------------------------------------------------------------
1134
+ // Tests: Invalid handle
1135
+ // ---------------------------------------------------------------------------
1136
+
1137
+ describe("HTTP executor: invalid handle", () => {
1138
+ let fixture: TestFixture;
1139
+
1140
+ beforeEach(() => {
1141
+ fixture = createFixture();
1142
+ });
1143
+
1144
+ afterEach(() => {
1145
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
1146
+ });
1147
+
1148
+ test("rejects completely invalid handle format", async () => {
1149
+ const deps = buildDeps(fixture, []);
1150
+
1151
+ const result = await executeAuthenticatedHttpRequest(
1152
+ {
1153
+ credentialHandle: "not-a-valid-handle",
1154
+ method: "GET",
1155
+ url: "https://api.example.com/data",
1156
+ purpose: "Get data",
1157
+ },
1158
+ deps,
1159
+ );
1160
+
1161
+ expect(result.success).toBe(false);
1162
+ expect(result.error!.code).toBe("INVALID_HANDLE");
1163
+ });
1164
+ });
1165
+
1166
+ // ---------------------------------------------------------------------------
1167
+ // Tests: Network error handling
1168
+ // ---------------------------------------------------------------------------
1169
+
1170
+ describe("HTTP executor: network error handling", () => {
1171
+ let fixture: TestFixture;
1172
+
1173
+ beforeEach(() => {
1174
+ const storageKey = credentialKey("example", "key");
1175
+ fixture = createFixture(
1176
+ { [storageKey]: "secret-key-value-12345678" },
1177
+ [buildStaticRecord({ service: "example", field: "key" })],
1178
+ );
1179
+ });
1180
+
1181
+ afterEach(() => {
1182
+ rmSync(fixture.tmpDir, { recursive: true, force: true });
1183
+ });
1184
+
1185
+ test("returns error when fetch fails", async () => {
1186
+ const handle = localStaticHandle("example", "key");
1187
+
1188
+ fixture.persistentStore.add({
1189
+ id: "grant-network-fail",
1190
+ tool: "http",
1191
+ pattern: "GET https://api.example.com/data",
1192
+ scope: handle,
1193
+ createdAt: Date.now(),
1194
+ });
1195
+
1196
+ const fetchFn = asFetch(async () => {
1197
+ throw new Error("Connection refused");
1198
+ });
1199
+
1200
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
1201
+
1202
+ const result = await executeAuthenticatedHttpRequest(
1203
+ {
1204
+ credentialHandle: handle,
1205
+ method: "GET",
1206
+ url: "https://api.example.com/data",
1207
+ purpose: "Get data",
1208
+ },
1209
+ deps,
1210
+ );
1211
+
1212
+ expect(result.success).toBe(false);
1213
+ expect(result.error!.code).toBe("HTTP_REQUEST_FAILED");
1214
+ expect(result.error!.message).toContain("Connection refused");
1215
+ expect(result.auditId).toBeDefined();
1216
+ });
1217
+
1218
+ test("error message is scrubbed of secret values", async () => {
1219
+ const handle = localStaticHandle("example", "key");
1220
+
1221
+ fixture.persistentStore.add({
1222
+ id: "grant-error-scrub",
1223
+ tool: "http",
1224
+ pattern: "GET https://api.example.com/data",
1225
+ scope: handle,
1226
+ createdAt: Date.now(),
1227
+ });
1228
+
1229
+ const fetchFn = asFetch(async () => {
1230
+ throw new Error(
1231
+ "Failed to connect with token secret-key-value-12345678 to api.example.com",
1232
+ );
1233
+ });
1234
+
1235
+ const deps = buildDeps(fixture, [], { fetch: fetchFn });
1236
+
1237
+ const result = await executeAuthenticatedHttpRequest(
1238
+ {
1239
+ credentialHandle: handle,
1240
+ method: "GET",
1241
+ url: "https://api.example.com/data",
1242
+ purpose: "Get data",
1243
+ },
1244
+ deps,
1245
+ );
1246
+
1247
+ expect(result.success).toBe(false);
1248
+ expect(result.error!.message).not.toContain("secret-key-value-12345678");
1249
+ expect(result.error!.message).toContain("[CES:REDACTED]");
1250
+ });
1251
+ });