@vellumai/cli 0.10.5-dev.202607022329.d89bf2f → 0.10.5-dev.202607030140.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.
- package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +128 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/upgrade.ts +43 -0
- package/package.json +1 -1
- package/src/__tests__/local-runtime-version.test.ts +42 -0
- package/src/lib/local.ts +14 -0
|
@@ -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
|
@@ -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}`;
|