A platform team enables Claude Code across its engineering org. To enforce policy on tool calls, they wire up a hook server — a local process that receives hook events on stdin and returns a JSON decision on stdout. The first week looks fine. PreToolUse fires before every tool call, the hook returns deny on anything touching production, and the team believes enforcement is working.
Then Claude Code ships a new release. PermissionRequest starts firing alongside PreToolUse. The hook server returns the same permissionDecision JSON it was already returning. Claude Code accepts the response, parses it, finds no field it recognises for that event, and proceeds as if no decision was given. The deny is silently ignored. The team’s enforcement point is now a wall with a gap in it, and nothing in the logs says so.
This is the problem a governed PEP has to solve: Claude Code’s hook lifecycle is not one event with one wire format. It is approximately 30 events, each with a different output schema the runtime honours, and a mistake in the schema is indistinguishable from silence.
The wire mechanism is the security contract
The reason a single permissionDecision field does not work everywhere is that Claude Code’s hook events evolved independently. The output shape that gates a tool call is not the shape that gates a prompt submission, which is not the shape that stops a task.
There are six distinct wire mechanisms:
| Mechanism | Output shape | Events |
|---|---|---|
permissionDecision | hookSpecificOutput.permissionDecision (allow/deny/ask/defer) | PreToolUse |
permissionBehavior | hookSpecificOutput.decision.behavior (allow/deny) | PermissionRequest |
topLevelDecision | Top-level decision: "block" + reason | UserPromptSubmit, UserPromptExpansion, PreCompact, ConfigChange, PostToolBatch |
continueFalse | continue: false + stopReason | TaskCreated, TaskCompleted, TeammateIdle |
postToolUse | decision: "block" (blocks further processing; tool already ran) | PostToolUse |
neutral | No enforceable block | All context/observe events, plus inverted events like Stop |
If a PEP returns permissionDecision: "deny" on a PermissionRequest event, Claude Code ignores it — that event expects decision.behavior, not permissionDecision. The JSON is valid, the HTTP response is 200, and the deny does not exist. This is not a theoretical edge case; it is the documented wire contract, verified against code.claude.com/docs/en/hooks.
Three-way classification: gating, context, observe
Not every hook event can gate an action. Trying to deny a SessionEnd or a Notification is meaningless — those events are informational and carry no decision control. Trying to deny a Stop event is worse than meaningless: decision: "block" on Stop keeps the agent running, which is the opposite of a safety stop.
The PEP classifies every recognised hook event into one of three categories:
Gating events carry a decision that can allow, deny, or block an action. They are the enforcement surface. But not all gating events are equally enforceable: Stop and SubagentStop are classified as gating in Claude Code’s own taxonomy, but their block semantics are inverted (block = keep running), so the PEP treats them as neutral — it never emits a block that would keep an agent alive against the operator’s intent. Similarly, Elicitation and ElicitationResult have a gating classification but lack a wired action mechanism in the current version, so the PEP defaults them to neutral rather than pretending enforcement exists where it does not.
Context events let the PEP inject additionalContext or rewrite output, but cannot truly block the action. PostToolUse is the partial exception — it can block further processing of a flagged output, but the tool has already executed. The rest (PermissionDenied, MessageDisplay, SessionStart, Setup, SubagentStart, PostCompact, InstructionsLoaded, PostToolUseFailure) are observe-with-context: useful for enriching the model’s view, not for stopping it.
Observe events (Notification, SessionEnd, StopFailure, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove) have no decision control at all. The PEP records them for the telemetry path and the SIEM inventory, answers neutrally, and moves on.
This classification is not advisory. It determines whether the composition-root decider applies a policy rule (gating + enforceable), injects context (context), or just observes (observe). Getting it wrong means either false enforcement (returning a deny that is ignored) or missed enforcement (treating a gating event as observe).
The hookSpecs map: single source of truth
The classification, the wire mechanism, and the enforceability flag live in one map. This is the actual code the connector uses — both the HTTP response renderer and the composition-root decider read from it:
type hookSpec struct {
class string // "gating" | "context" | "observe"
mech hookMech
enforceable bool
}
var hookSpecs = map[string]hookSpec{
// GATING — a hook return can allow/deny/block the action.
"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}, // inverted
"SubagentStop": {"gating", mechNeutral, false}, // inverted
"Elicitation": {"gating", mechNeutral, false}, // not wired v1
"ElicitationResult": {"gating", mechNeutral, false},
// CONTEXT — additionalContext / output rewrite, no true block.
"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 — no decision control.
"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},
}
Thirty events, each with exactly one classification, one wire mechanism, and one enforceability verdict. The renderer consults hookMechFor(event) to decide which JSON shape to emit. The decider consults HookEnforcementFor(event) to decide whether to apply a policy rule, inject context, or observe. Both read from the same map, so they cannot disagree.
The deny-closed default
The most important function in the file is four lines long:
func hookSpecFor(event string) hookSpec {
if s, ok := hookSpecs[event]; ok {
return s
}
return hookSpec{class: "unknown", mech: mechPermissionDecision, enforceable: true}
}
An event that is not in the map — because Claude Code shipped a new hook event the connector has not classified yet — is treated as unknown, assigned the mechPermissionDecision wire mechanism, and marked enforceable. This is the deny-closed default: an unrecognised event is a permission gate, not a silent pass-through. If no policy rule matches, the operator’s configured default posture applies, which in a governed deployment is deny.
The alternative — defaulting to neutral or observe — would mean that every new hook event Claude Code introduces is uncontrolled until someone notices and adds it to the map. In a deny-closed model, the new event is governed from the moment it fires, even if the classification is conservative. A false deny on a new event is visible and fixable; a silent allow is invisible and may persist for months.
The HookEnforcement struct exports this classification so the decider can distinguish three tiers of gating events:
- Classic gate (
PreToolUse,PermissionRequest,PostToolUse, and any unknown event): when no governed rule matches, the operator’s policy default applies (deny-closed). - Lifecycle gate (other enforceable gating events like
UserPromptSubmit,TaskCreated): the decider applies a per-event safe default instead — neutral for UX/lifecycle events, deny for state-mutation events. - Non-enforceable gate (
Stop,SubagentStop,Elicitation): the decider returns neutral regardless. Emitting a deny that Claude Code interprets as “keep running” would be the opposite of safe.
Distribution: from classification to fleet
Classifying events correctly is one half. Getting the PEP onto every Claude Code instance is the other. The managed-settings connector renders the hook configuration into the shape Claude Code expects and distributes it as a server-managed settings file — a non-overridable configuration the control plane pushes to every managed host.
The PEP hook is distributed with an empty matcher (matches all tools — no tool escapes the enforcement point) and pairs it with allowManagedHooksOnly to prevent a developer’s local hooks from undercutting the managed PEP. Without that flag, a user-level hook could shadow the managed one, and enforcement would look present while being bypassable. The authoring-time validation catches this: if a policy ships a PreToolUse PEP hook without allowManagedHooksOnly, the console raises an anti-tamper advisory.
The telemetry path is separate and plan-ungated: Claude Code’s OpenTelemetry export is enabled via managed environment variables (CLAUDE_CODE_ENABLE_TELEMETRY, the OTEL_* exporter keys) so the control plane can observe subscription use without proxying inference or touching the subscription credential. Content capture (OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_CONTENT) defaults to off; turning it on is a deliberate, flagged choice because it ships prompt and tool content off the developer’s machine, creating a residency and redaction duty the control plane must own.
What this means for a governed deployment
A team running Claude Code with a governed PEP gets a few properties that matter:
- No silent allow. Every hook event is classified, and every unrecognised event defaults to deny. A new Claude Code release cannot introduce an ungoverned lifecycle event.
- Correct wire shape per event. The PEP does not return a generic JSON object and hope Claude Code honours it. It returns the exact output schema the specific event expects, because a deny in the wrong schema is not a deny.
- Honest non-enforcement. Events that cannot be enforced (observe events, inverted gating events) are not pretend-enforced. The PEP records them, returns neutral, and does not give the operator a false sense of control.
- Anti-tamper at distribution. The managed-settings layer ensures the PEP hook is non-overridable and flags configurations where it could be undercut.
The PEP is the enforcement half of a larger governed operation model. The access map, the audit ledger, and the policy-as-code layer around it are covered in the product overview and the hooks documentation. If you want to see how the telemetry path and the permission gate compose, the architecture overview walks through both.