@ingeniomaps/cauce 0.80.0 → 0.82.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 (48) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/automatization/hooks/README.md +41 -11
  3. package/automatization/hooks/guard-chat.sh +5 -0
  4. package/automatization/hooks/guard-secrets-shell.sh +3 -0
  5. package/automatization/runners/antigravity/rules/cauce.md +7 -2
  6. package/automatization/runners/claude/CLAUDE.md +1 -4
  7. package/automatization/runners/claude/README.md +5 -4
  8. package/automatization/runners/claude/manifest.json +26 -1
  9. package/automatization/runners/claude/settings.json +8 -24
  10. package/automatization/runners/codex/AGENTS.md +7 -3
  11. package/automatization/runners/codex/README.md +4 -0
  12. package/automatization/runners/codex/hooks.json +7 -0
  13. package/automatization/runners/gemini/GEMINI.md +1 -4
  14. package/automatization/runners/gemini/README.md +5 -2
  15. package/automatization/runners/gemini/settings.json +11 -1
  16. package/automatization/shared/inbox.js +28 -0
  17. package/automatization/workflows/agent-eval.js +1 -1
  18. package/automatization/workflows/autobuild.js +52 -18
  19. package/automatization/workflows/flow.js +33 -8
  20. package/automatization/workflows/onboard.js +25 -3
  21. package/engine/automation/index.js +28 -5
  22. package/engine/automation/rules.js +122 -0
  23. package/engine/automation/runners.js +3 -1
  24. package/engine/cli/instance.js +35 -6
  25. package/engine/cli/planning.js +16 -2
  26. package/engine/config/validate.js +53 -3
  27. package/engine/core/onboarding.js +37 -7
  28. package/engine/core/ownership.js +64 -1
  29. package/engine/core/scan.js +23 -9
  30. package/engine/hooks/approval.js +40 -9
  31. package/engine/hooks/chat.js +171 -0
  32. package/engine/hooks/files.js +28 -16
  33. package/engine/hooks/input.js +8 -12
  34. package/engine/hooks/push.js +147 -0
  35. package/engine/hooks/run.js +23 -3
  36. package/engine/hooks/secrets-shell.js +62 -0
  37. package/engine/hooks/self-approval.js +31 -0
  38. package/engine/hooks/shell.js +37 -31
  39. package/engine/integrations/registry.js +13 -1
  40. package/engine/planning/inbox.js +36 -0
  41. package/engine/planning/parser.js +22 -9
  42. package/engine/planning/recurring.js +13 -2
  43. package/engine/schemas/ops-config.schema.json +21 -0
  44. package/package.json +1 -1
  45. package/template/AGENTS.md +23 -7
  46. package/template/planning/INBOX.md +2 -1
  47. package/template/planning/RECURRING.md +6 -5
  48. package/template/planning/rules/system/commits.md +3 -1
