@ingeniomaps/cauce 0.62.0 → 0.63.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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,42 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.63.0] - 2026-09-06
18
+
19
+ ### Corregido
20
+
21
+ - **El salto de línea termina la lista de argumentos de un comando.** `shell-boundary` leía los
22
+ argumentos de un `cp`, un `tee` o un `sed -i` cruzando a la línea siguiente, así que el destino que
23
+ acusaba podía ser el comando de abajo: un bloqueo que hablaba de una escritura en `…/python3`, una
24
+ ruta que no aparecía en el comando. Falla hacia el lado seguro —frena de más— pero señala algo que no
25
+ existe, y un guard que señala mal es el que se termina apagando. **Qué cambia para vos**: si escribís
26
+ scripts de varias líneas en un solo comando, dejás de ver bloqueos por rutas inventadas; lo que sí
27
+ escribe fuera de las raíces se sigue frenando igual.
28
+
29
+ - **El cuerpo de un heredoc es texto, no un comando.** Escribir un archivo con
30
+ `cat > nota.md <<'FIN' … FIN` juzgaba cada línea del documento como si fuera a ejecutarse, así que no
31
+ se podía documentar lo que los guards vigilan: un párrafo que explica por qué no se borra la raíz se
32
+ bloqueaba por nombrarlo, y la salida era cambiar de herramienta para escribir un archivo. Un heredoc
33
+ es entrada estándar y no se ejecuta nunca. **Qué cambia para vos**: el cuerpo deja de juzgarse y la
34
+ línea que lo abre se sigue juzgando entera, con su redirección —también si la escribís después del
35
+ delimitador, como en `cat <<FIN > salida`—. Lo que se pierde a cambio: un cuerpo que después alguien
36
+ ejecuta; al escribirse no ejecuta nada, y cuando se corra el guard verá el comando de verdad.
37
+
38
+ - **Una variable delante de `git commit` ya no apaga tres guards.** `dependencies`, `governance` y el
39
+ control de generados de `verify` sólo corren sobre un commit, y decidían si lo era con un ancla que
40
+ no contempla lo que un shell admite antes del verbo. Con `VAR=1 git commit` dejaban de correr **sin
41
+ decir nada**, y con la ironía de que el prefijo que se escribe para un commit de gobernanza es una
42
+ asignación: `OPS_GOVERNANCE_OVERRIDE=1 git commit` no leía el override, hacía que el guard no se
43
+ ejecutara. **Qué cambia para vos**: si venías escribiendo ese prefijo, ahora el guard corre y te va a
44
+ frenar. La variable se lee del entorno del guard, no del comando, y el mensaje ahora lo dice.
45
+
46
+ - **Un índice que no se puede leer deja de autorizar el commit.** `stagedFiles` devolvía una lista vacía
47
+ tanto si el índice estaba vacío como si no se pudo leer, y los tres guards de arriba leen esa
48
+ respuesta: una lectura fallida se les presentaba como «no hay nada que revisar». Llegar a una es
49
+ fácil, porque el guard no expande variables: `git -C $OPS commit` resuelve la ruta literal `$OPS`.
50
+ **Qué cambia para vos**: ese comando ahora se frena con un mensaje que nombra la causa. Escribí la
51
+ ruta literal en `git -C`.
52
+
17
53
  ## [0.62.0] - 2026-09-06
18
54
 
19
55
  ### Corregido
@@ -102,7 +102,15 @@ function evaluationBench(root, agent, caso, force, kind) {
102
102
  // reintentos, rehacer un banco es una operación que falla de vez en cuando y deja la corrida sin
103
103
  // empezar.
104
104
  fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
105
- IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
105
+ // Con `force`: el banco es desechable y se acaba de borrar, así que lo que sobreviva al `rmSync` se
106
+ // pisa en vez de cortar la corrida. Sin esto, `copyTemplate` se niega ante cualquier archivo que
107
+ // quede —«El destino contiene …/AGENTS.md»— y el mismo test falló así tres veces en un día, en las
108
+ // dos patas de la matriz. Por qué algo sobrevive a un borrado que no lanzó no está establecido.
109
+ //
110
+ // No ablanda ninguna protección: la pregunta «¿acá alguien trabajó?» la contesta el `git status` de
111
+ // arriba, que exige `--force` explícito para seguir. Esta segunda puerta no la eligió nadie y sólo
112
+ // se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer.
113
+ IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true, force: true })
106
114
  // El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
107
115
  // sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
108
116
  const scope = path.join(dir, 'node_modules', '@ingeniomaps')
@@ -22,10 +22,27 @@ function readInput() {
22
22
  }
23
23
  }
24
24
 
