chocolatito-code 1.0.0 → 1.2.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.
Files changed (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { isBrowserAttached } from "../tools/browserSession.js";
4
5
  /**
5
6
  * Acciones de SOLO OBSERVAR de cada herramienta de control.
6
7
  *
@@ -30,19 +31,131 @@ const OBSERVE_ONLY = {
30
31
  };
31
32
  /** Herramientas que conducen el ordenador o el navegador del usuario. */
32
33
  const CONTROL_TOOLS = Object.keys(OBSERVE_ONLY);
34
+ /**
35
+ * Herramientas cuyas acciones "observadoras" pueden ARRANCAR el navegador.
36
+ *
37
+ * browserCdp llama a ensureBrowser() en todas las acciones menos "close", y
38
+ * ensureBrowser, si no encuentra un Chrome escuchando el puerto de depuracion,
39
+ * lo lanza con spawn(detached) y lo deja vivo cuando la CLI termina. Con
40
+ * useSystemProfile lo lanza sobre el perfil REAL del usuario —sus cookies y sus
41
+ * sesiones— con el 9222 abierto en 127.0.0.1, que cualquier proceso local puede
42
+ * conducir. Clasificar eso como "solo observar" no lo hacia inofensivo: lo hacia
43
+ * silencioso, y ademas lo colaba en modo plan.
44
+ *
45
+ * La regla sigue siendo "mirar es gratis", pero mirar solo es gratis cuando no
46
+ * hay nada que arrancar: con una sesion CDP ya viva, snapshot y tabs no hacen
47
+ * mas que leer.
48
+ */
49
+ const BROWSER_TOOLS = new Set(["browser", "browser_live"]);
33
50
  /** Herramientas que modifican el disco o el repositorio. */
34
51
  const MODIFYING_TOOLS = ["edit_file", "write_file", "delete_file", "move_copy_file", "git_commit"];
52
+ /**
53
+ * Argumentos con ruta de cada herramienta que escribe en disco.
54
+ *
55
+ * Sirve para lo unico que la tabla no comprobaba en ningun sitio: si lo que se
56
+ * va a tocar cae dentro del proyecto o en cualquier otro punto del disco.
57
+ */
58
+ const RUTAS_DE_ESCRITURA = {
59
+ write_file: ["path"],
60
+ edit_file: ["path"],
61
+ delete_file: ["path"],
62
+ move_copy_file: ["sourcePath", "destinationPath"],
63
+ create_image: ["outputPath"],
64
+ };
65
+ /**
66
+ * Una ruta esta fuera del proyecto si, resuelta, no cuelga de la raiz.
67
+ *
68
+ * Vale para rutas absolutas (C:\Windows\..., ~/.ssh/...) y para las relativas
69
+ * con ".." que salen del arbol, que es la forma que no se ve venir leyendo la
70
+ * llamada por encima. path.relative en Windows ya compara sin distinguir
71
+ * mayusculas y devuelve la ruta entera cuando cambia de unidad.
72
+ */
73
+ export function isOutsideProject(projectRoot, target) {
74
+ if (typeof target !== "string" || !target.trim())
75
+ return false;
76
+ const raiz = path.resolve(projectRoot);
77
+ const destino = path.resolve(raiz, target);
78
+ const rel = path.relative(raiz, destino);
79
+ if (rel === "")
80
+ return false;
81
+ return rel.startsWith("..") || path.isAbsolute(rel);
82
+ }
83
+ /**
84
+ * create_image mete dos cosas muy distintas en el mismo argumento: un SVG, que
85
+ * solo se escribe en disco, o codigo Python, que se guarda en un .py y se lanza
86
+ * con `python`. Quien lo distingue es esta funcion, y la usan tanto la
87
+ * herramienta como la tabla de permisos: si cada una detectara el SVG a su
88
+ * manera, bastaria una diferencia entre las dos para colar codigo por la rama
89
+ * que no pregunta.
90
+ */
91
+ export function isSvgSource(script) {
92
+ const s = typeof script === "string" ? script.trim() : "";
93
+ return s.startsWith("<svg") || s.startsWith("<?xml");
94
+ }
95
+ /**
96
+ * create_image con Python es ejecucion de codigo arbitrario con nombre de
97
+ * herramienta de dibujo: el contenido que escriba el modelo acaba corriendo
98
+ * entero, no solo la parte que pinta. La especificacion la clasifica como
99
+ * `exec`, igual que run_command; la tabla la trataba como una escritura de
100
+ * archivo corriente porque clasificaba por nombre y este no salia en ninguna
101
+ * lista.
102
+ */
103
+ function isCodeGeneration(toolName, args) {
104
+ return toolName === "create_image" && !isSvgSource(args?.generatorScript);
105
+ }
35
106
  /** Solo lee estado, no toca nada. */
36
107
  function isObserveOnly(toolName, args) {
37
108
  const set = OBSERVE_ONLY[toolName];
38
109
  if (!set)
39
110
  return false;
40
- return set.has(String(args?.action ?? ""));
111
+ if (!set.has(String(args?.action ?? "")))
112
+ return false;
113
+ // Sin sesion CDP viva, hasta el "attach" mas inocente puede acabar en un
114
+ // spawn(chrome.exe, --remote-debugging-port) que sobrevive a la sesion. Eso no
115
+ // es observar: es arrancar un proceso, y se pregunta.
116
+ if (BROWSER_TOOLS.has(toolName) && !isBrowserAttached())
117
+ return false;
118
+ return true;
41
119
  }
42
120
  /** Conduce de verdad el ordenador o el navegador: clics, teclado, navegacion, JS. */
43
121
  function isActuation(toolName, args) {
44
122
  return CONTROL_TOOLS.includes(toolName) && !isObserveOnly(toolName, args);
45
123
  }
124
+ /**
125
+ * Prefijo de git que admite opciones globales antes del subcomando.
126
+ *
127
+ * El regex anterior exigia el subcomando pegado a "git", asi que
128
+ * "git -C otra/ruta checkout -- ." no lo veia nadie: bastaba con decirle a git
129
+ * en que repositorio trabajar para saltarse el filtro entero.
130
+ */
131
+ const GIT = String.raw `\bgit(?:\s+(?:-[cC]\s+\S+|--[\w-]+(?:=\S+)?|-[a-zA-Z]))*\s+`;
132
+ /**
133
+ * Git que destruye trabajo o pisa el remoto.
134
+ *
135
+ * Antes esto era un solo regex con las alternativas metidas en un grupo y un
136
+ * \b de cierre fuera: /\bgit\s+(...|restore\s+\.|checkout\s+\.|...)\b/. Como
137
+ * "restore \." y "checkout \." acaban en punto, que no es caracter de palabra,
138
+ * ese \b final NUNCA casaba y las dos ramas estaban muertas. Resultado: la regla
139
+ * quedaba del reves, "git checkout .gitignore" pedia dialogo (ahi el \b si
140
+ * encontraba frontera) y "git checkout .", que borra el arbol entero, se
141
+ * ejecutaba sin preguntar. Es el mismo fallo que el comentario de "format c:"
142
+ * dice haber arreglado, dejado intacto aqui.
143
+ *
144
+ * Ahora es una regla por forma destructiva, sin \b traicioneros.
145
+ */
146
+ const GIT_DESTRUCTIVO = [
147
+ new RegExp(GIT + String.raw `reset\s+--hard`, "i"),
148
+ new RegExp(GIT + String.raw `clean\s+-[a-z]*[fxd]`, "i"),
149
+ new RegExp(GIT + String.raw `push\b[^\n]*(\s-f\b|--force)`, "i"),
150
+ new RegExp(GIT + String.raw `branch\s+-D\b`, "i"),
151
+ // "restore" existe justamente para descartar cambios del arbol de trabajo: no
152
+ // hay forma inocente de usarlo, ni siquiera --staged, que deshace el stage.
153
+ new RegExp(GIT + String.raw `restore\b`, "i"),
154
+ // "checkout" tambien cambia de rama, que no destruye nada. Lo que borra
155
+ // trabajo es el pathspec: un "." pelado, un "*", cualquier cosa detras de
156
+ // "--" (incluido el "git checkout HEAD -- ." de toda la vida) o un -f.
157
+ new RegExp(GIT + String.raw `checkout\b[^\n]*(\s--(\s|$)|\s\.(\s|$)|\s\*(\s|$)|\s-f\b|\s--force\b)`, "i"),
158
+ ];
46
159
  /**
47
160
  * Detecta comandos destructivos o de alto riesgo que siempre deben pedir confirmación.
48
161
  * Comandos seguros de desarrollo (python, npm, pytest, tsc, git status, etc.) no interrumpen al usuario.
@@ -54,13 +167,13 @@ export function isDangerousCommand(command) {
54
167
  // 1. Borrado recursivo o forzado de archivos/directorios.
55
168
  // Ojo con \b al final: "format c:" termina en ':', que no es caracter de
56
169
  // palabra, asi que un \b de cierre nunca casa y el patron no servia de nada.
57
- if (/\b(rm\s+[^\n]*-[^\n]*[rf]|rmdir\s+[^\n]*\/s|\brd\s+[^\n]*\/s|del\s+[^\n]*\/[fsq]|Remove-Item\s+[^\n]*-Recurse|mkfs)\b/i.test(cmd) ||
170
+ if (/\b(rm\s+[^\n]*-[^\n]*[rf]|rmdir\s+[^\n]*\/s|\brd\s+[^\n]*\/s|del\s+[^\n]*\/[fsq]|Remove-Item\s+[^\n]*-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?\b|mkfs)\b/i.test(cmd) ||
58
171
  /\bformat\s+[a-z]:/i.test(cmd) ||
59
172
  /\bdd\s+[^\n]*\bof=/i.test(cmd)) {
60
173
  return true;
61
174
  }
62
175
  // 2. Operaciones destructivas de Git (pérdida irreversible de trabajo o sobreescritura remota)
63
- if (/\bgit\s+(reset\s+--hard|clean\s+-[fxd]+|push\s+[^\n]*(-f\b|--force)|restore\s+\.|checkout\s+\.|branch\s+-D\b)\b/i.test(cmd)) {
176
+ if (GIT_DESTRUCTIVO.some((re) => re.test(cmd))) {
64
177
  return true;
65
178
  }
66
179
  // 3. Terminación de procesos y apagado del sistema
@@ -95,13 +208,91 @@ export function isDangerousCommand(command) {
95
208
  }
96
209
  return false;
97
210
  }
211
+ /**
212
+ * Cuanto restringe cada modo. Sirve para decidir si una capa de settings sube o
213
+ * baja el liston: el proyecto solo puede subirlo.
214
+ */
215
+ const RESTRICCION = {
216
+ bypass: 0,
217
+ acceptEdits: 1,
218
+ default: 2,
219
+ plan: 3,
220
+ };
221
+ /**
222
+ * Marcas de "aqui empieza otro comando".
223
+ *
224
+ * La lista anterior era /[;&|]/ y se dejaba fuera el salto de linea, que en
225
+ * PowerShell y en bash separa sentencias igual que un punto y coma. runCommand
226
+ * le pasa al shell la cadena entera de una pieza, asi que con un patron tan
227
+ * inocente como "npm" guardado (loop.ts guarda solo el primer token del comando
228
+ * aprobado: aceptar una vez `npm publish` guarda "npm"), esto se ejecutaba
229
+ * entero y sin un solo dialogo:
230
+ *
231
+ * npm test
232
+ * Remove-Item -Recurse -Force C:\Users\yo\proyecto
233
+ *
234
+ * Van tambien el retorno de carro y las dos formas de sustitucion de comandos,
235
+ * `...` y $(...), que lanzan un segundo proceso sin necesitar separador.
236
+ */
237
+ const ENCADENADO = /[;&|\r\n`]|\$\(/;
238
+ /** Trocea la linea en sentencias sueltas. Un deny tiene que mirarlas todas. */
239
+ function sentencias(command) {
240
+ return command
241
+ .split(/[;\r\n]+|&&|\|\||[|&]/)
242
+ .map((s) => s.trim())
243
+ .filter(Boolean);
244
+ }
245
+ /**
246
+ * Piezas que por si solas no dicen que hace el comando: hay que mirar la
247
+ * siguiente. "npm run" puede ser cualquier cosa; "npm run build" ya no.
248
+ */
249
+ const DESPACHADORES = new Set(["run", "run-script", "exec", "x", "dlx", "workspace"]);
250
+ /**
251
+ * Firma con la que se recuerda un comando aprobado.
252
+ *
253
+ * El bucle guardaba command.split(" ")[0], o sea solo el binario: aprobar un
254
+ * "git status" dejaba escrito el patron "git" y, como el allow autoriza por
255
+ * prefijo, a partir de ahi pasaban sin dialogo tambien "git push --force" y
256
+ * "git reset --hard", en todas las sesiones del proyecto.
257
+ *
258
+ * La firma es binario + subcomando ("git status", "npm run build"). Si el
259
+ * comando no trae subcomando sino opciones se guarda entero: ensancharlo hasta
260
+ * el binario es justo el fallo que se esta arreglando.
261
+ */
262
+ export function commandSignature(command) {
263
+ if (!command || typeof command !== "string")
264
+ return "";
265
+ // Lo encadenado detras de ; && | no forma parte de lo que el usuario aprobo.
266
+ const primero = command.trim().split(/[;&|\r\n]/)[0].trim();
267
+ const partes = primero.split(/\s+/).filter(Boolean);
268
+ if (partes.length === 0)
269
+ return "";
270
+ const firma = [partes[0]];
271
+ for (let i = 1; i < partes.length && firma.length < 3; i++) {
272
+ const pieza = partes[i];
273
+ // Una opcion ya no describe que hace el comando; el subcomando, si.
274
+ if (pieza.startsWith("-"))
275
+ break;
276
+ firma.push(pieza);
277
+ if (!DESPACHADORES.has(pieza))
278
+ break;
279
+ }
280
+ // Sin subcomando se guarda el comando entero. Reducirlo al binario es
281
+ // exactamente lo que dejaba "git" autorizando cualquier git.
282
+ if (firma.length === 1 && partes.length > 1)
283
+ return primero;
284
+ return firma.join(" ");
285
+ }
98
286
  export class PermissionManager {
99
287
  mode = "default";
100
288
  allowPatterns = new Set();
101
289
  denyPatterns = new Set();
102
290
  settingsFilePath;
291
+ /** Raiz del proyecto: el limite dentro del cual escribir no interrumpe. */
292
+ projectRoot;
103
293
  constructor(initialMode = "default", cwd = process.cwd()) {
104
294
  this.mode = initialMode;
295
+ this.projectRoot = path.resolve(cwd);
105
296
  this.settingsFilePath = path.join(cwd, ".chocolatito", "settings.json");
106
297
  this.loadSettings();
107
298
  }
@@ -111,7 +302,7 @@ export class PermissionManager {
111
302
  if (fs.existsSync(globalPath)) {
112
303
  try {
113
304
  const parsed = JSON.parse(fs.readFileSync(globalPath, "utf-8"));
114
- this.applySettings(parsed);
305
+ this.applySettings(parsed, "global");
115
306
  }
116
307
  catch { }
117
308
  }
@@ -119,14 +310,33 @@ export class PermissionManager {
119
310
  if (fs.existsSync(this.settingsFilePath)) {
120
311
  try {
121
312
  const parsed = JSON.parse(fs.readFileSync(this.settingsFilePath, "utf-8"));
122
- this.applySettings(parsed);
313
+ this.applySettings(parsed, "proyecto");
123
314
  }
124
315
  catch { }
125
316
  }
126
317
  }
127
- applySettings(settings) {
128
- if (settings.permissions?.mode) {
129
- this.mode = settings.permissions.mode;
318
+ /**
319
+ * Aplica una capa de settings.
320
+ *
321
+ * La capa del proyecto solo puede RESTRINGIR. Antes se aplicaba despues de la
322
+ * global y fijaba el modo sin mas, asi que un repositorio clonado con
323
+ * {"permissions":{"mode":"bypass"}} en su .chocolatito/settings.json apagaba
324
+ * los dialogos en la maquina de quien lo abriera, sin decir nada. Elevar
325
+ * permisos es cosa del usuario: su settings global o la linea de comandos.
326
+ */
327
+ applySettings(settings, origen) {
328
+ const modo = settings.permissions?.mode;
329
+ if (modo) {
330
+ if (origen === "global" || RESTRICCION[modo] >= RESTRICCION[this.mode]) {
331
+ this.mode = modo;
332
+ }
333
+ else {
334
+ // Se avisa por pantalla: un intento de elevacion silencioso es
335
+ // exactamente lo que hay que ver, no lo que hay que tragarse.
336
+ console.warn(`Aviso de permisos: el settings.json de este proyecto pide el modo "${modo}" y se ignora. ` +
337
+ `Un repositorio no puede elevar los permisos de tu maquina; sigues en "${this.mode}". ` +
338
+ `Si lo quieres de verdad, ponlo en tu settings global o cambia de modo con shift+tab.`);
339
+ }
130
340
  }
131
341
  if (settings.permissions?.allowPatterns) {
132
342
  settings.permissions.allowPatterns.forEach((p) => this.allowPatterns.add(p));
@@ -140,9 +350,21 @@ export class PermissionManager {
140
350
  const dir = path.dirname(this.settingsFilePath);
141
351
  if (!fs.existsSync(dir))
142
352
  fs.mkdirSync(dir, { recursive: true });
353
+ // El modo no se vuelca desde memoria. El usuario puede estar en bypass
354
+ // temporal (shift+tab) y dejarlo escrito en un fichero que se commitea es
355
+ // justo el agujero de arriba, servido por nosotros mismos. Se respeta lo que
356
+ // ya hubiera en el fichero y punto.
357
+ let modoEnDisco;
358
+ try {
359
+ if (fs.existsSync(this.settingsFilePath)) {
360
+ const previo = JSON.parse(fs.readFileSync(this.settingsFilePath, "utf-8"));
361
+ modoEnDisco = previo.permissions?.mode;
362
+ }
363
+ }
364
+ catch { }
143
365
  const config = {
144
366
  permissions: {
145
- mode: this.mode,
367
+ ...(modoEnDisco ? { mode: modoEnDisco } : {}),
146
368
  allowPatterns: Array.from(this.allowPatterns),
147
369
  denyPatterns: Array.from(this.denyPatterns),
148
370
  },
@@ -170,13 +392,42 @@ export class PermissionManager {
170
392
  if (toolName === "run_command") {
171
393
  return "El modo plan es de solo lectura: no se ejecutan comandos. Investiga con view_file, grep_search y list_dir.";
172
394
  }
395
+ // Lanzar Chrome es arrancar un proceso, no mirar: en un modo que promete
396
+ // solo lectura no cabe, aunque la accion pedida se llame "snapshot".
397
+ if (BROWSER_TOOLS.has(toolName) && !isBrowserAttached()) {
398
+ return (`El modo plan es de solo lectura: no hay ningun Chrome enganchado, asi que "${toolName}" tendria que ` +
399
+ "lanzarlo con el puerto de depuracion abierto. Sobre un navegador ya enganchado si puedes mirar.");
400
+ }
173
401
  if (isActuation(toolName, args)) {
174
402
  return (`El modo plan es de solo lectura: "${toolName}" puede mirar (snapshot, screenshot, tabs) ` +
175
403
  "pero no actuar sobre el ordenador ni el navegador del usuario.");
176
404
  }
405
+ // Las dos ramas de create_image tienen efecto en disco: la de Python ejecuta
406
+ // el script y la de SVG escribe el archivo en cualquier ruta absoluta. Sin
407
+ // esto, el modo plan era de solo lectura para write_file y run_command pero
408
+ // no para la herramienta que hace las dos cosas a la vez.
409
+ if (toolName === "create_image") {
410
+ return isCodeGeneration(toolName, args)
411
+ ? 'El modo plan es de solo lectura: "create_image" ejecuta el script generador con python. Descríbelo en el plan y ejecútalo al salir del modo plan.'
412
+ : 'El modo plan es de solo lectura: "create_image" escribe el archivo en disco.';
413
+ }
177
414
  return null;
178
415
  }
179
- requiresConfirmation(toolName, args) {
416
+ /**
417
+ * `cwd` es contra que directorio se mide "dentro del proyecto". Tiene que ser
418
+ * el MISMO que recibe executeToolCall, porque es contra el que las
419
+ * herramientas resuelven las rutas relativas: si aqui se midiera contra la
420
+ * raiz de la sesion y el bucle escribiera desde otro directorio (currentCwd
421
+ * cambia con cd), un "x.txt" saldria "dentro" en la comprobacion y caeria
422
+ * fuera al escribirse. Sin argumento se usa la raiz con la que se construyo.
423
+ */
424
+ requiresConfirmation(toolName, args, cwd) {
425
+ // El deny va lo primero, antes incluso del bypass: si el usuario escribio
426
+ // algo en denyPatterns es que no lo quiere ver nunca, no "salvo que hoy
427
+ // tenga prisa". Quien llame a esto deberia usar deniedBy y cortar del todo;
428
+ // devolver true aqui es la red por si alguien solo mira este metodo.
429
+ if (this.deniedBy(toolName, args))
430
+ return true;
180
431
  if (this.mode === "bypass")
181
432
  return false;
182
433
  if (this.mode === "plan")
@@ -192,6 +443,21 @@ export class PermissionManager {
192
443
  if (toolName === "delete_file") {
193
444
  return !this.isPatternAllowed(toolName, args);
194
445
  }
446
+ // Mover o copiar encima de algo que ya existe borra el destino, exactamente
447
+ // igual que un delete_file, y hasta ahora caia en la rama de abajo y pasaba
448
+ // sin dialogo. La herramienta se niega a pisar nada sin overwrite, asi que
449
+ // este flag es la declaracion de intenciones de destruir el destino y se
450
+ // trata como el borrado que es. Se mira el argumento y no el disco: esta
451
+ // tabla decide con lo que pide el modelo, no con lo que haya en el sistema
452
+ // de ficheros en ese instante.
453
+ if (toolName === "move_copy_file" && args?.overwrite === true) {
454
+ return !this.isPatternAllowed(toolName, args);
455
+ }
456
+ // create_image que no sea SVG se pregunta como un comando: lo que corre es
457
+ // el texto que ha escrito el modelo, y ni el usuario ni /undo lo ven venir.
458
+ if (isCodeGeneration(toolName, args)) {
459
+ return !this.isPatternAllowed(toolName, args);
460
+ }
195
461
  // Comandos de terminal: solo preguntar si son destructivos o peligrosos
196
462
  if (toolName === "run_command") {
197
463
  const command = typeof args?.command === "string" ? args.command : "";
@@ -200,12 +466,85 @@ export class PermissionManager {
200
466
  }
201
467
  return false;
202
468
  }
469
+ // Escribir FUERA del proyecto no es una edicion, es tocar la maquina.
470
+ //
471
+ // El comentario de abajo lleva desde siempre diciendo "en el proyecto", pero
472
+ // nadie comprobaba la ruta: ni aqui ni en writeFile/editFile/moveCopyFile,
473
+ // que hacen path.resolve y siguen adelante (writeFile hasta crea los
474
+ // directorios que falten). Asi que write_file sobre C:\Windows\... o
475
+ // move_copy_file hacia ../../otro-proyecto salian sin un solo dialogo,
476
+ // mientras que el MISMO cambio hecho con run_command si lo pedia
477
+ // (isDangerousCommand, punto 4). La misma accion, bloqueada por un camino y
478
+ // abierta por el otro.
479
+ //
480
+ // Y /undo no es red aqui: vive solo en memoria de la sesion.
481
+ const fuera = this.rutasFueraDelProyecto(toolName, args, cwd);
482
+ if (fuera.length > 0) {
483
+ return !this.isPatternAllowed(toolName, args);
484
+ }
203
485
  // Ediciones y escrituras en el proyecto: fluidas (el usuario puede usar /undo)
204
486
  if (this.mode === "acceptEdits" || this.mode === "default") {
205
487
  return false;
206
488
  }
207
489
  return false;
208
490
  }
491
+ /**
492
+ * Rutas de la llamada que caen fuera de la raiz del proyecto. Vacio si todo
493
+ * queda dentro (o si la herramienta no escribe en disco: leer fuera del
494
+ * proyecto es legitimo y no se pregunta).
495
+ */
496
+ rutasFueraDelProyecto(toolName, args, cwd) {
497
+ const campos = RUTAS_DE_ESCRITURA[toolName];
498
+ if (!campos)
499
+ return [];
500
+ const raiz = cwd ? path.resolve(cwd) : this.projectRoot;
501
+ // create_image con SVG tambien escribe donde le digan; la rama de Python ya
502
+ // se pregunta antes por ser ejecucion de codigo.
503
+ return campos
504
+ .map((campo) => args?.[campo])
505
+ .filter((ruta) => isOutsideProject(raiz, ruta))
506
+ .map((ruta) => String(ruta));
507
+ }
508
+ /**
509
+ * Patron denegado: devuelve cual, o null. Corta la llamada entera; no es un
510
+ * "pregunta antes".
511
+ *
512
+ * denyPatterns se leia del settings.json y se volvia a guardar tal cual, pero
513
+ * NADIE lo consultaba: la unica lista que se miraba era la de allow. Es decir,
514
+ * prohibir algo por configuracion no hacia absolutamente nada.
515
+ *
516
+ * La coincidencia es la misma que la del allow (firma exacta o prefijo de
517
+ * comando) con una diferencia deliberada: un allow no puede autorizar lo que
518
+ * venga encadenado detras, pero un deny si tiene que alcanzarlo. Si no,
519
+ * "echo hola && git push --force" se saltaria un deny de "git push" con solo
520
+ * poner algo inocente delante.
521
+ */
522
+ deniedBy(toolName, args) {
523
+ if (this.denyPatterns.size === 0)
524
+ return null;
525
+ const command = typeof args?.command === "string" ? args.command.trim() : "";
526
+ const detail = String(args?.path ?? args?.outputPath ?? args?.action ?? "");
527
+ const trozos = command ? sentencias(command) : [];
528
+ for (const pattern of this.denyPatterns) {
529
+ const p = pattern.trim();
530
+ if (!p)
531
+ continue;
532
+ // Denegar una herramienta entera ("delete_file", "chrome") es una peticion
533
+ // razonable. Al reves, autorizarla entera, no lo es: por eso el allow no
534
+ // acepta esta forma.
535
+ if (p === toolName)
536
+ return pattern;
537
+ if (this.coincideExacta(p, toolName, command, detail))
538
+ return pattern;
539
+ if (this.coincidePrefijo(p, toolName, command))
540
+ return pattern;
541
+ for (const trozo of trozos) {
542
+ if (this.coincidePrefijo(p, toolName, trozo))
543
+ return pattern;
544
+ }
545
+ }
546
+ return null;
547
+ }
209
548
  /**
210
549
  * Un patron autorizado vale para ESE comando, no para cualquiera que lo
211
550
  * contenga. Con la comparacion por subcadena de antes, autorizar "git status"
@@ -214,26 +553,43 @@ export class PermissionManager {
214
553
  */
215
554
  isPatternAllowed(toolName, args) {
216
555
  const command = typeof args?.command === "string" ? args.command.trim() : "";
217
- const detail = String(args?.path ?? args?.action ?? "");
556
+ const detail = String(args?.path ?? args?.outputPath ?? args?.action ?? "");
557
+ // Si la linea trae mas de una sentencia, ningun prefijo la autoriza: lo que
558
+ // va detras del separador no lo ha aprobado nadie. Solo vale entonces la
559
+ // firma exacta, que es el comando completo escrito a mano.
560
+ const encadenado = ENCADENADO.test(command);
561
+ // Segunda barrera, independiente de con que patron se guardo: lo
562
+ // irreversible se pregunta siempre. Los patrones demasiado anchos que ya
563
+ // estan escritos en el settings.json de mucha gente ("git", "rm") quedan
564
+ // asi desactivados sin tener que ir a buscarlos uno a uno.
565
+ if (command && isDangerousCommand(command))
566
+ return false;
218
567
  for (const pattern of this.allowPatterns) {
219
568
  const p = pattern.trim();
220
569
  if (!p)
221
570
  continue;
222
- // Firma exacta de la llamada: "run_command:npm test".
223
- if (p === `${toolName}:${command || detail}`)
571
+ if (this.coincideExacta(p, toolName, command, detail))
224
572
  return true;
225
- // Un prefijo de comando ("npm", "git status") autoriza sus variantes, pero
226
- // solo al principio de la linea y sin otro comando encadenado detras.
227
- if (command && toolName === "run_command") {
228
- const bare = p.startsWith("run_command:") ? p.slice("run_command:".length) : p;
229
- if (bare && (command === bare || command.startsWith(`${bare} `)) && !/[;&|]/.test(command)) {
230
- return true;
231
- }
232
- }
233
- // Rutas y acciones: coincidencia exacta.
234
- if (detail && (p === detail || p === `${toolName}:${detail}`))
573
+ if (!encadenado && this.coincidePrefijo(p, toolName, command))
235
574
  return true;
236
575
  }
237
576
  return false;
238
577
  }
578
+ /** Firma exacta de la llamada ("run_command:npm test") o ruta/accion exacta. */
579
+ coincideExacta(p, toolName, command, detail) {
580
+ if (p === `${toolName}:${command || detail}`)
581
+ return true;
582
+ if (detail && (p === detail || p === `${toolName}:${detail}`))
583
+ return true;
584
+ return false;
585
+ }
586
+ /** Prefijo de comando ("npm", "git status"), solo al principio de la linea. */
587
+ coincidePrefijo(p, toolName, command) {
588
+ if (!command || toolName !== "run_command")
589
+ return false;
590
+ const bare = p.startsWith("run_command:") ? p.slice("run_command:".length) : p;
591
+ if (!bare)
592
+ return false;
593
+ return command === bare || command.startsWith(`${bare} `);
594
+ }
239
595
  }
@@ -63,21 +63,44 @@ function load() {
63
63
  return [];
64
64
  }
65
65
  }
66
+ /**
67
+ * Guarda el libro de gasto de forma atomica.
68
+ *
69
+ * Antes se escribia directamente sobre usage.json. Si el proceso moria a mitad
70
+ * -Ctrl+C, un cierre de sesion de Windows, la bateria- el archivo quedaba
71
+ * truncado, load() no podia parsearlo y devolvia la lista vacia: el contador de
72
+ * cuota se ponia a cero y el usuario empezaba el mes de nuevo a costa nuestra.
73
+ *
74
+ * Escribir en un temporal y renombrar lo evita: el renombrado es atomico en el
75
+ * mismo sistema de archivos, asi que usage.json o es el de antes o es el nuevo,
76
+ * nunca la mitad de uno.
77
+ */
66
78
  function save(entries) {
67
79
  try {
68
80
  const dir = path.dirname(USAGE_FILE);
69
81
  if (!fs.existsSync(dir))
70
82
  fs.mkdirSync(dir, { recursive: true });
71
- fs.writeFileSync(USAGE_FILE, JSON.stringify(entries), "utf-8");
83
+ const temporal = `${USAGE_FILE}.${process.pid}.tmp`;
84
+ fs.writeFileSync(temporal, JSON.stringify(entries), "utf-8");
85
+ fs.renameSync(temporal, USAGE_FILE);
72
86
  }
73
87
  catch {
74
88
  // Si no se puede escribir, se sigue trabajando. Perder la cuenta es malo;
75
89
  // dejar al usuario tirado por no poder escribir un archivo es peor.
90
+ try {
91
+ fs.unlinkSync(`${USAGE_FILE}.${process.pid}.tmp`);
92
+ }
93
+ catch { }
76
94
  }
77
95
  }
78
96
  export function recordUsage(costUsd, ahora = Date.now()) {
79
97
  if (!Number.isFinite(costUsd) || costUsd <= 0)
80
98
  return;
99
+ // Se relee justo antes de escribir, no al principio del turno: con dos
100
+ // sesiones abiertas, la que guardaba segunda pisaba el apunte de la primera y
101
+ // ese gasto desaparecia del libro. Releer aqui no elimina la carrera -harian
102
+ // falta cerrojos entre procesos- pero reduce la ventana a los microsegundos
103
+ // entre esta lectura y el renombrado.
81
104
  const entries = load();
82
105
  entries.push({ t: ahora, c: costUsd });
83
106
  save(entries);
@@ -0,0 +1,68 @@
1
+ /** Cada cuanto se le pregunta al registro. Doce veces al dia no aporta nada. */
2
+ export declare const HORAS_ENTRE_COMPROBACIONES = 6;
3
+ export interface CacheDeActualizacion {
4
+ /** Milisegundos epoch de la ultima vez que se pregunto. */
5
+ comprobadoEn?: number;
6
+ /** Ultima version que dijo el registro. */
7
+ ultima?: string;
8
+ /** Version que ya se instalo sola, para no volver a anunciarla. */
9
+ avisadaDe?: string;
10
+ }
11
+ /**
12
+ * Compara dos versiones de tipo 1.2.3.
13
+ *
14
+ * No se usa una libreria por una razon concreta: `semver` arrastra dependencias
15
+ * y esto se ejecuta en el arranque de un CLI. Lo que si se respeta es lo unico
16
+ * que importa aqui: una preversion (1.1.0-beta.1) NUNCA es mas nueva que la
17
+ * final del mismo numero, o el usuario acabaria bajando a una beta sin pedirlo.
18
+ */
19
+ export declare function esMasNueva(candidata: string, actual: string): boolean;
20
+ /**
21
+ * De donde sale el `chocolatito` que se esta ejecutando.
22
+ *
23
+ * Si al lado del `dist` hay un `.git` o un `src`, esto es el repositorio, no un
24
+ * paquete instalado: `npm link` o `node dist/index.js` a pelo. Instalar encima
25
+ * seria pisarle el codigo al que lo esta escribiendo.
26
+ */
27
+ export declare function esInstalacionDeDesarrollo(raizDelPaquete: string): boolean;
28
+ export declare function leerCache(fichero?: string): CacheDeActualizacion;
29
+ export declare function guardarCache(datos: CacheDeActualizacion, fichero?: string): void;
30
+ /** Si toca preguntar al registro. Un reloj adelantado no deja de comprobar nunca. */
31
+ export declare function tocaComprobar(cache: CacheDeActualizacion, ahora: number): boolean;
32
+ /** Le pregunta al registro. Devuelve null si no contesta a tiempo o no se entiende. */
33
+ export declare function ultimaVersionPublicada(url?: string): Promise<string | null>;
34
+ export interface ResultadoDeInstalacion {
35
+ ok: boolean;
36
+ /** Que decirle al usuario. Vacio significa que no hay nada que decir. */
37
+ mensaje: string;
38
+ }
39
+ /**
40
+ * Instala la version nueva en la carpeta global.
41
+ *
42
+ * Se hace con `npm install -g`, que es como se instalo, en vez de inventarse
43
+ * una descarga: asi funciona igual con nvm, con volta y con la instalacion de
44
+ * Windows, que ponen la carpeta global en sitios distintos.
45
+ */
46
+ export declare function instalar(version: string, ejecutar?: typeof spawnNpm): Promise<ResultadoDeInstalacion>;
47
+ declare function spawnNpm(args: string[]): Promise<{
48
+ codigo: number;
49
+ salida: string;
50
+ }>;
51
+ export interface Ajustes {
52
+ versionActual: string;
53
+ raizDelPaquete: string;
54
+ ahora: number;
55
+ /** Fuerza la comprobacion aunque no toque, para el comando /actualizar. */
56
+ forzar?: boolean;
57
+ automatico?: boolean;
58
+ }
59
+ /**
60
+ * El paso completo: comprobar, y si procede instalar.
61
+ *
62
+ * Devuelve lo que hay que ensenarle al usuario, o cadena vacia si no hay nada.
63
+ * No imprime nada por su cuenta: quien llama decide cuando y como, que en un
64
+ * CLI con un prompt dibujado no es lo mismo escribir ahora que dentro de un
65
+ * segundo.
66
+ */
67
+ export declare function comprobarActualizacion(ajustes: Ajustes): Promise<string>;
68
+ export {};