Alex HerreraDesarrollador de software · Enfoque en ciberseguridad
EN
← Volver al blog

Seguridad web

Cómo pedirle a la IA código seguro

Qué poner en la petición para obtener código seguro de la IA y qué comprobar después.

  • IA
  • Seguridad
Publicado
Actualizado
Lectura
18 min de lectura

Pedirle a un modelo que escriba código seguro cambia poco el resultado, salvo en los modelos de razonamiento. BaxBench midió 392 tareas de backend con tres versiones de la misma petición1:

  1. sin ninguna indicación de seguridad;
  2. con un recordatorio genérico: «sigue las buenas prácticas de seguridad y evita las vulnerabilidades comunes»;
  3. con la lista exacta de vulnerabilidades que podía tener esa tarea.

El recordatorio genérico mejoró mucho a los modelos de razonamiento y no mejoró de forma significativa a los demás. La lista exacta dio el mejor resultado de seguridad en todos los modelos, pero redujo el número de programas que funcionaban1. Los autores advierten de que esa lista es casi imposible de conocer de antemano en un proyecto real1.

Veracode mide peticiones que no mencionan la seguridad. En su actualización de marzo de 2026, con más de 150 modelos, el 55 % de las muestras fue seguro, la misma proporción que dos años antes2.

De las dos mediciones salen dos prácticas. La petición tiene que nombrar el riesgo concreto de la tarea, no la seguridad en general. Y lo que la petición no garantiza lo tiene que comprobar una orden: el compilador, el linter o una prueba que se ha visto fallar.

Todo el código está reconstruido para el artículo. Las mediciones se hicieron el 27 de septiembre de 2026 con Claude Code 2.1.283, Node.js 24.19.0, Express 5.2.1, TypeScript 6.0.3, Zod 4.6.5, ESLint 10.11.0 y typescript-eslint 8.70.1. Esta última versión admite TypeScript hasta la 6.0, así que el linter del punto 4 todavía no funciona con TypeScript 73.

1. Qué poner en la petición

El modelo deduce lo que el código muestra, como el nombre de un campo o una convención que se repite. La petición tiene que traer lo que el código no muestra:

  • quién es el dueño de cada dato y dónde está el usuario de la sesión;
  • qué responde cada caso de error, como un recurso ajeno o una petición sin sesión;
  • el criterio de terminado, con la prueba que lo demuestra.

Para medirlo, la misma tarea se pidió dos veces a Claude Code, cada vez sobre una copia limpia de un proyecto en Express. El proyecto guarda los pedidos en memoria, con un campo usuarioId, y deja el usuario de la sesión en req.usuario. La primera petición describe solo la función:

Añade a src/app.js una ruta GET /pedidos/:id que devuelva el pedido.

La segunda añade el dueño, los casos de error y el criterio:

Añade a src/app.js una ruta GET /pedidos/:id que devuelva el pedido.
Cada pedido pertenece a un usuario (campo usuarioId), y el usuario de la
sesión está en req.usuario. Solo su dueño puede leerlo: un pedido de otro
usuario responde 404, igual que uno que no existe. Sin sesión, 401.
Criterio: npm test pasa con una prueba nueva que pide p2 con la sesión de
ana y espera 404.

Las dos versiones filtraron por dueño, porque el campo usuarioId estaba a la vista en el mismo archivo. La diferencia estuvo en la comprobación. La primera ejecución no escribió ninguna prueba y terminó con «No la he probado», porque su prueba rápida necesitaba una aprobación que el modo no interactivo no da. La segunda creó test/pedidos.test.js con cuatro casos, aunque tampoco pudo ejecutarlos por el mismo motivo; al ejecutarlos después, npm test pasó.

Al quitar del código la comparación con usuarioId, la prueba «un pedido de otro usuario responde 404» falló. La segunda ejecución avisó además de que la cabecera que simula la sesión se puede falsificar.

Una ejecución de cada petición no mide con qué frecuencia ocurre cada resultado. Muestra, en cambio, qué añade el criterio: una orden que demuestra el comportamiento y que sigue funcionando cuando alguien cambie la ruta.

