@ingeniomaps/cauce 0.80.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +177 -0
- package/automatization/hooks/README.md +41 -11
- package/automatization/hooks/guard-chat.sh +5 -0
- package/automatization/hooks/guard-secrets-shell.sh +3 -0
- package/automatization/runners/antigravity/rules/cauce.md +7 -2
- package/automatization/runners/claude/CLAUDE.md +1 -4
- package/automatization/runners/claude/README.md +5 -4
- package/automatization/runners/claude/manifest.json +26 -1
- package/automatization/runners/claude/settings.json +8 -24
- package/automatization/runners/codex/AGENTS.md +7 -3
- package/automatization/runners/codex/README.md +4 -0
- package/automatization/runners/codex/hooks.json +7 -0
- package/automatization/runners/gemini/GEMINI.md +1 -4
- package/automatization/runners/gemini/README.md +5 -2
- package/automatization/runners/gemini/settings.json +11 -1
- package/automatization/shared/inbox.js +28 -0
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +52 -18
- package/automatization/workflows/flow.js +33 -8
- package/automatization/workflows/onboard.js +25 -3
- package/engine/automation/index.js +28 -5
- 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 +40 -9
- package/engine/hooks/chat.js +171 -0
- package/engine/hooks/files.js +28 -16
- package/engine/hooks/input.js +8 -12
- package/engine/hooks/push.js +147 -0
- package/engine/hooks/run.js +23 -3
- package/engine/hooks/secrets-shell.js +62 -0
- package/engine/hooks/self-approval.js +31 -0
- package/engine/hooks/shell.js +37 -31
- 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 +23 -7
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/rules/system/commits.md +3 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Lo que la persona dijo en el chat, capturado por el runner y no contado por el modelo. Existe porque un
|
|
4
|
+
// guard sólo ve la llamada a la herramienta: frenaba igual lo que la persona pidió con todas las letras y
|
|
5
|
+
// lo que el agente decidió solo, y la única forma de decir «sí» era un archivo que el agente también
|
|
6
|
+
// podía escribirse (caso 098).
|
|
7
|
+
//
|
|
8
|
+
// El hook de mensaje del runner —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini— corre
|
|
9
|
+
// cuando la persona manda algo, y Claude y Codex le pasan a cada llamada el identificador del mensaje que
|
|
10
|
+
// la originó. Lo que llega por otro lado —un README, un ticket, el resultado de una herramienta— nunca
|
|
11
|
+
// pasa por acá, y ésa es toda la diferencia entre una orden y una sugerencia.
|
|
12
|
+
//
|
|
13
|
+
// No es un límite de seguridad, como ningún guard: un agente decidido a escribir este registro lo escribe
|
|
14
|
+
// con un script. Lo que se frena es la forma habitual, y la frenan los guards de límites.
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const os = require('node:os')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
// El temporal y no la instancia: el texto de la persona no tiene por qué terminar en un commit, y una
|
|
21
|
+
// orden dura lo que dura la sesión.
|
|
22
|
+
const DIR = path.join(os.tmpdir(), 'cauce-chat')
|
|
23
|
+
|
|
24
|
+
// Los recorridos de Cauce, donde el que piensa es el agente y los guards contienen como siempre. Salen de
|
|
25
|
+
// los workflows que el paquete entrega, así que uno nuevo queda cubierto sin tocar esto; los de
|
|
26
|
+
// `integrations/` se invocan con ese prefijo. Claude y Gemini los llaman con `/`, Codex con `$`.
|
|
27
|
+
const WORKFLOWS = path.join(__dirname, '..', '..', 'automatization', 'workflows')
|
|
28
|
+
const scripts = (dir, prefix) => fs.readdirSync(dir).filter((one) => one.endsWith('.js'))
|
|
29
|
+
.map((one) => `${prefix}${one.slice(0, -3)}`)
|
|
30
|
+
function flowCommand(text) {
|
|
31
|
+
let names = []
|
|
32
|
+
try { names = [...scripts(WORKFLOWS, ''), ...scripts(path.join(WORKFLOWS, 'integrations'), 'integration-')] }
|
|
33
|
+
catch { return false }
|
|
34
|
+
return names.length > 0 && new RegExp(`^\\s*[/$](?:cauce:)?(?:${names.join('|')})(?![\\w-])`).test(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Claude lo llama `prompt_id` y Codex `turn_id`; Gemini no manda ninguno y ahí vale el último mensaje.
|
|
38
|
+
const idOf = (input) => String(input.prompt_id || input.turn_id || '')
|
|
39
|
+
const recordPath = (session) => path.join(DIR, `${String(session).replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
|
|
40
|
+
|
|
41
|
+
function load(session) {
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(recordPath(session), 'utf8')) } catch { return null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
|
|
46
|
+
// antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
|
|
47
|
+
const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
|
|
48
|
+
// Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
|
|
49
|
+
// «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
|
|
50
|
+
const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
|
|
51
|
+
|
|
52
|
+
// Y no negarlo tampoco alcanza: «¿para qué sirven las credentials?» o «el .env tiene algo raro» nombran el
|
|
53
|
+
// archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
|
|
54
|
+
// español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
|
|
55
|
+
// quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
|
|
56
|
+
const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
|
|
57
|
+
+ 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
|
|
58
|
+
+ 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
|
|
59
|
+
+ 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
|
|
60
|
+
+ 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
|
|
61
|
+
+ 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
|
|
62
|
+
+ 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
|
|
63
|
+
+ 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
|
|
64
|
+
+ 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
|
|
65
|
+
+ 'rename move restore skip').split(' '))
|
|
66
|
+
const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
|
|
67
|
+
|
|
68
|
+
function asks(clause) {
|
|
69
|
+
const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
|
|
70
|
+
return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
|
|
74
|
+
// entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
|
|
75
|
+
function mentions(text, item) {
|
|
76
|
+
const lower = String(text).toLowerCase()
|
|
77
|
+
const found = []
|
|
78
|
+
for (const name of new Set([item, path.basename(item)].map((one) => one.toLowerCase()))) {
|
|
79
|
+
if (name.length < 3) continue
|
|
80
|
+
for (let at = lower.indexOf(name); at !== -1; at = lower.indexOf(name, at + 1)) {
|
|
81
|
+
const before = lower[at - 1]
|
|
82
|
+
const rest = lower.slice(at + name.length)
|
|
83
|
+
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
84
|
+
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
85
|
+
const clause = lower.slice(0, at).split(CLAUSE).pop()
|
|
86
|
+
found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
|
|
93
|
+
// `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
|
|
94
|
+
// el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
|
|
95
|
+
// —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
|
|
96
|
+
// antes del último de los dos.
|
|
97
|
+
const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
|
|
98
|
+
function ordersPush(text, item) {
|
|
99
|
+
const [verb, remote, branch] = item.split(' ')
|
|
100
|
+
if (verb !== 'push' || !remote || !branch) return false
|
|
101
|
+
return String(text).split(CLAUSE).some((clause) => {
|
|
102
|
+
// La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
|
|
103
|
+
// negación.
|
|
104
|
+
const words = clause.split(/[\s"`()]+/)
|
|
105
|
+
.map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
|
|
106
|
+
const last = Math.max(words.indexOf(remote), words.indexOf(branch))
|
|
107
|
+
if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
|
|
108
|
+
const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
109
|
+
.match(/[a-z]+/g) || []
|
|
110
|
+
return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
|
|
111
|
+
&& !NEGATION.test(words.slice(0, last + 1).join(' '))
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
|
|
116
|
+
// entera o su primera palabra, no algo que aparece en medio de otra frase.
|
|
117
|
+
const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
|
|
118
|
+
+ String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
|
|
119
|
+
|
|
120
|
+
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
121
|
+
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
122
|
+
// que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
|
|
123
|
+
function record(input) {
|
|
124
|
+
try {
|
|
125
|
+
if (!input.session_id) return
|
|
126
|
+
const text = String(input.prompt || '')
|
|
127
|
+
const human = !process.env.CI && !/^\s*</.test(text)
|
|
128
|
+
const previous = load(input.session_id)
|
|
129
|
+
const approved = human && previous && YES.test(text)
|
|
130
|
+
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
131
|
+
: []
|
|
132
|
+
fs.mkdirSync(DIR, { recursive: true })
|
|
133
|
+
fs.writeFileSync(recordPath(input.session_id),
|
|
134
|
+
JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
|
|
135
|
+
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// El mensaje de la persona que originó esta llamada, o nada. Nada cuando no hay persona, cuando lo que
|
|
139
|
+
// pidió es un recorrido de Cauce, cuando la llamada la hace un subagente —trabajo que el agente delegó,
|
|
140
|
+
// y Claude lo marca con `agent_id`— o cuando el registro es de otro mensaje.
|
|
141
|
+
function said(input) {
|
|
142
|
+
if (process.env.CI || input.agent_id || !input.session_id) return null
|
|
143
|
+
const saved = load(input.session_id)
|
|
144
|
+
if (!saved || !saved.human || saved.flow) return null
|
|
145
|
+
const current = idOf(input)
|
|
146
|
+
return current && saved.id && current !== saved.id ? null : saved
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar: ni lo pidió en su mensaje ni lo
|
|
150
|
+
// aprobó contestando. Qué cuenta como pedirlo depende de qué se frena: un archivo se nombra, un push se
|
|
151
|
+
// ordena con su remoto y su rama.
|
|
152
|
+
const named = (text, item) => mentions(text, item).named
|
|
153
|
+
function unauthorized(input, items, asked = named) {
|
|
154
|
+
const saved = said(input)
|
|
155
|
+
if (!saved) return items
|
|
156
|
+
return items.filter((item) => !saved.approved.includes(item) && !asked(saved.text, item))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
160
|
+
// Devuelve si hay una persona a quien preguntarle.
|
|
161
|
+
function hold(input, items) {
|
|
162
|
+
const saved = said(input)
|
|
163
|
+
if (!saved) return false
|
|
164
|
+
try {
|
|
165
|
+
saved.pending = [...new Set([...saved.pending, ...items])]
|
|
166
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
167
|
+
return true
|
|
168
|
+
} catch { return false }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { DIR, record, said, unauthorized, hold, ordersPush }
|
package/engine/hooks/files.js
CHANGED
|
@@ -8,24 +8,21 @@ const fs = require('node:fs')
|
|
|
8
8
|
const path = require('node:path')
|
|
9
9
|
const { spawnSync } = require('node:child_process')
|
|
10
10
|
const {
|
|
11
|
-
patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
|
|
11
|
+
patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot, opsRoot,
|
|
12
12
|
writableRoots, outsideRoots, DECLARE_IT,
|
|
13
13
|
} = require('./input')
|
|
14
14
|
const AP = require('./approval')
|
|
15
|
+
const CHAT = require('./chat')
|
|
16
|
+
const { selfApproval } = require('./self-approval')
|
|
15
17
|
const { readWip } = require('../planning/parser')
|
|
16
18
|
const { runner } = require('../planning/claims')
|
|
17
19
|
const { hasTasks } = require('../planning/state')
|
|
18
20
|
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
19
21
|
|
|
20
|
-
// La raíz donde vive `planning/`, que es donde se busca la aprobación por operación.
|
|
21
|
-
function opsRoot(input) {
|
|
22
|
-
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
22
|
// Si la ruta que este guard está por bloquear está aprobada, no hay nada que decir. Es la salida
|
|
26
23
|
// angosta: vale para esa ruta y deja de valer en cuanto cambie, a diferencia de la variable, que apaga
|
|
27
24
|
// el guard hasta que cierre la sesión.
|
|
28
|
-
const approved = (input, file) => !AP.pending(opsRoot(input), [file]).length
|
|
25
|
+
const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
|
|
29
26
|
|
|
30
27
|
// Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
|
|
31
28
|
// no contesta. Devuelve el motivo del bloqueo o cadena vacía.
|
|
@@ -88,12 +85,20 @@ function secrets(input) {
|
|
|
88
85
|
|
|
89
86
|
// Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
|
|
90
87
|
// grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
|
|
88
|
+
// Un comodín también nombra: `rg -g '.env*'`, el `glob` del Grep de Claude o el `include_pattern` del
|
|
89
|
+
// `grep_search` de Gemini recorren la carpeta buscando justo eso, y así leyeron el `.env` dos agentes en
|
|
90
|
+
// sesiones reales (caso 104). Se prueba el nombre con el comodín vacío, la forma más corta que el patrón
|
|
91
|
+
// acepta.
|
|
92
|
+
const patternNames = (token) => (/[*?]/.test(token) ? [token.replace(/[*?]/g, '')] : [token]).filter(Boolean)
|
|
93
|
+
|
|
91
94
|
function secretsRead(input) {
|
|
92
95
|
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
const fields = input.tool_input || {}
|
|
97
|
+
const patterns = [fields.glob, fields.include_pattern].filter((one) => typeof one === 'string')
|
|
98
|
+
for (const file of [...filesOf(input), ...patterns]) {
|
|
99
|
+
if (!patternNames(file).some((name) => credential(input, name)) || approved(input, file)) continue
|
|
95
100
|
block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
|
|
96
|
-
+ `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file])}`)
|
|
101
|
+
+ `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
@@ -147,7 +152,7 @@ function testEvidence(input) {
|
|
|
147
152
|
'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
|
|
148
153
|
'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
|
|
149
154
|
'decisión con dueño.\n'
|
|
150
|
-
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file])
|
|
155
|
+
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file], input)
|
|
151
156
|
for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
|
|
152
157
|
const removed = match[1].trim()
|
|
153
158
|
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
|
|
@@ -208,6 +213,10 @@ function isProduct(root, file) {
|
|
|
208
213
|
// vigila: la tarea ya está nombrada y el plan todavía no existe.
|
|
209
214
|
function planFirst(input) {
|
|
210
215
|
if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
|
|
216
|
+
// El plan lo exige el flujo, que es donde piensa el agente. Cuando la persona pide un cambio directo en
|
|
217
|
+
// el chat la que decidió es ella, y frenarla para que escriba un plan o apruebe una ruta es limitarla en
|
|
218
|
+
// lo que acaba de pedir (caso 098). Un subagente o un recorrido de Cauce no cuentan: `said` los descarta.
|
|
219
|
+
if (CHAT.said(input)) return
|
|
211
220
|
const root = opsRoot(input)
|
|
212
221
|
if (!root) return
|
|
213
222
|
const planning = path.join(root, 'planning')
|
|
@@ -227,16 +236,18 @@ function planFirst(input) {
|
|
|
227
236
|
for (const raw of filesOf(input)) {
|
|
228
237
|
if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
|
|
229
238
|
if (approved(input, raw)) continue
|
|
230
|
-
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw])}`)
|
|
239
|
+
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
|
|
231
240
|
}
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
function workspaceBoundary(input) {
|
|
235
244
|
const allowed = writableRoots(input)
|
|
236
|
-
if (!allowed) return
|
|
237
245
|
for (const raw of filesOf(input)) {
|
|
238
246
|
const file = path.resolve(cwdOf(input), raw)
|
|
239
|
-
|
|
247
|
+
// Lo mismo que en `shell-boundary`: la aprobación de la persona se juzga aunque no haya raíces.
|
|
248
|
+
const own = selfApproval(input, file)
|
|
249
|
+
if (own) block(own)
|
|
250
|
+
if (allowed && outsideRoots(file, allowed)) {
|
|
240
251
|
block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
241
252
|
}
|
|
242
253
|
}
|
|
@@ -301,7 +312,7 @@ function migrations(input) {
|
|
|
301
312
|
if (!esMigracion.test(normalized)) continue
|
|
302
313
|
if (approved(input, normalized)) continue
|
|
303
314
|
if (destructiveSql.test(contentOf(input))) {
|
|
304
|
-
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized])}`)
|
|
315
|
+
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
|
|
305
316
|
}
|
|
306
317
|
// El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
|
|
307
318
|
// lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
|
|
@@ -311,7 +322,7 @@ function migrations(input) {
|
|
|
311
322
|
const shipped = alreadyShipped(file)
|
|
312
323
|
if (shipped) {
|
|
313
324
|
block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
|
|
314
|
-
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized]))
|
|
325
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
}
|
|
@@ -342,6 +353,7 @@ function engineWrites(input) {
|
|
|
342
353
|
}
|
|
343
354
|
|
|
344
355
|
module.exports = {
|
|
356
|
+
credential, patternNames,
|
|
345
357
|
secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
346
358
|
migrations, engineWrites,
|
|
347
359
|
}
|
package/engine/hooks/input.js
CHANGED
|
@@ -183,9 +183,6 @@ function stagedFiles(dir) {
|
|
|
183
183
|
return result.stdout.trim().split('\n').filter(Boolean)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
|
|
187
|
-
// sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
|
|
188
|
-
// guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
|
|
189
186
|
// El índice que un hook de pre-ejecución lee es el de **antes** del comando, y el comando puede ser
|
|
190
187
|
// justamente el que lo llene. Ahí los tres guards que juzgan mirando el índice no fallan: leen bien,
|
|
191
188
|
// encuentran cero archivos y concluyen que no hay nada que revisar.
|
|
@@ -212,13 +209,6 @@ function stagedForCommit(command, cwd) {
|
|
|
212
209
|
return { dir, staged: stagedFiles(dir) }
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
function pushAllowed(input) {
|
|
216
|
-
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
217
|
-
if (!root) return false
|
|
218
|
-
const runner = configOf(root).runner
|
|
219
|
-
return Boolean(runner && runner.allowPush === true)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
212
|
function findOpsRoot(start) {
|
|
223
213
|
let current = path.resolve(start)
|
|
224
214
|
while (true) {
|
|
@@ -262,9 +252,15 @@ function outsideRoots(file, allowed) {
|
|
|
262
252
|
const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
|
|
263
253
|
+ 'ops.config.json; cambiar de herramienta no lo autoriza.'
|
|
264
254
|
|
|
255
|
+
// La raíz donde vive `planning/`, que es donde se busca la aprobación. La resuelven igual los guards de
|
|
256
|
+
// archivos, los de shell y la aprobación misma, así que se resuelve en un solo lugar.
|
|
257
|
+
function opsRoot(input) {
|
|
258
|
+
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
259
|
+
}
|
|
260
|
+
|
|
265
261
|
module.exports = {
|
|
266
262
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
267
|
-
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
268
|
-
findOpsRoot,
|
|
263
|
+
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
264
|
+
findOpsRoot, opsRoot,
|
|
269
265
|
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
270
266
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Si un `git push` se publica, y con qué autorización. R10 pide «la autorización configurada para el
|
|
4
|
+
// proyecto», y hasta 0.81.0 era un solo interruptor: `runner.allowPush` dejaba pasar cualquier push —a la
|
|
5
|
+
// rama viva, y de un subagente igual que de la persona— y, apagado, frenaba también el que la persona
|
|
6
|
+
// acababa de pedir con todas las letras (casos 103 y 108).
|
|
7
|
+
//
|
|
8
|
+
// Lo que garantiza, en orden:
|
|
9
|
+
//
|
|
10
|
+
// - Un subagente no publica, con ningún permiso: es trabajo que el agente delegó, dos pasos más lejos de
|
|
11
|
+
// la persona que la sesión. El push sale de la sesión principal.
|
|
12
|
+
// - Una rama viva —`main`, `master` y la rama por defecto de cada remoto— no la alcanza ni `allowPush` ni
|
|
13
|
+
// una orden del chat si el proyecto no la nombró en `runner.pushToLiveBranches`. La línea exacta en
|
|
14
|
+
// `.ops-approval` sí la alcanza: la escribe una persona a mano y los guards de límites no dejan que el
|
|
15
|
+
// agente se la escriba.
|
|
16
|
+
// - A una rama de trabajo, o a una viva ya nombrada, la alcanzan `allowPush`, la línea exacta en
|
|
17
|
+
// `.ops-approval`, un mensaje de la persona que ordena ese push con su remoto y su rama, o un «dale» al
|
|
18
|
+
// push que quedó frenado. Qué cuenta como orden lo decide `chat.js`.
|
|
19
|
+
//
|
|
20
|
+
// El `--force` no llega hasta acá: lo frena `destructive` antes, sin override (R8).
|
|
21
|
+
|
|
22
|
+
const { spawnSync } = require('node:child_process')
|
|
23
|
+
const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
|
|
24
|
+
const AP = require('./approval')
|
|
25
|
+
const CHAT = require('./chat')
|
|
26
|
+
|
|
27
|
+
// Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
|
|
28
|
+
// `destructive` explica en MISMO.
|
|
29
|
+
const PUSH = /\bgit\s+push\b([^;&|\n]*)/g
|
|
30
|
+
// Las banderas que llevan su valor en la palabra siguiente: sin saltearlo, el valor se leía como remoto.
|
|
31
|
+
const VALUED = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
|
|
32
|
+
// Publican todas las ramas, así que entre ellas la viva: no hay una rama que nombrar ni que aprobar.
|
|
33
|
+
const EVERY = new Set(['--all', '--branches', '--mirror'])
|
|
34
|
+
|
|
35
|
+
// Lectura local e inocua del repositorio: ninguna de estas consultas habla con el remoto. Si falla, no
|
|
36
|
+
// hay dato, y quien pregunta decide sin él.
|
|
37
|
+
function git(dir, args) {
|
|
38
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
|
|
39
|
+
return result.status === 0 ? result.stdout.trim() : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// La rama por defecto de un remoto es la que git anota en `refs/remotes/<remoto>/HEAD`: la escribe el
|
|
43
|
+
// `clone` y la cambia `git remote set-head`, así que el proyecto la declara sin un campo nuevo. `main` y
|
|
44
|
+
// `master` cuentan siempre, porque un remoto agregado a mano no la anota.
|
|
45
|
+
function liveBranches(dir) {
|
|
46
|
+
const live = new Set(['main', 'master'])
|
|
47
|
+
const refs = git(dir, ['for-each-ref', '--format=%(refname)%09%(symref)', 'refs/remotes/'])
|
|
48
|
+
for (const line of refs.split('\n')) {
|
|
49
|
+
const [name, target] = line.split('\t')
|
|
50
|
+
if (!target || !name.endsWith('/HEAD')) continue
|
|
51
|
+
live.add(target.slice(name.length - 'HEAD'.length))
|
|
52
|
+
}
|
|
53
|
+
return live
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A dónde publicaría un `git push` sin rama: lo que git resuelve como `@{push}`, que ya aplica
|
|
57
|
+
// push.default y el remoto de publicación que declare la rama. Sin upstream no resuelve, y git mismo
|
|
58
|
+
// tampoco sabría a dónde ir.
|
|
59
|
+
function pushTarget(dir, current) {
|
|
60
|
+
if (!current) return null
|
|
61
|
+
const remote = git(dir, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${current}`])
|
|
62
|
+
const full = git(dir, ['rev-parse', '--symbolic-full-name', '@{push}'])
|
|
63
|
+
const prefix = `refs/remotes/${remote}/`
|
|
64
|
+
return remote && full.startsWith(prefix) ? { remote, branch: full.slice(prefix.length) } : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Los destinos de un push: `{ item, remote, branch }` con el ítem que se aprueba, `push <remoto> <rama>`.
|
|
68
|
+
// El que no se puede resolver lleva por ítem el comando mismo, que no nombra ninguna rama: lo aprueba un
|
|
69
|
+
// «dale» a ese bloqueo y nada en `.ops-approval`.
|
|
70
|
+
function destinationsOf(args, dir) {
|
|
71
|
+
const words = args.trim().split(/\s+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''))
|
|
72
|
+
const positional = []
|
|
73
|
+
const found = []
|
|
74
|
+
for (let at = 0; at < words.length; at += 1) {
|
|
75
|
+
if (!words[at].startsWith('-')) positional.push(words[at])
|
|
76
|
+
else if (EVERY.has(words[at])) found.push({ every: true })
|
|
77
|
+
else if (VALUED.has(words[at])) at += 1
|
|
78
|
+
else if (words[at] === '--tags') found.push({ tags: true })
|
|
79
|
+
}
|
|
80
|
+
const current = git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
81
|
+
const target = pushTarget(dir, current)
|
|
82
|
+
const [remote, ...refspecs] = positional
|
|
83
|
+
const unknown = { item: 'git push', branch: current }
|
|
84
|
+
const one = (to, branch) => (branch ? { item: `push ${to} ${branch}`, remote: to, branch } : unknown)
|
|
85
|
+
const named = refspecs.map((spec) => {
|
|
86
|
+
const destination = spec.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '')
|
|
87
|
+
return one(remote, destination === 'HEAD' ? current : destination)
|
|
88
|
+
})
|
|
89
|
+
const rest = found.map((flag) => (flag.every ? { every: true }
|
|
90
|
+
: one(remote || (target && target.remote) || 'origin', '--tags')))
|
|
91
|
+
if (named.length || found.length) return [...named, ...rest]
|
|
92
|
+
if (remote) return [one(remote, target && target.remote === remote ? target.branch : current)]
|
|
93
|
+
return [target ? one(target.remote, target.branch) : unknown]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SUBAGENT = "'git push' desde un subagente no se publica, con ningún permiso: publicar lo decide una "
|
|
97
|
+
+ 'persona (R10), y un subagente es trabajo que el agente delegó. Devolvé el resultado a la sesión '
|
|
98
|
+
+ 'principal, que es la que publica.'
|
|
99
|
+
|
|
100
|
+
function liveMessage(live, input) {
|
|
101
|
+
const lines = live.filter((to) => to.remote).map((to) => to.item)
|
|
102
|
+
const names = [...new Set(live.map((to) => (to.every ? 'todas las ramas' : to.branch)))].join(', ')
|
|
103
|
+
// Le habla al agente y deja el permiso como cosa de la persona, por lo mismo que `AP.HOW`: medido en
|
|
104
|
+
// una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
|
|
105
|
+
return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
|
|
106
|
+
+ 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
|
|
107
|
+
+ 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
|
|
108
|
+
+ 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
|
|
109
|
+
+ (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
110
|
+
+ lines.map((line) => ` ${line}\n`).join('') : '.')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function workMessage(items, input) {
|
|
114
|
+
const lines = items.filter((item) => item.startsWith('push '))
|
|
115
|
+
const chat = CHAT.hold(input, items)
|
|
116
|
+
const how = chat
|
|
117
|
+
? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
|
|
118
|
+
+ 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
|
|
119
|
+
: 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
|
|
120
|
+
const paste = lines.length
|
|
121
|
+
? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
122
|
+
+ lines.map((line) => ` ${line}\n`).join('')
|
|
123
|
+
: ' Sin remoto y rama que se puedan leer del comando no hay línea que aprobar: nombralos.\n'
|
|
124
|
+
return `'git push' publica cambios y requiere una acción humana. ${how}${paste}`
|
|
125
|
+
+ 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function publish(input, command) {
|
|
129
|
+
const pushes = [...command.matchAll(PUSH)]
|
|
130
|
+
if (!pushes.length) return
|
|
131
|
+
if (input.agent_id) block(SUBAGENT)
|
|
132
|
+
const dir = gitDirectory(command, cwdOf(input))
|
|
133
|
+
const root = opsRoot(input)
|
|
134
|
+
const runner = (root && configOf(root).runner) || {}
|
|
135
|
+
const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
|
|
136
|
+
const filed = new Set(root ? AP.read(root) : [])
|
|
137
|
+
const live = liveBranches(dir)
|
|
138
|
+
const left = pushes.flatMap((match) => destinationsOf(match[1], dir))
|
|
139
|
+
.filter((to) => !(to.item && to.item.startsWith('push ') && filed.has(to.item)))
|
|
140
|
+
const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
|
|
141
|
+
if (unlisted.length) block(liveMessage(unlisted, input))
|
|
142
|
+
if (runner.allowPush === true) return
|
|
143
|
+
const unordered = CHAT.unauthorized(input, [...new Set(left.map((to) => to.item))], CHAT.ordersPush)
|
|
144
|
+
if (unordered.length) block(workMessage(unordered, input))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { publish }
|
package/engine/hooks/run.js
CHANGED
|
@@ -12,6 +12,8 @@ const path = require('node:path')
|
|
|
12
12
|
const { readInput, cwdOf, block, findOpsRoot } = require('./input')
|
|
13
13
|
const shell = require('./shell')
|
|
14
14
|
const files = require('./files')
|
|
15
|
+
const chat = require('./chat')
|
|
16
|
+
const { secretsShell } = require('./secrets-shell')
|
|
15
17
|
|
|
16
18
|
function planningDrift(input) {
|
|
17
19
|
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
@@ -39,6 +41,7 @@ const guards = {
|
|
|
39
41
|
governance: shell.governance,
|
|
40
42
|
verify: shell.verify,
|
|
41
43
|
'shell-boundary': shell.shellBoundary,
|
|
44
|
+
'secrets-shell': secretsShell,
|
|
42
45
|
secrets: files.secrets,
|
|
43
46
|
generated: files.generated,
|
|
44
47
|
'workspace-boundary': files.workspaceBoundary,
|
|
@@ -48,15 +51,18 @@ const guards = {
|
|
|
48
51
|
'test-evidence': files.testEvidence,
|
|
49
52
|
'plan-first': files.planFirst,
|
|
50
53
|
'secrets-read': files.secretsRead,
|
|
54
|
+
chat: chat.record,
|
|
51
55
|
'planning-drift': planningDrift,
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
|
|
55
59
|
const hookGroups = {
|
|
56
|
-
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'
|
|
60
|
+
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
|
|
61
|
+
'secrets-shell'],
|
|
57
62
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
58
63
|
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
59
64
|
'pre-read': ['secrets-read'],
|
|
65
|
+
prompt: ['chat'],
|
|
60
66
|
stop: ['planning-drift'],
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -89,7 +95,8 @@ const hookMetadata = [
|
|
|
89
95
|
{
|
|
90
96
|
name: 'shell-boundary',
|
|
91
97
|
event: 'PreToolUse · shell',
|
|
92
|
-
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas
|
|
98
|
+
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas, o en la '
|
|
99
|
+
+ 'aprobación de la persona.',
|
|
93
100
|
},
|
|
94
101
|
{
|
|
95
102
|
name: 'secrets',
|
|
@@ -101,11 +108,18 @@ const hookMetadata = [
|
|
|
101
108
|
event: 'PreToolUse · read',
|
|
102
109
|
purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
|
|
103
110
|
},
|
|
111
|
+
{
|
|
112
|
+
name: 'secrets-shell',
|
|
113
|
+
event: 'PreToolUse · shell',
|
|
114
|
+
purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
|
|
115
|
+
+ 'pasa.',
|
|
116
|
+
},
|
|
104
117
|
{ name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
|
|
105
118
|
{
|
|
106
119
|
name: 'workspace-boundary',
|
|
107
120
|
event: 'PreToolUse · files',
|
|
108
|
-
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json
|
|
121
|
+
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json, y no deja al agente escribirse '
|
|
122
|
+
+ 'la aprobación de la persona.',
|
|
109
123
|
},
|
|
110
124
|
{
|
|
111
125
|
name: 'engine',
|
|
@@ -133,6 +147,12 @@ const hookMetadata = [
|
|
|
133
147
|
event: 'PreToolUse · files',
|
|
134
148
|
purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
|
|
135
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'chat',
|
|
152
|
+
event: 'UserPromptSubmit / BeforeAgent',
|
|
153
|
+
purpose: 'Registra el mensaje de la persona: lo que nombró o aprobó en el chat pasa sin archivo. Nunca '
|
|
154
|
+
+ 'bloquea.',
|
|
155
|
+
},
|
|
136
156
|
{
|
|
137
157
|
name: 'planning-drift',
|
|
138
158
|
event: 'Stop / SessionEnd',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Leer una credencial por shell. `secrets-read` mira la herramienta de lectura del runner, y un `cat .env`
|
|
4
|
+
// no pasa por ella: en Claude lo tapaba una regla nativa `permissions.deny`, que además frenaba lo que la
|
|
5
|
+
// persona pedía, y en los otros runners no lo tapaba nada (caso 104). Esto lo mira donde pasan todos los
|
|
6
|
+
// comandos, con la misma salida que el resto: lo que la persona pidió en el chat pasa.
|
|
7
|
+
//
|
|
8
|
+
// Como todo lo que lee el texto de un comando, frena la forma habitual y no un script decidido: un nombre
|
|
9
|
+
// armado en una variable o un `grep -r` sobre la carpeta sin nombrar el archivo pasan.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { commandOf, cwdOf, block, isCommit, unquoted, opsRoot } = require('./input')
|
|
13
|
+
const { credential, patternNames } = require('./files')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
|
|
16
|
+
// Lo que muestra el contenido de un archivo. Queda afuera lo que sólo lo nombra —`ls`, `test -f`, `rm`,
|
|
17
|
+
// `git add`—, que no deja nada en la sesión, y `cp` y `mv`, cuyo último argumento es un destino: `cp
|
|
18
|
+
// .env.example .env` es preparar el entorno, no leerlo. `nl` entró porque un agente lo usó en una sesión
|
|
19
|
+
// real para leer el `.env` después de que la lista no lo tuviera.
|
|
20
|
+
const READERS = new Set(['cat', 'tac', 'nl', 'head', 'tail', 'less', 'more', 'bat', 'sed', 'awk', 'grep', 'egrep',
|
|
21
|
+
'fgrep', 'rg', 'strings', 'xxd', 'od', 'hexdump', 'base64', 'cut', 'sort', 'uniq', 'rev', 'paste', 'fold', 'pr',
|
|
22
|
+
'dd', 'jq', 'yq', 'diff', 'cmp', 'source', '.', 'node', 'python', 'python3', 'ruby', 'perl', 'php', 'deno', 'bun'])
|
|
23
|
+
const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nohup', 'nice', 'xargs'])
|
|
24
|
+
|
|
25
|
+
// El verbo de un tramo, saltando lo que va delante sin serlo: asignaciones, prefijos y sus banderas.
|
|
26
|
+
function verbOf(words) {
|
|
27
|
+
let prefixed = false
|
|
28
|
+
while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || PREFIXES.has(words[0])
|
|
29
|
+
|| (prefixed && words[0].startsWith('-')))) {
|
|
30
|
+
prefixed = prefixed || PREFIXES.has(words[0])
|
|
31
|
+
words.shift()
|
|
32
|
+
}
|
|
33
|
+
return words[0] || ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
|
|
37
|
+
// entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
|
|
38
|
+
function readTokens(command) {
|
|
39
|
+
const found = []
|
|
40
|
+
for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
|
|
41
|
+
const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
|
|
42
|
+
const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
|
|
43
|
+
if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(found)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function secretsShell(input) {
|
|
49
|
+
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
50
|
+
const raw = commandOf(input)
|
|
51
|
+
// El mensaje de un commit es dato, como en `destructive`: nombrar el archivo ahí no lo lee.
|
|
52
|
+
const command = isCommit(raw) ? unquoted(raw) : raw
|
|
53
|
+
const cwd = cwdOf(input)
|
|
54
|
+
const files = readTokens(command).filter((token) => patternNames(token).some((name) => credential(input, name)))
|
|
55
|
+
.map((token) => path.resolve(cwd, token))
|
|
56
|
+
const left = AP.pending(opsRoot(input), [...new Set(files)], input)
|
|
57
|
+
if (!left.length) return
|
|
58
|
+
block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
|
|
59
|
+
+ `Si hace falta un valor, pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { secretsShell }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El canal por el que una persona dice «sí» no lo puede escribir el agente: si pudiera, cada bloqueo que
|
|
4
|
+
// ofrece aprobarse se aprobaría solo. Pasaba —ningún guard miraba `planning/.ops-approval`— y en la prueba
|
|
5
|
+
// en vivo del 092 el agente, frenado, intentó escribírsela (caso 098).
|
|
6
|
+
//
|
|
7
|
+
// Lo preguntan los dos guards de límites —el que mira un `Write` y el que mira el destino de un comando—,
|
|
8
|
+
// que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
|
|
9
|
+
// hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
|
|
10
|
+
// herramienta.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
const { opsRoot } = require('./input')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
const CHAT = require('./chat')
|
|
16
|
+
|
|
17
|
+
// Por qué el agente no puede escribir en `file`, o vacío.
|
|
18
|
+
function selfApproval(input, file) {
|
|
19
|
+
if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) {
|
|
20
|
+
return `${file} es el registro de lo que la persona dijo en el chat: lo escribe el runner, nunca una `
|
|
21
|
+
+ 'herramienta.'
|
|
22
|
+
}
|
|
23
|
+
const root = opsRoot(input)
|
|
24
|
+
if (!root || file !== path.join(root, 'planning', AP.APPROVAL)) return ''
|
|
25
|
+
if (!CHAT.unauthorized(input, [file]).length) return ''
|
|
26
|
+
return `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la persona quiere `
|
|
27
|
+
+ 'autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o que edite el '
|
|
28
|
+
+ 'archivo ella.'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { selfApproval }
|