git-preflight-gate 0.1.6__py3-none-any.whl

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.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-preflight-gate
3
+ Version: 0.1.6
4
+ Summary: Pre-write preflight gate (bash) and CI authority gate (Python composite action): fail-closed, standard library only except PyYAML for the CI extra.
5
+ Author: alexisromtz-lab
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alexisromtz-lab/git-preflight-gate
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: PyYAML>=6.0
12
+ Provides-Extra: ci-authority-gate
13
+ Requires-Dist: PyYAML==6.0.3; extra == "ci-authority-gate"
14
+ Dynamic: license-file
15
+
16
+ <!-- HUELLA-README-EN-20261105 -->
17
+ **Language:** English · [Español](README.es.md)
18
+
19
+ # CI Authority Gate
20
+
21
+ **Repository:** [`git-preflight-gate`](https://github.com/alexisromtz-lab/git-preflight-gate) · **Marketplace:** [Install the action](https://github.com/marketplace/actions/ci-authority-gate)
22
+
23
+ **An integrity gate for autonomous AI agents: so that a bot does not dirty the tree
24
+ and does not commit concurrency collisions either.**
25
+
26
+ Before an agent writes a line, the gate demands a destination tuple —
27
+ `TARGET` (absolute) · `BLOB_PRE` · `HUELLA` · `LINEAS_PRE` — and returns a code that
28
+ **authorizes or blocks** the write. Without a prior run ending in `RC 0`, nothing is written.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install git-preflight-gate # Python CI gate (PyYAML comes with it)
34
+ ci-authority-gate /path/to/your/repo # RC 0 = pass · RC > 0 = fail-closed, cause printed
35
+ ```
36
+
37
+ Two doors, measured separately — do not confuse them:
38
+
39
+ | door | command | what it needs |
40
+ |---|---|---|
41
+ | **CI authority gate** | `pip install git-preflight-gate` | Python ≥ 3.9 + PyYAML (pip resolves it) |
42
+ | **pre-write gate (bash)** | `curl -fsSL https://raw.githubusercontent.com/alexisromtz-lab/git-preflight-gate/main/preflight-gate.sh -o preflight-gate.sh` | bash + git, **zero dependencies** |
43
+
44
+ The wheel ships the Python gate (`sim/`); the bash gate ships in the source distribution and in the repository, and is deliberately dependency-free so it can run on a machine with nothing installed. Neither is a wrapper around the other.
45
+
46
+ ### The pre-write gate, at the shell
47
+
48
+ ```bash
49
+ bash preflight-gate.sh /absolute/path/file.py <blob_pre> <exclusive_marker> <lineas_pre>
50
+ ```
51
+
52
+ ## The codes (fail-closed)
53
+
54
+ | RC | meaning |
55
+ |---:|---|
56
+ | `0` | PRE-FLIGHT OK — the target accepts an edit on `BLOB_PRE` |
57
+ | `10` | the object does not exist (creation) |
58
+ | `11` | **the marker already lives in the target** ⇒ collision: the row is already written |
59
+ | `12` | measured blob ≠ contract ⇒ the tree moved |
60
+ | `13` | dirty git state ⇒ unadjudicated modifications |
61
+ | `14` | the target is not inside a git tree ⇒ the gate cannot verify identity |
62
+ | `15` | line count differs from the contract |
63
+ | `20` | misuse (incomplete tuple, or `TARGET` not absolute) |
64
+
65
+ Every code comes out **with its cause printed**. A correct `RC` for the wrong reason is not a gate: it is a coincidence.
66
+
67
+ ## Why `RC 11` is the case that matters
68
+
69
+ The most expensive defect of an agent is not picking the wrong path: it is **writing again something another hand already wrote**, and reporting success. The marker is an exclusive literal fragment of the text to be inserted; if it is already in the target, the write is rejected before the file is touched. The gate does not guess intent: it denies the collision.
70
+
71
+ ## Why `TARGET` must be absolute
72
+
73
+ The gate rebases with `git -C <directory>` to measure the blob and the state. A relative path would be counted from **two different bases** (`cwd` and the object's directory) and could hash *another* file that does exist. Fail-closed: relative path ⇒ `RC 20`.
74
+
75
+ ## Why `RC 14` exists
76
+
77
+ Without a git tree, `git status` returns 128 with empty output. A naive gate reads that empty output as "clean" and goes green. Here the impossibility is declared: without a tree there is no blob identity worth anything.
78
+
79
+ ## Case bank
80
+
81
+ ```bash
82
+ bash tests/must_fail.sh
83
+ ```
84
+
85
+ 9 cases, **9/9**, each verifying the code **and** the cause: `20` (incomplete tuple), `20` (relative path), `10`, `11`, `12`, `15`, `13`, `14`, and the positive control `0`. A bank without a positive control only proves the instrument knows how to say no.
86
+
87
+ ## Out-of-band telemetry
88
+
89
+ Each run records 7 fields (`hora_utc target blob_pre blob_medido rc hash_arbol_ini hash_arbol_fin`) in `${GATE_TELEMETRIA_LOG:-$HOME/preflight-telemetria.log}`. Telemetry **cannot move the RC**, **cannot abort** and **cannot refresh the index** (`--no-optional-locks`). The path is never relative: a log inside the tree becomes an untracked file, and then the gate ends up measuring its own telemetry — the instrument counting itself.
90
+
91
+ ## Declared limits
92
+
93
+ - On a nonexistent `TARGET` the gate stops at `RC 10` **before** comparing blob and lines: in the creation branch, the tuple is a **contract**, not a measurement. It is declared, not repaired (fail-closed is correct).
94
+ - It does not judge content: it certifies identity and collision, not correctness.
95
+
96
+ ## The root Action: CI Authority Gate
97
+
98
+ The `action.yml` at this repository's root is a **composite action** running a second instrument, `sim/gate_workflows_ci.py`: a structural verifier of the authority topology in `.github/workflows/`. Five counting invariants, seven typed causes, `RC = min(causes)`, `UNIVERSO_VACIO` = cause `31` (zero files is failure, not approval).
99
+
100
+ Measured identity of the instrument copied into this tree:
101
+
102
+ | property | value |
103
+ |---|---|
104
+ | lines / bytes | 615 / 21470 |
105
+ | git blob | `ff9c69a944e7965b3fda0901f7745d80a9e85c7c` |
106
+ | sha256-16 | `073954a84f3c84fd` |
107
+
108
+ Using it as a consumer:
109
+
110
+ ```yaml
111
+ - uses: alexisromtz-lab/git-preflight-gate@v0.1.4
112
+ with:
113
+ workflows-dir: .github/workflows
114
+ fail-on-red: "true"
115
+ ```
116
+
117
+ <!-- HUELLA-RELEASE-V013-20261031 -->
118
+ `v0.1.3`, `v0.1.2` and `v0.1.1` run the same `action.yml` (`9ef420ec`) and the same `sim/gate_workflows_ci.py` (`ff9c69a9`): the gate's code did not change across the three. What changes is the adoption wrapper — `v0.1.2` added `templates/platino-gate.yml` and `v0.1.3` added `action-selftest.yml`, the manifest alignment and this documentation. If you pin by SHA, use the commit — not the tag.
119
+
120
+ The four inputs (`workflows-dir`, `root`, `python-bin`, `fail-on-red`) and the two
121
+ outputs (`rc`, `veredicto`) preserve the contract of the `action.yml` that this
122
+ repository **replaces**: any CI that pointed at the root action of a working repository
123
+ migrates by changing only the repository name in `uses:`; the rest of the `with:` block
124
+ does not change.
125
+
126
+ The action does not guess the domain: the instrument resolves a single point, `<root>/.github/workflows`. That is why `workflows-dir` routes to `--file` when the path exists and to `--root` when it does not. A `--root` that accepted any directory with YAML files would be editing the ruler so the demo fits.
127
+
128
+ Single dependency: `PyYAML==6.0.3`, declared as an optional extra (`pip install ".[ci-authority-gate]"`). The rest is stdlib. The instrument does not read the network, does not write files, does not use randomness and does not consult the clock.
129
+
130
+ Installable without cloning the repository, from the release wheel. The
131
+ `ci-authority-gate` extra is mandatory for the CLI: without PyYAML the instrument does
132
+ not measure and exits `RC=30` (fail-closed), not green.
133
+
134
+ ```bash
135
+ pip install "git-preflight-gate[ci-authority-gate] @ https://github.com/alexisromtz-lab/git-preflight-gate/releases/download/v0.1.4/git_preflight_gate-0.1.4-py3-none-any.whl"
136
+ ci-authority-gate --self-test # built-in must-fail bank; touches no disk
137
+ ci-authority-gate --root . # measures <root>/.github/workflows and returns its RC
138
+ ```
139
+
140
+ <!-- HUELLA-README-V014-20261106 -->
141
+ `v0.1.4` does not change the gate: `action.yml` (`9ef420ec`) and
142
+ `sim/gate_workflows_ci.py` (`ff9c69a9`) are the same blobs as in `v0.1.3`. What changes
143
+ is the package: `MANIFEST.in` makes the sdist carry the product, and the manifest
144
+ declares the `sim` package and the `ci-authority-gate` script. Before this change the
145
+ wheel was 6 768 B of `dist-info` only and no CLI existed. It is not on PyPI yet: the
146
+ only `pip` path today is the release URL.
147
+
148
+ Measured positive control: workflow with `permissions: contents: read` and `uses` pinned by full SHA ⇒ `CAUSAS PRESENTES: ninguna`, `VEREDICTO: PASA`, `RC=0`. Negative control: `pull_request_target` + `permissions: write-all` + `curl | bash` + `actions/checkout@v4` ⇒ `CAUSAS PRESENTES: [1, 2, 5]`, `RC=1`. Empty universe ⇒ `CAUSAS PRESENTES: [31]`, `RC=31`.
149
+
150
+ ## The two instruments are not to be confused
151
+
152
+ | instrument | question | form |
153
+ |---|---|---|
154
+ | `preflight-gate.sh` | may this agent write *this file* now? | bash CLI, target tuple |
155
+ | `sim/gate_workflows_ci.py` | does this CI topology concentrate authority without control? | composite action + Python CLI |
156
+
157
+ The first protects a write; the second audits a workflow tree. They share the doctrine (fail-closed, printed cause, zero files = declared impossibility), not the code.
158
+
159
+ ## Install it in your repository in 1 minute
160
+
161
+ <!-- HUELLA-BADGE-PLATINO-README-20261031 -->
162
+
163
+ ```bash
164
+ mkdir -p .github/workflows
165
+ curl -fsSL https://raw.githubusercontent.com/alexisromtz-lab/git-preflight-gate/main/templates/platino-gate.yml \
166
+ -o .github/workflows/platino-gate.yml
167
+ git add .github/workflows/platino-gate.yml
168
+ git commit -m "ci: platino preflight gate"
169
+ git push
170
+ ```
171
+
172
+ That is all: the workflow fires on the next `push` or PR.
173
+
174
+ ### The file must be named `platino-gate.yml` (this is a contract, not advice)
175
+
176
+ If you rename it — `ci.yml`, `gate.yml`, `platino-gate.yaml` — the first job fails closed with `RC 20` and tells you why. The reason is measured, not aesthetic: the status badge resolves **by file name** (`.../workflow/status/YOUR-REPO/YOUR-REPO/platino-gate.yml`). With another name, the badge would read *your* CI workflow, not this gate, and would publish a green that measures nothing. The contract lives inside the workflow, not on this page.
177
+
178
+ Falsified by `tests/test_platino_gate_template.py`: 5 name cases and 5 structural cases, `RC 0` only if all 10 agree; a template mutated to accept any `.yml` returns `RC 1` naming the offending row.
179
+
180
+ ## The badge
181
+
182
+ When the run finishes, the **Summary** tab publishes the badge already filled in with your `owner/repo`. There are two, and they are not the same:
183
+
184
+ | badge | what it claims | permissions it asks for |
185
+ |---|---|---|
186
+ | **status** (dynamic) | the real result of the most recent run of `platino-gate.yml` | `contents: read` |
187
+ | **seal** (static) | "this repository passed the gate" | `contents: read` |
188
+
189
+ ```markdown
190
+ [![Platino Preflight Gate](https://img.shields.io/github/actions/workflow/status/OWNER/REPO/platino-gate.yml?label=preflight%20gate&branch=main)](https://github.com/alexisromtz-lab/la-auditora)
191
+
192
+ [![Audited by Platino Labs](https://img.shields.io/badge/audited_by-Platino_Labs-6f42c1)](https://github.com/alexisromtz-lab/la-auditora)
193
+ ```
194
+
195
+ The status one cannot lie: GitHub draws it from the run. **The seal can**: it is a literal anyone can paste in green without having run anything. Use it only when the run is green — the gate cannot verify that for you.
196
+
197
+ **Zero write permissions, in both cases.** The workflow does not touch your README, does not ask for `contents: write` and needs no service of ours: the badge is a `shields.io` URL that reads the public status of your own workflow. There is no endpoint to host, no token to expire.
198
+
199
+ ### What it measures and what it does not
200
+
201
+ | measures | does not measure |
202
+ |---|---|
203
+ | authority topology in `.github/workflows/`: `permissions`, triggers, `uses` pinned by SHA, execution sinks | whether your code does what it says |
204
+ | seven typed causes, `RC = min(causes)`, `UNIVERSO_VACIO` = cause `31` | default `GITHUB_TOKEN` permissions at organization level |
205
+ | the tree you have, at the commit that runs | OIDC / Trusted Publishing trust in the destination registry |
206
+
207
+ The second block is printed on every run, under `RESIDUO DECLARADO`. A gate that does not declare its limits is not a gate: it is a promise.
208
+
209
+ ### How the wrapper tests itself
210
+
211
+ <!-- HUELLA-BADGE-SELFTEST-DOC-20261031 -->
212
+ `action.yml` is not tested by reading its YAML: it is executed. On every push to `main`, the workflow [`action-selftest.yml`](.github/workflows/action-selftest.yml) runs the action twice with `uses: ./` — once on this repository itself, where it demands `rc=0` / `PASA`, and once on a poisoned fixture in `tests/fixtures/sumidero`, where it demands `rc=2` / `SUMIDERO_IMPUDO`. If the extra's installation (`pip install "<action_dir>[ci-authority-gate]"`) or the input routing breaks, it goes red here before going red on your runner.
213
+
214
+ A self-test that only knows how to be green discriminates nothing: that is why the second case demands the action **fail** with the correct cause.
215
+
216
+ ## License
217
+
218
+ MIT. See `LICENSE`.
@@ -0,0 +1,8 @@
1
+ git_preflight_gate-0.1.6.dist-info/licenses/LICENSE,sha256=FiSMHs2bDfBb2jb4R3qAR6MRDeku8203wSaPlRg5tic,1069
2
+ sim/__init__.py,sha256=tkOQZJGTK4hlzrVJJkWJNcaAFuFhj3geBw8R79BS_a4,428
3
+ sim/gate_workflows_ci.py,sha256=tCpdndGc7RMPOtjd-IsCwOC8C-YopxFT6wiVkjK9bck,29941
4
+ git_preflight_gate-0.1.6.dist-info/METADATA,sha256=Hgj6cY4dYH8jPJoY17FsS0mPBqJIzC1XE7kBZ6tIj48,12834
5
+ git_preflight_gate-0.1.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ git_preflight_gate-0.1.6.dist-info/entry_points.txt,sha256=-KoZKb1gLlgvyqHZ2SwTeHJhYIage76hBTF_5M1XUAs,65
7
+ git_preflight_gate-0.1.6.dist-info/top_level.txt,sha256=lf-JWfiGnB0ZZ1Sd9fz8Zh9R5MCupexpcxpg21zinzs,4
8
+ git_preflight_gate-0.1.6.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ci-authority-gate = sim.gate_workflows_ci:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platino Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sim
sim/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Paquete del gate estructural de CI (`gate_workflows_ci`).
2
+
3
+ HUELLA-SIM-PAQUETE-20261106: el manifiesto declara este paquete para que el
4
+ wheel lleve el instrumento y `pip install` deje un CLI en el PATH. El action
5
+ composite NO lo usa: invoca el instrumento por ruta absoluta, porque en un
6
+ runner `github.action_path` apunta al checkout de la accion, no a
7
+ site-packages. Las dos rutas conviven y ninguna depende de la otra.
8
+ """
@@ -0,0 +1,813 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ gate_workflows_ci.py
5
+ ====================
6
+ Verificador estructural DETERMINISTA de la topologia de autoridad en pipelines
7
+ de CI. Analiza el AST de los YAML de .github/workflows/ y aplica CINCO
8
+ invariantes contables. No lee red, no escribe archivos, no usa aleatoriedad,
9
+ no consulta el reloj.
10
+
11
+ INVARIANTE CENTRAL (forma corregida):
12
+
13
+ Autoridad(Job) > 0 => Ejecucion(repo U deps)(Job) = vacio
14
+
15
+ No se afirma "cero ejecucion": la autoridad tiene que ejecutar ALGO (descargar
16
+ un artefacto, correr el cliente nativo). Lo que se prohibe es que ese algo
17
+ provenga del repositorio o de sus dependencias.
18
+
19
+ UNIVERSO DECLARADO: .github/workflows/*.yml + .github/workflows/*.yaml
20
+ MOTOR: python3 stdlib + PyYAML (safe_load) -- sin red, sin escritura, sin azar
21
+ REGLA DE VEREDICTO: RC 0 (PASA) | RC > 0 (FALLO CERRADO, causa tipada)
22
+
23
+ TABLA DE CAUSAS (RC):
24
+ 1 AUTORIDAD_CONTAMINADA Job con permisos de ESCRITURA que ejecuta codigo
25
+ de terceros mediante `run:` (gestor de paquetes,
26
+ interprete, descarga o mutacion de VCS). Un
27
+ cliente nativo de publicacion (ALLOWLIST_SINK)
28
+ no cuenta como ejecucion: publica artefactos.
29
+ 2 SUMIDERO_IMPUDO Job sumidero (`id-token: write`) que hace
30
+ `actions/checkout`, invoca herramientas dinamicas
31
+ (npx/pipx) o no consume exclusivamente artefactos
32
+ descargados (`actions/download-artifact`).
33
+ 3 DISPARADOR_ENVENENADO Trigger `pull_request_target` / `workflow_run`
34
+ con checkout explicito del commit del PR / head
35
+ dentro de un contexto con secretos o autoridad.
36
+ 4 SECRETO_EXPUESTO `${{ secrets.* }}` interpolado dentro de un bloque
37
+ `run:` (el valor entra al argv y al log), o en
38
+ `env:` / `with:` de un job con disparador
39
+ privilegiado que alcanza contenido no confiable.
40
+ Un secreto pasado como INPUT de una accion no es
41
+ exposicion: es el mecanismo documentado. Ver el
42
+ dominio declarado de RC 4 mas abajo.
43
+ 5 REF_MUTABLE `uses:` con tag flotante (@v*, @main, @master) en
44
+ vez de SHA-1 de 40 caracteres.
45
+ 31 UNIVERSO_VACIO No hay archivos de workflow que medir.
46
+ 32 YAML_INVALIDO El archivo no parsea.
47
+
48
+ RESIDUO DECLARADO (PLANO CIEGO FUERA DEL ARCHIVO):
49
+ 1. Permisos por defecto del GITHUB_TOKEN a nivel de repositorio/organizacion.
50
+ 2. Politicas de confianza OIDC / Trusted Publishing en el registro destino.
51
+ 3. Ejecucion de codigo nativo no aislado en runtime (modificacion de deps).
52
+ 4. Workflows reutilizables (`workflow_call`) y acciones compuestas alojadas
53
+ en repositorios ajenos.
54
+ => El instrumento certifica la AUSENCIA de la forma `autoridad contaminada`
55
+ en el archivo que PUEDE leer. NO certifica el plano que no ve.
56
+
57
+ DOMINIO DECLARADO DE RC 1 (ancho, no umbral):
58
+ `actions/checkout` por si solo NO dispara RC 1. RC 1 mide EJECUCION
59
+ (`run:`), no presencia del arbol. La presencia del arbol en un job con
60
+ autoridad de publicacion es RC 2 (SUMIDERO_IMPUDO). La particion es
61
+ declarada, no silenciosa.
62
+ ALLOWLIST_SINK: el cliente nativo de publicacion (`npm publish ...`) queda
63
+ fuera del computo de ejecucion de terceros, porque el sumidero canonico
64
+ TIENE que ejecutarlo. Es una restriccion de DOMINIO declarada, no una
65
+ bajada de umbral: el conjunto admitido es exactamente el cliente nativo.
66
+
67
+ DOMINIO DECLARADO DE RC 4 (secreto + sumidero):
68
+ El defecto es la EXPOSICION, no la PRESENCIA del secreto. Hay dos sumideros
69
+ y solo dos:
70
+ SINK_SHELL -> `${{ secrets.* }}` interpolado dentro de `run:`: el valor
71
+ entra al argv del proceso y al log del step, y habilita
72
+ inyeccion de shell. Siempre RC 4.
73
+ SINK_CONTEXTO -> `${{ secrets.* }}` en `env:` (global o de job) o en `with:`
74
+ de un paso, EN UN JOB cuyo disparador es privilegiado
75
+ (`pull_request_target` / `workflow_run`) y que hace checkout
76
+ de una ref no confiable. RC 4.
77
+ Un secreto en `env:` de un job ordinario, o como INPUT (`with:`) de una accion
78
+ SHA-pinada, NO es exposicion: es la forma documentada de dar una credencial.
79
+ Se publica como OBSERVACION (se vio y no es sumidero), no como hallazgo: el
80
+ lector tiene que poder distinguir "no lo vi" de "lo vi y no lo es".
81
+ Medido 2026-11-06 sobre 47 workflows de 6 repos maduros: 23 jobs con secreto,
82
+ 23/23 en `with:`, 0/23 en `run:`. Con la regla anterior el 100 % de la clase
83
+ era falso positivo (HUELLA-RC4-SUMIDERO-20261106).
84
+ """
85
+
86
+ import argparse
87
+ import io
88
+ import json
89
+ import os
90
+ import re
91
+ import sys
92
+
93
+ try:
94
+ import yaml
95
+ except Exception as _e: # pragma: no cover
96
+ sys.stderr.write("MOTOR INCOMPLETO: PyYAML ausente (%s). Fail-closed.\n" % (_e,))
97
+ raise SystemExit(30)
98
+
99
+ MOTOR = "python3 stdlib + PyYAML %s (sin red, sin escritura, sin azar)" % (
100
+ getattr(yaml, "__version__", "?"),
101
+ )
102
+ UNIVERSO = ".github/workflows/*.yml + .github/workflows/*.yaml del arbol objetivo"
103
+ INVARIANTE = "Autoridad(Job) > 0 => Ejecucion(repo U deps)(Job) = vacio"
104
+ REGLA = "RC 0 (PASA) | RC > 0 (FALLO CERRADO, causa tipada)"
105
+
106
+ RESIDUO = (
107
+ "Permisos por defecto del GITHUB_TOKEN a nivel de repositorio u organizacion.",
108
+ "Politicas de confianza OIDC / Trusted Publishing en el registro destino (npm, PyPI, AWS).",
109
+ "Modificacion de dependencias en runtime si se ejecuta codigo nativo no aislado.",
110
+ "Workflows reutilizables (workflow_call) y acciones compuestas alojadas en repositorios ajenos.",
111
+ )
112
+
113
+ CAUSAS = {
114
+ 1: "AUTORIDAD_CONTAMINADA",
115
+ 2: "SUMIDERO_IMPUDO",
116
+ 3: "DISPARADOR_ENVENENADO",
117
+ 4: "SECRETO_EXPUESTO",
118
+ 5: "REF_MUTABLE",
119
+ 31: "UNIVERSO_VACIO",
120
+ 32: "YAML_INVALIDO",
121
+ }
122
+
123
+ # ----------------------------------------------------------------------------
124
+ # Patrones. Todos son LEXICOS y se aplican linea por linea (dominio declarado:
125
+ # una linea de `run:` es la unidad de juicio).
126
+ # ----------------------------------------------------------------------------
127
+ PAT_EXEC = re.compile(
128
+ r"\b(npm|pnpm|yarn|npx|pipx|pip3|pip|cargo|rustc|go|gem|bundle|composer"
129
+ r"|make|cmake|node|deno|bun|python3|python|ruby|php|java|mvn|gradle|dotnet)\b"
130
+ )
131
+ PAT_FETCH = re.compile(r"\b(curl|wget)\b")
132
+ PAT_GITMUT = re.compile(r"\bgit\s+(push|commit|tag|remote|config)\b")
133
+ PAT_GH = re.compile(r"(^|[\s;&|(])gh\s+[a-z]")
134
+ # HUELLA-SINK-NATIVO-20261106: el allowlist cubria solo `npm publish`, asi que
135
+ # todo otro cliente nativo de publicacion (`gh release create`, `twine upload`,
136
+ # `docker push`, `cargo|poetry|uv publish`) contaba como "ejecuta codigo de
137
+ # terceros" y disparaba RC 1 en el patron de release canonico y endurecido.
138
+ # Medido en `pallets/flask`: `.github/workflows/publish.yaml :: create-release`
139
+ # (`permissions: contents: write`, sin checkout, `gh release create --draft`)
140
+ # -> RC 1 falso. La allowlist NO abre la puerta a `gh api` ni a `gh` en general:
141
+ # solo a los verbos de publicacion, que no ejecutan codigo del repositorio.
142
+ PAT_SINK_OK = re.compile(
143
+ r"^\s*(?:"
144
+ r"npm\s+publish\b"
145
+ r"|pnpm\s+publish\b"
146
+ r"|yarn\s+npm\s+publish\b"
147
+ r"|twine\s+upload\b"
148
+ r"|gh\s+release\s+(?:create|upload|edit)\b"
149
+ r"|docker\s+push\b"
150
+ r"|cargo\s+publish\b"
151
+ r"|poetry\s+publish\b"
152
+ r"|uv\s+publish\b"
153
+ r")"
154
+ )
155
+ PAT_DINAMICO = re.compile(r"\b(npx|pipx)\b")
156
+ PAT_SECRETS = re.compile(r"\$\{\{\s*secrets\.[A-Za-z0-9_]")
157
+ PAT_SHA40 = re.compile(r"^[0-9a-f]{40}$")
158
+ PAT_UNTRUSTED_REF = re.compile(r"github\.event\.(pull_request\.head|workflow_run\.head)")
159
+
160
+
161
+ def _walk_str(obj):
162
+ """Todos los literales de cadena de un subarbol YAML (claves incluidas)."""
163
+ if isinstance(obj, str):
164
+ yield obj
165
+ elif isinstance(obj, dict):
166
+ for k, v in obj.items():
167
+ yield str(k)
168
+ yield from _walk_str(v)
169
+ elif isinstance(obj, (list, tuple)):
170
+ for x in obj:
171
+ yield from _walk_str(x)
172
+
173
+
174
+ def _clave_on(wf):
175
+ """PyYAML (YAML 1.1) convierte la clave `on` en el booleano True."""
176
+ for k in (True, "on", "On", "ON"):
177
+ if k in wf:
178
+ return wf[k]
179
+ return None
180
+
181
+
182
+ def _triggers(val):
183
+ if val is None:
184
+ return set()
185
+ if isinstance(val, str):
186
+ return {val}
187
+ if isinstance(val, (list, tuple)):
188
+ return {str(x) for x in val}
189
+ if isinstance(val, dict):
190
+ return {str(k) for k in val.keys()}
191
+ return set()
192
+
193
+
194
+ def _mapa_perm(p):
195
+ if p is None:
196
+ return None
197
+ if isinstance(p, str):
198
+ v = p.strip().lower()
199
+ if v == "write-all":
200
+ return {"*": "write"}
201
+ if v == "read-all":
202
+ return {"*": "read"}
203
+ return {v: "write"}
204
+ if isinstance(p, dict):
205
+ return {str(k): str(v).strip().lower() for k, v in p.items()}
206
+ return None
207
+
208
+
209
+ def scopes_write(mapa):
210
+ if not mapa:
211
+ return set()
212
+ return {k for k, v in mapa.items() if v == "write"}
213
+
214
+
215
+ def _pasos(job):
216
+ out = []
217
+ for s in (job.get("steps") or []):
218
+ if isinstance(s, dict):
219
+ out.append(s)
220
+ return out
221
+
222
+
223
+ def _runs(job):
224
+ return [str(s.get("run", "")) for s in _pasos(job) if "run" in s]
225
+
226
+
227
+ def _uses(job):
228
+ return [str(s.get("uses", "")) for s in _pasos(job) if "uses" in s]
229
+
230
+
231
+ def _todos_los_uses(wf):
232
+ out = []
233
+ jobs = wf.get("jobs")
234
+ if isinstance(jobs, dict):
235
+ for job in jobs.values():
236
+ if not isinstance(job, dict):
237
+ continue
238
+ if isinstance(job.get("uses"), str):
239
+ out.append(job["uses"])
240
+ out.extend(_uses(job))
241
+ return out
242
+
243
+
244
+ def _tiene_checkout(job):
245
+ return any(u.startswith("actions/checkout") for u in _uses(job))
246
+
247
+
248
+ def _tiene_download(job):
249
+ return any(u.startswith("actions/download-artifact") for u in _uses(job))
250
+
251
+
252
+ def _secretos_por_ubicacion(wf, job):
253
+ """Ubica cada `${{ secrets.* }}` en su sumidero real.
254
+
255
+ HUELLA-RC4-SUMIDERO-20261106 (medido 2026-11-06): RC 4 medía PRESENCIA de
256
+ `secrets.*`, no EXPOSICION. Sobre 47 workflows de 6 repos maduros
257
+ (pallets/flask, pydantic/pydantic, python-poetry/poetry, saleor/saleor,
258
+ wagtail/wagtail, encode/httpx) hay 23 jobs con secreto y 23/23 lo pasan en
259
+ `with:` a una accion SHA-pinada (codecov-action, create-github-app-token,
260
+ configure-aws-credentials, create-pull-request): 0/23 en `run:`. La forma
261
+ documentada de dar una credencial a una accion se reportaba como fuga: el
262
+ 100 % de la clase era falso positivo.
263
+
264
+ Devuelve (en_run, en_insumo, obs):
265
+ en_run -- textos `run:` que interpolan un secreto (SINK_SHELL)
266
+ en_insumo -- valores secretos en `env:` (global / de job) o `with:` (SINK_CONTEXTO)
267
+ obs -- [(ubicacion, fuente)]: el secreto se vio y no es sumidero por si mismo
268
+ """
269
+ en_run, en_insumo, obs = [], [], []
270
+ for etiqueta, src in (("env global", wf.get("env")), ("env de job", job.get("env"))):
271
+ if src:
272
+ vals = [x for x in _walk_str(src) if PAT_SECRETS.search(x)]
273
+ if vals:
274
+ en_insumo.extend(vals)
275
+ obs.append((etiqueta, etiqueta))
276
+ for s in _pasos(job):
277
+ if "run" in s and PAT_SECRETS.search(str(s.get("run", ""))):
278
+ en_run.append(str(s.get("run", "")))
279
+ if "with" in s:
280
+ vals = [x for x in _walk_str(s.get("with")) if PAT_SECRETS.search(x)]
281
+ if vals:
282
+ en_insumo.extend(vals)
283
+ obs.append(("with:", "with: %s" % (str(s.get("uses", "?"))[:60],)))
284
+ return en_run, en_insumo, obs
285
+
286
+
287
+ def _linea_ejecuta(linea):
288
+ """Una linea de shell ejecuta codigo de terceros?"""
289
+ s = linea.strip()
290
+ if not s or s.startswith("#"):
291
+ return False
292
+ if PAT_SINK_OK.match(s): # ALLOWLIST_SINK: cliente nativo de publicacion
293
+ return False
294
+ return bool(
295
+ PAT_EXEC.search(s) or PAT_FETCH.search(s) or PAT_GITMUT.search(s) or PAT_GH.search(s)
296
+ )
297
+
298
+
299
+ def ref_mutable(valor):
300
+ v = valor.strip()
301
+ if v.startswith("./") or v.startswith(".\\"):
302
+ return False
303
+ if v.startswith("docker://"):
304
+ return "@sha256:" not in v
305
+ if "@" not in v:
306
+ return True
307
+ return not PAT_SHA40.match(v.rsplit("@", 1)[1])
308
+
309
+
310
+ # ----------------------------------------------------------------------------
311
+ # NUCLEO
312
+ # ----------------------------------------------------------------------------
313
+ def analizar(texto, nombre):
314
+ """Devuelve la lista de hallazgos [(rc, causa, archivo, job, detalle)]."""
315
+ return analizar_detallado(texto, nombre)[0]
316
+
317
+
318
+ def analizar_detallado(texto, nombre):
319
+ """Devuelve (hallazgos, observaciones).
320
+
321
+ Las observaciones son secretos que el instrumento VIO y no son sumidero por
322
+ si mismos. Viajan aparte para que `rc` no las cuente como defecto y para
323
+ que el lector pueda distinguir "no lo vi" de "lo vi y no lo es".
324
+ """
325
+ hs = []
326
+ obs = {}
327
+ try:
328
+ wf = yaml.safe_load(texto)
329
+ except yaml.YAMLError as e:
330
+ return [{"rc": 32, "causa": CAUSAS[32], "archivo": nombre, "job": "-",
331
+ "detalle": str(e)[:200]}], []
332
+ if not isinstance(wf, dict):
333
+ return [{"rc": 32, "causa": CAUSAS[32], "archivo": nombre, "job": "-",
334
+ "detalle": "raiz del YAML no es un mapping"}], []
335
+
336
+ wf_perm = _mapa_perm(wf.get("permissions"))
337
+ triggers = _triggers(_clave_on(wf))
338
+ pr_target = bool(triggers & {"pull_request_target", "workflow_run"})
339
+ clase_trigger = ",".join(sorted(triggers & {"pull_request_target", "workflow_run"}))
340
+
341
+ # ---- RC 5: barrido global de referencias
342
+ for v in _todos_los_uses(wf):
343
+ if ref_mutable(v):
344
+ hs.append({"rc": 5, "causa": CAUSAS[5], "archivo": nombre, "job": "-",
345
+ "detalle": "uses con ref flotante: %s" % (v[:90],)})
346
+
347
+ # HUELLA-RC4-SUMIDERO-20261106: el bloque `env:` global ya no emite RC 4 por
348
+ # si mismo. Lo mide cada job que lo hereda (el env global se propaga a todos
349
+ # los jobs), y solo cuenta como exposicion si el job tiene sumidero.
350
+
351
+ jobs = wf.get("jobs")
352
+ if not isinstance(jobs, dict):
353
+ jobs = {}
354
+
355
+ for jn in sorted(jobs.keys(), key=str):
356
+ job = jobs.get(jn)
357
+ if not isinstance(job, dict):
358
+ continue
359
+ perm = _mapa_perm(job.get("permissions")) if "permissions" in job else wf_perm
360
+ sw = scopes_write(perm)
361
+
362
+ # ---- RC 1: AUTORIDAD_CONTAMINADA
363
+ if sw:
364
+ malas = []
365
+ for cmd in _runs(job):
366
+ for linea in cmd.splitlines():
367
+ if _linea_ejecuta(linea):
368
+ malas.append(linea.strip())
369
+ if malas:
370
+ hs.append({"rc": 1, "causa": CAUSAS[1], "archivo": nombre, "job": str(jn),
371
+ "detalle": "scopes=%s ejecuta: %s"
372
+ % (",".join(sorted(sw)), malas[0][:90])})
373
+
374
+ # ---- RC 2: SUMIDERO_IMPUDO
375
+ if ("id-token" in sw) or ("*" in sw):
376
+ motivos = []
377
+ if _tiene_checkout(job):
378
+ motivos.append("actions/checkout")
379
+ if any(PAT_DINAMICO.search(c) for c in _runs(job)):
380
+ motivos.append("herramienta dinamica (npx/pipx)")
381
+ if not _tiene_download(job):
382
+ motivos.append("sin actions/download-artifact")
383
+ if motivos:
384
+ hs.append({"rc": 2, "causa": CAUSAS[2], "archivo": nombre, "job": str(jn),
385
+ "detalle": "; ".join(motivos)})
386
+
387
+ # ---- secretos: ubicacion por sumidero (HUELLA-RC4-SUMIDERO-20261106)
388
+ en_run, en_insumo, obs_sec = _secretos_por_ubicacion(wf, job)
389
+
390
+ untrusted = False
391
+ if pr_target and _tiene_checkout(job):
392
+ refs = []
393
+ for s in _pasos(job):
394
+ if str(s.get("uses", "")).startswith("actions/checkout"):
395
+ w = s.get("with")
396
+ if isinstance(w, dict):
397
+ refs.append(str(w.get("ref", "")))
398
+ untrusted = any(PAT_UNTRUSTED_REF.search(r) for r in refs)
399
+
400
+ # ---- RC 3: DISPARADOR_ENVENENADO
401
+ contexto = bool(sw) or bool(en_run or en_insumo)
402
+ if pr_target and _tiene_checkout(job) and untrusted and contexto:
403
+ hs.append({"rc": 3, "causa": CAUSAS[3], "archivo": nombre, "job": str(jn),
404
+ "detalle": "trigger %s + checkout de ref no confiable en contexto privilegiado"
405
+ % (clase_trigger,)})
406
+
407
+ # ---- RC 4: SECRETO_EXPUESTO (secreto + sumidero, no presencia)
408
+ if en_run:
409
+ hs.append({"rc": 4, "causa": CAUSAS[4], "archivo": nombre, "job": str(jn),
410
+ "detalle": "secrets interpolado en run: (%d bloque(s)): el valor entra al argv"
411
+ % (len(en_run),)})
412
+ elif en_insumo and pr_target and untrusted:
413
+ hs.append({"rc": 4, "causa": CAUSAS[4], "archivo": nombre, "job": str(jn),
414
+ "detalle": "secrets en env/with bajo %s + checkout de ref no confiable"
415
+ " (el instrumento no evalua if:/labels: una etiqueta de"
416
+ " mantenedor no cubre el relanzamiento por synchronize)"
417
+ % (clase_trigger,)})
418
+ else:
419
+ for sitio, fuente in obs_sec:
420
+ obs.setdefault((sitio, fuente), set()).add(str(jn))
421
+
422
+ observaciones = [
423
+ {"archivo": nombre, "ubicacion": k[0], "fuente": k[1],
424
+ "job": ", ".join(sorted(js)) if len(js) < 6 else "%d jobs" % (len(js),)}
425
+ for k, js in sorted(obs.items())
426
+ ]
427
+ return hs, observaciones
428
+
429
+
430
+ # ----------------------------------------------------------------------------
431
+ # BANCO MUST-FAIL INCORPORADO (sobre cadenas: CERO escrituras a disco)
432
+ # ----------------------------------------------------------------------------
433
+ SHA_CHECKOUT = "11bd71901bbe5b1630ceea73d27597364c9af683"
434
+ SHA_SETUPNODE = "39370e3970a6d050c480ffad4ff0ed4d3fdee5af"
435
+ SHA_UPLOAD = "65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08"
436
+ SHA_DOWNLOAD = "fa0a91b85d4f404e444e00e005971372dc801d16"
437
+
438
+ C0_CANONICO = """
439
+ name: publicar
440
+ on:
441
+ release:
442
+ types: [published]
443
+ permissions:
444
+ contents: read
445
+ jobs:
446
+ build:
447
+ runs-on: ubuntu-latest
448
+ permissions:
449
+ contents: read
450
+ steps:
451
+ - uses: actions/checkout@%s
452
+ - uses: actions/setup-node@%s
453
+ with:
454
+ node-version: "20"
455
+ - run: npm ci --ignore-scripts
456
+ - run: npm pack
457
+ - run: sha256sum ./*.tgz > SHASUMS256.txt
458
+ - uses: actions/upload-artifact@%s
459
+ with:
460
+ name: dist
461
+ path: |
462
+ *.tgz
463
+ SHASUMS256.txt
464
+ publish:
465
+ needs: [build]
466
+ runs-on: ubuntu-latest
467
+ permissions:
468
+ id-token: write
469
+ actions: read
470
+ steps:
471
+ - uses: actions/download-artifact@%s
472
+ with:
473
+ name: dist
474
+ - run: sha256sum -c SHASUMS256.txt
475
+ - run: npm publish --provenance ./*.tgz
476
+ """ % (SHA_CHECKOUT, SHA_SETUPNODE, SHA_UPLOAD, SHA_DOWNLOAD)
477
+
478
+ C1_AUTORIDAD = """
479
+ name: contaminado
480
+ on: push
481
+ jobs:
482
+ build:
483
+ runs-on: ubuntu-latest
484
+ permissions:
485
+ contents: write
486
+ steps:
487
+ - uses: actions/checkout@%s
488
+ - run: npm ci
489
+ """ % (SHA_CHECKOUT,)
490
+
491
+ # HUELLA-SINK-NATIVO-20261106: control positivo -- el release canonico de
492
+ # `pallets/flask` (autoridad de escritura, sin checkout, `gh release create`)
493
+ # tiene que PASAR. Antes de la allowlist daba RC 1.
494
+ C7_PUBLICADOR_NATIVO = """
495
+ name: publish
496
+ on:
497
+ push:
498
+ tags: ['*']
499
+ permissions: {}
500
+ jobs:
501
+ create-release:
502
+ runs-on: ubuntu-latest
503
+ permissions:
504
+ contents: write
505
+ steps:
506
+ - uses: actions/download-artifact@%s
507
+ with:
508
+ name: dist
509
+ path: dist/
510
+ - name: create release
511
+ run: gh release create --draft --repo ${GITHUB_REPOSITORY} ${GITHUB_REF_NAME} dist/*
512
+ env:
513
+ GH_TOKEN: ${{ github.token }}
514
+ """ % (SHA_DOWNLOAD,)
515
+
516
+ # ... y su must-fail: `gh` NO es un sink en bloque. Bajo la misma autoridad,
517
+ # `gh api` mutando la configuracion del repositorio sigue siendo RC 1.
518
+ C8_GH_API_EN_AUTORIDAD = """
519
+ name: contaminado-gh
520
+ on: push
521
+ jobs:
522
+ alterar:
523
+ runs-on: ubuntu-latest
524
+ permissions:
525
+ contents: write
526
+ steps:
527
+ - run: gh api -X DELETE repos/${GITHUB_REPOSITORY}/branches/main/protection
528
+ """
529
+
530
+ C2_SUMIDERO = """
531
+ name: sumidero
532
+ on:
533
+ release:
534
+ types: [published]
535
+ jobs:
536
+ publish:
537
+ runs-on: ubuntu-latest
538
+ permissions:
539
+ id-token: write
540
+ steps:
541
+ - run: npm publish --provenance ./*.tgz
542
+ """
543
+
544
+ C3_DISPARADOR = """
545
+ name: pr-target
546
+ on: pull_request_target
547
+ jobs:
548
+ lint:
549
+ runs-on: ubuntu-latest
550
+ permissions:
551
+ pull-requests: write
552
+ steps:
553
+ - uses: actions/checkout@%s
554
+ with:
555
+ ref: ${{ github.event.pull_request.head.sha }}
556
+ """ % (SHA_CHECKOUT,)
557
+
558
+ D1_ENV_SECRETO_SIN_SUMIDERO = """
559
+ name: env-secreto
560
+ on: push
561
+ env:
562
+ TOKEN: ${{ secrets.NPM_TOKEN }}
563
+ jobs:
564
+ build:
565
+ runs-on: ubuntu-latest
566
+ permissions:
567
+ contents: read
568
+ steps:
569
+ - run: echo ok
570
+ """
571
+
572
+ D2_ACCION_CON_SECRETO = """
573
+ name: inputo-de-accion
574
+ on: push
575
+ jobs:
576
+ build:
577
+ runs-on: ubuntu-latest
578
+ permissions:
579
+ contents: read
580
+ steps:
581
+ - uses: actions/checkout@%s
582
+ with:
583
+ repository: alexisromtz-lab/privado
584
+ token: ${{ secrets.REPO_TOKEN }}
585
+ """ % (SHA_CHECKOUT,)
586
+
587
+ D3_SECRETO_EN_RUN = """
588
+ name: secreto-en-shell
589
+ on: push
590
+ jobs:
591
+ build:
592
+ runs-on: ubuntu-latest
593
+ permissions:
594
+ contents: read
595
+ steps:
596
+ - run: |
597
+ curl -sS -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://example.test/hook
598
+ """
599
+
600
+ D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE = """
601
+ name: pr-target-con-secreto
602
+ on: pull_request_target
603
+ jobs:
604
+ comment:
605
+ runs-on: ubuntu-latest
606
+ permissions:
607
+ pull-requests: write
608
+ steps:
609
+ - uses: actions/checkout@%s
610
+ with:
611
+ ref: ${{ github.event.pull_request.head.sha }}
612
+ token: ${{ secrets.APP_TOKEN }}
613
+ """ % (SHA_CHECKOUT,)
614
+
615
+ C5_REF = """
616
+ name: flotante
617
+ on: push
618
+ jobs:
619
+ build:
620
+ runs-on: ubuntu-latest
621
+ permissions:
622
+ contents: read
623
+ steps:
624
+ - uses: actions/checkout@v4
625
+ - run: npm ci --ignore-scripts
626
+ """
627
+
628
+ C6_DOS_CAUSAS = """
629
+ name: sumidero-sucio
630
+ on:
631
+ release:
632
+ types: [published]
633
+ jobs:
634
+ publish:
635
+ runs-on: ubuntu-latest
636
+ permissions:
637
+ id-token: write
638
+ steps:
639
+ - uses: actions/checkout@%s
640
+ - uses: actions/download-artifact@%s
641
+ with:
642
+ name: dist
643
+ - run: npx --yes provenance-publish ./dist/*.tgz
644
+ """ % (SHA_CHECKOUT, SHA_DOWNLOAD)
645
+
646
+ BANCO = (
647
+ ("C0_CANONICO_DOS_JOBS", C0_CANONICO, 0, frozenset(), 0),
648
+ ("C1_AUTORIDAD_CONTAMINADA", C1_AUTORIDAD, 1, frozenset([1]), 0),
649
+ ("C2_SUMIDERO_IMPUDO", C2_SUMIDERO, 2, frozenset([2]), 0),
650
+ ("C3_DISPARADOR_ENVENENADO", C3_DISPARADOR, 3, frozenset([3]), 0),
651
+ ("C5_REF_MUTABLE", C5_REF, 5, frozenset([5]), 0),
652
+ ("C6_DOS_CAUSAS_A_LA_VEZ", C6_DOS_CAUSAS, 1, frozenset([1, 2]), 0),
653
+ ("C7_PUBLICADOR_NATIVO", C7_PUBLICADOR_NATIVO, 0, frozenset(), 0),
654
+ ("C8_GH_API_EN_AUTORIDAD", C8_GH_API_EN_AUTORIDAD, 1, frozenset([1]), 0),
655
+ ("D1_ENV_SECRETO_SIN_SUMIDERO", D1_ENV_SECRETO_SIN_SUMIDERO, 0, frozenset(), 1),
656
+ ("D2_ACCION_CON_SECRETO", D2_ACCION_CON_SECRETO, 0, frozenset(), 1),
657
+ ("D3_SECRETO_EN_RUN", D3_SECRETO_EN_RUN, 4, frozenset([4]), 0),
658
+ ("D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE", D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE, 3,
659
+ frozenset([3, 4]), 0),
660
+ )
661
+ CONTROL = "C0_CANONICO_DOS_JOBS"
662
+
663
+
664
+ def correr_banco():
665
+ filas = []
666
+ for nombre, texto, rc_esp, causas_esp, obs_esp in BANCO:
667
+ hs, obs = analizar_detallado(texto, "<memoria>/%s.yml" % nombre)
668
+ rc = min((h["rc"] for h in hs), default=0)
669
+ causas = frozenset(h["rc"] for h in hs)
670
+ ok = (rc == rc_esp) and (causas == causas_esp) and (len(obs) == obs_esp)
671
+ filas.append({
672
+ "caso": nombre,
673
+ "rc_esperado": rc_esp,
674
+ "rc_medido": rc,
675
+ "causas_esperadas": sorted(causas_esp),
676
+ "causas_medidas": sorted(causas),
677
+ "observaciones_esperadas": obs_esp,
678
+ "observaciones_medidas": len(obs),
679
+ "ok": ok,
680
+ "detalle": [h["detalle"] for h in hs],
681
+ })
682
+ banco_ok = all(f["ok"] for f in filas)
683
+ return filas, banco_ok
684
+
685
+
686
+ # ----------------------------------------------------------------------------
687
+ # SALIDA
688
+ # ----------------------------------------------------------------------------
689
+ def imprimir_cabecera():
690
+ print("UNIVERSO DECLARADO: %s" % UNIVERSO)
691
+ print("MOTOR: %s" % MOTOR)
692
+ print("REGLA DE VEREDICTO: %s" % REGLA)
693
+ print("INVARIANTE: %s" % INVARIANTE)
694
+ print("RESIDUO DECLARADO (PLANO CIEGO FUERA DE YAML):")
695
+ for r in RESIDUO:
696
+ print(" - %s" % r)
697
+
698
+
699
+ def _fmt_causas(s):
700
+ return "{rc:%s|%s}" % (
701
+ ",".join(str(x) for x in s) if s else "-",
702
+ ",".join(CAUSAS.get(x, "?") for x in s) if s else "ninguna",
703
+ )
704
+
705
+
706
+ def main(argv=None):
707
+ ap = argparse.ArgumentParser(
708
+ description="Gate estructural de CI: topologia de autoridad en .github/workflows/."
709
+ )
710
+ ap.add_argument("ruta", nargs="?", default=".", help="raiz del arbol objetivo (default: .)")
711
+ ap.add_argument("--root", dest="root", default=None, help="raiz explicita del arbol objetivo")
712
+ ap.add_argument("--file", dest="archivo", default=None, help="analizar UN archivo YAML")
713
+ ap.add_argument("--json", dest="json", action="store_true", help="salida JSON")
714
+ ap.add_argument("--self-test", dest="self_test", action="store_true",
715
+ help="correr el banco must-fail incorporado (sin tocar disco)")
716
+ ap.add_argument("--must-fail", dest="must_fail", action="store_true",
717
+ help="alias de --self-test: exige que cada causa provoque su RC")
718
+ ns = ap.parse_args(argv)
719
+
720
+ banco = ns.self_test or ns.must_fail
721
+
722
+ if banco:
723
+ filas, banco_ok = correr_banco()
724
+ if ns.json:
725
+ print(json.dumps({
726
+ "universo": UNIVERSO, "motor": MOTOR, "invariante": INVARIANTE,
727
+ "regla_de_veredicto": REGLA, "residuo_declarado": list(RESIDUO),
728
+ "causas": CAUSAS, "self_test": filas, "banco_ok": banco_ok,
729
+ "rc": 0 if banco_ok else 21,
730
+ }, indent=2, ensure_ascii=False))
731
+ else:
732
+ imprimir_cabecera()
733
+ print("")
734
+ print("BANCO MUST-FAIL (casos en memoria; el instrumento no escribe disco)")
735
+ print("%-28s %-20s %-20s %-6s %s" % ("CASO", "ESPERADO", "MEDIDO", "OBS", "OK"))
736
+ for f in filas:
737
+ print("%-28s %-20s %-20s %-6s %s" % (
738
+ f["caso"],
739
+ _fmt_causas(frozenset(f["causas_esperadas"])),
740
+ _fmt_causas(frozenset(f["causas_medidas"])),
741
+ "%d/%d" % (f["observaciones_medidas"], f["observaciones_esperadas"]),
742
+ "SI" if f["ok"] else "NO",
743
+ ))
744
+ print("")
745
+ for f in filas:
746
+ for d in f["detalle"]:
747
+ print(" [%s] %s" % (f["caso"], d))
748
+ print("")
749
+ print("banco_ok = %s" % banco_ok)
750
+ return 0 if banco_ok else 21
751
+
752
+ raiz = ns.root or ns.ruta
753
+ archivos = []
754
+ if ns.archivo:
755
+ archivos = [ns.archivo]
756
+ else:
757
+ d = os.path.join(raiz, ".github", "workflows")
758
+ if os.path.isdir(d):
759
+ for fn in sorted(os.listdir(d)):
760
+ if fn.endswith(".yml") or fn.endswith(".yaml"):
761
+ archivos.append(os.path.join(d, fn))
762
+
763
+ hallazgos = []
764
+ observaciones = []
765
+ if not archivos:
766
+ hallazgos.append({"rc": 31, "causa": CAUSAS[31], "archivo": "-", "job": "-",
767
+ "detalle": "0 archivos en %s: nada que medir" % (os.path.join(raiz, ".github", "workflows"),)})
768
+ else:
769
+ for ruta in archivos:
770
+ try:
771
+ with io.open(ruta, "r", encoding="utf-8") as fh:
772
+ texto = fh.read()
773
+ except Exception as e:
774
+ hallazgos.append({"rc": 32, "causa": CAUSAS[32], "archivo": ruta, "job": "-",
775
+ "detalle": "no legible: %s" % (e,)})
776
+ continue
777
+ hs_archivo, obs_archivo = analizar_detallado(texto, ruta)
778
+ hallazgos.extend(hs_archivo)
779
+ observaciones.extend(obs_archivo)
780
+
781
+ causas = sorted({h["rc"] for h in hallazgos})
782
+ rc = min(causas) if causas else 0
783
+
784
+ if ns.json:
785
+ print(json.dumps({
786
+ "universo": UNIVERSO, "motor": MOTOR, "invariante": INVARIANTE,
787
+ "regla_de_veredicto": REGLA, "residuo_declarado": list(RESIDUO),
788
+ "causas": CAUSAS, "raiz": raiz, "archivos": archivos,
789
+ "hallazgos": hallazgos, "causas_presentes": causas, "rc": rc,
790
+ "observaciones": observaciones,
791
+ }, indent=2, ensure_ascii=False))
792
+ else:
793
+ imprimir_cabecera()
794
+ print("")
795
+ print("RAIZ: %s" % raiz)
796
+ print("ARCHIVOS MEDIDOS: %d" % len(archivos))
797
+ for h in hallazgos:
798
+ print(" RC %-2d %-22s %s :: %s :: %s"
799
+ % (h["rc"], h["causa"], h["archivo"], h["job"], h["detalle"]))
800
+ print("")
801
+ print("OBSERVACIONES (secreto visto, sin sumidero: NO es hallazgo): %d"
802
+ % len(observaciones))
803
+ for o in observaciones:
804
+ print(" OBS %-22s %s :: %s (%s)"
805
+ % (o["ubicacion"], o["archivo"], o["fuente"], o["job"]))
806
+ print("")
807
+ print("CAUSAS PRESENTES: %s" % (causas if causas else "ninguna"))
808
+ print("VEREDICTO: %s RC=%d" % ("PASA" if rc == 0 else "FALLO CERRADO", rc))
809
+ return rc
810
+
811
+
812
+ if __name__ == "__main__":
813
+ raise SystemExit(main())