Cómo hacerlo.

  1. Escriba en la petición el dueño de los datos que toca la tarea y dónde se obtiene el usuario.
  2. Escriba la respuesta de cada caso de error.
  3. Termine con el criterio: la orden y el resultado que tiene que devolver.
  4. Lo que se repite en todas las tareas va en el archivo de instrucciones del proyecto y no en cada petición. Por ejemplo, el cliente de base de datos o el formato de los errores.

Cómo comprobarlo. Antes de enviar la petición, busque en ella un dueño, un caso de error y una orden. Si falta la orden, tendrá que leer el cambio entero para decidir si está terminado.

2. Cómo hacer que una regla se cargue cuando hace falta

Una regla es una instrucción para el agente guardada en un archivo que el harness carga en la sesión. El harness es el programa que ejecuta el agente, como Claude Code o Cursor. Una regla sirve para lo que se repite en todas las tareas de una parte del código. Solo influye en el resultado si está en la ventana de contexto en el momento del cambio. La ventana de contexto es el texto que el modelo tiene disponible en la sesión.

El harness carga cada regla según su disparador, la condición que la activa. Claude Code tiene dos disparadores que no dependen de que alguien nombre la regla:

  1. Las reglas de .claude/rules/ con una cabecera paths se cargan cuando el agente trabaja con un archivo que coincide con esas rutas4.
  2. Las skills se cargan por su descripción. El nombre y la descripción de cada una ocupan unos 100 tokens y se cargan siempre; el cuerpo se lee solo cuando la petición coincide con la descripción5.

Cursor tiene los dos mismos disparadores: por los patrones de archivo o por la descripción6.

Por eso, divida las reglas por el momento en que tienen que cargarse, no por tema. Una regla sobre cómo se valida la entrada de la API va en un archivo con paths que apunte a las rutas de la API:

---
paths:
  - "src/api/**/*.ts"
---

- Cada ruta valida el cuerpo con el esquema de Zod de src/esquemas antes de usarlo.
- Un recurso de otro usuario responde 404, no 403.

Según la guía de Anthropic, la ventana de contexto es un recurso compartido. Cada párrafo de una regla ocupa espacio que necesitan la conversación y el trabajo en curso7. Por eso una regla solo debe contener lo que el modelo no sabe ya. Una regla que explica qué es una consulta parametrizada no aporta nada. Una que indica qué cliente de base de datos usa el repositorio aporta un dato que el modelo no puede deducir.

El repositorio público de skills de Vercel permite medir la diferencia entre cargar solo lo necesario y cargarlo todo. Publica cada guía en dos formatos: un índice y la concatenación de todas sus reglas. En react-best-practices, consultado el 7 de septiembre de 2026, el índice ocupa 7251 bytes y la concatenación, 108 261, repartidos en 72 archivos de regla8. Leer el índice y las dos reglas que afectan a un cambio ocupa unos 10 000 bytes, la décima parte.

Cómo comprobarlo. En Claude Code, abra un archivo que coincida con paths, pida un cambio y ejecute /context. La regla tiene que aparecer en la sesión. Después, pida un cambio en un archivo que no coincida: la regla no debe cargarse.

3. Cómo escribir una regla que se pueda comprobar

Una regla escrita como preferencia admite discusión. Ante «Prefiere consultas parametrizadas», el modelo puede justificar excepciones razonables, y quien revise el cambio no tiene con qué contradecirlo. Una regla que trae el fallo concreto y la orden que lo detecta no deja ese margen.

Una regla útil tiene cinco partes:

  1. El mecanismo del defecto: qué hace el código incorrecto, no cómo se llama el fallo.
  2. Un caso real que se pueda localizar, para que la regla no se lea como una opinión.
  3. La regla, en una frase.
  4. Un ejemplo incorrecto y uno correcto, sin comentarios que digan cuál es cuál, porque el código tiene que distinguirse solo.
  5. La orden que vuelve a detectar el fallo: un grep, una regla del linter o una prueba.

La quinta parte convierte la regla en algo comprobable. Sin ella, no hay forma de saber si el código ya incumple la regla en otros archivos.

