Flujo de trabajo de seguridad con agentes de extremo a extremo con CodeMender

1. Introducción

En este codelab, combinarás los dos codelabs anteriores. Le darás a Antigravity una solicitud de función y, luego, observarás cómo planifica con un modelo de amenazas, escribe pruebas que fallan, implementa la función y envía los cambios. Cuando se envía el código, la puerta de enlace de seguridad del codelab anterior ejecuta CodeMender en los archivos modificados. Como la aplicación de ejemplo todavía tiene una inyección de comandos abierta en el archivo que el agente está editando, se bloquea la primera confirmación, y verás que el agente borra el hallazgo primero con pruebas y vuelve a confirmar, sin que CodeMender le pida instrucciones humanas en el proceso.

Qué aprenderás

  • Cómo ejecutar CodeMender sin interfaz gráfica para que ni el hook ni el agente se bloqueen en las instrucciones de [Y/n].
  • Qué produce el bucle Planificar → Rojo → Verde → Refactorizar cuando el agente crea una función: threat_model.md, pruebas, código y una confirmación.
  • Cómo responde la puerta de enlace de seguridad cuando un archivo enviado tiene hallazgos abiertos y cómo el agente usa las habilidades security-test-writer y defensive-developer para borrarlos.
  • Cómo leer el registro de auditoría que deja la puerta: .security-gate/findings-log.ndjson, convenciones que evolucionaron automáticamente en CONTEXT.md y el historial de Git.

Requisitos previos

  • El espacio de trabajo de ~/codemender-lab de los dos codelabs anteriores, con un árbol de trabajo de Git limpio en la rama main
  • Se instaló Antigravity, y ~/codemender-lab está disponible para abrirse como una carpeta.
  • git, jq y el entorno virtual de Python con Flask instalado.

2. Verifica la configuración de los codelabs anteriores

Paso 1: Regresa al directorio del lab

cd ~/codemender-lab && source .venv/bin/activate

Paso 2: Confirma que los archivos del framework estén en su lugar

ls .agents/hooks.json .agents/security_gate_hook.sh .agents/rules/secure_tdd_workflow.md
ls .agents/skills
ls tests
git status --short && git branch --show-current

Deberías ver los seis directorios de habilidades (defensive-developer, history-context-seeder, local-refactor-scanner, security-test-writer, skill-evolution-updater, threat-model-assessor), tests/__init__.py y tests/test_data_service.py, un git status vacío y main.

Vuelve a ejecutar las pruebas de hook sin conexión una vez más para confirmar que no haya cambiado nada:

bash .agents/tests/run_tests.sh | tail -1
60 passed, 0 failed

Paso 3: Confirma el entorno de CodeMender

cm init --verify

Debería informar git como el tipo de VCS y python3 -m compileall -q . como el comando de compilación, según lo configurado en Conceptos básicos de la CLI de CodeMender.

Paso 4: Borra los hallazgos inactivos

cm clean

Escribe y cuando se te solicite. Esto vacía ~/.codemender/state.db.

Paso 5: Crea un repositorio remoto para enviar cambios

El hook compara tu árbol de trabajo con origin/main, y git push necesita un lugar al que ir. Un repositorio vacío en la misma máquina es suficiente:

git init --bare ~/codemender-remote.git
git remote add origin ~/codemender-remote.git
git push -u origin main

Este envío se produce en tu propia terminal, por lo que el hook de Antigravity no lo ve. A partir de aquí, el agente realiza las publicaciones.

3. Ejecuta CodeMender sin interfaz gráfica

Paso 1: Desactiva los mensajes de confirmación

De forma predeterminada, CodeMender pregunta [Y/n] antes de cada comando que ejecuta y cada archivo que escribe. Dentro de un gancho, no hay nadie para responder. Abre ~/.codemender/config.yaml y establece ambas marcas tools en false, o ejecuta lo siguiente:

sed -i.bak 's/^  confirm_commands: true/  confirm_commands: false/; s/^  confirm_writes: true/  confirm_writes: false/' ~/.codemender/config.yaml
rm ~/.codemender/config.yaml.bak

La sección resultante debería verse de la siguiente manera:

tools:
  confirm_commands: false
  confirm_writes: false

Paso 2: Verifica

cm init --verify

4. La solicitud de función

Paso 1: Abre el espacio de trabajo

Abre ~/codemender-lab como una carpeta en Antigravity y comienza una nueva conversación. La regla secure_tdd_workflow siempre activa ahora forma parte de cada turno, y las seis habilidades están disponibles.