25
+ // El cuerpo de un heredoc es entrada estándar: no se ejecuta, se escribe. Juzgarlo como comando frenaba
26
+ // documentar lo que los guards vigilan — escribir un archivo que explica por qué borrar la raíz es
27
+ // catastrófico se bloqueaba por nombrarlo—, y la salida era cambiar de herramienta, que es el rodeo que
28
+ // un guard no debería enseñar.
29
+ //
30
+ // La línea de apertura se conserva **entera**, porque sí es comando y `shell-boundary` tiene que seguir
31
+ // viendo dónde escribe. Entera incluye lo que va después del delimitador: `cat <<FIN > salida` es una
32
+ // forma válida y su destino está ahí. El cuerpo empieza en el salto de línea, no en el delimitador —
33
+ // recortar desde el delimitador se llevaba esa redirección, y nada lo notaba porque en la forma común
34
+ // el destino va antes del `<<`.
35
+ //
36
+ // Lo que se pierde: un cuerpo que después alguien ejecuta. `cat > script.sh <<EOF` no ejecuta nada al
37
+ // escribirse y el guard verá el comando de verdad cuando alguien corra el script; el borde filoso es
38
+ // `$(cat <<EOF …)`, donde el cuerpo sí corre y ya no se mira. Es el mismo trato que con el mensaje de un
39
+ // commit: se frena la forma habitual, no al que quiere pasar.
40
+ const HEREDOC = /<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2([^\n]*)\n[\s\S]*?^\s*\3\s*$/gm
41
+
25
42
  function commandOf(input) {
26
43
  const value = input.tool_input && (input.tool_input.command || input.tool_input.cmd)
27
44
  || input.command || input.input && input.input.command || process.env.OPS_HOOK_COMMAND || ''
28
- return Array.isArray(value) ? value.join(' ') : String(value)
45
+ return String(Array.isArray(value) ? value.join(' ') : value).replace(HEREDOC, '<<$1$2$3$2$4')
29
46
  }
30
47
 
31
48
  function fileOf(input) {
@@ -86,19 +103,59 @@ function configOf(root) {
86
103
  }
87
104
  }
88
105
 
106
+ // Vacía lo que va entre comillas, dejando una marca que ningún patrón confunde con una ruta ni con un
107
+ // comando. Vive acá porque la usan tres lugares por razones distintas, y cada uno explica la suya donde
108
+ // la llama.
109
+ const unquoted = (command) => String(command).replace(/'[^']*'|"[^"]*"/g, '\u0000')
110
+
111
+ // Sobre qué repositorio se lee el índice. En un commit se mira el comando con el mensaje vaciado, por
112
+ // la misma razón por la que `destructive` lo hace: un mensaje que menciona `git -C $VAR` no está
113
+ // eligiendo un repositorio, lo está citando. Sin esto, el commit que explica este arreglo se bloquea a
114
+ // sí mismo — pasó al escribirlo.
115
+ //
116
+ // El precio es una ruta entrecomillada en el propio `-C` de un commit —`git -C "mi carpeta" commit`—,
117
+ // que se pierde y cae al cwd. Es más raro que un mensaje que cita un comando, y el cwd de un commit
118
+ // suele ser el repositorio correcto; el caso contrario deja al guard leyendo un índice ajeno.
89
119
  function gitDirectory(command, cwd) {
90
- const flag = command.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
91
- const cd = command.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
120
+ const text = isCommit(command) ? unquoted(command) : command
121
+ const flag = text.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
122
+ const cd = text.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
92
123
  return path.resolve(cwd, flag ? flag[2] : cd ? cd[2] : '.')
93
124
  }
94
125
 
