@ingeniomaps/cauce 0.79.0 → 0.80.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 +83 -0
- package/README.md +6 -3
- package/automatization/hooks/README.md +18 -2
- package/automatization/hooks/guard-secrets-read.sh +3 -0
- package/automatization/runners/claude/README.md +3 -1
- package/automatization/runners/claude/settings.json +29 -0
- package/automatization/runners/gemini/README.md +2 -1
- package/automatization/runners/gemini/settings.json +9 -0
- 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 +8 -4
- package/engine/hooks/files.js +73 -26
- package/engine/hooks/run.js +7 -0
- package/engine/hooks/shell.js +34 -17
- 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 +1 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,89 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
|
|
|
14
14
|
unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
|
|
15
15
|
diseño — eso vive en el commit y en el código.
|
|
16
16
|
|
|
17
|
+
## [0.80.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Agregado
|
|
20
|
+
|
|
21
|
+
- **Leer una credencial también se frena, y las identidades declaradas cuentan como credencial.** Un guard
|
|
22
|
+
nuevo, `secrets-read`, corre en la herramienta de lectura de Claude (`Read`) y de Gemini (`read_file`) y
|
|
23
|
+
frena los mismos archivos que `secrets` frena al escribir. Esos archivos ahora incluyen las identidades
|
|
24
|
+
`source: file` de `organization/secrets.json`, que antes pasaban por no tener nombre de credencial. En
|
|
25
|
+
Claude, además, la instalación agrega reglas `permissions.deny` `Read(...)` que el propio Claude aplica
|
|
26
|
+
también a `cat`, `head`, `tail`, `sed` y redirecciones.
|
|
27
|
+
|
|
28
|
+
**Qué cambia para vos**: el agente deja de poder leer `.env`, claves y tokens conocidos; `.env.example`
|
|
29
|
+
sigue legible. Si una lectura hace falta, aprobá la ruta en `planning/.ops-approval`. Corré
|
|
30
|
+
`automation install` para que tu `.claude/settings.json` reciba las reglas: se suman a las tuyas. No es un
|
|
31
|
+
límite de seguridad —un `grep -r` o un script propio siguen leyendo—, y en Codex y Antigravity no hay guard
|
|
32
|
+
de lectura.
|
|
33
|
+
|
|
34
|
+
- **Un proveedor de integraciones propio, sin tocar Cauce.** El registro de `integrations/config.json`
|
|
35
|
+
ya tenía un campo `adapter` que nadie leía: ahora `"adapter": "./adapter.js"` carga el adaptador de la
|
|
36
|
+
empresa desde `integrations/<nombre>/`, e `integration enable` lo conecta sin exigir un molde de Cauce.
|
|
37
|
+
El adaptador declara `contract: 1` y las tres funciones del contrato, y `check` rechaza el que no
|
|
38
|
+
cumpla.
|
|
39
|
+
|
|
40
|
+
**Qué cambia para vos**: nada si sólo usás Jira. Para otra herramienta, `integrations/README.md` de tu
|
|
41
|
+
instancia trae el recorrido.
|
|
42
|
+
|
|
43
|
+
- **Un contrato de secretos compartido entre repositorios, y un chequeo sin red.** Si tus servicios
|
|
44
|
+
usan un gestor de secretos, los scripts y workflows que lo conectan terminan copiados en cada
|
|
45
|
+
repositorio, y el arreglo que alguien hizo en uno no llega a los demás. `organization/secrets.json`
|
|
46
|
+
declara qué cuenta, qué identidad y qué archivos comparte cada servicio, y
|
|
47
|
+
`node tools/ops.js secrets check .` compara cada copia contra la canónica que guarda la instancia:
|
|
48
|
+
dice cuál quedó atrás y el `cp` que la pone al día. También falla si una credencial vive dentro de un
|
|
49
|
+
repositorio o si la declaración guarda un valor en vez de una referencia.
|
|
50
|
+
|
|
51
|
+
**Qué cambia para vos**: nada si no lo usás. Para adoptarlo, `organization/README.md` trae el formato
|
|
52
|
+
y el recorrido. Cauce no se conecta a ningún gestor ni trae adaptadores: lo que habla con el gestor
|
|
53
|
+
sigue siendo tuyo. Si tus proyectos tienen instancias separadas, el chequeo sólo compara dentro de
|
|
54
|
+
cada una: para que un esqueleto y sus derivados se midan contra lo mismo, van como raíces de la misma
|
|
55
|
+
instancia.
|
|
56
|
+
|
|
57
|
+
### Corregido
|
|
58
|
+
|
|
59
|
+
- **`init` rechaza un runner mal escrito sin crear la instancia.** Con `--runner none` —el valor es
|
|
60
|
+
`ninguno`— o una `--integration` que no existe, `init` escribía la instancia entera y recién después
|
|
61
|
+
salía con error: el código de salida decía que no había pasado nada y el segundo intento encontraba la
|
|
62
|
+
carpeta creada. Ahora valida los dos valores antes de escribir.
|
|
63
|
+
|
|
64
|
+
**Qué cambia para vos**: nada con un valor correcto. Con uno mal escrito, el destino queda como estaba.
|
|
65
|
+
|
|
66
|
+
- **El bloqueo de `verify` cita la prueba que falló.** Mostraba la primera línea de la salida que dijera
|
|
67
|
+
«error», y en un reporte de pruebas ésa puede ser una verde cuyo nombre lo dice. Ahora, con la salida de
|
|
68
|
+
`node --test` —spec y TAP— y de `go test`, cita la primera prueba en rojo; con otras herramientas sigue
|
|
69
|
+
buscando por palabra, pero ya no elige una línea marcada como verde.
|
|
70
|
+
|
|
71
|
+
**Qué cambia para vos**: el mensaje apunta a la prueba que hay que mirar.
|
|
72
|
+
|
|
73
|
+
- **Cada bloqueo con salida angosta dice qué líneas pegar.** Aprobar en `planning/.ops-approval` sólo
|
|
74
|
+
funciona si la ruta está escrita en la forma que ese guard coteja —absoluta la de un `Write`, relativa
|
|
75
|
+
al repositorio la de un commit—, y el mensaje decía «escribí esa(s) ruta(s)» sin nombrarlas. `verify`
|
|
76
|
+
y `dependencies` ni siquiera mostraban la línea. Ahora cada bloqueo imprime las líneas exactas, y
|
|
77
|
+
pegarlas tal cual destraba ese mismo bloqueo.
|
|
78
|
+
|
|
79
|
+
**Qué cambia para vos**: si una aprobación no pegaba y terminabas exportando la variable del guard,
|
|
80
|
+
pegá lo que dice el mensaje.
|
|
81
|
+
|
|
82
|
+
- **`plan-first` deja de frenar la configuración y los archivos de la instancia.** Frenaba
|
|
83
|
+
`ops.config.json` —justo el archivo que el límite de raíces manda a editar para declarar una ruta— y,
|
|
84
|
+
en sidecar, también `AGENTS.md`, `CLAUDE.md`, `package.json` y `.gitignore` de la instancia, como si
|
|
85
|
+
fueran producto. Ahora producto es el código de una raíz declarada: la instancia sidecar no lo es
|
|
86
|
+
aunque viva dentro de su raíz (`..`), y lo que queda fuera de toda raíz —lo declarado en
|
|
87
|
+
`writableOutsideRoots`— tampoco. En embedded, el `package.json` de la raíz sigue siendo producto.
|
|
88
|
+
|
|
89
|
+
**Qué cambia para vos**: si aprobabas esas rutas a mano en `.ops-approval` o exportabas
|
|
90
|
+
`OPS_PLAN_FIRST_OVERRIDE` para poder tocarlas, ya no hace falta.
|
|
91
|
+
|
|
92
|
+
- **`verify` deja afuera del índice de su copia lo que enlaza.** Cuando el árbol difiere del índice, el
|
|
93
|
+
guard corre los gates sobre una copia del índice con lo ignorado enlazado —`node_modules`, `.env`—. Si
|
|
94
|
+
tu `.gitignore` escribe ese directorio con barra final (`node_modules/`), el enlace entraba al índice de
|
|
95
|
+
la copia, y un gate que recorre lo trackeado —`git ls-files`— lo recibía como si fuera parte del commit.
|
|
96
|
+
|
|
97
|
+
**Qué cambia para vos**: si una suite que pasa a mano fallaba bajo `verify` sólo cuando tenías archivos
|
|
98
|
+
sin trackear, podía ser esto.
|
|
99
|
+
|
|
17
100
|
## [0.79.0] - 2026-09-10
|
|
18
101
|
|
|
19
102
|
### Corregido
|
package/README.md
CHANGED
|
@@ -216,6 +216,7 @@ Lee [template/planning/PROTOCOL.md](template/planning/PROTOCOL.md) para el contr
|
|
|
216
216
|
| `ops integration rebase <ops-root> jira KEY` | Recalcula el borrador canónico sin avanzar la base remota. |
|
|
217
217
|
| `ops integration reconcile <ops-root> jira KEY` | Conserva curación sobre la nueva base remota. |
|
|
218
218
|
| `ops integration writeback-plan <ops-root> jira` | Muestra escrituras posibles sin ejecutarlas. |
|
|
219
|
+
| `ops secrets check <ops-root>` | Compara el contrato de secretos compartido contra cada servicio, sin conectarse. |
|
|
219
220
|
| `ops automation list <ops-root>` | Lista adaptadores y su instalación. |
|
|
220
221
|
| `ops automation list-hooks <ops-root>` | Describe los guards portables disponibles. |
|
|
221
222
|
| `ops automation check <ops-root>` | Valida guards, permisos y configuraciones. |
|
|
@@ -314,9 +315,11 @@ La sincronización solo lee Jira. `writeback-plan` calcula intención local, no
|
|
|
314
315
|
`writeBack` permanece en `false`. Consulta el [recorrido de Jira](template/integrations/jira/README.md) antes
|
|
315
316
|
de conectar una instancia real.
|
|
316
317
|
|
|
317
|
-
Para añadir otra herramienta
|
|
318
|
-
`
|
|
319
|
-
promoción y validación no se reimplementan.
|
|
318
|
+
Para añadir otra herramienta no hace falta tocar Cauce: el adaptador se escribe en la instancia, se
|
|
319
|
+
registra con una ruta en el campo `adapter` y cumple el contrato —`contract: 1`, `validateConfig`,
|
|
320
|
+
`fetchItems` y `normalizeFixture`—. Staging, revisión, promoción y validación no se reimplementan. El
|
|
321
|
+
recorrido está en [template/integrations/README.md](template/integrations/README.md) y el contrato en
|
|
322
|
+
[integrations/README.md](integrations/README.md).
|
|
320
323
|
|
|
321
324
|
## Hooks y runners
|
|
322
325
|
|
|
@@ -4,6 +4,7 @@ Los hooks convierten invariantes comprobables en gates mecánicos. La base recom
|
|
|
4
4
|
|
|
5
5
|
- comandos destructivos, force, amend y stage indiscriminado;
|
|
6
6
|
- escritura de secretos o credenciales;
|
|
7
|
+
- lectura de credenciales conocidas o declaradas, en los runners que tienen una herramienta de lectura;
|
|
7
8
|
- edición manual de código generado y drift respecto a OpenAPI/SQL;
|
|
8
9
|
- commits sin Verify aplicable;
|
|
9
10
|
- apagado o borrado de la prueba que juzga el cambio;
|
|
@@ -44,6 +45,20 @@ publica— por la sensación de que ya está cubierto.
|
|
|
44
45
|
Regla práctica: si algo **tiene** que ser imposible, no lo pongas acá. Ponelo donde no dependa de leer
|
|
45
46
|
una cadena — permisos del runner, alcance del token, aprobación de un PR.
|
|
46
47
|
|
|
48
|
+
### Leer una credencial
|
|
49
|
+
|
|
50
|
+
`secrets-read` frena leer, con la herramienta de lectura del runner, un archivo que `secrets` frenaría al
|
|
51
|
+
escribir —los nombres conocidos y las identidades que declara `organization/secrets.json`—. Qué alcanza en
|
|
52
|
+
cada runner es distinto, y conviene saberlo:
|
|
53
|
+
|
|
54
|
+
- **Claude Code**: el guard corre en `Read`, y además la instalación agrega reglas `permissions.deny`
|
|
55
|
+
`Read(...)` por los nombres conocidos. Esas reglas las aplica el propio Claude Code a `Read`, a `cat`,
|
|
56
|
+
`head`, `tail`, `sed` y a las redirecciones, pero no a un `grep -r` ni a un subproceso que abra el archivo
|
|
57
|
+
por su cuenta. Van por nombre exacto: `.env.*` también negaría `.env.example`.
|
|
58
|
+
- **Gemini CLI**: el guard corre en `read_file`. Un `cat` por `run_shell_command` no lo ve.
|
|
59
|
+
- **Codex y Antigravity**: sin guard de lectura. Sus adaptadores sólo enganchan shell y edición, y leer por
|
|
60
|
+
shell no pasa por ningún matcher de archivo.
|
|
61
|
+
|
|
47
62
|
## Cómo se ejecutan
|
|
48
63
|
|
|
49
64
|
```text
|
|
@@ -67,8 +82,9 @@ runner, mientras la lógica se prueba y mantiene una sola vez en `engine/hooks/r
|
|
|
67
82
|
|
|
68
83
|
| Grupo | Guards | Wrapper |
|
|
69
84
|
|---|---|---|
|
|
70
|
-
| `pre-shell` | destructive, git-add, dependencies, governance, verify | `guard-shell.sh` |
|
|
71
|
-
| `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence | `guard-files.sh` |
|
|
85
|
+
| `pre-shell` | destructive, git-add, dependencies, governance, verify, shell-boundary | `guard-shell.sh` |
|
|
86
|
+
| `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence, plan-first | `guard-files.sh` |
|
|
87
|
+
| `pre-read` | secrets-read | `guard-secrets-read.sh` |
|
|
72
88
|
| `stop` | planning-drift | `guard-planning-drift.sh` |
|
|
73
89
|
|
|
74
90
|
Registrar el grupo gasta un proceso por herramienta en lugar de cinco, con el mismo orden y la misma
|
|
@@ -6,7 +6,9 @@ Adaptador nativo mediante `PreToolUse` y `Stop`. Instalar con:
|
|
|
6
6
|
node tools/ops.js automation install . claude
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves.
|
|
9
|
+
El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves. También
|
|
10
|
+
suma a `permissions.deny` reglas `Read(...)` por los nombres de credencial conocidos, junto a las que el
|
|
11
|
+
proyecto ya tenga; una que Cauce retire en una versión futura no se quita sola. Si ya
|
|
10
12
|
existe una versión distinta de un archivo, se detiene sin sobrescribirla para no destruir
|
|
11
13
|
personalizaciones del proyecto. También crea `CLAUDE.md` cuando no existe y conserva uno existente.
|
|
12
14
|
|
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"deny": [
|
|
4
|
+
"Read(.env)",
|
|
5
|
+
"Read(.env.local)",
|
|
6
|
+
"Read(.env.*.local)",
|
|
7
|
+
"Read(.npmrc)",
|
|
8
|
+
"Read(.netrc)",
|
|
9
|
+
"Read(_netrc)",
|
|
10
|
+
"Read(.pypirc)",
|
|
11
|
+
"Read(.dockercfg)",
|
|
12
|
+
"Read(id_rsa)",
|
|
13
|
+
"Read(id_dsa)",
|
|
14
|
+
"Read(id_ecdsa)",
|
|
15
|
+
"Read(id_ed25519)",
|
|
16
|
+
"Read(credentials)",
|
|
17
|
+
"Read(credentials*.json)",
|
|
18
|
+
"Read(*service-account*.json)",
|
|
19
|
+
"Read(*.pem)",
|
|
20
|
+
"Read(*.key)",
|
|
21
|
+
"Read(accesos.md)",
|
|
22
|
+
"Read(credenciales*)"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
2
25
|
"hooks": {
|
|
3
26
|
"PreToolUse": [
|
|
4
27
|
{
|
|
@@ -12,6 +35,12 @@
|
|
|
12
35
|
"hooks": [
|
|
13
36
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-files.sh" }
|
|
14
37
|
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"matcher": "Read",
|
|
41
|
+
"hooks": [
|
|
42
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-secrets-read.sh" }
|
|
43
|
+
]
|
|
15
44
|
}
|
|
16
45
|
],
|
|
17
46
|
"Stop": [
|
|
@@ -19,6 +19,7 @@ instalación vieja, `.gemini/commands/ops/` queda huérfano y se borra a mano.
|
|
|
19
19
|
|
|
20
20
|
Gemini CLI tiene hooks nativos y el adaptador los usa: `BeforeTool` y `AfterAgent` en
|
|
21
21
|
`.gemini/settings.json`, declarados en `manifest.json`. Sólo corren si la carpeta está marcada como
|
|
22
|
-
confiable —`GEMINI.md` explica qué avisa Gemini cuando no lo está—.
|
|
22
|
+
confiable —`GEMINI.md` explica qué avisa Gemini cuando no lo está—. `read_file` pasa por
|
|
23
|
+
`guard-secrets-read.sh`, que frena leer una credencial; un `cat` por `run_shell_command` no lo ve.
|
|
23
24
|
|
|
24
25
|
Comprueba la instalación con `node tools/ops.js automation doctor . gemini`.
|
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
"command": "$GEMINI_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-files.sh"
|
|
28
28
|
}
|
|
29
29
|
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"matcher": "read_file",
|
|
33
|
+
"hooks": [
|
|
34
|
+
{
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "$GEMINI_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-secrets-read.sh"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
30
39
|
}
|
|
31
40
|
],
|
|
32
41
|
"AfterAgent": [
|
package/engine/cli/args.js
CHANGED
|
@@ -32,6 +32,7 @@ const FLAGS = {
|
|
|
32
32
|
adopt: [],
|
|
33
33
|
agents: ['--json', '--own', '--system'],
|
|
34
34
|
integration: ['--fixture'],
|
|
35
|
+
secrets: [],
|
|
35
36
|
automation: ['--force'],
|
|
36
37
|
learn: ['--flow', '--proposal', '--applied', '--archived', '--period'],
|
|
37
38
|
evaluate: ['--cases', '--json', '--bench', '--force', '--record', '--flow'],
|
package/engine/cli/bootstrap.js
CHANGED
package/engine/cli/ops.js
CHANGED
|
@@ -95,11 +95,6 @@ async function init(target, cli) {
|
|
|
95
95
|
if (existing.length && !force) {
|
|
96
96
|
fail(`El destino no está vacío: ${root}. Usa --force para agregar solo archivos faltantes.`)
|
|
97
97
|
}
|
|
98
|
-
IN.scaffold(root, { name, mode, force })
|
|
99
|
-
const relative = path.relative(process.cwd(), root)
|
|
100
|
-
const enter = relative && relative !== '.' ? `cd ${relative} && ` : ''
|
|
101
|
-
console.log(`\n✓ ${name}: sistema ops creado en ${root} (modo ${mode})`)
|
|
102
|
-
|
|
103
98
|
// Preguntar exige una terminal, e instalar baja un paquete y escribe `node_modules`: las dos cosas
|
|
104
99
|
// pasan cuando hay alguien mirando. Una corrida automatizada —CI, un contenedor, estas pruebas—
|
|
105
100
|
// recibe la instancia materializada y decide por bandera, sin descargas ni preguntas implícitas.
|
|
@@ -112,6 +107,13 @@ async function init(target, cli) {
|
|
|
112
107
|
interactive,
|
|
113
108
|
install: cli.has('--install') || (interactive && !cli.has('--no-install')),
|
|
114
109
|
}
|
|
110
|
+
// Se valida antes de escribir: un runner o una integración que no existen no pueden dejar una
|
|
111
|
+
// instancia hecha con el comando en error (caso 096). `BOOT.run` vuelve a validar con la misma función.
|
|
112
|
+
try { BOOT.validate(options) } catch (error) { fail(error.message, 2) }
|
|
113
|
+
IN.scaffold(root, { name, mode, force })
|
|
114
|
+
const relative = path.relative(process.cwd(), root)
|
|
115
|
+
const enter = relative && relative !== '.' ? `cd ${relative} && ` : ''
|
|
116
|
+
console.log(`\n✓ ${name}: sistema ops creado en ${root} (modo ${mode})`)
|
|
115
117
|
let result
|
|
116
118
|
try {
|
|
117
119
|
result = await BOOT.run(root, options, {
|
|
@@ -166,6 +168,7 @@ function usage() {
|
|
|
166
168
|
ops integration rebase <ops-root> <provider> <remote-key>
|
|
167
169
|
ops integration reconcile <ops-root> <provider> <remote-key>
|
|
168
170
|
ops integration writeback-plan <ops-root> <provider>
|
|
171
|
+
ops secrets check <ops-root>
|
|
169
172
|
ops automation list <ops-root>
|
|
170
173
|
ops automation list-hooks <ops-root>
|
|
171
174
|
ops automation check <ops-root>
|
|
@@ -218,6 +221,7 @@ async function run(cli) {
|
|
|
218
221
|
else if (command === 'integration') {
|
|
219
222
|
await W.integration(arg[1], arg[2], arg[3], arg[4], cli)
|
|
220
223
|
}
|
|
224
|
+
else if (command === 'secrets') W.secrets(arg[1], arg[2])
|
|
221
225
|
else if (command === 'automation') W.automation(arg[1], arg[2], arg[3], cli)
|
|
222
226
|
else if (command === 'learn') CAT.learn(arg[1], cli)
|
|
223
227
|
else if (command === 'evaluate') CAT.evaluate(arg[1], arg[2], cli)
|
package/engine/cli/wiring.js
CHANGED
|
@@ -8,6 +8,7 @@ const fs = require('node:fs')
|
|
|
8
8
|
const path = require('node:path')
|
|
9
9
|
const F = require('../core/files')
|
|
10
10
|
const I = require('../integrations/registry')
|
|
11
|
+
const SE = require('../secrets')
|
|
11
12
|
const A = require('../automation')
|
|
12
13
|
const SC = require('../core/scan')
|
|
13
14
|
const OB = require('../core/onboarding')
|
|
@@ -38,11 +39,18 @@ const INTEGRATION = {
|
|
|
38
39
|
missing: 'Falta <provider>.',
|
|
39
40
|
run: (root, provider) => {
|
|
40
41
|
const source = path.join(IN.PROJECT_ROOT, 'template', 'integrations', provider)
|
|
41
|
-
|
|
42
|
+
const fromCauce = fs.existsSync(source)
|
|
43
|
+
// Sin molde en Cauce no hay andamiaje que reponer: habilitar un proveedor propio es sólo el interruptor.
|
|
44
|
+
const own = !fromCauce && (providerRegistry(root).config.providers || {})[provider]
|
|
45
|
+
&& fs.existsSync(path.join(root, 'integrations', provider))
|
|
46
|
+
if (!fromCauce && !own) {
|
|
47
|
+
fail(`Cauce no trae un adaptador para ${provider}. Uno propio vive en integrations/${provider}/ y se `
|
|
48
|
+
+ `registra en integrations/config.json con "adapter": "./adapter.js"; con eso, enable lo conecta.`, 2)
|
|
49
|
+
}
|
|
42
50
|
// Habilitar no es inicializar: repone lo que falte y conserva lo que ya esté. Una instancia que
|
|
43
51
|
// trae el andamiaje de una versión anterior —o que ya tiene snapshots— sólo quiere el interruptor.
|
|
44
52
|
providerRegistry(root)
|
|
45
|
-
IN.copyTemplate(source, path.join(root, 'integrations', provider), {}, true)
|
|
53
|
+
if (fromCauce) IN.copyTemplate(source, path.join(root, 'integrations', provider), {}, true)
|
|
46
54
|
switchProvider(root, provider, true)
|
|
47
55
|
console.log(`✓ ${provider}: conectado al proyecto y andamiaje en integrations/${provider}/.`)
|
|
48
56
|
// Sólo se pide lo que falta: reencender un proveedor ya configurado no debería mandar a
|
|
@@ -277,8 +285,18 @@ function automation(action, rootArg, runnerName, cli) {
|
|
|
277
285
|
fail(`Acción de automatización desconocida: ${action || '(vacía)'}`, 2)
|
|
278
286
|
}
|
|
279
287
|
|
|
288
|
+
function secrets(action, rootArg) {
|
|
289
|
+
if (action !== 'check') fail(`Acción de secretos desconocida: ${action || '(vacía)'}`, 2)
|
|
290
|
+
const result = SE.check(opsRoot(rootArg))
|
|
291
|
+
if (!result.declared) return console.log(`Sin ${SE.DECLARATION}: no hay contrato de secretos que comprobar.`)
|
|
292
|
+
for (const warning of result.warnings) console.log(`⚠ ${warning}`)
|
|
293
|
+
for (const error of result.errors) console.error(`✗ ${error}`)
|
|
294
|
+
if (result.errors.length) fail(`${result.errors.length} error(es) en el contrato de secretos`)
|
|
295
|
+
console.log(`✓ contrato de secretos: ${result.current} servicio(s) al día`)
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
// `init` enciende un proveedor en la misma corrida en que crea la instancia, y ésta es la operación
|
|
281
299
|
// que lo hace: se expone para que la composición no tenga que conocer la tabla entera.
|
|
282
300
|
const enableProvider = (root, provider) => INTEGRATION.enable.run(root, provider)
|
|
283
301
|
|
|
284
|
-
module.exports = { scan, onboard, integration, automation, enableProvider }
|
|
302
|
+
module.exports = { scan, onboard, integration, automation, secrets, enableProvider }
|
package/engine/config/paths.js
CHANGED
|
@@ -10,7 +10,9 @@ const path = require('node:path')
|
|
|
10
10
|
|
|
11
11
|
// `~` se expande sólo cuando es el prefijo entero. `~datos` es un nombre de directorio válido y no la
|
|
12
12
|
// casa de nadie; expandirlo ahí convertiría una ruta relativa en una absoluta que el autor no escribió.
|
|
13
|
-
|
|
13
|
+
// La usa también el contrato de secretos para las rutas de sus identidades, por la misma razón que
|
|
14
|
+
// arriba: una ruta declarada en un archivo del proyecto se resuelve igual la lea quien la lea.
|
|
15
|
+
function resolvePath(root, entry) {
|
|
14
16
|
return path.resolve(root, String(entry).replace(/^~(?=$|[/\\])/, os.homedir()))
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -21,7 +23,7 @@ function resolve(root, entry) {
|
|
|
21
23
|
function writableOutsideRoots(root, config) {
|
|
22
24
|
const declared = config && Array.isArray(config.writableOutsideRoots) ? config.writableOutsideRoots : []
|
|
23
25
|
return declared.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
24
|
-
.map((entry) => ({ declared: entry, path:
|
|
26
|
+
.map((entry) => ({ declared: entry, path: resolvePath(root, entry) }))
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
module.exports = { writableOutsideRoots }
|
|
29
|
+
module.exports = { writableOutsideRoots, resolvePath }
|
package/engine/hooks/approval.js
CHANGED
|
@@ -41,10 +41,14 @@ function pending(root, files) {
|
|
|
41
41
|
return files.filter((file) => !approved.has(file))
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Cómo se toma la salida angosta, dicho una vez porque
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
// Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
|
|
45
|
+
// las líneas exactas porque cada guard coteja la ruta en la forma que tiene a mano —absoluta la que llega
|
|
46
|
+
// de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
|
|
47
|
+
// decirla, lo que quedaba a mano era la variable (caso 089). Nombra también la variable: sigue
|
|
48
|
+
// existiendo, y esconderla haría que quien la necesite la descubra sin saber su alcance.
|
|
49
|
+
const HOW = (variable, lines) => `Aprobalo pegando tal cual en planning/${APPROVAL} estas líneas:\n`
|
|
50
|
+
+ lines.map((line) => ` ${line}\n`).join('')
|
|
51
|
+
+ `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
|
|
48
52
|
+ 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
|
|
49
53
|
|
|
50
54
|
module.exports = { APPROVAL, read, pending, HOW }
|
package/engine/hooks/files.js
CHANGED
|
@@ -50,25 +50,50 @@ function alreadyShipped(file) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
// Qué archivo es una credencial, para los dos guards que la cuidan: `secrets`, que frena escribirla, y
|
|
54
|
+
// `secrets-read`, que frena leerla. Devuelve el motivo, o vacío.
|
|
55
|
+
function credential(input, raw) {
|
|
56
|
+
const base = path.basename(raw)
|
|
57
|
+
if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
|
|
58
|
+
return 'parece contener secretos. Edita una plantilla o registra una acción humana.'
|
|
59
|
+
}
|
|
60
|
+
if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
|
|
61
|
+
return 'parece un archivo de credenciales en texto plano.'
|
|
62
|
+
}
|
|
63
|
+
// Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
|
|
64
|
+
// `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
|
|
65
|
+
// hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
|
|
66
|
+
// exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
|
|
67
|
+
//
|
|
68
|
+
// Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
|
|
69
|
+
// nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
|
|
70
|
+
if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
|
|
71
|
+
return 'es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.'
|
|
72
|
+
}
|
|
73
|
+
// Lo que ningún nombre delata: una identidad de máquina que el 088 declara puede llamarse
|
|
74
|
+
// `local-dev.env` (caso 092). Se lee sólo si la declaración existe, para no cargarla en cada hook.
|
|
75
|
+
const root = opsRoot(input)
|
|
76
|
+
if (!root || !fs.existsSync(path.join(root, 'organization', 'secrets.json'))) return ''
|
|
77
|
+
return require('../secrets').identityFiles(root).includes(path.resolve(cwdOf(input), raw))
|
|
78
|
+
? 'es una identidad declarada en organization/secrets.json: la carga una persona.'
|
|
79
|
+
: ''
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
function secrets(input) {
|
|
54
83
|
for (const file of filesOf(input)) {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
|
|
69
|
-
if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
|
|
70
|
-
block(`${file} es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.`)
|
|
71
|
-
}
|
|
84
|
+
const reason = credential(input, file)
|
|
85
|
+
if (reason) block(`${file} ${reason}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
|
|
90
|
+
// grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
|
|
91
|
+
function secretsRead(input) {
|
|
92
|
+
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
93
|
+
for (const file of filesOf(input)) {
|
|
94
|
+
if (!credential(input, file) || approved(input, file)) continue
|
|
95
|
+
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])}`)
|
|
72
97
|
}
|
|
73
98
|
}
|
|
74
99
|
|
|
@@ -121,17 +146,18 @@ function testEvidence(input) {
|
|
|
121
146
|
'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
|
|
122
147
|
'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
|
|
123
148
|
'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
|
|
124
|
-
'decisión con dueño.\n'
|
|
149
|
+
'decisión con dueño.\n'
|
|
150
|
+
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file])
|
|
125
151
|
for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
|
|
126
152
|
const removed = match[1].trim()
|
|
127
|
-
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}`)
|
|
153
|
+
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
|
|
128
154
|
}
|
|
129
155
|
const content = contentOf(input)
|
|
130
156
|
if (!content) return
|
|
131
157
|
for (const raw of filesOf(input)) {
|
|
132
158
|
if (!isTestFile(raw) || approved(input, raw)) continue
|
|
133
159
|
for (const [marca, nombre] of TEST_OFF) {
|
|
134
|
-
if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
|
|
160
|
+
if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}${how(raw)}`)
|
|
135
161
|
}
|
|
136
162
|
}
|
|
137
163
|
}
|
|
@@ -150,6 +176,28 @@ function opsOwned(root, file) {
|
|
|
150
176
|
return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
// Producto es el código de una raíz declarada, y la instancia sidecar no lo es aunque viva dentro de una:
|
|
180
|
+
// `init` escribe `..` como raíz en sidecar, así que la carpeta de la instancia cae adentro. En embedded la
|
|
181
|
+
// raíz de ops **es** una raíz de producto, y ahí sólo se exime lo que la instancia posee. Lo que queda
|
|
182
|
+
// fuera de toda raíz tampoco es producto: el límite de raíces ya lo juzgó, y si pasó es porque el proyecto
|
|
183
|
+
// lo declaró en `writableOutsideRoots` (casos 089 y 090).
|
|
184
|
+
//
|
|
185
|
+
// `ops.config.json` se exime por nombre porque es la llave del límite de raíces: su mensaje manda a
|
|
186
|
+
// editarlo, y frenar esa edición era el candado de arriba con otra forma.
|
|
187
|
+
const INSTANCE_CONFIG = 'ops.config.json'
|
|
188
|
+
|
|
189
|
+
function isProduct(root, file) {
|
|
190
|
+
if (opsOwned(root, file) || file === path.join(root, INSTANCE_CONFIG)) return false
|
|
191
|
+
const declared = configOf(root).workspaceRoots
|
|
192
|
+
const roots = (Array.isArray(declared) ? declared : [])
|
|
193
|
+
.filter((entry) => entry && typeof entry.path === 'string')
|
|
194
|
+
.map((entry) => path.resolve(root, entry.path))
|
|
195
|
+
// Sin raíces legibles no hay contra qué comparar, y se juzga como antes: frenar de más.
|
|
196
|
+
if (!roots.length) return true
|
|
197
|
+
if (outsideRoots(file, roots)) return false
|
|
198
|
+
return outsideRoots(file, [root]) || roots.includes(root)
|
|
199
|
+
}
|
|
200
|
+
|
|
153
201
|
// R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
|
|
154
202
|
// tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
|
|
155
203
|
// hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
|
|
@@ -176,11 +224,10 @@ function planFirst(input) {
|
|
|
176
224
|
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
177
225
|
+ 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
178
226
|
+ '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
227
|
for (const raw of filesOf(input)) {
|
|
181
|
-
if (
|
|
228
|
+
if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
|
|
182
229
|
if (approved(input, raw)) continue
|
|
183
|
-
block(`${raw} cambia el producto sin plan. ${why}`)
|
|
230
|
+
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw])}`)
|
|
184
231
|
}
|
|
185
232
|
}
|
|
186
233
|
|
|
@@ -254,7 +301,7 @@ function migrations(input) {
|
|
|
254
301
|
if (!esMigracion.test(normalized)) continue
|
|
255
302
|
if (approved(input, normalized)) continue
|
|
256
303
|
if (destructiveSql.test(contentOf(input))) {
|
|
257
|
-
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
|
|
304
|
+
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized])}`)
|
|
258
305
|
}
|
|
259
306
|
// El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
|
|
260
307
|
// lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
|
|
@@ -264,7 +311,7 @@ function migrations(input) {
|
|
|
264
311
|
const shipped = alreadyShipped(file)
|
|
265
312
|
if (shipped) {
|
|
266
313
|
block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
|
|
267
|
-
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE'))
|
|
314
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized]))
|
|
268
315
|
}
|
|
269
316
|
}
|
|
270
317
|
}
|
|
@@ -295,6 +342,6 @@ function engineWrites(input) {
|
|
|
295
342
|
}
|
|
296
343
|
|
|
297
344
|
module.exports = {
|
|
298
|
-
secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
345
|
+
secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
299
346
|
migrations, engineWrites,
|
|
300
347
|
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -47,6 +47,7 @@ const guards = {
|
|
|
47
47
|
'integration-snapshot': files.integrationSnapshot,
|
|
48
48
|
'test-evidence': files.testEvidence,
|
|
49
49
|
'plan-first': files.planFirst,
|
|
50
|
+
'secrets-read': files.secretsRead,
|
|
50
51
|
'planning-drift': planningDrift,
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -55,6 +56,7 @@ const hookGroups = {
|
|
|
55
56
|
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
|
|
56
57
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
57
58
|
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
59
|
+
'pre-read': ['secrets-read'],
|
|
58
60
|
stop: ['planning-drift'],
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -94,6 +96,11 @@ const hookMetadata = [
|
|
|
94
96
|
event: 'PreToolUse · files',
|
|
95
97
|
purpose: 'Bloquea escribir secretos, claves privadas y credenciales en texto plano.',
|
|
96
98
|
},
|
|
99
|
+
{
|
|
100
|
+
name: 'secrets-read',
|
|
101
|
+
event: 'PreToolUse · read',
|
|
102
|
+
purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
|
|
103
|
+
},
|
|
97
104
|
{ name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
|
|
98
105
|
{
|
|
99
106
|
name: 'workspace-boundary',
|
package/engine/hooks/shell.js
CHANGED
|
@@ -181,7 +181,7 @@ function dependencies(input) {
|
|
|
181
181
|
// `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
|
|
182
182
|
// conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
|
|
183
183
|
const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
|
|
184
|
-
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
|
|
184
|
+
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
|
|
185
185
|
// 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
186
|
// disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
|
|
187
187
|
// en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
|
|
@@ -201,14 +201,15 @@ function dependencies(input) {
|
|
|
201
201
|
if (onDisk.length > 1) {
|
|
202
202
|
block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
|
|
203
203
|
}
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
const manifests = sinAprobar(parent, state.manifests)
|
|
205
|
+
if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
|
|
206
206
|
block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
|
|
207
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
|
|
207
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests))
|
|
208
208
|
}
|
|
209
|
-
|
|
209
|
+
const lockfiles = sinAprobar(parent, state.locks)
|
|
210
|
+
if (state.locks.length && !state.manifests.length && lockfiles.length) {
|
|
210
211
|
block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
|
|
211
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
|
|
212
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles))
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
215
|
}
|
|
@@ -348,8 +349,7 @@ function governance(input) {
|
|
|
348
349
|
// entre una llave por operación y una puerta que quedó abierta.
|
|
349
350
|
const pendientes = AP.pending(opsRoot(input), governed)
|
|
350
351
|
if (!pendientes.length) return
|
|
351
|
-
|
|
352
|
-
block(`El commit toca gobernanza protegida:\n${files}\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE')}`)
|
|
352
|
+
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes)}`)
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
function run(program, args, cwd, extra = {}) {
|
|
@@ -404,6 +404,7 @@ function commitTree(dir) {
|
|
|
404
404
|
fs.rmSync(temp, { recursive: true, force: true })
|
|
405
405
|
block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
|
|
406
406
|
}
|
|
407
|
+
const linked = []
|
|
407
408
|
for (const line of lines) {
|
|
408
409
|
if (!line.startsWith('!! ')) continue
|
|
409
410
|
const name = line.slice(3).trim().replace(/\/$/, '')
|
|
@@ -424,6 +425,7 @@ function commitTree(dir) {
|
|
|
424
425
|
if (fs.existsSync(link)) continue
|
|
425
426
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
|
426
427
|
fs.symlinkSync(path.join(dir, name), link, 'junction')
|
|
428
|
+
linked.push(name)
|
|
427
429
|
}
|
|
428
430
|
// Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
|
|
429
431
|
// por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
|
|
@@ -441,7 +443,15 @@ function commitTree(dir) {
|
|
|
441
443
|
// pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
|
|
442
444
|
// quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
|
|
443
445
|
const started = run('git', ['init', '--quiet'], temp)
|
|
444
|
-
|
|
446
|
+
// Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
|
|
447
|
+
// patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
|
|
448
|
+
// y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
|
|
449
|
+
if (started.ok) {
|
|
450
|
+
fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
|
|
451
|
+
fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
|
|
452
|
+
linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
|
|
453
|
+
run('git', ['add', '--all'], temp)
|
|
454
|
+
}
|
|
445
455
|
// Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
|
|
446
456
|
// y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
|
|
447
457
|
// sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
|
|
@@ -478,19 +488,20 @@ function verify(input) {
|
|
|
478
488
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
479
489
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
480
490
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
481
|
-
const
|
|
491
|
+
const sinAprobar = AP.pending(opsRoot(input), staged)
|
|
492
|
+
const aprobado = !sinAprobar.length
|
|
482
493
|
if (changedOpenApi && !hasApiGenerated && !aprobado) {
|
|
483
494
|
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')}`)
|
|
495
|
+
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar)}`)
|
|
485
496
|
}
|
|
486
497
|
if (changedSqlSource && !hasSqlGenerated && !aprobado) {
|
|
487
498
|
block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
|
|
488
|
-
+ AP.HOW('OPS_SKIP_VERIFY'))
|
|
499
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
|
|
489
500
|
}
|
|
490
501
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
491
502
|
const { root, temp, env } = commitTree(dir)
|
|
492
503
|
try {
|
|
493
|
-
verifyGates(root, dir,
|
|
504
|
+
verifyGates(root, dir, sinAprobar, env, opsRoot(input))
|
|
494
505
|
} finally {
|
|
495
506
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
496
507
|
}
|
|
@@ -511,6 +522,11 @@ function verify(input) {
|
|
|
511
522
|
// Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
|
|
512
523
|
// que hace falta para diagnosticar es la primera línea de error, no el volcado.
|
|
513
524
|
const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
|
|
525
|
+
// Cómo marca un reporte de pruebas cada resultado: `node --test` en spec y en TAP, y `go test`. Van sólo
|
|
526
|
+
// las comprobadas contra la herramienta (caso 094): el nombre de una prueba verde puede decir «error», y
|
|
527
|
+
// sin mirar la marca la búsqueda por palabra se quedaba con ella y el mensaje escondía la roja.
|
|
528
|
+
const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:)/
|
|
529
|
+
const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)/
|
|
514
530
|
const MAX_LINE = 160
|
|
515
531
|
function fallo(gate, result) {
|
|
516
532
|
// La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
|
|
@@ -519,7 +535,8 @@ function fallo(gate, result) {
|
|
|
519
535
|
// palabra de error gana siempre.
|
|
520
536
|
const lines = (result.output || '').split('\n').map((one) => one.trim())
|
|
521
537
|
.filter((one) => one && !one.startsWith('>'))
|
|
522
|
-
const line = lines.find((one) =>
|
|
538
|
+
const line = lines.find((one) => FAILED_TEST.test(one))
|
|
539
|
+
|| lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
|
|
523
540
|
return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
|
|
524
541
|
}
|
|
525
542
|
|
|
@@ -538,7 +555,7 @@ function comoSeLee(failures) {
|
|
|
538
555
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
539
556
|
}
|
|
540
557
|
|
|
541
|
-
function verifyGates(root, dir,
|
|
558
|
+
function verifyGates(root, dir, sinAprobar, env, ops) {
|
|
542
559
|
const failures = []
|
|
543
560
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
544
561
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
@@ -572,13 +589,13 @@ function verifyGates(root, dir, aprobado, env, ops) {
|
|
|
572
589
|
if (!result.ok) failures.push(fallo('make test', result))
|
|
573
590
|
}
|
|
574
591
|
}
|
|
575
|
-
if (!failures.length ||
|
|
592
|
+
if (!failures.length || !sinAprobar.length) return
|
|
576
593
|
// Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
|
|
577
594
|
// comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
|
|
578
595
|
const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
579
596
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
580
597
|
block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
|
|
581
|
-
+ AP.HOW('OPS_SKIP_VERIFY'))
|
|
598
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
|
|
582
599
|
}
|
|
583
600
|
|
|
584
601
|
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
|
@@ -151,6 +151,7 @@ y eso lo deja apagado **hasta que cierres la sesión**, no para un comando.
|
|
|
151
151
|
| `OPS_TEST_EVIDENCE_OVERRIDE=1` | el de evidencia de pruebas |
|
|
152
152
|
| `OPS_DEPENDENCIES_OVERRIDE=1` | el de dependencias, incluido publicar e instalar global |
|
|
153
153
|
| `OPS_PLAN_FIRST_OVERRIDE=1` | el que exige plan antes de cambiar el producto |
|
|
154
|
+
| `OPS_SECRETS_READ_OVERRIDE=1` | el que frena leer una credencial con la herramienta del runner |
|
|
154
155
|
| `OPS_SKIP_VERIFY=1` | el que corre los gates |
|
|
155
156
|
|
|
156
157
|
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.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# OPS-007 — Un contrato de secretos compartido, sin conocer ningún gestor
|
|
2
|
+
|
|
3
|
+
**Estado:** Aceptado
|
|
4
|
+
**Fecha:** 2026-09-10
|
|
5
|
+
|
|
6
|
+
> Decide qué parte del manejo de secretos es de la base; el gestor, sus scripts y su CI siguen siendo de
|
|
7
|
+
> la empresa.
|
|
8
|
+
|
|
9
|
+
## Contexto
|
|
10
|
+
|
|
11
|
+
Una empresa que adopta un gestor de secretos termina con el mismo modelo copiado en cada repositorio:
|
|
12
|
+
la identidad con que se lee el gestor, un script que compara el gestor contra el contrato de variables y
|
|
13
|
+
los workflows que avisan en el PR. Nada dice qué servicio usa qué cuenta ni qué identidad, y nada detecta
|
|
14
|
+
que una copia se quedó atrás. En una instancia real se contaron dieciséis copias de un mismo script en
|
|
15
|
+
tres variantes, y el arreglo que tenía una sola no había llegado ni al esqueleto del que salen los
|
|
16
|
+
servicios nuevos.
|
|
17
|
+
|
|
18
|
+
`integrations/` no sirve para esto: su ciclo es de contenido de trabajo que baja a planning (OPS-003), y
|
|
19
|
+
su README prohíbe guardar secretos ahí.
|
|
20
|
+
|
|
21
|
+
## Decisión
|
|
22
|
+
|
|
23
|
+
**La base declara y compara; no se conecta, no genera y no conoce ningún gestor.**
|
|
24
|
+
|
|
25
|
+
- La declaración vive en `organization/secrets.json`, que es del proyecto: cuentas, proyectos,
|
|
26
|
+
identidades, servicios y los archivos que comparten. Nunca un valor: una clave con forma de secreto es
|
|
27
|
+
un error.
|
|
28
|
+
- Una identidad es un nivel de acceso, no un repositorio. `source: file` es un archivo **fuera de todo
|
|
29
|
+
repositorio**, que carga una persona; `source: ci-secret` vive en el CI.
|
|
30
|
+
- La copia canónica de cada archivo compartido vive en la instancia. `ops secrets check` compara cada
|
|
31
|
+
copia de cada servicio contra ella por hash, sin red, y dice el `cp` que la pone al día. Cauce no
|
|
32
|
+
escribe en los repositorios de producto.
|
|
33
|
+
- El chequeo compara los servicios de una instancia. Una empresa con varios proyectos los declara como
|
|
34
|
+
raíces de la misma instancia, que es lo que hace que un esqueleto y sus derivados se midan contra lo
|
|
35
|
+
mismo.
|
|
36
|
+
|
|
37
|
+
## Alternativas consideradas
|
|
38
|
+
|
|
39
|
+
- **Un adaptador de la empresa que genere los archivos**: flexible, pero obliga al motor a cargar código
|
|
40
|
+
de la instancia y a versionar una interfaz, para resolver lo mismo que una copia comparada por hash.
|
|
41
|
+
- **El adaptador como paquete versionado que cada instancia instala**: mantiene una instancia por
|
|
42
|
+
proyecto a cambio de publicar y versionar un paquete más.
|
|
43
|
+
- **Un workflow reutilizable compartido**: una sola copia, pero supone GitHub y una organización, y deja
|
|
44
|
+
a la base atada a un proveedor de CI.
|
|
45
|
+
- **Hacerlo un proveedor de `integrations/`**: su staging, reconciliación y promoción no se aplican a
|
|
46
|
+
secretos.
|
|
47
|
+
|
|
48
|
+
## Consecuencias
|
|
49
|
+
|
|
50
|
+
**Ganamos:** la copia que se quedó atrás se ve, con su arreglo al lado; rotar una identidad compartida
|
|
51
|
+
es cambiar un archivo; y la base sigue sin saber de ningún gestor.
|
|
52
|
+
|
|
53
|
+
**Costos que aceptamos:** un arreglo sigue siendo un commit por repositorio —el chequeo los lista, no los
|
|
54
|
+
aplica—. Juntar varios proyectos en una instancia comparte también su `planning/`. Y una credencial
|
|
55
|
+
dentro de un repositorio es un error aunque esté ignorada por git: es la copia por repositorio que esta
|
|
56
|
+
decisión viene a sacar.
|
|
57
|
+
|
|
58
|
+
## Estado de implementación
|
|
59
|
+
|
|
60
|
+
Implementado en 0.80.0: `organization/secrets.json`, `ops secrets check` y el recorrido documentado en
|
|
61
|
+
`organization/README.md`. Cauce no trae adaptadores ni un workflow de ejemplo que corra el chequeo en CI;
|
|
62
|
+
correrlo ahí es un paso de la empresa.
|