Escriba la comprobación antes que la regla. La guía de Anthropic lo recomienda: primero se mide qué falla sin la regla, y después se escribe la instrucción mínima que lo corrige7. Si se hace al revés, se escriben reglas para problemas que nunca ocurrieron.

Cómo comprobarlo. Ejecute la orden de detección sobre el ejemplo incorrecto y sobre el correcto. Tiene que encontrar el primero y no encontrar el segundo. Si no distingue los dos, la regla no tiene detector: escriba otro.

4. Qué comprobar con el compilador y el linter

Si un incumplimiento se puede detectar en el código sin ejecutarlo, no lo deje como regla: haga que lo detecte el compilador o el linter. Una regla influye en el modelo, pero el compilador y el linter deciden si el código pasa.

Validar en el límite. La función del dominio, la que aplica la lógica del negocio, solo acepta datos ya validados. El esquema de Zod declara la validación y el tipo a la vez:

// src/pedido.ts
import { z } from 'zod';

export const CrearPedido = z.object({
  clienteId: z.uuid(),
  monto: z.number().positive()
});

export type CrearPedido = z.infer<typeof CrearPedido>;

export function crearPedido(datos: CrearPedido): string {
  return `${datos.clienteId}:${datos.monto}`;
}

El tipo no basta. req.json() devuelve Promise<any>, y req.body de Express también es any. Un valor any se acepta en cualquier parámetro, así que esta ruta, que pasa el cuerpo sin validar, compila sin errores:

// src/ruta.ts
import { crearPedido } from './pedido.js';

export async function POST(req: Request) {
  return crearPedido(await req.json());
}

En la medición, tsc --noEmit terminó con código 0. La regla del linter @typescript-eslint/no-unsafe-argument detecta justo ese caso: un valor any que llega a un parámetro con tipo9. Está en la configuración recommended-type-checked de typescript-eslint.

Queda la aserción de tipo. Escribir cuerpo as CrearPedido le dice al compilador que trate el valor como si fuera de ese tipo, y no genera ninguna comprobación:

// src/ruta-as.ts
import { crearPedido, type CrearPedido } from './pedido.js';

export async function POST(req: Request) {
  const cuerpo: unknown = await req.json();
  return crearPedido(cuerpo as CrearPedido);
}

tsc --noEmit también aceptó esta ruta. La regla @typescript-eslint/no-unsafe-type-assertion prohíbe las aserciones que estrechan un tipo10. No está en las configuraciones recomendadas ni en las estrictas, así que hay que activarla a mano:

// eslint.config.js
import tseslint from 'typescript-eslint';

export default tseslint.config(
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true } },
    rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error' }
  }
);

Con esa configuración, el linter rechazó las dos rutas que el compilador había aceptado:

src/ruta-as.ts
  5:22  error  Unsafe type assertion: type '{ clienteId: string; monto: number; }'
               is more narrow than the original type
               @typescript-eslint/no-unsafe-type-assertion

src/ruta.ts
  4:22  error  Unsafe argument of type `any` assigned to a parameter of type
               `{ clienteId: string; monto: number; }`
               @typescript-eslint/no-unsafe-argument

✖ 2 problems (2 errors, 0 warnings)

La ruta que valida pasa las dos comprobaciones:

// src/ruta-ok.ts
export async function POST(req: Request) {
  return crearPedido(CrearPedido.parse(await req.json()));
}

Cómo comprobarlo. Escriba a propósito las dos rutas incorrectas y pase tsc --noEmit y el linter. tsc tiene que terminar en 0 y el linter en 1, con un error en cada ruta. Si el linter no dice nada, falta la información de tipos (projectService) o la regla no está activada.

5. Cómo comprobar que una prueba detecta el defecto

Si pide pruebas a un agente, las escribe, y en general pasan. Cuando las escribe después del código, tienden a describir lo que el código hace y no lo que debería hacer. Una prueba que pasa con el defecto y sin él no distingue los dos estados.

Las pruebas de mutación miden eso. Introducen un defecto, llamado mutante, en el código de producción y ejecutan las pruebas. Si alguna falla, el mutante queda matado; si todas pasan, el mutante sobrevive y señala una carencia en las pruebas. La documentación de Stryker lo distingue de la cobertura, que mide qué líneas se ejecutan y no si las pruebas comprueban el comportamiento11.

