@ingeniomaps/cauce 0.80.0 → 0.81.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,55 @@ 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.81.0] - 2026-09-11
18
+
19
+ ### Cambiado
20
+
21
+ - **Lo que pedís en el chat ya no te frena.** Los guards veían la herramienta y nada de la conversación,
22
+ así que frenaban igual lo que pediste con todas las letras y lo que el agente decidía solo. Ahora, en
23
+ Claude Code, Codex y Gemini, un hook registra tu mensaje y los guards lo leen: si nombraste lo que se
24
+ iba a frenar —«borrá la prueba de altas», «reescribí la migración 004»—, pasa; si no, el agente te dice
25
+ qué se frenó y un «dale» aprueba exactamente eso. `plan-first` deja de pedir un plan cuando el cambio lo
26
+ pediste vos. Lo que el agente hace por su cuenta, dentro de un recorrido o en un subagente, se sigue
27
+ frenando igual.
28
+
29
+ **Qué cambia para vos**: corré `automation install` para que tu runner registre el hook nuevo
30
+ (`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini); en Codex, confialo con `/hooks`. Tu
31
+ mensaje se guarda en el temporal del sistema, no en el repositorio, y sólo el último de cada sesión.
32
+ Antigravity sigue con el archivo de aprobación.
33
+
34
+ - **Leer una credencial por shell se frena en los cuatro runners, y en Claude ya no te frena a vos.** Un
35
+ guard nuevo, `secrets-shell`, frena el comando que muestra una credencial —`cat .env`, `head`, `grep`,
36
+ `source`, una redirección `<`, un `node -e`— en Claude, Codex, Gemini y Antigravity. Hasta ahora sólo
37
+ Claude lo frenaba, con reglas nativas `permissions.deny` que tampoco dejaban pasar tu pedido; esas reglas
38
+ se retiran, y como con el resto de los guards, si lo pedís en el chat, pasa.
39
+
40
+ **Qué cambia para vos**: corré `automation install`; en Claude quita las reglas que Cauce había puesto y
41
+ conserva las tuyas. Si querés un bloqueo nativo total, escribilo como regla propia.
42
+
43
+ ### Corregido
44
+
45
+ - **El bloqueo de `verify` cita la prueba que falló también con jest, vitest, mocha y pytest.** Con esas
46
+ herramientas citaba el archivo, el resumen o —con `pytest -v`— una prueba verde cuyo nombre decía «error».
47
+ Ahora reconoce cómo marca cada una la prueba que falla, comprobado contra la salida real de cada una.
48
+
49
+ **Qué cambia para vos**: el mensaje apunta a la prueba que hay que mirar.
50
+
51
+ - **El agente ya no puede escribirse la aprobación.** `planning/.ops-approval` se escribía con la misma
52
+ herramienta que usa el agente y ningún guard lo miraba, así que una aprobación suya destrababa igual que
53
+ una tuya. Los guards de límites lo frenan ahora —por la herramienta de escritura y por el destino evidente
54
+ de un comando—, salvo que se lo hayas pedido nombrándolo en el chat.
55
+
56
+ **Qué cambia para vos**: nada si el archivo lo editás vos. Si el agente lo escribía por pedido tuyo,
57
+ ahora se lo pedís en el chat.
58
+
59
+ - **En sidecar, el bloqueo dice en qué archivo aprobar.** Mandaba a `planning/.ops-approval`, y desde la
60
+ carpeta del workspace, donde se abre la sesión, ése no es el `planning/` de la instancia: pegar donde
61
+ decía no destrababa nada. Ahora nombra la ruta que el guard lee —`acme-ops/planning/.ops-approval`—; en
62
+ modo embebido sigue diciendo `planning/.ops-approval`.
63
+
64
+ **Qué cambia para vos**: nada que hacer; el mensaje dice dónde.
65
+
17
66
  ## [0.80.0] - 2026-09-10
18
67
 
19
68
  ### Agregado
@@ -47,17 +47,43 @@ una cadena — permisos del runner, alcance del token, aprobación de un PR.
47
47
 
48
48
  ### Leer una credencial
49
49
 
50
- `secrets-read` frena leer, con la herramienta de lectura del runner, un archivo que `secrets` frenaría al
51
- escribir —los nombres conocidos y las identidades que declara `organization/secrets.json`—. Qué alcanza en
52
- cada runner es distinto, y conviene saberlo:
53
-
54
- - **Claude Code**: el guard corre en `Read`, y además la instalación agrega reglas `permissions.deny`
55
- `Read(...)` por los nombres conocidos. Esas reglas las aplica el propio Claude Code a `Read`, a `cat`,
56
- `head`, `tail`, `sed` y a las redirecciones, pero no a un `grep -r` ni a un subproceso que abra el archivo
57
- por su cuenta. Van por nombre exacto: `.env.*` también negaría `.env.example`.
58
- - **Gemini CLI**: el guard corre en `read_file`. Un `cat` por `run_shell_command` no lo ve.
59
- - **Codex y Antigravity**: sin guard de lectura. Sus adaptadores sólo enganchan shell y edición, y leer por
60
- shell no pasa por ningún matcher de archivo.
50
+ Dos guards frenan leer un archivo que `secrets` frenaría al escribir —los nombres conocidos y las
51
+ identidades que declara `organization/secrets.json`—: `secrets-read` en las herramientas de lectura y de
52
+ búsqueda del runner (`Read` y `Grep` en Claude, `read_file` y `grep_search` en Gemini) y `secrets-shell` en
53
+ el shell de los cuatro runners, cuando un comando la muestra —`cat`, `head`, `grep`, `sed`, `source`, una
54
+ redirección `<`, un intérprete en línea—. Un comodín que la nombra —`rg -g '.env*'`, `--include='*.env'`—
55
+ cuenta igual. Lo que sólo la nombra —`ls`, `test -f`, `rm`, `cp .env.example .env`— pasa, y lo que la
56
+ persona pidió en el chat también.
57
+
58
+ Lo que no ve ninguno, comprobado en sesiones reales: una búsqueda sobre la carpeta que no nombra el archivo
59
+ —un `rg` de todo el árbol—, un nombre armado en una variable, y en Gemini el propio entorno del runner,
60
+ que carga el `.env` de la carpeta al arrancar (documentado en geminicli.com/docs/reference/configuration):
61
+ un `env` muestra sus valores sin leer ningún archivo.
62
+
63
+ Hasta 0.80.0 Claude traía además reglas nativas `permissions.deny` `Read(...)`. Las aplica Claude mismo, sin
64
+ pasar por ningún hook, así que frenaban también lo que la persona pedía, mientras en los otros runners el
65
+ shell no tenía freno (caso 104). `automation install` las retira de una instalación anterior y conserva las
66
+ que escribió la empresa: quien quiera un bloqueo nativo total lo escribe como regla propia.
67
+
68
+ ### Lo que pide la persona
69
+
70
+ Un guard ve la llamada a la herramienta y nada de la conversación, así que frenaba igual lo que la persona
71
+ pidió con todas las letras y lo que el agente decidió solo. `chat` corre sobre el mensaje de la persona —el
72
+ runner lo dispara cuando ella manda algo, nunca por el resultado de una herramienta— y lo deja donde los
73
+ guards lo leen:
74
+
75
+ - lo que la persona **nombró** en su mensaje pasa: «leé el `.env`» autoriza leer el `.env`, y «no toques
76
+ el `.env`» no;
77
+ - lo que se frenó sin que lo nombrara queda anotado, y un «dale» en el mensaje siguiente aprueba
78
+ exactamente eso;
79
+ - `plan-first` no aplica: el plan es del trabajo que va por tareas.
80
+
81
+ No cuenta cuando no hay persona —CI, o un aviso del runner como el de un subagente que terminó—, cuando lo
82
+ que pidió es un recorrido de Cauce (`/autobuild`, `$flow`…), ni en la llamada de un subagente, que Claude
83
+ marca con `agent_id`. En Claude y Codex cada llamada trae el identificador del mensaje que la originó;
84
+ Gemini no lo manda, y ahí vale el último mensaje. El registro vive en el temporal del sistema, uno por
85
+ sesión, y los guards de límites lo cuidan junto con `planning/.ops-approval`: el agente no puede
86
+ escribirse ninguno de los dos. Como todo lo de esta página, frena la forma habitual y no un script decidido.
61
87
 
62
88
  ## Cómo se ejecutan
63
89
 
@@ -85,8 +111,12 @@ runner, mientras la lógica se prueba y mantiene una sola vez en `engine/hooks/r
85
111
  | `pre-shell` | destructive, git-add, dependencies, governance, verify, shell-boundary | `guard-shell.sh` |
86
112
  | `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence, plan-first | `guard-files.sh` |
87
113
  | `pre-read` | secrets-read | `guard-secrets-read.sh` |
114
+ | `prompt` | chat | `guard-chat.sh` |
88
115
  | `stop` | planning-drift | `guard-planning-drift.sh` |
89
116
 
117
+ `prompt` corre sobre el mensaje de la persona —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en
118
+ Gemini— y no sobre una herramienta, y su shim sale siempre con 0.
119
+
90
120
  Registrar el grupo gasta un proceso por herramienta en lugar de cinco, con el mismo orden y la misma
91
121
  semántica: el primer guard que bloquea corta la ejecución. Un runner que necesite granularidad fina puede
92
122
  seguir registrando los wrappers individuales.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Shim: qué registra está en engine/hooks/run.js → guards['chat']. Sale siempre con 0: el runner lo corre
3
+ # sobre el mensaje de la persona, y un 2 ahí no frena una herramienta sino lo que la persona escribió.
4
+ "$(dirname "$0")/run-hook.sh" chat >/dev/null 2>&1
5
+ exit 0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Shim: qué bloquea el guard está en engine/hooks/run.js → guards['secrets-shell'].
3
+ exec "$(dirname "$0")/run-hook.sh" secrets-shell
@@ -1,14 +1,15 @@
1
1
  # Claude Code
2
2
 
3
- Adaptador nativo mediante `PreToolUse` y `Stop`. Instalar con:
3
+ Adaptador nativo mediante `PreToolUse`, `UserPromptSubmit` y `Stop`. Instalar con:
4
4
 
5
5
  ```bash
6
6
  node tools/ops.js automation install . claude
7
7
  ```
8
8
 
9
- El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves. También
10
- suma a `permissions.deny` reglas `Read(...)` por los nombres de credencial conocidos, junto a las que el
11
- proyecto ya tenga; una que Cauce retire en una versión futura no se quita sola. Si ya
9
+ El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves. Las reglas
10
+ `permissions.deny` `Read(...)` que Cauce sumaba hasta 0.80.0 las retira al reinstalar —las declara
11
+ `config.retired` en `manifest.json`— y deja las que el proyecto haya escrito: leer una credencial lo frenan
12
+ ahora los guards, y lo que la persona pide en el chat pasa (caso 104). Si ya
12
13
  existe una versión distinta de un archivo, se detiene sin sobrescribirla para no destruir
13
14
  personalizaciones del proyecto. También crea `CLAUDE.md` cuando no existe y conserva uno existente.
14
15
 
@@ -4,7 +4,32 @@
4
4
  "command": "claude",
5
5
  "config": {
6
6
  "source": "settings.json",
7
- "target": ".claude/settings.json"
7
+ "target": ".claude/settings.json",
8
+ "retired": {
9
+ "permissions": {
10
+ "deny": [
11
+ "Read(.env)",
12
+ "Read(.env.local)",
13
+ "Read(.env.*.local)",
14
+ "Read(.npmrc)",
15
+ "Read(.netrc)",
16
+ "Read(_netrc)",
17
+ "Read(.pypirc)",
18
+ "Read(.dockercfg)",
19
+ "Read(id_rsa)",
20
+ "Read(id_dsa)",
21
+ "Read(id_ecdsa)",
22
+ "Read(id_ed25519)",
23
+ "Read(credentials)",
24
+ "Read(credentials*.json)",
25
+ "Read(*service-account*.json)",
26
+ "Read(*.pem)",
27
+ "Read(*.key)",
28
+ "Read(accesos.md)",
29
+ "Read(credenciales*)"
30
+ ]
31
+ }
32
+ }
8
33
  },
9
34
  "instructions": [
10
35
  {
@@ -1,27 +1,4 @@
1
1
  {
2
- "permissions": {
3
- "deny": [
4
- "Read(.env)",
5
- "Read(.env.local)",
6
- "Read(.env.*.local)",
7
- "Read(.npmrc)",
8
- "Read(.netrc)",
9
- "Read(_netrc)",
10
- "Read(.pypirc)",
11
- "Read(.dockercfg)",
12
- "Read(id_rsa)",
13
- "Read(id_dsa)",
14
- "Read(id_ecdsa)",
15
- "Read(id_ed25519)",
16
- "Read(credentials)",
17
- "Read(credentials*.json)",
18
- "Read(*service-account*.json)",
19
- "Read(*.pem)",
20
- "Read(*.key)",
21
- "Read(accesos.md)",
22
- "Read(credenciales*)"
23
- ]
24
- },
25
2
  "hooks": {
26
3
  "PreToolUse": [
27
4
  {
@@ -37,12 +14,19 @@
37
14
  ]
38
15
  },
39
16
  {
40
- "matcher": "Read",
17
+ "matcher": "Read|Grep",
41
18
  "hooks": [
42
19
  { "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-secrets-read.sh" }
43
20
  ]
44
21
  }
45
22
  ],
23
+ "UserPromptSubmit": [
24
+ {
25
+ "hooks": [
26
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-chat.sh" }
27
+ ]
28
+ }
29
+ ],
46
30
  "Stop": [
47
31
  {
48
32
  "hooks": [
@@ -18,6 +18,10 @@ porque cambia el hash. No se usa `--dangerously-bypass-hook-trust`.
18
18
  Los `matcher` filtran el **nombre de la herramienta**: los comandos de shell llegan como `Bash` y las
19
19
  ediciones como `apply_patch`, `Edit` o `Write`. No son los nombres internos del protocolo.
20
20
 
21
+ `UserPromptSubmit` registra el mensaje de la persona, para que lo que pidió en el chat pase sin aprobarlo
22
+ a mano. Codex le pasa a cada herramienta el `turn_id` del mensaje que la originó, que es lo que ata la
23
+ llamada al pedido.
24
+
21
25
  Si actualizás una instalación anterior a este cambio, `.codex/hooks/hooks.json` queda huérfano —Codex
22
26
  nunca lo leyó— y se borra a mano.
23
27
 
@@ -14,6 +14,13 @@
14
14
  ]
15
15
  }
16
16
  ],
17
+ "UserPromptSubmit": [
18
+ {
19
+ "hooks": [
20
+ { "type": "command", "command": "{{OPS_ROOT}}/automatization/hooks/guard-chat.sh" }
21
+ ]
22
+ }
23
+ ],
17
24
  "SessionEnd": [
18
25
  {
19
26
  "hooks": [
@@ -17,9 +17,12 @@ Antes vivían bajo `/ops:` y eran tres: el arranque y el recorrido de equipo le
17
17
  así que alguien que venía de otro runner los buscaba en la lista y no estaban. Si actualizás una
18
18
  instalación vieja, `.gemini/commands/ops/` queda huérfano y se borra a mano.
19
19
 
20
- Gemini CLI tiene hooks nativos y el adaptador los usa: `BeforeTool` y `AfterAgent` en
20
+ Gemini CLI tiene hooks nativos y el adaptador los usa: `BeforeTool`, `BeforeAgent` y `AfterAgent` en
21
21
  `.gemini/settings.json`, declarados en `manifest.json`. Sólo corren si la carpeta está marcada como
22
22
  confiable —`GEMINI.md` explica qué avisa Gemini cuando no lo está—. `read_file` pasa por
23
- `guard-secrets-read.sh`, que frena leer una credencial; un `cat` por `run_shell_command` no lo ve.
23
+ `guard-secrets-read.sh`, que frena leer una credencial; un `cat` por `run_shell_command` lo frena
24
+ `secrets-shell`, en el grupo de shell.
25
+ `BeforeAgent` registra el mensaje de la persona, para que lo que pidió en el chat pase sin aprobarlo a
26
+ mano; Gemini no le pasa a cada herramienta de qué mensaje viene, así que ahí vale el último.
24
27
 
25
28
  Comprueba la instalación con `node tools/ops.js automation doctor . gemini`.
@@ -29,7 +29,7 @@
29
29
  ]
30
30
  },
31
31
  {
32
- "matcher": "read_file",
32
+ "matcher": "read_file|grep_search",
33
33
  "hooks": [
34
34
  {
35
35
  "type": "command",
@@ -38,6 +38,16 @@
38
38
  ]
39
39
  }
40
40
  ],
41
+ "BeforeAgent": [
42
+ {
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "command": "$GEMINI_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-chat.sh"
47
+ }
48
+ ]
49
+ }
50
+ ],
41
51
  "AfterAgent": [
42
52
  {
43
53
  "hooks": [
@@ -322,7 +322,7 @@ function uninstall(root, name, output = console) {
322
322
 
323
323
  if (fs.existsSync(paths.configTarget)) {
324
324
  const current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
325
- const clean = unmergeConfig(current, runnerConfig(paths, root))
325
+ const clean = unmergeConfig(unmergeConfig(current, runnerConfig(paths, root)), runner.config.retired || {})
326
326
  if (clean && Object.keys(clean).length) F.atomicWriteJson(paths.configTarget, clean)
327
327
  else { removeFile(paths.configTarget, paths.install); removed += 1 }
328
328
  output.log(`✓ ${name}: ${runner.config.target} sin las entradas de Cauce`)
@@ -389,6 +389,14 @@ function install(root, name, output = console, options = {}) {
389
389
  ? { config: {}, dropped: [] }
390
390
  : withoutDeliveredHooks(current, live, previous)
391
391
  reportRemoved(name, clean.dropped, live, output)
392
+ // Lo que Cauce entregó en una versión y retiró en otra sin ser un hook, que el merge no saca: las reglas
393
+ // `permissions.deny` que puso el 092 y sacó el 104. Se va exactamente lo que el adaptador declara como
394
+ // retirado; una regla de la empresa que no coincide letra por letra se queda.
395
+ if (runner.config.retired) {
396
+ const before = JSON.stringify(clean.config)
397
+ clean.config = unmergeConfig(clean.config, runner.config.retired) || {}
398
+ if (JSON.stringify(clean.config) !== before) output.log(`− ${name}: quitadas las reglas que Cauce ya no entrega`)
399
+ }
392
400
  F.atomicWriteJson(paths.configTarget, mergeConfig(clean.config, incoming))
393
401
  // Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
394
402
  // el comando, y descubrirlo por sorpresa es la diferencia entre confiar y adivinar.
@@ -20,9 +20,14 @@
20
20
  //
21
21
  // Queda a la vista porque `check` avisa mientras exista. Sin eso, un archivo olvidado sigue autorizando
22
22
  // esas mismas rutas la próxima vez que alguien las stagee, que es la puerta abierta que esto evitaba.
23
+ //
24
+ // El archivo es la vía de cuando no hay chat. Con una persona hablando, lo que ella pidió ya está
25
+ // aprobado —cómo se sabe, en `chat.js`—, y escribir el archivo deja de ser necesario (caso 098).
23
26
 
24
27
  const path = require('node:path')
25
28
  const fs = require('node:fs')
29
+ const { opsRoot, cwdOf } = require('./input')
30
+ const CHAT = require('./chat')
26
31
 
27
32
  const APPROVAL = '.ops-approval'
28
33
 
@@ -35,10 +40,23 @@ function read(root) {
35
40
  }
36
41
 
37
42
  // Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
38
- // que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje.
39
- function pending(root, files) {
43
+ // que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
44
+ // persona pidió en el chat.
45
+ function pending(root, files, input) {
40
46
  const approved = new Set(root ? read(root) : [])
41
- return files.filter((file) => !approved.has(file))
47
+ return CHAT.unauthorized(input, files.filter((file) => !approved.has(file)))
48
+ }
49
+
50
+ // El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
51
+ // sesión se abre en el workspace y la instancia es una subcarpeta, así que `planning/` a secas nombraba
52
+ // otro directorio y pegar ahí no destrababa nada (caso 097).
53
+ function where(input) {
54
+ const root = opsRoot(input)
55
+ if (!root) return `planning/${APPROVAL}`
56
+ const file = path.join(root, 'planning', APPROVAL)
57
+ const session = process.env.CLAUDE_PROJECT_DIR || process.env.GEMINI_PROJECT_DIR || cwdOf(input)
58
+ const relative = path.relative(session, file)
59
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
42
60
  }
43
61
 
44
62
  // Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
@@ -46,9 +64,21 @@ function pending(root, files) {
46
64
  // de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
47
65
  // decirla, lo que quedaba a mano era la variable (caso 089). Nombra también la variable: sigue
48
66
  // existiendo, y esconderla haría que quien la necesite la descubra sin saber su alcance.
49
- const HOW = (variable, lines) => `Aprobalo pegando tal cual en planning/${APPROVAL} estas líneas:\n`
50
- + lines.map((line) => ` ${line}\n`).join('')
51
- + `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
52
- + 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
67
+ //
68
+ // Con una persona en el chat, además, deja anotado lo que se frenó —para eso llama a `hold`— y lo dice
69
+ // primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
70
+ // archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
71
+ // e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
72
+ function HOW(variable, lines, input) {
73
+ const chat = CHAT.hold(input, lines)
74
+ return (chat
75
+ ? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
76
+ + 'pasa. Si prefiere aprobarlo a mano, que pegue ella tal cual en'
77
+ : 'Aprobalo pegando tal cual en')
78
+ + ` ${where(input)} estas líneas:\n`
79
+ + lines.map((line) => ` ${line}\n`).join('')
80
+ + `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
81
+ + 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
82
+ }
53
83
 
54
84
  module.exports = { APPROVAL, read, pending, HOW }
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+
3
+ // Lo que la persona dijo en el chat, capturado por el runner y no contado por el modelo. Existe porque un
4
+ // guard sólo ve la llamada a la herramienta: frenaba igual lo que la persona pidió con todas las letras y
5
+ // lo que el agente decidió solo, y la única forma de decir «sí» era un archivo que el agente también
6
+ // podía escribirse (caso 098).
7
+ //
8
+ // El hook de mensaje del runner —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini— corre
9
+ // cuando la persona manda algo, y Claude y Codex le pasan a cada llamada el identificador del mensaje que
10
+ // la originó. Lo que llega por otro lado —un README, un ticket, el resultado de una herramienta— nunca
11
+ // pasa por acá, y ésa es toda la diferencia entre una orden y una sugerencia.
12
+ //
13
+ // No es un límite de seguridad, como ningún guard: un agente decidido a escribir este registro lo escribe
14
+ // con un script. Lo que se frena es la forma habitual, y la frenan los guards de límites.
15
+
16
+ const fs = require('node:fs')
17
+ const os = require('node:os')
18
+ const path = require('node:path')
19
+
20
+ // El temporal y no la instancia: el texto de la persona no tiene por qué terminar en un commit, y una
21
+ // orden dura lo que dura la sesión.
22
+ const DIR = path.join(os.tmpdir(), 'cauce-chat')
23
+
24
+ // Los recorridos de Cauce, donde el que piensa es el agente y los guards contienen como siempre. Salen de
25
+ // los workflows que el paquete entrega, así que uno nuevo queda cubierto sin tocar esto; los de
26
+ // `integrations/` se invocan con ese prefijo. Claude y Gemini los llaman con `/`, Codex con `$`.
27
+ const WORKFLOWS = path.join(__dirname, '..', '..', 'automatization', 'workflows')
28
+ const scripts = (dir, prefix) => fs.readdirSync(dir).filter((one) => one.endsWith('.js'))
29
+ .map((one) => `${prefix}${one.slice(0, -3)}`)
30
+ function flowCommand(text) {
31
+ let names = []
32
+ try { names = [...scripts(WORKFLOWS, ''), ...scripts(path.join(WORKFLOWS, 'integrations'), 'integration-')] }
33
+ catch { return false }
34
+ return names.length > 0 && new RegExp(`^\\s*[/$](?:cauce:)?(?:${names.join('|')})(?![\\w-])`).test(text)
35
+ }
36
+
37
+ // Claude lo llama `prompt_id` y Codex `turn_id`; Gemini no manda ninguno y ahí vale el último mensaje.
38
+ const idOf = (input) => String(input.prompt_id || input.turn_id || '')
39
+ const recordPath = (session) => path.join(DIR, `${String(session).replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
40
+
41
+ function load(session) {
42
+ try { return JSON.parse(fs.readFileSync(recordPath(session), 'utf8')) } catch { return null }
43
+ }
44
+
45
+ // Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
46
+ // antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
47
+ const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
48
+ const CLAUSE = /[.,;:!?\n]/
49
+
50
+ // Cada aparición del nombre en el texto y si va negada. Un nombre tiene que estar entero: `.env` no
51
+ // aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
52
+ function mentions(text, item) {
53
+ const lower = String(text).toLowerCase()
54
+ const found = []
55
+ for (const name of new Set([item, path.basename(item)].map((one) => one.toLowerCase()))) {
56
+ if (name.length < 3) continue
57
+ for (let at = lower.indexOf(name); at !== -1; at = lower.indexOf(name, at + 1)) {
58
+ const before = lower[at - 1]
59
+ const rest = lower.slice(at + name.length)
60
+ if (before && !/[\s'"`(/]/.test(before)) continue
61
+ if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
62
+ found.push(NEGATION.test(lower.slice(0, at).split(CLAUSE).pop()))
63
+ }
64
+ }
65
+ return { named: found.includes(false), denied: found.includes(true) }
66
+ }
67
+
68
+ // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
69
+ // entera o su primera palabra, no algo que aparece en medio de otra frase.
70
+ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
71
+ + String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
72
+
73
+ // El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
74
+ // deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
75
+ // que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
76
+ function record(input) {
77
+ try {
78
+ if (!input.session_id) return
79
+ const text = String(input.prompt || '')
80
+ const human = !process.env.CI && !/^\s*</.test(text)
81
+ const previous = load(input.session_id)
82
+ const approved = human && previous && YES.test(text)
83
+ ? previous.pending.filter((item) => !mentions(text, item).denied)
84
+ : []
85
+ fs.mkdirSync(DIR, { recursive: true })
86
+ fs.writeFileSync(recordPath(input.session_id),
87
+ JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
88
+ } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
89
+ }
90
+
91
+ // El mensaje de la persona que originó esta llamada, o nada. Nada cuando no hay persona, cuando lo que
92
+ // pidió es un recorrido de Cauce, cuando la llamada la hace un subagente —trabajo que el agente delegó,
93
+ // y Claude lo marca con `agent_id`— o cuando el registro es de otro mensaje.
94
+ function said(input) {
95
+ if (process.env.CI || input.agent_id || !input.session_id) return null
96
+ const saved = load(input.session_id)
97
+ if (!saved || !saved.human || saved.flow) return null
98
+ const current = idOf(input)
99
+ return current && saved.id && current !== saved.id ? null : saved
100
+ }
101
+
102
+ // Lo que la persona no autorizó de lo que un guard está por frenar: ni lo nombró en su mensaje ni lo
103
+ // aprobó contestando.
104
+ function unauthorized(input, items) {
105
+ const saved = said(input)
106
+ if (!saved) return items
107
+ return items.filter((item) => !saved.approved.includes(item) && !mentions(saved.text, item).named)
108
+ }
109
+
110
+ // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
111
+ // Devuelve si hay una persona a quien preguntarle.
112
+ function hold(input, items) {
113
+ const saved = said(input)
114
+ if (!saved) return false
115
+ try {
116
+ saved.pending = [...new Set([...saved.pending, ...items])]
117
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
118
+ return true
119
+ } catch { return false }
120
+ }
121
+
122
+ module.exports = { DIR, record, said, unauthorized, hold }
@@ -8,24 +8,21 @@ const fs = require('node:fs')
8
8
  const path = require('node:path')
9
9
  const { spawnSync } = require('node:child_process')
10
10
  const {
11
- patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
11
+ patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot, opsRoot,
12
12
  writableRoots, outsideRoots, DECLARE_IT,
13
13
  } = require('./input')
14
14
  const AP = require('./approval')
15
+ const CHAT = require('./chat')
16
+ const { selfApproval } = require('./self-approval')
15
17
  const { readWip } = require('../planning/parser')
16
18
  const { runner } = require('../planning/claims')
17
19
  const { hasTasks } = require('../planning/state')
18
20
  const { TEMPLATE_PREFIXES } = require('../core/ownership')
19
21
 
20
- // La raíz donde vive `planning/`, que es donde se busca la aprobación por operación.
21
- function opsRoot(input) {
22
- return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
23
- }
24
-
25
22
  // Si la ruta que este guard está por bloquear está aprobada, no hay nada que decir. Es la salida
26
23
  // angosta: vale para esa ruta y deja de valer en cuanto cambie, a diferencia de la variable, que apaga
27
24
  // el guard hasta que cierre la sesión.
28
- const approved = (input, file) => !AP.pending(opsRoot(input), [file]).length
25
+ const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
29
26
 
30
27
  // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
31
28
  // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
@@ -88,12 +85,20 @@ function secrets(input) {
88
85
 
89
86
  // Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
90
87
  // grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
88
+ // Un comodín también nombra: `rg -g '.env*'`, el `glob` del Grep de Claude o el `include_pattern` del
89
+ // `grep_search` de Gemini recorren la carpeta buscando justo eso, y así leyeron el `.env` dos agentes en
90
+ // sesiones reales (caso 104). Se prueba el nombre con el comodín vacío, la forma más corta que el patrón
91
+ // acepta.
92
+ const patternNames = (token) => (/[*?]/.test(token) ? [token.replace(/[*?]/g, '')] : [token]).filter(Boolean)
93
+
91
94
  function secretsRead(input) {
92
95
  if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
93
- for (const file of filesOf(input)) {
94
- if (!credential(input, file) || approved(input, file)) continue
96
+ const fields = input.tool_input || {}
97
+ const patterns = [fields.glob, fields.include_pattern].filter((one) => typeof one === 'string')
98
+ for (const file of [...filesOf(input), ...patterns]) {
99
+ if (!patternNames(file).some((name) => credential(input, name)) || approved(input, file)) continue
95
100
  block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
96
- + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file])}`)
101
+ + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
97
102
  }
98
103
  }
99
104
 
@@ -147,7 +152,7 @@ function testEvidence(input) {
147
152
  'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
148
153
  'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
149
154
  'decisión con dueño.\n'
150
- const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file])
155
+ const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file], input)
151
156
  for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
152
157
  const removed = match[1].trim()
153
158
  if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
@@ -208,6 +213,10 @@ function isProduct(root, file) {
208
213
  // vigila: la tarea ya está nombrada y el plan todavía no existe.
209
214
  function planFirst(input) {
210
215
  if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
216
+ // El plan lo exige el flujo, que es donde piensa el agente. Cuando la persona pide un cambio directo en
217
+ // el chat la que decidió es ella, y frenarla para que escriba un plan o apruebe una ruta es limitarla en
218
+ // lo que acaba de pedir (caso 098). Un subagente o un recorrido de Cauce no cuentan: `said` los descarta.
219
+ if (CHAT.said(input)) return
211
220
  const root = opsRoot(input)
212
221
  if (!root) return
213
222
  const planning = path.join(root, 'planning')
@@ -227,16 +236,18 @@ function planFirst(input) {
227
236
  for (const raw of filesOf(input)) {
228
237
  if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
229
238
  if (approved(input, raw)) continue
230
- block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw])}`)
239
+ block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
231
240
  }
232
241
  }
233
242
 
234
243
  function workspaceBoundary(input) {
235
244
  const allowed = writableRoots(input)
236
- if (!allowed) return
237
245
  for (const raw of filesOf(input)) {
238
246
  const file = path.resolve(cwdOf(input), raw)
239
- if (outsideRoots(file, allowed)) {
247
+ // Lo mismo que en `shell-boundary`: la aprobación de la persona se juzga aunque no haya raíces.
248
+ const own = selfApproval(input, file)
249
+ if (own) block(own)
250
+ if (allowed && outsideRoots(file, allowed)) {
240
251
  block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
241
252
  }
242
253
  }
@@ -301,7 +312,7 @@ function migrations(input) {
301
312
  if (!esMigracion.test(normalized)) continue
302
313
  if (approved(input, normalized)) continue
303
314
  if (destructiveSql.test(contentOf(input))) {
304
- block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized])}`)
315
+ block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
305
316
  }
306
317
  // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
307
318
  // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
@@ -311,7 +322,7 @@ function migrations(input) {
311
322
  const shipped = alreadyShipped(file)
312
323
  if (shipped) {
313
324
  block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
314
- + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized]))
325
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
315
326
  }
316
327
  }
317
328
  }
@@ -342,6 +353,7 @@ function engineWrites(input) {
342
353
  }
343
354
 
344
355
  module.exports = {
356
+ credential, patternNames,
345
357
  secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
346
358
  migrations, engineWrites,
347
359
  }
@@ -262,9 +262,15 @@ function outsideRoots(file, allowed) {
262
262
  const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
263
263
  + 'ops.config.json; cambiar de herramienta no lo autoriza.'
264
264
 
265
+ // La raíz donde vive `planning/`, que es donde se busca la aprobación. La resuelven igual los guards de
266
+ // archivos, los de shell y la aprobación misma, así que se resuelve en un solo lugar.
267
+ function opsRoot(input) {
268
+ return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
269
+ }
270
+
265
271
  module.exports = {
266
272
  readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
267
273
  gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit, pushAllowed,
268
- findOpsRoot,
274
+ findOpsRoot, opsRoot,
269
275
  writableRoots, outsideRoots, DECLARE_IT, unquoted,
270
276
  }
@@ -12,6 +12,8 @@ const path = require('node:path')
12
12
  const { readInput, cwdOf, block, findOpsRoot } = require('./input')
13
13
  const shell = require('./shell')
14
14
  const files = require('./files')
15
+ const chat = require('./chat')
16
+ const { secretsShell } = require('./secrets-shell')
15
17
 
16
18
  function planningDrift(input) {
17
19
  const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
@@ -39,6 +41,7 @@ const guards = {
39
41
  governance: shell.governance,
40
42
  verify: shell.verify,
41
43
  'shell-boundary': shell.shellBoundary,
44
+ 'secrets-shell': secretsShell,
42
45
  secrets: files.secrets,
43
46
  generated: files.generated,
44
47
  'workspace-boundary': files.workspaceBoundary,
@@ -48,15 +51,18 @@ const guards = {
48
51
  'test-evidence': files.testEvidence,
49
52
  'plan-first': files.planFirst,
50
53
  'secrets-read': files.secretsRead,
54
+ chat: chat.record,
51
55
  'planning-drift': planningDrift,
52
56
  }
53
57
 
54
58
  // Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
55
59
  const hookGroups = {
56
- 'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
60
+ 'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
61
+ 'secrets-shell'],
57
62
  'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
58
63
  'integration-snapshot', 'test-evidence', 'plan-first'],
59
64
  'pre-read': ['secrets-read'],
65
+ prompt: ['chat'],
60
66
  stop: ['planning-drift'],
61
67
  }
62
68
 
@@ -89,7 +95,8 @@ const hookMetadata = [
89
95
  {
90
96
  name: 'shell-boundary',
91
97
  event: 'PreToolUse · shell',
92
- purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas.',
98
+ purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas, o en la '
99
+ + 'aprobación de la persona.',
93
100
  },
94
101
  {
95
102
  name: 'secrets',
@@ -101,11 +108,18 @@ const hookMetadata = [
101
108
  event: 'PreToolUse · read',
102
109
  purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
103
110
  },
111
+ {
112
+ name: 'secrets-shell',
113
+ event: 'PreToolUse · shell',
114
+ purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
115
+ + 'pasa.',
116
+ },
104
117
  { name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
105
118
  {
106
119
  name: 'workspace-boundary',
107
120
  event: 'PreToolUse · files',
108
- purpose: 'Limita escrituras a las raíces declaradas en ops.config.json.',
121
+ purpose: 'Limita escrituras a las raíces declaradas en ops.config.json, y no deja al agente escribirse '
122
+ + 'la aprobación de la persona.',
109
123
  },
110
124
  {
111
125
  name: 'engine',
@@ -133,6 +147,12 @@ const hookMetadata = [
133
147
  event: 'PreToolUse · files',
134
148
  purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
135
149
  },
150
+ {
151
+ name: 'chat',
152
+ event: 'UserPromptSubmit / BeforeAgent',
153
+ purpose: 'Registra el mensaje de la persona: lo que nombró o aprobó en el chat pasa sin archivo. Nunca '
154
+ + 'bloquea.',
155
+ },
136
156
  {
137
157
  name: 'planning-drift',
138
158
  event: 'Stop / SessionEnd',
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ // Leer una credencial por shell. `secrets-read` mira la herramienta de lectura del runner, y un `cat .env`
4
+ // no pasa por ella: en Claude lo tapaba una regla nativa `permissions.deny`, que además frenaba lo que la
5
+ // persona pedía, y en los otros runners no lo tapaba nada (caso 104). Esto lo mira donde pasan todos los
6
+ // comandos, con la misma salida que el resto: lo que la persona pidió en el chat pasa.
7
+ //
8
+ // Como todo lo que lee el texto de un comando, frena la forma habitual y no un script decidido: un nombre
9
+ // armado en una variable o un `grep -r` sobre la carpeta sin nombrar el archivo pasan.
10
+
11
+ const path = require('node:path')
12
+ const { commandOf, cwdOf, block, isCommit, unquoted, opsRoot } = require('./input')
13
+ const { credential, patternNames } = require('./files')
14
+ const AP = require('./approval')
15
+
16
+ // Lo que muestra el contenido de un archivo. Queda afuera lo que sólo lo nombra —`ls`, `test -f`, `rm`,
17
+ // `git add`—, que no deja nada en la sesión, y `cp` y `mv`, cuyo último argumento es un destino: `cp
18
+ // .env.example .env` es preparar el entorno, no leerlo. `nl` entró porque un agente lo usó en una sesión
19
+ // real para leer el `.env` después de que la lista no lo tuviera.
20
+ const READERS = new Set(['cat', 'tac', 'nl', 'head', 'tail', 'less', 'more', 'bat', 'sed', 'awk', 'grep', 'egrep',
21
+ 'fgrep', 'rg', 'strings', 'xxd', 'od', 'hexdump', 'base64', 'cut', 'sort', 'uniq', 'rev', 'paste', 'fold', 'pr',
22
+ 'dd', 'jq', 'yq', 'diff', 'cmp', 'source', '.', 'node', 'python', 'python3', 'ruby', 'perl', 'php', 'deno', 'bun'])
23
+ const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nohup', 'nice', 'xargs'])
24
+
25
+ // El verbo de un tramo, saltando lo que va delante sin serlo: asignaciones, prefijos y sus banderas.
26
+ function verbOf(words) {
27
+ let prefixed = false
28
+ while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || PREFIXES.has(words[0])
29
+ || (prefixed && words[0].startsWith('-')))) {
30
+ prefixed = prefixed || PREFIXES.has(words[0])
31
+ words.shift()
32
+ }
33
+ return words[0] || ''
34
+ }
35
+
36
+ // Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
37
+ // entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
38
+ function readTokens(command) {
39
+ const found = []
40
+ for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
41
+ const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
42
+ const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
43
+ if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
44
+ }
45
+ return [...new Set(found)]
46
+ }
47
+
48
+ function secretsShell(input) {
49
+ if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
50
+ const raw = commandOf(input)
51
+ // El mensaje de un commit es dato, como en `destructive`: nombrar el archivo ahí no lo lee.
52
+ const command = isCommit(raw) ? unquoted(raw) : raw
53
+ const cwd = cwdOf(input)
54
+ const files = readTokens(command).filter((token) => patternNames(token).some((name) => credential(input, name)))
55
+ .map((token) => path.resolve(cwd, token))
56
+ const left = AP.pending(opsRoot(input), [...new Set(files)], input)
57
+ if (!left.length) return
58
+ block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
59
+ + `Si hace falta un valor, pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input)}`)
60
+ }
61
+
62
+ module.exports = { secretsShell }
@@ -0,0 +1,31 @@
1
+ 'use strict'
2
+
3
+ // El canal por el que una persona dice «sí» no lo puede escribir el agente: si pudiera, cada bloqueo que
4
+ // ofrece aprobarse se aprobaría solo. Pasaba —ningún guard miraba `planning/.ops-approval`— y en la prueba
5
+ // en vivo del 092 el agente, frenado, intentó escribírsela (caso 098).
6
+ //
7
+ // Lo preguntan los dos guards de límites —el que mira un `Write` y el que mira el destino de un comando—,
8
+ // que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
9
+ // hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
10
+ // herramienta.
11
+
12
+ const path = require('node:path')
13
+ const { opsRoot } = require('./input')
14
+ const AP = require('./approval')
15
+ const CHAT = require('./chat')
16
+
17
+ // Por qué el agente no puede escribir en `file`, o vacío.
18
+ function selfApproval(input, file) {
19
+ if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) {
20
+ return `${file} es el registro de lo que la persona dijo en el chat: lo escribe el runner, nunca una `
21
+ + 'herramienta.'
22
+ }
23
+ const root = opsRoot(input)
24
+ if (!root || file !== path.join(root, 'planning', AP.APPROVAL)) return ''
25
+ if (!CHAT.unauthorized(input, [file]).length) return ''
26
+ return `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la persona quiere `
27
+ + 'autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o que edite el '
28
+ + 'archivo ella.'
29
+ }
30
+
31
+ module.exports = { selfApproval }
@@ -10,9 +10,10 @@ const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
12
  commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