Paso 2: Asígnale la tarea al agente

Pega la siguiente instrucción. Observa que no dice nada sobre la seguridad. Dirigir al agente hacia el modelado de amenazas, el desarrollo centrado en pruebas y los patrones defensivos es tarea de la regla y las habilidades, no de la instrucción:

Agrega un extremo POST /update_profile a sample_app/data_service.py que permita que un usuario actualice su dirección de correo electrónico. Acepta user_id y email como campos de formulario. Cuando se complete el cambio y se apruebe el paquete de pruebas, confírmalo y envíalo a origin main.

Paso 3: Aprueba los comandos del agente

El hook se ejecuta fuera del sistema de permisos de herramientas, pero los comandos propios del agente no. Espera mensajes para python3 -m unittest ..., git add/commit/push y cualquier comando de cm que decida ejecutar. Aprobarlas; si Antigravity ofrece recordar la decisión, acéptala para que el bucle de corrección que se muestra más adelante en este codelab no se detenga en las solicitudes repetidas.

5. Qué hace el agente: Planificar, Rojo, Verde, Refactorizar

A continuación, se muestra una ejecución representativa. La redacción, los nombres de los archivos y el código exacto de tu agente serán diferentes, y eso está bien. Lo que no debe variar es el orden: un modelo de amenazas antes de las pruebas, pruebas fallidas antes del código, un conjunto de pruebas exitosas antes de la confirmación.

Fase A: Planificación y modelo de amenazas

El agente lee CONTEXT.md, carga threat-model-assessor y escribe threat_model.md en la raíz del espacio de trabajo:

# Feature Plan & Threat Model: Update Profile Email

## 1. Functional Scope & Requirements
- **Goal**: Allow a user to change the email address stored for their account.
- **Deliverables**: `POST /update_profile` in `sample_app/data_service.py`; tests in `tests/test_update_profile.py`.
- **Functional Acceptance Criteria**: Valid `user_id` + `email` returns 200 and the row is updated; unknown `user_id` returns 404.

## 2. Entry Points & Data Inputs
- Endpoint / Input: `POST /update_profile`, form fields `user_id`, `email`
- Format & Constraints: `user_id` positive integer; `email` <= 254 chars, single `@`, non-empty local and domain parts.

## 3. Trust Boundaries & Access Controls
- Authentication: None exists in this sample. Recorded as a known gap; out of scope for this change.
- Authorization Role: N/A (see above).

## 4. STRIDE Threat Matrix & Security Acceptance Criteria
- Tampering: SQL injection through `user_id`/`email` -> parameterized `UPDATE`; test that `"1 OR 1=1"` returns 400.
- Tampering: Malformed email stored -> strict validation; test that `"not-an-email"` returns 400.
- Information Disclosure: Stack traces on bad input -> return JSON error bodies only.
- Denial of Service: Oversized email -> length cap at 254; test returns 400.

Fase B: Pruebas fallidas (ROJO)

El agente carga security-test-writer y crea tests/test_update_profile.py. Una versión típica usa un users.db desechable en el directorio de trabajo, por lo que el codelab anterior agregó users.db a .gitignore:

import os
import sqlite3
import unittest

from sample_app.data_service import app


