Una barrera verificable para los parches generados por IA

Una barrera verificable para los parches generados por IA

Un parche generado por IA puede pasar las pruebas y aun así cambiar una respuesta de API, tocar un archivo ajeno al encargo o debilitar una aserción. La propuesta de Akshat Raj es exigir que cada cambio lleve consigo un informe explícito: qué controles pasaron, cuáles fallaron y qué quedó sin verificar.

Vamos a construir esa puerta con Python y Git. No demostrará que el programa sea correcto; sí hará que la aceptación dependa de evidencias reproducibles, no de la confianza del agente en su propio trabajo. La barrera comparará una revisión base con el commit actual, limitará los archivos modificados, ejecutará una orden de pruebas y escribirá un informe JSON.

flowchart TD
    A["Parche en HEAD"] --> B["Comparar con base"]
    B --> C["Revisar alcance y diff"]
    C --> D["Ejecutar pruebas"]
    D --> E["Guardar informe JSON"]
    E --> F["Aceptar o rechazar"]

Preparar el repositorio y la barrera

El ejemplo está pensado para Python 3.10 o posterior y un repositorio Git. No requiere una API de modelos ni paquetes externos. La comparación se hace entre la revisión indicada y HEAD; por tanto, antes de ejecutar la puerta hay que incluir en un commit el parche que se quiere revisar. Los cambios sin confirmar quedan fuera de esa comparación.

Guarda el siguiente programa como proofgate.py en la raíz del repositorio. La orden de pruebas se entrega como argumentos al programa y se ejecuta sin pasar por un intérprete de shell.

#!/usr/bin/env python3
"""Comprobaciones básicas para un parche Git y un comando de pruebas."""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from pathlib import Path


def run_command(command: list[str], timeout: int) -> dict:
    """Ejecuta un proceso sin shell y conserva una salida acotada."""
    started = time.monotonic()
    try:
        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
            shell=False,
        )
        return {
            "command": command,
            "exit_code": result.returncode,
            "stdout": result.stdout[-12000:],
            "stderr": result.stderr[-12000:],
            "timed_out": False,
            "duration_seconds": round(time.monotonic() - started, 3),
        }
    except subprocess.TimeoutExpired as exc:
        stdout = exc.stdout or ""
        stderr = exc.stderr or ""
        if isinstance(stdout, bytes):
            stdout = stdout.decode(errors="replace")
        if isinstance(stderr, bytes):
            stderr = stderr.decode(errors="replace")
        return {
            "command": command,
            "exit_code": None,
            "stdout": stdout[-12000:],
            "stderr": stderr[-12000:],
            "timed_out": True,
            "duration_seconds": round(time.monotonic() - started, 3),
        }


def git(*arguments: str) -> dict:
    return run_command(["git", *arguments], timeout=30)


def main() -> int:
    parser = argparse.ArgumentParser(description="Verifica un parche Git.")
    parser.add_argument("--base", required=True, help="Revisión base de Git.")
    parser.add_argument("--max-files", type=int, default=20)
    parser.add_argument("--allow-prefix", action="append", default=[])
    parser.add_argument("--test-timeout", type=int, default=300)
    parser.add_argument(
        "--test", nargs=argparse.REMAINDER,
        help="Comando de pruebas y argumentos; debe ir al final.",
    )
    args = parser.parse_args()

    if args.max_files < 1 or args.test_timeout < 1:
        parser.error("--max-files y --test-timeout deben ser positivos")

    test_command = args.test or []
    if test_command and test_command[0] == "--":
        test_command = test_command[1:]

    report = {
        "schema_version": 1,
        "base": args.base,
        "checks": [],
        "changed_files": [],
        "status": "failed",
    }

    def record(name: str, passed: bool, details: dict) -> None:
        report["checks"].append(
            {"name": name, "passed": passed, "details": details}
        )

    base_result = git("rev-parse", "--verify", args.base)
    record("base_revision", base_result["exit_code"] == 0, base_result)
    if base_result["exit_code"] != 0:
        report["reason"] = "No se pudo resolver la revisión base."
        print(json.dumps(report, indent=2, ensure_ascii=False))
        return 1

    diff = git("diff", "--name-only", "-z", f"{args.base}...HEAD")
    if diff["exit_code"] != 0:
        record("changed_files", False, diff)
        print(json.dumps(report, indent=2, ensure_ascii=False))
        return 1

    changed_files = [name for name in diff["stdout"].split("\0") if name]
    report["changed_files"] = changed_files
    record("nonempty_patch", bool(changed_files), {"count": len(changed_files)})
    record(
        "file_count_limit",
        len(changed_files) <= args.max_files,
        {"actual": len(changed_files), "maximum": args.max_files},
    )

    if args.allow_prefix:
        prefixes = [prefix.strip("/") for prefix in args.allow_prefix]
        violations = [
            name for name in changed_files
            if not any(name == prefix or name.startswith(prefix + "/")
                       for prefix in prefixes)
        ]
        record("allowed_paths", not violations, {"violations": violations})

    diff_check = git("diff", "--check", f"{args.base}...HEAD")
    record("git_diff_check", diff_check["exit_code"] == 0, diff_check)

    if not test_command:
        record("tests", False, {"error": "No se proporcionó --test."})
    else:
        result = run_command(test_command, timeout=args.test_timeout)
        record(
            "tests",
            result["exit_code"] == 0 and not result["timed_out"],
            result,
        )

    passed = all(check["passed"] for check in report["checks"])
    report["status"] = "passed" if passed else "failed"
    print(json.dumps(report, indent=2, ensure_ascii=False))
    return 0 if passed else 1