13
- writableRoots, outsideRoots, DECLARE_IT, unquoted, findOpsRoot, withoutGitGlobals,
13
+ writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
14
14
  } = require('./input')
15
15
  const AP = require('./approval')
16
+ const { selfApproval } = require('./self-approval')
16
17
  const EV = require('../core/evidence')
17
18
 
18
19
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
@@ -48,12 +49,6 @@ const MISMO = String.raw`[^;&|\n]`
48
49
  // `bash -c "git push origin main"` y `eval "git reset --hard"` siguen cayendo, comprobado. Queda afuera
49
50
  // la sustitución dentro del propio mensaje —`git commit -m "$(...)"` corre y ya no se ve—, que es
50
51
  // evasión y no la forma habitual.
51
- // La raíz donde vive `planning/`, que es donde se busca la aprobación. Los cuatro guards que la
52
- // consultan la resuelven igual, así que se resuelve una vez.
53
- function opsRoot(input) {
54
- return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
55
- }
56
-
57
52
  function destructive(input) {
58
53
  const raw = commandOf(input)
59
54
  // Las opciones globales de `git` se sacan acá y no en cada regla: toda regla de abajo que mire un
@@ -181,7 +176,7 @@ function dependencies(input) {
181
176
  // `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
182
177
  // conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
183
178
  const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
184
- names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
179
+ names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
185
180
  // Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
186
181
  // disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
187
182
  // en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
@@ -204,12 +199,12 @@ function dependencies(input) {
204
199
  const manifests = sinAprobar(parent, state.manifests)
205
200
  if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
206
201
  block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
207
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests))
202
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
208
203
  }
