@ingeniomaps/cauce 0.79.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +6 -3
  3. package/automatization/hooks/README.md +48 -2
  4. package/automatization/hooks/guard-chat.sh +5 -0
  5. package/automatization/hooks/guard-secrets-read.sh +3 -0
  6. package/automatization/hooks/guard-secrets-shell.sh +3 -0
  7. package/automatization/runners/claude/README.md +5 -2
  8. package/automatization/runners/claude/manifest.json +26 -1
  9. package/automatization/runners/claude/settings.json +13 -0
  10. package/automatization/runners/codex/README.md +4 -0
  11. package/automatization/runners/codex/hooks.json +7 -0
  12. package/automatization/runners/gemini/README.md +6 -2
  13. package/automatization/runners/gemini/settings.json +19 -0
  14. package/engine/automation/index.js +9 -1
  15. package/engine/cli/args.js +1 -0
  16. package/engine/cli/bootstrap.js +1 -1
  17. package/engine/cli/ops.js +9 -5
  18. package/engine/cli/wiring.js +21 -3
  19. package/engine/config/paths.js +5 -3
  20. package/engine/hooks/approval.js +42 -8
  21. package/engine/hooks/chat.js +122 -0
  22. package/engine/hooks/files.js +94 -35
  23. package/engine/hooks/input.js +7 -1
  24. package/engine/hooks/run.js +30 -3
  25. package/engine/hooks/secrets-shell.js +62 -0
  26. package/engine/hooks/self-approval.js +31 -0
  27. package/engine/hooks/shell.js +49 -28
  28. package/engine/integrations/providers/jira.js +1 -1
  29. package/engine/integrations/registry.js +28 -5
  30. package/engine/secrets/index.js +201 -0
  31. package/package.json +1 -1
  32. package/template/AGENTS.md +14 -3
  33. package/template/integrations/README.md +21 -0
  34. package/template/organization/README.md +52 -0
  35. package/template/planning/adr/README.md +1 -0
  36. package/template/planning/adr/system/OPS-007-contrato-de-secretos-compartido.md +62 -0
@@ -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.
@@ -50,25 +47,58 @@ function alreadyShipped(file) {
50
47
  }
51
48
 
52
49
 
50
+ // Qué archivo es una credencial, para los dos guards que la cuidan: `secrets`, que frena escribirla, y
51
+ // `secrets-read`, que frena leerla. Devuelve el motivo, o vacío.
52
+ function credential(input, raw) {
53
+ const base = path.basename(raw)
54
+ if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
55
+ return 'parece contener secretos. Edita una plantilla o registra una acción humana.'
56
+ }
57
+ if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
58
+ return 'parece un archivo de credenciales en texto plano.'
59
+ }
60
+ // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
61
+ // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
62
+ // hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
63
+ // exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
64
+ //
65
+ // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
66
+ // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
67
+ if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
68
+ return 'es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.'
69
+ }
70
+ // Lo que ningún nombre delata: una identidad de máquina que el 088 declara puede llamarse
71
+ // `local-dev.env` (caso 092). Se lee sólo si la declaración existe, para no cargarla en cada hook.
72
+ const root = opsRoot(input)
73
+ if (!root || !fs.existsSync(path.join(root, 'organization', 'secrets.json'))) return ''
74
+ return require('../secrets').identityFiles(root).includes(path.resolve(cwdOf(input), raw))
75
+ ? 'es una identidad declarada en organization/secrets.json: la carga una persona.'
76
+ : ''
77
+ }
78
+
53
79
  function secrets(input) {
54
80
  for (const file of filesOf(input)) {
55
- const base = path.basename(file)
56
- if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
57
- block(`${file} parece contener secretos. Edita una plantilla o registra una acción humana.`)
58
- }
59
- if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
60
- block(`${file} parece un archivo de credenciales en texto plano.`)
61
- }
62
- // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
63
- // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
64
- // hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
65
- // exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
66
- //
67
- // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
68
- // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
69
- if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
70
- block(`${file} es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.`)
71
- }
81
+ const reason = credential(input, file)
82
+ if (reason) block(`${file} ${reason}`)
83
+ }
84
+ }
85
+
86
+ // Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
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
+
94
+ function secretsRead(input) {
95
+ if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
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
100
+ block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
101
+ + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
72
102
  }
73
103
  }
74
104
 
@@ -121,17 +151,18 @@ function testEvidence(input) {
121
151
  'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
122
152
  'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
123
153
  'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
124
- 'decisión con dueño.\n' + AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE')
154
+ 'decisión con dueño.\n'
155
+ const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file], input)
125
156
  for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
126
157
  const removed = match[1].trim()
127
- if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}`)
158
+ if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
128
159
  }
129
160
  const content = contentOf(input)
130
161
  if (!content) return
131
162
  for (const raw of filesOf(input)) {
132
163
  if (!isTestFile(raw) || approved(input, raw)) continue
133
164
  for (const [marca, nombre] of TEST_OFF) {
134
- if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
165
+ if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}${how(raw)}`)
135
166
  }
136
167
  }
137
168
  }
@@ -150,6 +181,28 @@ function opsOwned(root, file) {
150
181
  return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
151
182
  }
152
183
 
