Secure Cursor
Cursor already fires hooks for prompts and tool calls. Arcjet uses those hooks to enforce your policies and record the session. You don’t change how developers work.
What is Arcjet?
Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.Before you start
Section titled “Before you start”Create a free Arcjet account then use the key to authenticate the Arcjet hooks. It is semi-secret, but can be distributed to multiple devices and team members through environment variables or configuration management.
Cursor runs hooks as commands, not as native HTTP handlers. The install is a
small script that reads the hook payload from standard input, posts it to
Arcjet, and writes Arcjet’s response to standard output. The machine that
runs Cursor needs curl.
Choose where to deploy
Section titled “Choose where to deploy”Cursor loads hooks from several sources. When responses conflict, higher-priority sources win: Enterprise, then Team, then Project, then User.
| Mechanism | Where the files go | Reaches | Removable by a developer |
|---|---|---|---|
| Enterprise (MDM) | /Library/Application Support/Cursor/hooks.json (macOS), /etc/cursor/hooks.json (Linux, WSL), C:\ProgramData\Cursor\hooks.json (Windows), plus scripts beside that file | That device: Agent Chat, Cmd+K, and the CLI | Local administrator only |
| Team hooks | Cursor dashboard, Enterprise plan | Members of the team, including cloud agents | Only from the dashboard |
| Project hooks | .cursor/hooks.json, committed | Sessions in that repository, including cloud agents | Yes |
| User hooks | ~/.cursor/hooks.json | That developer’s local IDE and CLI | Yes |
User hooks don’t reach cloud agents. Cloud agent VMs have no access to a developer’s home directory.
Install the hook script
Section titled “Install the hook script”Save this script next to the hook configuration. In a repository, put it at
.cursor/hooks/arcjet-hook.sh. For an enterprise install, put it in the
same directory as the managed hooks.json.
#!/bin/sh# Usage: arcjet-hook.sh VENDOR EVENT# Reads one hook payload from stdin and posts it to Arcjet.# On success: print the body and exit 0.# On any transport failure, non-2xx, or non-JSON 2xx: print the# vendor denial shape and exit 2.
vendor=$1event=$2# Prefer Cursor's signed-in email, then an explicit override, then# the git author Cursor injects, then the Cloud Agent socket (turn# email, then owner email). Skip ubuntu. Strip CR/LF so a tainted# env value cannot smuggle extra headers.principal=${CURSOR_USER_EMAIL:-${ARCJET_PRINCIPAL:-${GIT_AUTHOR_EMAIL:-${GIT_AUTHOR_NAME:-}}}}if [ -z "${principal}" ] && [ -n "${CURSOR_AGENT_SOCKET-}" ] \ && [ -S "${CURSOR_AGENT_SOCKET}" ]; then _sock=$(mktemp) || _sock="" if [ -n "${_sock}" ]; then trap 'rm -f "${_sock}"' EXIT for _path in /v1/meta-data/turn/user-email \ /v1/meta-data/owner/user-email; do _code=$(curl -sS --max-time 1 -o "${_sock}" -w "%{http_code}" \ --unix-socket "${CURSOR_AGENT_SOCKET}" \ "http://localhost${_path}" || printf '%s' "000") if [ "${_code}" = "200" ]; then principal=$(tr -d '\r\n' <"${_sock}") if [ -n "${principal}" ]; then break fi fi done rm -f "${_sock}" fi unset _sock _path _codefiif [ -z "${principal}" ]; then case ${USER-} in "" | ubuntu) ;; *) principal=${USER} ;; esacfiif [ -z "${principal}" ]; then case ${USERNAME-} in "" | ubuntu) ;; *) principal=${USERNAME} ;; esacfiprincipal=$(printf %s "${principal}" | tr -d '\r\n')
deny() { case "${vendor}:${event}" in cursor:pre-tool-use) printf '%s\n' '{"permission":"deny","user_message":"arcjet-hook","agent_message":"arcjet-hook"}' ;; cursor:user-prompt-submit) printf '%s\n' '{"continue":false,"user_message":"arcjet-hook"}' ;; *) printf '%s\n' '{}' ;; esac exit 2}
tmp=$(mktemp) || denytrap 'rm -f "$tmp"' EXITcode=$(curl -sS --max-time 5 -o "$tmp" -w '%{http_code}' -X POST \ "https://decide.arcjet.com/v1/agent-hooks/${vendor}?event=${event}" \ -H "Authorization: Bearer ${ARCJET_KEY}" \ -H "Content-Type: application/json" \ -H "X-Arcjet-Principal: ${principal}" \ --data-binary @-) || denycase "$code" in 2??) ;; *) deny ;;esachead=$(dd if="$tmp" bs=1 count=1 2>/dev/null) || denycase "$head" in \{|\[) ;; *) deny ;;esaccat "$tmp"exit 0Make the script executable. Project hooks run from the project root, so the
command path is .cursor/hooks/arcjet-hook.sh.
On Windows, save a sibling arcjet-hook.ps1:
param([string]$Vendor, [string]$Event)# Prefer Cursor's signed-in email, then an explicit override, then# the git author Cursor injects, then the OS account. The Unix# socket is a Cloud Agent path; Windows uses the env fallbacks.# Skip ubuntu.$principal = if ($env:CURSOR_USER_EMAIL) { $env:CURSOR_USER_EMAIL } ` elseif ($env:ARCJET_PRINCIPAL) { $env:ARCJET_PRINCIPAL } ` elseif ($env:GIT_AUTHOR_EMAIL) { $env:GIT_AUTHOR_EMAIL } ` elseif ($env:GIT_AUTHOR_NAME) { $env:GIT_AUTHOR_NAME } ` elseif ($env:USERNAME -and $env:USERNAME -ne 'ubuntu') { $env:USERNAME } ` elseif ($env:USER -and $env:USER -ne 'ubuntu') { $env:USER } ` else { '' }$principal = ($principal -replace '[\r\n]', '')function Deny { switch ("$Vendor`:$Event") { "cursor:pre-tool-use" { Write-Output '{"permission":"deny","user_message":"arcjet-hook","agent_message":"arcjet-hook"}' } "cursor:user-prompt-submit" { Write-Output '{"continue":false,"user_message":"arcjet-hook"}' } default { Write-Output '{}' } } exit 2}try { $body = [Console]::In.ReadToEnd() $headers = @{ Authorization = "Bearer $env:ARCJET_KEY" "Content-Type" = "application/json" "X-Arcjet-Principal" = "$principal" } $response = Invoke-WebRequest -Method Post -TimeoutSec 5 ` -Uri "https://decide.arcjet.com/v1/agent-hooks/$Vendor`?event=$Event" ` -Headers $headers -Body $body if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { Deny } $content = $response.Content if ($content -notmatch '^\s*[\{\[]') { Deny } Write-Output $content} catch { Deny}arcjet-hook reads stdin, POSTs it to
https://decide.arcjet.com/v1/agent-hooks/<vendor>?event=<event> with
Authorization: Bearer $ARCJET_KEY, prints the body, and exits 0. On
failure it prints the denial shape for that vendor and event and exits 2.
Cursor is configured with failClosed: true, so a crash, timeout, or
invalid JSON denies as well. A wrong key denies every Cursor prompt and
tool call. The script does not log the body.
Install the hooks
Section titled “Install the hooks”This template is a complete setup. Put this file in the repository as
.cursor/hooks.json. It installs every enforcement point plus the recorded
events worth keeping. It omits matcher, so Arcjet sees every tool Cursor
reports on preToolUse.
IDE sessions run both preToolUse and beforeSubmitPrompt. Cloud agents
run preToolUse only. Tool calls are refused; the prompt itself is not,
because it was submitted before the VM existed. Enterprise team hooks
reach cloud agents. User-level ~/.cursor/hooks.json does not. A
repository file on the default branch is what cloud agents load.
Cursor has no model-switch hook. A model-list policy refuses the prompt and the tool call while a model that isn’t in the list is selected. For more information about writing the list, see Allowed models.
Show .cursor/hooks.json
{ "version": 1, "hooks": { "preToolUse": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor pre-tool-use", "timeout": 5, "failClosed": true } ], "beforeSubmitPrompt": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor user-prompt-submit", "timeout": 5, "failClosed": true } ], "postToolUse": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor post-tool-use", "timeout": 5 } ], "postToolUseFailure": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor post-tool-use-failure", "timeout": 5 } ], "sessionStart": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor session-start", "timeout": 5 } ], "sessionEnd": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor session-end", "timeout": 5 } ], "subagentStart": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor subagent-start", "timeout": 5 } ], "subagentStop": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor subagent-stop", "timeout": 5 } ], "stop": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor stop", "timeout": 5 } ], "preCompact": [ { "command": "sh .cursor/hooks/arcjet-hook.sh cursor pre-compact", "timeout": 5 } ] }}For an enterprise or user install, change the command to an absolute path
or to ./hooks/arcjet-hook.sh so it resolves from that source’s working
directory.
What the hooks do
Section titled “What the hooks do”Cursor runs every matching hook for an event, so these entries add one request of latency (typically a few tens of milliseconds).
- The wrapper posts to
https://decide.arcjet.com/v1/agent-hooks/cursorwitheventas a query parameter. Policy inputs identify Cursor asagent_vendor: cursorandagent_product: cursor. preToolUseruns on Tool call. The wrapper sendsevent=pre-tool-use. Cursor fires it forShell,Read,Write, MCP,Task, and the other agent tools.beforeSubmitPromptruns on Prompt (event=user-prompt-submit). Cursor honors a prompt denial. Cursor has no permission-request or prompt-expansion hook.- Cursor also has
beforeShellExecution,beforeMCPExecution, andbeforeReadFile. They alias ontopre-tool-useand fire in addition topreToolUse. This template usespreToolUseso one entry covers every tool without posting the same call twice. The matching after hooks (afterShellExecution,afterMCPExecution,afterFileEdit) alias ontopost-tool-use. Don’t install those either. - Cursor reports
Shell,Read,Write,Grep,Delete, andTask. Arcjet maps those toshell,file_read,file_write, andagent. That list is the tools Cursor reports today. A name that isn’t in it istool_kind: other, so a rule over the kind never matches it. A specialized before-hook that omitstool_nameis inferred asShell,Read, orWrite. - Omit
matcher. A matcher that lists onlyShellorWriteleaves reads, MCP tools, and Task calls outside the policy. failClosed: trueon the two enforcement entries. Cursor’s default is fail-open: a crash, a timeout, or invalid JSON lets the action through.timeouton every entry. Five seconds is a sensible ceiling for a policy decision.X-Arcjet-Principalattributes a session to a developer. It’s untrusted, but useful extra metadata. The script prefersCURSOR_USER_EMAIL, thenARCJET_PRINCIPAL, thenGIT_AUTHOR_EMAIL, thenGIT_AUTHOR_NAME, then the Cloud Agent socket, then$USER/$USERNAMEwhen the value is notubuntu. See Attribute the developer.- The hook URLs omit
surface. The same file reaches the IDE, CLI, and cloud, so a hard-codedidemislabels cloud sessions. Arcjet recordsunknownwhen you omit the parameter. Set it only when the hook file is specific to one surface.
What Arcjet answers
Section titled “What Arcjet answers”The wrapper prints Arcjet’s JSON to standard output. Cursor reads that as the hook result. Echo the body as-is.
| Event | Denial body | Honored |
|---|---|---|
pre-tool-use | { "permission": "deny", "user_message": "REASON", "agent_message": "REASON" } | Yes |
user-prompt-submit | { "continue": false, "user_message": "REASON" } | Yes |
An allow is {}. Cursor treats permission: "allow" as a grant that
skips its own permission flow. REASON is the denial reason. It names the
rule IDs that fired, the denying policy’s label and decision ID, and a link
to report a false positive. It never includes a rule’s description.
Supply the Arcjet key
Section titled “Supply the Arcjet key”The script reads ARCJET_KEY from the environment. Export it where Cursor
runs, or inject it through your configuration-management tooling. Never
commit a literal key.
Attribute the developer
Section titled “Attribute the developer”Activity’s Actor column comes from X-Arcjet-Principal. That value is
untrusted: it is an assertion from the same configuration that holds
the API key, not proof of identity, and it never becomes the Guard actor.
Cursor also sends user_email on the hook payload when the developer is
signed in. Arcjet reads the asserted principal from the header, not from
that field.
The install script picks the principal in this order:
CURSOR_USER_EMAIL– injected by Cursor in local signed-in IDE and CLI sessions; unset on cloud agent VMsARCJET_PRINCIPAL– an explicit override you inject (MDM, secrets managers, or a cloud agent environment)GIT_AUTHOR_EMAIL, thenGIT_AUTHOR_NAME– the git author Cursor injects on some cloud agents- The Cloud Agent socket –
CURSOR_AGENT_SOCKET. The wrapper requeststurn/user-email(who submitted the current turn), thenowner/user-email(the agent owner), withcurl --unix-socket. A 200 response with a non-empty body wins. If the request fails, returns anything else, or comes back empty, the wrapper tries the next source and says nothing. A broken socket can leave Actor empty. The Windows script skips the socket. $USER, then$USERNAME– the OS account, only when the value is notubuntu. Empty is better than a misleading Actor.
The script does not read git config. Cursor sets that to
Cursor Agent on cloud VMs, which is the bot.
Cloud agent VMs run as ubuntu and do not set CURSOR_USER_EMAIL.
Some also leave the git author unset. The template reads the socket
in that case, and it skips ubuntu rather than sending it. Set
ARCJET_PRINCIPAL only when you want a different assertion.
Lock it down
Section titled “Lock it down”Enterprise MDM and Team dashboard hooks outrank a developer’s project or
user file. Distribute the script with the hooks.json so the command
path exists on every machine.
Project hooks run only in a trusted workspace. A developer who rejects workspace trust skips the repository file.
Cloud agents
Section titled “Cloud agents”A cloud agent runs command hooks
from the repository. Commit .cursor/hooks.json and
.cursor/hooks/arcjet-hook.sh so the cloud VM can execute them.
The template prefers CURSOR_USER_EMAIL, then ARCJET_PRINCIPAL,
then the git author Cursor injects, then the Cloud Agent socket
(CURSOR_AGENT_SOCKET, turn email then owner email). Cloud agent
VMs run as ubuntu and do not set CURSOR_USER_EMAIL; some also
leave the git author unset. The wrapper skips ubuntu rather than
sending it, so the socket is what Activity shows in that case. Set
ARCJET_PRINCIPAL only when you want a different assertion. For
more information about the order, see
Attribute the developer.
On Enterprise plans, cloud agents also run Team hooks and enterprise-managed hooks from the dashboard.
Cloud agents sometimes start in a read-only environment for early exploratory turns. Hooks don’t run during those turns. They start once the agent has a writable environment.
The following hooks don’t run in cloud agents: sessionStart,
sessionEnd, beforeMCPExecution, and afterMCPExecution. Tab hooks
and workspaceOpen are IDE-only. The template still installs
sessionStart and sessionEnd for local sessions.
Cloud agents have internet access by default. If you restrict
network egress,
add decide.arcjet.com to the allowlist for Default + allowlist or
Allowlist only. The hook is an HTTP POST of the vendor payload.
Verify the install
Section titled “Verify the install”-
Open View > Output and select the Hooks channel, or open Customize > Hooks, and confirm the Arcjet commands loaded.
-
Start an agent session in a repository that carries the hook file and ask it to run a harmless command, such as listing a directory.
-
Open the site’s Activity in the Arcjet Console and confirm the session and the tool call appear.
Related
Section titled “Related”- Coding agent hook reference – the endpoint, the events, and where enforcement stops
- Coding agent policies – the input contract and the starter policies
- Secure Claude Code
- Secure GitHub Copilot
- Secure OpenAI Codex
- Secure Muse Code
- Block personal coding agent accounts – keep personal Cursor and other agents off the managed network