Cursor hooks are user-provided scripts registered in a
hooks.json file that the agent spawns at points in its loop. Each hook is a process
that receives a JSON payload on stdin, may print a JSON response on stdout, and
can observe, block, or annotate the action that triggered it.
This document records the Cursor hooks contract, what Copse honours today, and what
remains for fuller parity. It is the hooks counterpart to
docs/cursor-plugins.md. For the cross-cutting architecture — the
unified registry, canonical events, executors, async/budget/epoch, spine, sandbox, and UI
that are dialect-agnostic — see docs/hooks.md.
~/.cursor/hooks.json # user hooks — always honoured
<workspace>/.cursor/hooks.json # project hooks — only when the workspace is trusted
Example hooks.json:
{
"version": 1,
"hooks": {
"beforeShellExecution": [{ "command": "./hooks/audit.sh", "failClosed": true }],
"beforeMCPExecution": [{ "command": "node hooks/mcp-guard.js" }],
"beforeReadFile": [{ "command": "./hooks/redact.sh" }]
}
}command is a shell command spawned with the directory of the hooks.json as its
working directory, so relative paths resolve against the config. A hook may set
"failClosed": true to make a crash / timeout / invalid JSON block the action
instead of failing open (see Reliability and trust below).
Each hook receives a base payload — conversation_id, generation_id,
hook_event_name, workspace_roots — plus event-specific fields. Every
agent-session event also carries the model identity of the model actually
running the turn: model (slug), model_id, and model_params (Cursor's
{ id, value }[] array — e.g. context_window / max_output_tokens), matching
the vendor contract (the permission-hook I/O phase).
| Event | stdin (event fields) | stdout | Copse |
|---|---|---|---|
beforeShellExecution |
command, cwd |
{ permission: "allow"|"deny"|"ask" } |
✅ honoured |
beforeMCPExecution |
tool_name, tool_input |
{ permission: "allow"|"deny"|"ask" } |
✅ honoured |
beforeReadFile |
file_path, content |
{ permission: "allow"|"deny" } |
✅ honoured |
preToolUse |
tool_name (tool type), tool_input, cwd |
{ permission, updated_input } |
✅ wired |
beforeSubmitPrompt |
prompt, attachments |
{ continue: boolean } |
✅ wired (beforeSubmitPrompt) |
afterFileEdit |
file_path, edits |
none (notification) | ✅ wired (afterFileEdit) |
stop |
status, loop_count |
followup_message (queued) |
✅ wired (stop) |
afterShellExecution |
command, output, duration |
none (notification) | ✅ wired (afterShellExecution / afterMCPExecution) |
afterMCPExecution |
tool_name, tool_input, result_json, duration |
none (notification) | ✅ wired (afterShellExecution / afterMCPExecution) |
postToolUse |
tool_name, tool_input, tool_output, tool_use_id, cwd, duration |
additional_context (queued) |
✅ wired |
postToolUseFailure |
tool_name, tool_input, tool_use_id, cwd, error_message, failure_type, duration, is_interrupt |
none | ✅ wired |
The real conversation_id (thread id) and generation_id (turn id) come from
the active run (the permission-hook I/O phase); they are empty strings only when a hook fires outside any
agent turn.
Matchers (per-event, matcher support)
A hook entry may carry an optional matcher — a regex string that filters
when the hook runs. Which field the regex is tested against depends on the event
(matching Cursor's "which field the matcher applies to depends on the hook"):
| Event(s) | matcher matched against |
|---|---|
beforeShellExecution / afterShellExecution |
the full shell command string |
beforeMCPExecution / afterMCPExecution |
the (MCP) tool name |
beforeReadFile |
the tool type (Read) |
afterFileEdit |
the tool type (Write) |
beforeSubmitPrompt |
the value UserPromptSubmit |
stop |
the value Stop |
subagentStart / subagentStop |
the subagent type |
preToolUse / postToolUse / postToolUseFailure |
the Cursor tool type |
{
"hooks": {
"beforeShellExecution": [{ "command": "./approve-network.sh", "matcher": "curl|wget|nc " }],
"beforeMCPExecution": [{ "command": "./mcp-guard.sh", "matcher": "db__query" }],
"subagentStart": [{ "command": "./validate-explore.sh", "matcher": "explore|shell" }]
}
}Semantics:
- No matcher fires for every action — Cursor's default. (For
afterFileEdit, Copse additionally supports aglobconvenience field, matched against the edited path; see below.) - An invalid regex skips the hook (skip-and-warn). Cursor's docs do not specify invalid-matcher behavior; Copse chooses to skip rather than fail-open so a broken matcher can never accidentally deny (or observe) every action. The skip is logged once with the offending pattern.
- MCP tool names are Copse's canonical form (
mcp__<server>__<tool>), so an MCP matcher is written against that — e.g.db__querymatchesmcp__db__query. (Cursor's dedicatedbeforeMCPExecution/afterMCPExecutionevents are not in its published "available matchers" list; Copse matches them by tool name, the natural analogue of Cursor'sMCP:<tool_name>tool-type token used by the genericpreToolUsehook.) - Generic post-tool names use Cursor's tool-type tokens:
Shell,Read,Write,Grep,Delete,Task, andMCP:<canonical-tool-name>. Copse-native tools with no direct Cursor analogue keep their canonical id. afterFileEdithas two independent filters that must both pass: the Copse-convenienceglob(path,string | string[], B2) and the Cursor nativematcher(tool typeWrite, D3). They are distinct fields with distinct meanings —globnarrows by which file changed,matchernarrows by the edit tool type. Since every Copse edit funnels through the diff-queue write path, aWritematcher matches and aTabWritematcher never does (Copse has no inline-tab edits).
Matcher evaluation is centralized in the Cursor adapter
(cursorMatcherMatches / cursorMatcherSubject) and applied at discovery — the
adapter's dispatch-side filter — so every event runs the same matcher code with
only its subject field differing (the dialect-by-source-path decision: adapters own matchers).
preToolUse ask. Cursor's docs say ask "is accepted by the schema but
not enforced for preToolUse today" — upstream it behaves as allow. Copse
does enforce it, escalating to its own approval prompt. That is a divergence in
the tightening direction, consistent with the dedicated flavors (where Cursor
does enforce ask) and with the rule that a hook can only ever tighten the gate.
stop / subagentStop follow-ups are queued held, not auto-submitted.
Upstream, a followup_message is submitted automatically as the next user
message — that is what makes loop-style flows work, and what loop_limit exists
to bound. Copse fires both events detached (decision 3), so by the time a
hook responds there is no turn to submit into, and silently auto-starting one is
exactly the bespoke protocol decision 4 rules out. The follow-up therefore lands
in the pending-message queue with sendNow: false — held, waiting for the
user to drain it.
Note this is a property of these two events, not of Copse: Copse does auto-continue (the unified auto-continuation budget, capped per turn tree, covers hook send-now, remediation, and closeout turns). A held follow-up simply never spends that budget until a human sends it.
Two consequences worth knowing:
loop_limitis ignored in a.cursor/hooks.json. The field is parsed and validated only by the Copse dialect, where it is reserved pending plan row C5. The Cursor adapter does not read it at all, so it is dropped silently — not even the reserved-field warning the Copse adapter emits. Since a Cursor follow-up is held rather than auto-submitted, nothing is currently unbounded by that; when C5 wires per-script enforcement, the Cursor adapter needs to start parsing it too.loop_countonstopstdin is always0, honestly so: a held follow-up has by definition never re-triggered a run. It is sent rather than omitted because vendor hook scripts read it unconditionally — Cursor's own documented example gates onloop_count < 4, which an absent field makes silently false.
Permission responses may also carry agentMessage / userMessage. A denying
hook's agentMessage is now surfaced to the agent as the tool-result reason
(the permission-hook I/O phase) — a message-bearing deny fails the call with that reason so the model sees
why. A hook ask escalates to Copse's approval prompt (the same prompt a
policy ask uses): approving lets the call proceed, declining blocks it. A hook
still can only tighten the gate — an allow never auto-approves something
Copse would otherwise prompt about.
beforeReadFile receives the file content on stdin (the permission-hook I/O phase), so a redaction /
secret-detection hook can inspect the bytes and deny. Cursor's beforeReadFile
response is allow / deny only — there is no content-rewrite field in the
vendor contract — so "redaction" is expressed as deny on inspection, not by
returning modified content.
| Capability | Status | Notes |
|---|---|---|
| Permission hooks | Supported | beforeShellExecution, beforeMCPExecution, beforeReadFile run in the permission gate |
| User hooks | Supported | ~/.cursor/hooks.json, always honoured |
| Project hooks | Supported | <root>/.cursor/hooks.json, only when the workspace is trusted (#100) |
| Hook discovery / list | Supported | hooks:list IPC returns hooks + validation warnings for the Sources panel |
| Lifecycle hooks | Supported | beforeSubmitPrompt, afterFileEdit, stop, afterShellExecution / afterMCPExecution are wired (phase B, phase D) |
Generic preToolUse |
Supported | The pre-side twin of postToolUse: gates every tool, not just the shell / MCP / read calls the dedicated flavors cover, so a hook can deny a write, a search, or a subagent spawn. Matcher is the Cursor tool-type token (Shell, Read, Write, Grep, Delete, Task, MCP:<tool_name>). Fires alongside a dedicated flavor when both are declared |
| Unwired Cursor events | Not supported | sessionEnd, preCompact, afterAgentResponse, afterAgentThought, the Tab events beforeTabFileRead / afterTabFileEdit, and the app-lifecycle workspaceOpen are recognised and reported as unsupported in Sources rather than dismissed as typos. Copse has no inline-tab surface at all, no canonical session-end / compaction fire site yet, and nowhere to hang an event that fires outside any agent session |
| Post-tool observation | Supported | afterShellExecution / afterMCPExecution plus generic postToolUse / postToolUseFailure fire detached from the one canonical afterToolUse event. Generic events cover every tool and split on success/failure; the output snapshot is capped before it reaches hook stdin. Because detached hooks cannot mutate an already-consumed result, additional_context becomes a budgeted queued message and updated_mcp_tool_output is not applied. |
beforeReadFile content |
Supported | The hook receives the file contents on stdin (permission-hook I/O) so it can inspect and deny (redaction = deny-on-inspection; Cursor has no content-rewrite response) |
| Model identity in payload | Supported | model / model_id / model_params on every agent-session event (permission-hook I/O), sourced from the model actually running |
agentMessage / ask |
Supported | A denying hook's agentMessage reaches the agent as the tool-result reason; a hook ask escalates to Copse's approval prompt (permission-hook I/O) |
| Content rewriting | Not supported | Hooks can block but not yet mutate prompts, read output, or edits (updated_input is the tool-gate input rewriting phase) |
| Plugin-contributed hooks | Not supported | Marketplace plugins do not declare hooks in current plugin.json examples |
| Settings UI | Supported | Developer mode → Settings → Customise → Hooks: cursorHooksEnabled toggle, discovered hooks, per-entry validation warnings, per-hook runtime error state (first failure per session) |
Hooks are off by default. Honouring a hook spawns a user/project script on the
agent's hot path, so it is gated behind the cursorHooksEnabled security setting
(Developer mode → Settings → Customise → Hooks). When disabled the gate skips discovery entirely (no
overhead); the Sources panel still lists discovered hooks so authoring problems are
visible before enabling.
Cursor is a dialect adapter (the dialect-adapter phase of the hooks platform,
docs/plans/hooks-and-feature-packs.md, the dialect-by-source-path decision:
"dialect by source path"). Discovery, parsing, matchers, wire marshalling in both
directions, and the per-event exit-code table all live in
packages/hooks-dialects/src/cursor-adapter.ts:
listCursorHooks()— discovered hooks for the current context (diagnostics /hooks:list)cursorToolGateHooks(payload, opts)— the Cursor command hooks matching a tool gate, as canonicalCommandHooks (theironFailureset fromfailClosed)cursorAdapter— theDialectAdapterthe shared runner delegates to (marshalling + the exit-code table)
The shared process spawn (stdin marshalling, stdout/stderr capture, timeout, output cap)
lives in hook-spawn.ts; the host runner in
command-hook-runner.ts spawns each
hook and applies its dialect's failure semantics.
The permission gate (permission-gate.ts)
maps a tool call onto the canonical toolGate event and calls
runToolGateHooks (tool-gate.ts), which fires
the hooks through the registry → runner → adapter seam. Hooks can only tighten the gate:
a deny blocks the call, but an allow still flows through Copse's normal prompting — a
hook can never auto-approve something Copse would otherwise ask about.
- Fail closed by Copse host policy. A hook crash, timeout, spawn error, or malformed
response denies a gated action. Omitted or invalid
failClosedresolves closed;{ "command": …, "failClosed": false }explicitly restores Cursor's fail-open compatibility behavior. Users can turn all external hooks off in Settings → Sources. - No LLM secrets. Hook processes inherit
envForRendererChildProcess()— the same scrubbed environment asrun_shell, so provider LLM API keys never reach hook scripts. Note this is not an empty environment: non-LLM tool tokens that the agent uses (for exampleGITHUB_TOKEN) are still present — see the Security section below. - Output is capped at 1 MB to bound a runaway hook.
| Source | Trust |
|---|---|
~/.cursor/hooks.json (user) |
Trusted — the user authored it |
<root>/.cursor/hooks.json |
Requires workspace trust (#100); skipped for untrusted clones |
Enabling Cursor hooks hands real, local execution authority to whoever authored the
hooks.json. Read this before turning the feature on.
Enabling cursorHooksEnabled and trusting a workspace grants that repo's
.cursor/hooks.json arbitrary local code execution on every gated tool call. Each
matching hook command is spawned through a shell on the agent's hot path, with the
directory of the hooks.json as its working directory. A trusted repo's hooks run with
whatever authority the sandbox leaves them (see below).
Concretely, "trusting a workspace" with hooks enabled also means:
- Arbitrary code on every tool call. Any tool call that maps to a hook event
(
beforeShellExecution,beforeMCPExecution,beforeReadFile) spawns the repo's hook command first. A cloned or third-party repo can ship ahooks.jsonthat runs whatever it likes, repeatedly, for the lifetime of the session. - Sandboxed by default (the sandbox phase, macOS-only). Hook processes run inside the project sandbox by default (the sandboxed-by-default decision of the
hooks platform plan), hook processes run inside
the project sandbox by default — the same workspace-scoped seatbelt that constrains the
agent's shell/file tools. Cursor / Claude hooks cannot opt out (only the Copse dialect's
sandbox: falsecan). Enforcement is macOS-only: on Linux / Windows there is no OS sandbox, so a hook still runs with full user authority — treat "sandboxed" as a default, not a guarantee. A hook the sandbox blocks is recorded on the spine and surfaced in Sources; it is never a silent fail-open. - Tool tokens are in the environment. Hook processes inherit the scrubbed
envForRendererChildProcess()environment. That strips LLM provider keys, but non-LLM tool tokens used by the agent (e.g.GITHUB_TOKEN) remain inenvand are therefore readable by a hook script. - Fail open — can tighten, never relied on to block. Hooks fail open by design
(timeout, crash, non-JSON, or oversized output → treated as
allow). A hook can tighten Copse's permission gate (adenyblocks a call), but it can never be relied upon to block: do not treat adeny-returning hook as a security control, because any failure path silently degrades toallow.
Mitigations and guidance:
- The feature is off by default. Leave it off unless you specifically need it.
- Only enable it for workspaces you would already trust to run arbitrary code on your
machine — the same bar as running the repo's build scripts or
npm install. - Project hooks are only honoured when the workspace is trusted (#100); untrusted clones are skipped entirely. Trust is the gate — granting it is the consent.
- When a project-supplied hook command runs for the first time in a session, Copse logs a one-time warning naming the command, so it is auditable in the logs.
This is the same trust boundary described in
docs/supply-chain-security.md: trusting a workspace means
trusting the code it can cause to run.
Copse pins committed copies of the upstream hook-config JSON schemas for both
foreign dialects under schemas/vendor/ —
claude-code-settings.schema.json (Claude Code, from SchemaStore) and
cursor-hooks.schema.json (the community cursor-hooks npm schema). See
schemas/vendor/README.md for provenance, pins,
and the re-vendoring steps.
These exist for exactly two purposes, and are subject to two hard rules — they are never fetched over the network at runtime or in CI, and they are never a load gate (a config that violates an upstream schema still loads):
- Warn-level authoring lint. Parsing a foreign config uses the schema's published event list to warn when a hooks group targets an event the vendor recognises but Copse does not act on yet (vs an outright typo). The valid hooks still load; the warning surfaces in Settings → Customise.
- CI drift detector (
packages/hooks-dialects/src/vendor-schema-drift.test.ts) diffs each vendored schema's published events against the events our adapters wire. Every published event must be either wired or listed in an explicit intentionally-unsupported set (packages/hooks-dialects/src/vendored-hook-schemas.ts); an upstream release adding an unaccounted event fails CI until it is wired or documented. Copse currently wires ClaudePreToolUse+SessionStartand the Cursor events above; the long tail of Claude events (Notification,TeammateIdle, …) is intentionally-unsupported v1.
Every dialect wire request payload — the stdin JSON a Cursor / Claude / Copse
hook actually receives — is snapshot-tested against a committed golden fixture
src/main/services/hooks/__snapshots__/wire-payloads.json
by src/main/services/hooks/payload-snapshots.test.ts.
The test marshals a fixed synthetic payload (with a fixed agent-session identity,
so the B4 model fields are captured) for every canonical event each dialect
declares a marshaller for — including the tool-flavor splits (shell / MCP / read
for toolGate, shell / MCP for afterToolUse) — and asserts the result is
byte-identical to the fixture.
This implements the payload stability at publish decision of
docs/plans/hooks-and-feature-packs.md:
pre-v1 with zero consumers we do not version payloads, but the request direction
is the stability contract, so changing a snapshot is a publish-time stability
audit — the reviewed JSON diff of the golden fixture is the stability
declaration. Regenerate the fixture (and review the diff) with:
UPDATE_HOOK_PAYLOAD_SNAPSHOTS=1 npm test- Content rewriting —
updated_inputon tool gates (rewrite the proposed tool input, re-running policy analysis) is the tool-gate input rewriting phase, not yet wired. - Hook cards — deny/ask decisions and hook executions surface today through the
existing text / approval-prompt channels; the dedicated right-aligned hook-card
UI family is the hook cards UI phase.
userMessageon a plain deny (no approval prompt) waits for that card surface. - Claude
SessionStartmodel — the wire payload type carries the running model (AgentSessionInfo), but Claude's optionalmodelonsessionStartneeds thesessionStartfire site, which is the per-hook timeout + sessionStart phase; Cursor agent-session events carry model identity now (the permission-hook I/O phase). - Plugin-contributed hooks — if Cursor adds a
hooksslot toplugin.json, load them via the sharedcursor-pluginsdiscovery module.
packages/hooks-dialects/src/cursor-adapter.ts— Cursor dialect adapter: discovery, parsing (incl.failClosed), matchers, wire marshalling, exit-code tablepackages/hooks-dialects/src/claude-adapter.ts— Claude Code PreToolUse dialect adapter (#639)packages/hooks-dialects/src/hook-spawn.ts— shared process spawn + stdout/stderr capturepackages/hooks-dialects/src/command-hook-runner.ts— host runner; applies dialect failure semanticssrc/main/services/hooks/tool-gate.ts— maps tool calls onto the canonicaltoolGateeventsrc/main/services/security/permission-gate.ts— calls the tool-gate hookspackages/hooks-dialects/src/cursor-hooks.ts—CursorHookEvent/CursorHookSummarypackages/hooks-dialects/src/hooks-types.ts— sharedHookSummaryfor Sources /hooks:listpackages/hooks-dialects/src/vendored-hook-schemas.ts— published-event mirrors + intentionally-unsupported sets (vendored schemas)schemas/vendor/— pinned upstream Cursor + Claude hook schemas (vendored schemas); see itsREADME.mdpackages/hooks-dialects/src/vendor-schema-drift.test.ts— CI drift detector (vendored schemas)src/main/services/hooks/payload-snapshots.test.ts— dialect wire payload snapshot tests (wire payload snapshots)src/main/services/hooks/__snapshots__/wire-payloads.json— committed golden wire-payload fixture (wire payload snapshots)src/main/services/exec/child-process-env.ts— secret-scrubbed env for hook processesdocs/hooks.md— dialect-agnostic hooks architecture umbrelladocs/claude-hooks.md— Claude Code hooks contractdocs/cursor-plugins.md— sibling exploration of Cursor plugin supportdocs/supply-chain-security.md— trust boundaries for executed code