What Four Coding Agents' Hook Systems Actually Do
Claude Code, Cursor, Codex CLI and Gemini CLI each expose hooks or lifecycle controls that can sit between an agent's intent and the tool execution. Wiring one authorization layer into all four meant learning what each runtime actually does in practice, and where that differs from what I initially expected from the documentation. Some of it surprised me.
I wasn't trying to build a feature-by-feature comparison. I was trying to answer a narrower question:
If I want one independent authorization layer to evaluate the actual action before it executes, where can I reliably intercept that action in each runtime?
The observations below come from live sessions against specific versions. They are version-specific runtime observations, not claims that these behaviors will remain unchanged as the agents evolve.
Methodology
I tested each runtime through the actual hook integration used by Yenop rather than inferring behavior from documentation alone. Where I describe a runtime behavior, I give the tested version and distinguish my observation from the runtime's documented contract. The goal is reproducibility, not a permanent compatibility claim. The recorded hook events from these sessions are the adapters' test fixtures in the repository.
Cursor fires preToolUse first, for every tool
Cursor 3.21.18
Cursor exposes a generic preToolUse hook as well as more specialized hooks such as beforeShellExecution and beforeReadFile.
In my testing, preToolUse ran first for Shell, Read, Write, Grep, MCP and the other tool types I exercised. In my tested path, the generic hook provided the allow/deny decision, while the specialized hook provided the approval path.
My first adapter ignored Shell at preToolUse, expecting beforeShellExecution to be the place where Shell should be judged. I had also configured the adapter to fail closed. The result was that Cursor blocked every command with:
hook returned no output
Right failure direction, wrong assumption.
The working design was to judge every tool at the first gate, then let an ask decision continue to the specialized hook where Cursor can present its own approval UI. In my test, that appeared as:
Pending approval… Hook requested approval
and the approval path still appeared in Run Everything mode.
The interesting lesson for an external authorization layer is that the generic interception point and the runtime's user-facing approval mechanism are not necessarily the same thing.
Cursor doesn't tell hooks about Skip
Cursor 3.21.18
When I clicked Skip on an approval, the hooks received a normal postToolUse event containing:
{"output":"","exitCode":0}
afterShellExecution fired in the same way. That makes a skipped command indistinguishable, at the hook and audit level I tested, from a command that actually ran and produced no output. This matters if you're building an audit trail from hook events.
Yenop therefore records this conservatively as "Cursor reported it complete" rather than claiming that the command definitely executed. That's an important distinction for an authorization and audit layer: absence of an execution signal isn't proof of execution. This is an auditability problem more than a Cursor bug; the runtime's approval UI simply doesn't tell hooks which way the person went.
Gemini CLI treats different hook outcomes differently
Gemini CLI 0.60.0
In my verified session, a hook exiting with code 1 was treated as a non-blocking outcome. A hook that timed out or failed to start was logged and ignored. The blocking path I tested was a structured response from a successful hook invocation:
{"decision":"deny"}
The interesting part is that Gemini's hook system also gives an authorization layer an ask path. In my testing, an ask decision caused Gemini to display its own confirmation dialog, including when running in YOLO mode. It also showed the confirmation again after the person selected "Allow for this session".
Another limitation I encountered is that the hook events did not give Yenop a per-call identifier that could simply be carried through the complete decision lifecycle. The adapter therefore has to correlate the decision using the available session, tool and argument information.
This is exactly the kind of runtime-specific detail that isn't obvious if you're trying to build one authorization model across multiple agents.
Codex CLI: the hook path I tested has no ask
Codex CLI 0.155.1
In the hook path I tested, Codex gave the authorization layer a continue/block boundary rather than an inline ask decision:
- remain silent → allow, continue
- exit 2 with a reason → block
That is different from the approval flow exposed by the other runtimes.
Codex also disables a hook until the person trusts it. In my first session, that happened without an obvious indication in the execution flow. That led to another important distinction: installed and enforcing are different states. Yenop therefore has to account for the runtime's own trust state rather than assuming that a configured hook is necessarily active.
There is another implementation detail with apply_patch: the tool input arrives as patch text rather than a simple list of files. To enforce file-level policy, the adapter has to parse the patch and evaluate the files it touches, using the strictest applicable decision.
Models refuse by name, not by meaning
I also wanted to see what happened when I left the decision entirely to the model.
Gemini Flash-Lite and Grok both declined to read .env in my tests, with a short explanation about credentials before making the tool call. Both subsequently read .npmrc, which in my environment contained an npm authentication token.
That distinction is the reason I don't consider a model's refusal to be an authorization boundary. A model's refusal is a mood. The model can make a reasonable semantic judgment, but if the boundary matters, something outside the model needs to evaluate the actual action: the command, path, tool and relevant context.
I got caught by my own rule
This one was particularly useful. Code passed to an interpreter such as:
python3 - <<EOF ... EOF
or node -e '...' is treated as opaque by my current policy and held for a person. That's intentional: an interpreter is a classic way to move an otherwise blocked operation into a string passed to a runtime. But my own build scripts use exactly these patterns. So they ask every time.
It's the right call and it's still annoying. And that is the calibration problem in one line: a useful authorization layer cannot simply maximize the number of things it blocks. The rules have to be tuned against real developer workflows, including how often a person allows something that the policy held for review.
Versions and test environment
Everything above was observed in live sessions against the following releases. The recorded hook events are also the test fixtures used by the adapters.
| Runtime | Version | Date | What the session established |
|---|---|---|---|
| Claude Code | 2.1.282 | 2026-09 | PreToolUse decides; ask and deny honored; outcomes attach to the decision. |
| Cursor | 3.21.18 | 2026-09-23 | preToolUse observed first for every tested tool; Skip was not exposed distinctly to hooks. |
| Gemini CLI | 0.60.0 | 2026-09-23 | ask survived YOLO and "Allow for this session"; exit code 1 was non-blocking in the tested path; failed hooks were ignored. |
| Codex CLI | 0.155.1 | 2026-09-20 | No ask in the tested hook path; trust state affected enforcement; apply_patch arrived as text. |
The recording linked below uses Gemini CLI 0.61.0, which was updated between the verified 0.60.0 session and the recording. That distinction is deliberate: this article reports the version I actually used for the verified observations, rather than silently replacing it with a newer release.
What this means for an independent authorization layer
The interesting thing wasn't that one runtime was "better" or "worse". It was that the same conceptual operation, judge an agent's intended action before execution, has different interception points, decision semantics, failure behavior and trust models across runtimes. None of these observations by themselves means that a runtime's built-in controls are inadequate. They mean that an independent authorization layer has to understand the runtime-specific enforcement model rather than assuming that a generic hook abstraction is enough. That makes a common authorization layer surprisingly non-trivial. The layer has to normalize:
Agent intent
↓
Runtime-specific hook
↓
Canonical action
↓
Authorization policy
↓
ALLOW / ASK / DENY
↓
Runtime-specific enforcement
↓
Tool execution
That's what led me to build Yenop. Yenop is a local execution authorization layer for AI agents. It sits outside the agent runtime and translates these runtime-specific execution paths into a common authorization model. It's pre-alpha and source-available. It runs locally; source code, prompts and commands don't leave the machine, and telemetry is off unless explicitly enabled.
Seeing it
I recorded a 40-second unedited Gemini session showing the layer in front of the agent: allow → deny → ask → report. Watch the 40-second demo.
If you run any of these agents daily, I'd especially like you to run Yenop in observe mode on one real repo for a week. It blocks nothing; it only records what the authorization layer would have allowed, asked about or denied.
I'm particularly interested in false positives, false negatives, bypasses, and decisions that are technically correct but painful in a real workflow. That's the part I can't learn alone.