126
+ // Lo que un shell admite delante del verbo: asignaciones de entorno, `env` y `sudo`. `VAR=1 git commit`
127
+ // empieza por la asignación, así que un ancla que sólo acepta el principio del comando o un separador
128
+ // no ve el `git` que viene después.
129
+ //
130
+ // Falla en los dos sentidos y uno no avisa. Del lado ruidoso, el mensaje del commit vuelve a juzgarse
131
+ // como comando. Del silencioso —el que importa— los tres guards que sólo corren sobre un commit dejan
132
+ // de correr: gobernanza, dependencias y generados. Cualquier variable delante alcanza, y la ironía es
133
+ // que el prefijo que el procedimiento manda escribir para un commit de gobernanza es
134
+ // `OPS_GOVERNANCE_OVERRIDE=1`: escrito ahí, el guard no lee el override, directamente no se ejecuta.
135
+ const PREFIX = String.raw`(?:^|[;&|]\s*)(?:(?:env|sudo)\s+)*`
136
+ + String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)*`
137
+ const COMMIT = new RegExp(PREFIX + String.raw`git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)`)
138
+
95
139
  function isCommit(command) {
96
- return /(?:^|[;&|]\s*)git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)/.test(command)
140
+ return COMMIT.test(command)
97
141
  }
98
142
 
143
+ // Un índice vacío y un índice ilegible no son la misma respuesta: la primera autoriza a seguir, la
144
+ // segunda no autoriza nada. Devolviendo `[]` en los dos casos, los tres guards que preguntan acá se
145
+ // apagaban en silencio ante cualquier lectura fallida — y llegar a una es fácil, porque `gitDirectory`
146
+ // no expande variables: `git -C $OPS commit` resuelve la ruta literal `$OPS`, que no existe.
147
+ //
148
+ // Es la regla que el propio shim ya tiene escrita —«Un guard que no encuentra su motor bloquea, nunca
149
+ // permite»—, aplicada donde faltaba. Bloquear desde acá es seguro: los tres llamadores son guards, así
150
+ // que no hay ningún consumidor que sólo quiera consultar el índice.
99
151
  function stagedFiles(dir) {
100
152
  const result = spawnSync('git', ['-C', dir, 'diff', '--cached', '--name-only'], { encoding: 'utf8' })
101
- return result.status === 0 ? result.stdout.trim().split('\n').filter(Boolean) : []
153
+ if (result.status !== 0) {
154
+ const why = (result.stderr || '').trim() || (result.error && result.error.message) || 'git falló'
155
+ block(`no se pudo leer el índice de ${dir} (${why}). Un guard que no puede verificar no autoriza. `
156
+ + 'Si usaste una variable en `git -C`, escribí la ruta literal.')
157
+ }
158
+ return result.stdout.trim().split('\n').filter(Boolean)
102
159
  }
103
160
 
104
161
  // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
@@ -157,5 +214,5 @@ const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writable
157
214
  module.exports = {
158
215
  readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
159
216
  gitDirectory, isCommit, stagedFiles, pushAllowed, findOpsRoot,
160
- writableRoots, outsideRoots, DECLARE_IT,
217
+ writableRoots, outsideRoots, DECLARE_IT, unquoted,
161
218
  }
@@ -10,7 +10,7 @@ const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
12
  commandOf, cwdOf, block, gitDirectory, isCommit, stagedFiles, pushAllowed,
13
- writableRoots, outsideRoots, DECLARE_IT,
13
+ writableRoots, outsideRoots, DECLARE_IT, unquoted,
14
14
  } = require('./input')
15
15
 
16
16
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
@@ -169,10 +169,17 @@ function dependencies(input) {
169
169
  // Tres familias, porque los comandos no nombran su destino igual: `tee` y `truncate` escriben en cada
170
170
  // argumento, `cp` y sus hermanos en el último, y `sed` sólo escribe con `-i` —sin él lee y manda a
171
171
  // stdout, y esa redirección la ve REDIRECT—.
172
+ //
173
+ // El salto de línea termina una lista de argumentos igual que `;`. Sin excluirlo, la de un `cp` seguía
174
+ // leyendo la línea de abajo y el destino terminaba siendo el comando siguiente: un bloqueo que nombraba
175
+ // `…/python3`, una ruta que no aparecía en el comando. Se veía con un heredoc debajo, pero el heredoc no
176
+ // era la causa —sólo hacía que la lectura frenara en un token que sobrevive—: sin él la lista cruzaba
177
+ // igual y el último token era la marca de lo entrecomillado, que el filtro final descarta. O sea que
178
+ // pasaba de casualidad, y aserciar que pasa no fijaba nada.
172
179
  const REDIRECT = /(?:^|[\s(])&?\d*>>?\s*(?![&(])([^\s;|&<>()]+)/g
173
- const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()]+)/g
174
- const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()]+)/g
175
- const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()]+)/g
180
+ const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()\n]+)/g
181
+ const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()\n]+)/g
182
+ const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()\n]+)/g
176
183
  const IN_PLACE = /(?:^|\s)-{1,2}i/
177
184
 
178
185
  // Los argumentos que no son flags. El valor de un flag se cuela —`truncate -s 0 log` trae el `0`— y no
@@ -180,10 +187,6 @@ const IN_PLACE = /(?:^|\s)-{1,2}i/
180
187
  // decide un bloqueo. Filtrarlo sería una rama que ninguna prueba puede ver caer.
181
188
  const positional = (text) => text.trim().split(/\s+/).filter((one) => one && !one.startsWith('-'))
182
189
 
183
- // Vacía lo que va entre comillas, dejando una marca que ningún patrón confunde con una ruta ni con un
184
- // comando. Lo usan dos guards por razones distintas, y cada uno explica la suya donde lo llama.
185
- const unquoted = (command) => String(command).replace(/'[^']*'|"[^"]*"/g, '\u0000')
186
-
187
190
  // Un `>` adentro de una cadena no redirige nada. Pierde el destino entrecomillado, que es un falso
188
191
  // negativo — el error barato en un guard que ya es incompleto, porque el caro es frenar un comando
189
192
  // legítimo y que alguien apague el guard entero.
@@ -250,7 +253,8 @@ function governance(input) {
250
253
  if (governed.length) {
251
254
  const files = governed.map((file) => ` - ${file}`).join('\n')
252
255
  block(`El commit toca gobernanza protegida:\n${files}\n` +
253
- 'Usa OPS_GOVERNANCE_OVERRIDE=1 solo con aprobación.')
256
+ 'Usa OPS_GOVERNANCE_OVERRIDE=1 solo con aprobación, en el entorno del guard: escrita delante '
257
+ + 'del comando no llega hasta acá.')
254
258
  }
255
259
  }
256
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",