@ingeniomaps/cauce 0.81.0 → 0.83.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 (43) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/automatization/hooks/README.md +10 -6
  3. package/automatization/hooks/guard-ops-config-shell.sh +3 -0
  4. package/automatization/hooks/guard-ops-config.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/codex/AGENTS.md +7 -3
  8. package/automatization/runners/gemini/GEMINI.md +1 -4
  9. package/automatization/shared/inbox.js +47 -0
  10. package/automatization/workflows/agent-eval.js +1 -1
  11. package/automatization/workflows/autobuild.js +56 -18
  12. package/automatization/workflows/flow.js +45 -8
  13. package/automatization/workflows/onboard.js +32 -3
  14. package/engine/automation/index.js +19 -4
  15. package/engine/automation/rules.js +122 -0
  16. package/engine/automation/runners.js +3 -1
  17. package/engine/cli/instance.js +35 -6
  18. package/engine/cli/planning.js +16 -2
  19. package/engine/config/validate.js +53 -3
  20. package/engine/core/onboarding.js +37 -7
  21. package/engine/core/ownership.js +64 -1
  22. package/engine/core/scan.js +23 -9
  23. package/engine/hooks/approval.js +50 -15
  24. package/engine/hooks/chat.js +122 -11
  25. package/engine/hooks/input.js +1 -11
  26. package/engine/hooks/ops-config.js +110 -0
  27. package/engine/hooks/push.js +194 -0
  28. package/engine/hooks/run.js +17 -2
  29. package/engine/hooks/secrets-shell.js +13 -2
  30. package/engine/hooks/self-approval.js +93 -13
  31. package/engine/hooks/shell.js +13 -11
  32. package/engine/integrations/registry.js +13 -1
  33. package/engine/planning/inbox.js +36 -0
  34. package/engine/planning/parser.js +22 -9
  35. package/engine/planning/recurring.js +13 -2
  36. package/engine/schemas/ops-config.schema.json +21 -0
  37. package/package.json +1 -1
  38. package/template/AGENTS.md +13 -5
  39. package/template/gitignore +5 -0
  40. package/template/planning/INBOX.md +2 -1
  41. package/template/planning/RECURRING.md +6 -5
  42. package/template/planning/delivery/teamwork.md +1 -1
  43. package/template/planning/rules/system/commits.md +3 -1
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+
3
+ // Las dos llaves que deciden si un `git push` se publica —`runner.allowPush` y `runner.pushToLiveBranches`—
4
+ // viven en `ops.config.json`, y hasta 0.82.0 ningún guard miraba ese archivo: el bloqueo que dice «esto lo
5
+ // decide una persona» se levantaba escribiendo el archivo que lo levanta, por `Write` o por `sed -i`, y el
6
+ // mensaje del bloqueo nombra el campo exacto que hay que agregar (caso 114).
7
+ //
8
+ // **Protege esos dos campos y no el archivo.** Protegerlo entero repondría el candado que el 090 sacó: el
9
+ // límite de raíces manda a declarar la ruta en `writableOutsideRoots`, de este mismo archivo, así que
10
+ // frenar esa edición dejaría al agente sin la salida que el otro guard le acaba de indicar. Todo lo demás
11
+ // —raíces, migraciones, lo que sea— sigue pasando como trabajo normal.
12
+ //
13
+ // Son dos guards y un solo archivo, como `secrets` y `secrets-shell`: corren en eventos distintos y cada
14
+ // grupo cubre a cada guard una vez, mientras que lo que deciden —cuál es el archivo, qué llave cambió y
15
+ // cómo se dice— es uno solo y copiarlo dejaría dos mitades que dejan de coincidir.
16
+
17
+ const path = require('node:path')
18
+ const { block, commandOf, configOf, contentOf, cwdOf, filesOf, opsRoot } = require('./input')
19
+ const CHAT = require('./chat')
20
+ const { writesWithBase } = require('./shell')
21
+
22
+ const INSTANCE_CONFIG = 'ops.config.json'
23
+ const PROTECTED = ['allowPush', 'pushToLiveBranches']
24
+ const NAMES = PROTECTED.map((name) => `runner.${name}`).join(' y ')
25
+
26
+ // El valor de cada llave en la forma en que se comparan. Ausente no es vacío: sacar `pushToLiveBranches`
27
+ // cambia qué ramas alcanza el permiso y tiene que distinguirse de dejarlo en una lista sin ramas. Lo hace
28
+ // `JSON.stringify`, que de un campo ausente no devuelve nada y de ninguno presente devuelve la cadena vacía.
29
+ function keysOf(config) {
30
+ const runner = (config && typeof config.runner === 'object' && config.runner) || {}
31
+ return PROTECTED.map((name) => JSON.stringify(runner[name])).join(' ')
32
+ }
33
+
34
+ // El mensaje le habla a la persona y deja el permiso como cosa suya; el porqué de esa redacción está en
35
+ // `HOW`, en `approval.js`.
36
+ const HANDS_OFF = 'Lo decide una persona (R10): decile qué se frenó y esperá. Lo pone ella editando el '
37
+ + `archivo, o pidiéndotelo en el chat nombrando ${INSTANCE_CONFIG}.`
38
+
39
+ function changeMessage(file) {
40
+ return `${file} lleva las dos llaves que deciden qué push se publica, ${NAMES}, y este cambio las toca. `
41
+ + `${HANDS_OFF}\nEl resto del archivo no lo frena este guard: si venías a declarar una raíz o a tocar `
42
+ + 'cualquier otro campo, mandá el mismo cambio sin mover esas dos llaves.'
43
+ }
44
+
45
+ // **Un contenido que no se puede leer como JSON se frena.** Un `Edit` manda el fragmento que reemplaza y no
46
+ // el archivo, así que no hay con qué comparar: dejarlo pasar apagaría este guard con la herramienta más
47
+ // común de todas, y frenarlo deja una salida más cara pero escrita —mandar el archivo entero, que sí se
48
+ // compara—. Es el mismo criterio con el que el motor decide cuando no puede leer el índice de git.
49
+ function unreadableMessage(file) {
50
+ return `${file} no llega como un JSON completo que se pueda leer, así que no hay cómo saber si el cambio `
51
+ + `toca ${NAMES}, las dos llaves que deciden qué push se publica. Un guard que no puede verificar no `
52
+ + 'autoriza: mandá el archivo entero en una sola escritura y el guard compara lo que cambia.'
53
+ }
54
+
55
+ // **Por shell se frena toda escritura, y no sólo la de las dos llaves.** La mitad de archivos compara dos
56
+ // contenidos porque tiene los dos: el entrante viene en la llamada y el otro está en disco. Un comando no
57
+ // trae ninguno —`sed -i 's/false/true/'` dice qué reemplaza, no con qué va a quedar el archivo—, y
58
+ // averiguarlo sería ejecutarlo, que es justo lo que este guard corre antes de que ocurra. Sin contenido que
59
+ // comparar quedan dos conductas posibles y ninguna intermedia, y se elige la que cierra la vía que el 114
60
+ // midió.
61
+ function commandMessage(file) {
62
+ return `el comando escribe en ${file}, donde viven las dos llaves que deciden qué push se publica, `
63
+ + `${NAMES}. Un comando no dice con qué va a quedar el archivo, así que acá se frena toda escritura y `
64
+ + `no sólo la de esas dos llaves. ${HANDS_OFF}\nSi el cambio es de cualquier otro campo, escribí el `
65
+ + 'archivo entero con la herramienta de edición, que el guard sí puede comparar.'
66
+ }
67
+
68
+ // Lo que la persona pidió nombrando el archivo pasa: ahí quien decide es ella, que es lo que este guard
69
+ // cuida. Se pregunta cada vez —sin conceder y sin heredar—, igual que `self-approval`: las dos llaves que
70
+ // viven acá son el permiso de push, y nombrar el archivo una vez no lo deja abierto toda la sesión. El
71
+ // comentario decía «lo mismo» mientras el código concedía (caso 119).
72
+ const asked = (input, file) => !CHAT.unauthorizedNow(input, [file]).length
73
+
74
+ function judgeWrite(input, root, file) {
75
+ if (!filesOf(input).some((raw) => path.resolve(cwdOf(input), raw) === file)) return
76
+ if (asked(input, file)) return
77
+ let incoming = null
78
+ try { incoming = JSON.parse(contentOf(input)) } catch { block(unreadableMessage(file)) }
79
+ if (keysOf(configOf(root)) !== keysOf(incoming)) block(changeMessage(file))
80
+ }
81
+
82
+ function judgeCommand(input, file) {
83
+ for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
84
+ // Una ruta relativa sin base contra la que resolverla no se puede juzgar, y esa pregunta es de
85
+ // `shell-boundary`, que ya la contesta para todo destino.
86
+ if (!path.isAbsolute(raw) && base === null) continue
87
+ if (path.resolve(base || '/', raw) !== file) continue
88
+ if (asked(input, file)) return
89
+ block(commandMessage(file))
90
+ }
91
+ }
92
+
93
+ // La configuración que decide, o nada. Sin raíz ops no hay ninguna: `push.js` lee el `runner` de esta
94
+ // misma raíz, así que donde no la hay tampoco hay permiso que escribirse.
95
+ function target(input) {
96
+ const root = opsRoot(input)
97
+ return root ? { root, file: path.join(root, INSTANCE_CONFIG) } : null
98
+ }
99
+
100
+ function opsConfig(input) {
101
+ const found = target(input)
102
+ if (found) judgeWrite(input, found.root, found.file)
103
+ }
104
+
105
+ function opsConfigShell(input) {
106
+ const found = target(input)
107
+ if (found) judgeCommand(input, found.file)
108
+ }
109
+
110
+ module.exports = { opsConfig, opsConfigShell }
@@ -0,0 +1,194 @@
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 fs = require('node:fs')
23
+ const path = require('node:path')
24
+ const { spawnSync } = require('node:child_process')
25
+ const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
26
+ const AP = require('./approval')
27
+ const CHAT = require('./chat')
28
+
29
+ // Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
30
+ // `destructive` explica en MISMO.
31
+ const PUSH = /\bgit\s+push\b([^;&|\n]*)/g
32
+ // Las banderas que llevan su valor en la palabra siguiente: sin saltearlo, el valor se leía como remoto.
33
+ const VALUED = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
34
+ // Publican todas las ramas, así que entre ellas la viva: no hay una rama que nombrar ni que aprobar.
35
+ const EVERY = new Set(['--all', '--branches', '--mirror'])
36
+
37
+ // Lectura local e inocua del repositorio: ninguna de estas consultas habla con el remoto. Si falla, no
38
+ // hay dato, y quien pregunta decide sin él.
39
+ function git(dir, args) {
40
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
41
+ return result.status === 0 ? result.stdout.trim() : ''
42
+ }
43
+
44
+ // La rama por defecto de un remoto es la que git anota en `refs/remotes/<remoto>/HEAD`: la escribe el
45
+ // `clone` y la cambia `git remote set-head`, así que el proyecto la declara sin un campo nuevo. `main` y
46
+ // `master` cuentan siempre, porque un remoto agregado a mano no la anota.
47
+ function liveBranches(dir) {
48
+ const live = new Set(['main', 'master'])
49
+ const refs = git(dir, ['for-each-ref', '--format=%(refname)%09%(symref)', 'refs/remotes/'])
50
+ for (const line of refs.split('\n')) {
51
+ const [name, target] = line.split('\t')
52
+ if (!target || !name.endsWith('/HEAD')) continue
53
+ live.add(target.slice(name.length - 'HEAD'.length))
54
+ }
55
+ return live
56
+ }
57
+
58
+ // A dónde publicaría un `git push` sin rama: lo que git resuelve como `@{push}`, que ya aplica
59
+ // push.default y el remoto de publicación que declare la rama. Sin upstream no resuelve, y git mismo
60
+ // tampoco sabría a dónde ir.
61
+ function pushTarget(dir, current) {
62
+ if (!current) return null
63
+ const remote = git(dir, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${current}`])
64
+ const full = git(dir, ['rev-parse', '--symbolic-full-name', '@{push}'])
65
+ const prefix = `refs/remotes/${remote}/`
66
+ return remote && full.startsWith(prefix) ? { remote, branch: full.slice(prefix.length) } : null
67
+ }
68
+
69
+ // Los destinos de un push: `{ item, remote, branch }` con el ítem que se aprueba, `push <remoto> <rama>`.
70
+ // El que no se puede resolver lleva por ítem el comando mismo, que no nombra ninguna rama: lo aprueba un
71
+ // «dale» a ese bloqueo y nada en `.ops-approval`.
72
+ function destinationsOf(args, dir) {
73
+ const words = args.trim().split(/\s+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''))
74
+ const positional = []
75
+ const found = []
76
+ for (let at = 0; at < words.length; at += 1) {
77
+ if (!words[at].startsWith('-')) positional.push(words[at])
78
+ else if (EVERY.has(words[at])) found.push({ every: true })
79
+ else if (VALUED.has(words[at])) at += 1
80
+ else if (words[at] === '--tags') found.push({ tags: true })
81
+ }
82
+ const current = git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
83
+ const target = pushTarget(dir, current)
84
+ const [remote, ...refspecs] = positional
85
+ const unknown = { item: 'git push', branch: current }
86
+ const one = (to, branch) => (branch ? { item: `push ${to} ${branch}`, remote: to, branch } : unknown)
87
+ const named = refspecs.map((spec) => {
88
+ const destination = spec.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '')
89
+ return one(remote, destination === 'HEAD' ? current : destination)
90
+ })
91
+ const rest = found.map((flag) => (flag.every ? { every: true }
92
+ : one(remote || (target && target.remote) || 'origin', '--tags')))
93
+ if (named.length || found.length) return [...named, ...rest]
94
+ if (remote) return [one(remote, target && target.remote === remote ? target.branch : current)]
95
+ return [target ? one(target.remote, target.branch) : unknown]
96
+ }
97
+
98
+ const SUBAGENT = "'git push' desde un subagente no se publica, con ningún permiso: publicar lo decide una "
99
+ + 'persona (R10), y un subagente es trabajo que el agente delegó. Devolvé el resultado a la sesión '
100
+ + 'principal, que es la que publica.'
101
+
102
+ function liveMessage(live, input) {
103
+ const lines = live.filter((to) => to.remote).map((to) => to.item)
104
+ const names = [...new Set(live.map((to) => (to.every ? 'todas las ramas' : to.branch)))].join(', ')
105
+ // Le habla al agente y deja el permiso como cosa de la persona, por lo mismo que `AP.HOW`: medido en
106
+ // una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
107
+ return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
108
+ + 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
109
+ + 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
110
+ + 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
111
+ + (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
112
+ + lines.map((line) => ` ${line}\n`).join('') : '.')
113
+ }
114
+
115
+ function workMessage(items, input) {
116
+ const lines = items.filter((item) => item.startsWith('push '))
117
+ const chat = CHAT.hold(input, items)
118
+ const how = chat
119
+ ? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
120
+ + 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
121
+ : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
122
+ const paste = lines.length
123
+ ? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
124
+ + lines.map((line) => ` ${line}\n`).join('')
125
+ : ' Sin remoto y rama que se puedan leer del comando no hay línea que aprobar: nombralos.\n'
126
+ return `'git push' publica cambios y requiere una acción humana. ${how}${paste}`
127
+ + 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
128
+ }
129
+
130
+ // El rastro de las aprobaciones que publicaron: una línea por push que pasó porque alguien lo autorizó.
131
+ // Una aprobación se consume sin dejar nada —un «dale» publica y al mensaje siguiente ya no queda quién lo
132
+ // autorizó— y un push no vuelve atrás, así que «quién, a qué rama y cuándo» no tenía de dónde salir
133
+ // (caso 112).
134
+ //
135
+ // Sólo agrega, a diferencia del registro de gates, que es rodante: lo que una auditoría pregunta es
136
+ // justamente la entrada vieja.
137
+ //
138
+ // No guarda el texto de la persona. La vía y la sesión alcanzan para reconstruir qué pasó, y el texto se
139
+ // queda en el temporal, que es donde el 098 decidió dejarlo.
140
+ //
141
+ // Y anota la autorización, no el resultado: este hook corre antes del comando, así que un push que después
142
+ // falla queda registrado igual. Por eso la fecha se llama `authorizedAt` y no `at`: leer la línea como
143
+ // «esto se publicó» afirmaría algo que el hook no puede saber.
144
+ const LOG = path.join('planning', '.push-log')
145
+
146
+ function trail(root, session, entries) {
147
+ if (!root) return
148
+ try {
149
+ const authorizedAt = new Date().toISOString()
150
+ const file = path.join(root, LOG)
151
+ fs.mkdirSync(path.dirname(file), { recursive: true })
152
+ fs.appendFileSync(file, `${entries
153
+ .map((one) => JSON.stringify({ authorizedAt, ...one, session: session || null })).join('\n')}\n`)
154
+ } catch { /* el push ya estaba autorizado: no lo frena un registro que no se pudo escribir */ }
155
+ }
156
+
157
+ // Un destino en la forma en que se anota. Sin remoto ni rama resolubles va `null`, que es exactamente lo
158
+ // que el comando dejó ver.
159
+ const entryOf = (to, via) => ({ remote: to.remote || null, branch: to.branch || null, via })
160
+
161
+ function publish(input, command) {
162
+ const pushes = [...command.matchAll(PUSH)]
163
+ if (!pushes.length) return
164
+ if (input.agent_id) block(SUBAGENT)
165
+ const dir = gitDirectory(command, cwdOf(input))
166
+ const root = opsRoot(input)
167
+ const runner = (root && configOf(root).runner) || {}
168
+ const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
169
+ const filed = new Set(root ? AP.read(root) : [])
170
+ const live = liveBranches(dir)
171
+ const destinations = pushes.flatMap((match) => destinationsOf(match[1], dir))
172
+ const byFile = destinations.filter((to) => to.item && to.item.startsWith('push ') && filed.has(to.item))
173
+ const left = destinations.filter((to) => !byFile.includes(to))
174
+ const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
175
+ if (unlisted.length) block(liveMessage(unlisted, input))
176
+ // Con la llave puesta no hizo falta ninguna aprobación, así que no hay ninguna que anotar: la
177
+ // autorización ya está escrita en `ops.config.json`, y repetirla en cada push sería registrar el archivo
178
+ // del proyecto contra sí mismo.
179
+ if (runner.allowPush === true) return
180
+ const items = [...new Set(left.map((to) => to.item))]
181
+ // Se pregunta sin conceder, a diferencia de lo que pasa por `AP.pending`: volver a leer lo que ya se
182
+ // leyó no agrega consecuencia y volver a publicar sí, así que la autorización de un push vale para esa
183
+ // operación y no para el mensaje siguiente (R10).
184
+ const cleared = CHAT.authorized(input, items, { asked: CHAT.ordersPush })
185
+ const unordered = items.filter((item) => !cleared.some((one) => one.item === item))
186
+ if (unordered.length) block(workMessage(unordered, input))
187
+ const destination = new Map(destinations.map((to) => [to.item, to]))
188
+ trail(root, input.session_id, [
189
+ ...new Map(byFile.map((to) => [to.item, entryOf(to, AP.APPROVAL)])).values(),
190
+ ...cleared.map((one) => entryOf(destination.get(one.item), one.via)),
191
+ ])
192
+ }
193
+
194
+ module.exports = { publish }
@@ -14,6 +14,7 @@ const shell = require('./shell')
14
14
  const files = require('./files')
15
15
  const chat = require('./chat')
16
16
  const { secretsShell } = require('./secrets-shell')
17
+ const { opsConfig, opsConfigShell } = require('./ops-config')
17
18
 
18
19
  function planningDrift(input) {
19
20
  const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
@@ -42,6 +43,8 @@ const guards = {
42
43
  verify: shell.verify,
43
44
  'shell-boundary': shell.shellBoundary,
44
45
  'secrets-shell': secretsShell,
46
+ 'ops-config-shell': opsConfigShell,
47
+ 'ops-config': opsConfig,
45
48
  secrets: files.secrets,
46
49
  generated: files.generated,
47
50
  'workspace-boundary': files.workspaceBoundary,
@@ -58,9 +61,9 @@ const guards = {
58
61
  // Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
59
62
  const hookGroups = {
60
63
  'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
61
- 'secrets-shell'],
64
+ 'secrets-shell', 'ops-config-shell'],
62
65
  'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
63
- 'integration-snapshot', 'test-evidence', 'plan-first'],
66
+ 'integration-snapshot', 'test-evidence', 'plan-first', 'ops-config'],
64
67
  'pre-read': ['secrets-read'],
65
68
  prompt: ['chat'],
66
69
  stop: ['planning-drift'],
@@ -114,6 +117,18 @@ const hookMetadata = [
114
117
  purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
115
118
  + 'pasa.',
116
119
  },
120
+ {
121
+ name: 'ops-config',
122
+ event: 'PreToolUse · files',
123
+ purpose: 'No deja al agente escribirse el permiso de push: frena la escritura que cambia '
124
+ + 'runner.allowPush o runner.pushToLiveBranches en ops.config.json. El resto del archivo pasa.',
125
+ },
126
+ {
127
+ name: 'ops-config-shell',
128
+ event: 'PreToolUse · shell',
129
+ purpose: 'Frena toda escritura por shell sobre ops.config.json: un comando no dice con qué va a '
130
+ + 'quedar el archivo, así que no hay contenido que comparar.',
131
+ },
117
132
  { name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
118
133
  {
119
134
  name: 'workspace-boundary',
@@ -33,11 +33,21 @@ function verbOf(words) {
33
33
  return words[0] || ''
34
34
  }
35
35
 
36
+ // `${VAR}` y `$VAR` son la misma expansión, y sólo la primera lleva llaves: la separación de palabras corta
37
+ // ahí, así que de `ops/${O}/.env.infisical` quedaba suelto `/.env.infisical` —una ruta absoluta que el
38
+ // comando no lee y que el bloqueo ofrecía aprobar—. Sin llaves, la expansión se queda pegada a su ruta y el
39
+ // guard la ve como lo que es: algo que no resolvió (caso 118).
40
+ const unbraced = (command) => String(command).replace(/\$\{(\w+)\}/g, '$$$1')
41
+
42
+ // Lo que el shell iba a expandir y el guard no: una variable, un `$(…)` o un backtick. Lo que sale de ahí
43
+ // no nombra ningún archivo, así que no se puede aprobar por archivo.
44
+ const UNRESOLVED = /[$`]/
45
+
36
46
  // Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
37
47
  // entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
38
48
  function readTokens(command) {
39
49
  const found = []
40
- for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
50
+ for (const segment of unbraced(command).split(/[;&|\n]+|\$\(|`/)) {
41
51
  const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
42
52
  const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
43
53
  if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
@@ -56,7 +66,8 @@ function secretsShell(input) {
56
66
  const left = AP.pending(opsRoot(input), [...new Set(files)], input)
57
67
  if (!left.length) return
58
68
  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)}`)
69
+ + 'Si hace falta un valor, pedíselo a una persona.\n'
70
+ + AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input, left.filter((one) => !UNRESOLVED.test(one))))
60
71
  }
61
72
 
62
73
  module.exports = { secretsShell }
@@ -8,24 +8,104 @@
8
8
  // que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
9
9
  // hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
10
10
  // herramienta.
11
+ //
12
+ // **Nombrar el archivo autoriza el archivo, no lo que se escribe adentro**, y hasta 0.83.0 eso era todo lo
13
+ // que se comprobaba: la persona pedía agregar una ruta inocua, el agente escribía `push origin main`, y esa
14
+ // línea después publicaba en la rama viva —el alcance que el 108 le dio a propósito, apoyado en que sólo
15
+ // una persona podía escribirla—. Medido punta a punta (caso 119). Por eso lo que llega se compara línea por
16
+ // línea contra lo que la persona pidió, y son dos entradas porque cada vía tiene con qué distinto.
11
17
 
12
18
  const path = require('node:path')
13
- const { opsRoot } = require('./input')
19
+ const { contentOf, opsRoot } = require('./input')
14
20
  const AP = require('./approval')
15
21
  const CHAT = require('./chat')
16
22
 
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 CHAT_RECORD = (file) => `${file} es el registro de lo que la persona dijo en el chat: lo escribe el `
24
+ + 'runner, nunca una herramienta.'
25
+
26
+ const SELF = (file) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la `
27
+ + 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o '
28
+ + 'que edite el archivo ella.'
29
+
30
+ // Las líneas van en el mensaje porque son lo que el «dale» va a aprobar: una confirmación que no muestra
31
+ // qué autoriza es la que firma lo que nadie leyó.
32
+ const UNASKED = (file, missing) => `${file} es la aprobación de una persona y estas líneas no las pidió:\n`
33
+ + missing.map((line) => ` ${line}\n`).join('')
34
+ + 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
35
+ + 'esperá: si contesta «dale», reintentá la misma escritura y pasa.'
36
+
37
+ // **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
38
+ // no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
39
+ // separa la línea que la persona pidió de la que el agente se escribe solo.
40
+ const BY_COMMAND = (file) => `el comando escribe en ${file}, la aprobación de una persona, y no dice con `
41
+ + 'qué va a quedar el archivo: sin eso no hay cómo saber si lo que se agrega es lo que ella pidió. '
42
+ + 'Mandalo con la herramienta de edición, que trae el contenido y se compara línea por línea, o que lo '
43
+ + 'pegue ella.'
44
+
45
+ // La raíz de la instancia si `file` es su aprobación, o `null`.
46
+ function approvalRoot(input, file) {
23
47
  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.'
48
+ return root && file === path.join(root, 'planning', AP.APPROVAL) ? root : null
49
+ }
50
+
51
+ // Lo que las dos vías deciden igual: el registro del chat no se escribe nunca, y la aprobación sólo si la
52
+ // persona la nombró en su mensaje.
53
+ //
54
+ // Se pregunta sin conceder: una concesión que sobreviviera al mensaje convertiría «agregá src/x.js a
55
+ // .ops-approval» en permiso para escribirle después cualquier otra línea, que es aprobarse solo por la
56
+ // puerta de al lado.
57
+ function common(input, file) {
58
+ if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) return CHAT_RECORD(file)
59
+ if (!approvalRoot(input, file)) return ''
60
+ return CHAT.authorized(input, [file]).length ? '' : SELF(file)
61
+ }
62
+
63
+ // Una línea de push no se pregunta como una ruta: `mentions` compara también el basename, así que para
64
+ // `push origin feat/login` alcanzaría con que el mensaje pidiera algo del login (caso 103). Es la misma
65
+ // partición que hace `push.js` cuando pregunta por un destino.
66
+ const isPush = (line) => /^push\s+\S+\s+\S+$/.test(line)
67
+
68
+ // Por qué no se puede escribir este contenido, o vacío.
69
+ //
70
+ // **Lo que ya estaba en disco no se vuelve a nombrar**: esta escritura no lo agrega, y exigirlo obligaría a
71
+ // repetir el archivo entero para sumar una línea. Quitar tampoco se pregunta: una aprobación más corta
72
+ // autoriza menos.
73
+ //
74
+ // **Un fragmento sí se juzga, al revés que en `ops.config.json`.** Allá el archivo entrante se compara
75
+ // entero porque una llave cambia de sentido según lo que la rodea, y un `Edit` manda un pedazo; acá cada
76
+ // línea vale por sí sola, así que el pedazo que llega es exactamente lo que se agrega. Lo que no se puede
77
+ // leer como líneas —un parche con sus encabezados— no coincide con nada pedido y se frena, que es el lado
78
+ // correcto para equivocarse.
79
+ //
80
+ // Y se pregunta sin heredar lo que la sesión concedió antes: una línea acá la leen todos los guards y llega
81
+ // hasta la rama viva, así que tiene que ser la que la persona dijo en el mensaje en curso.
82
+ function unasked(input, root, file) {
83
+ const filed = new Set(AP.read(root))
84
+ const added = AP.lines(contentOf(input)).filter((line) => !filed.has(line))
85
+ const pushes = CHAT.authorized(input, added.filter(isPush), { asked: CHAT.ordersPush, inherit: false })
86
+ const paths = CHAT.authorized(input, added.filter((line) => !isPush(line)), { inherit: false })
87
+ const cleared = new Set([...pushes, ...paths].map((one) => one.item))
88
+ const missing = added.filter((line) => !cleared.has(line))
89
+ if (!missing.length) return ''
90
+ // El archivo se anota junto con las líneas: sin él, el «dale» aprobaría las líneas y el guard volvería a
91
+ // frenar por el archivo, que en ese mensaje ya nadie nombra.
92
+ CHAT.hold(input, [file, ...missing])
93
+ return UNASKED(file, missing)
94
+ }
95
+
96
+ // Por qué el agente no puede escribir en `file` con la herramienta de edición, o vacío.
97
+ function selfApproval(input, file) {
98
+ const stop = common(input, file)
99
+ if (stop) return stop
100
+ const root = approvalRoot(input, file)
101
+ return root ? unasked(input, root, file) : ''
102
+ }
103
+
104
+ // Lo mismo para el destino de un comando, o vacío.
105
+ function selfApprovalShell(input, file) {
106
+ const stop = common(input, file)
107
+ if (stop) return stop
108
+ return approvalRoot(input, file) ? BY_COMMAND(file) : ''
29
109
  }
30
110
 
31
- module.exports = { selfApproval }
111
+ module.exports = { selfApproval, selfApprovalShell }
@@ -9,11 +9,12 @@ const os = require('node:os')
9
9
  const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
- commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
12
+ commandOf, cwdOf, block, isCommit, stagedForCommit,
13
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
+ const { publish } = require('./push')
17
+ const { selfApprovalShell } = require('./self-approval')
17
18
  const EV = require('../core/evidence')
18
19
 
19
20
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
@@ -64,14 +65,15 @@ function destructive(input) {
64
65
  // matchea igual las dos formas, así que `allowPush` habilitaba el force-push sin que nadie lo decidiera
65
66
  // y el párrafo de autonomía de `AGENTS.md` tenía que confesarlo. R8 prohíbe `force` sin excepción
66
67
  // configurable, así que esta rama va antes del permiso y no lo consulta.
67
- if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:-f|--force(?:-with-lease|-if-includes)?)\b`)
68
+ //
69
+ // El `+` delante de una rama es el mismo force escrito en el refspec —`git push origin +main`—, y
70
+ // pasaba como un push normal: la regla miraba sólo las banderas (caso 103).
71
+ if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:(?:-f|--force(?:-with-lease|-if-includes)?)\b|\+\S)`)
68
72
  .test(command)) {
69
73
  block("'git push --force' reescribe historia ya publicada. R8 lo prohíbe y runner.allowPush no lo "
70
74
  + 'habilita: publicá con un push normal, o registrá una acción humana.')
71
75
  }
72
- if (/\bgit\s+push\b/.test(command) && !pushAllowed(input)) {
73
- block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
74
- }
76
+ publish(input, command)
75
77
  const rules = [
76
78
  [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
77
79
  // R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
@@ -175,7 +177,7 @@ function dependencies(input) {
175
177
  // Lo que se juzga acá es el archivo staged, así que la aprobación por ruta lo expresa: autorizar
176
178
  // `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