La versión manual no necesita ningún programa. Antes de dar una prueba por buena, compruébela en dos direcciones:

  1. Dirección roja. Reintroduzca el defecto exacto que la prueba dice cubrir, sin renombrar nada. La prueba tiene que fallar; si sigue pasando, no protege de nada.
  2. Dirección verde. Renombre algo sin cambiar el comportamiento, como una variable local, un parámetro o un tipo. La prueba tiene que seguir pasando; si falla, comprueba nombres y no comportamiento.

Haga la mutación siempre en el código de producción, nunca en el archivo de prueba. Si tiene que editar la prueba para que falle, la prueba no comprobaba nada.

// pedidos.mjs
export function obtenerPedido(pedidos, id, usuarioId) {
	const pedido = pedidos.find((p) => p.id === id);
	if (!pedido) return null;
	if (pedido.usuarioId !== usuarioId) return null;
	return pedido;
}
// pedidos.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { obtenerPedido } from './pedidos.mjs';

const pedidos = [{ id: 'a1', usuarioId: 'ana' }];

test('devuelve el pedido a su dueno', () => {
	assert.equal(obtenerPedido(pedidos, 'a1', 'ana')?.id, 'a1');
});

test('no devuelve el pedido a otro usuario', () => {
	assert.equal(obtenerPedido(pedidos, 'a1', 'beto'), null);
});

Con la comprobación del dueño, node --test devuelve pass 2 y termina con código 012. Al quitar la línea que compara usuarioId, la segunda prueba falla:

✔ devuelve el pedido a su dueno
✖ no devuelve el pedido a otro usuario
ℹ pass 1
ℹ fail 1

AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ { id: 'a1', usuarioId: 'ana' }
- null

Pida al agente las dos direcciones en el criterio de la tarea, como en la segunda petición del punto 1.

Cómo comprobarlo. Revierta la corrección, ejecute las pruebas y compruebe que la nueva prueba falla. Después, vuelva a aplicar la corrección.

6. Cómo revisar cada cambio

Las guías de ingeniería de Google fijan el orden de magnitud: 100 líneas es un tamaño razonable para un cambio, y 1000 suele ser demasiado. Un cambio pequeño se revisa antes y mejor, porque en un cambio grande los comentarios importantes se pierden entre los demás. Según el documento, un revisor puede rechazar un cambio solo por ser demasiado grande13.

Con un agente, el tiempo de escribir ya no limita el tamaño del cambio: pedir 800 líneas cuesta lo mismo que pedir 80. Fije el tamaño en la petición.

Pida al revisor, sea una persona o un agente, que busque lo que no se sostiene y que escriba qué comprobó para cada hallazgo. Escriba también su límite. Los programas que detectan código muerto o duplicado solo sirven para orientar la búsqueda. No ven las reexportaciones ni los scripts que se ejecutan desde la línea de órdenes. Antes de escribir en el informe que algo no se usa, compruébelo en el archivo.

Cómo comprobarlo. Anote en cada revisión el hash del commit que revisó. Sin ese hash, nadie puede repetir la revisión ni contrastarla con el código.

7. Cómo ejecutar las comprobaciones en cada cambio

Las comprobaciones anteriores solo actúan si alguien las ejecuta. En la integración continua, el servidor que comprueba cada cambio que se sube al repositorio, se ejecutan siempre. Son cinco pasos:

  1. Compilación. Ejecute tsc --noEmit sobre todo el proyecto.
  2. Linter con información de tipos. Aplica las dos reglas del punto 4.
  3. Pruebas. Automatice su ejecución. La condición del punto 5, haber visto fallar la prueba, se comprueba en la revisión, porque necesita el código anterior a la corrección.
  4. Dependencias. Instalar ejecuta código de terceros, y el archivo de bloqueo decide qué se instala. El artículo Diferencia entre npm y pnpm en la seguridad al instalar explica cómo configurarlo.
  5. Secretos. GitHub bloquea la subida de un commit cuando detecta una credencial. En los repositorios públicos, esa protección viene activada por defecto para las cuentas de usuario. En los privados hay que activarla, y requiere GitHub Secret Protection14.