@@ -0,0 +1,171 @@
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
+ // Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
49
+ // «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
50
+ const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
51
+
52
+ // Y no negarlo tampoco alcanza: «¿para qué sirven las credentials?» o «el .env tiene algo raro» nombran el
53
+ // archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
54
+ // español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
55
+ // quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
56
+ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
57
+ + 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
58
+ + 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
59
+ + 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
60
+ + 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
61
+ + 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
62
+ + 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
63
+ + 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
64
+ + 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
65
+ + 'rename move restore skip').split(' '))
66
+ const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
67
+
68
+ function asks(clause) {
69
+ const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
70
+ return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
71
+ }
72
+
73
+ // Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
74
+ // entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
75
+ function mentions(text, item) {
76
+ const lower = String(text).toLowerCase()
77
+ const found = []
78
+ for (const name of new Set([item, path.basename(item)].map((one) => one.toLowerCase()))) {
79
+ if (name.length < 3) continue
80
+ for (let at = lower.indexOf(name); at !== -1; at = lower.indexOf(name, at + 1)) {
81
+ const before = lower[at - 1]
82
+ const rest = lower.slice(at + name.length)
83
+ if (before && !/[\s'"`(/]/.test(before)) continue
84
+ if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
85
+ const clause = lower.slice(0, at).split(CLAUSE).pop()
86
+ found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
87
+ }
88
+ }
89
+ return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
90
+ }
91
+
92
+ // Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
93
+ // `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
94
+ // el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
95
+ // —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
96
+ // antes del último de los dos.
97
+ const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
98
+ function ordersPush(text, item) {
99
+ const [verb, remote, branch] = item.split(' ')
100
+ if (verb !== 'push' || !remote || !branch) return false
101
+ return String(text).split(CLAUSE).some((clause) => {
102
+ // La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
103
+ // negación.
104
+ const words = clause.split(/[\s"`()]+/)
105
+ .map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
106
+ const last = Math.max(words.indexOf(remote), words.indexOf(branch))
107
+ if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
108
+ const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
109
+ .match(/[a-z]+/g) || []
110
+ return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
111
+ && !NEGATION.test(words.slice(0, last + 1).join(' '))
112
+ })
113
+ }
114
+
115
+ // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
116
+ // entera o su primera palabra, no algo que aparece en medio de otra frase.
117
+ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
118
+ + String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
119
+
120
+ // El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
121
+ // deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
122
+ // que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
123
+ function record(input) {
124
+ try {
125
+ if (!input.session_id) return
126
+ const text = String(input.prompt || '')
127
+ const human = !process.env.CI && !/^\s*</.test(text)
128
+ const previous = load(input.session_id)
129
+ const approved = human && previous && YES.test(text)
130
+ ? previous.pending.filter((item) => !mentions(text, item).denied)
131
+ : []
132
+ fs.mkdirSync(DIR, { recursive: true })
133
+ fs.writeFileSync(recordPath(input.session_id),
134
+ JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
135
+ } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
136
+ }
137
+
138
+ // El mensaje de la persona que originó esta llamada, o nada. Nada cuando no hay persona, cuando lo que
139
+ // pidió es un recorrido de Cauce, cuando la llamada la hace un subagente —trabajo que el agente delegó,
140
+ // y Claude lo marca con `agent_id`— o cuando el registro es de otro mensaje.
141
+ function said(input) {
142
+ if (process.env.CI || input.agent_id || !input.session_id) return null
143
+ const saved = load(input.session_id)
144
+ if (!saved || !saved.human || saved.flow) return null
145
+ const current = idOf(input)
146
+ return current && saved.id && current !== saved.id ? null : saved
147
+ }
148
+
149
+ // Lo que la persona no autorizó de lo que un guard está por frenar: ni lo pidió en su mensaje ni lo
150
+ // aprobó contestando. Qué cuenta como pedirlo depende de qué se frena: un archivo se nombra, un push se
151
+ // ordena con su remoto y su rama.
152
+ const named = (text, item) => mentions(text, item).named
153
+ function unauthorized(input, items, asked = named) {
154
+ const saved = said(input)
155
+ if (!saved) return items
156
+ return items.filter((item) => !saved.approved.includes(item) && !asked(saved.text, item))
157
+ }
158
+
159
+ // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
160
+ // Devuelve si hay una persona a quien preguntarle.
161
+ function hold(input, items) {
162
+ const saved = said(input)
163
+ if (!saved) return false
164
+ try {
165
+ saved.pending = [...new Set([...saved.pending, ...items])]
166
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
167
+ return true
168
+ } catch { return false }
169
+ }
170
+
171
+ module.exports = { DIR, record, said, unauthorized, hold, ordersPush }
@@ -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
  }
@@ -183,9 +183,6 @@ function stagedFiles(dir) {
183
183
  return result.stdout.trim().split('\n').filter(Boolean)
184
184
  }
185
185
 
186
- // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
187
- // sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
188
- // guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
189
186
  // El índice que un hook de pre-ejecución lee es el de **antes** del comando, y el comando puede ser
190
187
  // justamente el que lo llene. Ahí los tres guards que juzgan mirando el índice no fallan: leen bien,
191
188
  // encuentran cero archivos y concluyen que no hay nada que revisar.
@@ -212,13 +209,6 @@ function stagedForCommit(command, cwd) {
212
209
  return { dir, staged: stagedFiles(dir) }
213
210
  }
214
211
 
215
- function pushAllowed(input) {
216
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
217
- if (!root) return false
218
- const runner = configOf(root).runner
219
- return Boolean(runner && runner.allowPush === true)
220
- }
221
-
222
212
  function findOpsRoot(start) {
223
213
  let current = path.resolve(start)
224
214
  while (true) {
@@ -262,9 +252,15 @@ function outsideRoots(file, allowed) {
262
252
  const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
263
253
  + 'ops.config.json; cambiar de herramienta no lo autoriza.'
264
254
 
255
+ // La raíz donde vive `planning/`, que es donde se busca la aprobación. La resuelven igual los guards de
256
+ // archivos, los de shell y la aprobación misma, así que se resuelve en un solo lugar.
257
+ function opsRoot(input) {
258
+ return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
259
+ }
260
+
265
261
  module.exports = {
266
262
  readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
267
- gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit, pushAllowed,
268
- findOpsRoot,
263
+ gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
264
+ findOpsRoot, opsRoot,
269
265
  writableRoots, outsideRoots, DECLARE_IT, unquoted,
270
266
  }
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+
3
+ // Si un `git push` se publica, y con qué autorización. R10 pide «la autorización configurada para el
4
+ // proyecto», y hasta 0.81.0 era un solo interruptor: `runner.allowPush` dejaba pasar cualquier push —a la
5
+ // rama viva, y de un subagente igual que de la persona— y, apagado, frenaba también el que la persona
6
+ // acababa de pedir con todas las letras (casos 103 y 108).
7
+ //
8
+ // Lo que garantiza, en orden:
9
+ //
10
+ // - Un subagente no publica, con ningún permiso: es trabajo que el agente delegó, dos pasos más lejos de
11
+ // la persona que la sesión. El push sale de la sesión principal.
12
+ // - Una rama viva —`main`, `master` y la rama por defecto de cada remoto— no la alcanza ni `allowPush` ni
13
+ // una orden del chat si el proyecto no la nombró en `runner.pushToLiveBranches`. La línea exacta en
14
+ // `.ops-approval` sí la alcanza: la escribe una persona a mano y los guards de límites no dejan que el
15
+ // agente se la escriba.
16
+ // - A una rama de trabajo, o a una viva ya nombrada, la alcanzan `allowPush`, la línea exacta en
17
+ // `.ops-approval`, un mensaje de la persona que ordena ese push con su remoto y su rama, o un «dale» al
18
+ // push que quedó frenado. Qué cuenta como orden lo decide `chat.js`.
19
+ //
20
+ // El `--force` no llega hasta acá: lo frena `destructive` antes, sin override (R8).
21
+
22
+ const { spawnSync } = require('node:child_process')
23
+ const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
24
+ const AP = require('./approval')
25
+ const CHAT = require('./chat')
26
+
27
+ // Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
28
+ // `destructive` explica en MISMO.
29
+ const PUSH = /\bgit\s+push\b([^;&|\n]*)/g
30
+ // Las banderas que llevan su valor en la palabra siguiente: sin saltearlo, el valor se leía como remoto.
31
+ const VALUED = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
32
+ // Publican todas las ramas, así que entre ellas la viva: no hay una rama que nombrar ni que aprobar.
33
+ const EVERY = new Set(['--all', '--branches', '--mirror'])
34
+
35
+ // Lectura local e inocua del repositorio: ninguna de estas consultas habla con el remoto. Si falla, no
36
+ // hay dato, y quien pregunta decide sin él.
37
+ function git(dir, args) {
38
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
39
+ return result.status === 0 ? result.stdout.trim() : ''
40
+ }
41
+
42
+ // La rama por defecto de un remoto es la que git anota en `refs/remotes/<remoto>/HEAD`: la escribe el
43
+ // `clone` y la cambia `git remote set-head`, así que el proyecto la declara sin un campo nuevo. `main` y
44
+ // `master` cuentan siempre, porque un remoto agregado a mano no la anota.
45
+ function liveBranches(dir) {
46
+ const live = new Set(['main', 'master'])
47
+ const refs = git(dir, ['for-each-ref', '--format=%(refname)%09%(symref)', 'refs/remotes/'])
48
+ for (const line of refs.split('\n')) {
49
+ const [name, target] = line.split('\t')
50
+ if (!target || !name.endsWith('/HEAD')) continue
51
+ live.add(target.slice(name.length - 'HEAD'.length))
52
+ }
53
+ return live
54
+ }
55
+
56
+ // A dónde publicaría un `git push` sin rama: lo que git resuelve como `@{push}`, que ya aplica
57
+ // push.default y el remoto de publicación que declare la rama. Sin upstream no resuelve, y git mismo
58
+ // tampoco sabría a dónde ir.
59
+ function pushTarget(dir, current) {
60
+ if (!current) return null
61
+ const remote = git(dir, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${current}`])
62
+ const full = git(dir, ['rev-parse', '--symbolic-full-name', '@{push}'])
63
+ const prefix = `refs/remotes/${remote}/`
64
+ return remote && full.startsWith(prefix) ? { remote, branch: full.slice(prefix.length) } : null
65
+ }
66
+
67
+ // Los destinos de un push: `{ item, remote, branch }` con el ítem que se aprueba, `push <remoto> <rama>`.
68
+ // El que no se puede resolver lleva por ítem el comando mismo, que no nombra ninguna rama: lo aprueba un
69
+ // «dale» a ese bloqueo y nada en `.ops-approval`.
70
+ function destinationsOf(args, dir) {
71
+ const words = args.trim().split(/\s+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''))
72
+ const positional = []
73
+ const found = []
74
+ for (let at = 0; at < words.length; at += 1) {
75
+ if (!words[at].startsWith('-')) positional.push(words[at])
76
+ else if (EVERY.has(words[at])) found.push({ every: true })
77
+ else if (VALUED.has(words[at])) at += 1
78
+ else if (words[at] === '--tags') found.push({ tags: true })
79
+ }
80
+ const current = git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
81
+ const target = pushTarget(dir, current)
82
+ const [remote, ...refspecs] = positional
83
+ const unknown = { item: 'git push', branch: current }
84
+ const one = (to, branch) => (branch ? { item: `push ${to} ${branch}`, remote: to, branch } : unknown)
85
+ const named = refspecs.map((spec) => {
86
+ const destination = spec.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '')
87
+ return one(remote, destination === 'HEAD' ? current : destination)
88
+ })
89
+ const rest = found.map((flag) => (flag.every ? { every: true }
90
+ : one(remote || (target && target.remote) || 'origin', '--tags')))
91
+ if (named.length || found.length) return [...named, ...rest]
92
+ if (remote) return [one(remote, target && target.remote === remote ? target.branch : current)]
93
+ return [target ? one(target.remote, target.branch) : unknown]
94
+ }
95
+
96
+ const SUBAGENT = "'git push' desde un subagente no se publica, con ningún permiso: publicar lo decide una "
97
+ + 'persona (R10), y un subagente es trabajo que el agente delegó. Devolvé el resultado a la sesión '
98
+ + 'principal, que es la que publica.'
99
+
100
+ function liveMessage(live, input) {
101
+ const lines = live.filter((to) => to.remote).map((to) => to.item)
102
+ const names = [...new Set(live.map((to) => (to.every ? 'todas las ramas' : to.branch)))].join(', ')
103
+ // Le habla al agente y deja el permiso como cosa de la persona, por lo mismo que `AP.HOW`: medido en
104
+ // una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
105
+ return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
106
+ + 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
107
+ + 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
108
+ + 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
109
+ + (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
110
+ + lines.map((line) => ` ${line}\n`).join('') : '.')
111
+ }
112
+
113
+ function workMessage(items, input) {
114
+ const lines = items.filter((item) => item.startsWith('push '))
115
+ const chat = CHAT.hold(input, items)
116
+ const how = chat
117
+ ? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
118
+ + 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
119
+ : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
120
+ const paste = lines.length
121
+ ? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
122
+ + lines.map((line) => ` ${line}\n`).join('')
123
+ : ' Sin remoto y rama que se puedan leer del comando no hay línea que aprobar: nombralos.\n'
124
+ return `'git push' publica cambios y requiere una acción humana. ${how}${paste}`
125
+ + 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
126
+ }
127
+
128
+ function publish(input, command) {
129
+ const pushes = [...command.matchAll(PUSH)]
130
+ if (!pushes.length) return
131
+ if (input.agent_id) block(SUBAGENT)
132
+ const dir = gitDirectory(command, cwdOf(input))
133
+ const root = opsRoot(input)
134
+ const runner = (root && configOf(root).runner) || {}
135
+ const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
136
+ const filed = new Set(root ? AP.read(root) : [])
137
+ const live = liveBranches(dir)
138
+ const left = pushes.flatMap((match) => destinationsOf(match[1], dir))
139
+ .filter((to) => !(to.item && to.item.startsWith('push ') && filed.has(to.item)))
140
+ const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
141
+ if (unlisted.length) block(liveMessage(unlisted, input))
142
+ if (runner.allowPush === true) return
143
+ const unordered = CHAT.unauthorized(input, [...new Set(left.map((to) => to.item))], CHAT.ordersPush)
144
+ if (unordered.length) block(workMessage(unordered, input))
145
+ }
146
+
147
+ module.exports = { publish }
@@ -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 }