@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,840 @@
1
+ /**
2
+ * CES authenticated command executor.
3
+ *
4
+ * Orchestrates secure command execution through the following pipeline:
5
+ *
6
+ * 1. **Bundle resolution** — Resolve the bundle digest from the toolstore
7
+ * and verify the secure command manifest is published and approved.
8
+ *
9
+ * 2. **Profile validation** — Validate the command argv against the
10
+ * manifest's allowed profiles, checking for denied binaries, denied
11
+ * subcommands, and denied flags.
12
+ *
13
+ * 3. **Grant enforcement** — Verify that an active grant covers this
14
+ * bundle-digest/profile pair and credential handle.
15
+ *
16
+ * 4. **Workspace staging** — Stage declared workspace inputs into a
17
+ * CES-private scratch directory.
18
+ *
19
+ * 5. **Auth materialization** — Materialize the credential through the
20
+ * declared auth adapter (env_var, temp_file, or credential_process).
21
+ *
22
+ * 6. **Egress proxy startup** — Start a CES-owned egress proxy session
23
+ * (when egressMode is `proxy_required`) to enforce network target
24
+ * allowlists.
25
+ *
26
+ * 7. **Command execution** — Run the command with clean config dirs,
27
+ * materialized credential env vars, and proxy env vars. The command
28
+ * runs in the scratch directory, never in the assistant workspace.
29
+ *
30
+ * 8. **Output copyback** — After exit, validate and copy declared output
31
+ * files from the scratch directory back into the workspace.
32
+ *
33
+ * 9. **Cleanup** — Stop the egress proxy session, remove temp files, and
34
+ * clean up the scratch directory.
35
+ *
36
+ * The executor is fail-closed: bundle mismatches, missing grants,
37
+ * adapter failures, egress failures, undeclared outputs, and scan
38
+ * violations all result in command rejection before or after execution.
39
+ */
40
+
41
+ import { createHash, randomUUID } from "node:crypto";
42
+ import { dirname, join } from "node:path";
43
+ import { mkdirSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
44
+ import { tmpdir } from "node:os";
45
+ import {
46
+ SessionStore,
47
+ createSession,
48
+ startSession,
49
+ stopSession,
50
+ getSessionEnv,
51
+ type SessionStartHooks,
52
+ type ProxyEnvVars,
53
+ } from "@vellumai/egress-proxy";
54
+
55
+ import { readPublishedManifest, getBundleContentPath, isBundlePublished } from "../toolstore/publish.js";
56
+ import { getCesToolStoreDir, type CesMode } from "../paths.js";
57
+ import type { SecureCommandManifest, CommandProfile } from "./profiles.js";
58
+ import { isDeniedBinary, EgressMode } from "./profiles.js";
59
+ import { validateCommand, type CommandValidationResult } from "./validator.js";
60
+ import type { AuthAdapterConfig } from "./auth-adapters.js";
61
+ import { AuthAdapterType, validateAuthAdapterConfig } from "./auth-adapters.js";
62
+ import {
63
+ stageInputs,
64
+ copybackOutputs,
65
+ cleanupScratchDir,
66
+ type WorkspaceStageConfig,
67
+ type WorkspaceInput,
68
+ type WorkspaceOutput,
69
+ type CopybackResult,
70
+ } from "./workspace.js";
71
+ import type { PersistentGrantStore } from "../grants/persistent-store.js";
72
+ import type { TemporaryGrantStore } from "../grants/temporary-store.js";
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Types
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Request to execute an authenticated command through the CES pipeline.
80
+ */
81
+ export interface ExecuteCommandRequest {
82
+ /** SHA-256 hex digest of the approved bundle. */
83
+ bundleDigest: string;
84
+ /** Name of the command profile to use within the manifest. */
85
+ profileName: string;
86
+ /** CES credential handle identifying which credential to inject. */
87
+ credentialHandle: string;
88
+ /** Argv tokens (command arguments, not including the binary path). */
89
+ argv: string[];
90
+ /** Absolute path to the assistant-visible workspace directory. */
91
+ workspaceDir: string;
92
+ /** Files to stage as read-only inputs in the scratch directory. */
93
+ inputs?: WorkspaceInput[];
94
+ /** Files to copy back from the scratch directory after execution. */
95
+ outputs?: WorkspaceOutput[];
96
+ /** Human-readable purpose for audit logging. */
97
+ purpose: string;
98
+ /** Explicit grant ID to consume, if the caller holds one. */
99
+ grantId?: string;
100
+ /** Conversation ID for thread-scoped temporary grants. */
101
+ conversationId?: string;
102
+ /** Session ID for the egress proxy. */
103
+ sessionId?: string;
104
+ }
105
+
106
+ /**
107
+ * Result of a command execution attempt.
108
+ */
109
+ export interface ExecuteCommandResult {
110
+ /** Whether the command executed successfully. */
111
+ success: boolean;
112
+ /** Process exit code (undefined if the command was never launched). */
113
+ exitCode?: number;
114
+ /** Combined stdout output (truncated for safety). */
115
+ stdout?: string;
116
+ /** Combined stderr output (truncated for safety). */
117
+ stderr?: string;
118
+ /** Copyback results for declared outputs. */
119
+ copybackResult?: CopybackResult;
120
+ /** Error message if execution failed. */
121
+ error?: string;
122
+ /** Audit-relevant metadata. */
123
+ auditId?: string;
124
+ }
125
+
126
+ /**
127
+ * Credential materializer abstraction.
128
+ *
129
+ * The executor does not import materializer implementations directly.
130
+ * Callers provide a materializer function that resolves a credential
131
+ * handle into a raw secret value.
132
+ */
133
+ export type MaterializeCredentialFn = (
134
+ credentialHandle: string,
135
+ ) => Promise<MaterializeCredentialResult>;
136
+
137
+ export type MaterializeCredentialResult =
138
+ | { ok: true; value: string; handleType: string }
139
+ | { ok: false; error: string };
140
+
141
+ /**
142
+ * Dependencies injected into the command executor.
143
+ */
144
+ export interface CommandExecutorDeps {
145
+ /** Persistent grant store for checking bundle/profile approvals. */
146
+ persistentStore: PersistentGrantStore;
147
+ /** Temporary grant store for session-scoped approvals. */
148
+ temporaryStore: TemporaryGrantStore;
149
+ /** Credential materializer function. */
150
+ materializeCredential: MaterializeCredentialFn;
151
+ /** CES operating mode (for toolstore path resolution). */
152
+ cesMode?: CesMode;
153
+ /** Egress proxy session start hooks (for creating the proxy server). */
154
+ egressHooks?: SessionStartHooks;
155
+ /** Egress proxy session store (shared or isolated). */
156
+ egressSessionStore?: SessionStore;
157
+ /** Maximum stdout/stderr capture size in bytes. */
158
+ maxOutputBytes?: number;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Constants
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Maximum stdout/stderr capture (256 KB). */
166
+ const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
167
+
168
+ /** Credential process helper timeout. */
169
+ const CREDENTIAL_PROCESS_TIMEOUT_MS = 10_000;
170
+
171
+ /**
172
+ * Banned binary names — checked at execution time as a defense-in-depth
173
+ * supplement to the manifest validator's static check.
174
+ */
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Executor implementation
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Execute an authenticated command through the full CES pipeline.
182
+ *
183
+ * This is the top-level orchestrator. Each step is fail-closed: if any
184
+ * phase returns an error, the command is rejected and cleanup runs.
185
+ */
186
+ export async function executeAuthenticatedCommand(
187
+ request: ExecuteCommandRequest,
188
+ deps: CommandExecutorDeps,
189
+ ): Promise<ExecuteCommandResult> {
190
+ const auditId = randomUUID();
191
+
192
+ // -- 1. Resolve and validate the bundle -----------------------------------
193
+ const bundleResult = resolveBundle(request.bundleDigest, deps.cesMode);
194
+ if (!bundleResult.ok) {
195
+ return {
196
+ success: false,
197
+ error: bundleResult.error,
198
+ auditId,
199
+ };
200
+ }
201
+
202
+ const { manifest, toolstoreDir } = bundleResult;
203
+
204
+ // -- 2. Validate the command profile and argv -----------------------------
205
+ const profileResult = validateProfile(
206
+ manifest,
207
+ request.profileName,
208
+ request.argv,
209
+ );
210
+ if (!profileResult.ok) {
211
+ return {
212
+ success: false,
213
+ error: profileResult.error,
214
+ auditId,
215
+ };
216
+ }
217
+
218
+ // -- 3. Check grant enforcement -------------------------------------------
219
+ const grantResult = checkGrant(
220
+ request,
221
+ manifest,
222
+ request.profileName,
223
+ deps.persistentStore,
224
+ deps.temporaryStore,
225
+ );
226
+ if (!grantResult.ok) {
227
+ return {
228
+ success: false,
229
+ error: grantResult.error,
230
+ auditId,
231
+ };
232
+ }
233
+
234
+ // -- 4. Stage workspace inputs --------------------------------------------
235
+ const stageConfig: WorkspaceStageConfig = {
236
+ workspaceDir: request.workspaceDir,
237
+ inputs: request.inputs ?? [],
238
+ outputs: request.outputs ?? [],
239
+ secrets: new Set<string>(), // Populated after materialization
240
+ };
241
+
242
+ let scratchDir: string;
243
+ try {
244
+ const staged = stageInputs(stageConfig, deps.cesMode);
245
+ scratchDir = staged.scratchDir;
246
+ } catch (err) {
247
+ return {
248
+ success: false,
249
+ error: `Input staging failed: ${err instanceof Error ? err.message : String(err)}`,
250
+ auditId,
251
+ };
252
+ }
253
+
254
+ // -- 5. Materialize the credential ----------------------------------------
255
+ const matResult = await deps.materializeCredential(request.credentialHandle);
256
+ if (!matResult.ok) {
257
+ cleanupScratchDir(scratchDir);
258
+ return {
259
+ success: false,
260
+ error: `Credential materialization failed: ${matResult.error}`,
261
+ auditId,
262
+ };
263
+ }
264
+
265
+ // Update the stage config with the materialized secret for output scanning
266
+ const secretSet = new Set<string>([matResult.value]);
267
+ const stageConfigWithSecrets: WorkspaceStageConfig = {
268
+ ...stageConfig,
269
+ secrets: secretSet,
270
+ };
271
+
272
+ // -- 6. Build auth adapter environment ------------------------------------
273
+ let adapterEnv: Record<string, string>;
274
+ let tempFilePath: string | undefined;
275
+ try {
276
+ const adapterResult = await buildAuthAdapterEnv(
277
+ manifest.authAdapter,
278
+ matResult.value,
279
+ );
280
+ adapterEnv = adapterResult.env;
281
+ tempFilePath = adapterResult.tempFilePath;
282
+ } catch (err) {
283
+ cleanupScratchDir(scratchDir);
284
+ return {
285
+ success: false,
286
+ error: `Auth adapter materialization failed: ${err instanceof Error ? err.message : String(err)}`,
287
+ auditId,
288
+ };
289
+ }
290
+
291
+ // -- 7. Start egress proxy (if proxy_required) ----------------------------
292
+ let proxyEnv: ProxyEnvVars | undefined;
293
+ let proxySessionId: string | undefined;
294
+ const sessionStore = deps.egressSessionStore ?? new SessionStore();
295
+
296
+ if (manifest.egressMode === EgressMode.ProxyRequired) {
297
+ if (!deps.egressHooks) {
298
+ cleanupAll(scratchDir, tempFilePath);
299
+ return {
300
+ success: false,
301
+ error: "Egress mode is proxy_required but no egress hooks were provided. " +
302
+ "Cannot enforce network policy without an egress proxy.",
303
+ auditId,
304
+ };
305
+ }
306
+
307
+ try {
308
+ const conversationId = request.conversationId ?? `ces-cmd-${auditId}`;
309
+ const session = createSession(
310
+ sessionStore,
311
+ conversationId,
312
+ [request.credentialHandle],
313
+ );
314
+ const started = await startSession(
315
+ sessionStore,
316
+ session.id,
317
+ deps.egressHooks,
318
+ );
319
+ proxySessionId = started.id;
320
+ proxyEnv = getSessionEnv(sessionStore, started.id);
321
+ } catch (err) {
322
+ cleanupAll(scratchDir, tempFilePath);
323
+ return {
324
+ success: false,
325
+ error: `Egress proxy startup failed: ${err instanceof Error ? err.message : String(err)}`,
326
+ auditId,
327
+ };
328
+ }
329
+ }
330
+
331
+ // -- 8. Build the execution environment -----------------------------------
332
+ const entrypointPath = join(
333
+ getBundleContentPath(toolstoreDir, request.bundleDigest),
334
+ "..",
335
+ manifest.entrypoint,
336
+ );
337
+
338
+ const commandEnv = buildCommandEnv(
339
+ adapterEnv,
340
+ proxyEnv,
341
+ manifest.cleanConfigDirs,
342
+ );
343
+
344
+ // -- 9. Execute the command -----------------------------------------------
345
+ const maxOutput = deps.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
346
+ let execResult: ExecuteCommandResult;
347
+
348
+ try {
349
+ execResult = await runCommand(
350
+ entrypointPath,
351
+ request.argv,
352
+ scratchDir,
353
+ commandEnv,
354
+ maxOutput,
355
+ auditId,
356
+ );
357
+ } catch (err) {
358
+ execResult = {
359
+ success: false,
360
+ error: `Command execution failed: ${err instanceof Error ? err.message : String(err)}`,
361
+ auditId,
362
+ };
363
+ }
364
+
365
+ // -- 10. Output copyback --------------------------------------------------
366
+ if (
367
+ request.outputs &&
368
+ request.outputs.length > 0 &&
369
+ execResult.exitCode !== undefined
370
+ ) {
371
+ try {
372
+ const copybackResult = copybackOutputs(
373
+ stageConfigWithSecrets,
374
+ scratchDir,
375
+ );
376
+ execResult.copybackResult = copybackResult;
377
+
378
+ if (!copybackResult.allSucceeded) {
379
+ const failures = copybackResult.outputs
380
+ .filter((o) => !o.success)
381
+ .map((o) => `${o.scratchPath}: ${o.reason}`)
382
+ .join("; ");
383
+ execResult.error = execResult.error
384
+ ? `${execResult.error}; Output copyback failures: ${failures}`
385
+ : `Output copyback failures: ${failures}`;
386
+ }
387
+ } catch (err) {
388
+ execResult.error = execResult.error
389
+ ? `${execResult.error}; Output copyback error: ${err instanceof Error ? err.message : String(err)}`
390
+ : `Output copyback error: ${err instanceof Error ? err.message : String(err)}`;
391
+ }
392
+ }
393
+
394
+ // -- 11. Cleanup ----------------------------------------------------------
395
+ if (proxySessionId) {
396
+ try {
397
+ await stopSession(proxySessionId, sessionStore);
398
+ } catch {
399
+ // Best-effort proxy cleanup
400
+ }
401
+ }
402
+
403
+ cleanupAll(scratchDir, tempFilePath);
404
+
405
+ return execResult;
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Internal: Bundle resolution
410
+ // ---------------------------------------------------------------------------
411
+
412
+ type BundleResolutionResult =
413
+ | { ok: true; manifest: SecureCommandManifest; toolstoreDir: string }
414
+ | { ok: false; error: string };
415
+
416
+ function resolveBundle(
417
+ bundleDigest: string,
418
+ cesMode?: CesMode,
419
+ ): BundleResolutionResult {
420
+ if (!isBundlePublished(bundleDigest, cesMode)) {
421
+ return {
422
+ ok: false,
423
+ error: `Bundle with digest "${bundleDigest}" is not published in the CES toolstore. ` +
424
+ `Only approved bundles can be executed.`,
425
+ };
426
+ }
427
+
428
+ const toolstoreManifest = readPublishedManifest(bundleDigest, cesMode);
429
+ if (!toolstoreManifest) {
430
+ return {
431
+ ok: false,
432
+ error: `Bundle manifest for digest "${bundleDigest}" could not be read from the toolstore.`,
433
+ };
434
+ }
435
+
436
+ const manifest = toolstoreManifest.secureCommandManifest;
437
+
438
+ // Defense-in-depth: re-check denied binary at execution time
439
+ if (isDeniedBinary(manifest.entrypoint)) {
440
+ return {
441
+ ok: false,
442
+ error: `Entrypoint "${manifest.entrypoint}" is a structurally denied binary. ` +
443
+ `Generic HTTP clients, interpreters, and shell trampolines cannot be executed.`,
444
+ };
445
+ }
446
+
447
+ if (isDeniedBinary(manifest.bundleId)) {
448
+ return {
449
+ ok: false,
450
+ error: `Bundle ID "${manifest.bundleId}" matches a structurally denied binary name.`,
451
+ };
452
+ }
453
+
454
+ const toolstoreDir = getCesToolStoreDir(cesMode);
455
+
456
+ return {
457
+ ok: true,
458
+ manifest,
459
+ toolstoreDir,
460
+ };
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Internal: Profile validation
465
+ // ---------------------------------------------------------------------------
466
+
467
+ interface ProfileValidationResult {
468
+ ok: boolean;
469
+ profile?: CommandProfile;
470
+ matchedPattern?: string;
471
+ error?: string;
472
+ }
473
+
474
+ function validateProfile(
475
+ manifest: SecureCommandManifest,
476
+ profileName: string,
477
+ argv: string[],
478
+ ): ProfileValidationResult {
479
+ const profile = manifest.commandProfiles[profileName];
480
+ if (!profile) {
481
+ const available = Object.keys(manifest.commandProfiles).join(", ");
482
+ return {
483
+ ok: false,
484
+ error: `Profile "${profileName}" not found in manifest for bundle "${manifest.bundleId}". ` +
485
+ `Available profiles: ${available}`,
486
+ };
487
+ }
488
+
489
+ // Validate the argv against the full manifest (checks denied subcommands/flags
490
+ // across all profiles, then matches against allowed patterns)
491
+ const cmdResult: CommandValidationResult = validateCommand(manifest, argv);
492
+ if (!cmdResult.allowed) {
493
+ return {
494
+ ok: false,
495
+ error: `Command validation failed: ${cmdResult.reason}`,
496
+ };
497
+ }
498
+
499
+ // Ensure the matched profile is the requested one
500
+ if (cmdResult.matchedProfile !== profileName) {
501
+ return {
502
+ ok: false,
503
+ error: `Command argv matched profile "${cmdResult.matchedProfile}" but the requested ` +
504
+ `profile is "${profileName}". The command does not match any pattern in the requested profile.`,
505
+ };
506
+ }
507
+
508
+ return {
509
+ ok: true,
510
+ profile,
511
+ matchedPattern: cmdResult.matchedPattern,
512
+ };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Internal: Grant enforcement
517
+ // ---------------------------------------------------------------------------
518
+
519
+ interface GrantCheckResult {
520
+ ok: boolean;
521
+ grantId?: string;
522
+ error?: string;
523
+ }
524
+
525
+ function checkGrant(
526
+ request: ExecuteCommandRequest,
527
+ manifest: SecureCommandManifest,
528
+ profileName: string,
529
+ persistentStore: PersistentGrantStore,
530
+ temporaryStore: TemporaryGrantStore,
531
+ ): GrantCheckResult {
532
+ // If an explicit grantId is provided, check it directly
533
+ if (request.grantId) {
534
+ const grant = persistentStore.getById(request.grantId);
535
+ if (grant) {
536
+ return { ok: true, grantId: grant.id };
537
+ }
538
+ // Explicit grant not found — fall through to pattern matching
539
+ }
540
+
541
+ // Check persistent grants for a matching command grant
542
+ const allGrants = persistentStore.getAll();
543
+ for (const grant of allGrants) {
544
+ if (
545
+ grant.tool === "command" &&
546
+ grant.scope === request.credentialHandle &&
547
+ grantMatchesCommand(grant.pattern, manifest.bundleId, profileName)
548
+ ) {
549
+ return { ok: true, grantId: grant.id };
550
+ }
551
+ }
552
+
553
+ // Check temporary grants
554
+ const proposalHash = computeCommandProposalHash(
555
+ request.credentialHandle,
556
+ manifest.bundleId,
557
+ profileName,
558
+ );
559
+ const tempKind = temporaryStore.checkAny(
560
+ proposalHash,
561
+ request.conversationId,
562
+ );
563
+ if (tempKind) {
564
+ return { ok: true, grantId: `temp:${tempKind}:${proposalHash}` };
565
+ }
566
+
567
+ return {
568
+ ok: false,
569
+ error: `No active grant found for bundle="${manifest.bundleId}", ` +
570
+ `profile="${profileName}", credential="${request.credentialHandle}". ` +
571
+ `Approval is required before command execution.`,
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Check if a persistent grant pattern matches a command invocation.
577
+ *
578
+ * Grant patterns for commands use the format: `<bundleId>/<profileName>`.
579
+ */
580
+ function grantMatchesCommand(
581
+ pattern: string,
582
+ bundleId: string,
583
+ profileName: string,
584
+ ): boolean {
585
+ return pattern === `${bundleId}/${profileName}`;
586
+ }
587
+
588
+ /**
589
+ * Compute a deterministic hash for temporary grant lookup.
590
+ */
591
+ function computeCommandProposalHash(
592
+ credentialHandle: string,
593
+ bundleId: string,
594
+ profileName: string,
595
+ ): string {
596
+ const parts = ["command", credentialHandle, bundleId, profileName];
597
+ const canonical = JSON.stringify(parts);
598
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Internal: Auth adapter environment construction
603
+ // ---------------------------------------------------------------------------
604
+
605
+ interface AuthAdapterEnvResult {
606
+ /** Environment variables to inject into the command. */
607
+ env: Record<string, string>;
608
+ /** Path to a temp file that must be cleaned up (for temp_file adapter). */
609
+ tempFilePath?: string;
610
+ }
611
+
612
+ async function buildAuthAdapterEnv(
613
+ adapter: AuthAdapterConfig,
614
+ credentialValue: string,
615
+ ): Promise<AuthAdapterEnvResult> {
616
+ // Validate adapter config
617
+ const errors = validateAuthAdapterConfig(adapter);
618
+ if (errors.length > 0) {
619
+ throw new Error(
620
+ `Invalid auth adapter config: ${errors.join("; ")}`,
621
+ );
622
+ }
623
+
624
+ switch (adapter.type) {
625
+ case AuthAdapterType.EnvVar: {
626
+ const value = adapter.valuePrefix
627
+ ? `${adapter.valuePrefix}${credentialValue}`
628
+ : credentialValue;
629
+ return {
630
+ env: { [adapter.envVarName]: value },
631
+ };
632
+ }
633
+
634
+ case AuthAdapterType.TempFile: {
635
+ // Write credential to a temp file and set the env var to the path
636
+ const tempDir = join(tmpdir(), `ces-auth-${randomUUID()}`);
637
+ mkdirSync(tempDir, { recursive: true });
638
+ const ext = adapter.fileExtension ?? "";
639
+ const tempPath = join(tempDir, `credential${ext}`);
640
+ const mode = adapter.fileMode ?? 0o600;
641
+ writeFileSync(tempPath, credentialValue, { mode });
642
+ return {
643
+ env: { [adapter.envVarName]: tempPath },
644
+ tempFilePath: tempPath,
645
+ };
646
+ }
647
+
648
+ case AuthAdapterType.CredentialProcess: {
649
+ // Run the helper command and capture its stdout
650
+ const timeoutMs = adapter.timeoutMs ?? CREDENTIAL_PROCESS_TIMEOUT_MS;
651
+ const helperResult = await runCredentialProcess(
652
+ adapter.helperCommand,
653
+ credentialValue,
654
+ timeoutMs,
655
+ );
656
+ if (!helperResult.ok) {
657
+ throw new Error(
658
+ `Credential process helper failed: ${helperResult.error}`,
659
+ );
660
+ }
661
+ return {
662
+ env: { [adapter.envVarName]: helperResult.stdout },
663
+ };
664
+ }
665
+
666
+ default:
667
+ throw new Error(`Unknown auth adapter type: ${(adapter as AuthAdapterConfig).type}`);
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Run a credential_process helper command inside CES.
673
+ *
674
+ * The helper receives the raw credential value on stdin and writes
675
+ * the transformed credential to stdout. It is never exposed to the
676
+ * subprocess directly.
677
+ */
678
+ async function runCredentialProcess(
679
+ helperCommand: string,
680
+ _credentialValue: string,
681
+ timeoutMs: number,
682
+ ): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
683
+ try {
684
+ const proc = Bun.spawn(["sh", "-c", helperCommand], {
685
+ stdin: "pipe",
686
+ stdout: "pipe",
687
+ stderr: "pipe",
688
+ env: {}, // Clean environment — no secrets leaked
689
+ });
690
+
691
+ // Close stdin immediately — the helper reads only from its argv/config
692
+ proc.stdin.end();
693
+
694
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
695
+ const exitCode = await Promise.race([
696
+ proc.exited,
697
+ new Promise<never>((_, reject) => {
698
+ timeoutSignal.addEventListener("abort", () => {
699
+ proc.kill();
700
+ reject(new Error(`Credential process timed out after ${timeoutMs}ms`));
701
+ });
702
+ }),
703
+ ]);
704
+
705
+ const stdout = await new Response(proc.stdout).text();
706
+ const stderr = await new Response(proc.stderr).text();
707
+
708
+ if (exitCode !== 0) {
709
+ return {
710
+ ok: false,
711
+ error: `Helper exited with code ${exitCode}: ${stderr.trim()}`,
712
+ };
713
+ }
714
+
715
+ return { ok: true, stdout: stdout.trim() };
716
+ } catch (err) {
717
+ return {
718
+ ok: false,
719
+ error: err instanceof Error ? err.message : String(err),
720
+ };
721
+ }
722
+ }
723
+
724
+ // ---------------------------------------------------------------------------
725
+ // Internal: Command environment construction
726
+ // ---------------------------------------------------------------------------
727
+
728
+ /**
729
+ * Build the clean execution environment for the command.
730
+ *
731
+ * The environment contains:
732
+ * - Auth adapter env vars (credential injection)
733
+ * - Proxy env vars (when egress proxy is active)
734
+ * - HOME set to a temp directory (isolates config reads)
735
+ * - PATH preserved from the CES process
736
+ *
737
+ * The environment explicitly does NOT inherit the CES process env.
738
+ * Clean config dirs are handled by setting HOME to a temp directory.
739
+ */
740
+ function buildCommandEnv(
741
+ adapterEnv: Record<string, string>,
742
+ proxyEnv?: ProxyEnvVars,
743
+ _cleanConfigDirs?: Record<string, string>,
744
+ ): Record<string, string> {
745
+ const env: Record<string, string> = {
746
+ // Minimal baseline environment
747
+ PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
748
+ HOME: join(tmpdir(), `ces-home-${randomUUID()}`),
749
+ LANG: "en_US.UTF-8",
750
+ // Inject auth adapter env vars
751
+ ...adapterEnv,
752
+ };
753
+
754
+ // Inject proxy env vars if the egress proxy is active
755
+ if (proxyEnv) {
756
+ env["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
757
+ env["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
758
+ env["NO_PROXY"] = proxyEnv.NO_PROXY;
759
+ env["http_proxy"] = proxyEnv.HTTP_PROXY;
760
+ env["https_proxy"] = proxyEnv.HTTPS_PROXY;
761
+ env["no_proxy"] = proxyEnv.NO_PROXY;
762
+ if (proxyEnv.NODE_EXTRA_CA_CERTS) {
763
+ env["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
764
+ }
765
+ if (proxyEnv.SSL_CERT_FILE) {
766
+ env["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
767
+ }
768
+ }
769
+
770
+ return env;
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // Internal: Command execution
775
+ // ---------------------------------------------------------------------------
776
+
777
+ async function runCommand(
778
+ entrypointPath: string,
779
+ argv: string[],
780
+ scratchDir: string,
781
+ env: Record<string, string>,
782
+ maxOutputBytes: number,
783
+ auditId: string,
784
+ ): Promise<ExecuteCommandResult> {
785
+ // Ensure the HOME directory exists (for clean config dirs isolation)
786
+ if (env["HOME"]) {
787
+ mkdirSync(env["HOME"], { recursive: true });
788
+ }
789
+
790
+ const proc = Bun.spawn([entrypointPath, ...argv], {
791
+ cwd: scratchDir,
792
+ env,
793
+ stdin: "ignore",
794
+ stdout: "pipe",
795
+ stderr: "pipe",
796
+ });
797
+
798
+ const exitCode = await proc.exited;
799
+
800
+ // Capture stdout and stderr with size limits
801
+ const stdoutRaw = await new Response(proc.stdout).text();
802
+ const stderrRaw = await new Response(proc.stderr).text();
803
+
804
+ const stdout = stdoutRaw.length > maxOutputBytes
805
+ ? stdoutRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
806
+ : stdoutRaw;
807
+
808
+ const stderr = stderrRaw.length > maxOutputBytes
809
+ ? stderrRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
810
+ : stderrRaw;
811
+
812
+ return {
813
+ success: exitCode === 0,
814
+ exitCode,
815
+ stdout,
816
+ stderr,
817
+ auditId,
818
+ ...(exitCode !== 0 ? { error: `Command exited with code ${exitCode}` } : {}),
819
+ };
820
+ }
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // Internal: Cleanup helpers
824
+ // ---------------------------------------------------------------------------
825
+
826
+ function cleanupAll(scratchDir: string, tempFilePath?: string): void {
827
+ // Clean up temp auth file
828
+ if (tempFilePath) {
829
+ try {
830
+ unlinkSync(tempFilePath);
831
+ // Also remove the parent temp directory
832
+ rmSync(dirname(tempFilePath), { recursive: true, force: true });
833
+ } catch {
834
+ // Best-effort cleanup
835
+ }
836
+ }
837
+
838
+ // Clean up scratch directory
839
+ cleanupScratchDir(scratchDir);
840
+ }