209
204
  const lockfiles = sinAprobar(parent, state.locks)
210
205
  if (state.locks.length && !state.manifests.length && lockfiles.length) {
211
206
  block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
212
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles))
207
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles, input))
213
208
  }
214
209
  }
215
210
  }
@@ -306,17 +301,21 @@ function writesWithBase(command, cwd) {
306
301
 
307
302
  function shellBoundary(input) {
308
303
  const allowed = writableRoots(input)
309
- if (!allowed) return
310
304
  for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
311
305
  // Una ruta absoluta no depende del `cd`, así que un destino que no se sabe no la vuelve injuzgable.
312
306
  // Al revés sí: sin saber desde dónde se resuelve, una relativa no se puede verificar, y un guard que
313
307
  // no puede verificar no autoriza —el criterio que fijó el 031 para el índice—.
314
308
  if (!path.isAbsolute(raw) && base === null) {
309
+ if (!allowed) continue
315
310
  block(`el comando hace \`cd\` a un destino que no se puede resolver acá, así que no hay contra qué `
316
311
  + `resolver ${raw}. Escribí la ruta absoluta, o hacé el \`cd\` en un comando aparte.`)
317
312
  }
318
- const file = path.resolve(base, raw)
319
- if (NEUTRAL.some((pattern) => pattern.test(file))) continue
313
+ const file = path.resolve(base || '/', raw)
314
+ // El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
315
+ // declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
316
+ const own = selfApproval(input, file)
317
+ if (own) block(own)
318
+ if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
320
319
  if (outsideRoots(file, allowed)) {
321
320
  block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
322
321
  }
@@ -347,9 +346,9 @@ function governance(input) {
347
346
  // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
348
347
  // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
349
348
  // entre una llave por operación y una puerta que quedó abierta.
350
- const pendientes = AP.pending(opsRoot(input), governed)
349
+ const pendientes = AP.pending(opsRoot(input), governed, input)
351
350
  if (!pendientes.length) return
352
- block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes)}`)
351
+ block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
353
352
  }
354
353
 
355
354
  function run(program, args, cwd, extra = {}) {
@@ -488,20 +487,20 @@ function verify(input) {
488
487
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
489
488
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
490
489
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
491
- const sinAprobar = AP.pending(opsRoot(input), staged)
490
+ const sinAprobar = AP.pending(opsRoot(input), staged, input)
492
491
  const aprobado = !sinAprobar.length
493
492
  if (changedOpenApi && !hasApiGenerated && !aprobado) {
494
493
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
495
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar)}`)
494
+ + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
496
495
  }
