@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.
Files changed (2) hide show
  1. package/bin/0dai.js +79 -20
  2. 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
- const API_URL = process.env.ODAI_API_URL || "https://api.0dai.dev";
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
- "package.json", "go.mod", "pyproject.toml", "requirements.txt",
23
- "pubspec.yaml", "Cargo.toml", "next.config.js", "next.config.mjs",
24
- "next.config.ts", "tsconfig.json", "Makefile", "docker-compose.yml",
25
- "Dockerfile", "pom.xml", "build.gradle",
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
- const p = path.join(target, rel);
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
- const result = await apiCall("/v1/detect", { files: projectFiles });
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
- const clis = result.available_clis || [];
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 { execSync } = require("child_process");
1488
+ const { execFileSync } = require("child_process");
1450
1489
  const cmd = os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
1451
- execSync(`${cmd} "${url}"`, { stdio: "ignore" });
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
- log(`${auth.email} (${auth.plan} plan)`);
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() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "description": "One config layer for 5 AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"