Ninguno de los cinco pasos distingue si el cambio lo escribió una persona o un modelo, así que se aplican igual a los dos.

Las mismas órdenes se pueden ejecutar también en dos momentos anteriores:

  1. Después de cada edición del agente. El harness puede ejecutar un hook, una orden en un momento fijo, cada vez que el agente escribe un archivo. Si ejecuta ahí la compilación, el modelo no sigue construyendo sobre código que ya no compila.
  2. Antes de cada commit. El hook de pre-commit ejecuta las órdenes antes de que el cambio exista como commit.

Ninguno de los dos sustituye a la integración continua, porque los dos se ejecutan en su máquina y se pueden saltar. Sirven para que el aviso llegue antes, más cerca del momento en que se introdujo el defecto.

Cómo comprobarlo. Abra un cambio que contenga un fallo de cada tipo y compruebe que la integración continua lo detiene. Un control que nunca se ha visto fallar no está comprobado, igual que una prueba que nunca ha fallado.

Qué fallos no detecta el análisis estático

El compilador y el linter no encuentran el control de acceso roto. Ningún patrón distingue obtenerPedido(pedidos, id, usuarioId) de obtenerPedido(pedidos, id): las dos versiones compilan, pasan el linter y la comprobación que falta no deja rastro en el código. Un analizador estático encuentra la consulta que concatena texto, pero no la consulta correcta a la que le falta un filtro.

Ese fallo lo detectan una prueba con dos usuarios que se ha visto fallar, como la del punto 1, y la revisión de cada cambio. La base de datos también puede imponerlo con la seguridad por filas (row-level security), que filtra cada consulta por el usuario de la conexión. A cambio, cada petición tiene que abrir la conexión con la identidad del usuario, y la regla queda repartida entre la aplicación y la base de datos.

Si la autorización está en la aplicación, incluya al dueño en la condición de la consulta, dentro del where. El punto 2 de Ocho cosas que revisar en el código que genera la IA lo muestra con código.

Cómo comprobarlo. Con la sesión de un usuario, pida un identificador que pertenezca a otro. La respuesta correcta es 404. Si llega el registro, ni el compilador ni el linter lo habrían detenido.

Por dónde seguir

Ocho cosas que revisar en el código que genera la IA enseña a buscar a mano ocho fallos frecuentes. Para cada uno da la corrección y la orden que la comprueba.

Cómo proteger una página web reúne las diez capas que estas reglas y comprobaciones deben cubrir en una aplicación con usuarios.

Referencias

  1. Vero, M., Mündler, N., Chibotaru, V., Raychev, V., Baader, M., Jovanović, N., He, J. y Vechev, M.. BaxBench: Can LLMs Generate Correct and Secure Backends?ICML 2025; 392 tareas y tres versiones de la petición.arxiv.org↩
  2. Veracode. Spring 2026 GenAI Code Security Update24 de marzo de 2026; más de 150 modelos, 80 tareas, cuatro lenguajes.veracode.com↩
  3. typescript-eslint. Dependency VersionsTypeScript >=4.8.4 <6.1.0.typescript-eslint.io↩
  4. Claude Code. How Claude remembers your project, Path-specific rulescode.claude.com↩
  5. Anthropic. Agent Skills, overviewniveles de carga, coste en tokens y consideraciones de seguridad.platform.claude.com↩
  6. Cursor. Rulescursor.com↩
  7. Anthropic. Skill authoring best practicesla descripción, la concisión y las evaluaciones antes que la documentación.platform.claude.com↩
  8. Vercel. agent-skills, skills/react-best-practicestamaños medidos el 7 de septiembre de 2026 con la API de GitHub.github.com↩
  9. typescript-eslint. no-unsafe-argumenttypescript-eslint.io↩
  10. typescript-eslint. no-unsafe-type-assertiontypescript-eslint.io↩
  11. Stryker Mutator. Mutation testingstryker-mutator.io↩
  12. Node.js. Test runnernodejs.org↩
  13. Google. Engineering Practices, Small CLsgoogle.github.io↩
  14. GitHub Docs. About push protectiondocs.github.com↩