497
496
  if (changedSqlSource && !hasSqlGenerated && !aprobado) {
498
497
  block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
499
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
498
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
500
499
  }
501
500
  if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
502
501
  const { root, temp, env } = commitTree(dir)
503
502
  try {
504
- verifyGates(root, dir, sinAprobar, env, opsRoot(input))
503
+ verifyGates(root, dir, sinAprobar, env, input)
505
504
  } finally {
506
505
  if (temp) fs.rmSync(temp, { recursive: true, force: true })
507
506
  }
@@ -522,11 +521,15 @@ function verify(input) {
522
521
  // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
523
522
  // que hace falta para diagnosticar es la primera línea de error, no el volcado.
524
523
  const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
525
- // Cómo marca un reporte de pruebas cada resultado: `node --test` en spec y en TAP, y `go test`. Van sólo
526
- // las comprobadas contra la herramienta (caso 094): el nombre de una prueba verde puede decir «error», y
527
- // sin mirar la marca la búsqueda por palabra se quedaba con ella y el mensaje escondía la roja.
528
- const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:)/
529
- const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)/
524
+ // Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
525
+ // 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
526
+ // quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
527
+ // `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
528
+ // `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
529
+ // pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
530
+ // no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
531
+ const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
532
+ const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
530
533
  const MAX_LINE = 160
