@vellumai/cli 0.10.5-dev.202607022329.d89bf2f → 0.10.5-dev.202607030052.c8c2585

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.
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import type { CliInvocation } from "../util";
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new EventEmitter();
8
+ stderr = new EventEmitter();
9
+ kill = mock(() => true);
10
+ }
11
+
12
+ let lastChild: FakeChild;
13
+ const spawnArgs: Array<
14
+ [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
15
+ > = [];
16
+ const spawnMock = mock(
17
+ (
18
+ command: string,
19
+ args: string[],
20
+ options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
21
+ ) => {
22
+ spawnArgs.push([command, args, options]);
23
+ lastChild = new FakeChild();
24
+ return lastChild;
25
+ },
26
+ );
27
+
28
+ mock.module("node:child_process", () => ({ spawn: spawnMock }));
29
+
30
+ let runUpgrade: typeof import("../upgrade").runUpgrade;
31
+ let isValidReleaseVersion: typeof import("../upgrade").isValidReleaseVersion;
32
+
33
+ beforeAll(async () => {
34
+ ({ runUpgrade, isValidReleaseVersion } = await import("../upgrade"));
35
+ });
36
+
37
+ afterEach(() => {
38
+ spawnArgs.length = 0;
39
+ spawnMock.mockClear();
40
+ });
41
+
42
+ const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
43
+
44
+ describe("isValidReleaseVersion", () => {
45
+ test("accepts release tags, staying lenient about semver shape", () => {
46
+ for (const version of [
47
+ "latest",
48
+ "v1.2.3",
49
+ "V1.2.3", // uppercase prefix
50
+ "1.2.3",
51
+ "1.2", // two-part
52
+ "1.2.3.4", // four-part
53
+ "0.6.0-staging.5",
54
+ "1.2.3-rc.1+build.7",
55
+ ]) {
56
+ expect(isValidReleaseVersion(version)).toBe(true);
57
+ }
58
+ });
59
+
60
+ test("rejects package specs and path-traversal input", () => {
61
+ for (const version of [
62
+ "npm:@attacker/evil@1.0.0",
63
+ "https://evil.example/x.tgz",
64
+ "git+https://evil.example/x.git",
65
+ "github:attacker/vellum",
66
+ "file:/tmp/evil",
67
+ "../../../../tmp/evil",
68
+ "1.2.3-..", // no consecutive dots (would be a traversal segment)
69
+ "1.2.3-a..b",
70
+ "..",
71
+ "",
72
+ "vellum@1.2.3",
73
+ ]) {
74
+ expect(isValidReleaseVersion(version)).toBe(false);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe("runUpgrade", () => {
80
+ test("spawns the CLI upgrade command for a release tag", async () => {
81
+ const pending = runUpgrade(invocation, "asst-42", { version: "v1.2.3" });
82
+ lastChild.emit("close", 0);
83
+
84
+ expect(await pending).toEqual({ ok: true });
85
+ expect(spawnArgs[0]).toEqual([
86
+ "bun",
87
+ ["run", "cli", "upgrade", "asst-42", "--version", "v1.2.3"],
88
+ { stdio: ["ignore", "pipe", "pipe"] },
89
+ ]);
90
+ });
91
+
92
+ test("spawns the CLI upgrade command for --latest", async () => {
93
+ const pending = runUpgrade(invocation, "asst-42", { latest: true });
94
+ lastChild.emit("close", 0);
95
+
96
+ expect(await pending).toEqual({ ok: true });
97
+ expect(spawnArgs[0]?.[1]).toEqual([
98
+ "run",
99
+ "cli",
100
+ "upgrade",
101
+ "asst-42",
102
+ "--latest",
103
+ ]);
104
+ });
105
+
106
+ test("rejects a malicious version without spawning", async () => {
107
+ const result = await runUpgrade(invocation, "asst-42", {
108
+ version: "npm:@attacker/evil@1.0.0",
109
+ });
110
+
111
+ expect(result).toEqual({
112
+ ok: false,
113
+ status: 400,
114
+ error:
115
+ "Invalid upgrade version 'npm:@attacker/evil@1.0.0': expected a release tag like v1.2.3 or 'latest'.",
116
+ });
117
+ expect(spawnArgs.length).toBe(0);
118
+ });
119
+
120
+ test("rejects a path-traversal version without spawning", async () => {
121
+ const result = await runUpgrade(invocation, "asst-42", {
122
+ version: "../../../../tmp/evil",
123
+ });
124
+
125
+ expect(result.ok).toBe(false);
126
+ expect(spawnArgs.length).toBe(0);
127
+ });
128
+ });
@@ -48,7 +48,7 @@ export { runSleep } from "./sleep";
48
48
  export type { SleepResult } from "./sleep";
49
49
  export { runWake } from "./wake";
50
50
  export type { WakeOptions, WakeResult } from "./wake";
51
- export { runUpgrade } from "./upgrade";
51
+ export { runUpgrade, isValidReleaseVersion } from "./upgrade";
52
52
  export type { UpgradeOptions, UpgradeResult } from "./upgrade";
53
53
  export { getLocalAssistantStatus } from "./status";
54
54
  export type {
@@ -14,6 +14,37 @@ export interface UpgradeOptions {
14
14
  force?: boolean;
15
15
  }
16
16
 
17
+ // A trusted release version is the literal `latest` dist-tag, or a
18
+ // version-shaped string: an optional `v`/`V` prefix, then a leading digit,
19
+ // then any run of `[0-9A-Za-z.+-]`. This is intentionally lenient about the
20
+ // *shape* of the version (2-, 3-, or 4-part, arbitrary pre-release/build
21
+ // identifiers, uppercase `V`) so we never reject a legitimate release tag.
22
+ //
23
+ // Security does not depend on the shape — it depends on the CHARSET. The
24
+ // allowed set excludes `/`, `\`, `:`, `@`, `#` and whitespace, so the value can
25
+ // never be interpreted by `bun install` as a package-manager spec (npm alias,
26
+ // tarball/git URL, `file:`/`github:` ref) — all of which require one of those
27
+ // characters. The mandatory leading digit means the value can never be a bare
28
+ // `.`/`..` path segment, and `..` is rejected outright, so the version is safe
29
+ // to use as the runtime-install directory name.
30
+ const RELEASE_VERSION_PATTERN = /^(?:latest|[vV]?\d[0-9A-Za-z.+-]*)$/;
31
+
32
+ /**
33
+ * Whether `version` is a trusted release identifier: the literal `latest`, or a
34
+ * version-shaped tag like `v1.2.3` / `1.2.3` / `0.6.0-staging.5`. Rejects
35
+ * package-manager specifiers (npm aliases, tarball or git URLs) and any
36
+ * path-traversal-like input, while staying permissive about semver shape.
37
+ *
38
+ * Defined once here and reused by both the host-bridge boundary guard
39
+ * (`runUpgrade`, in the shared library backing the Electron host and the web
40
+ * dev-server middleware) and the CLI's runtime-install sink
41
+ * (`ensureLocalRuntime`), so the security boundary can never drift between the
42
+ * two call sites.
43
+ */
44
+ export function isValidReleaseVersion(version: string): boolean {
45
+ return RELEASE_VERSION_PATTERN.test(version) && !version.includes("..");
46
+ }
47
+
17
48
  function extractVersion(output: string): string | undefined {
18
49
  const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
19
50
  const upgraded = output.match(
@@ -35,6 +66,18 @@ export function runUpgrade(
35
66
  options?: UpgradeOptions,
36
67
  ): Promise<UpgradeResult> {
37
68
  return new Promise((resolve) => {
69
+ // Reject an untrusted `--version` at the host boundary before spawning the
70
+ // CLI. `latest` (or the `--latest` flag) is always allowed; an explicit
71
+ // version must be a release tag. Preserves the never-reject contract.
72
+ if (options?.version && !isValidReleaseVersion(options.version)) {
73
+ resolve({
74
+ ok: false,
75
+ status: 400,
76
+ error: `Invalid upgrade version '${options.version}': expected a release tag like v1.2.3 or 'latest'.`,
77
+ });
78
+ return;
79
+ }
80
+
38
81
  const args = [...invocation.baseArgs, "upgrade", assistantId];
39
82
  if (options?.latest) {
40
83
  args.push("--latest");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.5-dev.202607022329.d89bf2f",
3
+ "version": "0.10.5-dev.202607030052.c8c2585",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { isValidReleaseVersion } from "@vellumai/local-mode";
4
+
5
+ import type { LocalInstanceResources } from "../lib/assistant-config.js";
6
+ import { ensureLocalRuntime } from "../lib/local.js";
7
+
8
+ // Only `instanceDir` is read, and only after the validation guard passes —
9
+ // invalid versions throw before any filesystem access, so a minimal stub is
10
+ // sufficient for the rejection cases.
11
+ const resources = {
12
+ instanceDir: "/tmp/vellum-nonexistent-instance",
13
+ } as unknown as LocalInstanceResources;
14
+
15
+ const VALID_VERSIONS = ["latest", "v1.2.3", "1.2.3", "0.6.0-staging.5"];
16
+ const MALICIOUS_VERSIONS = [
17
+ "npm:@attacker/evil@1.0.0",
18
+ "https://evil.example/x.tgz",
19
+ "git+https://evil.example/x.git",
20
+ "../../../../tmp/evil",
21
+ "1.2.3-..",
22
+ "1.2.3-a..b",
23
+ ];
24
+
25
+ describe("ensureLocalRuntime version guard", () => {
26
+ test("shares the trusted-release validator with the host boundary", () => {
27
+ for (const version of VALID_VERSIONS) {
28
+ expect(isValidReleaseVersion(version)).toBe(true);
29
+ }
30
+ for (const version of MALICIOUS_VERSIONS) {
31
+ expect(isValidReleaseVersion(version)).toBe(false);
32
+ }
33
+ });
34
+
35
+ test("throws before any install for untrusted versions", () => {
36
+ for (const version of MALICIOUS_VERSIONS) {
37
+ expect(() => ensureLocalRuntime(resources, version)).toThrow(
38
+ `Invalid runtime version '${version}'`,
39
+ );
40
+ }
41
+ });
42
+ });
package/src/lib/local.ts CHANGED
@@ -11,6 +11,8 @@ import { createRequire } from "module";
11
11
  import { homedir, networkInterfaces, platform, tmpdir } from "os";
12
12
  import { basename, dirname, join } from "path";
13
13
 
14
+ import { isValidReleaseVersion } from "@vellumai/local-mode";
15
+
14
16
  import {
15
17
  getDaemonPidPath,
16
18
  type LocalInstanceResources,
@@ -148,6 +150,18 @@ export function ensureLocalRuntime(
148
150
  version: string,
149
151
  options: { force?: boolean } = {},
150
152
  ): LocalRuntimeInstall {
153
+ // Reject anything that is not a trusted release identifier BEFORE it becomes
154
+ // a filesystem path segment or a `bun install` dependency spec. Without this,
155
+ // a package-manager spec (npm alias, tarball/git URL) or a `../`-laden string
156
+ // reaching this sink would install and then execute arbitrary attacker code
157
+ // as the local assistant runtime. Shares the validator with the host-bridge
158
+ // boundary guard (`runUpgrade`) so the two can never drift.
159
+ if (!isValidReleaseVersion(version)) {
160
+ throw new Error(
161
+ `Invalid runtime version '${version}': expected a release tag like v1.2.3 or 'latest'.`,
162
+ );
163
+ }
164
+
151
165
  const normalizedVersion = normalizeRuntimeVersion(version);
152
166
  const displayVersion =
153
167
  normalizedVersion === "latest" ? "latest" : `v${normalizedVersion}`;