177
179
  // conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
178
- const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
180
+ const sinAprobar = (parent, names) => AP.pendingNow(opsRoot(input),
179
181
  names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
180
182
  // Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
181
183
  // disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
@@ -313,7 +315,7 @@ function shellBoundary(input) {
313
315
  const file = path.resolve(base || '/', raw)
314
316
  // El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
315
317
  // declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
316
- const own = selfApproval(input, file)
318
+ const own = selfApprovalShell(input, file)
317
319
  if (own) block(own)
318
320
  if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
319
321
  if (outsideRoots(file, allowed)) {
@@ -346,7 +348,7 @@ function governance(input) {
346
348
  // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
347
349
  // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
348
350
  // entre una llave por operación y una puerta que quedó abierta.
349
- const pendientes = AP.pending(opsRoot(input), governed, input)
351
+ const pendientes = AP.pendingNow(opsRoot(input), governed, input)
350
352
  if (!pendientes.length) return
351
353
  block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
352
354
  }
@@ -487,7 +489,7 @@ function verify(input) {
487
489
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
488
490
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
489
491
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
490
- const sinAprobar = AP.pending(opsRoot(input), staged, input)
492
+ const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
491
493
  const aprobado = !sinAprobar.length
492
494
  if (changedOpenApi && !hasApiGenerated && !aprobado) {
493
495
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
@@ -602,4 +604,4 @@ function verifyGates(root, dir, sinAprobar, env, input) {
602
604
  + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
603
605
  }
604
606
 
605
- module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
607
+ module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run, writesWithBase }
@@ -64,11 +64,22 @@ function adapter(root, name, entry = {}) {
64
64
  return impl
65
65
  }
66
66
 
67
+ // Qué nombre tiene forma de secreto. Es una sola regla para los tres que la necesitan: la configuración
68
+ // de una integración, la declaración de secretos y el aviso de credenciales sin dueño de «ops check».
69
+ // La clave cuenta sólo como palabra propia —API_KEY sí, projectKey no—, porque el campo de Jira con la
70
+ // clave del proyecto está en instancias reales y rechazarlo les rompería la validación (caso 102, donde
71
+ // está la medición).
72
+ const SENSITIVE = /(password|secret|token|authorization|cookie|dsn|credentials?|(?:^|_)key)$/i
73
+
74
+ function sensitiveKey(key) {
75
+ return SENSITIVE.test(key)
76
+ }
77
+
67
78
  function sensitivePath(value, trail = '') {
68
79
  if (!value || typeof value !== 'object') return ''
69
80
  for (const [key, child] of Object.entries(value)) {
70
81
  const next = trail ? `${trail}.${key}` : key
71
- if (/(password|secret|token|authorization|cookie)$/i.test(key) && !/Env$/i.test(key)) return next
82
+ if (sensitiveKey(key)) return next
72
83
  const nested = sensitivePath(child, next)
73
84
  if (nested) return nested
74
85
  }
@@ -442,6 +453,7 @@ module.exports = {
442
453
  providerConfig,
443
454
  reconcile,
444
455
  safeSegment,
456
+ sensitiveKey,
445
457
  sensitivePath,
446
458
  sync,
447
459
  validate,
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ // Lo que `check` dice sobre el INBOX. Son advertencias y nunca errores: el INBOX es de la persona, y un
4
+ // `check` rojo por lo que tiene adentro la frenaría a ella por lo que escribió un recorrido (caso 101).
5
+
6
+ const path = require('node:path')
7
+ const P = require('./parser')
8
+
9
+ const FILE = 'INBOX.md'
10
+ // Un archivo que todavía se recorre de una sentada; el molde vacío ocupa menos de treinta. La instancia
11
+ // lo cambia en `inbox.warnLines`.
12
+ const WARN_LINES = 300
13
+
14
+ function warnings(root, done, config) {
15
+ const text = P.read(path.join(root, FILE))
16
+ if (!text.trim()) return []
17
+ const found = []
18
+ const declared = config && config.inbox && config.inbox.warnLines
19
+ const limit = Number.isInteger(declared) && declared > 0 ? declared : WARN_LINES
20
+ const count = text.replace(/\n$/, '').split('\n').length
21
+ if (count > limit) {
22
+ found.push(`${FILE}: ${count} líneas, más que el umbral de ${limit} (inbox.warnLines en ops.config.json); `
23
+ + 'recorrelo y borrá lo que ya se decidió')
24
+ }
25
+ // Una entrada que se llama como una tarea cerrada probablemente se promovió y nadie la borró. Es un
26
+ // indicio y no una prueba —el molde no obliga a que la tarea conserve el nombre del ítem—, y por eso
27
+ // avisa en vez de fallar (caso 106).
28
+ for (const name of Object.values(P.inboxHeads(root)).flat()) {
29
+ if (done.set.has(name)) {
30
+ found.push(`${FILE}: **${name}** se llama como done/${name}.md; si ya se promovió, borrala`)
31
+ }
32
+ }
33
+ return found
34
+ }
35
+
36
+ module.exports = { warnings }