@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,773 +0,0 @@
1
- import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
- import {
3
- mkdirSync,
4
- rmSync,
5
- existsSync,
6
- readFileSync,
7
- readdirSync,
8
- writeFileSync,
9
- symlinkSync,
10
- } from "node:fs";
11
- import { join } from "node:path";
12
- import { tmpdir } from "node:os";
13
- import { randomUUID } from "node:crypto";
14
-
15
- import {
16
- type SecureCommandManifest,
17
- MANIFEST_SCHEMA_VERSION,
18
- EgressMode,
19
- } from "../commands/profiles.js";
20
- import { AuthAdapterType } from "../commands/auth-adapters.js";
21
- import { computeDigest, verifyDigest } from "../toolstore/integrity.js";
22
- import {
23
- isValidSha256Hex,
24
- validateSourceUrl,
25
- isWorkspaceOriginPath,
26
- } from "../toolstore/manifest.js";
27
- import {
28
- publishBundle,
29
- readPublishedManifest,
30
- isBundlePublished,
31
- type PublishRequest,
32
- } from "../toolstore/publish.js";
33
-
34
- // ---------------------------------------------------------------------------
35
- // Test fixtures
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * Create a tar.gz archive containing a shell script at the given entrypoint path.
40
- * Returns the archive bytes.
41
- */
42
- function createTestArchive(
43
- entrypoint: string,
44
- scriptContent = "#!/usr/bin/env bash\necho hello\n",
45
- ): Buffer {
46
- const stagingDir = join(tmpdir(), `ces-test-archive-${randomUUID()}`);
47
- try {
48
- const entrypointPath = join(stagingDir, entrypoint);
49
- mkdirSync(join(stagingDir, entrypoint, ".."), { recursive: true });
50
- writeFileSync(entrypointPath, scriptContent, { mode: 0o755 });
51
-
52
- const archivePath = join(stagingDir, "bundle.tar.gz");
53
- const proc = Bun.spawnSync(
54
- ["tar", "czf", archivePath, "-C", stagingDir, entrypoint],
55
- { stdout: "pipe", stderr: "pipe" },
56
- );
57
- if (proc.exitCode !== 0) {
58
- throw new Error(
59
- `Failed to create test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
60
- );
61
- }
62
- return Buffer.from(readFileSync(archivePath));
63
- } finally {
64
- try {
65
- rmSync(stagingDir, { recursive: true, force: true });
66
- } catch {
67
- /* best effort */
68
- }
69
- }
70
- }
71
-
72
- /**
73
- * Create a tar.gz archive containing a symlink entrypoint that points to an external path.
74
- * This simulates a malicious bundle that attempts symlink escape.
75
- */
76
- function createSymlinkArchive(
77
- entrypoint: string,
78
- symlinkTarget: string,
79
- ): Buffer {
80
- const stagingDir = join(tmpdir(), `ces-test-symlink-archive-${randomUUID()}`);
81
- try {
82
- const entrypointPath = join(stagingDir, entrypoint);
83
- mkdirSync(join(stagingDir, entrypoint, ".."), { recursive: true });
84
- // Create a symlink at the entrypoint path pointing to the external target
85
- symlinkSync(symlinkTarget, entrypointPath);
86
-
87
- const archivePath = join(stagingDir, "bundle.tar.gz");
88
- // Use -h flag to follow symlinks during archive creation would defeat the
89
- // purpose; instead we archive the symlink itself using default tar behavior
90
- const proc = Bun.spawnSync(
91
- ["tar", "czf", archivePath, "-C", stagingDir, entrypoint],
92
- { stdout: "pipe", stderr: "pipe" },
93
- );
94
- if (proc.exitCode !== 0) {
95
- throw new Error(
96
- `Failed to create symlink test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
97
- );
98
- }
99
- return Buffer.from(readFileSync(archivePath));
100
- } finally {
101
- try {
102
- rmSync(stagingDir, { recursive: true, force: true });
103
- } catch {
104
- /* best effort */
105
- }
106
- }
107
- }
108
-
109
- /** Sample bundle bytes as a valid tar.gz archive containing bin/test-cli. */
110
- const SAMPLE_BUNDLE_BYTES = createTestArchive("bin/test-cli");
111
-
112
- /** The correct SHA-256 digest of SAMPLE_BUNDLE_BYTES. */
113
- const SAMPLE_BUNDLE_DIGEST = computeDigest(SAMPLE_BUNDLE_BYTES);
114
-
115
- /** A different archive to test digest mismatches. */
116
- const TAMPERED_BUNDLE_BYTES = createTestArchive(
117
- "bin/test-cli",
118
- "#!/usr/bin/env bash\nrm -rf /\n",
119
- );
120
-
121
- /**
122
- * Build a minimal valid SecureCommandManifest for testing.
123
- */
124
- function buildSecureManifest(
125
- overrides: Partial<SecureCommandManifest> = {},
126
- ): SecureCommandManifest {
127
- return {
128
- schemaVersion: MANIFEST_SCHEMA_VERSION,
129
- bundleDigest: SAMPLE_BUNDLE_DIGEST,
130
- bundleId: "test-cli",
131
- version: "1.0.0",
132
- entrypoint: "bin/test-cli",
133
- commandProfiles: {
134
- "read-data": {
135
- description: "Read-only data access",
136
- allowedArgvPatterns: [{ name: "list", tokens: ["list", "<resource>"] }],
137
- deniedSubcommands: ["admin"],
138
- allowedNetworkTargets: [
139
- { hostPattern: "api.example.com", protocols: ["https"] },
140
- ],
141
- },
142
- },
143
- authAdapter: {
144
- type: AuthAdapterType.EnvVar,
145
- envVarName: "TEST_TOKEN",
146
- },
147
- egressMode: EgressMode.ProxyRequired,
148
- ...overrides,
149
- };
150
- }
151
-
152
- /**
153
- * Build a valid PublishRequest for testing.
154
- */
155
- function buildPublishRequest(
156
- overrides: Partial<PublishRequest> = {},
157
- ): PublishRequest {
158
- return {
159
- bundleBytes: SAMPLE_BUNDLE_BYTES,
160
- expectedDigest: SAMPLE_BUNDLE_DIGEST,
161
- bundleId: "test-cli",
162
- version: "1.0.0",
163
- sourceUrl: "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
164
- secureCommandManifest: buildSecureManifest(),
165
- ...overrides,
166
- };
167
- }
168
-
169
- // ---------------------------------------------------------------------------
170
- // Temp directory management for publisher tests
171
- // ---------------------------------------------------------------------------
172
-
173
- let testTmpDir: string;
174
-
175
- beforeEach(() => {
176
- testTmpDir = join(
177
- tmpdir(),
178
- `ces-toolstore-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
179
- );
180
- mkdirSync(testTmpDir, { recursive: true });
181
-
182
- // Point CES data root to the temp directory so tests are isolated
183
- process.env["CREDENTIAL_SECURITY_DIR"] = testTmpDir;
184
- });
185
-
186
- afterEach(() => {
187
- try {
188
- rmSync(testTmpDir, { recursive: true, force: true });
189
- } catch {
190
- // Best effort cleanup
191
- }
192
- delete process.env["CREDENTIAL_SECURITY_DIR"];
193
- });
194
-
195
- // ---------------------------------------------------------------------------
196
- // Integrity: computeDigest / verifyDigest
197
- // ---------------------------------------------------------------------------
198
-
199
- describe("integrity", () => {
200
- test("computeDigest returns a 64-character hex string", () => {
201
- const digest = computeDigest(SAMPLE_BUNDLE_BYTES);
202
- expect(digest).toHaveLength(64);
203
- expect(/^[a-f0-9]{64}$/.test(digest)).toBe(true);
204
- });
205
-
206
- test("computeDigest is deterministic", () => {
207
- const d1 = computeDigest(SAMPLE_BUNDLE_BYTES);
208
- const d2 = computeDigest(SAMPLE_BUNDLE_BYTES);
209
- expect(d1).toBe(d2);
210
- });
211
-
212
- test("computeDigest differs for different inputs", () => {
213
- const d1 = computeDigest(SAMPLE_BUNDLE_BYTES);
214
- const d2 = computeDigest(TAMPERED_BUNDLE_BYTES);
215
- expect(d1).not.toBe(d2);
216
- });
217
-
218
- test("verifyDigest succeeds when digest matches", () => {
219
- const result = verifyDigest(SAMPLE_BUNDLE_BYTES, SAMPLE_BUNDLE_DIGEST);
220
- expect(result.valid).toBe(true);
221
- expect(result.computedDigest).toBe(SAMPLE_BUNDLE_DIGEST);
222
- expect(result.expectedDigest).toBe(SAMPLE_BUNDLE_DIGEST);
223
- expect(result.error).toBeUndefined();
224
- });
225
-
226
- test("verifyDigest fails when digest does not match", () => {
227
- const wrongDigest = computeDigest(TAMPERED_BUNDLE_BYTES);
228
- const result = verifyDigest(SAMPLE_BUNDLE_BYTES, wrongDigest);
229
- expect(result.valid).toBe(false);
230
- expect(result.computedDigest).toBe(SAMPLE_BUNDLE_DIGEST);
231
- expect(result.expectedDigest).toBe(wrongDigest);
232
- expect(result.error).toContain("Digest mismatch");
233
- });
234
-
235
- test("verifyDigest fails for invalid hex digest", () => {
236
- const result = verifyDigest(SAMPLE_BUNDLE_BYTES, "not-a-valid-hex-digest");
237
- expect(result.valid).toBe(false);
238
- expect(result.error).toContain("Digest mismatch");
239
- });
240
-
241
- test("verifyDigest fails for truncated digest", () => {
242
- const truncated = SAMPLE_BUNDLE_DIGEST.slice(0, 32);
243
- const result = verifyDigest(SAMPLE_BUNDLE_BYTES, truncated);
244
- expect(result.valid).toBe(false);
245
- expect(result.error).toContain("Digest mismatch");
246
- });
247
- });
248
-
249
- // ---------------------------------------------------------------------------
250
- // Manifest validation helpers
251
- // ---------------------------------------------------------------------------
252
-
253
- describe("manifest validation helpers", () => {
254
- describe("isValidSha256Hex", () => {
255
- test("accepts valid 64-char hex digest", () => {
256
- expect(isValidSha256Hex(SAMPLE_BUNDLE_DIGEST)).toBe(true);
257
- });
258
-
259
- test("accepts all-zero digest", () => {
260
- expect(isValidSha256Hex("0".repeat(64))).toBe(true);
261
- });
262
-
263
- test("rejects too-short digest", () => {
264
- expect(isValidSha256Hex("abc123")).toBe(false);
265
- });
266
-
267
- test("rejects too-long digest", () => {
268
- expect(isValidSha256Hex("a".repeat(65))).toBe(false);
269
- });
270
-
271
- test("rejects uppercase hex", () => {
272
- expect(isValidSha256Hex("A".repeat(64))).toBe(false);
273
- });
274
-
275
- test("rejects non-hex characters", () => {
276
- expect(isValidSha256Hex("g".repeat(64))).toBe(false);
277
- });
278
-
279
- test("rejects empty string", () => {
280
- expect(isValidSha256Hex("")).toBe(false);
281
- });
282
- });
283
-
284
- describe("validateSourceUrl", () => {
285
- test("accepts valid HTTPS URL", () => {
286
- expect(
287
- validateSourceUrl("https://releases.example.com/v1/bundle.tar.gz"),
288
- ).toBeNull();
289
- });
290
-
291
- test("rejects empty string", () => {
292
- const err = validateSourceUrl("");
293
- expect(err).not.toBeNull();
294
- expect(err).toContain("required");
295
- });
296
-
297
- test("rejects file:// URL", () => {
298
- const err = validateSourceUrl("file:///tmp/bundle.tar.gz");
299
- expect(err).not.toBeNull();
300
- expect(err).toContain("file:");
301
- });
302
-
303
- test("rejects data: URL", () => {
304
- const err = validateSourceUrl(
305
- "data:application/octet-stream;base64,AA==",
306
- );
307
- expect(err).not.toBeNull();
308
- expect(err).toContain("data:");
309
- });
310
-
311
- test("rejects HTTP URL (not HTTPS)", () => {
312
- const err = validateSourceUrl("http://example.com/bundle.tar.gz");
313
- expect(err).not.toBeNull();
314
- expect(err).toContain("HTTPS");
315
- });
316
-
317
- test("rejects non-URL string", () => {
318
- const err = validateSourceUrl("/usr/local/bin/my-tool");
319
- expect(err).not.toBeNull();
320
- expect(err).toContain("not a valid URL");
321
- });
322
- });
323
-
324
- describe("isWorkspaceOriginPath", () => {
325
- test("detects .vellum/ paths", () => {
326
- expect(isWorkspaceOriginPath("~/.vellum/workspace/tools/my-tool")).toBe(
327
- true,
328
- );
329
- expect(isWorkspaceOriginPath(".vellum/workspace/tools/my-tool")).toBe(
330
- true,
331
- );
332
- expect(
333
- isWorkspaceOriginPath("/home/user/.vellum/workspace/tools/my-tool"),
334
- ).toBe(true);
335
- });
336
-
337
- test("detects workspace paths", () => {
338
- expect(isWorkspaceOriginPath("/some/path/workspace/tools/my-tool")).toBe(
339
- true,
340
- );
341
- });
342
-
343
- test("allows non-workspace paths", () => {
344
- expect(isWorkspaceOriginPath("/usr/local/bin/gh")).toBe(false);
345
- expect(isWorkspaceOriginPath("/opt/tools/my-cli")).toBe(false);
346
- });
347
- });
348
- });
349
-
350
- // ---------------------------------------------------------------------------
351
- // Publisher: digest mismatch rejection
352
- // ---------------------------------------------------------------------------
353
-
354
- describe("publishBundle — digest mismatch rejection", () => {
355
- test("rejects bundle whose bytes do not match expectedDigest", () => {
356
- const result = publishBundle(
357
- buildPublishRequest({
358
- bundleBytes: TAMPERED_BUNDLE_BYTES,
359
- // expectedDigest is for SAMPLE_BUNDLE_BYTES, not TAMPERED_BUNDLE_BYTES
360
- expectedDigest: SAMPLE_BUNDLE_DIGEST,
361
- }),
362
- );
363
- expect(result.success).toBe(false);
364
- expect(result.deduplicated).toBe(false);
365
- expect(result.error).toContain("Digest mismatch");
366
- });
367
-
368
- test("rejects bundle with invalid digest format", () => {
369
- const result = publishBundle(
370
- buildPublishRequest({
371
- expectedDigest: "not-a-valid-digest",
372
- }),
373
- );
374
- expect(result.success).toBe(false);
375
- expect(result.error).toContain("Invalid expectedDigest");
376
- });
377
-
378
- test("no files are written when digest mismatches", () => {
379
- publishBundle(
380
- buildPublishRequest({
381
- bundleBytes: TAMPERED_BUNDLE_BYTES,
382
- expectedDigest: SAMPLE_BUNDLE_DIGEST,
383
- }),
384
- );
385
-
386
- // The toolstore directory should not contain a directory for the expected digest
387
- const toolstoreDir = join(
388
- testTmpDir,
389
- ".vellum",
390
- "protected",
391
- "credential-executor",
392
- "toolstore",
393
- );
394
- const bundleDir = join(toolstoreDir, SAMPLE_BUNDLE_DIGEST);
395
- expect(existsSync(bundleDir)).toBe(false);
396
-
397
- // Also check that no staging directories were left behind
398
- if (existsSync(toolstoreDir)) {
399
- const entries = readdirSync(toolstoreDir) as string[];
400
- const stagingDirs = entries.filter((e: string) =>
401
- e.startsWith(".staging-"),
402
- );
403
- expect(stagingDirs).toHaveLength(0);
404
- }
405
- });
406
- });
407
-
408
- // ---------------------------------------------------------------------------
409
- // Publisher: immutable and deduplicated
410
- // ---------------------------------------------------------------------------
411
-
412
- describe("publishBundle — immutable and deduplicated by digest", () => {
413
- test("first publish succeeds with deduplicated=false", () => {
414
- const result = publishBundle(buildPublishRequest());
415
- expect(result.success).toBe(true);
416
- expect(result.deduplicated).toBe(false);
417
- expect(result.bundlePath).toContain(SAMPLE_BUNDLE_DIGEST);
418
- });
419
-
420
- test("second publish of same digest returns deduplicated=true", () => {
421
- const first = publishBundle(buildPublishRequest());
422
- expect(first.success).toBe(true);
423
- expect(first.deduplicated).toBe(false);
424
-
425
- const second = publishBundle(buildPublishRequest());
426
- expect(second.success).toBe(true);
427
- expect(second.deduplicated).toBe(true);
428
- expect(second.bundlePath).toBe(first.bundlePath);
429
- });
430
-
431
- test("published bundle contains extracted entrypoint (not raw archive)", () => {
432
- const result = publishBundle(buildPublishRequest());
433
- expect(result.success).toBe(true);
434
-
435
- // After extraction, bundle.bin is removed and replaced by extracted contents
436
- const bundleContentPath = join(result.bundlePath, "bundle.bin");
437
- expect(existsSync(bundleContentPath)).toBe(false);
438
-
439
- // The entrypoint should exist and be executable
440
- const entrypointPath = join(result.bundlePath, "bin", "test-cli");
441
- expect(existsSync(entrypointPath)).toBe(true);
442
-
443
- const content = readFileSync(entrypointPath, "utf-8");
444
- expect(content).toContain("echo hello");
445
- });
446
-
447
- test("published manifest is readable and has correct fields", () => {
448
- publishBundle(buildPublishRequest());
449
-
450
- const manifest = readPublishedManifest(SAMPLE_BUNDLE_DIGEST);
451
- expect(manifest).not.toBeNull();
452
- expect(manifest!.digest).toBe(SAMPLE_BUNDLE_DIGEST);
453
- expect(manifest!.bundleId).toBe("test-cli");
454
- expect(manifest!.version).toBe("1.0.0");
455
- expect(manifest!.origin.sourceUrl).toBe(
456
- "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
457
- );
458
- expect(manifest!.declaredProfiles).toEqual(["read-data"]);
459
- expect(manifest!.publishedAt).toBeTruthy();
460
- });
461
-
462
- test("isBundlePublished returns false before publish", () => {
463
- expect(isBundlePublished(SAMPLE_BUNDLE_DIGEST)).toBe(false);
464
- });
465
-
466
- test("isBundlePublished returns true after publish", () => {
467
- publishBundle(buildPublishRequest());
468
- expect(isBundlePublished(SAMPLE_BUNDLE_DIGEST)).toBe(true);
469
- });
470
-
471
- test("different bundles with different digests are stored independently", () => {
472
- // Publish first bundle
473
- const firstResult = publishBundle(buildPublishRequest());
474
- expect(firstResult.success).toBe(true);
475
-
476
- // Publish a second, different bundle (real tar.gz archive)
477
- const otherBytes = createTestArchive(
478
- "bin/test-cli",
479
- "#!/usr/bin/env bash\necho other\n",
480
- );
481
- const otherDigest = computeDigest(otherBytes);
482
- const otherManifest = buildSecureManifest({
483
- bundleDigest: otherDigest,
484
- bundleId: "other-cli",
485
- version: "2.0.0",
486
- });
487
-
488
- const secondResult = publishBundle(
489
- buildPublishRequest({
490
- bundleBytes: otherBytes,
491
- expectedDigest: otherDigest,
492
- bundleId: "other-cli",
493
- version: "2.0.0",
494
- sourceUrl:
495
- "https://releases.example.com/other-cli/v2.0.0/bundle.tar.gz",
496
- secureCommandManifest: otherManifest,
497
- }),
498
- );
499
- expect(secondResult.success).toBe(true);
500
- expect(secondResult.deduplicated).toBe(false);
501
- expect(secondResult.bundlePath).not.toBe(firstResult.bundlePath);
502
-
503
- // Both are independently published
504
- expect(isBundlePublished(SAMPLE_BUNDLE_DIGEST)).toBe(true);
505
- expect(isBundlePublished(otherDigest)).toBe(true);
506
- });
507
- });
508
-
509
- // ---------------------------------------------------------------------------
510
- // Publisher: publication does not imply credential grant
511
- // ---------------------------------------------------------------------------
512
-
513
- describe("publishBundle — publication does not imply credential grant", () => {
514
- test("publish result does not contain any grant or credential fields", () => {
515
- const result = publishBundle(buildPublishRequest());
516
- expect(result.success).toBe(true);
517
-
518
- // The PublishResult type only has success, deduplicated, bundlePath, error.
519
- // There are no grant-related fields.
520
- const keys = Object.keys(result);
521
- expect(keys).not.toContain("grant");
522
- expect(keys).not.toContain("credential");
523
- expect(keys).not.toContain("token");
524
- expect(keys).not.toContain("secret");
525
- });
526
-
527
- test("toolstore manifest does not contain grant or credential data", () => {
528
- publishBundle(buildPublishRequest());
529
-
530
- const manifest = readPublishedManifest(SAMPLE_BUNDLE_DIGEST);
531
- expect(manifest).not.toBeNull();
532
-
533
- // Verify the manifest only contains content metadata, not grants
534
- const keys = Object.keys(manifest!);
535
- expect(keys).not.toContain("grant");
536
- expect(keys).not.toContain("credential");
537
- expect(keys).not.toContain("token");
538
- expect(keys).not.toContain("secret");
539
- expect(keys).toContain("digest");
540
- expect(keys).toContain("bundleId");
541
- expect(keys).toContain("origin");
542
- expect(keys).toContain("declaredProfiles");
543
- });
544
- });
545
-
546
- // ---------------------------------------------------------------------------
547
- // Publisher: source URL validation
548
- // ---------------------------------------------------------------------------
549
-
550
- describe("publishBundle — source URL validation", () => {
551
- test("rejects file:// source URL", () => {
552
- const result = publishBundle(
553
- buildPublishRequest({
554
- sourceUrl: "file:///tmp/bundle.tar.gz",
555
- }),
556
- );
557
- expect(result.success).toBe(false);
558
- expect(result.error).toContain("file:");
559
- });
560
-
561
- test("rejects HTTP source URL", () => {
562
- const result = publishBundle(
563
- buildPublishRequest({
564
- sourceUrl: "http://insecure.example.com/bundle.tar.gz",
565
- }),
566
- );
567
- expect(result.success).toBe(false);
568
- expect(result.error).toContain("HTTPS");
569
- });
570
-
571
- test("rejects workspace-origin source URL", () => {
572
- const result = publishBundle(
573
- buildPublishRequest({
574
- sourceUrl: "https://example.com/.vellum/workspace/tools/bundle.tar.gz",
575
- }),
576
- );
577
- expect(result.success).toBe(false);
578
- expect(result.error).toContain("workspace-origin");
579
- });
580
-
581
- test("rejects empty source URL", () => {
582
- const result = publishBundle(
583
- buildPublishRequest({
584
- sourceUrl: "",
585
- }),
586
- );
587
- expect(result.success).toBe(false);
588
- expect(result.error).toContain("required");
589
- });
590
- });
591
-
592
- // ---------------------------------------------------------------------------
593
- // Publisher: manifest validation pass-through
594
- // ---------------------------------------------------------------------------
595
-
596
- describe("publishBundle — manifest validation", () => {
597
- test("rejects bundle with invalid secure command manifest", () => {
598
- const invalidManifest = buildSecureManifest({
599
- entrypoint: "/bin/bash", // denied binary
600
- });
601
-
602
- const result = publishBundle(
603
- buildPublishRequest({
604
- secureCommandManifest: invalidManifest,
605
- }),
606
- );
607
- expect(result.success).toBe(false);
608
- expect(result.error).toContain("Invalid secure command manifest");
609
- expect(result.error).toContain("bash");
610
- });
611
-
612
- test("rejects bundle with empty command profiles", () => {
613
- const invalidManifest = buildSecureManifest({
614
- commandProfiles: {},
615
- });
616
-
617
- const result = publishBundle(
618
- buildPublishRequest({
619
- secureCommandManifest: invalidManifest,
620
- }),
621
- );
622
- expect(result.success).toBe(false);
623
- expect(result.error).toContain("Invalid secure command manifest");
624
- });
625
- });
626
-
627
- // ---------------------------------------------------------------------------
628
- // Publisher: symlink escape prevention
629
- // ---------------------------------------------------------------------------
630
-
631
- describe("publishBundle — symlink escape prevention", () => {
632
- test("rejects bundle with symlink entrypoint pointing outside bundle", () => {
633
- // Create a tar.gz with a symlink entrypoint: bin/test-cli -> /usr/bin/curl
634
- const symlinkBytes = createSymlinkArchive("bin/test-cli", "/usr/bin/curl");
635
- const symlinkDigest = computeDigest(symlinkBytes);
636
-
637
- const manifest = buildSecureManifest({
638
- bundleDigest: symlinkDigest,
639
- });
640
-
641
- const result = publishBundle({
642
- bundleBytes: symlinkBytes,
643
- expectedDigest: symlinkDigest,
644
- bundleId: "test-cli",
645
- version: "1.0.0",
646
- sourceUrl: "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
647
- secureCommandManifest: manifest,
648
- });
649
-
650
- expect(result.success).toBe(false);
651
- expect(result.error).toContain("symlink");
652
- });
653
-
654
- test("rejects bundle with non-entrypoint symlink pointing outside bundle", () => {
655
- // Create an archive where a non-entrypoint file is a symlink to an external path.
656
- // The entrypoint itself is a real file, but the bundle contains a sneaky symlink.
657
- const stagingDir = join(tmpdir(), `ces-test-mixed-symlink-${randomUUID()}`);
658
- try {
659
- // Create a real entrypoint
660
- const entrypointPath = join(stagingDir, "bin/test-cli");
661
- mkdirSync(join(stagingDir, "bin"), { recursive: true });
662
- writeFileSync(entrypointPath, "#!/usr/bin/env bash\necho hello\n", {
663
- mode: 0o755,
664
- });
665
-
666
- // Create a symlink that escapes
667
- symlinkSync("/etc/passwd", join(stagingDir, "bin/evil-link"));
668
-
669
- const archivePath = join(stagingDir, "bundle.tar.gz");
670
- const proc = Bun.spawnSync(
671
- ["tar", "czf", archivePath, "-C", stagingDir, "bin"],
672
- { stdout: "pipe", stderr: "pipe" },
673
- );
674
- expect(proc.exitCode).toBe(0);
675
-
676
- const bundleBytes = Buffer.from(readFileSync(archivePath));
677
- const digest = computeDigest(bundleBytes);
678
-
679
- const manifest = buildSecureManifest({
680
- bundleDigest: digest,
681
- });
682
-
683
- const result = publishBundle({
684
- bundleBytes,
685
- expectedDigest: digest,
686
- bundleId: "test-cli",
687
- version: "1.0.0",
688
- sourceUrl: "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
689
- secureCommandManifest: manifest,
690
- });
691
-
692
- expect(result.success).toBe(false);
693
- expect(result.error).toContain("symlink");
694
- expect(result.error).toContain("outside the bundle directory");
695
- } finally {
696
- try {
697
- rmSync(stagingDir, { recursive: true, force: true });
698
- } catch {
699
- /* best effort */
700
- }
701
- }
702
- });
703
-
704
- test("accepts bundle with internal symlinks (not escaping)", () => {
705
- // Create an archive with a symlink that points within the bundle
706
- const stagingDir = join(
707
- tmpdir(),
708
- `ces-test-internal-symlink-${randomUUID()}`,
709
- );
710
- try {
711
- mkdirSync(join(stagingDir, "bin"), { recursive: true });
712
- writeFileSync(
713
- join(stagingDir, "bin/test-cli"),
714
- "#!/usr/bin/env bash\necho hello\n",
715
- { mode: 0o755 },
716
- );
717
- // Create a symlink within the bundle: bin/alias -> test-cli (relative)
718
- symlinkSync("test-cli", join(stagingDir, "bin/alias"));
719
-
720
- const archivePath = join(stagingDir, "bundle.tar.gz");
721
- const proc = Bun.spawnSync(
722
- ["tar", "czf", archivePath, "-C", stagingDir, "bin"],
723
- { stdout: "pipe", stderr: "pipe" },
724
- );
725
- expect(proc.exitCode).toBe(0);
726
-
727
- const bundleBytes = Buffer.from(readFileSync(archivePath));
728
- const digest = computeDigest(bundleBytes);
729
-
730
- const manifest = buildSecureManifest({
731
- bundleDigest: digest,
732
- });
733
-
734
- const result = publishBundle({
735
- bundleBytes,
736
- expectedDigest: digest,
737
- bundleId: "test-cli",
738
- version: "1.0.0",
739
- sourceUrl: "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
740
- secureCommandManifest: manifest,
741
- });
742
-
743
- expect(result.success).toBe(true);
744
- } finally {
745
- try {
746
- rmSync(stagingDir, { recursive: true, force: true });
747
- } catch {
748
- /* best effort */
749
- }
750
- }
751
- });
752
-
753
- test("no files are left in toolstore when symlink escape is detected", () => {
754
- const symlinkBytes = createSymlinkArchive("bin/test-cli", "/usr/bin/curl");
755
- const symlinkDigest = computeDigest(symlinkBytes);
756
-
757
- const manifest = buildSecureManifest({
758
- bundleDigest: symlinkDigest,
759
- });
760
-
761
- publishBundle({
762
- bundleBytes: symlinkBytes,
763
- expectedDigest: symlinkDigest,
764
- bundleId: "test-cli",
765
- version: "1.0.0",
766
- sourceUrl: "https://releases.example.com/test-cli/v1.0.0/bundle.tar.gz",
767
- secureCommandManifest: manifest,
768
- });
769
-
770
- // The bundle should not be published
771
- expect(isBundlePublished(symlinkDigest)).toBe(false);
772
- });
773
- });