Un equipo de plataforma habilita Claude Code en toda su organización de ingeniería. Para aplicar políticas sobre las llamadas a herramientas, monta un servidor de hooks — un proceso local que recibe hook events por stdin y devuelve una decisión JSON por stdout. La primera semana pinta bien. PreToolUse se dispara antes de cada llamada a herramienta, el hook devuelve deny sobre todo lo que toque producción, y el equipo cree que el enforcement funciona.
Entonces Claude Code lanza una nueva versión. PermissionRequest empieza a dispararse junto a PreToolUse. El servidor de hooks devuelve el mismo JSON con permissionDecision que ya estaba devolviendo. Claude Code acepta la respuesta, la parsea, no encuentra ningún campo que reconozca para ese evento y sigue adelante como si no se hubiera dado ninguna decisión. El deny se ignora silenciosamente. El punto de enforcement del equipo es ahora un muro con un hueco, y nada en los logs lo dice.
Este es el problema que un PEP gobernado tiene que resolver: el ciclo de vida de hooks de Claude Code no es un único evento con un único formato wire. Son aproximadamente 30 eventos, cada uno con un esquema de salida diferente que el runtime acepta, y un error en el esquema es indistinguible del silencio.
El mecanismo wire es el contrato de seguridad
La razón por la que un único campo permissionDecision no funciona en todas partes es que los hook events de Claude Code evolucionaron de forma independiente. La forma de salida que bloquea una llamada a herramienta no es la forma que bloquea un envío de prompt, que a su vez no es la forma que detiene una tarea.
Hay seis mecanismos wire distintos:
| Mecanismo | Forma de salida | Eventos |
|---|---|---|
permissionDecision | hookSpecificOutput.permissionDecision (allow/deny/ask/defer) | PreToolUse |
permissionBehavior | hookSpecificOutput.decision.behavior (allow/deny) | PermissionRequest |
topLevelDecision | decision: "block" de nivel superior + reason | UserPromptSubmit, UserPromptExpansion, PreCompact, ConfigChange, PostToolBatch |
continueFalse | continue: false + stopReason | TaskCreated, TaskCompleted, TeammateIdle |
postToolUse | decision: "block" (bloquea procesamiento posterior; la herramienta ya se ejecutó) | PostToolUse |
neutral | Sin bloqueo ejecutable | Todos los eventos context/observe, más eventos invertidos como Stop |
Si un PEP devuelve permissionDecision: "deny" en un evento PermissionRequest, Claude Code lo ignora — ese evento espera decision.behavior, no permissionDecision. El JSON es válido, la respuesta HTTP es 200, y el deny no existe. Esto no es un caso límite teórico; es el contrato wire documentado, verificado contra code.claude.com/docs/en/hooks.
Clasificación en tres vías: gating, context, observe
No todos los hook events pueden bloquear una acción. Intentar denegar un SessionEnd o una Notification carece de sentido — esos eventos son informativos y no tienen control de decisión. Intentar denegar un evento Stop es peor que absurdo: decision: "block" en Stop mantiene al agente ejecutándose, que es lo contrario de una parada de seguridad.
El PEP clasifica cada hook event reconocido en una de tres categorías:
Los eventos gating llevan una decisión que puede permitir, denegar o bloquear una acción. Son la superficie de enforcement. Pero no todos los eventos gating son igualmente ejecutables: Stop y SubagentStop están clasificados como gating en la propia taxonomía de Claude Code, pero su semántica de bloqueo está invertida (block = seguir ejecutándose), así que el PEP los trata como neutrales — nunca emite un block que mantendría vivo a un agente contra la intención del operador. De forma similar, Elicitation y ElicitationResult tienen clasificación gating pero carecen de un mecanismo de acción cableado en la versión actual, así que el PEP los pasa a neutral en lugar de simular un enforcement que no existe.
Los eventos context permiten al PEP inyectar additionalContext o reescribir la salida, pero no pueden bloquear verdaderamente la acción. PostToolUse es la excepción parcial — puede bloquear el procesamiento posterior de una salida marcada, pero la herramienta ya se ha ejecutado. El resto (PermissionDenied, MessageDisplay, SessionStart, Setup, SubagentStart, PostCompact, InstructionsLoaded, PostToolUseFailure) son observación-con-contexto: útiles para enriquecer la vista del modelo, no para detenerlo.
Los eventos observe (Notification, SessionEnd, StopFailure, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove) no tienen ningún control de decisión. El PEP los registra para la ruta de telemetría y el inventario SIEM, responde con neutralidad y continúa.
Esta clasificación no es orientativa. Determina si el decider de la raíz de composición aplica una regla de política (gating + enforceable), inyecta contexto (context) o solo observa (observe). Equivocarse implica o bien un falso enforcement (devolver un deny que se ignora) o bien un enforcement omitido (tratar un evento gating como observe).
El mapa hookSpecs: fuente única de verdad
La clasificación, el mecanismo wire y la marca de enforceability viven en un único mapa. Este es el código real que usa el conector — tanto el renderer de respuestas HTTP como el decider de la raíz de composición leen de él:
type hookSpec struct {
class string // "gating" | "context" | "observe"
mech hookMech
enforceable bool
}
var hookSpecs = map[string]hookSpec{
// GATING — el retorno del hook puede allow/deny/block la acción.
"PreToolUse": {"gating", mechPermissionDecision, true},
"PermissionRequest": {"gating", mechPermissionBehavior, true},
"UserPromptSubmit": {"gating", mechTopLevelDecision, true},
"UserPromptExpansion": {"gating", mechTopLevelDecision, true},
"PreCompact": {"gating", mechTopLevelDecision, true},
"ConfigChange": {"gating", mechTopLevelDecision, true},
"PostToolBatch": {"gating", mechTopLevelDecision, true},
"TaskCreated": {"gating", mechContinueFalse, true},
"TaskCompleted": {"gating", mechContinueFalse, true},
"TeammateIdle": {"gating", mechContinueFalse, true},
"Stop": {"gating", mechNeutral, false}, // invertido
"SubagentStop": {"gating", mechNeutral, false}, // invertido
"Elicitation": {"gating", mechNeutral, false}, // sin wire v1
"ElicitationResult": {"gating", mechNeutral, false},
// CONTEXT — additionalContext / reescritura de salida, sin bloqueo real.
"PostToolUse": {"context", mechPostToolUse, true},
"PostToolUseFailure": {"context", mechNeutral, false},
"PermissionDenied": {"context", mechNeutral, false},
"MessageDisplay": {"context", mechNeutral, false},
"SessionStart": {"context", mechNeutral, false},
"Setup": {"context", mechNeutral, false},
"SubagentStart": {"context", mechNeutral, false},
"PostCompact": {"context", mechNeutral, false},
"InstructionsLoaded": {"context", mechNeutral, false},
// OBSERVE — sin control de decisión.
"Notification": {"observe", mechNeutral, false},
"SessionEnd": {"observe", mechNeutral, false},
"StopFailure": {"observe", mechNeutral, false},
"CwdChanged": {"observe", mechNeutral, false},
"FileChanged": {"observe", mechNeutral, false},
"WorktreeCreate": {"observe", mechNeutral, false},
"WorktreeRemove": {"observe", mechNeutral, false},
}
Treinta eventos, cada uno con exactamente una clasificación, un mecanismo wire y un veredicto de enforceability. El renderer consulta hookMechFor(event) para decidir qué forma JSON emitir. El decider consulta HookEnforcementFor(event) para decidir si aplica una regla de política, inyecta contexto u observa. Ambos leen del mismo mapa, así que no pueden discrepar.
El default deny-closed
La función más importante del fichero tiene cuatro líneas:
func hookSpecFor(event string) hookSpec {
if s, ok := hookSpecs[event]; ok {
return s
}
return hookSpec{class: "unknown", mech: mechPermissionDecision, enforceable: true}
}
Un evento que no está en el mapa — porque Claude Code lanzó un nuevo hook event que el conector aún no ha clasificado — se trata como unknown, se le asigna el mecanismo wire mechPermissionDecision y se marca como enforceable. Este es el default deny-closed: un evento no reconocido es una puerta de permisos, no un paso silencioso. Si ninguna regla de política coincide, se aplica la postura por defecto configurada por el operador, que en un despliegue gobernado es deny.
La alternativa — defaultear a neutral u observe — significaría que cada nuevo hook event que Claude Code introduzca queda sin control hasta que alguien se da cuenta y lo añade al mapa. En un modelo deny-closed, el nuevo evento queda gobernado desde el instante en que se dispara, aunque la clasificación sea conservadora. Un falso deny sobre un evento nuevo es visible y corregible; un allow silencioso es invisible y puede persistir durante meses.
El struct HookEnforcement exporta esta clasificación para que el decider distinga tres niveles de eventos gating:
- Classic gate (
PreToolUse,PermissionRequest,PostToolUsey cualquier evento desconocido): cuando no hay regla gobernada que coincida, se aplica el default de política del operador (deny-closed). - Lifecycle gate (otros eventos gating ejecutables como
UserPromptSubmit,TaskCreated): el decider aplica un default seguro por evento — neutral para eventos UX/lifecycle, deny para eventos de mutación de estado. - Non-enforceable gate (
Stop,SubagentStop,Elicitation): el decider devuelve neutral en cualquier caso. Emitir un deny que Claude Code interprete como “sigue ejecutándose” sería lo contrario de seguro.
Distribución: de la clasificación a la flota
Clasificar correctamente los eventos es una mitad. Llevar el PEP a cada instancia de Claude Code es la otra. El conector de managed-settings renderiza la configuración de hooks en la forma que Claude Code espera y la distribuye como un fichero de server-managed settings — una configuración no sobreescribible que el plano de control envía a cada host gestionado.
El hook del PEP se distribuye con un matcher vacío (coincide con todas las herramientas — ninguna herramienta escapa al punto de enforcement) y se empareja con allowManagedHooksOnly para impedir que los hooks locales de un desarrollador socaven el PEP gestionado. Sin esa marca, un hook a nivel de usuario podría ensombrecer el gestionado, y el enforcement parecería presente mientras es evitable. La validación en tiempo de autoría lo detecta: si una política distribuye un hook PEP de PreToolUse sin allowManagedHooksOnly, la consola lanza una advertencia de anti-tamper.
La ruta de telemetría es independiente y no está sujeta al plan: la exportación de OpenTelemetry de Claude Code se habilita a través de variables de entorno gestionadas (CLAUDE_CODE_ENABLE_TELEMETRY, las claves OTEL_* del exportador) para que el plano de control pueda observar el uso de la suscripción sin hacer proxy de la inferencia ni tocar la credencial de suscripción. La captura de contenido (OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_CONTENT) está desactivada por defecto; activarla es una decisión deliberada y señalizada porque envía contenido de prompts y herramientas fuera de la máquina del desarrollador, creando un deber de residencia y redacción que el plano de control debe asumir.
Qué significa esto para un despliegue gobernado
Un equipo que ejecuta Claude Code con un PEP gobernado obtiene unas cuantas propiedades que importan:
- Sin allow silencioso. Cada hook event está clasificado, y cada evento no reconocido se deniega por defecto. Una nueva versión de Claude Code no puede introducir un evento de ciclo de vida sin gobernar.
- Forma wire correcta por evento. El PEP no devuelve un objeto JSON genérico esperando que Claude Code lo acepte. Devuelve el esquema de salida exacto que el evento concreto espera, porque un deny en el esquema equivocado no es un deny.
- No-enforcement honesto. Los eventos que no se pueden ejecutar (eventos observe, eventos gating invertidos) no se enforcean de mentira. El PEP los registra, devuelve neutral y no da al operador una falsa sensación de control.
- Anti-tamper en la distribución. La capa de managed-settings garantiza que el hook del PEP es no sobreescribible y señala las configuraciones donde podría ser socavado.
El PEP es la mitad de enforcement de un modelo de operación gobernada más amplio. El mapa de accesos, el ledger de auditoría y la capa de policy-as-code que lo rodean se cubren en la visión del producto y la documentación de hooks. Si quieres ver cómo componen la ruta de telemetría y la puerta de permisos, la visión de arquitectura los recorre juntos.