@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.
- package/CHANGELOG.md +248 -0
- package/automatization/hooks/README.md +10 -6
- package/automatization/hooks/guard-ops-config-shell.sh +3 -0
- package/automatization/hooks/guard-ops-config.sh +3 -0
- package/automatization/runners/antigravity/rules/cauce.md +7 -2
- package/automatization/runners/claude/CLAUDE.md +1 -4
- package/automatization/runners/codex/AGENTS.md +7 -3
- package/automatization/runners/gemini/GEMINI.md +1 -4
- package/automatization/shared/inbox.js +47 -0
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +56 -18
- package/automatization/workflows/flow.js +45 -8
- package/automatization/workflows/onboard.js +32 -3
- package/engine/automation/index.js +19 -4
- package/engine/automation/rules.js +122 -0
- package/engine/automation/runners.js +3 -1
- package/engine/cli/instance.js +35 -6
- package/engine/cli/planning.js +16 -2
- package/engine/config/validate.js +53 -3
- package/engine/core/onboarding.js +37 -7
- package/engine/core/ownership.js +64 -1
- package/engine/core/scan.js +23 -9
- package/engine/hooks/approval.js +50 -15
- package/engine/hooks/chat.js +122 -11
- package/engine/hooks/input.js +1 -11
- package/engine/hooks/ops-config.js +110 -0
- package/engine/hooks/push.js +194 -0
- package/engine/hooks/run.js +17 -2
- package/engine/hooks/secrets-shell.js +13 -2
- package/engine/hooks/self-approval.js +93 -13
- package/engine/hooks/shell.js +13 -11
- package/engine/integrations/registry.js +13 -1
- package/engine/planning/inbox.js +36 -0
- package/engine/planning/parser.js +22 -9
- package/engine/planning/recurring.js +13 -2
- package/engine/schemas/ops-config.schema.json +21 -0
- package/package.json +1 -1
- package/template/AGENTS.md +13 -5
- package/template/gitignore +5 -0
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/delivery/teamwork.md +1 -1
- 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 }
|
package/engine/hooks/run.js
CHANGED
|
@@ -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
|
-
+
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 }
|
package/engine/hooks/shell.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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 (
|
|
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 }
|