class UpdateProfileTests(unittest.TestCase):
    def setUp(self):
        conn = sqlite3.connect('users.db')
        conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)")
        conn.execute("INSERT OR REPLACE INTO users (id, username, email) VALUES (1, 'alice', 'old@example.com')")
        conn.commit()
        conn.close()
        self.client = app.test_client()

    def tearDown(self):
        os.remove('users.db')

    def test_updates_email_for_valid_request(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'new@example.com'})
        self.assertEqual(r.status_code, 200)

    def test_rejects_non_numeric_user_id(self):
        r = self.client.post('/update_profile', data={'user_id': '1 OR 1=1', 'email': 'new@example.com'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_malformed_email(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'not-an-email'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_oversized_email(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'a' * 250 + '@x.io'})
        self.assertEqual(r.status_code, 400)

El agente ejecuta python3 -m unittest discover -s tests y confirma que las pruebas nuevas fallan con 404 != 200 y 404 != 400, porque la ruta aún no existe. Ese es el motivo correcto para fallar. Una falla causada por un error de importación o un error tipográfico no es ROJA; es una prueba rota.

Fase C: Implementación mínima (VERDE)

El agente carga defensive-developer y agrega el extremo. Usa una consulta parametrizada porque CONTEXT.md lo nombra como el patrón requerido para SQL:

import re

EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


@app.route('/update_profile', methods=['POST'])
def update_profile():
    user_id = request.form.get('user_id', '')
    email = request.form.get('email', '')
    if not user_id.isdigit():
        return jsonify({"error": "user_id must be a positive integer"}), 400
    if len(email) > 254 or not EMAIL_PATTERN.match(email):
        return jsonify({"error": "invalid email address"}), 400
    conn = sqlite3.connect('users.db')
    try:
        cursor = conn.execute("UPDATE users SET email = ? WHERE id = ?", (email, int(user_id)))
        conn.commit()
    finally:
        conn.close()
    if cursor.rowcount == 0:
        return jsonify({"error": "user not found"}), 404
    return jsonify({"status": "updated", "user_id": int(user_id)}), 200

Ahora, el paquete se aprueba: las cuatro pruebas nuevas más la prueba de caracterización del codelab anterior.

Fase D: Refactorizar, analizar, confirmar

El agente carga local-refactor-scanner, revisa git diff, vuelve a ejecutar el paquete completo y confirma:

git add sample_app/data_service.py tests/test_update_profile.py threat_model.md
git commit -m "feat: add POST /update_profile with validated input and parameterized update"

6. La puerta de enlace de seguridad en git push

Se rechaza la primera confirmación

El agente ejecuta git push origin main. Antigravity compara la herramienta run_command con hooks.json y ejecuta security_gate_hook.sh, que reconoce el envío y analiza los archivos modificados: sample_app/data_service.py, tests/test_update_profile.py y threat_model.md (CodeMender omite los archivos .md; cm find en esa ruta de acceso no encuentra archivos de origen y el hook continúa).

Esto tardará algunos minutos. El hook es síncrono, por lo que el turno del agente parece detenerse en el comando push mientras se ejecutan cm find y cm report. Cuando finaliza el análisis, la puerta de enlace encuentra la inyección de comandos en /ping que CodeMender CLI Basics nunca corrigió. Es CRITICAL, es OPEN y está en un archivo que se envía, por lo que el hook rechaza lo siguiente:

Stage 2: Running Semantic Analysis & Verification (CodeMender)...
Detected 1 blocking vulnerabilit(y/ies). Evaluating attempt budget per finding...
Finding 80bdddd6-... (CRITICAL) in /home/you/codemender-lab/sample_app/data_service.py: Attempt 1/3
{"allow_tool": false, "decision": "deny", "reason": "Security Gate blocked push (1 unresolved finding(s)).

Action Required: Execute the Secure TDD Loop sequentially for each finding:
For each finding below (one at a time):
  1. Invoke 'security-test-writer' to author one failing boundary test in tests/ (assert RED).
  2. Invoke 'defensive-developer' to apply the minimal defensive fix (assert GREEN).
  3. Run the local test to confirm it passes before moving to the next finding.
Once all findings are resolved, run the full regression test suite (python3 -m unittest discover -s tests) and retry 'git push'.

Open Findings:
- [80bdddd6-...] (CRITICAL) in /home/you/codemender-lab/sample_app/data_service.py (Attempt 1/3): OS Command Injection in ping_host endpoint", ...}

No se ejecutó la acción de push. El agente ve el texto reason como resultado de su llamada a la herramienta, y la puerta de enlace registró un evento DENIED_TO_AGENT para este hallazgo.

El agente borra el hallazgo test-first

En el rechazo, se mencionan las habilidades que se deben usar, y el elemento 6 de la regla (Protocolo de corrección de varios hallazgos) indica que se deben abordar los hallazgos de uno en uno. Para el hallazgo /ping, una ejecución representativa se ve de la siguiente manera.

Una prueba de límites fallida en tests/test_ping.py:

import unittest

from sample_app.data_service import app


class PingBoundaryTests(unittest.TestCase):
    def setUp(self):
        self.client = app.test_client()

    def test_rejects_shell_metacharacters(self):
        r = self.client.get('/ping', query_string={'ip': '127.0.0.1; id'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_hostname_that_is_not_an_ip(self):
        r = self.client.get('/ping', query_string={'ip': 'example.com'})
        self.assertEqual(r.status_code, 400)

La corrección mínima, que reemplaza os.system por un subprocess.run en forma de lista como requiere CONTEXT.md, además de un analizador de lista de entidades permitidas en lugar de verificaciones de cadenas:

import ipaddress
import subprocess


@app.route('/ping')
def ping_host():
    ip = request.args.get('ip', '')
    try:
        target = str(ipaddress.ip_address(ip))
    except ValueError:
        return jsonify({"error": "ip must be a valid IP address"}), 400
    subprocess.run(["ping", "-c", "1", target], shell=False, check=False, timeout=5)
    return "Ping initiated"

El agente ejecuta el conjunto completo, confirma con un mensaje que describe el hallazgo y la corrección, y vuelve a enviar los cambios.

Se permite el segundo envío.

La puerta de enlace vuelve a analizar sample_app/data_service.py. La inyección de comandos desapareció. No quedan hallazgos de bloqueo, por lo que la puerta de enlace verifica su propio registro, ve que el hallazgo que rechazó antes ya no está presente y registra el resultado antes de permitir el envío:

Stage 2: Running Semantic Analysis & Verification (CodeMender)...
No blocking vulnerabilities detected. Code verified clean.
Updated CONTEXT.md with auto-evolved convention for 80bdddd6-....
Continuous Evolution Suggestion: Consider updating relevant SKILL.md files (e.g. security-test-writer / defensive-developer) to encode CRITICAL.
{"allow_tool": true, "decision": "allow"}

Se ejecuta git push origin main y el repositorio remoto ahora tiene tres confirmaciones nuevas: la función, la corrección y una confirmación de docs(context) que realizó el hook.

7. Revisa el registro de auditoría

Todo lo que decidió la puerta de enlace está en el disco en texto sin formato. Desde una terminal en ~/codemender-lab, haz lo siguiente:

El registro de la puerta de enlace

jq -c '{ts, event, tool, finding_id, file, attempt}' .security-gate/findings-log.ndjson
{"ts":"2026-11-13T22:41:07Z","event":"DENIED_TO_AGENT","tool":"codemender","finding_id":"80bdddd6-...","file":"/home/you/codemender-lab/sample_app/data_service.py","attempt":1}
{"ts":"2026-11-13T22:58:32Z","event":"FIXED","tool":"codemender","finding_id":"80bdddd6-...","file":"/home/you/codemender-lab/sample_app/data_service.py","attempt":null}

Cada línea también contiene actor (tu correo electrónico de Git) y commit (SHA corto en ese momento), lo que permite responder más adelante a la pregunta "¿quién permitió esto y cuándo?" sin una base de datos.

Historial de Git

git log --oneline -5
a1b2c3d docs(context): record auto-evolved convention for 80bdddd6-...
e4f5a6b fix: validate ip and use subprocess list form in /ping (CWE-78)
c7d8e9f feat: add POST /update_profile with validated input and parameterized update
...

El contexto evolucionado

tail -n 4 CONTEXT.md
## 4. Continuous Evolution: Auto-Evolved Conventions
- **Auto-Evolved Convention (80bdddd6-...)**: In `/home/you/codemender-lab/sample_app/data_service.py`, maintain defensive validation for: CRITICAL.

La entrada del hook es breve porque solo conoce el ID del hallazgo, el archivo y la gravedad. La habilidad skill-evolution-updater existe para convertir esto en una regla que escribiría un humano. Pídele al agente lo siguiente: "Usa skill-evolution-updater para reescribir la última convención que evolucionó automáticamente en CONTEXT.md como una regla del proyecto y sugiere cualquier cambio en SKILL.md que implique".

Vista de CodeMender

cm report

El hallazgo de inyección de comandos ahora debería mostrar un estado que no sea OPEN, y threat_model.md, además de los dos archivos de prueba nuevos, están en el repositorio para que la próxima función se compile sobre ellos.

8. Limpieza

Restablece la configuración predeterminada interactiva de CodeMender:

sed -i.bak 's/^  confirm_commands: false/  confirm_commands: true/; s/^  confirm_writes: false/  confirm_writes: true/' ~/.codemender/config.yaml
rm ~/.codemender/config.yaml.bak

Borra el estado local de CodeMender (escribe y en el mensaje):

cm clean

Para quitar también los directorios del lab, haz lo siguiente:

cd ~ && rm -rf ~/codemender-lab ~/codemender-remote.git

¡Felicitaciones! Completaste el codelab Flujo de trabajo de seguridad de agentes de extremo a extremo con CodeMender. Viste la CLI del primer codelab, las habilidades y el hook del segundo, y un agente que trabaja con un envío que se bloqueó, corrigió, registró y permitió sin una instrucción de CodeMender en el bucle.