@ingeniomaps/cauce 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/README.md +6 -3
- package/automatization/hooks/README.md +48 -2
- package/automatization/hooks/guard-chat.sh +5 -0
- package/automatization/hooks/guard-secrets-read.sh +3 -0
- package/automatization/hooks/guard-secrets-shell.sh +3 -0
- package/automatization/runners/claude/README.md +5 -2
- package/automatization/runners/claude/manifest.json +26 -1
- package/automatization/runners/claude/settings.json +13 -0
- package/automatization/runners/codex/README.md +4 -0
- package/automatization/runners/codex/hooks.json +7 -0
- package/automatization/runners/gemini/README.md +6 -2
- package/automatization/runners/gemini/settings.json +19 -0
- package/engine/automation/index.js +9 -1
- package/engine/cli/args.js +1 -0
- package/engine/cli/bootstrap.js +1 -1
- package/engine/cli/ops.js +9 -5
- package/engine/cli/wiring.js +21 -3
- package/engine/config/paths.js +5 -3
- package/engine/hooks/approval.js +42 -8
- package/engine/hooks/chat.js +122 -0
- package/engine/hooks/files.js +94 -35
- package/engine/hooks/input.js +7 -1
- package/engine/hooks/run.js +30 -3
- package/engine/hooks/secrets-shell.js +62 -0
- package/engine/hooks/self-approval.js +31 -0
- package/engine/hooks/shell.js +49 -28
- package/engine/integrations/providers/jira.js +1 -1
- package/engine/integrations/registry.js +28 -5
- package/engine/secrets/index.js +201 -0
- package/package.json +1 -1
- package/template/AGENTS.md +14 -3
- package/template/integrations/README.md +21 -0
- package/template/organization/README.md +52 -0
- package/template/planning/adr/README.md +1 -0
- package/template/planning/adr/system/OPS-007-contrato-de-secretos-compartido.md +62 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Lo que la persona dijo en el chat, capturado por el runner y no contado por el modelo. Existe porque un
|
|
4
|
+
// guard sólo ve la llamada a la herramienta: frenaba igual lo que la persona pidió con todas las letras y
|
|
5
|
+
// lo que el agente decidió solo, y la única forma de decir «sí» era un archivo que el agente también
|
|
6
|
+
// podía escribirse (caso 098).
|
|
7
|
+
//
|
|
8
|
+
// El hook de mensaje del runner —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini— corre
|
|
9
|
+
// cuando la persona manda algo, y Claude y Codex le pasan a cada llamada el identificador del mensaje que
|
|
10
|
+
// la originó. Lo que llega por otro lado —un README, un ticket, el resultado de una herramienta— nunca
|
|
11
|
+
// pasa por acá, y ésa es toda la diferencia entre una orden y una sugerencia.
|
|
12
|
+
//
|
|
13
|
+
// No es un límite de seguridad, como ningún guard: un agente decidido a escribir este registro lo escribe
|
|
14
|
+
// con un script. Lo que se frena es la forma habitual, y la frenan los guards de límites.
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const os = require('node:os')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
// El temporal y no la instancia: el texto de la persona no tiene por qué terminar en un commit, y una
|
|
21
|
+
// orden dura lo que dura la sesión.
|
|
22
|
+
const DIR = path.join(os.tmpdir(), 'cauce-chat')
|
|
23
|
+
|
|
24
|
+
// Los recorridos de Cauce, donde el que piensa es el agente y los guards contienen como siempre. Salen de
|
|
25
|
+
// los workflows que el paquete entrega, así que uno nuevo queda cubierto sin tocar esto; los de
|
|
26
|
+
// `integrations/` se invocan con ese prefijo. Claude y Gemini los llaman con `/`, Codex con `$`.
|
|
27
|
+
const WORKFLOWS = path.join(__dirname, '..', '..', 'automatization', 'workflows')
|
|
28
|
+
const scripts = (dir, prefix) => fs.readdirSync(dir).filter((one) => one.endsWith('.js'))
|
|
29
|
+
.map((one) => `${prefix}${one.slice(0, -3)}`)
|
|
30
|
+
function flowCommand(text) {
|
|
31
|
+
let names = []
|
|
32
|
+
try { names = [...scripts(WORKFLOWS, ''), ...scripts(path.join(WORKFLOWS, 'integrations'), 'integration-')] }
|
|
33
|
+
catch { return false }
|
|
34
|
+
return names.length > 0 && new RegExp(`^\\s*[/$](?:cauce:)?(?:${names.join('|')})(?![\\w-])`).test(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Claude lo llama `prompt_id` y Codex `turn_id`; Gemini no manda ninguno y ahí vale el último mensaje.
|
|
38
|
+
const idOf = (input) => String(input.prompt_id || input.turn_id || '')
|
|
39
|
+
const recordPath = (session) => path.join(DIR, `${String(session).replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
|
|
40
|
+
|
|
41
|
+
function load(session) {
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(recordPath(session), 'utf8')) } catch { return null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
|
|
46
|
+
// antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
|
|
47
|
+
const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
|
|
48
|
+
const CLAUSE = /[.,;:!?\n]/
|
|
49
|
+
|
|
50
|
+
// Cada aparición del nombre en el texto y si va negada. Un nombre tiene que estar entero: `.env` no
|
|
51
|
+
// aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
|
|
52
|
+
function mentions(text, item) {
|
|
53
|
+
const lower = String(text).toLowerCase()
|
|
54
|
+
const found = []
|
|
55
|
+
for (const name of new Set([item, path.basename(item)].map((one) => one.toLowerCase()))) {
|
|
56
|
+
if (name.length < 3) continue
|
|
57
|
+
for (let at = lower.indexOf(name); at !== -1; at = lower.indexOf(name, at + 1)) {
|
|
58
|
+
const before = lower[at - 1]
|
|
59
|
+
const rest = lower.slice(at + name.length)
|
|
60
|
+
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
61
|
+
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
62
|
+
found.push(NEGATION.test(lower.slice(0, at).split(CLAUSE).pop()))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { named: found.includes(false), denied: found.includes(true) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
|
|
69
|
+
// entera o su primera palabra, no algo que aparece en medio de otra frase.
|
|
70
|
+
const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
|
|
71
|
+
+ String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
|
|
72
|
+
|
|
73
|
+
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
74
|
+
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
75
|
+
// que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
|
|
76
|
+
function record(input) {
|
|
77
|
+
try {
|
|
78
|
+
if (!input.session_id) return
|
|
79
|
+
const text = String(input.prompt || '')
|
|
80
|
+
const human = !process.env.CI && !/^\s*</.test(text)
|
|
81
|
+
const previous = load(input.session_id)
|
|
82
|
+
const approved = human && previous && YES.test(text)
|
|
83
|
+
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
84
|
+
: []
|
|
85
|
+
fs.mkdirSync(DIR, { recursive: true })
|
|
86
|
+
fs.writeFileSync(recordPath(input.session_id),
|
|
87
|
+
JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
|
|
88
|
+
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// El mensaje de la persona que originó esta llamada, o nada. Nada cuando no hay persona, cuando lo que
|
|
92
|
+
// pidió es un recorrido de Cauce, cuando la llamada la hace un subagente —trabajo que el agente delegó,
|
|
93
|
+
// y Claude lo marca con `agent_id`— o cuando el registro es de otro mensaje.
|
|
94
|
+
function said(input) {
|
|
95
|
+
if (process.env.CI || input.agent_id || !input.session_id) return null
|
|
96
|
+
const saved = load(input.session_id)
|
|
97
|
+
if (!saved || !saved.human || saved.flow) return null
|
|
98
|
+
const current = idOf(input)
|
|
99
|
+
return current && saved.id && current !== saved.id ? null : saved
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar: ni lo nombró en su mensaje ni lo
|
|
103
|
+
// aprobó contestando.
|
|
104
|
+
function unauthorized(input, items) {
|
|
105
|
+
const saved = said(input)
|
|
106
|
+
if (!saved) return items
|
|
107
|
+
return items.filter((item) => !saved.approved.includes(item) && !mentions(saved.text, item).named)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
111
|
+
// Devuelve si hay una persona a quien preguntarle.
|
|
112
|
+
function hold(input, items) {
|
|
113
|
+
const saved = said(input)
|
|
114
|
+
if (!saved) return false
|
|
115
|
+
try {
|
|
116
|
+
saved.pending = [...new Set([...saved.pending, ...items])]
|
|
117
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
118
|
+
return true
|
|
119
|
+
} catch { return false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { DIR, record, said, unauthorized, hold }
|
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.
|
|
@@ -50,25 +47,58 @@ function alreadyShipped(file) {
|
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
|
|
50
|
+
// Qué archivo es una credencial, para los dos guards que la cuidan: `secrets`, que frena escribirla, y
|
|
51
|
+
// `secrets-read`, que frena leerla. Devuelve el motivo, o vacío.
|
|
52
|
+
function credential(input, raw) {
|
|
53
|
+
const base = path.basename(raw)
|
|
54
|
+
if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
|
|
55
|
+
return 'parece contener secretos. Edita una plantilla o registra una acción humana.'
|
|
56
|
+
}
|
|
57
|
+
if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
|
|
58
|
+
return 'parece un archivo de credenciales en texto plano.'
|
|
59
|
+
}
|
|
60
|
+
// Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
|
|
61
|
+
// `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
|
|
62
|
+
// hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
|
|
63
|
+
// exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
|
|
64
|
+
//
|
|
65
|
+
// Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
|
|
66
|
+
// nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
|
|
67
|
+
if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
|
|
68
|
+
return 'es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.'
|
|
69
|
+
}
|
|
70
|
+
// Lo que ningún nombre delata: una identidad de máquina que el 088 declara puede llamarse
|
|
71
|
+
// `local-dev.env` (caso 092). Se lee sólo si la declaración existe, para no cargarla en cada hook.
|
|
72
|
+
const root = opsRoot(input)
|
|
73
|
+
if (!root || !fs.existsSync(path.join(root, 'organization', 'secrets.json'))) return ''
|
|
74
|
+
return require('../secrets').identityFiles(root).includes(path.resolve(cwdOf(input), raw))
|
|
75
|
+
? 'es una identidad declarada en organization/secrets.json: la carga una persona.'
|
|
76
|
+
: ''
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
function secrets(input) {
|
|
54
80
|
for (const file of filesOf(input)) {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
const reason = credential(input, file)
|
|
82
|
+
if (reason) block(`${file} ${reason}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
|
|
87
|
+
// grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
|
|
88
|
+
// Un comodín también nombra: `rg -g '.env*'`, el `glob` del Grep de Claude o el `include_pattern` del
|
|
89
|
+
// `grep_search` de Gemini recorren la carpeta buscando justo eso, y así leyeron el `.env` dos agentes en
|
|
90
|
+
// sesiones reales (caso 104). Se prueba el nombre con el comodín vacío, la forma más corta que el patrón
|
|
91
|
+
// acepta.
|
|
92
|
+
const patternNames = (token) => (/[*?]/.test(token) ? [token.replace(/[*?]/g, '')] : [token]).filter(Boolean)
|
|
93
|
+
|
|
94
|
+
function secretsRead(input) {
|
|
95
|
+
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
96
|
+
const fields = input.tool_input || {}
|
|
97
|
+
const patterns = [fields.glob, fields.include_pattern].filter((one) => typeof one === 'string')
|
|
98
|
+
for (const file of [...filesOf(input), ...patterns]) {
|
|
99
|
+
if (!patternNames(file).some((name) => credential(input, name)) || approved(input, file)) continue
|
|
100
|
+
block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
|
|
101
|
+
+ `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
|
|
72
102
|
}
|
|
73
103
|
}
|
|
74
104
|
|
|
@@ -121,17 +151,18 @@ function testEvidence(input) {
|
|
|
121
151
|
'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
|
|
122
152
|
'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
|
|
123
153
|
'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
|
|
124
|
-
'decisión con dueño.\n'
|
|
154
|
+
'decisión con dueño.\n'
|
|
155
|
+
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file], input)
|
|
125
156
|
for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
|
|
126
157
|
const removed = match[1].trim()
|
|
127
|
-
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}`)
|
|
158
|
+
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
|
|
128
159
|
}
|
|
129
160
|
const content = contentOf(input)
|
|
130
161
|
if (!content) return
|
|
131
162
|
for (const raw of filesOf(input)) {
|
|
132
163
|
if (!isTestFile(raw) || approved(input, raw)) continue
|
|
133
164
|
for (const [marca, nombre] of TEST_OFF) {
|
|
134
|
-
if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
|
|
165
|
+
if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}${how(raw)}`)
|
|
135
166
|
}
|
|
136
167
|
}
|
|
137
168
|
}
|
|
@@ -150,6 +181,28 @@ function opsOwned(root, file) {
|
|
|
150
181
|
return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
|
|
151
182
|
}
|
|
152
183
|
|
|
184
|
+
// Producto es el código de una raíz declarada, y la instancia sidecar no lo es aunque viva dentro de una:
|
|
185
|
+
// `init` escribe `..` como raíz en sidecar, así que la carpeta de la instancia cae adentro. En embedded la
|
|
186
|
+
// raíz de ops **es** una raíz de producto, y ahí sólo se exime lo que la instancia posee. Lo que queda
|
|
187
|
+
// fuera de toda raíz tampoco es producto: el límite de raíces ya lo juzgó, y si pasó es porque el proyecto
|
|
188
|
+
// lo declaró en `writableOutsideRoots` (casos 089 y 090).
|
|
189
|
+
//
|
|
190
|
+
// `ops.config.json` se exime por nombre porque es la llave del límite de raíces: su mensaje manda a
|
|
191
|
+
// editarlo, y frenar esa edición era el candado de arriba con otra forma.
|
|
192
|
+
const INSTANCE_CONFIG = 'ops.config.json'
|
|
193
|
+
|
|
194
|
+
function isProduct(root, file) {
|
|
195
|
+
if (opsOwned(root, file) || file === path.join(root, INSTANCE_CONFIG)) return false
|
|
196
|
+
const declared = configOf(root).workspaceRoots
|
|
197
|
+
const roots = (Array.isArray(declared) ? declared : [])
|
|
198
|
+
.filter((entry) => entry && typeof entry.path === 'string')
|
|
199
|
+
.map((entry) => path.resolve(root, entry.path))
|
|
200
|
+
// Sin raíces legibles no hay contra qué comparar, y se juzga como antes: frenar de más.
|
|
201
|
+
if (!roots.length) return true
|
|
202
|
+
if (outsideRoots(file, roots)) return false
|
|
203
|
+
return outsideRoots(file, [root]) || roots.includes(root)
|
|
204
|
+
}
|
|
205
|
+
|
|
153
206
|
// R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
|
|
154
207
|
// tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
|
|
155
208
|
// hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
|
|
@@ -160,6 +213,10 @@ function opsOwned(root, file) {
|
|
|
160
213
|
// vigila: la tarea ya está nombrada y el plan todavía no existe.
|
|
161
214
|
function planFirst(input) {
|
|
162
215
|
if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
|
|
216
|
+
// El plan lo exige el flujo, que es donde piensa el agente. Cuando la persona pide un cambio directo en
|
|
217
|
+
// el chat la que decidió es ella, y frenarla para que escriba un plan o apruebe una ruta es limitarla en
|
|
218
|
+
// lo que acaba de pedir (caso 098). Un subagente o un recorrido de Cauce no cuentan: `said` los descarta.
|
|
219
|
+
if (CHAT.said(input)) return
|
|
163
220
|
const root = opsRoot(input)
|
|
164
221
|
if (!root) return
|
|
165
222
|
const planning = path.join(root, 'planning')
|
|
@@ -176,20 +233,21 @@ function planFirst(input) {
|
|
|
176
233
|
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
177
234
|
+ 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
178
235
|
+ 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
|
|
179
|
-
+ AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
|
|
180
236
|
for (const raw of filesOf(input)) {
|
|
181
|
-
if (
|
|
237
|
+
if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
|
|
182
238
|
if (approved(input, raw)) continue
|
|
183
|
-
block(`${raw} cambia el producto sin plan. ${why}`)
|
|
239
|
+
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
|
|
184
240
|
}
|
|
185
241
|
}
|
|
186
242
|
|
|
187
243
|
function workspaceBoundary(input) {
|
|
188
244
|
const allowed = writableRoots(input)
|
|
189
|
-
if (!allowed) return
|
|
190
245
|
for (const raw of filesOf(input)) {
|
|
191
246
|
const file = path.resolve(cwdOf(input), raw)
|
|
192
|
-
|
|
247
|
+
// Lo mismo que en `shell-boundary`: la aprobación de la persona se juzga aunque no haya raíces.
|
|
248
|
+
const own = selfApproval(input, file)
|
|
249
|
+
if (own) block(own)
|
|
250
|
+
if (allowed && outsideRoots(file, allowed)) {
|
|
193
251
|
block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
194
252
|
}
|
|
195
253
|
}
|
|
@@ -254,7 +312,7 @@ function migrations(input) {
|
|
|
254
312
|
if (!esMigracion.test(normalized)) continue
|
|
255
313
|
if (approved(input, normalized)) continue
|
|
256
314
|
if (destructiveSql.test(contentOf(input))) {
|
|
257
|
-
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
|
|
315
|
+
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
|
|
258
316
|
}
|
|
259
317
|
// El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
|
|
260
318
|
// lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
|
|
@@ -264,7 +322,7 @@ function migrations(input) {
|
|
|
264
322
|
const shipped = alreadyShipped(file)
|
|
265
323
|
if (shipped) {
|
|
266
324
|
block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
|
|
267
|
-
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE'))
|
|
325
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
|
|
268
326
|
}
|
|
269
327
|
}
|
|
270
328
|
}
|
|
@@ -295,6 +353,7 @@ function engineWrites(input) {
|
|
|
295
353
|
}
|
|
296
354
|
|
|
297
355
|
module.exports = {
|
|
298
|
-
|
|
356
|
+
credential, patternNames,
|
|
357
|
+
secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
299
358
|
migrations, engineWrites,
|
|
300
359
|
}
|
package/engine/hooks/input.js
CHANGED
|
@@ -262,9 +262,15 @@ function outsideRoots(file, allowed) {
|
|
|
262
262
|
const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
|
|
263
263
|
+ 'ops.config.json; cambiar de herramienta no lo autoriza.'
|
|
264
264
|
|
|
265
|
+
// La raíz donde vive `planning/`, que es donde se busca la aprobación. La resuelven igual los guards de
|
|
266
|
+
// archivos, los de shell y la aprobación misma, así que se resuelve en un solo lugar.
|
|
267
|
+
function opsRoot(input) {
|
|
268
|
+
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
269
|
+
}
|
|
270
|
+
|
|
265
271
|
module.exports = {
|
|
266
272
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
267
273
|
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit, pushAllowed,
|
|
268
|
-
findOpsRoot,
|
|
274
|
+
findOpsRoot, opsRoot,
|
|
269
275
|
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
270
276
|
}
|
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,
|
|
@@ -47,14 +50,19 @@ const guards = {
|
|
|
47
50
|
'integration-snapshot': files.integrationSnapshot,
|
|
48
51
|
'test-evidence': files.testEvidence,
|
|
49
52
|
'plan-first': files.planFirst,
|
|
53
|
+
'secrets-read': files.secretsRead,
|
|
54
|
+
chat: chat.record,
|
|
50
55
|
'planning-drift': planningDrift,
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
// Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
|
|
54
59
|
const hookGroups = {
|
|
55
|
-
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'
|
|
60
|
+
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
|
|
61
|
+
'secrets-shell'],
|
|
56
62
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
57
63
|
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
64
|
+
'pre-read': ['secrets-read'],
|
|
65
|
+
prompt: ['chat'],
|
|
58
66
|
stop: ['planning-drift'],
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -87,18 +95,31 @@ const hookMetadata = [
|
|
|
87
95
|
{
|
|
88
96
|
name: 'shell-boundary',
|
|
89
97
|
event: 'PreToolUse · shell',
|
|
90
|
-
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas
|
|
98
|
+
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas, o en la '
|
|
99
|
+
+ 'aprobación de la persona.',
|
|
91
100
|
},
|
|
92
101
|
{
|
|
93
102
|
name: 'secrets',
|
|
94
103
|
event: 'PreToolUse · files',
|
|
95
104
|
purpose: 'Bloquea escribir secretos, claves privadas y credenciales en texto plano.',
|
|
96
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: 'secrets-read',
|
|
108
|
+
event: 'PreToolUse · read',
|
|
109
|
+
purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'secrets-shell',
|
|
113
|
+
event: 'PreToolUse · shell',
|
|
114
|
+
purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
|
|
115
|
+
+ 'pasa.',
|
|
116
|
+
},
|
|
97
117
|
{ name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
|
|
98
118
|
{
|
|
99
119
|
name: 'workspace-boundary',
|
|
100
120
|
event: 'PreToolUse · files',
|
|
101
|
-
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json
|
|
121
|
+
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json, y no deja al agente escribirse '
|
|
122
|
+
+ 'la aprobación de la persona.',
|
|
102
123
|
},
|
|
103
124
|
{
|
|
104
125
|
name: 'engine',
|
|
@@ -126,6 +147,12 @@ const hookMetadata = [
|
|
|
126
147
|
event: 'PreToolUse · files',
|
|
127
148
|
purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
|
|
128
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'chat',
|
|
152
|
+
event: 'UserPromptSubmit / BeforeAgent',
|
|
153
|
+
purpose: 'Registra el mensaje de la persona: lo que nombró o aprobó en el chat pasa sin archivo. Nunca '
|
|
154
|
+
+ 'bloquea.',
|
|
155
|
+
},
|
|
129
156
|
{
|
|
130
157
|
name: 'planning-drift',
|
|
131
158
|
event: 'Stop / SessionEnd',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Leer una credencial por shell. `secrets-read` mira la herramienta de lectura del runner, y un `cat .env`
|
|
4
|
+
// no pasa por ella: en Claude lo tapaba una regla nativa `permissions.deny`, que además frenaba lo que la
|
|
5
|
+
// persona pedía, y en los otros runners no lo tapaba nada (caso 104). Esto lo mira donde pasan todos los
|
|
6
|
+
// comandos, con la misma salida que el resto: lo que la persona pidió en el chat pasa.
|
|
7
|
+
//
|
|
8
|
+
// Como todo lo que lee el texto de un comando, frena la forma habitual y no un script decidido: un nombre
|
|
9
|
+
// armado en una variable o un `grep -r` sobre la carpeta sin nombrar el archivo pasan.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { commandOf, cwdOf, block, isCommit, unquoted, opsRoot } = require('./input')
|
|
13
|
+
const { credential, patternNames } = require('./files')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
|
|
16
|
+
// Lo que muestra el contenido de un archivo. Queda afuera lo que sólo lo nombra —`ls`, `test -f`, `rm`,
|
|
17
|
+
// `git add`—, que no deja nada en la sesión, y `cp` y `mv`, cuyo último argumento es un destino: `cp
|
|
18
|
+
// .env.example .env` es preparar el entorno, no leerlo. `nl` entró porque un agente lo usó en una sesión
|
|
19
|
+
// real para leer el `.env` después de que la lista no lo tuviera.
|
|
20
|
+
const READERS = new Set(['cat', 'tac', 'nl', 'head', 'tail', 'less', 'more', 'bat', 'sed', 'awk', 'grep', 'egrep',
|
|
21
|
+
'fgrep', 'rg', 'strings', 'xxd', 'od', 'hexdump', 'base64', 'cut', 'sort', 'uniq', 'rev', 'paste', 'fold', 'pr',
|
|
22
|
+
'dd', 'jq', 'yq', 'diff', 'cmp', 'source', '.', 'node', 'python', 'python3', 'ruby', 'perl', 'php', 'deno', 'bun'])
|
|
23
|
+
const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nohup', 'nice', 'xargs'])
|
|
24
|
+
|
|
25
|
+
// El verbo de un tramo, saltando lo que va delante sin serlo: asignaciones, prefijos y sus banderas.
|
|
26
|
+
function verbOf(words) {
|
|
27
|
+
let prefixed = false
|
|
28
|
+
while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || PREFIXES.has(words[0])
|
|
29
|
+
|| (prefixed && words[0].startsWith('-')))) {
|
|
30
|
+
prefixed = prefixed || PREFIXES.has(words[0])
|
|
31
|
+
words.shift()
|
|
32
|
+
}
|
|
33
|
+
return words[0] || ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
|
|
37
|
+
// entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
|
|
38
|
+
function readTokens(command) {
|
|
39
|
+
const found = []
|
|
40
|
+
for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
|
|
41
|
+
const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
|
|
42
|
+
const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
|
|
43
|
+
if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(found)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function secretsShell(input) {
|
|
49
|
+
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
50
|
+
const raw = commandOf(input)
|
|
51
|
+
// El mensaje de un commit es dato, como en `destructive`: nombrar el archivo ahí no lo lee.
|
|
52
|
+
const command = isCommit(raw) ? unquoted(raw) : raw
|
|
53
|
+
const cwd = cwdOf(input)
|
|
54
|
+
const files = readTokens(command).filter((token) => patternNames(token).some((name) => credential(input, name)))
|
|
55
|
+
.map((token) => path.resolve(cwd, token))
|
|
56
|
+
const left = AP.pending(opsRoot(input), [...new Set(files)], input)
|
|
57
|
+
if (!left.length) return
|
|
58
|
+
block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
|
|
59
|
+
+ `Si hace falta un valor, pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { secretsShell }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El canal por el que una persona dice «sí» no lo puede escribir el agente: si pudiera, cada bloqueo que
|
|
4
|
+
// ofrece aprobarse se aprobaría solo. Pasaba —ningún guard miraba `planning/.ops-approval`— y en la prueba
|
|
5
|
+
// en vivo del 092 el agente, frenado, intentó escribírsela (caso 098).
|
|
6
|
+
//
|
|
7
|
+
// Lo preguntan los dos guards de límites —el que mira un `Write` y el que mira el destino de un comando—,
|
|
8
|
+
// que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
|
|
9
|
+
// hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
|
|
10
|
+
// herramienta.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
const { opsRoot } = require('./input')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
const CHAT = require('./chat')
|
|
16
|
+
|
|
17
|
+
// Por qué el agente no puede escribir en `file`, o vacío.
|
|
18
|
+
function selfApproval(input, file) {
|
|
19
|
+
if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) {
|
|
20
|
+
return `${file} es el registro de lo que la persona dijo en el chat: lo escribe el runner, nunca una `
|
|
21
|
+
+ 'herramienta.'
|
|
22
|
+
}
|
|
23
|
+
const root = opsRoot(input)
|
|
24
|
+
if (!root || file !== path.join(root, 'planning', AP.APPROVAL)) return ''
|
|
25
|
+
if (!CHAT.unauthorized(input, [file]).length) return ''
|
|
26
|
+
return `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la persona quiere `
|
|
27
|
+
+ 'autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o que edite el '
|
|
28
|
+
+ 'archivo ella.'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { selfApproval }
|