Skip to content

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.

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.

Cursor loads hooks from several sources. When responses conflict, higher-priority sources win: Enterprise, then Team, then Project, then User.

MechanismWhere the files goReachesRemovable 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 fileThat device: Agent Chat, Cmd+K, and the CLILocal administrator only
Team hooksCursor dashboard, Enterprise planMembers of the team, including cloud agentsOnly from the dashboard
Project hooks.cursor/hooks.json, committedSessions in that repository, including cloud agentsYes
User hooks~/.cursor/hooks.jsonThat developer’s local IDE and CLIYes

User hooks don’t reach cloud agents. Cloud agent VMs have no access to a developer’s home directory.

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.

arcjet-hook.sh
#!/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=$1
event=$2
principal=${USER:-${USERNAME:-}}
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) || deny
trap 'rm -f "$tmp"' EXIT
code=$(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 @-) || deny
case "$code" in
2??) ;;
*) deny ;;
esac
head=$(dd if="$tmp" bs=1 count=1 2>/dev/null) || deny
case "$head" in
\{|\[) ;;
*) deny ;;
esac
cat "$tmp"
exit 0

Make 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:

arcjet-hook.ps1
param([string]$Vendor, [string]$Event)
$principal = if ($env:USERNAME) { $env:USERNAME } else { $env:USER }
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.

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
.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.

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/cursor with event as a query parameter. Policy inputs identify Cursor as agent_vendor: cursor and agent_product: cursor.
  • preToolUse runs on Tool call. The wrapper sends event=pre-tool-use. Cursor fires it for Shell, Read, Write, MCP, Task, and the other agent tools. beforeSubmitPrompt runs 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, and beforeReadFile. They alias onto pre-tool-use and fire in addition to preToolUse. This template uses preToolUse so one entry covers every tool without posting the same call twice. The matching after hooks (afterShellExecution, afterMCPExecution, afterFileEdit) alias onto post-tool-use. Don’t install those either.
  • Cursor reports Shell, Read, Write, Grep, Delete, and Task. Arcjet maps those to shell, file_read, file_write, and agent. That list is the tools Cursor reports today. A name that isn’t in it is tool_kind: other, so a rule over the kind never matches it. A specialized before-hook that omits tool_name is inferred as Shell, Read, or Write.
  • Omit matcher. A matcher that lists only Shell or Write leaves reads, MCP tools, and Task calls outside the policy.
  • failClosed: true on the two enforcement entries. Cursor’s default is fail-open: a crash, a timeout, or invalid JSON lets the action through.
  • timeout on every entry. Five seconds is a sensible ceiling for a policy decision.
  • X-Arcjet-Principal attributes a session to a developer. It’s untrusted, but useful extra metadata. $USER is unset on Windows, so the script falls back to $USERNAME.
  • The hook URLs omit surface. The same file reaches the IDE, CLI, and cloud, so a hard-coded ide mislabels cloud sessions. Arcjet records unknown when you omit the parameter. Set it only when the hook file is specific to one surface.

The wrapper prints Arcjet’s JSON to standard output. Cursor reads that as the hook result. Echo the body as-is.

EventDenial bodyHonored
pre-tool-use{ "permission": "deny", "user_message": "RULE_ID", "agent_message": "RULE_ID" }Yes
user-prompt-submit{ "continue": false, "user_message": "RULE_ID" }Yes

An allow is {}. Cursor treats permission: "allow" as a grant that skips its own permission flow. The denial reason names the rule IDs that fired and nothing else.

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.

X-Arcjet-Principal uses $USER or $USERNAME. 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.

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.

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.

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.

  1. Open View > Output and select the Hooks channel, or open Customize > Hooks, and confirm the Arcjet commands loaded.

  2. 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.

  3. Open the site’s Activity in the Arcjet Console and confirm the session and the tool call appear.