@0dai-dev/cli 3.3.1 → 3.3.3
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/bin/0dai.js +79 -20
- package/package.json +1 -1
package/bin/0dai.js
CHANGED
|
@@ -8,7 +8,26 @@ const path = require("path");
|
|
|
8
8
|
const os = require("os");
|
|
9
9
|
|
|
10
10
|
const VERSION = require("../package.json").version;
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
// CRITICAL: validate API_URL to prevent credential exfiltration to attacker hosts
|
|
13
|
+
function _validateApiUrl(url) {
|
|
14
|
+
const DEFAULT = "https://api.0dai.dev";
|
|
15
|
+
if (!url) return DEFAULT;
|
|
16
|
+
try {
|
|
17
|
+
const u = new URL(url);
|
|
18
|
+
// Only allow https (or http for localhost during dev)
|
|
19
|
+
if (u.protocol === "https:") return url;
|
|
20
|
+
if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
console.error(`[0dai] warning: ODAI_API_URL must use https (or http://localhost). Got: ${u.protocol}//${u.hostname}. Ignoring.`);
|
|
24
|
+
return DEFAULT;
|
|
25
|
+
} catch {
|
|
26
|
+
console.error(`[0dai] warning: ODAI_API_URL is not a valid URL, ignoring`);
|
|
27
|
+
return DEFAULT;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const API_URL = _validateApiUrl(process.env.ODAI_API_URL);
|
|
12
31
|
const T = process.stdout.isTTY ? "\x1b[38;2;45;212;168m" : ""; // teal
|
|
13
32
|
const R = process.stdout.isTTY ? "\x1b[0m" : ""; // reset
|
|
14
33
|
const D = process.stdout.isTTY ? "\x1b[2m" : ""; // dim
|
|
@@ -19,10 +38,20 @@ const VERSION_CHECK_FILE = path.join(CONFIG_DIR, ".version_check");
|
|
|
19
38
|
const PROJECTS_FILE = path.join(CONFIG_DIR, "projects.json");
|
|
20
39
|
|
|
21
40
|
const MANIFEST_FILES = [
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"next.config.
|
|
25
|
-
"
|
|
41
|
+
// Node ecosystem
|
|
42
|
+
"package.json", "tsconfig.json",
|
|
43
|
+
"next.config.js", "next.config.mjs", "next.config.ts",
|
|
44
|
+
"vite.config.js", "vite.config.ts", "vite.config.mjs",
|
|
45
|
+
"vue.config.js", "nuxt.config.js", "nuxt.config.ts",
|
|
46
|
+
"svelte.config.js", "astro.config.mjs", "astro.config.ts",
|
|
47
|
+
"remix.config.js", "angular.json",
|
|
48
|
+
// Other languages
|
|
49
|
+
"go.mod", "pyproject.toml", "requirements.txt", "setup.py",
|
|
50
|
+
"pubspec.yaml", "Cargo.toml", "pom.xml", "build.gradle",
|
|
51
|
+
"Gemfile", "composer.json",
|
|
52
|
+
// Build/deploy
|
|
53
|
+
"Makefile", "docker-compose.yml", "Dockerfile",
|
|
54
|
+
"pnpm-workspace.yaml", "lerna.json", "turbo.json", "nx.json",
|
|
26
55
|
];
|
|
27
56
|
|
|
28
57
|
const PROBE_DIRS = [
|
|
@@ -321,17 +350,20 @@ function mergeSettingsJson(existing, incoming) {
|
|
|
321
350
|
} catch { return incoming; }
|
|
322
351
|
}
|
|
323
352
|
|
|
324
|
-
function mergeAgentsMd(existing, incoming) {
|
|
325
|
-
// If user marked as unmanaged, don't touch
|
|
326
|
-
if (existing.includes("managed: false")) return existing;
|
|
327
|
-
// If managed, update with new content but preserve user additions after managed block
|
|
328
|
-
return incoming;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
353
|
function writeFiles(target, files) {
|
|
332
|
-
let created = 0, updated = 0, unchanged = 0, merged = 0;
|
|
354
|
+
let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
|
|
355
|
+
const targetResolved = path.resolve(target);
|
|
333
356
|
for (const [rel, content] of Object.entries(files)) {
|
|
334
|
-
|
|
357
|
+
// HIGH: path traversal protection — reject absolute paths and `..` escapes
|
|
358
|
+
if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
|
|
359
|
+
skipped++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const p = path.resolve(targetResolved, rel);
|
|
363
|
+
if (!p.startsWith(targetResolved + path.sep) && p !== targetResolved) {
|
|
364
|
+
skipped++;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
335
367
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
336
368
|
|
|
337
369
|
let finalContent = content;
|
|
@@ -365,6 +397,7 @@ function writeFiles(target, files) {
|
|
|
365
397
|
}
|
|
366
398
|
const parts = [`${created} created`, `${updated} updated`, `${unchanged} unchanged`];
|
|
367
399
|
if (merged) parts.push(`${merged} merged`);
|
|
400
|
+
if (skipped) parts.push(`${skipped} skipped (unsafe path)`);
|
|
368
401
|
log(parts.join(", "));
|
|
369
402
|
return created + updated;
|
|
370
403
|
}
|
|
@@ -566,11 +599,17 @@ async function cmdSync(target, args = []) {
|
|
|
566
599
|
|
|
567
600
|
async function cmdDetect(target) {
|
|
568
601
|
const OPTIONAL_CLIS = ["gemini", "aider", "opencode"];
|
|
569
|
-
const { projectFiles } = collectMetadata(target);
|
|
570
|
-
|
|
602
|
+
const { projectFiles, fileContents, clis: localClis } = collectMetadata(target);
|
|
603
|
+
// Send file contents AND local CLI inventory so server can do content-based detection
|
|
604
|
+
const result = await apiCall("/v1/detect", {
|
|
605
|
+
project_files: projectFiles,
|
|
606
|
+
file_contents: fileContents,
|
|
607
|
+
available_clis: localClis,
|
|
608
|
+
});
|
|
571
609
|
if (result.error) { log(`error: ${result.error}`); return; }
|
|
572
610
|
console.log(`stack: ${result.stack || "?"}`);
|
|
573
|
-
|
|
611
|
+
// Use local CLIs if server didn't return any (server can't detect locally installed binaries)
|
|
612
|
+
const clis = (result.available_clis && result.available_clis.length && result.available_clis[0]) ? result.available_clis : localClis;
|
|
574
613
|
if (clis.length) {
|
|
575
614
|
console.log(`clis: ${clis.join(", ")}`);
|
|
576
615
|
} else {
|
|
@@ -1446,9 +1485,10 @@ async function cmdAuthLogin() {
|
|
|
1446
1485
|
const url = `${API_URL}/v1/auth/${method}?cli=true`;
|
|
1447
1486
|
p.log.info(`Opening browser: ${url}`);
|
|
1448
1487
|
try {
|
|
1449
|
-
const {
|
|
1488
|
+
const { execFileSync } = require("child_process");
|
|
1450
1489
|
const cmd = os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
|
|
1451
|
-
|
|
1490
|
+
// MED: use execFileSync to avoid shell injection via URL metacharacters
|
|
1491
|
+
execFileSync(cmd, [url], { stdio: "ignore" });
|
|
1452
1492
|
} catch {
|
|
1453
1493
|
p.log.warn(`Could not open browser. Visit manually:\n ${url}`);
|
|
1454
1494
|
}
|
|
@@ -1585,7 +1625,9 @@ async function cmdRedeem(code) {
|
|
|
1585
1625
|
async function cmdAuthStatus() {
|
|
1586
1626
|
try {
|
|
1587
1627
|
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
1588
|
-
|
|
1628
|
+
// Backwards compat: old auth.json used `user`, new uses `email`
|
|
1629
|
+
const email = auth.email || auth.user || "unknown";
|
|
1630
|
+
log(`${email} (${auth.plan || "free"} plan)`);
|
|
1589
1631
|
// Get usage from API
|
|
1590
1632
|
const status = await apiCall("/v1/auth/status");
|
|
1591
1633
|
if (status.usage_today) {
|
|
@@ -1791,6 +1833,23 @@ function cmdSwarm(target, sub, args) {
|
|
|
1791
1833
|
const event = args.find((_, i) => args[i-1] === "--event") || "all";
|
|
1792
1834
|
const secret = args.find((_, i) => args[i-1] === "--secret") || "";
|
|
1793
1835
|
if (!url || !url.startsWith("http")) { log("Usage: 0dai swarm webhook add <url> [--event task_done|task_failed|all] [--secret TOKEN]"); return; }
|
|
1836
|
+
// MED: SSRF protection — block internal/metadata endpoints
|
|
1837
|
+
try {
|
|
1838
|
+
const u = new URL(url);
|
|
1839
|
+
const host = u.hostname;
|
|
1840
|
+
const BLOCKED = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|::1$|fc00:|fe80:|localhost$|0\.0\.0\.0$)/i;
|
|
1841
|
+
if (BLOCKED.test(host) || host === "metadata.google.internal") {
|
|
1842
|
+
log(`rejected: ${host} is a private/internal address (SSRF protection)`);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
1846
|
+
log(`rejected: only http/https allowed, got ${u.protocol}`);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
} catch {
|
|
1850
|
+
log(`invalid URL: ${url}`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1794
1853
|
const hooks = loadHooks();
|
|
1795
1854
|
if (hooks.find(h => h.url === url)) { log(`already registered: ${url}`); return; }
|
|
1796
1855
|
hooks.push({ url, event, secret: secret || undefined, added_at: new Date().toISOString() });
|