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 deny es 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.

Instrucción sola: 'no publiques sin avisarme'. El modelo debe interpretarla y recordarla.
Permiso + hook: Claude Code exige confirmación y el payload debe superar un gate concreto.

Cada capa responde una pregunta distinta:

CapaPreguntaEjemplo
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 corrige

Para 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:

  1. El registro, dentro de .claude/settings.json: le dice a Claude Code en qué evento debe intervenir y qué script ejecutar.
  2. 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.py

Abre 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:

Terminal window
python3 hooks/mi-gate.py

args: 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-harness

Claude Code resolverá la ruta como:

/Users/adriana/proyectos/mi-harness/hooks/mi-gate.py

La 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 bloquea

El matcher decide cuándo mirar. El script decide qué considera aceptable.

Qué recibe el script

Cuando el agente intenta ejecutar este comando:

Terminal window
git reset --hard

Claude 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 Python

Es el mismo mecanismo que usas en una terminal cuando conectas dos comandos con |:

Terminal window
echo "hola" | python3 mi-script.py

El 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 Python
Claude Code ← stdout ← respuesta deny, si el script decide bloquear

Si 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."
}
}
  • hookEventName confirma para qué evento es la respuesta.
  • permissionDecision: "deny" impide ejecutar la herramienta.
  • permissionDecisionReason es 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 python3
import json
import re
import 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)
break

Qué 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:

ComandoPara qué se utilizaQué riesgo tiene
rm -rf ruta/Eliminar una carpeta completa y todo su contenidoUna ruta equivocada borra archivos sin confirmación ni papelera
git reset --hardDevolver los archivos al estado de un commit y descartar cambios localesPuede eliminar trabajo que todavía no guardaste en un commit
git clean -fdLimpiar archivos y directorios que Git todavía no está siguiendoPuede borrar archivos nuevos, evidencias o configuraciones que nunca entraron al historial
git push --forceReemplazar el historial de una rama remota con tu historial localPuede sobrescribir commits que otras personas ya habían subido

En git clean -fd, las letras también importan:

  • -f significa force: autoriza la eliminación.
  • -d incluye directorios, no solo archivos sueltos.
  • -n significa dry run: muestra qué eliminaría, pero no elimina nada.

Por eso el gate bloquea git clean -fd, pero permite:

Terminal window
git clean -nfd

Ese 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 -rf y 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=PROTECTED

Despué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:

  • Edit cambia una parte concreta de un archivo que ya existe.
  • Write escribe 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 agente

Esta 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 de Edit o de Write.
  • timeout: 120 permite 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_text lee el contenido del archivo.
  • encoding="utf-8" indica cómo interpretar caracteres como tildes o la letra ñ.
  • ast.parse le 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:

Terminal window
python3 -B -m unittest discover -s tests -p "test_*.py"
  • -B evita crear archivos de caché __pycache__ durante el chequeo.
  • -m unittest ejecuta el framework de pruebas incluido en Python.
  • discover busca automáticamente los tests.
  • -s tests indica que debe buscarlos dentro de la carpeta tests/.
  • -p "test_*.py" limita la búsqueda a archivos cuyo nombre empiece por test_.

La función run devuelve dos valores:

  • ok: True si el comando terminó correctamente; False si 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 0

La 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.
  • reason devuelve una explicación y, en la implementación completa, adjunta la salida real del validador.
  • json.dumps convierte el diccionario de Python en el JSON que Claude Code espera recibir por stdout.

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 pasa

El 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[]\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:

EventoAcción observadaGate
Antes de BashComando potencialmente destructivoBloquear antes del daño
Después de Edit/WriteArchivo recién modificadoValidar y devolver feedback
Antes de escribir por MCPPayload que saldrá del entorno localRevisar 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/.

Terminal window
cd /ruta/mi-proyecto

Sin 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:

Terminal window
printf '%s\n' '{
"tool_name": "mcp__atlassian__addCommentToJiraIssue",
"tool_input": {
"issueIdOrKey": "QA-123",
"commentBody": "Resultado pendiente para <TICKET>"
}
}' | python3 hooks/validate-external-write.py

El 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 terminal
  • printf construye el texto JSON que simula lo que enviaría Claude Code.
  • | conecta la salida de printf con el stdin del script.
  • python3 hooks/validate-external-write.py ejecuta 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: OK
26 escenarios smoke / 92 asserts: OK

Y 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.