@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
package/engine/hooks/shell.js
CHANGED
|
@@ -10,9 +10,10 @@ const path = require('node:path')
|
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
11
|
const {
|
|
12
12
|
commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
|
|
13
|
-
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
13
|
+
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const { selfApproval } = require('./self-approval')
|
|
16
17
|
const EV = require('../core/evidence')
|
|
17
18
|
|
|
18
19
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
@@ -48,12 +49,6 @@ const MISMO = String.raw`[^;&|\n]`
|
|
|
48
49
|
// `bash -c "git push origin main"` y `eval "git reset --hard"` siguen cayendo, comprobado. Queda afuera
|
|
49
50
|
// la sustitución dentro del propio mensaje —`git commit -m "$(...)"` corre y ya no se ve—, que es
|
|
50
51
|
// evasión y no la forma habitual.
|
|
51
|
-
// La raíz donde vive `planning/`, que es donde se busca la aprobación. Los cuatro guards que la
|
|
52
|
-
// consultan la resuelven igual, así que se resuelve una vez.
|
|
53
|
-
function opsRoot(input) {
|
|
54
|
-
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
52
|
function destructive(input) {
|
|
58
53
|
const raw = commandOf(input)
|
|
59
54
|
// Las opciones globales de `git` se sacan acá y no en cada regla: toda regla de abajo que mire un
|
|
@@ -181,7 +176,7 @@ function dependencies(input) {
|
|
|
181
176
|
// `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
|
|
182
177
|
// conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
|
|
183
178
|
const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
|
|
184
|
-
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
|
|
179
|
+
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
|
|
185
180
|
// Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
|
|
186
181
|
// disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
|
|
187
182
|
// en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
|
|
@@ -201,14 +196,15 @@ function dependencies(input) {
|
|
|
201
196
|
if (onDisk.length > 1) {
|
|
202
197
|
block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
|
|
203
198
|
}
|
|
204
|
-
|
|
205
|
-
|
|
199
|
+
const manifests = sinAprobar(parent, state.manifests)
|
|
200
|
+
if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
|
|
206
201
|
block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
|
|
207
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
|
|
202
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
|
|
208
203
|
}
|
|
209
|
-
|
|
204
|
+
const lockfiles = sinAprobar(parent, state.locks)
|
|
205
|
+
if (state.locks.length && !state.manifests.length && lockfiles.length) {
|
|
210
206
|
block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
|
|
211
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
|
|
207
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles, input))
|
|
212
208
|
}
|
|
213
209
|
}
|
|
214
210
|
}
|
|
@@ -305,17 +301,21 @@ function writesWithBase(command, cwd) {
|
|
|
305
301
|
|
|
306
302
|
function shellBoundary(input) {
|
|
307
303
|
const allowed = writableRoots(input)
|
|
308
|
-
if (!allowed) return
|
|
309
304
|
for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
|
|
310
305
|
// Una ruta absoluta no depende del `cd`, así que un destino que no se sabe no la vuelve injuzgable.
|
|
311
306
|
// Al revés sí: sin saber desde dónde se resuelve, una relativa no se puede verificar, y un guard que
|
|
312
307
|
// no puede verificar no autoriza —el criterio que fijó el 031 para el índice—.
|
|
313
308
|
if (!path.isAbsolute(raw) && base === null) {
|
|
309
|
+
if (!allowed) continue
|
|
314
310
|
block(`el comando hace \`cd\` a un destino que no se puede resolver acá, así que no hay contra qué `
|
|
315
311
|
+ `resolver ${raw}. Escribí la ruta absoluta, o hacé el \`cd\` en un comando aparte.`)
|
|
316
312
|
}
|
|
317
|
-
const file = path.resolve(base, raw)
|
|
318
|
-
|
|
313
|
+
const file = path.resolve(base || '/', raw)
|
|
314
|
+
// El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
|
|
315
|
+
// declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
|
|
316
|
+
const own = selfApproval(input, file)
|
|
317
|
+
if (own) block(own)
|
|
318
|
+
if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
|
|
319
319
|
if (outsideRoots(file, allowed)) {
|
|
320
320
|
block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
321
321
|
}
|
|
@@ -346,10 +346,9 @@ function governance(input) {
|
|
|
346
346
|
// La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
|
|
347
347
|
// reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
|
|
348
348
|
// entre una llave por operación y una puerta que quedó abierta.
|
|
349
|
-
const pendientes = AP.pending(opsRoot(input), governed)
|
|
349
|
+
const pendientes = AP.pending(opsRoot(input), governed, input)
|
|
350
350
|
if (!pendientes.length) return
|
|
351
|
-
|
|
352
|
-
block(`El commit toca gobernanza protegida:\n${files}\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE')}`)
|
|
351
|
+
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
|
|
353
352
|
}
|
|
354
353
|
|
|
355
354
|
function run(program, args, cwd, extra = {}) {
|
|
@@ -404,6 +403,7 @@ function commitTree(dir) {
|
|
|
404
403
|
fs.rmSync(temp, { recursive: true, force: true })
|
|
405
404
|
block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
|
|
406
405
|
}
|
|
406
|
+
const linked = []
|
|
407
407
|
for (const line of lines) {
|
|
408
408
|
if (!line.startsWith('!! ')) continue
|
|
409
409
|
const name = line.slice(3).trim().replace(/\/$/, '')
|
|
@@ -424,6 +424,7 @@ function commitTree(dir) {
|
|
|
424
424
|
if (fs.existsSync(link)) continue
|
|
425
425
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
|
426
426
|
fs.symlinkSync(path.join(dir, name), link, 'junction')
|
|
427
|
+
linked.push(name)
|
|
427
428
|
}
|
|
428
429
|
// Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
|
|
429
430
|
// por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
|
|
@@ -441,7 +442,15 @@ function commitTree(dir) {
|
|
|
441
442
|
// pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
|
|
442
443
|
// quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
|
|
443
444
|
const started = run('git', ['init', '--quiet'], temp)
|
|
444
|
-
|
|
445
|
+
// Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
|
|
446
|
+
// patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
|
|
447
|
+
// y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
|
|
448
|
+
if (started.ok) {
|
|
449
|
+
fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
|
|
450
|
+
fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
|
|
451
|
+
linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
|
|
452
|
+
run('git', ['add', '--all'], temp)
|
|
453
|
+
}
|
|
445
454
|
// Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
|
|
446
455
|
// y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
|
|
447
456
|
// sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
|
|
@@ -478,19 +487,20 @@ function verify(input) {
|
|
|
478
487
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
479
488
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
480
489
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
481
|
-
const
|
|
490
|
+
const sinAprobar = AP.pending(opsRoot(input), staged, input)
|
|
491
|
+
const aprobado = !sinAprobar.length
|
|
482
492
|
if (changedOpenApi && !hasApiGenerated && !aprobado) {
|
|
483
493
|
block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
|
|
484
|
-
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY')}`)
|
|
494
|
+
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
|
|
485
495
|
}
|
|
486
496
|
if (changedSqlSource && !hasSqlGenerated && !aprobado) {
|
|
487
497
|
block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
|
|
488
|
-
+ AP.HOW('OPS_SKIP_VERIFY'))
|
|
498
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
489
499
|
}
|
|
490
500
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
491
501
|
const { root, temp, env } = commitTree(dir)
|
|
492
502
|
try {
|
|
493
|
-
verifyGates(root, dir,
|
|
503
|
+
verifyGates(root, dir, sinAprobar, env, input)
|
|
494
504
|
} finally {
|
|
495
505
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
496
506
|
}
|
|
@@ -511,6 +521,15 @@ function verify(input) {
|
|
|
511
521
|
// Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
|
|
512
522
|
// que hace falta para diagnosticar es la primera línea de error, no el volcado.
|
|
513
523
|
const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
|
|
524
|
+
// Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
|
|
525
|
+
// 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
|
|
526
|
+
// quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
|
|
527
|
+
// `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
|
|
528
|
+
// `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
|
|
529
|
+
// pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
|
|
530
|
+
// no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
|
|
531
|
+
const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
|
|
532
|
+
const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
|
|
514
533
|
const MAX_LINE = 160
|
|
515
534
|
function fallo(gate, result) {
|
|
516
535
|
// La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
|
|
@@ -519,7 +538,8 @@ function fallo(gate, result) {
|
|
|
519
538
|
// palabra de error gana siempre.
|
|
520
539
|
const lines = (result.output || '').split('\n').map((one) => one.trim())
|
|
521
540
|
.filter((one) => one && !one.startsWith('>'))
|
|
522
|
-
const line = lines.find((one) =>
|
|
541
|
+
const line = lines.find((one) => FAILED_TEST.test(one))
|
|
542
|
+
|| lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
|
|
523
543
|
return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
|
|
524
544
|
}
|
|
525
545
|
|
|
@@ -538,7 +558,8 @@ function comoSeLee(failures) {
|
|
|
538
558
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
539
559
|
}
|
|
540
560
|
|
|
541
|
-
function verifyGates(root, dir,
|
|
561
|
+
function verifyGates(root, dir, sinAprobar, env, input) {
|
|
562
|
+
const ops = opsRoot(input)
|
|
542
563
|
const failures = []
|
|
543
564
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
544
565
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
@@ -572,13 +593,13 @@ function verifyGates(root, dir, aprobado, env, ops) {
|
|
|
572
593
|
if (!result.ok) failures.push(fallo('make test', result))
|
|
573
594
|
}
|
|
574
595
|
}
|
|
575
|
-
if (!failures.length ||
|
|
596
|
+
if (!failures.length || !sinAprobar.length) return
|
|
576
597
|
// Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
|
|
577
598
|
// comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
|
|
578
599
|
const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
579
600
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
580
601
|
block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
|
|
581
|
-
+ AP.HOW('OPS_SKIP_VERIFY'))
|
|
602
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
582
603
|
}
|
|
583
604
|
|
|
584
605
|
module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
|
|
@@ -161,4 +161,4 @@ async function fetchItems(config, options = {}) {
|
|
|
161
161
|
return issues.map((issue) => normalizeIssue(issue, config))
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
module.exports = { fetchItems, normalizeFixture, validateConfig }
|
|
164
|
+
module.exports = { contract: 1, fetchItems, normalizeFixture, validateConfig }
|
|
@@ -37,9 +37,31 @@ function providerConfig(root, name) {
|
|
|
37
37
|
return { registry, entry, config: readJson(configFile), configFile }
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Los adaptadores que trae Cauce. Uno de la empresa no va acá: se declara con una ruta en el registro de
|
|
41
|
+
// la instancia y vive en la carpeta de su proveedor (caso 091).
|
|
42
|
+
const BUILTIN = { jira: () => require('./providers/jira') }
|
|
43
|
+
// La versión del contrato que el motor sabe llamar. A un adaptador de la empresa no lo toca `upgrade`, así
|
|
44
|
+
// que sin esto un cambio de interfaz lo rompería en silencio.
|
|
45
|
+
const CONTRACT = 1
|
|
46
|
+
const CONTRACT_FUNCTIONS = ['validateConfig', 'fetchItems', 'normalizeFixture']
|
|
47
|
+
|
|
48
|
+
function adapter(root, name, entry = {}) {
|
|
49
|
+
const declared = String(entry.adapter || '')
|
|
50
|
+
let impl
|
|
51
|
+
if (Object.hasOwn(BUILTIN, declared)) impl = BUILTIN[declared]()
|
|
52
|
+
else if (declared.startsWith('./')) {
|
|
53
|
+
const base = path.join(root, 'integrations', name)
|
|
54
|
+
impl = require(F.assertWithin(base, path.resolve(base, declared), `${name}: adapter`))
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(`No existe adaptador para ${declared || name}: usá uno de Cauce `
|
|
57
|
+
+ `(${Object.keys(BUILTIN).join(', ')}) o una ruta ./ dentro de integrations/${name}/`)
|
|
58
|
+
}
|
|
59
|
+
if (impl.contract !== CONTRACT) {
|
|
60
|
+
throw new Error(`el motor sabe llamar contract ${CONTRACT} y el adaptador declara ${impl.contract}`)
|
|
61
|
+
}
|
|
62
|
+
const missing = CONTRACT_FUNCTIONS.filter((fn) => typeof impl[fn] !== 'function')
|
|
63
|
+
if (missing.length) throw new Error(`al adaptador le falta ${missing.join(', ')}`)
|
|
64
|
+
return impl
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
function sensitivePath(value, trail = '') {
|
|
@@ -91,7 +113,7 @@ function validate(root, onlyProvider = '') {
|
|
|
91
113
|
const secret = sensitivePath(loaded.config)
|
|
92
114
|
if (secret) errors.push(`${name}: ${secret} no puede contener secretos; usa una variable de entorno`)
|
|
93
115
|
try {
|
|
94
|
-
adapter(name).validateConfig(loaded.config, errors)
|
|
116
|
+
adapter(root, name, loaded.entry).validateConfig(loaded.config, errors)
|
|
95
117
|
} catch (error) {
|
|
96
118
|
errors.push(`${name}: ${error.message}`)
|
|
97
119
|
}
|
|
@@ -185,7 +207,7 @@ async function sync(root, name, options = {}) {
|
|
|
185
207
|
// Son dos interruptores y se exigen los dos: el del registro dice que el proveedor está conectado
|
|
186
208
|
// al proyecto, y el suyo que hay a dónde apuntar.
|
|
187
209
|
if (!entry.enabled || !config.enabled) throw new Error(`${name} está deshabilitado`)
|
|
188
|
-
const provider = adapter(name)
|
|
210
|
+
const provider = adapter(root, name, entry)
|
|
189
211
|
const items = options.fixture
|
|
190
212
|
? provider.normalizeFixture(readJson(path.resolve(options.fixture)), config)
|
|
191
213
|
: await provider.fetchItems(config)
|
|
@@ -420,6 +442,7 @@ module.exports = {
|
|
|
420
442
|
providerConfig,
|
|
421
443
|
reconcile,
|
|
422
444
|
safeSegment,
|
|
445
|
+
sensitivePath,
|
|
423
446
|
sync,
|
|
424
447
|
validate,
|
|
425
448
|
writebackPlan,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El contrato de secretos que una empresa comparte entre sus repositorios, y el chequeo sin red que lo
|
|
4
|
+
// hace cumplir (caso 088). La base no conoce ningún gestor: lee `organization/secrets.json`, comprueba
|
|
5
|
+
// que sus referencias cierren, que ninguna credencial viva dentro de un repositorio y que cada copia de
|
|
6
|
+
// un archivo compartido coincida con la canónica que guarda la instancia. Lo que habla con el gestor y
|
|
7
|
+
// lo que se copia es de la empresa; esto sólo dice qué quedó atrás y cómo ponerlo al día.
|
|
8
|
+
//
|
|
9
|
+
// Compara los servicios de **una** instancia. Una empresa con varios proyectos los declara como raíces
|
|
10
|
+
// de la misma, y eso es lo que hace que un esqueleto y sus derivados se midan contra la misma copia.
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs')
|
|
13
|
+
const path = require('node:path')
|
|
14
|
+
const crypto = require('node:crypto')
|
|
15
|
+
const { spawnSync } = require('node:child_process')
|
|
16
|
+
const F = require('../core/files')
|
|
17
|
+
const { resolvePath } = require('../config/paths')
|
|
18
|
+
const { sensitivePath } = require('../integrations/registry')
|
|
19
|
+
|
|
20
|
+
const DECLARATION = path.join('organization', 'secrets.json')
|
|
21
|
+
const TOP_LEVEL = ['schemaVersion', 'accounts', 'projects', 'identities', 'shared', 'services']
|
|
22
|
+
const SOURCES = ['file', 'ci-secret']
|
|
23
|
+
|
|
24
|
+
function inside(base, target) {
|
|
25
|
+
try { F.assertWithin(base, target); return true } catch { return false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
29
|
+
|
|
30
|
+
// Una credencial dentro de un repositorio es la que se commitea por error, y el repositorio puede ser
|
|
31
|
+
// uno que la instancia no declara: sólo git sabe si el directorio está en un árbol de trabajo. Sin
|
|
32
|
+
// `GIT_DIR` heredado, que respondería por otro repositorio (caso 045).
|
|
33
|
+
function inRepository(file) {
|
|
34
|
+
const dir = path.dirname(file)
|
|
35
|
+
if (!fs.existsSync(dir)) return false
|
|
36
|
+
const env = { ...process.env }
|
|
37
|
+
delete env.GIT_DIR
|
|
38
|
+
delete env.GIT_WORK_TREE
|
|
39
|
+
const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', env })
|
|
40
|
+
return result.status === 0 && result.stdout.trim() === 'true'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJson(file) {
|
|
44
|
+
try { return { value: JSON.parse(fs.readFileSync(file, 'utf8')) } } catch (error) { return { error } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
48
|
+
|
|
49
|
+
// Una sección que falta es una sección vacía; una que no es objeto es un error y se lee como vacía para
|
|
50
|
+
// que el resto del chequeo siga diciendo lo que encuentre.
|
|
51
|
+
function section(declaration, name, errors) {
|
|
52
|
+
const value = declaration[name]
|
|
53
|
+
if (value === undefined) return {}
|
|
54
|
+
if (isObject(value) && Object.values(value).every(isObject)) return value
|
|
55
|
+
errors.push(`${DECLARATION}: ${name} debe ser un objeto de entradas`)
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function reference(errors, where, field, value, declared, sectionName) {
|
|
60
|
+
if (value === undefined) return errors.push(`${where}: falta ${field}`)
|
|
61
|
+
if (!Object.hasOwn(declared, value)) {
|
|
62
|
+
errors.push(`${where}: ${field} «${value}» no está declarado en ${sectionName}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkIdentities(context) {
|
|
67
|
+
const { root, identities, accounts, workspaces, errors, warnings } = context
|
|
68
|
+
for (const [name, identity] of Object.entries(identities)) {
|
|
69
|
+
const where = `identities.${name}`
|
|
70
|
+
reference(errors, where, 'account', identity.account, accounts, 'accounts')
|
|
71
|
+
if (!SOURCES.includes(identity.source)) {
|
|
72
|
+
errors.push(`${where}: source debe ser ${SOURCES.join(' o ')}`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (identity.source === 'ci-secret') {
|
|
76
|
+
if (identity.file !== undefined) errors.push(`${where}: una identidad ci-secret vive en el CI y no lleva file`)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (typeof identity.file !== 'string' || !identity.file.trim()) {
|
|
80
|
+
errors.push(`${where}: una identidad file necesita la ruta de su archivo`)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const file = resolvePath(root, identity.file)
|
|
84
|
+
const base = [root, ...workspaces.map((workspace) => workspace.dir)].find((dir) => inside(dir, file))
|
|
85
|
+
if (base) errors.push(`${where}: ${file} está dentro de ${base}; una credencial vive fuera de todo repositorio`)
|
|
86
|
+
else if (inRepository(file)) {
|
|
87
|
+
errors.push(`${where}: ${file} está dentro de un repositorio de git; una credencial vive fuera de todos`)
|
|
88
|
+
}
|
|
89
|
+
if (!fs.existsSync(file)) {
|
|
90
|
+
warnings.push(`${where}: ${file} no está en esta máquina; la carga una persona, el chequeo no la lee`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Las copias de cada servicio contra la canónica. Devuelve cuántos servicios quedaron al día.
|
|
96
|
+
function checkServices(context) {
|
|
97
|
+
const { root, declaration, services, projects, identities, shared, workspaces, errors } = context
|
|
98
|
+
let current = 0
|
|
99
|
+
for (const [name, service] of Object.entries(services)) {
|
|
100
|
+
const where = `services.${name}`
|
|
101
|
+
const before = errors.length
|
|
102
|
+
reference(errors, where, 'project', service.project, projects, 'projects')
|
|
103
|
+
if (service.identity !== undefined) {
|
|
104
|
+
reference(errors, where, 'identity', service.identity, identities, 'identities')
|
|
105
|
+
}
|
|
106
|
+
const workspace = workspaces.find((entry) => entry.name === service.root)
|
|
107
|
+
if (!workspace) {
|
|
108
|
+
errors.push(`${where}: root «${service.root}» no es una raíz de ops.config.json`)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!fs.existsSync(workspace.dir)) {
|
|
112
|
+
errors.push(`${where}: no existe ${workspace.dir}`)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
const schema = service.schema || '.env.schema'
|
|
116
|
+
if (!fs.existsSync(path.resolve(workspace.dir, schema))) {
|
|
117
|
+
errors.push(`${where}: falta ${schema} en ${workspace.dir}`)
|
|
118
|
+
}
|
|
119
|
+
for (const [target, key] of Object.entries(isObject(service.files) ? service.files : {})) {
|
|
120
|
+
if (!Object.hasOwn(shared, key)) {
|
|
121
|
+
errors.push(`${where}: files.${target} apunta a «${key}», que no está declarado en shared`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const copy = path.resolve(workspace.dir, target)
|
|
125
|
+
const canonical = path.resolve(root, declaration.shared[key])
|
|
126
|
+
if (!inside(workspace.dir, copy)) errors.push(`${where}: ${target} está fuera de su raíz`)
|
|
127
|
+
else if (!fs.existsSync(canonical)) continue
|
|
128
|
+
else if (!fs.existsSync(copy)) errors.push(`${where}: falta ${target}; copiala de ${declaration.shared[key]}`)
|
|
129
|
+
else if (digest(copy) !== digest(canonical)) {
|
|
130
|
+
errors.push(`${where}: ${target} no coincide con ${declaration.shared[key]}; para ponerla al día: `
|
|
131
|
+
+ `cp ${canonical} ${copy}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (errors.length === before) current += 1
|
|
135
|
+
}
|
|
136
|
+
return current
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkShared(root, shared, errors) {
|
|
140
|
+
for (const [key, value] of Object.entries(shared)) {
|
|
141
|
+
const file = path.resolve(root, String(value))
|
|
142
|
+
if (!inside(root, file)) errors.push(`shared.${key}: ${value} está fuera de la instancia`)
|
|
143
|
+
else if (!fs.existsSync(file)) errors.push(`shared.${key}: no existe ${value}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// El chequeo entero. Sin declaración no hay nada que comprobar, y no es un error: la mayoría de las
|
|
148
|
+
// instancias no la usa.
|
|
149
|
+
function check(root) {
|
|
150
|
+
const file = path.join(root, DECLARATION)
|
|
151
|
+
if (!fs.existsSync(file)) return { declared: false, errors: [], warnings: [], current: 0 }
|
|
152
|
+
const errors = []
|
|
153
|
+
const warnings = []
|
|
154
|
+
const done = () => ({ declared: true, errors, warnings, current })
|
|
155
|
+
let current = 0
|
|
156
|
+
const read = readJson(file)
|
|
157
|
+
if (read.error) errors.push(`${DECLARATION}: JSON inválido (${read.error.message})`)
|
|
158
|
+
else if (!isObject(read.value)) errors.push(`${DECLARATION}: debe ser un objeto`)
|
|
159
|
+
if (errors.length) return done()
|
|
160
|
+
const declaration = read.value
|
|
161
|
+
for (const key of Object.keys(declaration)) {
|
|
162
|
+
if (!TOP_LEVEL.includes(key)) errors.push(`${DECLARATION}: propiedad desconocida ${key}`)
|
|
163
|
+
}
|
|
164
|
+
if (declaration.schemaVersion !== 1) errors.push(`${DECLARATION}: schemaVersion debe ser 1`)
|
|
165
|
+
const secret = sensitivePath(declaration)
|
|
166
|
+
if (secret) errors.push(`${DECLARATION}: ${secret} tiene forma de secreto; acá van referencias, nunca valores`)
|
|
167
|
+
const config = readJson(path.join(root, 'ops.config.json'))
|
|
168
|
+
const roots = config.error || !Array.isArray(config.value.workspaceRoots) ? [] : config.value.workspaceRoots
|
|
169
|
+
if (config.error) errors.push(`ops.config.json: no se puede leer (${config.error.message})`)
|
|
170
|
+
const workspaces = roots.filter(isObject).map((entry) => ({ name: entry.name, dir: path.resolve(root, entry.path) }))
|
|
171
|
+
const accounts = section(declaration, 'accounts', errors)
|
|
172
|
+
const projects = section(declaration, 'projects', errors)
|
|
173
|
+
const identities = section(declaration, 'identities', errors)
|
|
174
|
+
const services = section(declaration, 'services', errors)
|
|
175
|
+
const shared = isObject(declaration.shared) ? declaration.shared : {}
|
|
176
|
+
if (declaration.shared !== undefined && !isObject(declaration.shared)) {
|
|
177
|
+
errors.push(`${DECLARATION}: shared debe ser un objeto de rutas`)
|
|
178
|
+
}
|
|
179
|
+
for (const [name, project] of Object.entries(projects)) {
|
|
180
|
+
reference(errors, `projects.${name}`, 'account', project.account, accounts, 'accounts')
|
|
181
|
+
}
|
|
182
|
+
const context = { root, declaration, accounts, projects, identities, services, shared, workspaces, errors, warnings }
|
|
183
|
+
checkIdentities(context)
|
|
184
|
+
checkShared(root, shared, errors)
|
|
185
|
+
current = checkServices(context)
|
|
186
|
+
return done()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Las rutas de las identidades que la declaración pone en disco, resueltas como las resuelve el chequeo.
|
|
190
|
+
// Las lee el guard de secretos (caso 092); una declaración ausente o ilegible no aporta ninguna, porque
|
|
191
|
+
// decir qué está mal es trabajo del chequeo.
|
|
192
|
+
function identityFiles(root) {
|
|
193
|
+
const read = readJson(path.join(root, DECLARATION))
|
|
194
|
+
if (read.error || !isObject(read.value) || !isObject(read.value.identities)) return []
|
|
195
|
+
return Object.values(read.value.identities)
|
|
196
|
+
.filter((identity) => isObject(identity) && identity.source === 'file')
|
|
197
|
+
.filter((identity) => typeof identity.file === 'string' && identity.file.trim())
|
|
198
|
+
.map((identity) => resolvePath(root, identity.file))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { DECLARATION, check, identityFiles }
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -103,9 +103,19 @@ Un guard existente **no se edita**: `upgrade` detecta el cambio y se detiene ant
|
|
|
103
103
|
Algunos bloqueos tienen salida, y conviene saber cuál antes de necesitarla — el momento en que un guard
|
|
104
104
|
te frena es el peor para elegir bien.
|
|
105
105
|
|
|
106
|
-
**
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
**Si lo pediste vos en el chat, no hace falta nada.** Los guards contienen al agente cuando decide solo o
|
|
107
|
+
cuando trabaja dentro de un recorrido; lo que vos pedís directo no se frena. Nombrá lo que querés que
|
|
108
|
+
toque —«borrá la prueba de altas», «reescribí la migración 004»— y pasa sin preguntarte de nuevo. Si tu
|
|
109
|
+
pedido no lo nombraba y algo se frena, el agente te dice qué y por qué: contestá «dale» y pasa exactamente
|
|
110
|
+
eso. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
|
|
111
|
+
por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
|
|
112
|
+
Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
|
|
113
|
+
|
|
114
|
+
**Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
|
|
115
|
+
autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
|
|
116
|
+
instancia y no una carpeta al lado de tus proyectos; el bloqueo dice la ruta exacta. Es un solo archivo
|
|
117
|
+
para todos los guards, porque lo que escribís son rutas y quién las mira lo decide qué guard esté
|
|
118
|
+
juzgando esa ruta. Lo escribís vos: si el agente intenta escribírselo, un guard lo frena.
|
|
109
119
|
|
|
110
120
|
| lo que te frena | qué ruta aprobás |
|
|
111
121
|
|---|---|
|
|
@@ -151,6 +161,7 @@ y eso lo deja apagado **hasta que cierres la sesión**, no para un comando.
|
|
|
151
161
|
| `OPS_TEST_EVIDENCE_OVERRIDE=1` | el de evidencia de pruebas |
|
|
152
162
|
| `OPS_DEPENDENCIES_OVERRIDE=1` | el de dependencias, incluido publicar e instalar global |
|
|
153
163
|
| `OPS_PLAN_FIRST_OVERRIDE=1` | el que exige plan antes de cambiar el producto |
|
|
164
|
+
| `OPS_SECRETS_READ_OVERRIDE=1` | el que frena leer una credencial con la herramienta del runner |
|
|
154
165
|
| `OPS_SKIP_VERIFY=1` | el que corre los gates |
|
|
155
166
|
|
|
156
167
|
Por eso la aprobación es la vía recomendada y esto es lo que queda cuando no alcanza: prendela para lo
|
|
@@ -22,3 +22,24 @@ node tools/ops.js integration promote . jira KEY-123
|
|
|
22
22
|
El motor compara base reconciliada, remoto actual y curación local. Usa `reset` para adoptar el remoto,
|
|
23
23
|
`reconcile` para conservar la edición local sobre la nueva base y `rebase` para reparar hashes mecánicos.
|
|
24
24
|
Ninguno de esos comandos escribe en el proveedor.
|
|
25
|
+
|
|
26
|
+
## Un proveedor propio
|
|
27
|
+
|
|
28
|
+
Cauce trae Jira. Para conectar otra herramienta, el adaptador se escribe acá, en la instancia, sin tocar
|
|
29
|
+
Cauce:
|
|
30
|
+
|
|
31
|
+
1. Crear `integrations/<nombre>/` con su `config.json` y el adaptador, por ejemplo `adapter.js`.
|
|
32
|
+
2. Registrarlo en `config.json` con la ruta, relativa a `integrations/<nombre>/`:
|
|
33
|
+
`"adapter": "./adapter.js"`. Un nombre sin `./` —`"jira"`— es un adaptador de Cauce.
|
|
34
|
+
3. `node tools/ops.js integration enable . <nombre>` lo conecta y `integration check .` lo valida.
|
|
35
|
+
|
|
36
|
+
El adaptador exporta `contract: 1` y tres funciones:
|
|
37
|
+
|
|
38
|
+
- `validateConfig(config, errors)` valida sin conectarse, y empuja a `errors` lo que impide correr.
|
|
39
|
+
- `fetchItems(config, options)` hace la lectura paginada completa del proveedor.
|
|
40
|
+
- `normalizeFixture(payload, config)` usa el mismo normalizador sin red, para pruebas e importaciones.
|
|
41
|
+
|
|
42
|
+
`check` rechaza un adaptador con otra versión o sin alguna de las funciones, y una ruta que salga de
|
|
43
|
+
`integrations/<nombre>/`. Puede ser CommonJS o ESM. El motor lo ejecuta con los mismos permisos que el
|
|
44
|
+
CLI: la contención es de ruta, no de capacidad. Staging, revisión, promoción y validación son los mismos
|
|
45
|
+
que para Jira; el adaptador sólo traduce la API del proveedor.
|
|
@@ -18,4 +18,56 @@ Recomendados, sin molde: escribilos con la forma que le sirva a este proyecto.
|
|
|
18
18
|
La lista no es cerrada. Todo hecho de negocio o producto que cambie lentamente vive acá —una guía de
|
|
19
19
|
marca, un manual de operación, un pipeline de contenido— aunque no tenga una línea propia arriba.
|
|
20
20
|
|
|
21
|
+
Opcional, con forma fija: `secrets.json`, el contrato de secretos que la empresa comparte entre sus
|
|
22
|
+
repositorios. Ver «Secretos compartidos» abajo.
|
|
23
|
+
|
|
21
24
|
Principio: cada hecho tiene un dueño. Enlaza en vez de copiar información que ya vive en otro lugar.
|
|
25
|
+
|
|
26
|
+
## Secretos compartidos
|
|
27
|
+
|
|
28
|
+
Cuando varios servicios usan el mismo gestor de secretos —Infisical, Vault, Doppler—, terminan con los
|
|
29
|
+
mismos scripts y workflows copiados en cada repositorio, y la copia que se quedó atrás no avisa.
|
|
30
|
+
`secrets.json` declara ese contrato una vez y `node tools/ops.js secrets check .` lo compara contra cada
|
|
31
|
+
servicio **sin conectarse a nada**. Cauce no conoce ningún gestor: lo que habla con él es de la empresa.
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"schemaVersion": 1,
|
|
36
|
+
"accounts": { "principal": { "url": "https://app.infisical.com" } },
|
|
37
|
+
"projects": { "tienda": { "account": "principal" } },
|
|
38
|
+
"identities": {
|
|
39
|
+
"local-dev": { "account": "principal", "source": "file", "file": "~/.config/acme/local-dev.env" },
|
|
40
|
+
"ci": { "account": "principal", "source": "ci-secret" }
|
|
41
|
+
},
|
|
42
|
+
"shared": { "check-schema": "organization/secrets/check-schema.py" },
|
|
43
|
+
"services": {
|
|
44
|
+
"api": { "root": "api", "project": "tienda", "identity": "local-dev",
|
|
45
|
+
"files": { "scripts/check-schema.py": "check-schema" } }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **Nunca guarda un valor.** Una clave con forma de secreto —`token`, `password`, `secret`— es un error.
|
|
51
|
+
Las entradas pueden llevar otros campos que lean los scripts de la empresa (un id de proyecto, sus
|
|
52
|
+
ambientes); el chequeo no los mira.
|
|
53
|
+
- **Una identidad por nivel de acceso, no por repositorio.** Compartir credenciales es apuntar al mismo
|
|
54
|
+
alias, y rotar es cambiar un archivo. `source: file` es un archivo **fuera de todo repositorio**, que
|
|
55
|
+
carga una persona; `source: ci-secret` vive en el CI y no lleva ruta.
|
|
56
|
+
- **`shared` guarda la copia canónica** de cada archivo compartido, dentro de esta instancia, y
|
|
57
|
+
`services.<nombre>.files` dice dónde va en el servicio. Cada `root` es una raíz de `ops.config.json`.
|
|
58
|
+
- **Varios proyectos, una instancia.** El chequeo compara los servicios de esta instancia; para que el
|
|
59
|
+
esqueleto de un proyecto y los servicios de otro se midan contra lo mismo, los dos van como raíces acá.
|
|
60
|
+
|
|
61
|
+
`secrets check` falla si una copia no coincide con la canónica —y dice el `cp` que la pone al día—, si
|
|
62
|
+
falta, si una referencia no cierra o si una credencial está dentro de un repositorio. Una identidad que
|
|
63
|
+
no está en esta máquina es una advertencia: en el CI no tiene por qué estar.
|
|
64
|
+
|
|
65
|
+
El recorrido:
|
|
66
|
+
|
|
67
|
+
- **Adoptar un servicio**: declararlo, copiar los archivos de `shared` y correr `secrets check`. Cargar
|
|
68
|
+
la identidad en su archivo y los secretos del CI lo hace una persona: el guard de secretos frena que
|
|
69
|
+
un agente escriba un `.env.*`, y es lo correcto.
|
|
70
|
+
- **Mantener**: se cambia la copia canónica, `secrets check` lista los servicios que quedaron atrás y
|
|
71
|
+
cada uno se actualiza con un commit en su repositorio.
|
|
72
|
+
- **Rotar**: se reemplaza el archivo de la identidad; los servicios que la usan no cambian.
|
|
73
|
+
- **Dar de baja**: se saca el servicio de `services` y se borran sus copias en su repositorio.
|
|
@@ -47,3 +47,4 @@ archivo lo mantiene Cauce, así que una fila agregada acá se perdería en el pr
|
|
|
47
47
|
- [OPS-004](system/OPS-004-promocion-humana-y-evidencia-verificable.md): promoción controlada y verificable.
|
|
48
48
|
- [OPS-005](system/OPS-005-catalogo-en-el-paquete.md): el catálogo viaja dentro del paquete.
|
|
49
49
|
- [OPS-006](system/OPS-006-ceremonia-por-superficie.md): la ceremonia escala con la superficie del cambio.
|
|
50
|
+
- [OPS-007](system/OPS-007-contrato-de-secretos-compartido.md): un contrato de secretos compartido, sin gestor.
|