@kernel.chat/kbot 2.25.0 → 2.27.0

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.
@@ -13,11 +13,56 @@
13
13
  // train_deploy — Deploy to Ollama, HuggingFace, or K:BOT local
14
14
  // train_cost — Estimate training cost, time, and VRAM
15
15
  import { execSync, spawn } from 'node:child_process';
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
17
17
  import { resolve, join, basename, extname, dirname } from 'node:path';
18
18
  import { homedir, cpus } from 'node:os';
19
19
  import { registerTool } from './index.js';
20
20
  // ── Helpers ──────────────────────────────────────────────────────────
21
+ /** Shell-escape a string for safe interpolation into shell commands */
22
+ function esc(s) {
23
+ // Single-quote wrapping with internal single-quote escaping
24
+ return `'${s.replace(/'/g, "'\\''")}'`;
25
+ }
26
+ /** Validate a string is safe for use as a model name/identifier (no shell metacharacters) */
27
+ function validateIdentifier(value, label) {
28
+ if (/[;&|`$(){}[\]!#~<>"\n\r\\]/.test(value)) {
29
+ throw new Error(`${label} contains unsafe characters. Only alphanumeric, hyphens, underscores, dots, colons, and slashes are allowed.`);
30
+ }
31
+ }
32
+ /** Validate a path doesn't escape the allowed directories */
33
+ function validatePath(p, label) {
34
+ const resolved = resolve(p);
35
+ const home = homedir();
36
+ const cwd = process.cwd();
37
+ // Allow paths under home directory, cwd, or /tmp
38
+ if (!resolved.startsWith(home) && !resolved.startsWith(cwd) && !resolved.startsWith('/tmp')) {
39
+ throw new Error(`${label} path must be under your home directory, current directory, or /tmp. Got: ${resolved}`);
40
+ }
41
+ }
42
+ /** Execute a command with argument array (no shell interpolation) */
43
+ function execSafe(cmd, args, opts) {
44
+ return execSync([cmd, ...args].map(a => esc(a)).join(' '), {
45
+ encoding: 'utf-8',
46
+ timeout: opts?.timeout ?? 60_000,
47
+ maxBuffer: 50 * 1024 * 1024,
48
+ cwd: opts?.cwd ?? process.cwd(),
49
+ env: opts?.env ?? process.env,
50
+ shell: '/bin/sh',
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ }).trim();
53
+ }
54
+ function execSafeResult(cmd, args, opts) {
55
+ try {
56
+ const output = execSafe(cmd, args, opts);
57
+ return { ok: true, output };
58
+ }
59
+ catch (err) {
60
+ const e = err;
61
+ const output = [e.stdout, e.stderr].filter(Boolean).join('\n').trim();
62
+ return { ok: false, output: output || e.message || 'Command failed' };
63
+ }
64
+ }
65
+ /** Legacy shell function — ONLY for hardcoded commands with no user input */
21
66
  function shell(cmd, opts) {
22
67
  return execSync(cmd, {
23
68
  encoding: 'utf-8',
@@ -40,14 +85,21 @@ function shellSafe(cmd, opts) {
40
85
  }
41
86
  }
42
87
  function isCommandAvailable(cmd) {
88
+ // Only hardcoded command names — never user input
89
+ if (!/^[a-zA-Z0-9_.-]+$/.test(cmd))
90
+ return false;
43
91
  try {
44
- execSync(`which ${cmd}`, { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'] });
92
+ execSync(`which ${esc(cmd)}`, { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'] });
45
93
  return true;
46
94
  }
47
95
  catch {
48
96
  return false;
49
97
  }
50
98
  }
99
+ /** Escape a string for safe embedding in a Python string literal (double-quoted) */
100
+ function pyEsc(s) {
101
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
102
+ }
51
103
  function estimateTokens(text) {
52
104
  // Rough estimate: ~4 chars per token for English text
53
105
  return Math.ceil(text.length / 4);
@@ -393,7 +445,7 @@ export function registerTrainingTools() {
393
445
  description: 'System prompt to prepend to each example (optional)',
394
446
  },
395
447
  },
396
- tier: 'pro',
448
+ tier: 'free',
397
449
  timeout: 300_000,
398
450
  async execute(args) {
399
451
  try {
@@ -527,7 +579,7 @@ export function registerTrainingTools() {
527
579
  description: 'Dataset format: jsonl, alpaca, sharegpt (default: jsonl)',
528
580
  },
529
581
  },
530
- tier: 'pro',
582
+ tier: 'free',
531
583
  timeout: 120_000,
532
584
  async execute(args) {
533
585
  try {
@@ -814,7 +866,7 @@ export function registerTrainingTools() {
814
866
  description: 'API key for cloud backends (optional — reads from env or ~/.kbot/config.json)',
815
867
  },
816
868
  },
817
- tier: 'pro',
869
+ tier: 'free',
818
870
  timeout: 600_000,
819
871
  async execute(args) {
820
872
  try {
@@ -829,6 +881,9 @@ export function registerTrainingTools() {
829
881
  if (!existsSync(datasetPath)) {
830
882
  return `Error: Dataset not found: ${datasetPath}`;
831
883
  }
884
+ // Input validation
885
+ validatePath(datasetPath, 'Dataset');
886
+ validateIdentifier(baseModel, 'Base model');
832
887
  const validBackends = ['openai', 'together', 'mistral', 'mlx', 'unsloth', 'llama-cpp'];
833
888
  if (!validBackends.includes(backend)) {
834
889
  return `Error: Invalid backend "${backend}". Supported: ${validBackends.join(', ')}`;
@@ -1022,27 +1077,27 @@ export function registerTrainingTools() {
1022
1077
  const iters = epochs * 100; // Rough: iters = epochs * (dataset_size / batch_size)
1023
1078
  const adapterPath = join(outputDir, 'adapters');
1024
1079
  mkdirSync(adapterPath, { recursive: true });
1025
- const cmd = [
1026
- 'python3 -m mlx_lm.lora',
1027
- `--model ${baseModel}`,
1028
- `--data ${datasetPath}`,
1080
+ const cmdArgs = [
1081
+ '-m', 'mlx_lm.lora',
1082
+ '--model', baseModel,
1083
+ '--data', datasetPath,
1029
1084
  '--train',
1030
- `--iters ${iters}`,
1031
- `--batch-size ${batchSize}`,
1032
- `--lora-layers ${loraRank}`,
1033
- `--adapter-path ${adapterPath}`,
1034
- ].join(' ');
1085
+ '--iters', String(iters),
1086
+ '--batch-size', String(batchSize),
1087
+ '--lora-layers', String(loraRank),
1088
+ '--adapter-path', adapterPath,
1089
+ ];
1035
1090
  // Write the command to a script file for background execution
1036
1091
  const scriptPath = join(outputDir, 'train.sh');
1037
1092
  const logPath = join(outputDir, 'train.log');
1093
+ const escapedCmd = ['python3', ...cmdArgs].map(a => esc(a)).join(' ');
1038
1094
  writeFileSync(scriptPath, [
1039
1095
  '#!/bin/bash',
1040
- `echo "Training started at $(date)" > ${logPath}`,
1041
- `echo "Command: ${cmd}" >> ${logPath}`,
1042
- `${cmd} 2>&1 | tee -a ${logPath}`,
1043
- `echo "Training finished at $(date)" >> ${logPath}`,
1044
- ].join('\n'), 'utf-8');
1045
- shell(`chmod +x ${scriptPath}`);
1096
+ `echo "Training started at $(date)" > ${esc(logPath)}`,
1097
+ `echo "Command: ${escapedCmd}" >> ${esc(logPath)}`,
1098
+ `${escapedCmd} 2>&1 | tee -a ${esc(logPath)}`,
1099
+ `echo "Training finished at $(date)" >> ${esc(logPath)}`,
1100
+ ].join('\n'), { encoding: 'utf-8', mode: 0o700 });
1046
1101
  // Launch in background
1047
1102
  const child = spawn('bash', [scriptPath], {
1048
1103
  detached: true,
@@ -1091,9 +1146,9 @@ from transformers import TrainingArguments
1091
1146
  from datasets import load_dataset
1092
1147
 
1093
1148
  # Configuration
1094
- BASE_MODEL = "${baseModel}"
1095
- DATASET_PATH = "${datasetPath}"
1096
- OUTPUT_DIR = "${outputDir}"
1149
+ BASE_MODEL = "${pyEsc(baseModel)}"
1150
+ DATASET_PATH = "${pyEsc(datasetPath)}"
1151
+ OUTPUT_DIR = "${pyEsc(outputDir)}"
1097
1152
  EPOCHS = ${epochs}
1098
1153
  BATCH_SIZE = ${batchSize}
1099
1154
  LEARNING_RATE = ${learningRate}
@@ -1184,11 +1239,10 @@ print("Done!")
1184
1239
  const launchScript = join(outputDir, 'train.sh');
1185
1240
  writeFileSync(launchScript, [
1186
1241
  '#!/bin/bash',
1187
- `echo "Training started at $(date)" > ${logPath}`,
1188
- `python3 ${scriptPath} 2>&1 | tee -a ${logPath}`,
1189
- `echo "Training finished at $(date)" >> ${logPath}`,
1190
- ].join('\n'), 'utf-8');
1191
- shell(`chmod +x ${launchScript}`);
1242
+ `echo "Training started at $(date)" > ${esc(logPath)}`,
1243
+ `python3 ${esc(scriptPath)} 2>&1 | tee -a ${esc(logPath)}`,
1244
+ `echo "Training finished at $(date)" >> ${esc(logPath)}`,
1245
+ ].join('\n'), { encoding: 'utf-8', mode: 0o700 });
1192
1246
  const child = spawn('bash', [launchScript], {
1193
1247
  detached: true,
1194
1248
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -1230,26 +1284,26 @@ print("Done!")
1230
1284
  const threads = Math.max(1, cpus().length - 2);
1231
1285
  const loraOutPath = join(outputDir, 'lora-adapter.bin');
1232
1286
  const logPath = join(outputDir, 'train.log');
1233
- const cmd = [
1287
+ const cmdArgs = [
1234
1288
  'llama-finetune',
1235
- `--model-base ${baseModel}`,
1236
- `--lora-out ${loraOutPath}`,
1237
- `--train-data ${datasetPath}`,
1238
- `--threads ${threads}`,
1239
- `--epochs ${epochs}`,
1240
- `--batch ${batchSize}`,
1241
- `--lora-r ${loraRank}`,
1242
- `--lora-alpha ${loraAlpha}`,
1243
- ].join(' ');
1289
+ '--model-base', baseModel,
1290
+ '--lora-out', loraOutPath,
1291
+ '--train-data', datasetPath,
1292
+ '--threads', String(threads),
1293
+ '--epochs', String(epochs),
1294
+ '--batch', String(batchSize),
1295
+ '--lora-r', String(loraRank),
1296
+ '--lora-alpha', String(loraAlpha),
1297
+ ];
1244
1298
  const scriptPath = join(outputDir, 'train.sh');
1299
+ const escapedLlamaCmd = cmdArgs.map(a => esc(a)).join(' ');
1245
1300
  writeFileSync(scriptPath, [
1246
1301
  '#!/bin/bash',
1247
- `echo "Training started at $(date)" > ${logPath}`,
1248
- `echo "Command: ${cmd}" >> ${logPath}`,
1249
- `${cmd} 2>&1 | tee -a ${logPath}`,
1250
- `echo "Training finished at $(date)" >> ${logPath}`,
1251
- ].join('\n'), 'utf-8');
1252
- shell(`chmod +x ${scriptPath}`);
1302
+ `echo "Training started at $(date)" > ${esc(logPath)}`,
1303
+ `echo "Command: ${escapedLlamaCmd}" >> ${esc(logPath)}`,
1304
+ `${escapedLlamaCmd} 2>&1 | tee -a ${esc(logPath)}`,
1305
+ `echo "Training finished at $(date)" >> ${esc(logPath)}`,
1306
+ ].join('\n'), { encoding: 'utf-8', mode: 0o700 });
1253
1307
  const child = spawn('bash', [scriptPath], {
1254
1308
  detached: true,
1255
1309
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -1303,7 +1357,7 @@ print("Done!")
1303
1357
  description: 'API key for cloud backends (optional)',
1304
1358
  },
1305
1359
  },
1306
- tier: 'pro',
1360
+ tier: 'free',
1307
1361
  timeout: 30_000,
1308
1362
  async execute(args) {
1309
1363
  try {
@@ -1516,7 +1570,7 @@ print("Done!")
1516
1570
  description: 'API key for cloud backends (optional)',
1517
1571
  },
1518
1572
  },
1519
- tier: 'pro',
1573
+ tier: 'free',
1520
1574
  timeout: 600_000,
1521
1575
  async execute(args) {
1522
1576
  try {
@@ -1603,7 +1657,7 @@ print("Done!")
1603
1657
  const prompt = tc.system
1604
1658
  ? `System: ${tc.system}\n\nUser: ${tc.prompt}\n\nAssistant:`
1605
1659
  : `User: ${tc.prompt}\n\nAssistant:`;
1606
- const result = shellSafe(`ollama run ${model} ${JSON.stringify(prompt)}`, { timeout: 60_000 });
1660
+ const result = execSafeResult('ollama', ['run', model, prompt], { timeout: 60_000 });
1607
1661
  actual = result.ok ? result.output : '';
1608
1662
  }
1609
1663
  else if (backend === 'llama-cpp') {
@@ -1611,7 +1665,7 @@ print("Done!")
1611
1665
  return 'Error: llama-cli is not installed. Build from https://github.com/ggerganov/llama.cpp';
1612
1666
  }
1613
1667
  const prompt = tc.prompt;
1614
- const result = shellSafe(`llama-cli -m ${model} -p ${JSON.stringify(prompt)} -n 512 --temp 0.1`, { timeout: 120_000 });
1668
+ const result = execSafeResult('llama-cli', ['-m', model, '-p', prompt, '-n', '512', '--temp', '0.1'], { timeout: 120_000 });
1615
1669
  actual = result.ok ? result.output : '';
1616
1670
  }
1617
1671
  else if (backend === 'openai' || backend === 'together' || backend === 'mistral') {
@@ -1730,7 +1784,7 @@ print("Done!")
1730
1784
  description: 'Quantization type for to_gguf and quantize: q4_K_M, q5_K_M, q8_0, f16 (default: q4_K_M)',
1731
1785
  },
1732
1786
  },
1733
- tier: 'pro',
1787
+ tier: 'free',
1734
1788
  timeout: 600_000,
1735
1789
  async execute(args) {
1736
1790
  try {
@@ -1754,8 +1808,7 @@ print("Done!")
1754
1808
  // Try MLX merge first (Apple Silicon)
1755
1809
  const hasMlx = shellSafe('python3 -c "import mlx_lm"');
1756
1810
  if (hasMlx.ok) {
1757
- const cmd = `python3 -m mlx_lm.fuse --model ${baseModel} --adapter-path ${modelPath} --save-path ${outputPath}`;
1758
- const result = shellSafe(cmd, { timeout: 300_000 });
1811
+ const result = execSafeResult('python3', ['-m', 'mlx_lm.fuse', '--model', baseModel, '--adapter-path', modelPath, '--save-path', outputPath], { timeout: 300_000 });
1759
1812
  if (result.ok) {
1760
1813
  return [
1761
1814
  `LoRA merge completed (MLX).`,
@@ -1775,27 +1828,27 @@ import torch
1775
1828
  from peft import PeftModel
1776
1829
  from transformers import AutoModelForCausalLM, AutoTokenizer
1777
1830
 
1778
- print("Loading base model: ${baseModel}")
1779
- model = AutoModelForCausalLM.from_pretrained("${baseModel}", torch_dtype=torch.float16, device_map="auto")
1780
- tokenizer = AutoTokenizer.from_pretrained("${baseModel}")
1831
+ print("Loading base model: ${pyEsc(baseModel)}")
1832
+ model = AutoModelForCausalLM.from_pretrained("${pyEsc(baseModel)}", torch_dtype=torch.float16, device_map="auto")
1833
+ tokenizer = AutoTokenizer.from_pretrained("${pyEsc(baseModel)}")
1781
1834
 
1782
- print("Loading LoRA adapter: ${modelPath}")
1783
- model = PeftModel.from_pretrained(model, "${modelPath}")
1835
+ print("Loading LoRA adapter: ${pyEsc(modelPath)}")
1836
+ model = PeftModel.from_pretrained(model, "${pyEsc(modelPath)}")
1784
1837
 
1785
1838
  print("Merging LoRA weights into base model...")
1786
1839
  model = model.merge_and_unload()
1787
1840
 
1788
- print("Saving merged model to: ${outputPath}")
1789
- model.save_pretrained("${outputPath}")
1790
- tokenizer.save_pretrained("${outputPath}")
1841
+ print("Saving merged model to: ${pyEsc(outputPath)}")
1842
+ model.save_pretrained("${pyEsc(outputPath)}")
1843
+ tokenizer.save_pretrained("${pyEsc(outputPath)}")
1791
1844
  print("Done!")
1792
1845
  `;
1793
1846
  const scriptPath = join(dirname(modelPath), '_merge_lora.py');
1794
- writeFileSync(scriptPath, mergeScript, 'utf-8');
1795
- const result = shellSafe(`python3 ${scriptPath}`, { timeout: 600_000 });
1847
+ writeFileSync(scriptPath, mergeScript, { encoding: 'utf-8', mode: 0o700 });
1848
+ const result = execSafeResult('python3', [scriptPath], { timeout: 600_000 });
1796
1849
  // Clean up script
1797
1850
  try {
1798
- execSync(`rm -f ${scriptPath}`, { stdio: 'pipe' });
1851
+ unlinkSync(scriptPath);
1799
1852
  }
1800
1853
  catch { /* ignore */ }
1801
1854
  if (!result.ok) {
@@ -1816,13 +1869,13 @@ print("Done!")
1816
1869
  ? resolve(String(args.output))
1817
1870
  : resolve(dirname(modelPath), `${basename(modelPath)}.${quantType}.gguf`);
1818
1871
  // Try llama.cpp's convert script
1819
- const convertScript = shellSafe('which convert_hf_to_gguf.py || which convert-hf-to-gguf.py');
1820
- let convertCmd;
1821
- if (convertScript.ok && convertScript.output) {
1822
- convertCmd = `python3 ${convertScript.output} ${modelPath} --outfile ${outputPath} --outtype ${quantType}`;
1872
+ // Find conversion script
1873
+ let convertScriptPath = '';
1874
+ const whichResult = shellSafe('which convert_hf_to_gguf.py || which convert-hf-to-gguf.py');
1875
+ if (whichResult.ok && whichResult.output) {
1876
+ convertScriptPath = whichResult.output;
1823
1877
  }
1824
1878
  else {
1825
- // Try finding it in common locations
1826
1879
  const commonPaths = [
1827
1880
  join(homedir(), 'llama.cpp/convert_hf_to_gguf.py'),
1828
1881
  join(homedir(), 'llama.cpp/convert-hf-to-gguf.py'),
@@ -1839,12 +1892,12 @@ print("Done!")
1839
1892
  ' pip install -r requirements.txt',
1840
1893
  '',
1841
1894
  'Then run:',
1842
- ` python3 convert_hf_to_gguf.py ${modelPath} --outfile ${outputPath} --outtype ${quantType}`,
1895
+ ` python3 convert_hf_to_gguf.py <model_path> --outfile <output> --outtype ${quantType}`,
1843
1896
  ].join('\n');
1844
1897
  }
1845
- convertCmd = `python3 ${found} ${modelPath} --outfile ${outputPath} --outtype ${quantType}`;
1898
+ convertScriptPath = found;
1846
1899
  }
1847
- const result = shellSafe(convertCmd, { timeout: 600_000 });
1900
+ const result = execSafeResult('python3', [convertScriptPath, modelPath, '--outfile', outputPath, '--outtype', quantType], { timeout: 600_000 });
1848
1901
  if (!result.ok) {
1849
1902
  return `Error converting to GGUF:\n${result.output}`;
1850
1903
  }
@@ -1873,7 +1926,7 @@ print("Done!")
1873
1926
  const outputPath = args.output
1874
1927
  ? resolve(String(args.output))
1875
1928
  : modelPath.replace(/\.gguf$/, '') + `.${quantType}.gguf`;
1876
- const result = shellSafe(`llama-quantize ${modelPath} ${outputPath} ${quantType}`, { timeout: 600_000 });
1929
+ const result = execSafeResult('llama-quantize', [modelPath, outputPath, quantType], { timeout: 600_000 });
1877
1930
  if (!result.ok) {
1878
1931
  return `Error quantizing model:\n${result.output}`;
1879
1932
  }
@@ -1925,7 +1978,7 @@ print("Done!")
1925
1978
  description: 'Model description (optional)',
1926
1979
  },
1927
1980
  },
1928
- tier: 'pro',
1981
+ tier: 'free',
1929
1982
  timeout: 600_000,
1930
1983
  async execute(args) {
1931
1984
  try {
@@ -1945,7 +1998,13 @@ print("Done!")
1945
1998
  }
1946
1999
  // Determine if model is GGUF file or directory
1947
2000
  const isGguf = modelPath.endsWith('.gguf');
1948
- const fromLine = isGguf ? `FROM ${modelPath}` : `FROM ${modelPath}`;
2001
+ // Validate name for Ollama (alphanumeric, hyphens, underscores, colons)
2002
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_:.-]*$/.test(name)) {
2003
+ return 'Error: Ollama model name must be alphanumeric (hyphens, underscores, colons, dots allowed).';
2004
+ }
2005
+ const fromLine = `FROM ${modelPath}`;
2006
+ // Escape description for Ollama Modelfile (prevent """ breakout)
2007
+ const safeDescription = description.replace(/"""/g, '').replace(/\\/g, '\\\\');
1949
2008
  // Create a Modelfile
1950
2009
  const modelfileContent = [
1951
2010
  fromLine,
@@ -1954,12 +2013,12 @@ print("Done!")
1954
2013
  `PARAMETER top_p 0.9`,
1955
2014
  `PARAMETER top_k 40`,
1956
2015
  '',
1957
- `SYSTEM """${description}"""`,
2016
+ `SYSTEM """${safeDescription}"""`,
1958
2017
  ].join('\n');
1959
2018
  const modelfilePath = join(dirname(modelPath), 'Modelfile');
1960
2019
  writeFileSync(modelfilePath, modelfileContent, 'utf-8');
1961
2020
  // Create the Ollama model
1962
- const result = shellSafe(`ollama create ${name} -f ${modelfilePath}`, { timeout: 300_000 });
2021
+ const result = execSafeResult('ollama', ['create', name, '-f', modelfilePath], { timeout: 300_000 });
1963
2022
  if (!result.ok) {
1964
2023
  return `Error creating Ollama model:\n${result.output}\n\nModelfile written to: ${modelfilePath}`;
1965
2024
  }
@@ -1990,12 +2049,12 @@ print("Done!")
1990
2049
  // Check if HF_TOKEN is available
1991
2050
  const hasToken = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN;
1992
2051
  if (!hasToken) {
1993
- const loginCheck = shellSafe('huggingface-cli whoami');
2052
+ const loginCheck = execSafeResult('huggingface-cli', ['whoami']);
1994
2053
  if (!loginCheck.ok) {
1995
2054
  return 'Error: Not authenticated with HuggingFace. Run: huggingface-cli login\nOr set HF_TOKEN environment variable.';
1996
2055
  }
1997
2056
  }
1998
- const result = shellSafe(`huggingface-cli upload ${name} ${modelPath}`, { timeout: 600_000 });
2057
+ const result = execSafeResult('huggingface-cli', ['upload', name, modelPath], { timeout: 600_000 });
1999
2058
  if (!result.ok) {
2000
2059
  return `Error uploading to HuggingFace:\n${result.output}`;
2001
2060
  }
@@ -2018,15 +2077,19 @@ print("Done!")
2018
2077
  // Copy model file(s)
2019
2078
  const stat = statSync(modelPath);
2020
2079
  if (stat.isFile()) {
2021
- const result = shellSafe(`cp ${modelPath} ${destPath}`, { timeout: 120_000 });
2080
+ const result = execSafeResult('cp', [modelPath, destPath], { timeout: 120_000 });
2022
2081
  if (!result.ok) {
2023
2082
  return `Error copying model: ${result.output}`;
2024
2083
  }
2025
2084
  }
2026
2085
  else if (stat.isDirectory()) {
2086
+ // Validate name for path safety
2087
+ if (/[\/\\]/.test(name) || name.includes('..')) {
2088
+ return 'Error: Model name must not contain path separators or ".."';
2089
+ }
2027
2090
  const destDir = join(modelsDir, name);
2028
2091
  mkdirSync(destDir, { recursive: true });
2029
- const result = shellSafe(`cp -r ${modelPath}/* ${destDir}/`, { timeout: 300_000 });
2092
+ const result = execSafeResult('cp', ['-r', modelPath + '/.', destDir + '/'], { timeout: 300_000 });
2030
2093
  if (!result.ok) {
2031
2094
  return `Error copying model directory: ${result.output}`;
2032
2095
  }