vipcode 0.2.0 → 0.2.2

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/dist/labels.js ADDED
@@ -0,0 +1,20 @@
1
+ // Cómo se cuenta cada herramienta en pantalla y en las conversaciones guardadas
2
+ const TOOL_LABEL = {
3
+ read_file: "Lee",
4
+ write_file: "Escribe",
5
+ edit_file: "Edita",
6
+ list_dir: "Lista",
7
+ glob: "Busca ficheros",
8
+ grep: "Busca",
9
+ bash: "Ejecuta",
10
+ webfetch: "Abre",
11
+ websearch: "Busca en internet",
12
+ todowrite: "Tareas",
13
+ todoread: "Tareas",
14
+ diagnostics: "Diagnósticos",
15
+ task: "Subagente",
16
+ skill: "Skill",
17
+ };
18
+ export function toolLabel(name) {
19
+ return TOOL_LABEL[name] || name;
20
+ }
package/dist/markdown.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import hljs from "highlight.js/lib/common";
2
2
  import { marked } from "marked";
3
3
  import pc from "picocolors";
4
+ import { stripControl } from "./sanitize.js";
4
5
  // Colores de resaltado de código por clase de highlight.js
5
6
  const CODE_COLORS = {
6
7
  keyword: pc.magenta,
@@ -249,11 +250,13 @@ function blocks(tokens, width, depth = 0) {
249
250
  }
250
251
  // Markdown → texto con colores para la terminal
251
252
  export function renderMarkdown(text, width = 80) {
253
+ // El texto del modelo no puede llevar secuencias de la terminal: los colores los ponemos nosotros
254
+ const clean = stripControl(text);
252
255
  try {
253
- const tokens = marked.lexer(text, { gfm: true });
256
+ const tokens = marked.lexer(clean, { gfm: true });
254
257
  return blocks(tokens, width).replace(/\n{3,}/g, "\n\n");
255
258
  }
256
259
  catch {
257
- return text;
260
+ return clean;
258
261
  }
259
262
  }
package/dist/paths.js CHANGED
@@ -1,5 +1,7 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  // Carpeta de datos de vipcode (VIPCODE_HOME permite usar otra, por ejemplo en pruebas)
4
6
  export const HOME_DIR = process.env.VIPCODE_HOME || path.join(os.homedir(), ".vipcode");
5
7
  export const CONFIG_PATH = path.join(HOME_DIR, "config.json");
@@ -15,6 +17,8 @@ export const HISTORY_PATH = path.join(HOME_DIR, "history.jsonl");
15
17
  export const NOTICES_PATH = path.join(HOME_DIR, "notices.json");
16
18
  // Última comprobación de versiones nuevas en GitHub
17
19
  export const UPDATE_PATH = path.join(HOME_DIR, "update.json");
20
+ // Carpetas en las que confías para usar la configuración que traen
21
+ export const TRUST_PATH = path.join(HOME_DIR, "trusted.json");
18
22
  export const SESSIONS_DIR = path.join(HOME_DIR, "sessions");
19
23
  export const SNAPSHOTS_DIR = path.join(HOME_DIR, "snapshots");
20
24
  export const EXPORTS_DIR = path.join(HOME_DIR, "exports");
@@ -25,3 +29,25 @@ export const GLOBAL_SKILLS_DIR = path.join(HOME_DIR, "skills");
25
29
  // Instrucciones que se aplican a todos tus proyectos
26
30
  export const GLOBAL_INSTRUCTIONS_PATH = path.join(HOME_DIR, "AGENTS.md");
27
31
  export const DEBUG_LOG_PATH = path.join(HOME_DIR, "debug.log");
32
+ // La carpeta de datos guarda claves y conversaciones: solo la puede abrir tu usuario
33
+ export function ensureHomeDir() {
34
+ fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
35
+ try {
36
+ fs.chmodSync(HOME_DIR, 0o700);
37
+ }
38
+ catch {
39
+ // en Windows o en carpetas que no son tuyas no se puede: no pasa nada
40
+ }
41
+ }
42
+ // Carpeta donde está instalado vipcode (la que contiene dist/)
43
+ export const INSTALL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
+ // Lo que el agente no puede tocar: los datos de vipcode (claves, configuración, carpetas de confianza) ni leerlos,
45
+ // y su propio código no modificarlo. Quien desarrolle vipcode con vipcode puede permitir lo segundo con
46
+ // VIPCODE_ALLOW_SELF_EDIT=1.
47
+ export function protectedDirs() {
48
+ const dirs = [{ dir: HOME_DIR, read: true }];
49
+ if (process.env.VIPCODE_ALLOW_SELF_EDIT !== "1") {
50
+ dirs.push({ dir: INSTALL_DIR, read: false });
51
+ }
52
+ return dirs;
53
+ }
@@ -1,5 +1,8 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
1
3
  import { deepMerge } from "./config.js";
2
4
  import { wildcardMatch } from "./match.js";
5
+ import { HOME_DIR, INSTALL_DIR } from "./paths.js";
3
6
  const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
4
7
  function strictest(a, b) {
5
8
  if (STRICTNESS[b] > STRICTNESS[a]) {
@@ -27,9 +30,13 @@ export function matchRules(rules, subject, fallback) {
27
30
  return fallback;
28
31
  }
29
32
  // Parte "a && b | c; d" en comandos sueltos para que "git status && rm -rf x" no pase como "git status"
33
+ // Redirecciones que no escriben ficheros (2>&1, >/dev/null): no cuentan como separadores ni como escritura
34
+ const HARMLESS_REDIRECTS = /\d*>&\d+|&>\s*\/dev\/null|\d*>>?\s*\/dev\/null/g;
30
35
  export function splitCommand(command) {
36
+ // Un & suelto (segundo plano) también separa: «ls & rm -rf ~» son dos comandos
31
37
  return command
32
- .split(/&&|\|\||;|\||\n/)
38
+ .replace(HARMLESS_REDIRECTS, " ")
39
+ .split(/&&|\|\||;|\||\n|&/)
33
40
  .map((part) => part.trim())
34
41
  .filter((part) => part.length > 0);
35
42
  }
@@ -55,7 +62,10 @@ export function evaluate(config, kind, subject) {
55
62
  result = strictest(result, matchRules(config.bash, part, "ask"));
56
63
  }
57
64
  // Una sustitución $(...) o `...` puede esconder cualquier cosa
58
- if (/\$\(|`/.test(subject)) {
65
+ // Sustituciones, escribir en ficheros (> o --output) y saltos de línea raros siempre se preguntan,
66
+ // aunque el comando esté permitido (si no, «ls > ~/.zshrc» pasaría por «ls*»)
67
+ const writes = /[<>]\(|>/.test(subject.replace(HARMLESS_REDIRECTS, " ")) || /--output\b|\r/.test(subject);
68
+ if (/\$\(|`/.test(subject) || writes) {
59
69
  result = strictest(result, "ask");
60
70
  }
61
71
  return result;
@@ -106,12 +116,26 @@ export class SessionGrants {
106
116
  }
107
117
  }
108
118
  // Decide si una acción se permite sola, se bloquea o hay que preguntar
119
+ // Un comando que nombra la carpeta de datos de vipcode o su instalación (para leer tus claves, cambiar la
120
+ // configuración o tocar su propio código) se pregunta siempre: no vale ninguna regla allow ni «sí a todo»
121
+ export function touchesVipcode(command) {
122
+ const home = os.homedir();
123
+ const names = [HOME_DIR, INSTALL_DIR, "lib/node_modules/vipcode"];
124
+ if (HOME_DIR.startsWith(home + path.sep)) {
125
+ const inHome = HOME_DIR.slice(home.length + 1);
126
+ names.push(`~/${inHome}`, `$HOME/${inHome}`, `\${HOME}/${inHome}`);
127
+ }
128
+ return names.some((name) => command.includes(name));
129
+ }
109
130
  export function createAsker(getConfig, grants, prompt) {
110
131
  return async (request) => {
111
132
  const action = evaluate(getConfig(), request.kind, request.subject);
112
133
  if (action === "deny") {
113
134
  return { allow: false, feedback: "bloqueado por la configuración de permisos (deny)" };
114
135
  }
136
+ if (request.kind === "bash" && touchesVipcode(request.subject)) {
137
+ return prompt({ ...request, title: `${request.title} (toca la carpeta de vipcode)` });
138
+ }
115
139
  if (action === "allow" || grants.allows(request.kind, request.subject)) {
116
140
  return { allow: true };
117
141
  }
package/dist/ranking.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { readAuth } from "./auth.js";
3
3
  import { debugLog } from "./debug.js";
4
- import { HOME_DIR, RANKING_PATH } from "./paths.js";
4
+ import { RANKING_PATH, ensureHomeDir } from "./paths.js";
5
5
  const WEIGHTS = {
6
6
  coding: { code: 0.4, agents: 0.3, tools: 0.15, general: 0.15 },
7
7
  general: { general: 1 },
@@ -223,7 +223,7 @@ export async function refreshRanking(apiKey, signal) {
223
223
  throw new Error("la API no devolvió puntuaciones que vipcode sepa leer");
224
224
  }
225
225
  const data = { updated: new Date().toISOString().slice(0, 10), source: "https://llm-stats.com (API)", models: rated };
226
- fs.mkdirSync(HOME_DIR, { recursive: true });
226
+ ensureHomeDir();
227
227
  fs.writeFileSync(RANKING_PATH, JSON.stringify(data));
228
228
  debugLog("ranking actualizado", rated.length);
229
229
  return { count: rated.length };
@@ -0,0 +1,38 @@
1
+ // Lo que llega del modelo, de las herramientas o de los proveedores no puede mover el cursor, borrar la pantalla,
2
+ // cambiar el portapapeles (OSC 52) ni disfrazar un comando en el diálogo de permiso
3
+ // Secuencias completas (CSI, OSC, DCS…) y escapes sueltos
4
+ const SEQUENCES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[@-Z\\-_]?/g;
5
+ // Caracteres de control salvo el tabulador y el salto de línea (incluye \r y los C1, como el CSI de un byte)
6
+ const CONTROLS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
7
+ // Colores y estilos (SGR): son inofensivos salvo «oculto» (8), que escondería texto
8
+ const SGR = /\x1b\[([0-9;]*)m/g;
9
+ // Para enseñar texto que no es nuestro
10
+ export function stripControl(text) {
11
+ return String(text).replace(SEQUENCES, "").replace(CONTROLS, "");
12
+ }
13
+ // Para nuestros propios bloques (tablas con colores) que llevan dentro datos de fuera: deja solo los colores
14
+ export function stripUnsafe(text) {
15
+ const input = String(text);
16
+ let result = "";
17
+ let last = 0;
18
+ for (const match of input.matchAll(SGR)) {
19
+ result += stripControl(input.slice(last, match.index));
20
+ if (!match[1].split(";").includes("8")) {
21
+ result += match[0];
22
+ }
23
+ last = (match.index || 0) + match[0].length;
24
+ }
25
+ return result + stripControl(input.slice(last));
26
+ }
27
+ // Para lo que vas a aprobar (comandos, rutas, diffs): los caracteres de control se ven en vez de actuar
28
+ export function showControl(text) {
29
+ return String(text).replace(CONTROLS, (char) => {
30
+ if (char === "\x1b") {
31
+ return "␛";
32
+ }
33
+ if (char === "\r") {
34
+ return "␍";
35
+ }
36
+ return `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`;
37
+ });
38
+ }
package/dist/session.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { EXPORTS_DIR, HISTORY_PATH, HOME_DIR, SESSIONS_DIR } from "./paths.js";
3
+ import { toolLabel } from "./labels.js";
4
+ import { EXPORTS_DIR, HISTORY_PATH, SESSIONS_DIR, ensureHomeDir } from "./paths.js";
5
+ import { describeCall } from "./tools.js";
4
6
  export function newSession(cwd) {
5
7
  const now = new Date();
6
8
  // id legible y ordenable: 20261029-174512-k3x
@@ -18,26 +20,98 @@ export function newSession(cwd) {
18
20
  redo: [],
19
21
  epoch: 0,
20
22
  usage: { prompt: 0, completion: 0 },
23
+ log: [],
21
24
  };
22
25
  }
26
+ function contentText(content) {
27
+ if (typeof content === "string") {
28
+ return content;
29
+ }
30
+ if (Array.isArray(content)) {
31
+ return content
32
+ .map((part) => {
33
+ if (part.type === "text") {
34
+ return part.text;
35
+ }
36
+ if (part.type === "image_url") {
37
+ return "[imagen]";
38
+ }
39
+ return "";
40
+ })
41
+ .join("\n");
42
+ }
43
+ return "";
44
+ }
45
+ // Lo que escribiste, sin los ficheros adjuntos con @ ni las notas que añade vipcode
46
+ function userText(text) {
47
+ let clean = text;
48
+ const attached = clean.indexOf("\n\nFicheros adjuntos con @");
49
+ if (attached >= 0) {
50
+ const names = [...clean.slice(attached).matchAll(/<(?:fichero|carpeta) ruta="([^"]+)">/g)].map((match) => match[1]);
51
+ clean = `${clean.slice(0, attached)}\n\n_Adjuntos: ${names.join(", ")}_`;
52
+ }
53
+ return clean.replace(/^\((?:Nota|El usuario ha ejecutado)[\s\S]*?\)\n\n/, "").replace(/^\[Mensaje nuevo del usuario mientras trabajabas\] /, "");
54
+ }
55
+ // Las sesiones de antes no tenían historial: se saca de los mensajes que quedan
56
+ function logFromMessages(messages, time) {
57
+ const log = [];
58
+ const results = new Map();
59
+ for (const message of messages) {
60
+ if (message.role === "tool") {
61
+ results.set(String(message.tool_call_id), contentText(message.content));
62
+ }
63
+ }
64
+ for (const message of messages) {
65
+ if (message.role === "user") {
66
+ const text = contentText(message.content);
67
+ if (/^(Continúa con la tarea pendiente\.|Has hecho la misma llamada|Has repetido la misma llamada|Imágenes devueltas por las herramientas:)/.test(text)) {
68
+ continue;
69
+ }
70
+ if (text.startsWith("Resumen de la conversación anterior:")) {
71
+ log.push({ role: "info", text: "Conversación resumida para ahorrar contexto (lo anterior al resumen ya no se guardaba entonces).", time });
72
+ continue;
73
+ }
74
+ log.push({ role: "user", text: userText(text), time });
75
+ }
76
+ else if (message.role === "assistant") {
77
+ const text = contentText(message.content);
78
+ if (text.trim() !== "" && text !== "Entendido, sigo desde aquí.") {
79
+ log.push({ role: "assistant", text, time });
80
+ }
81
+ for (const call of (message.tool_calls || [])) {
82
+ const name = String(call.function?.name || "");
83
+ const output = results.get(String(call.id)) || "";
84
+ log.push({ role: "tool", text: output.slice(0, 4000), time, tool: { name, summary: describeCall(name, call.function?.arguments || ""), isError: output.startsWith("Error") } });
85
+ }
86
+ }
87
+ }
88
+ return log;
89
+ }
23
90
  // Las sesiones de la versión anterior no tenían todos los campos
24
91
  function normalize(data) {
92
+ const messages = Array.isArray(data.messages) ? data.messages : [];
25
93
  return {
26
94
  id: String(data.id),
27
95
  title: data.title || "(sin título)",
28
96
  cwd: data.cwd || "",
29
97
  created: data.created || data.updated || Date.now(),
30
98
  updated: data.updated || Date.now(),
31
- messages: Array.isArray(data.messages) ? data.messages : [],
99
+ messages,
32
100
  todos: Array.isArray(data.todos) ? data.todos : [],
33
101
  turns: Array.isArray(data.turns) ? data.turns : [],
34
102
  redo: Array.isArray(data.redo) ? data.redo : [],
35
103
  epoch: data.epoch || 0,
36
104
  usage: data.usage || { prompt: 0, completion: 0 },
37
105
  agent: data.agent,
106
+ log: Array.isArray(data.log) ? data.log : logFromMessages(messages, data.updated || Date.now()),
38
107
  };
39
108
  }
40
109
  function firstUserText(session) {
110
+ // Lo que escribiste (por ejemplo «/review»), mejor que el mensaje ya expandido
111
+ const logged = session.log.find((entry) => entry.role === "user");
112
+ if (logged) {
113
+ return logged.text;
114
+ }
41
115
  const first = session.messages.find((m) => m.role === "user");
42
116
  if (!first) {
43
117
  return "";
@@ -65,6 +139,16 @@ export function saveSession(session) {
65
139
  }
66
140
  }
67
141
  fs.writeFileSync(path.join(SESSIONS_DIR, `${session.id}.json`), JSON.stringify(session));
142
+ // Y la conversación entera en Markdown, para leerla cuando quieras
143
+ try {
144
+ fs.writeFileSync(sessionMarkdownPath(session), exportMarkdown(session));
145
+ }
146
+ catch {
147
+ // no es grave: lo importante es el .json
148
+ }
149
+ }
150
+ export function sessionMarkdownPath(session) {
151
+ return path.join(SESSIONS_DIR, `${session.id}.md`);
68
152
  }
69
153
  export function listSessions(cwd) {
70
154
  if (!fs.existsSync(SESSIONS_DIR)) {
@@ -80,6 +164,10 @@ export function listSessions(cwd) {
80
164
  if (!cwd || data.cwd === cwd) {
81
165
  sessions.push(data);
82
166
  }
167
+ // Las conversaciones de antes de que existiera el .md también lo tienen
168
+ if (data.log.length > 0 && !fs.existsSync(sessionMarkdownPath(data))) {
169
+ fs.writeFileSync(sessionMarkdownPath(data), exportMarkdown(data));
170
+ }
83
171
  }
84
172
  catch {
85
173
  // sesión corrupta: la ignoramos
@@ -102,75 +190,57 @@ export function deleteSession(id) {
102
190
  return false;
103
191
  }
104
192
  fs.rmSync(file);
193
+ fs.rmSync(path.join(SESSIONS_DIR, `${id}.md`), { force: true });
105
194
  return true;
106
195
  }
107
- function contentText(content) {
108
- if (typeof content === "string") {
109
- return content;
110
- }
111
- if (Array.isArray(content)) {
112
- return content
113
- .map((part) => {
114
- if (part.type === "text") {
115
- return part.text;
116
- }
117
- if (part.type === "image_url") {
118
- return "[imagen]";
119
- }
120
- return "";
121
- })
122
- .join("\n");
123
- }
124
- return "";
196
+ // Un bloque de código que no se rompe aunque el texto lleve ```
197
+ function fence(text, lang = "") {
198
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
199
+ const ticks = "`".repeat(longest + 1);
200
+ return [`${ticks}${lang}`, text, ticks];
125
201
  }
126
- // La conversación en Markdown, para guardarla o compartirla
202
+ // En español aunque el sistema no diga el idioma: 29/10/2026, 20:06:06
203
+ function when(time) {
204
+ return new Date(time).toLocaleString("es-ES");
205
+ }
206
+ // La conversación entera en Markdown (sale del historial, que no se resume nunca)
127
207
  export function exportMarkdown(session) {
128
208
  const lines = [
129
209
  `# ${session.title}`,
130
210
  "",
131
211
  `- Carpeta: \`${session.cwd}\``,
132
- `- Empezada: ${new Date(session.created).toLocaleString()}`,
133
- `- Última actividad: ${new Date(session.updated).toLocaleString()}`,
212
+ `- Empezada: ${when(session.created)} · Última actividad: ${when(session.updated)}`,
134
213
  `- Tokens: ${session.usage.prompt} de entrada, ${session.usage.completion} de salida`,
135
214
  "",
215
+ "---",
216
+ "",
136
217
  ];
137
- for (const message of session.messages) {
138
- if (message.role === "system") {
139
- continue;
218
+ for (const entry of session.log) {
219
+ if (entry.role === "user") {
220
+ const images = entry.images ? ` · ${entry.images} imagen${entry.images > 1 ? "es" : ""}` : "";
221
+ lines.push(`### Tú · ${when(entry.time)}${images}`, "", entry.text, "");
140
222
  }
141
- if (message.role === "user") {
142
- let text = contentText(message.content);
143
- const attached = text.indexOf("\n\nFicheros adjuntos con @");
144
- if (attached >= 0) {
145
- const names = [...text.slice(attached).matchAll(/<(?:fichero|carpeta) ruta="([^"]+)">/g)].map((match) => match[1]);
146
- text = `${text.slice(0, attached)}\n\n_Adjuntos: ${names.join(", ")}_`;
147
- }
148
- lines.push("## Tú", "", text, "");
223
+ else if (entry.role === "assistant") {
224
+ lines.push(`### vipcode${entry.model ? ` · ${entry.model}` : ""}`, "", entry.text, "");
149
225
  }
150
- else if (message.role === "assistant") {
151
- const text = contentText(message.content);
152
- lines.push("## vipcode", "");
153
- if (text.trim() !== "") {
154
- lines.push(text, "");
226
+ else if (entry.role === "tool" && entry.tool) {
227
+ const summary = entry.tool.summary ? ` \`${entry.tool.summary.replace(/`/g, "'")}\`` : "";
228
+ lines.push(`- **${toolLabel(entry.tool.name)}**${summary}${entry.tool.isError ? " — ✗ falló" : ""}`);
229
+ if (entry.tool.display) {
230
+ lines.push("", ...fence(entry.tool.display, "diff"), "");
155
231
  }
156
- const calls = message.tool_calls || [];
157
- for (const call of calls) {
158
- let args = call.function?.arguments || "";
159
- if (args.length > 600) {
160
- args = `${args.slice(0, 600)}…`;
232
+ else if (entry.tool.isError || entry.tool.name === "bash") {
233
+ const output = entry.text.split("\n").slice(0, 15).join("\n");
234
+ if (output.trim() !== "") {
235
+ lines.push("", ...fence(output), "");
161
236
  }
162
- lines.push(`> 🔧 **${call.function?.name}** \`${args.replace(/`/g, "'")}\``, "");
163
237
  }
164
238
  }
165
- else if (message.role === "tool") {
166
- let text = contentText(message.content);
167
- if (text.length > 2000) {
168
- text = `${text.slice(0, 2000)}\n[…]`;
169
- }
170
- lines.push("<details><summary>Resultado</summary>", "", "```", text.replace(/```/g, "'''"), "```", "", "</details>", "");
239
+ else if (entry.role === "info") {
240
+ lines.push("", `_${entry.text}_`, "");
171
241
  }
172
242
  }
173
- return lines.join("\n");
243
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
174
244
  }
175
245
  export function saveExport(session) {
176
246
  fs.mkdirSync(EXPORTS_DIR, { recursive: true });
@@ -204,7 +274,7 @@ export function appendHistory(text) {
204
274
  return;
205
275
  }
206
276
  try {
207
- fs.mkdirSync(HOME_DIR, { recursive: true });
277
+ ensureHomeDir();
208
278
  fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(text)}\n`);
209
279
  // De vez en cuando lo recortamos para que no crezca sin fin
210
280
  const stat = fs.statSync(HISTORY_PATH);
package/dist/skills.js CHANGED
@@ -278,7 +278,8 @@ export function installSkills(source, root, options = {}) {
278
278
  throw new Error(`No encuentro ${source}: pon la ruta de una carpeta con SKILL.md o la dirección de un repositorio git.`);
279
279
  }
280
280
  temp = fs.mkdtempSync(path.join(os.tmpdir(), "vipcode-skill-"));
281
- const args = ["clone", "--depth", "1", ...(git.ref ? ["--branch", git.ref] : []), git.url, temp];
281
+ // Con «--» un origen que empiece por - no se toma como una opción de git (por ejemplo --upload-pack)
282
+ const args = ["clone", "--depth", "1", ...(git.ref ? [`--branch=${git.ref}`] : []), "--", git.url, temp];
282
283
  const clone = spawnSync("git", args, { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
283
284
  if (clone.error || clone.status !== 0) {
284
285
  throw new Error(`No he podido descargar ${git.url}: ${(clone.stderr || clone.error?.message || "").trim().split("\n").pop()}`);
package/dist/tools.js CHANGED
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import fg from "fast-glob";
5
5
  import { isImagePath, toDataUrl } from "./images.js";
6
+ import { stripControl } from "./sanitize.js";
7
+ import { protectedDirs } from "./paths.js";
6
8
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
7
9
  const MAX_OUTPUT = 20000;
8
10
  export function truncate(text, max = MAX_OUTPUT) {
@@ -13,7 +15,31 @@ export function truncate(text, max = MAX_OUTPUT) {
13
15
  return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
14
16
  }
15
17
  // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
16
- export function resolvePath(root, p) {
18
+ // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista
19
+ function realPathOf(file, depth = 0) {
20
+ try {
21
+ return fs.realpathSync(file);
22
+ }
23
+ catch {
24
+ // no existe (todavía) o es un enlace roto
25
+ }
26
+ if (depth < 40) {
27
+ try {
28
+ if (fs.lstatSync(file).isSymbolicLink()) {
29
+ return realPathOf(path.resolve(path.dirname(file), fs.readlinkSync(file)), depth + 1);
30
+ }
31
+ }
32
+ catch {
33
+ // no existe
34
+ }
35
+ }
36
+ const parent = path.dirname(file);
37
+ if (parent === file || depth >= 40) {
38
+ return file;
39
+ }
40
+ return path.join(realPathOf(parent, depth + 1), path.basename(file));
41
+ }
42
+ export function resolvePath(root, p, options = {}) {
17
43
  let input = p;
18
44
  if (!input) {
19
45
  input = ".";
@@ -22,8 +48,36 @@ export function resolvePath(root, p) {
22
48
  if (abs !== root && !abs.startsWith(root + path.sep)) {
23
49
  throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
24
50
  }
51
+ // Un enlace simbólico dentro del proyecto tampoco puede llevar fuera (por ejemplo a ~/.ssh o a tus claves)
52
+ const real = realPathOf(abs);
53
+ const realRoot = realPathOf(root);
54
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
55
+ throw new Error(`La ruta ${p} es un enlace que lleva fuera del proyecto (${real})`);
56
+ }
57
+ // vipcode no puede tocarse a sí mismo: ni sus datos (claves, configuración) ni, para escribir, su instalación
58
+ for (const { dir, read } of protectedDirs()) {
59
+ const realDir = realPathOf(dir);
60
+ if ((read || options.write) && (real === realDir || real.startsWith(realDir + path.sep))) {
61
+ if (read) {
62
+ throw new Error(`La ruta ${p} está en la carpeta de datos de vipcode (${dir}), que guarda tus claves y tu configuración: no se puede leer ni cambiar desde aquí.`);
63
+ }
64
+ throw new Error(`La ruta ${p} es parte de la instalación de vipcode (${dir}): vipcode no puede modificarse a sí mismo.`);
65
+ }
66
+ }
25
67
  return abs;
26
68
  }
69
+ // Patrones para que glob y grep no recorran la carpeta de datos de vipcode si está dentro del proyecto
70
+ function protectedIgnore(root) {
71
+ const realRoot = realPathOf(root);
72
+ const patterns = [];
73
+ for (const { dir, read } of protectedDirs()) {
74
+ const relative = path.relative(realRoot, realPathOf(dir));
75
+ if (read && relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
76
+ patterns.push(`${relative.split(path.sep).join("/")}/**`);
77
+ }
78
+ }
79
+ return patterns;
80
+ }
27
81
  export function rel(root, abs) {
28
82
  const r = path.relative(root, abs);
29
83
  if (r === "") {
@@ -284,7 +338,7 @@ const writeFile = {
284
338
  permission: "write",
285
339
  readOnly: false,
286
340
  async run(args, ctx) {
287
- const file = resolvePath(ctx.root, args.path);
341
+ const file = resolvePath(ctx.root, args.path, { write: true });
288
342
  const content = String(args.content ?? "");
289
343
  const exists = fs.existsSync(file);
290
344
  let previous = null;
@@ -415,7 +469,7 @@ const editFile = {
415
469
  permission: "write",
416
470
  readOnly: false,
417
471
  async run(args, ctx) {
418
- const file = resolvePath(ctx.root, args.path);
472
+ const file = resolvePath(ctx.root, args.path, { write: true });
419
473
  if (!fs.existsSync(file)) {
420
474
  return { output: `Error: ${args.path} no existe. Para crear un fichero usa write_file.`, isError: true };
421
475
  }
@@ -491,7 +545,7 @@ const globTool = {
491
545
  readOnly: true,
492
546
  async run(args, ctx) {
493
547
  const cwd = resolvePath(ctx.root, args.path);
494
- const files = await fg(String(args.pattern), { cwd, ignore: IGNORE, dot: true, onlyFiles: true });
548
+ const files = await fg(String(args.pattern), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
495
549
  if (files.length === 0) {
496
550
  return "Sin resultados.";
497
551
  }
@@ -563,6 +617,9 @@ const grepTool = {
563
617
  if (args.include) {
564
618
  rgArgs.push("-g", String(args.include));
565
619
  }
620
+ for (const pattern of protectedIgnore(ctx.root)) {
621
+ rgArgs.push("-g", `!${pattern}`);
622
+ }
566
623
  rgArgs.push(".");
567
624
  const found = await ripgrep(rgArgs, ctx.root, ctx.signal);
568
625
  if (found) {
@@ -594,7 +651,7 @@ const grepTool = {
594
651
  if (args.include) {
595
652
  include = String(args.include);
596
653
  }
597
- const files = await fg(include, { cwd: ctx.root, ignore: IGNORE, dot: false, onlyFiles: true });
654
+ const files = await fg(include, { cwd: ctx.root, ignore: [...IGNORE, ...protectedIgnore(ctx.root)], dot: false, onlyFiles: true, followSymbolicLinks: false });
598
655
  const results = [];
599
656
  for (const file of files) {
600
657
  const abs = path.join(ctx.root, file);
@@ -628,6 +685,11 @@ const grepTool = {
628
685
  return results.join("\n");
629
686
  },
630
687
  };
688
+ // Un .git/config con core.fsmonitor ejecutaría un programa en cada «git status» (que se aprueba solo)
689
+ function gitSafetyEnv() {
690
+ const count = Number(process.env.GIT_CONFIG_COUNT) || 0;
691
+ return { GIT_CONFIG_COUNT: String(count + 1), [`GIT_CONFIG_KEY_${count}`]: "core.fsmonitor", [`GIT_CONFIG_VALUE_${count}`]: "false" };
692
+ }
631
693
  const bashTool = {
632
694
  name: "bash",
633
695
  description: "Ejecuta un comando de shell en la raíz del proyecto (tests, git, instalar dependencias…). No lo uses para leer o editar ficheros: para eso están las otras herramientas. No es interactivo: evita comandos que esperen que escribas algo.",
@@ -665,7 +727,7 @@ const bashTool = {
665
727
  }
666
728
  const child = spawn(shell, shellArgs, {
667
729
  cwd: ctx.root,
668
- env: { ...process.env, VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
730
+ env: { ...process.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
669
731
  stdio: ["ignore", "pipe", "pipe"],
670
732
  // Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
671
733
  detached: process.platform !== "win32",
@@ -916,7 +978,7 @@ export function formatTodos(todos) {
916
978
  if (todos.length === 0) {
917
979
  return "(lista de tareas vacía)";
918
980
  }
919
- return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${todo.content}`).join("\n");
981
+ return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${stripControl(String(todo.content))}`).join("\n");
920
982
  }
921
983
  const todoWrite = {
922
984
  name: "todowrite",
@@ -1184,7 +1246,9 @@ export function projectFiles(root) {
1184
1246
  }
1185
1247
  let files = [];
1186
1248
  try {
1187
- files = fg.sync("**/*", { cwd: root, ignore: IGNORE, dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true }).slice(0, 20000);
1249
+ files = fg
1250
+ .sync("**/*", { cwd: root, ignore: [...IGNORE, ...protectedIgnore(root)], dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true, followSymbolicLinks: false })
1251
+ .slice(0, 20000);
1188
1252
  }
1189
1253
  catch {
1190
1254
  files = [];