184
+ // Producto es el código de una raíz declarada, y la instancia sidecar no lo es aunque viva dentro de una:
185
+ // `init` escribe `..` como raíz en sidecar, así que la carpeta de la instancia cae adentro. En embedded la
186
+ // raíz de ops **es** una raíz de producto, y ahí sólo se exime lo que la instancia posee. Lo que queda
187
+ // fuera de toda raíz tampoco es producto: el límite de raíces ya lo juzgó, y si pasó es porque el proyecto
188
+ // lo declaró en `writableOutsideRoots` (casos 089 y 090).
189
+ //
190
+ // `ops.config.json` se exime por nombre porque es la llave del límite de raíces: su mensaje manda a
191
+ // editarlo, y frenar esa edición era el candado de arriba con otra forma.
192
+ const INSTANCE_CONFIG = 'ops.config.json'
193
+
194
+ function isProduct(root, file) {
195
+ if (opsOwned(root, file) || file === path.join(root, INSTANCE_CONFIG)) return false
196
+ const declared = configOf(root).workspaceRoots
197
+ const roots = (Array.isArray(declared) ? declared : [])
198
+ .filter((entry) => entry && typeof entry.path === 'string')
199
+ .map((entry) => path.resolve(root, entry.path))
200
+ // Sin raíces legibles no hay contra qué comparar, y se juzga como antes: frenar de más.
201
+ if (!roots.length) return true
202
+ if (outsideRoots(file, roots)) return false
203
+ return outsideRoots(file, [root]) || roots.includes(root)
204
+ }
205
+
153
206
  // R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
154
207
  // tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
155
208
  // hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
@@ -160,6 +213,10 @@ function opsOwned(root, file) {
160
213
  // vigila: la tarea ya está nombrada y el plan todavía no existe.
161
214
  function planFirst(input) {
162
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
163
220
  const root = opsRoot(input)
164
221
  if (!root) return
165
222
  const planning = path.join(root, 'planning')
@@ -176,20 +233,21 @@ function planFirst(input) {
176
233
  const why = `${estado}, así que el plan todavía no está escrito.\n`
177
234
  + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
178
235
  + 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
179
- + AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
180
236
  for (const raw of filesOf(input)) {
181
- if (opsOwned(root, path.resolve(cwdOf(input), raw))) continue
237
+ if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
182
238
  if (approved(input, raw)) continue
183
- block(`${raw} cambia el producto sin plan. ${why}`)
239
+ block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
184
240
  }
185
241
  }
186
242
 
187
243
  function workspaceBoundary(input) {
188
244
  const allowed = writableRoots(input)
189
- if (!allowed) return
190
245
  for (const raw of filesOf(input)) {
191
246
  const file = path.resolve(cwdOf(input), raw)
192
- 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)) {
193
251
  block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
194
252
  }
195
253
  }
@@ -254,7 +312,7 @@ function migrations(input) {
254
312
  if (!esMigracion.test(normalized)) continue
255
313
  if (approved(input, normalized)) continue
256
314
  if (destructiveSql.test(contentOf(input))) {
257
- block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
315
+ block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
258
316
  }
259
317
  // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
260
318
  // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
@@ -264,7 +322,7 @@ function migrations(input) {
264
322
  const shipped = alreadyShipped(file)
265
323
  if (shipped) {
266
324
  block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
267
- + AP.HOW('OPS_MIGRATIONS_OVERRIDE'))
325
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
268
326
  }
269
327
  }
270
328
  }
@@ -295,6 +353,7 @@ function engineWrites(input) {
295
353
  }
296
354
 
297
355
  module.exports = {
298
- secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
356
+ credential, patternNames,
357
+ secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
299
358
  migrations, engineWrites,
300
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,
@@ -47,14 +50,19 @@ const guards = {
47
50
  'integration-snapshot': files.integrationSnapshot,
48
51
  'test-evidence': files.testEvidence,
49
52
  'plan-first': files.planFirst,
53
+ 'secrets-read': files.secretsRead,
54
+ chat: chat.record,
50
55
  'planning-drift': planningDrift,
51
56
  }
52
57
 
53
58
  // Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
54
59
  const hookGroups = {
55
- 'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
60
+ 'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
61
+ 'secrets-shell'],
56
62
  'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
57
63
  'integration-snapshot', 'test-evidence', 'plan-first'],
64
+ 'pre-read': ['secrets-read'],
65
+ prompt: ['chat'],
58
66
  stop: ['planning-drift'],
59
67
  }
60
68
 
@@ -87,18 +95,31 @@ const hookMetadata = [
87
95
  {
88
96
  name: 'shell-boundary',
89
97
  event: 'PreToolUse · shell',
90
- 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.',
91
100
  },
92
101
  {
93
102
  name: 'secrets',
94
103
  event: 'PreToolUse · files',
95
104
  purpose: 'Bloquea escribir secretos, claves privadas y credenciales en texto plano.',
96
105
  },
106
+ {
107
+ name: 'secrets-read',
108
+ event: 'PreToolUse · read',
109
+ purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
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
+ },
97
117
  { name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
98
118
  {
99
119
  name: 'workspace-boundary',
100
120
  event: 'PreToolUse · files',
101
- 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.',
102
123
  },
103
124
  {
104
125
  name: 'engine',
@@ -126,6 +147,12 @@ const hookMetadata = [
126
147
  event: 'PreToolUse · files',
127
148
  purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
128
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
+ },
129
156
  {
130
157
  name: 'planning-drift',
131
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 }