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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -1,1155 +0,0 @@
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. **Credential materialization** — Materialize the raw credential
20
- * value from the credential store.
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. This happens BEFORE the auth adapter runs so that
25
- * credential_process helpers also execute under egress control.
26
- *
27
- * 7. **Auth adapter construction** — Build the credential environment
28
- * through the declared auth adapter (env_var, temp_file, or
29
- * credential_process). For credential_process, the helper runs
30
- * with proxy env vars injected.
31
- *
32
- * 8. **Command execution** — Run the command with clean config dirs,
33
- * materialized credential env vars, and proxy env vars. The command
34
- * runs in the scratch directory, never in the assistant workspace.
35
- *
36
- * 9. **Output copyback** — After exit, validate and copy declared output
37
- * files from the scratch directory back into the workspace.
38
- *
39
- * 10. **Cleanup** — Stop the egress proxy session, remove temp files, and
40
- * clean up the scratch directory.
41
- *
42
- * The executor is fail-closed: bundle mismatches, missing grants,
43
- * adapter failures, egress failures, undeclared outputs, and scan
44
- * violations all result in command rejection before or after execution.
45
- */
46
-
47
- import { randomUUID } from "node:crypto";
48
- import { dirname, join, resolve } from "node:path";
49
- import { mkdirSync, writeFileSync, unlinkSync, rmSync, realpathSync } from "node:fs";
50
- import { tmpdir } from "node:os";
51
- import {
52
- SessionStore,
53
- createSession,
54
- startSession,
55
- stopSession,
56
- getSessionEnv,
57
- type SessionStartHooks,
58
- type ProxyEnvVars,
59
- } from "@vellumai/egress-proxy";
60
-
61
- import { readPublishedManifest, getBundleContentPath, isBundlePublished } from "../toolstore/publish.js";
62
- import { getCesToolStoreDir, type CesMode } from "../paths.js";
63
- import type { SecureCommandManifest, CommandProfile } from "./profiles.js";
64
- import { isDeniedBinary, EgressMode } from "./profiles.js";
65
- import { validateCommand, extractShellBinary, containsShellMetacharacters, type CommandValidationResult } from "./validator.js";
66
- import type { AuthAdapterConfig } from "./auth-adapters.js";
67
- import { AuthAdapterType, validateAuthAdapterConfig } from "./auth-adapters.js";
68
- import {
69
- stageInputs,
70
- copybackOutputs,
71
- cleanupScratchDir,
72
- type WorkspaceStageConfig,
73
- type WorkspaceInput,
74
- type WorkspaceOutput,
75
- type CopybackResult,
76
- } from "./workspace.js";
77
- import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/service-contracts/credential-rpc";
78
-
79
- import type { AuditStore } from "../audit/store.js";
80
- import type { PersistentGrantStore } from "../grants/persistent-store.js";
81
- import type { TemporaryGrantStore } from "../grants/temporary-store.js";
82
-
83
- // ---------------------------------------------------------------------------
84
- // Types
85
- // ---------------------------------------------------------------------------
86
-
87
- /**
88
- * Request to execute an authenticated command through the CES pipeline.
89
- */
90
- export interface ExecuteCommandRequest {
91
- /** SHA-256 hex digest of the approved bundle. */
92
- bundleDigest: string;
93
- /** Name of the command profile to use within the manifest. */
94
- profileName: string;
95
- /** CES credential handle identifying which credential to inject. */
96
- credentialHandle: string;
97
- /** Argv tokens (command arguments, not including the binary path). */
98
- argv: string[];
99
- /** Absolute path to the assistant-visible workspace directory. */
100
- workspaceDir: string;
101
- /** Files to stage as read-only inputs in the scratch directory. */
102
- inputs?: WorkspaceInput[];
103
- /** Files to copy back from the scratch directory after execution. */
104
- outputs?: WorkspaceOutput[];
105
- /** Human-readable purpose for audit logging. */
106
- purpose: string;
107
- /** Explicit grant ID to consume, if the caller holds one. */
108
- grantId?: string;
109
- /** Conversation ID for conversation-scoped temporary grants. */
110
- conversationId?: string;
111
- }
112
-
113
- /**
114
- * Result of a command execution attempt.
115
- */
116
- export interface ExecuteCommandResult {
117
- /** Whether the command executed successfully. */
118
- success: boolean;
119
- /** Process exit code (undefined if the command was never launched). */
120
- exitCode?: number;
121
- /** Combined stdout output (truncated for safety). */
122
- stdout?: string;
123
- /** Combined stderr output (truncated for safety). */
124
- stderr?: string;
125
- /** Copyback results for declared outputs. */
126
- copybackResult?: CopybackResult;
127
- /** Error message if execution failed. */
128
- error?: string;
129
- /** Audit-relevant metadata. */
130
- auditId?: string;
131
- /**
132
- * When the failure reason is a missing grant, this field contains the
133
- * proposal metadata needed by the approval bridge. Present only when
134
- * the error is an approval-required grant failure.
135
- */
136
- approvalRequired?: {
137
- credentialHandle: string;
138
- bundleId: string;
139
- bundleDigest: string;
140
- profileName: string;
141
- command: string;
142
- purpose: string;
143
- };
144
- }
145
-
146
- /**
147
- * Credential materializer abstraction.
148
- *
149
- * The executor does not import materializer implementations directly.
150
- * Callers provide a materializer function that resolves a credential
151
- * handle into a raw secret value.
152
- */
153
- export type MaterializeCredentialFn = (
154
- credentialHandle: string,
155
- ) => Promise<MaterializeCredentialResult>;
156
-
157
- export type MaterializeCredentialResult =
158
- | { ok: true; value: string; handleType: string }
159
- | { ok: false; error: string };
160
-
161
- /**
162
- * Dependencies injected into the command executor.
163
- */
164
- export interface CommandExecutorDeps {
165
- /** Persistent grant store for checking bundle/profile approvals. */
166
- persistentStore: PersistentGrantStore;
167
- /** Temporary grant store for session-scoped approvals. */
168
- temporaryStore: TemporaryGrantStore;
169
- /** Credential materializer function. */
170
- materializeCredential: MaterializeCredentialFn;
171
- /** Audit store for persisting token-free audit records. */
172
- auditStore?: AuditStore;
173
- /** Session ID for audit records, taken from the calling connection's SessionContext at dispatch time. */
174
- sessionId?: string;
175
- /** CES operating mode (for toolstore path resolution). */
176
- cesMode?: CesMode;
177
- /** Egress proxy session start hooks (for creating the proxy server). */
178
- egressHooks?: SessionStartHooks;
179
- /** Egress proxy session store (shared or isolated). */
180
- egressSessionStore?: SessionStore;
181
- /** Maximum stdout/stderr capture size in bytes. */
182
- maxOutputBytes?: number;
183
- }
184
-
185
- // ---------------------------------------------------------------------------
186
- // Constants
187
- // ---------------------------------------------------------------------------
188
-
189
- /** Maximum stdout/stderr capture (256 KB). */
190
- const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
191
-
192
- /** Credential process helper timeout. */
193
- const CREDENTIAL_PROCESS_TIMEOUT_MS = 10_000;
194
-
195
- /**
196
- * Banned binary names — checked at execution time as a defense-in-depth
197
- * supplement to the manifest validator's static check.
198
- */
199
-
200
- // ---------------------------------------------------------------------------
201
- // Executor implementation
202
- // ---------------------------------------------------------------------------
203
-
204
- /**
205
- * Execute an authenticated command through the full CES pipeline.
206
- *
207
- * This is the top-level orchestrator. Each step is fail-closed: if any
208
- * phase returns an error, the command is rejected and cleanup runs.
209
- */
210
- export async function executeAuthenticatedCommand(
211
- request: ExecuteCommandRequest,
212
- deps: CommandExecutorDeps,
213
- ): Promise<ExecuteCommandResult> {
214
- const auditId = randomUUID();
215
-
216
- // -- 1. Resolve and validate the bundle -----------------------------------
217
- const bundleResult = resolveBundle(request.bundleDigest, deps.cesMode);
218
- if (!bundleResult.ok) {
219
- return {
220
- success: false,
221
- error: bundleResult.error,
222
- auditId,
223
- };
224
- }
225
-
226
- const { manifest, toolstoreDir } = bundleResult;
227
-
228
- // -- 2. Validate the command profile and argv -----------------------------
229
- const profileResult = validateProfile(
230
- manifest,
231
- request.profileName,
232
- request.argv,
233
- );
234
- if (!profileResult.ok) {
235
- return {
236
- success: false,
237
- error: profileResult.error,
238
- auditId,
239
- };
240
- }
241
-
242
- // -- 3. Check grant enforcement -------------------------------------------
243
- const grantResult = checkGrant(
244
- request,
245
- manifest,
246
- request.profileName,
247
- deps.persistentStore,
248
- deps.temporaryStore,
249
- );
250
- if (!grantResult.ok) {
251
- return {
252
- success: false,
253
- error: grantResult.error,
254
- auditId,
255
- approvalRequired: {
256
- credentialHandle: request.credentialHandle,
257
- bundleId: manifest.bundleId,
258
- bundleDigest: request.bundleDigest,
259
- profileName: request.profileName,
260
- command: `${request.bundleDigest}/${request.profileName} ${request.argv.join(" ")}`.trim(),
261
- purpose: request.purpose,
262
- },
263
- };
264
- }
265
-
266
- // -- 4. Stage workspace inputs --------------------------------------------
267
- const stageConfig: WorkspaceStageConfig = {
268
- workspaceDir: request.workspaceDir,
269
- inputs: request.inputs ?? [],
270
- outputs: request.outputs ?? [],
271
- secrets: new Set<string>(), // Populated after materialization
272
- };
273
-
274
- let scratchDir: string;
275
- try {
276
- const staged = stageInputs(stageConfig, deps.cesMode);
277
- scratchDir = staged.scratchDir;
278
- } catch (err) {
279
- return {
280
- success: false,
281
- error: `Input staging failed: ${err instanceof Error ? err.message : String(err)}`,
282
- auditId,
283
- };
284
- }
285
-
286
- // -- 5. Materialize the credential ----------------------------------------
287
- const matResult = await deps.materializeCredential(request.credentialHandle);
288
- if (!matResult.ok) {
289
- cleanupScratchDir(scratchDir);
290
- return {
291
- success: false,
292
- error: `Credential materialization failed: ${matResult.error}`,
293
- auditId,
294
- };
295
- }
296
-
297
- // Update the stage config with the materialized secret for output scanning
298
- const secretSet = new Set<string>([matResult.value]);
299
- const stageConfigWithSecrets: WorkspaceStageConfig = {
300
- ...stageConfig,
301
- secrets: secretSet,
302
- };
303
-
304
- // -- 6. Start egress proxy (if proxy_required) ----------------------------
305
- // The egress proxy must be started BEFORE the auth adapter runs, so that
306
- // credential_process helpers execute under egress control (not in an
307
- // uncontrolled network state).
308
- let proxyEnv: ProxyEnvVars | undefined;
309
- let proxySessionId: string | undefined;
310
- const sessionStore = deps.egressSessionStore ?? new SessionStore();
311
-
312
- if (manifest.egressMode === EgressMode.ProxyRequired) {
313
- if (!deps.egressHooks) {
314
- cleanupScratchDir(scratchDir);
315
- return {
316
- success: false,
317
- error: "Egress mode is proxy_required but no egress hooks were provided. " +
318
- "Cannot enforce network policy without an egress proxy.",
319
- auditId,
320
- };
321
- }
322
-
323
- try {
324
- const conversationId = request.conversationId ?? `ces-cmd-${auditId}`;
325
- // Carry the profile's allowedNetworkTargets into the session config
326
- // so the egress proxy can enforce the allowlist.
327
- const profile = manifest.commandProfiles[request.profileName];
328
- const allowedTargets = profile?.allowedNetworkTargets?.map((t) => ({
329
- host: t.hostPattern,
330
- ...(t.ports ? { ports: t.ports } : {}),
331
- ...(t.protocols ? { protocols: t.protocols } : {}),
332
- }));
333
- const session = createSession(
334
- sessionStore,
335
- conversationId,
336
- [request.credentialHandle],
337
- { allowedTargets },
338
- );
339
- const started = await startSession(
340
- sessionStore,
341
- session.id,
342
- deps.egressHooks,
343
- );
344
- proxySessionId = started.id;
345
- proxyEnv = getSessionEnv(sessionStore, started.id);
346
- } catch (err) {
347
- cleanupScratchDir(scratchDir);
348
- return {
349
- success: false,
350
- error: `Egress proxy startup failed: ${err instanceof Error ? err.message : String(err)}`,
351
- auditId,
352
- };
353
- }
354
- }
355
-
356
- // For no_network mode, block all outbound by pointing proxy vars at a
357
- // non-existent address. This prevents subprocesses from making direct
358
- // connections even without a running egress proxy.
359
- let noNetworkEnv: Record<string, string> | undefined;
360
- if (manifest.egressMode === EgressMode.NoNetwork) {
361
- const blockedProxy = "http://127.0.0.1:0";
362
- noNetworkEnv = {
363
- HTTP_PROXY: blockedProxy,
364
- HTTPS_PROXY: blockedProxy,
365
- http_proxy: blockedProxy,
366
- https_proxy: blockedProxy,
367
- NO_PROXY: "",
368
- no_proxy: "",
369
- };
370
- }
371
-
372
- // -- 7. Build auth adapter environment ------------------------------------
373
- // Pass proxy/no-network env vars so credential_process helpers also run
374
- // under egress control.
375
- let adapterEnv: Record<string, string>;
376
- let tempFilePath: string | undefined;
377
- try {
378
- const adapterResult = await buildAuthAdapterEnv(
379
- manifest.authAdapter,
380
- matResult.value,
381
- proxyEnv,
382
- noNetworkEnv,
383
- );
384
- adapterEnv = adapterResult.env;
385
- tempFilePath = adapterResult.tempFilePath;
386
- } catch (err) {
387
- // Stop the proxy session before returning — it may already be running
388
- if (proxySessionId) {
389
- try {
390
- await stopSession(proxySessionId, sessionStore);
391
- } catch {
392
- // Best-effort proxy cleanup
393
- }
394
- }
395
- cleanupScratchDir(scratchDir);
396
- return {
397
- success: false,
398
- error: `Auth adapter materialization failed: ${err instanceof Error ? err.message : String(err)}`,
399
- auditId,
400
- };
401
- }
402
-
403
- // -- 8. Build the execution environment -----------------------------------
404
- const bundleDir = dirname(getBundleContentPath(toolstoreDir, request.bundleDigest));
405
- const entrypointPath = resolve(bundleDir, manifest.entrypoint);
406
-
407
- // Containment check: entrypoint must resolve inside the bundle directory
408
- // (lexical check for path traversal via ../)
409
- if (!entrypointPath.startsWith(bundleDir + "/") && entrypointPath !== bundleDir) {
410
- // Stop the proxy session before returning — it may already be running
411
- if (proxySessionId) {
412
- try {
413
- await stopSession(proxySessionId, sessionStore);
414
- } catch {
415
- // Best-effort proxy cleanup
416
- }
417
- }
418
- cleanupAll(scratchDir, tempFilePath);
419
- return {
420
- success: false,
421
- error: `Entrypoint "${manifest.entrypoint}" resolves outside the bundle directory. ` +
422
- `Path traversal is not allowed.`,
423
- auditId,
424
- };
425
- }
426
-
427
- // Symlink escape check: follow symlinks and verify the real path is
428
- // still inside the bundle directory. A symlink entrypoint like
429
- // `bin/tool -> /usr/bin/curl` passes the lexical check above but
430
- // executes outside the bundle boundary.
431
- let realEntrypointPath: string;
432
- try {
433
- realEntrypointPath = realpathSync(entrypointPath);
434
- } catch {
435
- // realpathSync fails if the file doesn't exist or is a broken symlink
436
- if (proxySessionId) {
437
- try {
438
- await stopSession(proxySessionId, sessionStore);
439
- } catch {
440
- // Best-effort proxy cleanup
441
- }
442
- }
443
- cleanupAll(scratchDir, tempFilePath);
444
- return {
445
- success: false,
446
- error: `Entrypoint "${manifest.entrypoint}" could not be resolved (broken symlink or missing file).`,
447
- auditId,
448
- };
449
- }
450
- const realBundleDir = realpathSync(bundleDir);
451
- if (!realEntrypointPath.startsWith(realBundleDir + "/") && realEntrypointPath !== realBundleDir) {
452
- if (proxySessionId) {
453
- try {
454
- await stopSession(proxySessionId, sessionStore);
455
- } catch {
456
- // Best-effort proxy cleanup
457
- }
458
- }
459
- cleanupAll(scratchDir, tempFilePath);
460
- return {
461
- success: false,
462
- error: `Entrypoint "${manifest.entrypoint}" is a symlink that resolves to "${realEntrypointPath}", ` +
463
- `which is outside the bundle directory. Symlink escape is not allowed.`,
464
- auditId,
465
- };
466
- }
467
-
468
- // Generate HOME path before buildCommandEnv so we have a known-safe value
469
- // for cleanup. buildCommandEnv sets HOME after spreading adapterEnv to
470
- // prevent auth adapters from overriding the isolated home directory.
471
- const generatedHomeDir = join(tmpdir(), `ces-home-${randomUUID()}`);
472
-
473
- // Create the HOME directory and enforce cleanConfigDirs before building env
474
- try {
475
- mkdirSync(generatedHomeDir, { recursive: true });
476
- enforceCleanConfigDirs(manifest, generatedHomeDir);
477
- } catch (err) {
478
- if (proxySessionId) {
479
- try {
480
- await stopSession(proxySessionId, sessionStore);
481
- } catch {
482
- // Best-effort proxy cleanup
483
- }
484
- }
485
- cleanupAll(scratchDir, tempFilePath, generatedHomeDir);
486
- return {
487
- success: false,
488
- error: `Clean config dirs setup failed: ${err instanceof Error ? err.message : String(err)}`,
489
- auditId,
490
- };
491
- }
492
-
493
- const commandEnv = buildCommandEnv(
494
- adapterEnv,
495
- proxyEnv,
496
- noNetworkEnv,
497
- generatedHomeDir,
498
- );
499
-
500
- // -- 9. Execute the command -----------------------------------------------
501
- const maxOutput = deps.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
502
- let execResult: ExecuteCommandResult;
503
-
504
- try {
505
- execResult = await runCommand(
506
- entrypointPath,
507
- request.argv,
508
- scratchDir,
509
- commandEnv,
510
- maxOutput,
511
- auditId,
512
- );
513
- } catch (err) {
514
- execResult = {
515
- success: false,
516
- error: `Command execution failed: ${err instanceof Error ? err.message : String(err)}`,
517
- auditId,
518
- };
519
- }
520
-
521
- // -- 10. Output copyback --------------------------------------------------
522
- if (
523
- request.outputs &&
524
- request.outputs.length > 0 &&
525
- execResult.exitCode !== undefined
526
- ) {
527
- try {
528
- const copybackResult = copybackOutputs(
529
- stageConfigWithSecrets,
530
- scratchDir,
531
- );
532
- execResult.copybackResult = copybackResult;
533
-
534
- if (!copybackResult.allSucceeded) {
535
- const failures = copybackResult.outputs
536
- .filter((o) => !o.success)
537
- .map((o) => `${o.scratchPath}: ${o.reason}`)
538
- .join("; ");
539
- execResult.error = execResult.error
540
- ? `${execResult.error}; Output copyback failures: ${failures}`
541
- : `Output copyback failures: ${failures}`;
542
- }
543
- } catch (err) {
544
- execResult.error = execResult.error
545
- ? `${execResult.error}; Output copyback error: ${err instanceof Error ? err.message : String(err)}`
546
- : `Output copyback error: ${err instanceof Error ? err.message : String(err)}`;
547
- }
548
- }
549
-
550
- // -- 11. Cleanup ----------------------------------------------------------
551
- if (proxySessionId) {
552
- try {
553
- await stopSession(proxySessionId, sessionStore);
554
- } catch {
555
- // Best-effort proxy cleanup
556
- }
557
- }
558
-
559
- cleanupAll(scratchDir, tempFilePath, generatedHomeDir);
560
-
561
- // -- 12. Persist audit record -----------------------------------------------
562
- if (deps.auditStore) {
563
- const auditRecord: AuditRecordSummary = {
564
- auditId,
565
- grantId: grantResult.grantId ?? "unknown",
566
- credentialHandle: request.credentialHandle,
567
- toolName: "command",
568
- target: `${request.bundleDigest}/${request.profileName}`,
569
- sessionId: deps.sessionId ?? "unknown",
570
- success: execResult.success,
571
- ...(execResult.error ? { errorMessage: execResult.error } : {}),
572
- timestamp: new Date().toISOString(),
573
- };
574
- try { deps.auditStore.append(auditRecord); } catch { /* audit persistence must not block execution */ }
575
- }
576
-
577
- return execResult;
578
- }
579
-
580
- // ---------------------------------------------------------------------------
581
- // Internal: Bundle resolution
582
- // ---------------------------------------------------------------------------
583
-
584
- type BundleResolutionResult =
585
- | { ok: true; manifest: SecureCommandManifest; toolstoreDir: string }
586
- | { ok: false; error: string };
587
-
588
- function resolveBundle(
589
- bundleDigest: string,
590
- cesMode?: CesMode,
591
- ): BundleResolutionResult {
592
- if (!isBundlePublished(bundleDigest, cesMode)) {
593
- return {
594
- ok: false,
595
- error: `Bundle with digest "${bundleDigest}" is not published in the CES toolstore. ` +
596
- `Only approved bundles can be executed.`,
597
- };
598
- }
599
-
600
- const toolstoreManifest = readPublishedManifest(bundleDigest, cesMode);
601
- if (!toolstoreManifest) {
602
- return {
603
- ok: false,
604
- error: `Bundle manifest for digest "${bundleDigest}" could not be read from the toolstore.`,
605
- };
606
- }
607
-
608
- const manifest = toolstoreManifest.secureCommandManifest;
609
-
610
- // Defense-in-depth: re-check denied binary at execution time
611
- if (isDeniedBinary(manifest.entrypoint)) {
612
- return {
613
- ok: false,
614
- error: `Entrypoint "${manifest.entrypoint}" is a structurally denied binary. ` +
615
- `Generic HTTP clients, interpreters, and shell trampolines cannot be executed.`,
616
- };
617
- }
618
-
619
- if (isDeniedBinary(manifest.bundleId)) {
620
- return {
621
- ok: false,
622
- error: `Bundle ID "${manifest.bundleId}" matches a structurally denied binary name.`,
623
- };
624
- }
625
-
626
- const toolstoreDir = getCesToolStoreDir(cesMode);
627
-
628
- return {
629
- ok: true,
630
- manifest,
631
- toolstoreDir,
632
- };
633
- }
634
-
635
- // ---------------------------------------------------------------------------
636
- // Internal: Profile validation
637
- // ---------------------------------------------------------------------------
638
-
639
- interface ProfileValidationResult {
640
- ok: boolean;
641
- profile?: CommandProfile;
642
- matchedPattern?: string;
643
- error?: string;
644
- }
645
-
646
- function validateProfile(
647
- manifest: SecureCommandManifest,
648
- profileName: string,
649
- argv: string[],
650
- ): ProfileValidationResult {
651
- const profile = manifest.commandProfiles[profileName];
652
- if (!profile) {
653
- const available = Object.keys(manifest.commandProfiles).join(", ");
654
- return {
655
- ok: false,
656
- error: `Profile "${profileName}" not found in manifest for bundle "${manifest.bundleId}". ` +
657
- `Available profiles: ${available}`,
658
- };
659
- }
660
-
661
- // Validate the argv against the full manifest (checks denied subcommands/flags
662
- // across all profiles, then matches against allowed patterns)
663
- const cmdResult: CommandValidationResult = validateCommand(manifest, argv);
664
- if (!cmdResult.allowed) {
665
- return {
666
- ok: false,
667
- error: `Command validation failed: ${cmdResult.reason}`,
668
- };
669
- }
670
-
671
- // Ensure the matched profile is the requested one
672
- if (cmdResult.matchedProfile !== profileName) {
673
- return {
674
- ok: false,
675
- error: `Command argv matched profile "${cmdResult.matchedProfile}" but the requested ` +
676
- `profile is "${profileName}". The command does not match any pattern in the requested profile.`,
677
- };
678
- }
679
-
680
- return {
681
- ok: true,
682
- profile,
683
- matchedPattern: cmdResult.matchedPattern,
684
- };
685
- }
686
-
687
- // ---------------------------------------------------------------------------
688
- // Internal: Grant enforcement
689
- // ---------------------------------------------------------------------------
690
-
691
- interface GrantCheckResult {
692
- ok: boolean;
693
- grantId?: string;
694
- error?: string;
695
- }
696
-
697
- function checkGrant(
698
- request: ExecuteCommandRequest,
699
- manifest: SecureCommandManifest,
700
- profileName: string,
701
- persistentStore: PersistentGrantStore,
702
- temporaryStore: TemporaryGrantStore,
703
- ): GrantCheckResult {
704
- // Build the full legacy command string for exact matching against legacy grants.
705
- const legacyCommand = `${request.bundleDigest}/${profileName} ${request.argv.join(" ")}`.trim();
706
-
707
- // If an explicit grantId is provided, check it directly — but verify
708
- // that the grant's scope matches the current request. Without this
709
- // check, an agent with a valid grant for one command/credential could
710
- // reuse the grantId for a different command/credential (authorization
711
- // bypass).
712
- if (request.grantId) {
713
- const grant = persistentStore.getById(request.grantId);
714
- if (
715
- grant &&
716
- grant.tool === "command" &&
717
- grant.scope === request.credentialHandle &&
718
- grantMatchesCommand(grant.pattern, request.credentialHandle, request.bundleDigest, profileName, legacyCommand)
719
- ) {
720
- return { ok: true, grantId: grant.id };
721
- }
722
- // Explicit grant not found or does not match this request — fall through to pattern matching
723
- }
724
-
725
- // Check persistent grants for a matching command grant
726
- const allGrants = persistentStore.getAll();
727
- for (const grant of allGrants) {
728
- if (
729
- grant.tool === "command" &&
730
- grant.scope === request.credentialHandle &&
731
- grantMatchesCommand(grant.pattern, request.credentialHandle, request.bundleDigest, profileName, legacyCommand)
732
- ) {
733
- return { ok: true, grantId: grant.id };
734
- }
735
- }
736
-
737
- // Check temporary grants — build the same proposal shape that the
738
- // approval bridge produces, then hash with the canonical algorithm
739
- // from `@vellumai/service-contracts` so the hashes align.
740
- const tempProposal: CommandGrantProposal = {
741
- type: "command",
742
- credentialHandle: request.credentialHandle,
743
- command: `${request.bundleDigest}/${profileName} ${request.argv.join(" ")}`.trim(),
744
- purpose: request.purpose,
745
- allowedCommandPatterns: [`${request.credentialHandle}:${request.bundleDigest}:${profileName}`],
746
- };
747
- const proposalHash = hashProposal(tempProposal);
748
- const tempKind = temporaryStore.checkAny(
749
- proposalHash,
750
- request.conversationId,
751
- );
752
- if (tempKind) {
753
- return { ok: true, grantId: `temp:${tempKind}:${proposalHash}` };
754
- }
755
-
756
- return {
757
- ok: false,
758
- error: `No active grant found for bundle="${manifest.bundleId}", ` +
759
- `profile="${profileName}", credential="${request.credentialHandle}". ` +
760
- `Approval is required before command execution.`,
761
- };
762
- }
763
-
764
- /**
765
- * Check if a persistent grant pattern matches a command invocation.
766
- *
767
- * Grant patterns for commands can be stored in two formats:
768
- * 1. Canonical: `<credentialHandle>:<bundleDigest>:<profileName>` (from allowedCommandPatterns)
769
- * 2. Legacy: `<bundleDigest>/<profileName> <argv...>` (from proposal.command fallback)
770
- *
771
- * The legacy format exists because older grants were persisted using
772
- * `proposal.command` before `allowedCommandPatterns` was introduced.
773
- * Credential scope is already verified by the caller (`grant.scope === credentialHandle`),
774
- * so for legacy patterns we match the full command string (including argv) to prevent
775
- * a grant for one argv from authorizing a different argv on the same profile.
776
- */
777
- function grantMatchesCommand(
778
- pattern: string,
779
- credentialHandle: string,
780
- bundleDigest: string,
781
- profileName: string,
782
- legacyCommand: string,
783
- ): boolean {
784
- // Canonical format: <credentialHandle>:<bundleDigest>:<profileName>
785
- if (pattern === `${credentialHandle}:${bundleDigest}:${profileName}`) {
786
- return true;
787
- }
788
-
789
- // Legacy format: <bundleDigest>/<profileName> <argv...>
790
- // Match the full legacy command string exactly to prevent approval scope widening.
791
- if (pattern === legacyCommand) {
792
- return true;
793
- }
794
-
795
- return false;
796
- }
797
-
798
-
799
- // ---------------------------------------------------------------------------
800
- // Internal: Auth adapter environment construction
801
- // ---------------------------------------------------------------------------
802
-
803
- interface AuthAdapterEnvResult {
804
- /** Environment variables to inject into the command. */
805
- env: Record<string, string>;
806
- /** Path to a temp file that must be cleaned up (for temp_file adapter). */
807
- tempFilePath?: string;
808
- }
809
-
810
- async function buildAuthAdapterEnv(
811
- adapter: AuthAdapterConfig,
812
- credentialValue: string,
813
- proxyEnv?: ProxyEnvVars,
814
- noNetworkEnv?: Record<string, string>,
815
- ): Promise<AuthAdapterEnvResult> {
816
- // Validate adapter config
817
- const errors = validateAuthAdapterConfig(adapter);
818
- if (errors.length > 0) {
819
- throw new Error(
820
- `Invalid auth adapter config: ${errors.join("; ")}`,
821
- );
822
- }
823
-
824
- switch (adapter.type) {
825
- case AuthAdapterType.EnvVar: {
826
- const value = adapter.valuePrefix
827
- ? `${adapter.valuePrefix}${credentialValue}`
828
- : credentialValue;
829
- return {
830
- env: { [adapter.envVarName]: value },
831
- };
832
- }
833
-
834
- case AuthAdapterType.TempFile: {
835
- // Write credential to a temp file and set the env var to the path
836
- const tempDir = join(tmpdir(), `ces-auth-${randomUUID()}`);
837
- mkdirSync(tempDir, { recursive: true });
838
- const ext = adapter.fileExtension ?? "";
839
- const tempPath = join(tempDir, `credential${ext}`);
840
- const mode = adapter.fileMode ?? 0o600;
841
- writeFileSync(tempPath, credentialValue, { mode });
842
- return {
843
- env: { [adapter.envVarName]: tempPath },
844
- tempFilePath: tempPath,
845
- };
846
- }
847
-
848
- case AuthAdapterType.CredentialProcess: {
849
- // Run the helper command and capture its stdout.
850
- // Proxy env vars are forwarded so the helper runs under the same
851
- // egress control as the main command.
852
- const timeoutMs = adapter.timeoutMs ?? CREDENTIAL_PROCESS_TIMEOUT_MS;
853
- const helperResult = await runCredentialProcess(
854
- adapter.helperCommand,
855
- credentialValue,
856
- timeoutMs,
857
- proxyEnv,
858
- noNetworkEnv,
859
- );
860
- if (!helperResult.ok) {
861
- throw new Error(
862
- `Credential process helper failed: ${helperResult.error}`,
863
- );
864
- }
865
- return {
866
- env: { [adapter.envVarName]: helperResult.stdout },
867
- };
868
- }
869
-
870
- default:
871
- throw new Error(`Unknown auth adapter type: ${(adapter as AuthAdapterConfig).type}`);
872
- }
873
- }
874
-
875
- /**
876
- * Run a credential_process helper command inside CES.
877
- *
878
- * The helper receives the raw credential value on stdin and writes
879
- * the transformed credential to stdout. It is never exposed to the
880
- * subprocess directly.
881
- */
882
- async function runCredentialProcess(
883
- helperCommand: string,
884
- credentialValue: string,
885
- timeoutMs: number,
886
- proxyEnv?: ProxyEnvVars,
887
- noNetworkEnv?: Record<string, string>,
888
- ): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
889
- // Defense-in-depth: re-check denied binary and metacharacters at execution
890
- // time, mirroring the validator's static checks. If a manifest was tampered
891
- // with after validation, this blocks execution before spawning the shell.
892
- if (containsShellMetacharacters(helperCommand)) {
893
- return {
894
- ok: false,
895
- error: `Credential process helperCommand contains shell metacharacters. ` +
896
- `Command chaining operators are not allowed.`,
897
- };
898
- }
899
-
900
- const helperBinary = extractShellBinary(helperCommand);
901
- if (isDeniedBinary(helperBinary)) {
902
- return {
903
- ok: false,
904
- error: `Credential process helperCommand starts with denied binary "${helperBinary}". ` +
905
- `Generic HTTP clients, interpreters, and shell trampolines cannot be used as credential helpers.`,
906
- };
907
- }
908
-
909
- try {
910
- // Build a minimal environment for the helper. No host env is inherited,
911
- // but egress proxy or no-network env vars are injected so the helper
912
- // runs under the same network controls as the main command.
913
- const helperEnv: Record<string, string> = {};
914
-
915
- if (proxyEnv) {
916
- helperEnv["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
917
- helperEnv["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
918
- helperEnv["NO_PROXY"] = proxyEnv.NO_PROXY;
919
- helperEnv["http_proxy"] = proxyEnv.HTTP_PROXY;
920
- helperEnv["https_proxy"] = proxyEnv.HTTPS_PROXY;
921
- helperEnv["no_proxy"] = proxyEnv.NO_PROXY;
922
- if (proxyEnv.NODE_EXTRA_CA_CERTS) {
923
- helperEnv["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
924
- }
925
- if (proxyEnv.SSL_CERT_FILE) {
926
- helperEnv["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
927
- }
928
- }
929
-
930
- if (noNetworkEnv) {
931
- Object.assign(helperEnv, noNetworkEnv);
932
- }
933
-
934
- const proc = Bun.spawn(["sh", "-c", helperCommand], {
935
- stdin: "pipe",
936
- stdout: "pipe",
937
- stderr: "pipe",
938
- env: helperEnv,
939
- });
940
-
941
- // Write the credential value to stdin for the helper to consume
942
- proc.stdin.write(credentialValue);
943
- proc.stdin.end();
944
-
945
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
946
-
947
- // Consume stdout/stderr concurrently with waiting for exit to avoid
948
- // pipe buffer deadlocks when the helper produces large output.
949
- const [exitCode, stdout, stderr] = await Promise.race([
950
- Promise.all([
951
- proc.exited,
952
- new Response(proc.stdout).text(),
953
- new Response(proc.stderr).text(),
954
- ]),
955
- new Promise<never>((_, reject) => {
956
- timeoutSignal.addEventListener("abort", () => {
957
- proc.kill();
958
- reject(new Error(`Credential process timed out after ${timeoutMs}ms`));
959
- });
960
- }),
961
- ]);
962
-
963
- if (exitCode !== 0) {
964
- return {
965
- ok: false,
966
- error: `Helper exited with code ${exitCode}: ${stderr.trim()}`,
967
- };
968
- }
969
-
970
- return { ok: true, stdout: stdout.trim() };
971
- } catch (err) {
972
- return {
973
- ok: false,
974
- error: err instanceof Error ? err.message : String(err),
975
- };
976
- }
977
- }
978
-
979
- // ---------------------------------------------------------------------------
980
- // Internal: Command environment construction
981
- // ---------------------------------------------------------------------------
982
-
983
- /**
984
- * Build the clean execution environment for the command.
985
- *
986
- * The environment contains:
987
- * - Auth adapter env vars (credential injection)
988
- * - Proxy env vars (when egress proxy is active)
989
- * - HOME set to a temp directory (isolates config reads)
990
- * - PATH preserved from the CES process
991
- *
992
- * The environment explicitly does NOT inherit the CES process env.
993
- * Clean config dirs are handled by setting HOME to a temp directory.
994
- */
995
- function buildCommandEnv(
996
- adapterEnv: Record<string, string>,
997
- proxyEnv?: ProxyEnvVars,
998
- noNetworkEnv?: Record<string, string>,
999
- homeDir?: string,
1000
- ): Record<string, string> {
1001
- const env: Record<string, string> = {
1002
- // Inject auth adapter env vars first so they cannot override protected keys
1003
- ...adapterEnv,
1004
- // PATH, LANG, and HOME are set after adapterEnv spread to prevent auth
1005
- // adapters from overriding baseline environment invariants.
1006
- PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
1007
- LANG: "en_US.UTF-8",
1008
- HOME: homeDir ?? join(tmpdir(), `ces-home-${randomUUID()}`),
1009
- };
1010
-
1011
- // Inject proxy env vars if the egress proxy is active
1012
- if (proxyEnv) {
1013
- env["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
1014
- env["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
1015
- env["NO_PROXY"] = proxyEnv.NO_PROXY;
1016
- env["http_proxy"] = proxyEnv.HTTP_PROXY;
1017
- env["https_proxy"] = proxyEnv.HTTPS_PROXY;
1018
- env["no_proxy"] = proxyEnv.NO_PROXY;
1019
- if (proxyEnv.NODE_EXTRA_CA_CERTS) {
1020
- env["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
1021
- }
1022
- if (proxyEnv.SSL_CERT_FILE) {
1023
- env["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
1024
- }
1025
- }
1026
-
1027
- // For no_network mode, inject proxy vars pointing at a dead address to
1028
- // block direct outbound connections from the subprocess.
1029
- if (noNetworkEnv) {
1030
- Object.assign(env, noNetworkEnv);
1031
- }
1032
-
1033
- return env;
1034
- }
1035
-
1036
- // ---------------------------------------------------------------------------
1037
- // Internal: Clean config dirs enforcement
1038
- // ---------------------------------------------------------------------------
1039
-
1040
- /**
1041
- * Enforce the manifest's `cleanConfigDirs` contract by creating empty
1042
- * directories under the temp HOME directory.
1043
- *
1044
- * For each entry in `cleanConfigDirs`:
1045
- * - `~/`-prefixed paths are resolved relative to the temp HOME dir and
1046
- * created as empty directories. This ensures the command finds an empty
1047
- * config directory instead of reading host config that might contain secrets.
1048
- * - Absolute paths (not `~/`-prefixed) are skipped for v1 — they would
1049
- * require filesystem-level isolation (bind mounts, overlayfs).
1050
- */
1051
- function enforceCleanConfigDirs(
1052
- manifest: SecureCommandManifest,
1053
- homeDir: string,
1054
- ): void {
1055
- const dirs = manifest.cleanConfigDirs;
1056
- if (!dirs) return;
1057
-
1058
- for (const dirPath of Object.keys(dirs)) {
1059
- // Only handle ~/‑prefixed paths for v1
1060
- if (dirPath.startsWith("~/")) {
1061
- const relativePath = dirPath.slice(2); // strip "~/"
1062
- const resolvedPath = resolve(homeDir, relativePath);
1063
- // Containment check: resolved path must stay inside homeDir
1064
- if (!resolvedPath.startsWith(homeDir + "/") && resolvedPath !== homeDir) {
1065
- continue; // Skip paths that escape the home directory
1066
- }
1067
- mkdirSync(resolvedPath, { recursive: true });
1068
- } else if (dirPath === "~") {
1069
- // "~" alone is just the home dir itself, already created
1070
- continue;
1071
- }
1072
- // Absolute paths are skipped — would require filesystem-level isolation
1073
- }
1074
- }
1075
-
1076
- // ---------------------------------------------------------------------------
1077
- // Internal: Command execution
1078
- // ---------------------------------------------------------------------------
1079
-
1080
- async function runCommand(
1081
- entrypointPath: string,
1082
- argv: string[],
1083
- scratchDir: string,
1084
- env: Record<string, string>,
1085
- maxOutputBytes: number,
1086
- auditId: string,
1087
- ): Promise<ExecuteCommandResult> {
1088
- // Ensure the HOME directory exists (for clean config dirs isolation)
1089
- if (env["HOME"]) {
1090
- mkdirSync(env["HOME"], { recursive: true });
1091
- }
1092
-
1093
- const proc = Bun.spawn([entrypointPath, ...argv], {
1094
- cwd: scratchDir,
1095
- env,
1096
- stdin: "ignore",
1097
- stdout: "pipe",
1098
- stderr: "pipe",
1099
- });
1100
-
1101
- // Consume stdout/stderr concurrently with waiting for exit to avoid
1102
- // pipe buffer deadlocks when the command produces output exceeding the
1103
- // OS pipe buffer size (~64KB).
1104
- const [exitCode, stdoutRaw, stderrRaw] = await Promise.all([
1105
- proc.exited,
1106
- new Response(proc.stdout).text(),
1107
- new Response(proc.stderr).text(),
1108
- ]);
1109
-
1110
- const stdout = stdoutRaw.length > maxOutputBytes
1111
- ? stdoutRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
1112
- : stdoutRaw;
1113
-
1114
- const stderr = stderrRaw.length > maxOutputBytes
1115
- ? stderrRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
1116
- : stderrRaw;
1117
-
1118
- return {
1119
- success: exitCode === 0,
1120
- exitCode,
1121
- stdout,
1122
- stderr,
1123
- auditId,
1124
- ...(exitCode !== 0 ? { error: `Command exited with code ${exitCode}` } : {}),
1125
- };
1126
- }
1127
-
1128
- // ---------------------------------------------------------------------------
1129
- // Internal: Cleanup helpers
1130
- // ---------------------------------------------------------------------------
1131
-
1132
- function cleanupAll(scratchDir: string, tempFilePath?: string, homeDir?: string): void {
1133
- // Clean up temp auth file
1134
- if (tempFilePath) {
1135
- try {
1136
- unlinkSync(tempFilePath);
1137
- // Also remove the parent temp directory
1138
- rmSync(dirname(tempFilePath), { recursive: true, force: true });
1139
- } catch {
1140
- // Best-effort cleanup
1141
- }
1142
- }
1143
-
1144
- // Clean up per-execution HOME temp directory
1145
- if (homeDir) {
1146
- try {
1147
- rmSync(homeDir, { recursive: true, force: true });
1148
- } catch {
1149
- // Best-effort cleanup
1150
- }
1151
- }
1152
-
1153
- // Clean up scratch directory
1154
- cleanupScratchDir(scratchDir);
1155
- }