@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,540 @@
1
+ /**
2
+ * HTTP executor for the Credential Execution Service.
3
+ *
4
+ * Implements the full `make_authenticated_request` flow:
5
+ *
6
+ * 1. Resolve the credential handle to a local or managed subject.
7
+ * 2. Check grants (policy evaluation) — block off-grant requests before
8
+ * any network call.
9
+ * 3. Materialise the credential through the appropriate backend.
10
+ * 4. Inject auth into the outbound request according to the subject's
11
+ * handle type.
12
+ * 5. Perform the HTTP request.
13
+ * 6. Reject redirect hops that would violate the grant policy.
14
+ * 7. Filter the response through the PR 21 sanitisation pipeline.
15
+ * 8. Generate a token-free audit summary.
16
+ *
17
+ * Security invariants:
18
+ * - Off-grant requests never reach the network.
19
+ * - Caller-supplied raw auth headers are rejected.
20
+ * - Redirect hops to domains/paths outside the grant's scope are blocked.
21
+ * - The assistant runtime only sees sanitised HTTP results and audit
22
+ * summaries — never raw tokens or secrets.
23
+ * - Audit summaries are always token-free.
24
+ */
25
+
26
+ import type {
27
+ MakeAuthenticatedRequest,
28
+ MakeAuthenticatedRequestResponse,
29
+ } from "@vellumai/ces-contracts";
30
+ import { HandleType, parseHandle, hashProposal } from "@vellumai/ces-contracts";
31
+
32
+ import { evaluateHttpPolicy, type PolicyResult } from "./policy.js";
33
+ import { filterHttpResponse, type RawHttpResponse } from "./response-filter.js";
34
+ import { generateHttpAuditSummary } from "./audit.js";
35
+
36
+ import type { PersistentGrantStore } from "../grants/persistent-store.js";
37
+ import type { TemporaryGrantStore } from "../grants/temporary-store.js";
38
+
39
+ import type { LocalMaterialiser, MaterialisedCredential } from "../materializers/local.js";
40
+ import { materializeManagedToken, type ManagedMaterializerOptions } from "../materializers/managed-platform.js";
41
+ import { resolveLocalSubject, type LocalSubjectResolverDeps } from "../subjects/local.js";
42
+ import { resolveManagedSubject, type ManagedSubjectResolverOptions } from "../subjects/managed.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Auth injection constants
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Headers that are forbidden in caller-supplied requests. This is
50
+ * enforced at the policy layer, but we double-check before injection
51
+ * as defense-in-depth.
52
+ */
53
+ const AUTH_HEADERS_TO_STRIP = new Set([
54
+ "authorization",
55
+ "cookie",
56
+ "proxy-authorization",
57
+ "x-api-key",
58
+ "x-auth-token",
59
+ ]);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Executor dependencies
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface HttpExecutorDeps {
66
+ /** Persistent grant store for policy evaluation. */
67
+ persistentGrantStore: PersistentGrantStore;
68
+ /** Temporary grant store for policy evaluation. */
69
+ temporaryGrantStore: TemporaryGrantStore;
70
+ /** Local materialiser for local_static and local_oauth handles. */
71
+ localMaterialiser: LocalMaterialiser;
72
+ /** Dependencies for local subject resolution. */
73
+ localSubjectDeps: LocalSubjectResolverDeps;
74
+ /** Options for managed subject resolution (null if managed mode is unavailable). */
75
+ managedSubjectOptions?: ManagedSubjectResolverOptions;
76
+ /** Options for managed token materialisation (null if managed mode is unavailable). */
77
+ managedMaterializerOptions?: ManagedMaterializerOptions;
78
+ /** Session ID for audit records. */
79
+ sessionId: string;
80
+ /** Optional custom fetch implementation (for testing). */
81
+ fetch?: typeof globalThis.fetch;
82
+ /** Optional logger. */
83
+ logger?: Pick<Console, "log" | "warn" | "error">;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Redirect policy
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Maximum number of redirects to follow before aborting.
92
+ */
93
+ const MAX_REDIRECTS = 5;
94
+
95
+ /**
96
+ * HTTP status codes that indicate a redirect.
97
+ */
98
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Executor implementation
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Execute an authenticated HTTP request through the full CES pipeline.
106
+ *
107
+ * This is the handler implementation for the `make_authenticated_request`
108
+ * RPC method. It is pure logic with injected dependencies, making it
109
+ * testable without real network calls or credential stores.
110
+ */
111
+ export async function executeAuthenticatedHttpRequest(
112
+ request: MakeAuthenticatedRequest,
113
+ deps: HttpExecutorDeps,
114
+ ): Promise<MakeAuthenticatedRequestResponse> {
115
+ const logger = deps.logger ?? console;
116
+
117
+ // 1. Parse the handle to determine source (local vs managed)
118
+ const parseResult = parseHandle(request.credentialHandle);
119
+ if (!parseResult.ok) {
120
+ return {
121
+ success: false,
122
+ error: {
123
+ code: "INVALID_HANDLE",
124
+ message: parseResult.error,
125
+ },
126
+ };
127
+ }
128
+
129
+ // 2. Evaluate grant policy — blocks off-grant requests before network
130
+ const policyResult = evaluateHttpPolicy(
131
+ {
132
+ credentialHandle: request.credentialHandle,
133
+ method: request.method,
134
+ url: request.url,
135
+ headers: request.headers,
136
+ purpose: request.purpose,
137
+ grantId: request.grantId,
138
+ },
139
+ deps.persistentGrantStore,
140
+ deps.temporaryGrantStore,
141
+ );
142
+
143
+ if (!policyResult.allowed) {
144
+ if (policyResult.reason === "forbidden_headers") {
145
+ return {
146
+ success: false,
147
+ error: {
148
+ code: "FORBIDDEN_HEADERS",
149
+ message: `Request contains forbidden auth headers that the agent must not set: ${policyResult.forbiddenHeaders.join(", ")}. CES injects authentication — the caller must not supply raw auth headers.`,
150
+ },
151
+ };
152
+ }
153
+
154
+ // approval_required — return the proposal so the assistant can prompt
155
+ return {
156
+ success: false,
157
+ error: {
158
+ code: "APPROVAL_REQUIRED",
159
+ message: `No active grant covers this request. Approval is required.`,
160
+ details: {
161
+ proposal: policyResult.proposal,
162
+ proposalHash: hashProposal(policyResult.proposal),
163
+ },
164
+ },
165
+ };
166
+ }
167
+
168
+ const grantId = policyResult.grantId;
169
+
170
+ // 3. Materialise the credential
171
+ const materialiseResult = await materialiseCredential(
172
+ parseResult.handle.type,
173
+ request.credentialHandle,
174
+ deps,
175
+ );
176
+
177
+ if (!materialiseResult.ok) {
178
+ const audit = generateHttpAuditSummary({
179
+ credentialHandle: request.credentialHandle,
180
+ grantId,
181
+ sessionId: deps.sessionId,
182
+ method: request.method,
183
+ url: request.url,
184
+ success: false,
185
+ errorMessage: materialiseResult.error,
186
+ });
187
+
188
+ return {
189
+ success: false,
190
+ error: {
191
+ code: "MATERIALISATION_FAILED",
192
+ message: materialiseResult.error,
193
+ },
194
+ auditId: audit.auditId,
195
+ };
196
+ }
197
+
198
+ const { credential, secrets } = materialiseResult;
199
+
200
+ // 4. Build the outbound request with injected auth
201
+ const outboundHeaders = buildOutboundHeaders(
202
+ request.headers ?? {},
203
+ credential,
204
+ );
205
+
206
+ // 5. Perform the HTTP request with redirect enforcement
207
+ let rawResponse: RawHttpResponse;
208
+ try {
209
+ rawResponse = await performHttpRequest(
210
+ request.method,
211
+ request.url,
212
+ outboundHeaders,
213
+ request.body,
214
+ policyResult,
215
+ request.credentialHandle,
216
+ deps,
217
+ );
218
+ } catch (err) {
219
+ const errorMessage = err instanceof Error ? err.message : String(err);
220
+ // Sanitise error messages to avoid leaking secrets
221
+ const safeError = sanitiseErrorMessage(errorMessage, secrets);
222
+
223
+ const audit = generateHttpAuditSummary({
224
+ credentialHandle: request.credentialHandle,
225
+ grantId,
226
+ sessionId: deps.sessionId,
227
+ method: request.method,
228
+ url: request.url,
229
+ success: false,
230
+ errorMessage: safeError,
231
+ });
232
+
233
+ return {
234
+ success: false,
235
+ error: {
236
+ code: "HTTP_REQUEST_FAILED",
237
+ message: safeError,
238
+ },
239
+ auditId: audit.auditId,
240
+ };
241
+ }
242
+
243
+ // 6. Filter the response through the sanitisation pipeline
244
+ const filtered = filterHttpResponse(rawResponse, secrets);
245
+
246
+ // 7. Generate audit summary
247
+ const audit = generateHttpAuditSummary({
248
+ credentialHandle: request.credentialHandle,
249
+ grantId,
250
+ sessionId: deps.sessionId,
251
+ method: request.method,
252
+ url: request.url,
253
+ success: true,
254
+ statusCode: rawResponse.statusCode,
255
+ });
256
+
257
+ logger.log(
258
+ `[ces-http] ${request.method} ${request.url} -> ${rawResponse.statusCode} (grant=${grantId})`,
259
+ );
260
+
261
+ return {
262
+ success: true,
263
+ statusCode: filtered.statusCode,
264
+ responseHeaders: filtered.headers,
265
+ responseBody: filtered.body,
266
+ auditId: audit.auditId,
267
+ };
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Credential materialisation dispatch
272
+ // ---------------------------------------------------------------------------
273
+
274
+ interface MaterialiseSuccess {
275
+ ok: true;
276
+ credential: MaterialisedCredential;
277
+ /** Secret values to scrub from response bodies (defense-in-depth). */
278
+ secrets: string[];
279
+ }
280
+
281
+ interface MaterialiseFailure {
282
+ ok: false;
283
+ error: string;
284
+ }
285
+
286
+ type MaterialiseResult = MaterialiseSuccess | MaterialiseFailure;
287
+
288
+ async function materialiseCredential(
289
+ handleType: string,
290
+ rawHandle: string,
291
+ deps: HttpExecutorDeps,
292
+ ): Promise<MaterialiseResult> {
293
+ switch (handleType) {
294
+ case HandleType.LocalStatic:
295
+ case HandleType.LocalOAuth: {
296
+ // Resolve local subject
297
+ const subjectResult = resolveLocalSubject(rawHandle, deps.localSubjectDeps);
298
+ if (!subjectResult.ok) {
299
+ return { ok: false, error: subjectResult.error };
300
+ }
301
+
302
+ // Materialise through the local materialiser
303
+ const matResult = await deps.localMaterialiser.materialise(subjectResult.subject);
304
+ if (!matResult.ok) {
305
+ return { ok: false, error: matResult.error };
306
+ }
307
+
308
+ return {
309
+ ok: true,
310
+ credential: matResult.credential,
311
+ secrets: [matResult.credential.value],
312
+ };
313
+ }
314
+
315
+ case HandleType.PlatformOAuth: {
316
+ if (!deps.managedSubjectOptions || !deps.managedMaterializerOptions) {
317
+ return {
318
+ ok: false,
319
+ error: "Managed OAuth is not configured. Platform URL and API key are required.",
320
+ };
321
+ }
322
+
323
+ // Resolve managed subject
324
+ const subjectResult = await resolveManagedSubject(
325
+ rawHandle,
326
+ deps.managedSubjectOptions,
327
+ );
328
+ if (!subjectResult.ok) {
329
+ return { ok: false, error: subjectResult.error.message };
330
+ }
331
+
332
+ // Materialise through the managed materialiser
333
+ const matResult = await materializeManagedToken(
334
+ subjectResult.subject,
335
+ deps.managedMaterializerOptions,
336
+ );
337
+ if (!matResult.ok) {
338
+ return { ok: false, error: matResult.error.message };
339
+ }
340
+
341
+ return {
342
+ ok: true,
343
+ credential: {
344
+ value: matResult.token.accessToken,
345
+ handleType: HandleType.PlatformOAuth,
346
+ expiresAt: matResult.token.expiresAt,
347
+ },
348
+ secrets: [matResult.token.accessToken],
349
+ };
350
+ }
351
+
352
+ default:
353
+ return {
354
+ ok: false,
355
+ error: `Unsupported handle type "${handleType}" for HTTP execution`,
356
+ };
357
+ }
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Auth header injection
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /**
365
+ * Build the outbound request headers by:
366
+ * 1. Stripping any caller-supplied auth headers (defense-in-depth).
367
+ * 2. Injecting the credential as an Authorization header.
368
+ */
369
+ function buildOutboundHeaders(
370
+ callerHeaders: Record<string, string>,
371
+ credential: MaterialisedCredential,
372
+ ): Record<string, string> {
373
+ const headers: Record<string, string> = {};
374
+
375
+ // Copy caller headers, stripping any auth headers
376
+ for (const [key, value] of Object.entries(callerHeaders)) {
377
+ if (!AUTH_HEADERS_TO_STRIP.has(key.toLowerCase())) {
378
+ headers[key] = value;
379
+ }
380
+ }
381
+
382
+ // Inject credential based on handle type
383
+ switch (credential.handleType) {
384
+ case HandleType.LocalStatic:
385
+ // Static secrets are injected as Bearer tokens by default.
386
+ // The subject metadata could specify a different injection strategy
387
+ // in the future, but for now Bearer is the safe default.
388
+ headers["Authorization"] = `Bearer ${credential.value}`;
389
+ break;
390
+
391
+ case HandleType.LocalOAuth:
392
+ case HandleType.PlatformOAuth:
393
+ // OAuth tokens are always Bearer tokens.
394
+ headers["Authorization"] = `Bearer ${credential.value}`;
395
+ break;
396
+
397
+ default:
398
+ // Unknown type — inject as Bearer (fail-open on injection is OK
399
+ // because the grant policy already vetted the request).
400
+ headers["Authorization"] = `Bearer ${credential.value}`;
401
+ break;
402
+ }
403
+
404
+ return headers;
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // HTTP request execution with redirect enforcement
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Perform an HTTP request, following redirects only when each hop
413
+ * independently satisfies the grant policy.
414
+ */
415
+ async function performHttpRequest(
416
+ method: string,
417
+ url: string,
418
+ headers: Record<string, string>,
419
+ body: unknown | undefined,
420
+ originalPolicy: PolicyResult & { allowed: true },
421
+ credentialHandle: string,
422
+ deps: HttpExecutorDeps,
423
+ ): Promise<RawHttpResponse> {
424
+ const fetchFn = deps.fetch ?? globalThis.fetch;
425
+
426
+ let currentUrl = url;
427
+ let currentMethod = method;
428
+ let currentHeaders = headers;
429
+ let currentBody = body;
430
+ let redirectCount = 0;
431
+
432
+ while (true) {
433
+ // Build fetch options — disable automatic redirect following so we
434
+ // can enforce grant policy on each hop.
435
+ const fetchOptions: RequestInit = {
436
+ method: currentMethod,
437
+ headers: currentHeaders,
438
+ redirect: "manual",
439
+ };
440
+
441
+ if (currentBody !== undefined && currentBody !== null) {
442
+ fetchOptions.body =
443
+ typeof currentBody === "string"
444
+ ? currentBody
445
+ : JSON.stringify(currentBody);
446
+ }
447
+
448
+ const response = await fetchFn(currentUrl, fetchOptions);
449
+
450
+ // Check for redirect
451
+ if (REDIRECT_STATUSES.has(response.status)) {
452
+ redirectCount++;
453
+ if (redirectCount > MAX_REDIRECTS) {
454
+ throw new Error(
455
+ `Too many redirects (exceeded ${MAX_REDIRECTS}). Aborting.`,
456
+ );
457
+ }
458
+
459
+ const locationHeader = response.headers.get("location");
460
+ if (!locationHeader) {
461
+ throw new Error(
462
+ `Redirect response (${response.status}) missing Location header.`,
463
+ );
464
+ }
465
+
466
+ // Resolve the redirect URL (may be relative)
467
+ const redirectUrl = new URL(locationHeader, currentUrl).toString();
468
+
469
+ // Enforce grant policy on the redirect target — the redirect must
470
+ // independently satisfy the same credential handle's grant policy.
471
+ const redirectPolicy = evaluateHttpPolicy(
472
+ {
473
+ credentialHandle,
474
+ method: currentMethod,
475
+ url: redirectUrl,
476
+ purpose: `redirect from ${currentUrl}`,
477
+ },
478
+ deps.persistentGrantStore,
479
+ deps.temporaryGrantStore,
480
+ );
481
+
482
+ if (!redirectPolicy.allowed) {
483
+ throw new Error(
484
+ `Redirect to ${sanitiseUrl(redirectUrl)} denied: the redirect target does not satisfy the grant policy for credential handle "${credentialHandle}".`,
485
+ );
486
+ }
487
+
488
+ // For 303 redirects, convert to GET
489
+ if (response.status === 303) {
490
+ currentMethod = "GET";
491
+ currentBody = undefined;
492
+ }
493
+
494
+ currentUrl = redirectUrl;
495
+ continue;
496
+ }
497
+
498
+ // Not a redirect — read the response
499
+ const responseBody = await response.text();
500
+ const responseHeaders: Record<string, string> = {};
501
+ response.headers.forEach((value, key) => {
502
+ responseHeaders[key] = value;
503
+ });
504
+
505
+ return {
506
+ statusCode: response.status,
507
+ headers: responseHeaders,
508
+ body: responseBody,
509
+ };
510
+ }
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Helpers
515
+ // ---------------------------------------------------------------------------
516
+
517
+ /**
518
+ * Sanitise a URL for error messages by stripping query parameters
519
+ * (which may contain sensitive values).
520
+ */
521
+ function sanitiseUrl(url: string): string {
522
+ try {
523
+ const parsed = new URL(url);
524
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
525
+ } catch {
526
+ return "[invalid-url]";
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Sanitise error messages to avoid leaking secret values.
532
+ */
533
+ function sanitiseErrorMessage(message: string, secrets: string[]): string {
534
+ let result = message;
535
+ for (const secret of secrets) {
536
+ if (secret.length < 8) continue;
537
+ result = result.replaceAll(secret, "[CES:REDACTED]");
538
+ }
539
+ return result;
540
+ }