En la parada anterior construimos el harness mínimo de un agente QA: contexto, herramientas, goal, borradores y permisos. Terminamos con una distinción que parecía pequeña, pero cambia todo:
- Escribir “no cambies estados de Jira” en las instrucciones es pedirle al modelo que respete una regla.
- Poner esa herramienta en
denyes impedir que la ejecute.
Una depende del criterio del agente. La otra no.
Hoy vamos un paso más allá. No solo vamos a decidir qué herramientas puede usar: vamos a ejecutar controles automáticos antes y después de sus acciones. Si intenta borrar algo crítico, el control corre antes. Si escribe un JSON roto, el control corre después. Si prepara un comentario con <TICKET> todavía sin reemplazar, el control actúa antes de que ese borrador salga hacia Jira.
Esos controles son hooks.
Los devs suelen presentar los hooks como automatización del flujo: formatear código, lanzar un linter, ejecutar algo después de guardar. Está bien, pero mirados desde QA tienen una estructura que conocemos de sobra: ocurre un evento, se comprueba un criterio y el flujo continúa o se bloquea según el resultado.
Un hook no es un quality gate solo por ejecutarse automáticamente. Se convierte en uno cuando evalúa un criterio explícito, produce evidencia y decide si el flujo puede continuar.
En esta guía vas a construir tres hooks reales para Claude Code. La implementación sí es específica de esa herramienta: usa .claude/settings.json, sus eventos PreToolUse y PostToolUse, y su formato de respuesta. El patrón no lo es. Evento, criterio, evidencia y decisión existen aunque uses otro agente; lo que cambia es el mecanismo para conectarlos. En Codex o en otra herramienta tendrías que adaptar los eventos y el contrato, no copiar estos archivos sin más.
Elegí Claude Code para esta primera versión porque es el runtime donde construí y probé el harness completo. Prefiero enseñarte una implementación que funciona de punta a punta antes que fingir compatibilidad universal con ejemplos que no ejecuté. No voy a describir lo que estos hooks podrían hacer. Son los mismos tres que implementé, probé y vi bloquear acciones de verdad.
Antes de tocar código: instrucción, permiso y hook no son lo mismo
Estas tres capas suelen mezclarse y por eso muchas configuraciones parecen más seguras de lo que son.
Cada capa responde una pregunta distinta:
| Capa | Pregunta | Ejemplo |
|---|---|---|
| Instrucción | ¿Qué comportamiento espero? | “Muestra el borrador antes de publicar” |
| Permiso | ¿Puede ejecutar esta herramienta? | ask para escribir en Jira |
| Hook | ¿Esta acción concreta supera el gate? | “No contiene placeholders” |
No elijas una. Combínalas. Una buena instrucción dirige; un permiso limita; un hook verifica.
Cómo funciona un hook en Claude Code
Antes del primer script, necesitas entender qué estamos conectando.
Cuando le pides a Claude Code “corrige este JSON”, el modelo no modifica el archivo directamente. Decide usar una herramienta —por ejemplo Read, Edit, Write o Bash— y Claude Code ejecuta esa herramienta por él. Los hooks se insertan alrededor de ese momento:
Tu instrucción ↓El agente decide usar una herramienta ↓PreToolUse ← aquí puedes inspeccionar y bloquear ↓La herramienta se ejecuta ↓PostToolUse ← aquí puedes validar el resultado y devolver feedback ↓El agente continúa o corrigePara esta guía nos interesan esos dos eventos:
PreToolUse: corre antes de ejecutar una herramienta. Puede bloquearla.PostToolUse: corre después. No deshace lo que ya ocurrió, pero puede devolver feedback y obligar al agente a corregir antes de seguir.
Los dos archivos que forman un hook
Un hook no es un único archivo. Tiene dos partes:
- El registro, dentro de
.claude/settings.json: le dice a Claude Code en qué evento debe intervenir y qué script ejecutar. - El script, dentro de
hooks/: contiene el criterio que permite o bloquea la acción.
La estructura que vamos a usar es esta:
mi-proyecto/├── .claude/│ └── settings.json└── hooks/ └── mi-gate.pyAbre Claude Code desde mi-proyecto/, no desde una carpeta superior. ${CLAUDE_PROJECT_DIR} representa esa raíz y permite encontrar el script sin escribir una ruta absoluta que solo funciona en tu computadora.
Registrar el evento
La estructura mínima se ve así:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/hooks/mi-gate.py"] } ] } ] }}Vamos a leerlo con una acción concreta. Claude quiere ejecutar git reset --hard. Antes de hacerlo, Claude Code busca si existe algún hook registrado para la herramienta Bash.
PreToolUse: detente antes de ejecutar
"PreToolUse": [...]Le indica a Claude Code: “antes de ejecutar una herramienta, revisa si alguno de estos controles debe intervenir”.
Lo usamos porque queremos bloquear el comando antes del daño. Si utilizáramos PostToolUse, git reset --hard ya habría ocurrido cuando el gate intentara reaccionar.
matcher: "Bash": aplica este gate solo a Bash
"matcher": "Bash"Claude Code tiene herramientas con nombres como Read, Edit, Write y Bash. matcher selecciona cuál queremos vigilar.
En este caso significa: “ejecuta este hook cuando el agente intente usar la herramienta Bash”. No se activa cuando el agente lee un archivo con Read o lo modifica con Edit.
type: "command": ejecuta un programa local
"type": "command"Significa que el hook se implementará ejecutando un programa instalado en tu computadora. Ese programa recibirá la información de la acción que Claude quiere realizar y decidirá si puede continuar.
command: "python3": abre el intérprete de Python
"command": "python3"Este no es el comando Bash que estamos inspeccionando. Es el programa utilizado para ejecutar nuestro validador.
Claude Code hará internamente algo equivalente a:
python3 hooks/mi-gate.pyargs: indica qué script debe ejecutar Python
"args": [ "${CLAUDE_PROJECT_DIR}/hooks/mi-gate.py"]args contiene lo que se entrega a python3. En este caso es la ruta del script.
${CLAUDE_PROJECT_DIR} significa “la raíz del proyecto donde abriste Claude Code”. Si el proyecto está en:
/Users/adriana/proyectos/mi-harnessClaude Code resolverá la ruta como:
/Users/adriana/proyectos/mi-harness/hooks/mi-gate.pyLa configuración completa se puede leer así:
Antes de usar una herramienta ↓Si la herramienta es Bash ↓Ejecuta Python 3 ↓Abre hooks/mi-gate.py ↓Entrega al script el comando que el agente quiere ejecutar ↓El script permite o bloqueaEl matcher decide cuándo mirar. El script decide qué considera aceptable.
Qué recibe el script
Cuando el agente intenta ejecutar este comando:
git reset --hardClaude Code envía al hook un JSON parecido a este por la entrada estándar del proceso, conocida como stdin:
{ "tool_name": "Bash", "tool_input": { "command": "git reset --hard" }}stdin significa standard input o entrada estándar. Es un canal que usa el sistema operativo para entregar información a un programa mientras se está ejecutando. En este caso, Claude Code abre python3 hooks/mi-gate.py y le pasa el JSON por ese canal. No necesita crear un archivo temporal ni pegar los datos dentro del script.
Puedes visualizarlo así:
Claude Code │ │ envía el JSON ▼stdin del script │ │ json.load(...) lo lee ▼diccionario de PythonEs el mismo mecanismo que usas en una terminal cuando conectas dos comandos con |:
echo "hola" | python3 mi-script.pyEl símbolo | toma la salida del primer comando y la envía a la entrada estándar del segundo.
Por eso el script usa:
payload = json.load(sys.stdin)command = payload.get("tool_input", {}).get("command", "")La primera línea convierte el JSON recibido en un diccionario de Python. La segunda entra a tool_input, busca command y, si el campo no existe, devuelve un string vacío en vez de romper inmediatamente.
También existe el canal contrario: stdout, la salida estándar. stdin lleva información desde Claude Code hacia el script; stdout lleva la respuesta que el script imprime de vuelta hacia Claude Code:
Claude Code → stdin → script PythonClaude Code ← stdout ← respuesta deny, si el script decide bloquearSi el gate permite continuar, nuestro script termina sin escribir nada en stdout.
Qué debe responder
Si el comando es seguro, el hook termina sin imprimir nada. Claude Code continúa con su flujo normal de permisos.
Si debe bloquearlo, el script imprime una respuesta estructurada:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Quality gate: git reset --hard bloqueado." }}hookEventNameconfirma para qué evento es la respuesta.permissionDecision: "deny"impide ejecutar la herramienta.permissionDecisionReasones la explicación que verá el agente y que también sirve como evidencia para ti.
El proceso puede terminar con código 0 y aun así bloquear. No es una contradicción: 0 significa que el script funcionó correctamente; la decisión de denegar viaja dentro del JSON. Un crash del script y un gate que decide bloquear son situaciones distintas, y conviene no mezclarlas.
Un hook no es magia ni un firewall universal. Solo controla los eventos, herramientas y patrones que tú definiste. Si tu matcher no cubre una herramienta o tu regla no contempla una variante, ese caso no está protegido. Trátalo como código de producción: alcance explícito, pruebas positivas, pruebas negativas y mantenimiento.
Hook 1 — Bloquear un comando destructivo antes de ejecutarlo
rm -rf es un comando de macOS y Linux que elimina una carpeta completa: rm significa remove (eliminar), -r recorre de forma recursiva todo su contenido y -f fuerza la operación sin pedir confirmación. Es útil para limpiar carpetas temporales o artefactos que puedes regenerar, como dist/, pero una ruta equivocada puede borrar archivos importantes sin enviarlos a la papelera.
Empecemos por el caso que no admite “lo arreglo después”. Si el agente ejecuta rm -rf sobre la carpeta equivocada, un PostToolUse llega tarde. Este gate tiene que vivir en PreToolUse.
El matcher es Bash y el script inspecciona el comando completo:
#!/usr/bin/env python3import jsonimport reimport sys
RULES = ( (re.compile(r"git\s+reset\s+--hard(?:\s|$)"), "git reset --hard"), (re.compile(r"git\s+push.*(?:--force|-f)(?:\s|$)"), "git push forzado"),)
def deny(reason: str) -> None: print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": f"Quality gate: {reason} bloqueado." } }))
payload = json.load(sys.stdin)command = payload.get("tool_input", {}).get("command", "")
for pattern, reason in RULES: if pattern.search(command): deny(reason) breakQué hacen los comandos que estamos bloqueando
El ejemplo reducido muestra dos reglas, pero la implementación completa cubre cuatro familias de comandos destructivos. Antes de decidir si tiene sentido bloquearlos, necesitas saber qué hace cada uno:
| Comando | Para qué se utiliza | Qué riesgo tiene |
|---|---|---|
rm -rf ruta/ | Eliminar una carpeta completa y todo su contenido | Una ruta equivocada borra archivos sin confirmación ni papelera |
git reset --hard | Devolver los archivos al estado de un commit y descartar cambios locales | Puede eliminar trabajo que todavía no guardaste en un commit |
git clean -fd | Limpiar archivos y directorios que Git todavía no está siguiendo | Puede borrar archivos nuevos, evidencias o configuraciones que nunca entraron al historial |
git push --force | Reemplazar el historial de una rama remota con tu historial local | Puede sobrescribir commits que otras personas ya habían subido |
En git clean -fd, las letras también importan:
-fsignifica force: autoriza la eliminación.-dincluye directorios, no solo archivos sueltos.-nsignifica dry run: muestra qué eliminaría, pero no elimina nada.
Por eso el gate bloquea git clean -fd, pero permite:
git clean -nfdEse comando es una simulación segura: te entrega la lista de archivos y carpetas que serían eliminados si después ejecutaras la variante real. Parece un detalle, pero ahí vive la diferencia entre un gate útil y uno que el equipo termina desactivando por molesto. El objetivo no es prohibir Git; es frenar la variante que produce un cambio irreversible y permitir la que ayuda a inspeccionarlo.
La primera versión de una regla no se valida solo preguntando “¿bloquea lo peligroso?”. También hay que preguntar:
- ¿Permite la variante segura?
- ¿Reconoce flags combinados como
-rfy separados como-r -f? - ¿Detecta el comando dentro de una cadena con
&&o;? - ¿Qué hace si la entrada llega vacía o malformada?
Mi decisión para entradas inválidas fue fallar cerrado: si el hook no puede entender lo que va a ejecutar, no adivina; bloquea.
La prueba que importa
No probé este gate borrando una carpeta importante. Creé un marcador temporal y le pedí a Claude Code que ejecutara exactamente el comando destructivo. El resultado fue este:
Quality gate: rm recursivo y forzado bloqueado.MARKER=PROTECTEDDespués del intento, comprobé que el archivo marcador seguía existiendo. Esa verificación demuestra que el hook interceptó el comando antes de ejecutarlo. En cambio, escribir “no borres archivos” en las instrucciones solo documenta el comportamiento esperado; no demuestra que el agente esté técnicamente impedido de hacerlo.
Hook 2 — Validar después de cada edición
El segundo hook cambia de problema. Ya no intenta impedir una acción destructiva: quiere detectar rápidamente si el agente dejó un archivo roto después de modificarlo.
Imagina que le pides agregar una propiedad a este JSON:
{ "project": "QA"}El agente lo edita y, por error, deja una coma o una comilla fuera de lugar:
{ "project": "QA", "environment": "staging}Visualmente el cambio puede parecer casi correcto, pero el archivo ya no es JSON válido. Cualquier herramienta que intente leerlo después fallará.
Por qué usamos PostToolUse
Claude Code dispone de dos herramientas habituales para modificar archivos:
Editcambia una parte concreta de un archivo que ya existe.Writeescribe el contenido completo de un archivo; puede crearlo o reemplazarlo.
Para comprobar el resultado necesitamos que la modificación ya exista en disco. Por eso este gate corre en PostToolUse, después de Edit o Write:
El agente prepara el cambio ↓Edit o Write modifica el archivo ↓PostToolUse recibe la ruta modificada ↓El hook ejecuta el validador correspondiente ↓¿Pasó? continúa · ¿Falló? devuelve el error al agenteEsta vez el registro en .claude/settings.json se ve así:
{ "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/hooks/check-after-edit.py" ], "timeout": 120 } ]}Hay dos diferencias respecto al Hook 1:
matcher: "Edit|Write"usa|como “o”: se dispara después deEdito deWrite.timeout: 120permite que el chequeo tarde como máximo 120 segundos. Si tus pruebas necesitan más que eso, probablemente son demasiado pesadas para ejecutarse después de cada edición.
Cómo sabe qué archivo debe revisar
Después de editar, Claude Code entrega al hook un payload que contiene la herramienta utilizada y la ruta del archivo:
{ "tool_name": "Write", "tool_input": { "file_path": "/ruta/mi-proyecto/config/settings.json" }}El script toma file_path, resuelve la ruta y comprueba que pertenezca a ${CLAUDE_PROJECT_DIR}. Este control evita que el hook termine ejecutando validadores sobre un archivo ajeno al proyecto por una ruta incorrecta o manipulada.
Luego lee la extensión:
suffix = target.suffix.lower()Si target es settings.json, suffix vale .json. Con ese dato el hook elige qué comprobación ejecutar.
Si el archivo es Python: sintaxis y unit tests
if suffix == ".py": ast.parse( target.read_text(encoding="utf-8"), filename=str(target) )read_textlee el contenido del archivo.encoding="utf-8"indica cómo interpretar caracteres como tildes o la letra ñ.ast.parsele pide a Python que analice la estructura del código sin ejecutarlo. Si falta un paréntesis, hay una indentación inválida o la sintaxis está rota, lanza un error.
Si la sintaxis es correcta, corre los unit tests:
ok, output = run([ "python3", "-B", "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], root)Leído como un comando de terminal, equivale a:
python3 -B -m unittest discover -s tests -p "test_*.py"-Bevita crear archivos de caché__pycache__durante el chequeo.-m unittestejecuta el framework de pruebas incluido en Python.discoverbusca automáticamente los tests.-s testsindica que debe buscarlos dentro de la carpetatests/.-p "test_*.py"limita la búsqueda a archivos cuyo nombre empiece portest_.
La función run devuelve dos valores:
ok:Truesi el comando terminó correctamente;Falsesi falló.output: el texto producido por el comando, incluidos los mensajes de error que necesitamos mostrarle al agente.
Si el archivo es shell: revisar sin ejecutar
elif suffix == ".sh": ok, output = run(["bash", "-n", str(target)], root)bash -n archivo.sh revisa la sintaxis del script sin ejecutar sus comandos. Esto importa: queremos saber si falta un fi, una comilla o un cierre, no lanzar accidentalmente el contenido del archivo durante la validación.
Si el archivo es JSON: intentar interpretarlo
elif suffix == ".json": ok, output = run([ "python3", "-m", "json.tool", str(target) ], root)json.tool es un validador incluido en Python. Intenta interpretar el archivo como JSON:
- Si la estructura es válida, termina correctamente.
- Si falta una comilla, una coma o una llave, devuelve el lugar aproximado donde encontró el error.
El hook captura esa salida; no reescribe el archivo ni publica nada.
¿Y si es Markdown, TypeScript u otro formato?
else: return 0La primera versión solo tiene validadores configurados para .py, .sh y .json. Si recibe otro formato, termina sin bloquear.
Esto no significa que Markdown o TypeScript “estén bien”. Significa que este gate no sabe validarlos todavía. Para TypeScript podrías agregar tsc --noEmit; para Markdown, un linter como markdownlint. Prefiero declarar esa frontera antes que fingir una validación que no existe.
Qué ocurre cuando el chequeo falla
Si ok es False, el hook devuelve una respuesta como esta:
print(json.dumps({ "decision": "block", "reason": ( "Quality gate post-edit: falló JSON. " "Corrige el archivo antes de continuar." )}))decision: "block"informa que el resultado de la herramienta no debe considerarse aceptado.reasondevuelve una explicación y, en la implementación completa, adjunta la salida real del validador.json.dumpsconvierte el diccionario de Python en el JSON que Claude Code espera recibir porstdout.
Aquí hay una precisión importante: el hook no deshace la edición. El JSON roto ya fue escrito porque PostToolUse ocurre después. Lo que hace el gate es impedir que el agente trate esa edición como terminada y siga construyendo encima del error. Le devuelve evidencia concreta para que repare el archivo.
La prueba de campo
Lo probé pidiéndole a Claude Code que escribiera un JSON inválido. El flujo real fue:
Write crea un JSON inválido ↓PostToolUse ejecuta json.tool ↓json.tool informa el error de parsing ↓El hook devuelve decision: block y el error ↓Claude corrige el archivo ↓PostToolUse vuelve a validar y ahora pasaEl resultado final fue un JSON válido. No porque el modelo detectara el error por iniciativa propia, sino porque el harness convirtió la sintaxis en una condición explícita para avanzar.
No conviertas este hook en una suite de veinte minutos. El feedback post-edit tiene que ser rápido. Sintaxis, unit tests focalizados y validadores baratos aquí; regresión pesada en CI. Un gate que interrumpe demasiado deja de proteger porque alguien termina quitándolo.
Hook 3 — Revisar el payload antes de publicar
El tercer caso junta las tres capas del principio.
En .claude/settings.json, las herramientas que escriben en Jira, Confluence o Notion están en ask. Eso obliga a pedir autorización humana. Pero autorizar una herramienta no significa que cualquier payload esté listo.
Puedes decir “sí, publica el comentario” y todavía tener esto dentro:
Validación completada para <TICKET>.Evidencia: {{URL_EVIDENCIA}}TODO: agregar resultado en Firefox.La intención está aprobada. El contenido no.
Por eso el tercer hook también usa PreToolUse, pero su matcher apunta únicamente a las cinco herramientas externas de escritura que soporta el harness:
{ "matcher": "mcp__atlassian__addCommentToJiraIssue|mcp__atlassian__createConfluencePage|mcp__atlassian__updateConfluencePage|mcp__notion__notion-create-pages|mcp__notion__notion-update-page", "hooks": [ { "type": "command", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/hooks/validate-external-write.py" ] } ]}El script extrae el contenido publicable y busca placeholders de alta confianza:
PLACEHOLDERS = ( re.compile(r"\bPON[-_ ]?AQU[IÍ]\b", re.IGNORECASE), re.compile(r"\b(?:TODO|TBD)\s*:", re.IGNORECASE), re.compile(r"\{\{[^{}]+\}\}"), re.compile(r"<(?:TICKET|ID|NOMBRE|FECHA|URL|EMPRESA)>", re.IGNORECASE),)También bloquea un payload vacío, demasiado corto o con una estructura que no reconoce. Otra vez: falla cerrado. Si mañana cambia el contrato de una herramienta MCP y el hook ya no encuentra dónde vive el contenido, prefiero una publicación detenida y visible antes que un comentario incompleto en un ticket real.
La prueba controlada produjo exactamente esto:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Quality gate de publicación: quedó un placeholder sin resolver ('<TICKET>')." }}Con un comentario completo, el hook no devolvió nada: permitido. Después de eso sigue aplicando ask, porque son gates distintos:
- El hook responde: ¿el payload está en condiciones de salir?
- El permiso responde: ¿la persona autoriza que salga?
Ese orden importa. Contenido correcto no equivale a consentimiento.
Registrar los tres hooks
El mapa final en .claude/settings.json queda así:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/hooks/block-destructive-command.py"] }] }, { "matcher": "mcp__atlassian__addCommentToJiraIssue|mcp__atlassian__createConfluencePage|mcp__atlassian__updateConfluencePage|mcp__notion__notion-create-pages|mcp__notion__notion-update-page", "hooks": [{ "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/hooks/validate-external-write.py"] }] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/hooks/check-after-edit.py"], "timeout": 120 }] } ] }}Tres controles, tres momentos distintos:
| Evento | Acción observada | Gate |
|---|---|---|
| Antes de Bash | Comando potencialmente destructivo | Bloquear antes del daño |
| Después de Edit/Write | Archivo recién modificado | Validar y devolver feedback |
| Antes de escribir por MCP | Payload que saldrá del entorno local | Revisar integridad antes de publicar |
Cómo pruebas un hook sin poner en riesgo tu proyecto
No empieces con una sesión real conectada a producción. Prueba por capas.
1. Prueba el script como una función
Abre la aplicación de terminal que uses en tu computadora —por ejemplo Terminal, iTerm o Warp—. No lo ejecutes en el chat de Claude Code, en el navegador ni dentro de un archivo.
En esa terminal, entra en la raíz de tu proyecto: la carpeta que contiene hooks/.
cd /ruta/mi-proyectoSin cerrar esa terminal y después de entrar en la carpeta del proyecto, pega el siguiente bloque completo y presiona Enter. No tienes que crear un archivo JSON ni pegar este contenido dentro del script:
printf '%s\n' '{ "tool_name": "mcp__atlassian__addCommentToJiraIssue", "tool_input": { "issueIdOrKey": "QA-123", "commentBody": "Resultado pendiente para <TICKET>" }}' | python3 hooks/validate-external-write.pyEl comando tiene dos partes:
printf '...JSON...' │ │ produce el payload de prueba ▼ | lo envía por stdin ▼python3 hooks/validate-external-write.py │ │ el hook lee y evalúa el payload ▼respuesta impresa en la terminalprintfconstruye el texto JSON que simula lo que enviaría Claude Code.|conecta la salida deprintfcon elstdindel script.python3 hooks/validate-external-write.pyejecuta el gate que quieres probar.
Como el comentario contiene <TICKET>, la terminal debe mostrar una respuesta con permissionDecision: deny. Eso confirma que el hook lo rechazó.
Después cambia commentBody por un texto completo y vuelve a ejecutar el comando. En ese caso no debe imprimir nada: una salida vacía significa que el gate permitió continuar.
2. Automatiza casos buenos y malos
No alcanza con un ejemplo manual. Para estos tres hooks escribí pruebas que cubren:
- comandos destructivos bloqueados y variantes seguras permitidas;
- Python roto, shell inválido y JSON inválido;
- archivos fuera de la raíz del proyecto;
- payloads completos para Jira, Confluence y Notion;
- placeholders anidados y entradas malformadas;
- herramientas inesperadas.
3. Haz una prueba de campo contenida
Usa /tmp, un archivo marcador o un fixture descartable. El objetivo es observar al agente intentando la acción y al gate interviniendo, sin depender de que “seguro no pasa nada”.
Mi evidencia final quedó en dos niveles:
14 unit tests: OK26 escenarios smoke / 92 asserts: OKY además dos pruebas de campo: el comando destructivo quedó bloqueado con el marcador intacto, y el JSON inválido recibió feedback hasta terminar corregido.
Lo que estos hooks no resuelven
Un gate serio también declara su frontera.
- El hook de Bash cubre patrones destructivos definidos; no entiende la intención de cualquier comando posible.
- El hook post-edit valida Python, shell y JSON; ignora Markdown y formatos sin verificador configurado.
- El hook de publicación detecta placeholders de alta confianza; no decide si el contenido es correcto para el negocio.
- Ninguno reemplaza code review, CI, backups ni autorización humana.
Y no, esta implementación no es “para cualquier agente” por decreto. Los conceptos PreToolUse, PostToolUse y el formato de respuesta de esta guía son específicos de Claude Code. El patrón sí es portable: evento, matcher, criterio, evidencia y decisión. Si mañana lo llevas a otra herramienta, conserva ese contrato y adapta el mecanismo.
Decir esto no debilita el enfoque. Lo vuelve honesto. Agnóstico no significa fingir que todas las herramientas tienen la misma API; significa que tu arquitectura no depende de una marca para tener sentido.
De confiar a controlar
En la parada anterior te dije que un agente con harness deja de ser un chat suelto. Ahora podemos precisar qué significa “con harness”.
No significa escribir un archivo enorme de instrucciones y esperar obediencia perfecta. Significa decidir qué parte puede interpretar el modelo y qué parte conviertes en una comprobación externa.
El modelo puede proponer el comentario. El gate verifica que no tenga placeholders. Tú autorizas la publicación.
El modelo puede editar el archivo. El gate ejecuta la sintaxis y las pruebas. CI hace la regresión completa.
El modelo puede elegir un comando para resolver la tarea. El gate bloquea las variantes destructivas que no estás dispuesta a delegar.
Eso es QA aplicado a agentes: no perseguir el error después, sino diseñar el sistema para que ciertas clases de error no puedan avanzar en silencio.
La confiabilidad de un agente no se mide por cuántas veces acierta cuando lo miras. Se mide por lo que ocurre cuando se equivoca y nadie alcanza a frenarlo manualmente.
La próxima vez que alguien te muestre un agente “autónomo”, no preguntes solo qué modelo usa. Pregunta qué eventos observa, qué gates ejecuta, qué bloquea antes, qué valida después y dónde queda la evidencia.
Ahí empieza el harness de verdad.