if __name__ == "__main__":
    sys.exit(main())

La lista de argumentos y shell=False evitan que la orden de pruebas se interprete como una expresión de shell. Eso no convierte el proceso en seguro: el ejecutable y el código que prueba siguen corriendo con los permisos del entorno. La salida se limita para que un proceso ruidoso no infle indefinidamente el informe.

Ejecutar los controles

Primero confirma el parche en una rama y asegúrate de que la revisión base existe localmente. Desde la raíz del repositorio, una ejecución para un proyecto Python puede ser:

python proofgate.py \
  --base origin/main \
  --allow-prefix src \
  --allow-prefix tests \
  --max-files 20 \
  --test-timeout 300 \
  --test python -m pytest -q

El alcance permitido en este ejemplo son los directorios src y tests; ajusta esos prefijos a la estructura real del proyecto. Para otro lenguaje, cambia la orden posterior a --test, por ejemplo por go test ./.... El proceso devuelve código 0 solo si todos los controles pasan; devuelve 1 ante un fallo o si no se aporta una orden de pruebas.

La comparación usa base...HEAD, que representa el cambio de la rama respecto de su ancestro común con la base. Si la revisión indicada no está disponible, la barrera falla antes de ejecutar las pruebas. El informe distingue los controles por nombre, en vez de reducir todo a una puntuación opaca.

Para revisar el comportamiento sin depender de una ejecución concreta, este pequeño consumidor comprueba que el informe JSON tiene estado aprobado y que cada control está marcado como pasado. Guarda el código como check_report.py; recibe por argumento el archivo JSON generado al redirigir la salida de la barrera.

#!/usr/bin/env python3
"""Valida la estructura básica de un informe de proofgate."""

import json
import sys
from pathlib import Path


def main() -> int:
    if len(sys.argv) != 2:
        print("Uso: python check_report.py informe.json", file=sys.stderr)
        return 2

    try:
        report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        print(f"No se pudo leer el informe: {error}", file=sys.stderr)
        return 1

    checks = report.get("checks")
    valid = (
        report.get("schema_version") == 1
        and isinstance(checks, list)
        and bool(checks)
        and report.get("status") == "passed"
        and all(check.get("passed") is True for check in checks)
    )
    print("Informe aprobado" if valid else "Informe rechazado")
    return 0 if valid else 1


if __name__ == "__main__":
    raise SystemExit(main())

El consumidor sirve para una comprobación sencilla de forma y estado; no sustituye la validación de las pruebas, ni certifica que el informe proceda de una ejecución confiable. Para guardar y comprobar una ejecución de forma consecutiva:

python proofgate.py \
  --base origin/main \
  --allow-prefix src \
  --allow-prefix tests \
  --test python -m pytest -q > verification.json

python check_report.py verification.json

Convertirlo en una regla de CI

Una puerta resulta más consistente si se ejecuta en cada cambio propuesto. Este flujo de GitHub Actions es una plantilla: requiere adaptar las rutas y la instalación de dependencias al repositorio. El historial completo permite que Git compare la rama con la base.

name: Verificación del parche

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - name: Obtener el repositorio
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configurar Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Instalar dependencias de pruebas
        run: python -m pip install pytest

      - name: Verificar el parche
        run: >-
          python proofgate.py
          --base origin/main
          --allow-prefix src
          --allow-prefix tests
          --max-files 20
          --test python -m pytest -q

El control de CI debe bloquear la integración cuando falla una comprobación obligatoria, no convertir el fallo en una advertencia. En proyectos con políticas de cadena de suministro, las acciones de terceros también deben fijarse según esas políticas; el ejemplo usa etiquetas de versión y no impone una estrategia de pinning.

Qué cubre y qué queda fuera

Git detecta ciertos problemas de formato con diff --check; el límite de archivos y la lista de prefijos aplican reglas de alcance; el ejecutor registra código de salida y tiempo límite. Ninguno de esos resultados demuestra que las pruebas sean adecuadas o que el comportamiento cumpla todos los requisitos. OWASP SAMM recomienda que las reglas importantes se verifiquen independientemente de las instrucciones dadas al asistente, preferentemente mediante controles deterministas como análisis estático, pruebas o políticas automatizadas.

También se puede manipular la propia verificación: por ejemplo, modificar una prueba para que deje de comprobar el comportamiento esperado. Protege los archivos de pruebas, los flujos de CI y el script con revisión apropiada, y añade pruebas de regresión que expresen el contrato desde una perspectiva independiente de la implementación. La barrera propuesta no detecta por sí sola todas las formas de debilitar una prueba.

El límite de tiempo tampoco es un sandbox. Ejecutar código generado puede permitir acceso a archivos, credenciales o red según los permisos del entorno. No entregues secretos de producción a trabajos que ejecuten cambios no confiables; usa un entorno aislado y restringe permisos y recursos.

OpenAI distingue entre inspeccionar trazas de una ejecución para depurar el comportamiento de un agente y usar conjuntos de datos y ejecuciones de evaluación cuando se necesita repetibilidad. En esta puerta, el informe JSON cumple una función más acotada: deja constancia de los controles configurados para un parche concreto. Un siguiente paso es guardar junto al informe las revisiones exactas, la orden de pruebas y las versiones de herramientas.

El resultado es deliberadamente modesto: una propuesta pasa porque los controles definidos se ejecutaron y dejaron evidencia, no porque el agente afirmara que terminó. Antes de integrar, queda una pregunta concreta para el equipo: ¿las comprobaciones configuradas cubren el comportamiento que este parche debía preservar?

Fuentes: DEV Community, OpenAI Developers, OWASP SAMM, arXiv