@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,1050 +0,0 @@
1
- /**
2
- * CES workspace staging and output copyback tests.
3
- *
4
- * Covers:
5
- * 1. Staged input materialisation (files copied read-only into scratch).
6
- * 2. Undeclared output rejection (only declared outputs are copied back).
7
- * 3. Copyback path validation (path traversal, absolute paths rejected).
8
- * 4. Symlink traversal (symlinks pointing outside scratch dir rejected).
9
- * 5. Secret-bearing artifact rejection (exact secrets and auth patterns).
10
- * 6. Output scan standalone tests.
11
- */
12
-
13
- import { describe, expect, test, beforeEach, afterEach } from "bun:test";
14
- import {
15
- mkdirSync,
16
- writeFileSync,
17
- readFileSync,
18
- existsSync,
19
- symlinkSync,
20
- statSync,
21
- rmSync,
22
- } from "node:fs";
23
- import { join } from "node:path";
24
- import { randomUUID } from "node:crypto";
25
- import { tmpdir } from "node:os";
26
-
27
- import {
28
- validateRelativePath,
29
- validateContainedPath,
30
- checkSymlinkEscape,
31
- stageInputs,
32
- copybackOutputs,
33
- cleanupScratchDir,
34
- type WorkspaceStageConfig,
35
- } from "../commands/workspace.js";
36
-
37
- import { scanOutputFile } from "../commands/output-scan.js";
38
-
39
- // ---------------------------------------------------------------------------
40
- // Test helpers
41
- // ---------------------------------------------------------------------------
42
-
43
- /** Create a temp directory for a test and return its path. */
44
- function makeTempDir(prefix: string): string {
45
- const dir = join(tmpdir(), `ces-test-${prefix}-${randomUUID()}`);
46
- mkdirSync(dir, { recursive: true });
47
- return dir;
48
- }
49
-
50
- /** Set up a workspace dir with some files for testing. */
51
- function setupWorkspace(): {
52
- workspaceDir: string;
53
- cleanup: () => void;
54
- } {
55
- const workspaceDir = makeTempDir("workspace");
56
-
57
- // Create some sample workspace files
58
- writeFileSync(join(workspaceDir, "input.txt"), "Hello from workspace");
59
- mkdirSync(join(workspaceDir, "subdir"), { recursive: true });
60
- writeFileSync(
61
- join(workspaceDir, "subdir", "nested.json"),
62
- JSON.stringify({ data: "test" }),
63
- );
64
-
65
- return {
66
- workspaceDir,
67
- cleanup: () => rmSync(workspaceDir, { recursive: true, force: true }),
68
- };
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // 1. Staged input materialisation
73
- // ---------------------------------------------------------------------------
74
-
75
- describe("stageInputs", () => {
76
- let workspaceDir: string;
77
- let cleanup: () => void;
78
-
79
- beforeEach(() => {
80
- const ws = setupWorkspace();
81
- workspaceDir = ws.workspaceDir;
82
- cleanup = ws.cleanup;
83
- });
84
-
85
- afterEach(() => {
86
- cleanup();
87
- });
88
-
89
- test("copies declared inputs into scratch directory", () => {
90
- const config: WorkspaceStageConfig = {
91
- workspaceDir,
92
- inputs: [{ workspacePath: "input.txt" }],
93
- outputs: [],
94
- secrets: new Set(),
95
- };
96
-
97
- const staged = stageInputs(config);
98
-
99
- try {
100
- // File exists in scratch
101
- const scratchFile = join(staged.scratchDir, "input.txt");
102
- expect(existsSync(scratchFile)).toBe(true);
103
-
104
- // Content matches
105
- const content = readFileSync(scratchFile, "utf-8");
106
- expect(content).toBe("Hello from workspace");
107
-
108
- // Recorded in stagedInputs
109
- expect(staged.stagedInputs).toEqual(["input.txt"]);
110
- } finally {
111
- cleanupScratchDir(staged.scratchDir);
112
- }
113
- });
114
-
115
- test("concurrent stagings get isolated scratch directories", () => {
116
- // run_authenticated_command relies on each invocation getting its own
117
- // scratch dir so that interleaved commands cannot read or clobber each
118
- // other's staged inputs/outputs. Same config, two stagings → distinct dirs.
119
- const config: WorkspaceStageConfig = {
120
- workspaceDir,
121
- inputs: [{ workspacePath: "input.txt" }],
122
- outputs: [],
123
- secrets: new Set(),
124
- };
125
-
126
- const a = stageInputs(config);
127
- const b = stageInputs(config);
128
-
129
- try {
130
- expect(a.scratchDir).not.toBe(b.scratchDir);
131
- expect(existsSync(a.scratchDir)).toBe(true);
132
- expect(existsSync(b.scratchDir)).toBe(true);
133
- } finally {
134
- cleanupScratchDir(a.scratchDir);
135
- cleanupScratchDir(b.scratchDir);
136
- }
137
- });
138
-
139
- test("staged inputs are read-only", () => {
140
- const config: WorkspaceStageConfig = {
141
- workspaceDir,
142
- inputs: [{ workspacePath: "input.txt" }],
143
- outputs: [],
144
- secrets: new Set(),
145
- };
146
-
147
- const staged = stageInputs(config);
148
-
149
- try {
150
- const scratchFile = join(staged.scratchDir, "input.txt");
151
- const stat = statSync(scratchFile);
152
- // Permission should be 0o444 (read-only for all)
153
- const mode = stat.mode & 0o777;
154
- expect(mode).toBe(0o444);
155
- } finally {
156
- cleanupScratchDir(staged.scratchDir);
157
- }
158
- });
159
-
160
- test("stages nested directory inputs", () => {
161
- const config: WorkspaceStageConfig = {
162
- workspaceDir,
163
- inputs: [{ workspacePath: "subdir/nested.json" }],
164
- outputs: [],
165
- secrets: new Set(),
166
- };
167
-
168
- const staged = stageInputs(config);
169
-
170
- try {
171
- const scratchFile = join(staged.scratchDir, "subdir", "nested.json");
172
- expect(existsSync(scratchFile)).toBe(true);
173
- const content = JSON.parse(readFileSync(scratchFile, "utf-8"));
174
- expect(content.data).toBe("test");
175
- } finally {
176
- cleanupScratchDir(staged.scratchDir);
177
- }
178
- });
179
-
180
- test("stages multiple inputs", () => {
181
- const config: WorkspaceStageConfig = {
182
- workspaceDir,
183
- inputs: [
184
- { workspacePath: "input.txt" },
185
- { workspacePath: "subdir/nested.json" },
186
- ],
187
- outputs: [],
188
- secrets: new Set(),
189
- };
190
-
191
- const staged = stageInputs(config);
192
-
193
- try {
194
- expect(staged.stagedInputs).toEqual([
195
- "input.txt",
196
- "subdir/nested.json",
197
- ]);
198
- expect(existsSync(join(staged.scratchDir, "input.txt"))).toBe(true);
199
- expect(
200
- existsSync(join(staged.scratchDir, "subdir", "nested.json")),
201
- ).toBe(true);
202
- } finally {
203
- cleanupScratchDir(staged.scratchDir);
204
- }
205
- });
206
-
207
- test("rejects input with path traversal (..)", () => {
208
- const config: WorkspaceStageConfig = {
209
- workspaceDir,
210
- inputs: [{ workspacePath: "../etc/passwd" }],
211
- outputs: [],
212
- secrets: new Set(),
213
- };
214
-
215
- expect(() => stageInputs(config)).toThrow("path traversal");
216
- });
217
-
218
- test("rejects input with absolute path", () => {
219
- const config: WorkspaceStageConfig = {
220
- workspaceDir,
221
- inputs: [{ workspacePath: "/etc/passwd" }],
222
- outputs: [],
223
- secrets: new Set(),
224
- };
225
-
226
- expect(() => stageInputs(config)).toThrow("absolute path");
227
- });
228
-
229
- test("rejects input with empty path", () => {
230
- const config: WorkspaceStageConfig = {
231
- workspaceDir,
232
- inputs: [{ workspacePath: "" }],
233
- outputs: [],
234
- secrets: new Set(),
235
- };
236
-
237
- expect(() => stageInputs(config)).toThrow("empty");
238
- });
239
-
240
- test("rejects input that does not exist in workspace", () => {
241
- const config: WorkspaceStageConfig = {
242
- workspaceDir,
243
- inputs: [{ workspacePath: "nonexistent.txt" }],
244
- outputs: [],
245
- secrets: new Set(),
246
- };
247
-
248
- expect(() => stageInputs(config)).toThrow("does not exist");
249
- });
250
-
251
- test("cleans up scratch dir on staging failure", () => {
252
- const config: WorkspaceStageConfig = {
253
- workspaceDir,
254
- inputs: [
255
- { workspacePath: "input.txt" }, // exists — will succeed
256
- { workspacePath: "nonexistent.txt" }, // doesn't exist — will fail
257
- ],
258
- outputs: [],
259
- secrets: new Set(),
260
- };
261
-
262
- try {
263
- stageInputs(config);
264
- } catch {
265
- // Expected failure — scratchDir should have been cleaned up.
266
- // We can't easily capture scratchDir since the function throws,
267
- // but we verify the throw happens.
268
- }
269
- // The fact that it threw means cleanup was attempted.
270
- });
271
-
272
- test("with no inputs produces an empty scratch dir", () => {
273
- const config: WorkspaceStageConfig = {
274
- workspaceDir,
275
- inputs: [],
276
- outputs: [],
277
- secrets: new Set(),
278
- };
279
-
280
- const staged = stageInputs(config);
281
-
282
- try {
283
- expect(existsSync(staged.scratchDir)).toBe(true);
284
- expect(staged.stagedInputs).toEqual([]);
285
- } finally {
286
- cleanupScratchDir(staged.scratchDir);
287
- }
288
- });
289
- });
290
-
291
- // ---------------------------------------------------------------------------
292
- // 2. Undeclared output rejection
293
- // ---------------------------------------------------------------------------
294
-
295
- describe("copybackOutputs — undeclared output rejection", () => {
296
- let workspaceDir: string;
297
- let scratchDir: string;
298
- let cleanup: () => void;
299
-
300
- beforeEach(() => {
301
- workspaceDir = makeTempDir("ws-copyback");
302
- scratchDir = makeTempDir("scratch-copyback");
303
- cleanup = () => {
304
- rmSync(workspaceDir, { recursive: true, force: true });
305
- rmSync(scratchDir, { recursive: true, force: true });
306
- };
307
- });
308
-
309
- afterEach(() => {
310
- cleanup();
311
- });
312
-
313
- test("copies declared output to workspace", () => {
314
- // Create output file in scratch
315
- writeFileSync(join(scratchDir, "result.txt"), "output data");
316
-
317
- const config: WorkspaceStageConfig = {
318
- workspaceDir,
319
- inputs: [],
320
- outputs: [
321
- { scratchPath: "result.txt", workspacePath: "result.txt" },
322
- ],
323
- secrets: new Set(),
324
- };
325
-
326
- const result = copybackOutputs(config, scratchDir);
327
-
328
- expect(result.allSucceeded).toBe(true);
329
- expect(result.outputs).toHaveLength(1);
330
- expect(result.outputs[0]!.success).toBe(true);
331
-
332
- // File should exist in workspace
333
- const wsFile = join(workspaceDir, "result.txt");
334
- expect(existsSync(wsFile)).toBe(true);
335
- expect(readFileSync(wsFile, "utf-8")).toBe("output data");
336
- });
337
-
338
- test("rejects output file not present in scratch directory", () => {
339
- // Do NOT create the file in scratch
340
-
341
- const config: WorkspaceStageConfig = {
342
- workspaceDir,
343
- inputs: [],
344
- outputs: [
345
- { scratchPath: "missing.txt", workspacePath: "missing.txt" },
346
- ],
347
- secrets: new Set(),
348
- };
349
-
350
- const result = copybackOutputs(config, scratchDir);
351
-
352
- expect(result.allSucceeded).toBe(false);
353
- expect(result.outputs[0]!.success).toBe(false);
354
- expect(result.outputs[0]!.reason).toContain("does not exist");
355
-
356
- // File should NOT exist in workspace
357
- expect(existsSync(join(workspaceDir, "missing.txt"))).toBe(false);
358
- });
359
-
360
- test("undeclared files in scratch are not copied to workspace", () => {
361
- // Create two files in scratch, but only declare one
362
- writeFileSync(join(scratchDir, "declared.txt"), "safe");
363
- writeFileSync(join(scratchDir, "undeclared.txt"), "sneaky");
364
-
365
- const config: WorkspaceStageConfig = {
366
- workspaceDir,
367
- inputs: [],
368
- outputs: [
369
- { scratchPath: "declared.txt", workspacePath: "declared.txt" },
370
- ],
371
- secrets: new Set(),
372
- };
373
-
374
- const result = copybackOutputs(config, scratchDir);
375
-
376
- expect(result.allSucceeded).toBe(true);
377
- expect(existsSync(join(workspaceDir, "declared.txt"))).toBe(true);
378
- // Undeclared file must NOT appear in workspace
379
- expect(existsSync(join(workspaceDir, "undeclared.txt"))).toBe(false);
380
- });
381
-
382
- test("copies output to a different workspace path than scratch path", () => {
383
- writeFileSync(join(scratchDir, "output.json"), '{"result": true}');
384
-
385
- const config: WorkspaceStageConfig = {
386
- workspaceDir,
387
- inputs: [],
388
- outputs: [
389
- {
390
- scratchPath: "output.json",
391
- workspacePath: "reports/output.json",
392
- },
393
- ],
394
- secrets: new Set(),
395
- };
396
-
397
- const result = copybackOutputs(config, scratchDir);
398
-
399
- expect(result.allSucceeded).toBe(true);
400
- expect(
401
- existsSync(join(workspaceDir, "reports", "output.json")),
402
- ).toBe(true);
403
- });
404
- });
405
-
406
- // ---------------------------------------------------------------------------
407
- // 3. Copyback path validation
408
- // ---------------------------------------------------------------------------
409
-
410
- describe("copybackOutputs — path validation", () => {
411
- let workspaceDir: string;
412
- let scratchDir: string;
413
- let cleanup: () => void;
414
-
415
- beforeEach(() => {
416
- workspaceDir = makeTempDir("ws-pathval");
417
- scratchDir = makeTempDir("scratch-pathval");
418
- cleanup = () => {
419
- rmSync(workspaceDir, { recursive: true, force: true });
420
- rmSync(scratchDir, { recursive: true, force: true });
421
- };
422
- });
423
-
424
- afterEach(() => {
425
- cleanup();
426
- });
427
-
428
- test("rejects scratch path with path traversal", () => {
429
- const config: WorkspaceStageConfig = {
430
- workspaceDir,
431
- inputs: [],
432
- outputs: [
433
- {
434
- scratchPath: "../../../etc/shadow",
435
- workspacePath: "shadow",
436
- },
437
- ],
438
- secrets: new Set(),
439
- };
440
-
441
- const result = copybackOutputs(config, scratchDir);
442
-
443
- expect(result.allSucceeded).toBe(false);
444
- expect(result.outputs[0]!.reason).toContain("path traversal");
445
- });
446
-
447
- test("rejects workspace path with path traversal", () => {
448
- writeFileSync(join(scratchDir, "data.txt"), "safe data");
449
-
450
- const config: WorkspaceStageConfig = {
451
- workspaceDir,
452
- inputs: [],
453
- outputs: [
454
- {
455
- scratchPath: "data.txt",
456
- workspacePath: "../../etc/crontab",
457
- },
458
- ],
459
- secrets: new Set(),
460
- };
461
-
462
- const result = copybackOutputs(config, scratchDir);
463
-
464
- expect(result.allSucceeded).toBe(false);
465
- expect(result.outputs[0]!.reason).toContain("path traversal");
466
- });
467
-
468
- test("rejects absolute scratch path", () => {
469
- const config: WorkspaceStageConfig = {
470
- workspaceDir,
471
- inputs: [],
472
- outputs: [
473
- {
474
- scratchPath: "/etc/passwd",
475
- workspacePath: "passwd",
476
- },
477
- ],
478
- secrets: new Set(),
479
- };
480
-
481
- const result = copybackOutputs(config, scratchDir);
482
-
483
- expect(result.allSucceeded).toBe(false);
484
- expect(result.outputs[0]!.reason).toContain("absolute path");
485
- });
486
-
487
- test("rejects absolute workspace path", () => {
488
- writeFileSync(join(scratchDir, "output.txt"), "data");
489
-
490
- const config: WorkspaceStageConfig = {
491
- workspaceDir,
492
- inputs: [],
493
- outputs: [
494
- {
495
- scratchPath: "output.txt",
496
- workspacePath: "/tmp/evil/output.txt",
497
- },
498
- ],
499
- secrets: new Set(),
500
- };
501
-
502
- const result = copybackOutputs(config, scratchDir);
503
-
504
- expect(result.allSucceeded).toBe(false);
505
- expect(result.outputs[0]!.reason).toContain("absolute path");
506
- });
507
-
508
- test("rejects empty scratch path", () => {
509
- const config: WorkspaceStageConfig = {
510
- workspaceDir,
511
- inputs: [],
512
- outputs: [
513
- { scratchPath: "", workspacePath: "output.txt" },
514
- ],
515
- secrets: new Set(),
516
- };
517
-
518
- const result = copybackOutputs(config, scratchDir);
519
-
520
- expect(result.allSucceeded).toBe(false);
521
- expect(result.outputs[0]!.reason).toContain("empty");
522
- });
523
-
524
- test("rejects empty workspace path", () => {
525
- writeFileSync(join(scratchDir, "output.txt"), "data");
526
-
527
- const config: WorkspaceStageConfig = {
528
- workspaceDir,
529
- inputs: [],
530
- outputs: [
531
- { scratchPath: "output.txt", workspacePath: " " },
532
- ],
533
- secrets: new Set(),
534
- };
535
-
536
- const result = copybackOutputs(config, scratchDir);
537
-
538
- expect(result.allSucceeded).toBe(false);
539
- expect(result.outputs[0]!.reason).toContain("empty");
540
- });
541
- });
542
-
543
- // ---------------------------------------------------------------------------
544
- // 4. Symlink traversal rejection
545
- // ---------------------------------------------------------------------------
546
-
547
- describe("copybackOutputs — symlink escape", () => {
548
- let workspaceDir: string;
549
- let scratchDir: string;
550
- let outsideDir: string;
551
- let cleanup: () => void;
552
-
553
- beforeEach(() => {
554
- workspaceDir = makeTempDir("ws-symlink");
555
- scratchDir = makeTempDir("scratch-symlink");
556
- outsideDir = makeTempDir("outside-symlink");
557
- // Create a secret file outside scratch
558
- writeFileSync(join(outsideDir, "secret.key"), "top-secret-data");
559
- cleanup = () => {
560
- rmSync(workspaceDir, { recursive: true, force: true });
561
- rmSync(scratchDir, { recursive: true, force: true });
562
- rmSync(outsideDir, { recursive: true, force: true });
563
- };
564
- });
565
-
566
- afterEach(() => {
567
- cleanup();
568
- });
569
-
570
- test("rejects symlink pointing outside scratch directory", () => {
571
- // Create a symlink in scratch that points to the outside secret
572
- const symlinkPath = join(scratchDir, "sneaky.txt");
573
- symlinkSync(join(outsideDir, "secret.key"), symlinkPath);
574
-
575
- const config: WorkspaceStageConfig = {
576
- workspaceDir,
577
- inputs: [],
578
- outputs: [
579
- { scratchPath: "sneaky.txt", workspacePath: "sneaky.txt" },
580
- ],
581
- secrets: new Set(),
582
- };
583
-
584
- const result = copybackOutputs(config, scratchDir);
585
-
586
- expect(result.allSucceeded).toBe(false);
587
- expect(result.outputs[0]!.reason).toContain("symlink");
588
- expect(result.outputs[0]!.reason).toContain("outside");
589
-
590
- // Secret must NOT appear in workspace
591
- expect(existsSync(join(workspaceDir, "sneaky.txt"))).toBe(false);
592
- });
593
-
594
- test("allows symlink pointing within scratch directory", () => {
595
- // Create a regular file and a symlink to it, both within scratch
596
- writeFileSync(join(scratchDir, "real.txt"), "safe data");
597
- const symlinkPath = join(scratchDir, "link.txt");
598
- symlinkSync(join(scratchDir, "real.txt"), symlinkPath);
599
-
600
- const config: WorkspaceStageConfig = {
601
- workspaceDir,
602
- inputs: [],
603
- outputs: [
604
- { scratchPath: "link.txt", workspacePath: "link.txt" },
605
- ],
606
- secrets: new Set(),
607
- };
608
-
609
- const result = copybackOutputs(config, scratchDir);
610
-
611
- expect(result.allSucceeded).toBe(true);
612
- expect(existsSync(join(workspaceDir, "link.txt"))).toBe(true);
613
- });
614
- });
615
-
616
- // ---------------------------------------------------------------------------
617
- // 5. Secret-bearing artifact rejection
618
- // ---------------------------------------------------------------------------
619
-
620
- describe("copybackOutputs — secret scanning", () => {
621
- let workspaceDir: string;
622
- let scratchDir: string;
623
- let cleanup: () => void;
624
-
625
- beforeEach(() => {
626
- workspaceDir = makeTempDir("ws-secrets");
627
- scratchDir = makeTempDir("scratch-secrets");
628
- cleanup = () => {
629
- rmSync(workspaceDir, { recursive: true, force: true });
630
- rmSync(scratchDir, { recursive: true, force: true });
631
- };
632
- });
633
-
634
- afterEach(() => {
635
- cleanup();
636
- });
637
-
638
- test("rejects output containing an exact secret match", () => {
639
- const secretValue = "ghp_SuperSecretTokenValue1234567890abcdef";
640
- writeFileSync(
641
- join(scratchDir, "output.txt"),
642
- `Here is the token: ${secretValue}\n`,
643
- );
644
-
645
- const config: WorkspaceStageConfig = {
646
- workspaceDir,
647
- inputs: [],
648
- outputs: [
649
- { scratchPath: "output.txt", workspacePath: "output.txt" },
650
- ],
651
- secrets: new Set([secretValue]),
652
- };
653
-
654
- const result = copybackOutputs(config, scratchDir);
655
-
656
- expect(result.allSucceeded).toBe(false);
657
- expect(result.outputs[0]!.success).toBe(false);
658
- expect(result.outputs[0]!.reason).toContain("security scan");
659
- expect(result.outputs[0]!.scanResult?.violations.length).toBeGreaterThan(0);
660
-
661
- // File must NOT exist in workspace
662
- expect(existsSync(join(workspaceDir, "output.txt"))).toBe(false);
663
- });
664
-
665
- test("rejects output with AWS credentials pattern", () => {
666
- writeFileSync(
667
- join(scratchDir, "config"),
668
- `[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n`,
669
- );
670
-
671
- const config: WorkspaceStageConfig = {
672
- workspaceDir,
673
- inputs: [],
674
- outputs: [
675
- { scratchPath: "config", workspacePath: "config" },
676
- ],
677
- secrets: new Set(),
678
- };
679
-
680
- const result = copybackOutputs(config, scratchDir);
681
-
682
- expect(result.allSucceeded).toBe(false);
683
- expect(result.outputs[0]!.success).toBe(false);
684
- expect(result.outputs[0]!.reason).toContain("security scan");
685
- });
686
-
687
- test("rejects output with PEM private key", () => {
688
- writeFileSync(
689
- join(scratchDir, "key.pem"),
690
- `-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJBALRiMLAHudeSA...\n-----END RSA PRIVATE KEY-----\n`,
691
- );
692
-
693
- const config: WorkspaceStageConfig = {
694
- workspaceDir,
695
- inputs: [],
696
- outputs: [
697
- { scratchPath: "key.pem", workspacePath: "key.pem" },
698
- ],
699
- secrets: new Set(),
700
- };
701
-
702
- const result = copybackOutputs(config, scratchDir);
703
-
704
- expect(result.allSucceeded).toBe(false);
705
- expect(result.outputs[0]!.reason).toContain("security scan");
706
- });
707
-
708
- test("rejects .netrc file by filename", () => {
709
- writeFileSync(
710
- join(scratchDir, ".netrc"),
711
- "machine example.com login user password pass123",
712
- );
713
-
714
- const config: WorkspaceStageConfig = {
715
- workspaceDir,
716
- inputs: [],
717
- outputs: [
718
- { scratchPath: ".netrc", workspacePath: ".netrc" },
719
- ],
720
- secrets: new Set(),
721
- };
722
-
723
- const result = copybackOutputs(config, scratchDir);
724
-
725
- expect(result.allSucceeded).toBe(false);
726
- expect(result.outputs[0]!.reason).toContain("security scan");
727
- });
728
-
729
- test("allows clean output with no secrets", () => {
730
- writeFileSync(
731
- join(scratchDir, "report.json"),
732
- JSON.stringify({ status: "ok", count: 42 }),
733
- );
734
-
735
- const config: WorkspaceStageConfig = {
736
- workspaceDir,
737
- inputs: [],
738
- outputs: [
739
- { scratchPath: "report.json", workspacePath: "report.json" },
740
- ],
741
- secrets: new Set(["my-secret-value"]),
742
- };
743
-
744
- const result = copybackOutputs(config, scratchDir);
745
-
746
- expect(result.allSucceeded).toBe(true);
747
- expect(result.outputs[0]!.success).toBe(true);
748
- expect(
749
- existsSync(join(workspaceDir, "report.json")),
750
- ).toBe(true);
751
- });
752
-
753
- test("mixed results: one output passes, another fails", () => {
754
- const secret = "SuperSecretToken123";
755
- writeFileSync(join(scratchDir, "clean.txt"), "safe content");
756
- writeFileSync(join(scratchDir, "dirty.txt"), `token=${secret}`);
757
-
758
- const config: WorkspaceStageConfig = {
759
- workspaceDir,
760
- inputs: [],
761
- outputs: [
762
- { scratchPath: "clean.txt", workspacePath: "clean.txt" },
763
- { scratchPath: "dirty.txt", workspacePath: "dirty.txt" },
764
- ],
765
- secrets: new Set([secret]),
766
- };
767
-
768
- const result = copybackOutputs(config, scratchDir);
769
-
770
- expect(result.allSucceeded).toBe(false);
771
- expect(result.outputs).toHaveLength(2);
772
-
773
- const cleanResult = result.outputs.find((o) => o.scratchPath === "clean.txt");
774
- const dirtyResult = result.outputs.find((o) => o.scratchPath === "dirty.txt");
775
-
776
- expect(cleanResult!.success).toBe(true);
777
- expect(dirtyResult!.success).toBe(false);
778
-
779
- expect(existsSync(join(workspaceDir, "clean.txt"))).toBe(true);
780
- expect(existsSync(join(workspaceDir, "dirty.txt"))).toBe(false);
781
- });
782
- });
783
-
784
- // ---------------------------------------------------------------------------
785
- // 6. Output scan standalone tests
786
- // ---------------------------------------------------------------------------
787
-
788
- describe("scanOutputFile", () => {
789
- test("detects exact secret match", () => {
790
- const secret = "sk_live_abcdef1234567890";
791
- const content = `API response: ${secret}\n`;
792
- const result = scanOutputFile("output.txt", content, new Set([secret]));
793
- expect(result.safe).toBe(false);
794
- expect(result.violations.some((v) => v.includes("exact match"))).toBe(true);
795
- });
796
-
797
- test("ignores empty secret values", () => {
798
- const content = "some output";
799
- const result = scanOutputFile("output.txt", content, new Set([""]));
800
- expect(result.safe).toBe(true);
801
- });
802
-
803
- test("detects netrc-format credentials", () => {
804
- const content = "machine api.example.com login admin password hunter2";
805
- const result = scanOutputFile("output.txt", content, new Set());
806
- expect(result.safe).toBe(false);
807
- expect(result.violations.some((v) => v.includes("netrc"))).toBe(true);
808
- });
809
-
810
- test("detects AWS secret access key pattern", () => {
811
- const content = "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
812
- const result = scanOutputFile("config", content, new Set());
813
- expect(result.safe).toBe(false);
814
- expect(result.violations.some((v) => v.includes("AWS"))).toBe(true);
815
- });
816
-
817
- test("detects PEM private key", () => {
818
- const content = "-----BEGIN PRIVATE KEY-----\nbase64data\n-----END PRIVATE KEY-----";
819
- const result = scanOutputFile("key.pem", content, new Set());
820
- expect(result.safe).toBe(false);
821
- expect(result.violations.some((v) => v.includes("PEM private key"))).toBe(true);
822
- });
823
-
824
- test("detects OpenSSH private key", () => {
825
- const content = "-----BEGIN OPENSSH PRIVATE KEY-----\nbase64data\n-----END OPENSSH PRIVATE KEY-----";
826
- const result = scanOutputFile("id_ed25519", content, new Set());
827
- expect(result.safe).toBe(false);
828
- expect(result.violations.some((v) => v.includes("OpenSSH"))).toBe(true);
829
- });
830
-
831
- test("detects Docker registry auth token", () => {
832
- const content = '{"auths":{"registry.example.com":{"auth":"dXNlcjpwYXNzd29yZA=="}}}';
833
- const result = scanOutputFile("config.json", content, new Set());
834
- expect(result.safe).toBe(false);
835
- expect(result.violations.some((v) => v.includes("Docker"))).toBe(true);
836
- });
837
-
838
- test("detects npm auth token", () => {
839
- const content = "//registry.npmjs.org/:_authToken = npm_XXXXXXXXXXXXXXXXXXXXX";
840
- const result = scanOutputFile(".npmrc", content, new Set());
841
- expect(result.safe).toBe(false);
842
- expect(result.violations.some((v) => v.includes("npm"))).toBe(true);
843
- });
844
-
845
- test("flags .env filename as auth-bearing", () => {
846
- const content = "DATABASE_URL=postgres://localhost/mydb";
847
- const result = scanOutputFile(".env", content, new Set());
848
- expect(result.safe).toBe(false);
849
- expect(result.violations.some((v) => v.includes("auth-bearing config"))).toBe(true);
850
- });
851
-
852
- test("flags .netrc filename as auth-bearing", () => {
853
- // Even without matching content patterns, the filename itself is flagged
854
- const content = "# empty netrc";
855
- const result = scanOutputFile(".netrc", content, new Set());
856
- expect(result.safe).toBe(false);
857
- expect(result.violations.some((v) => v.includes("auth-bearing config"))).toBe(true);
858
- });
859
-
860
- test("allows clean JSON output", () => {
861
- const content = JSON.stringify({ repos: ["a", "b"], count: 2 });
862
- const result = scanOutputFile("repos.json", content, new Set(["my-secret"]));
863
- expect(result.safe).toBe(true);
864
- expect(result.violations).toHaveLength(0);
865
- });
866
-
867
- test("allows plain text output without secrets", () => {
868
- const content = "Build succeeded\n42 tests passed\n0 failures";
869
- const result = scanOutputFile("build-log.txt", content, new Set());
870
- expect(result.safe).toBe(true);
871
- });
872
-
873
- test("handles Buffer content", () => {
874
- const secret = "bufferSecret123";
875
- const content = Buffer.from(`data: ${secret}\n`, "utf-8");
876
- const result = scanOutputFile("output.bin", content, new Set([secret]));
877
- expect(result.safe).toBe(false);
878
- expect(result.violations.some((v) => v.includes("exact match"))).toBe(true);
879
- });
880
-
881
- test("accumulates multiple violations", () => {
882
- const secret = "my-leaked-secret";
883
- const content = `machine example.com login user password ${secret}`;
884
- const result = scanOutputFile(".netrc", content, new Set([secret]));
885
- expect(result.safe).toBe(false);
886
- // Should have at least 3 violations: exact match, filename, and content pattern
887
- expect(result.violations.length).toBeGreaterThanOrEqual(3);
888
- });
889
- });
890
-
891
- // ---------------------------------------------------------------------------
892
- // Path validation standalone tests
893
- // ---------------------------------------------------------------------------
894
-
895
- describe("validateRelativePath", () => {
896
- test("accepts simple relative path", () => {
897
- expect(validateRelativePath("file.txt", "test")).toBeUndefined();
898
- });
899
-
900
- test("accepts nested relative path", () => {
901
- expect(validateRelativePath("dir/subdir/file.txt", "test")).toBeUndefined();
902
- });
903
-
904
- test("rejects absolute path", () => {
905
- const err = validateRelativePath("/etc/passwd", "test");
906
- expect(err).toBeDefined();
907
- expect(err).toContain("absolute path");
908
- });
909
-
910
- test("rejects path with ..", () => {
911
- const err = validateRelativePath("../secret", "test");
912
- expect(err).toBeDefined();
913
- expect(err).toContain("path traversal");
914
- });
915
-
916
- test("rejects path with embedded ..", () => {
917
- const err = validateRelativePath("dir/../../../etc/shadow", "test");
918
- expect(err).toBeDefined();
919
- expect(err).toContain("path traversal");
920
- });
921
-
922
- test("rejects empty path", () => {
923
- const err = validateRelativePath("", "test");
924
- expect(err).toBeDefined();
925
- expect(err).toContain("empty");
926
- });
927
-
928
- test("rejects whitespace-only path", () => {
929
- const err = validateRelativePath(" ", "test");
930
- expect(err).toBeDefined();
931
- expect(err).toContain("empty");
932
- });
933
- });
934
-
935
- describe("validateContainedPath", () => {
936
- test("accepts path within root", () => {
937
- expect(
938
- validateContainedPath("/root/dir/file.txt", "/root/dir", "test"),
939
- ).toBeUndefined();
940
- });
941
-
942
- test("accepts nested path within root", () => {
943
- expect(
944
- validateContainedPath("/root/dir/sub/file.txt", "/root/dir", "test"),
945
- ).toBeUndefined();
946
- });
947
-
948
- test("rejects path outside root", () => {
949
- const err = validateContainedPath("/other/file.txt", "/root/dir", "test");
950
- expect(err).toBeDefined();
951
- expect(err).toContain("escapes");
952
- });
953
-
954
- test("rejects path that traverses out of root", () => {
955
- const err = validateContainedPath(
956
- "/root/dir/../other/file.txt",
957
- "/root/dir",
958
- "test",
959
- );
960
- expect(err).toBeDefined();
961
- expect(err).toContain("escapes");
962
- });
963
-
964
- test("accepts multi-level non-existent path under symlinked root", () => {
965
- // On macOS /tmp is a symlink to /private/tmp. Create a real directory
966
- // under /tmp so the root resolves through the symlink, then validate a
967
- // path with multiple non-existent levels (e.g., reports/output.json where
968
- // reports/ also doesn't exist). This exercises the while-loop in the
969
- // catch branch iterating more than once before finding an existing ancestor.
970
- const base = join("/tmp", `ces-test-multilevel-${randomUUID()}`);
971
- mkdirSync(base, { recursive: true });
972
- try {
973
- // Neither "reports/" nor "reports/output.json" exist on disk
974
- const deepPath = join(base, "reports", "output.json");
975
- const err = validateContainedPath(deepPath, base, "test");
976
- expect(err).toBeUndefined();
977
-
978
- // Verify it also works with even deeper non-existent paths
979
- const deeperPath = join(base, "a", "b", "c", "file.txt");
980
- const err2 = validateContainedPath(deeperPath, base, "test");
981
- expect(err2).toBeUndefined();
982
-
983
- // Verify escape detection still works with multi-level non-existent paths
984
- // under a symlinked root — path outside the root should still be rejected
985
- const outsidePath = join("/tmp", `ces-test-other-${randomUUID()}`, "a", "b", "file.txt");
986
- const err3 = validateContainedPath(outsidePath, base, "test");
987
- expect(err3).toBeDefined();
988
- expect(err3).toContain("escapes");
989
- } finally {
990
- rmSync(base, { recursive: true, force: true });
991
- }
992
- });
993
- });
994
-
995
- describe("checkSymlinkEscape", () => {
996
- let testDir: string;
997
- let outsideDir: string;
998
- let cleanup: () => void;
999
-
1000
- beforeEach(() => {
1001
- testDir = makeTempDir("symcheck");
1002
- outsideDir = makeTempDir("symcheck-outside");
1003
- writeFileSync(join(outsideDir, "target.txt"), "outside data");
1004
- writeFileSync(join(testDir, "internal.txt"), "internal data");
1005
- cleanup = () => {
1006
- rmSync(testDir, { recursive: true, force: true });
1007
- rmSync(outsideDir, { recursive: true, force: true });
1008
- };
1009
- });
1010
-
1011
- afterEach(() => {
1012
- cleanup();
1013
- });
1014
-
1015
- test("returns undefined for regular files", () => {
1016
- expect(
1017
- checkSymlinkEscape(join(testDir, "internal.txt"), testDir, "test"),
1018
- ).toBeUndefined();
1019
- });
1020
-
1021
- test("returns undefined for internal symlinks", () => {
1022
- symlinkSync(
1023
- join(testDir, "internal.txt"),
1024
- join(testDir, "link.txt"),
1025
- );
1026
- expect(
1027
- checkSymlinkEscape(join(testDir, "link.txt"), testDir, "test"),
1028
- ).toBeUndefined();
1029
- });
1030
-
1031
- test("returns error for symlinks pointing outside", () => {
1032
- symlinkSync(
1033
- join(outsideDir, "target.txt"),
1034
- join(testDir, "escape.txt"),
1035
- );
1036
- const err = checkSymlinkEscape(
1037
- join(testDir, "escape.txt"),
1038
- testDir,
1039
- "test",
1040
- );
1041
- expect(err).toBeDefined();
1042
- expect(err).toContain("outside");
1043
- });
1044
-
1045
- test("returns undefined for non-existent files", () => {
1046
- expect(
1047
- checkSymlinkEscape(join(testDir, "nope.txt"), testDir, "test"),
1048
- ).toBeUndefined();
1049
- });
1050
- });