Skip to content

Claude Code

Inside the hooks PEP: deny-closed policy inside Claude Code

By Olivares AI 8 min read

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:

MechanismOutput shapeEvents
permissionDecisionhookSpecificOutput.permissionDecision (allow/deny/ask/defer)PreToolUse
permissionBehaviorhookSpecificOutput.decision.behavior (allow/deny)PermissionRequest
topLevelDecisionTop-level decision: "block" + reasonUserPromptSubmit, UserPromptExpansion, PreCompact, ConfigChange, PostToolBatch
continueFalsecontinue: false + stopReasonTaskCreated, TaskCompleted, TeammateIdle
postToolUsedecision: "block" (blocks further processing; tool already ran)PostToolUse
neutralNo enforceable blockAll 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.

Deny-closed hook PEP: event classification and wire mechanism flow

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Related posts

Frequently asked

Why does the PEP default unknown hook events to deny instead of allow?

A silent allow on an unrecognized event means any new hook Claude Code ships in a future release would bypass enforcement until someone manually adds it to the classification map. That is the opposite of the security posture a governed deployment needs. The deny-closed default treats an unknown event as a permission gate: it is observed, never dropped, and if no policy rule matches it is denied. This ensures new lifecycle events are governed from the moment they appear, not from the moment someone notices they were not.

What happens if the PEP emits the wrong wire shape for a hook response?

Claude Code silently ignores it. Each hook event honours a specific output schema: PreToolUse expects hookSpecificOutput.permissionDecision, PermissionRequest expects hookSpecificOutput.decision.behavior, and other gating events expect a top-level decision or continue field. If the PEP returns a valid JSON object but in the wrong schema for that event, Claude Code treats it as no decision and proceeds. This is why the wire mechanism is part of the security contract, not cosmetic: a deny that Claude Code ignores is not a deny.

See what your agents can reach

Olivares AI is the open, self-hosted platform for your AI estate. Deploy it on your own infrastructure and get the access map your security and platform teams have been asking for.