@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,512 @@
1
+ /**
2
+ * CES workspace staging and output copyback.
3
+ *
4
+ * Secure commands execute inside a CES-private scratch directory, never
5
+ * directly in the assistant-visible workspace. This module handles:
6
+ *
7
+ * 1. **Input staging** — Copies declared workspace inputs into a
8
+ * CES-private scratch directory and marks them read-only. The command
9
+ * reads inputs from the scratch directory, never from the workspace
10
+ * directly.
11
+ *
12
+ * 2. **Output copyback** — After command execution, only declared output
13
+ * files are copied from the scratch directory back into the workspace.
14
+ * Each output file is validated:
15
+ * - It must be declared in the command's output manifest.
16
+ * - Its path must not escape the scratch directory (path traversal).
17
+ * - It must not be a symlink pointing outside the scratch directory.
18
+ * - Its content is scanned for secret leakage before copyback.
19
+ *
20
+ * This staging model ensures that:
21
+ * - Commands cannot write arbitrary files into the workspace.
22
+ * - Commands cannot read undeclared workspace files.
23
+ * - Secret material never leaks into assistant-visible outputs.
24
+ * - The behavior is identical for local and managed CES execution.
25
+ */
26
+
27
+ import {
28
+ copyFileSync,
29
+ chmodSync,
30
+ existsSync,
31
+ lstatSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ readlinkSync,
35
+ realpathSync,
36
+ rmSync,
37
+ } from "node:fs";
38
+ import { join, resolve, dirname, basename } from "node:path";
39
+ import { randomUUID } from "node:crypto";
40
+
41
+ import { getCesDataRoot, type CesMode } from "../paths.js";
42
+ import { scanOutputFile, type OutputScanResult } from "./output-scan.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Types
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Declares a file to be staged from the assistant workspace into the
50
+ * CES scratch directory before command execution.
51
+ */
52
+ export interface WorkspaceInput {
53
+ /**
54
+ * Relative path within the assistant workspace directory.
55
+ * Must not contain `..` segments or absolute paths.
56
+ */
57
+ workspacePath: string;
58
+ }
59
+
60
+ /**
61
+ * Declares a file that the command is expected to produce in the scratch
62
+ * directory. Only declared outputs are eligible for copyback.
63
+ */
64
+ export interface WorkspaceOutput {
65
+ /**
66
+ * Relative path within the scratch directory where the command writes
67
+ * its output. Must not contain `..` segments or absolute paths.
68
+ */
69
+ scratchPath: string;
70
+
71
+ /**
72
+ * Relative path within the assistant workspace where the output should
73
+ * be copied. Must not contain `..` segments or absolute paths.
74
+ */
75
+ workspacePath: string;
76
+ }
77
+
78
+ /**
79
+ * Configuration for workspace staging and output copyback.
80
+ */
81
+ export interface WorkspaceStageConfig {
82
+ /** Absolute path to the assistant-visible workspace directory. */
83
+ workspaceDir: string;
84
+ /** Files to stage as read-only inputs in the scratch directory. */
85
+ inputs: WorkspaceInput[];
86
+ /** Files to copy back from the scratch directory after execution. */
87
+ outputs: WorkspaceOutput[];
88
+ /**
89
+ * Set of known secret values injected into the command environment.
90
+ * Used for output scanning.
91
+ */
92
+ secrets: ReadonlySet<string>;
93
+ }
94
+
95
+ /**
96
+ * Result of preparing a staged workspace for command execution.
97
+ */
98
+ export interface StagedWorkspace {
99
+ /** Absolute path to the CES-private scratch directory. */
100
+ scratchDir: string;
101
+ /** List of input files that were staged (relative to scratch dir). */
102
+ stagedInputs: string[];
103
+ }
104
+
105
+ /**
106
+ * Result of attempting to copy a single output back to the workspace.
107
+ */
108
+ export interface OutputCopyResult {
109
+ /** The declared scratch path. */
110
+ scratchPath: string;
111
+ /** The target workspace path. */
112
+ workspacePath: string;
113
+ /** Whether the copy was successful. */
114
+ success: boolean;
115
+ /** Reason for failure (undefined when successful). */
116
+ reason?: string;
117
+ /** Output scan result (undefined when copy was rejected before scanning). */
118
+ scanResult?: OutputScanResult;
119
+ }
120
+
121
+ /**
122
+ * Result of the full output copyback phase.
123
+ */
124
+ export interface CopybackResult {
125
+ /** Individual results for each declared output. */
126
+ outputs: OutputCopyResult[];
127
+ /** Whether all declared outputs were copied successfully. */
128
+ allSucceeded: boolean;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Path validation helpers
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Validate that a relative path does not attempt directory traversal.
137
+ * Returns an error string if invalid, undefined if valid.
138
+ */
139
+ export function validateRelativePath(
140
+ relativePath: string,
141
+ label: string,
142
+ ): string | undefined {
143
+ // Must not be absolute
144
+ if (relativePath.startsWith("/")) {
145
+ return `${label}: "${relativePath}" is an absolute path. Only relative paths are allowed.`;
146
+ }
147
+
148
+ // Must not contain .. segments
149
+ const segments = relativePath.split("/");
150
+ for (const seg of segments) {
151
+ if (seg === "..") {
152
+ return `${label}: "${relativePath}" contains ".." path traversal. This is not allowed.`;
153
+ }
154
+ }
155
+
156
+ // Must not be empty
157
+ if (relativePath.trim().length === 0) {
158
+ return `${label}: path is empty.`;
159
+ }
160
+
161
+ return undefined;
162
+ }
163
+
164
+ /**
165
+ * Verify that a resolved path is contained within the expected root
166
+ * directory. Returns an error string if the path escapes.
167
+ */
168
+ export function validateContainedPath(
169
+ resolvedPath: string,
170
+ rootDir: string,
171
+ label: string,
172
+ ): string | undefined {
173
+ const normalizedRoot = resolve(rootDir) + "/";
174
+ const normalizedPath = resolve(resolvedPath);
175
+
176
+ // The path must start with the root directory prefix
177
+ // (or be the root directory itself, though that's unusual for files)
178
+ if (!normalizedPath.startsWith(normalizedRoot) && normalizedPath !== resolve(rootDir)) {
179
+ return `${label}: resolved path "${normalizedPath}" escapes the root directory "${resolve(rootDir)}".`;
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ /**
185
+ * Check if a path is a symlink that points outside the given root.
186
+ * Returns an error string if it's an escaping symlink, undefined if safe.
187
+ */
188
+ export function checkSymlinkEscape(
189
+ filePath: string,
190
+ rootDir: string,
191
+ label: string,
192
+ ): string | undefined {
193
+ try {
194
+ const stat = lstatSync(filePath);
195
+ if (!stat.isSymbolicLink()) {
196
+ return undefined; // Not a symlink — safe
197
+ }
198
+
199
+ // Resolve the symlink target
200
+ const target = readlinkSync(filePath);
201
+ const resolvedTarget = resolve(dirname(filePath), target);
202
+ const normalizedRoot = resolve(rootDir) + "/";
203
+
204
+ if (
205
+ !resolvedTarget.startsWith(normalizedRoot) &&
206
+ resolvedTarget !== resolve(rootDir)
207
+ ) {
208
+ return `${label}: symlink "${filePath}" points to "${resolvedTarget}" which is outside the scratch directory "${resolve(rootDir)}".`;
209
+ }
210
+ } catch {
211
+ // If we can't stat the file, it doesn't exist yet or is inaccessible.
212
+ // This will be caught later during the actual copy.
213
+ return undefined;
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Scratch directory management
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Return the base directory for CES scratch workspaces.
224
+ */
225
+ export function getScratchBaseDir(mode?: CesMode): string {
226
+ return join(getCesDataRoot(mode), "scratch");
227
+ }
228
+
229
+ /**
230
+ * Create a new scratch directory for a command execution.
231
+ * Returns the absolute path to the new scratch directory.
232
+ */
233
+ export function createScratchDir(mode?: CesMode): string {
234
+ const scratchBase = getScratchBaseDir(mode);
235
+ const scratchDir = join(scratchBase, randomUUID());
236
+ mkdirSync(scratchDir, { recursive: true });
237
+ return scratchDir;
238
+ }
239
+
240
+ /**
241
+ * Clean up a scratch directory after command execution.
242
+ */
243
+ export function cleanupScratchDir(scratchDir: string): void {
244
+ try {
245
+ rmSync(scratchDir, { recursive: true, force: true });
246
+ } catch {
247
+ // Best-effort cleanup — log but don't fail
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Input staging
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Stage workspace inputs into a CES-private scratch directory.
257
+ *
258
+ * Each declared input is:
259
+ * 1. Validated for path traversal.
260
+ * 2. Copied from the workspace to the scratch directory.
261
+ * 3. Made read-only (chmod 0o444).
262
+ *
263
+ * @returns The staged workspace descriptor, or throws on validation failure.
264
+ */
265
+ export function stageInputs(
266
+ config: WorkspaceStageConfig,
267
+ mode?: CesMode,
268
+ ): StagedWorkspace {
269
+ const scratchDir = createScratchDir(mode);
270
+ const stagedInputs: string[] = [];
271
+
272
+ try {
273
+ for (const input of config.inputs) {
274
+ // Validate relative path
275
+ const pathError = validateRelativePath(
276
+ input.workspacePath,
277
+ "Workspace input",
278
+ );
279
+ if (pathError) {
280
+ throw new Error(pathError);
281
+ }
282
+
283
+ const sourcePath = join(config.workspaceDir, input.workspacePath);
284
+ const destPath = join(scratchDir, input.workspacePath);
285
+
286
+ // Validate the resolved source is within the workspace
287
+ const containedError = validateContainedPath(
288
+ sourcePath,
289
+ config.workspaceDir,
290
+ "Workspace input source",
291
+ );
292
+ if (containedError) {
293
+ throw new Error(containedError);
294
+ }
295
+
296
+ // Check source exists
297
+ if (!existsSync(sourcePath)) {
298
+ throw new Error(
299
+ `Workspace input "${input.workspacePath}" does not exist in workspace at "${sourcePath}".`,
300
+ );
301
+ }
302
+
303
+ // Ensure destination directory exists
304
+ mkdirSync(dirname(destPath), { recursive: true });
305
+
306
+ // Copy the file
307
+ copyFileSync(sourcePath, destPath);
308
+
309
+ // Make read-only (owner + group + others can read, nobody can write)
310
+ chmodSync(destPath, 0o444);
311
+
312
+ stagedInputs.push(input.workspacePath);
313
+ }
314
+ } catch (err) {
315
+ // Clean up scratch dir on failure
316
+ cleanupScratchDir(scratchDir);
317
+ throw err;
318
+ }
319
+
320
+ return { scratchDir, stagedInputs };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Output copyback
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Copy declared output files from the scratch directory back to the
329
+ * assistant workspace, after validation and scanning.
330
+ *
331
+ * Each declared output is:
332
+ * 1. Validated for path traversal (both scratch and workspace paths).
333
+ * 2. Checked that it exists in the scratch directory.
334
+ * 3. Checked for symlink escape (must not point outside scratch dir).
335
+ * 4. Scanned for secret leakage and auth-bearing artifacts.
336
+ * 5. Copied to the workspace if all checks pass.
337
+ *
338
+ * @returns A {@link CopybackResult} with individual results per output.
339
+ */
340
+ export function copybackOutputs(
341
+ config: WorkspaceStageConfig,
342
+ scratchDir: string,
343
+ ): CopybackResult {
344
+ const results: OutputCopyResult[] = [];
345
+
346
+ for (const output of config.outputs) {
347
+ const result = copybackSingleOutput(
348
+ output,
349
+ scratchDir,
350
+ config.workspaceDir,
351
+ config.secrets,
352
+ );
353
+ results.push(result);
354
+ }
355
+
356
+ return {
357
+ outputs: results,
358
+ allSucceeded: results.every((r) => r.success),
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Copy back a single output file with full validation.
364
+ */
365
+ function copybackSingleOutput(
366
+ output: WorkspaceOutput,
367
+ scratchDir: string,
368
+ workspaceDir: string,
369
+ secrets: ReadonlySet<string>,
370
+ ): OutputCopyResult {
371
+ // -- Validate scratch path
372
+ const scratchPathError = validateRelativePath(
373
+ output.scratchPath,
374
+ "Output scratch path",
375
+ );
376
+ if (scratchPathError) {
377
+ return {
378
+ scratchPath: output.scratchPath,
379
+ workspacePath: output.workspacePath,
380
+ success: false,
381
+ reason: scratchPathError,
382
+ };
383
+ }
384
+
385
+ // -- Validate workspace path
386
+ const workspacePathError = validateRelativePath(
387
+ output.workspacePath,
388
+ "Output workspace path",
389
+ );
390
+ if (workspacePathError) {
391
+ return {
392
+ scratchPath: output.scratchPath,
393
+ workspacePath: output.workspacePath,
394
+ success: false,
395
+ reason: workspacePathError,
396
+ };
397
+ }
398
+
399
+ const scratchFilePath = join(scratchDir, output.scratchPath);
400
+ const workspaceFilePath = join(workspaceDir, output.workspacePath);
401
+
402
+ // -- Validate containment (scratch)
403
+ const scratchContainedError = validateContainedPath(
404
+ scratchFilePath,
405
+ scratchDir,
406
+ "Output scratch file",
407
+ );
408
+ if (scratchContainedError) {
409
+ return {
410
+ scratchPath: output.scratchPath,
411
+ workspacePath: output.workspacePath,
412
+ success: false,
413
+ reason: scratchContainedError,
414
+ };
415
+ }
416
+
417
+ // -- Validate containment (workspace)
418
+ const workspaceContainedError = validateContainedPath(
419
+ workspaceFilePath,
420
+ workspaceDir,
421
+ "Output workspace file",
422
+ );
423
+ if (workspaceContainedError) {
424
+ return {
425
+ scratchPath: output.scratchPath,
426
+ workspacePath: output.workspacePath,
427
+ success: false,
428
+ reason: workspaceContainedError,
429
+ };
430
+ }
431
+
432
+ // -- Check file exists in scratch
433
+ if (!existsSync(scratchFilePath)) {
434
+ return {
435
+ scratchPath: output.scratchPath,
436
+ workspacePath: output.workspacePath,
437
+ success: false,
438
+ reason: `Output file "${output.scratchPath}" does not exist in scratch directory.`,
439
+ };
440
+ }
441
+
442
+ // -- Check symlink escape
443
+ const symlinkError = checkSymlinkEscape(
444
+ scratchFilePath,
445
+ scratchDir,
446
+ "Output file",
447
+ );
448
+ if (symlinkError) {
449
+ return {
450
+ scratchPath: output.scratchPath,
451
+ workspacePath: output.workspacePath,
452
+ success: false,
453
+ reason: symlinkError,
454
+ };
455
+ }
456
+
457
+ // -- Read and scan the file
458
+ let content: Buffer;
459
+ try {
460
+ // If it's a symlink, resolve it first to read the actual content
461
+ const stat = lstatSync(scratchFilePath);
462
+ if (stat.isSymbolicLink()) {
463
+ const realPath = realpathSync(scratchFilePath);
464
+ content = readFileSync(realPath);
465
+ } else {
466
+ content = readFileSync(scratchFilePath);
467
+ }
468
+ } catch (err) {
469
+ return {
470
+ scratchPath: output.scratchPath,
471
+ workspacePath: output.workspacePath,
472
+ success: false,
473
+ reason: `Failed to read output file "${output.scratchPath}": ${err instanceof Error ? err.message : String(err)}`,
474
+ };
475
+ }
476
+
477
+ const scanResult = scanOutputFile(
478
+ basename(output.scratchPath),
479
+ content,
480
+ secrets,
481
+ );
482
+
483
+ if (!scanResult.safe) {
484
+ return {
485
+ scratchPath: output.scratchPath,
486
+ workspacePath: output.workspacePath,
487
+ success: false,
488
+ reason: `Output file "${output.scratchPath}" failed security scan: ${scanResult.violations.join("; ")}`,
489
+ scanResult,
490
+ };
491
+ }
492
+
493
+ // -- Copy to workspace
494
+ try {
495
+ mkdirSync(dirname(workspaceFilePath), { recursive: true });
496
+ copyFileSync(scratchFilePath, workspaceFilePath);
497
+ } catch (err) {
498
+ return {
499
+ scratchPath: output.scratchPath,
500
+ workspacePath: output.workspacePath,
501
+ success: false,
502
+ reason: `Failed to copy output to workspace: ${err instanceof Error ? err.message : String(err)}`,
503
+ };
504
+ }
505
+
506
+ return {
507
+ scratchPath: output.scratchPath,
508
+ workspacePath: output.workspacePath,
509
+ success: true,
510
+ scanResult,
511
+ };
512
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CES grant stores.
3
+ *
4
+ * Re-exports the persistent and temporary grant stores used by the
5
+ * Credential Execution Service to track user approval decisions.
6
+ *
7
+ * - **Persistent store**: Durable grants (e.g. `always_allow`) persisted
8
+ * to `grants.json` inside the CES-private data root. Survives restarts.
9
+ * - **Temporary store**: Ephemeral grants (`allow_once`, `allow_10m`,
10
+ * `allow_thread`) held in memory. Never survives a process restart.
11
+ */
12
+
13
+ export { PersistentGrantStore } from "./persistent-store.js";
14
+ export type { PersistentGrant } from "./persistent-store.js";
15
+
16
+ export { TemporaryGrantStore } from "./temporary-store.js";
17
+ export type { TemporaryGrant, TemporaryGrantKind } from "./temporary-store.js";