531
534
  function fallo(gate, result) {
532
535
  // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
@@ -555,7 +558,8 @@ function comoSeLee(failures) {
555
558
  + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
556
559
  }
557
560
 
558
- function verifyGates(root, dir, sinAprobar, env, ops) {
561
+ function verifyGates(root, dir, sinAprobar, env, input) {
562
+ const ops = opsRoot(input)
559
563
  const failures = []
560
564
  if (fs.existsSync(path.join(root, 'package.json'))) {
561
565
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
@@ -595,7 +599,7 @@ function verifyGates(root, dir, sinAprobar, env, ops) {
595
599
  const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
596
600
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
597
601
  block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
598
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
602
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
599
603
  }
600
604
 
601
605
  module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -103,9 +103,19 @@ Un guard existente **no se edita**: `upgrade` detecta el cambio y se detiene ant
103
103
  Algunos bloqueos tienen salida, y conviene saber cuál antes de necesitarla — el momento en que un guard
104
104
  te frena es el peor para elegir bien.
105
105
 
106
- **La salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que autorizás,
107
- una por línea, con `#` para lo que no sea una ruta. Es un solo archivo para todos los guards, porque lo
108
- que escribís son rutas y quién las mira lo decide qué guard esté juzgando esa ruta.
106
+ **Si lo pediste vos en el chat, no hace falta nada.** Los guards contienen al agente cuando decide solo o
107
+ cuando trabaja dentro de un recorrido; lo que vos pedís directo no se frena. Nombrá lo que querés que
108
+ toque —«borrá la prueba de altas», «reescribí la migración 004»— y pasa sin preguntarte de nuevo. Si tu
109
+ pedido no lo nombraba y algo se frena, el agente te dice qué y por qué: contestá «dale» y pasa exactamente
110
+ eso. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
111
+ por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
112
+ Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
113
+
114
+ **Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
115
+ autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
116
+ instancia y no una carpeta al lado de tus proyectos; el bloqueo dice la ruta exacta. Es un solo archivo
117
+ para todos los guards, porque lo que escribís son rutas y quién las mira lo decide qué guard esté
118
+ juzgando esa ruta. Lo escribís vos: si el agente intenta escribírselo, un guard lo frena.
109
119
 
110
120
  | lo que te frena | qué ruta aprobás |
111
121
  |---|---|