Secure coding agents
A coding agent runs shell commands, edits files, fetches URLs, and calls MCP servers with the developer’s credentials. Arcjet checks each of those tool calls against your policies before the agent makes it, from a hook the agent already fires, and records every other lifecycle event so you can see what the agent did.
Arcjet also screens what the developer typed for prompt injection and sensitive information, and records the whole session as activity in the Console.
There is no SDK to install and no code to change. Publish a policy from the Arcjet Console.
┌─────────────┐ hook ┌───────────────┐ allow ┌───────────────┐│ Coding agent│───────────────▶│ Arcjet policy │────────────▶│ Tool runs │└─────────────┘ └───────┬───────┘ └───────────────┘ │ │ deny ▼ ┌───────────────┐ │ Agent stops │ └───────────────┘How it works
Section titled “How it works”Coding agent events are tracked using HTTP hooks that report events to Arcjet. When a hook fires, Arcjet runs the policies attached to that event and decides whether the agent can proceed. You can enforce a policy or log only, and a change takes effect in real time. Evaluation runs at the edge in over 300 data centers, so the extra latency stays small.
Policies work across every supported coding agent, so you don’t write a separate policy for each one.
If a policy denies an action, the agent is told the rule ID, for example:
Blocked by Arcjet policy: destructive-command.Policy enforcement
Section titled “Policy enforcement”Choose one or more Execute on options. There are three: Tool
call, Prompt, and Model switch. Events that share an Execute
on option share inputs. model is filled only on the vendors in
Where model is filled.
| Execute on | Events | Inputs the policy can read |
|---|---|---|
| Tool call | pre-tool-use, permission-request | tool_name, tool_kind, command, command_tokens, paths, domains, destinations, permission_mode, mcp_server, mcp_tool, model |
| Prompt | user-prompt-submit, user-prompt-expansion | prompt, model |
| Model switch | pre-model-switch enforces. post-model-switch is recorded and never adjudicated. | model |
A model rule on Tool call or Prompt does nothing for Claude Code, Copilot,
or Muse Code, and that is intended. It is not an INCOMPLETE policy.
The hook URL names the vendor. Use POST /v1/agent-hooks/claude-code, /copilot, /codex, /cursor, or /muse-code, with event as a query parameter.
event | Claude Code | Copilot | Codex | Cursor | Muse Code | Denial honoured |
|---|---|---|---|---|---|---|
pre-tool-use | PreToolUse | preToolUse | PreToolUse | preToolUse (aliases: beforeShellExecution, beforeMCPExecution, beforeReadFile) | PreToolUse | All |
permission-request | PermissionRequest | permissionRequest | PermissionRequest | No such hook | PermissionRequest | Claude Code, Copilot, Codex, Muse Code |
user-prompt-submit | UserPromptSubmit | userPromptSubmitted | UserPromptSubmit | beforeSubmitPrompt | UserPromptSubmit | Claude Code, Codex, Cursor, Muse Code |
user-prompt-expansion | UserPromptExpansion | – | – | – | – | Claude Code only |
pre-model-switch | PreModelSwitch | – | – | – | – | Claude Code only |
Recorded events
Section titled “Recorded events”Arcjet tracks all these events, but can only enforce policy on the enforceable events in the preceding table.
event | Claude Code | Copilot | Codex | Cursor | Muse Code | What it carries |
|---|---|---|---|---|---|---|
post-model-switch | PostModelSwitch | – | – | – | – | The model the session is now using |
post-tool-use | PostToolUse | postToolUse | PostToolUse | postToolUse (aliases: afterShellExecution, afterMCPExecution, afterFileEdit) | PostToolUse | What the tool returned |
post-tool-use-failure | PostToolUseFailure | postToolUseFailure | – | postToolUseFailure | PostToolUseFailure | Why it did not |
user-prompt-transformed | – | userPromptTransformed | – | – | – | The model-facing prompt after a rewrite |
stop | Stop | agentStop | Stop | stop | Stop | The agent’s final message |
subagent-start | SubagentStart | subagentStart | SubagentStart | subagentStart | SubagentStart | Work delegated to another agent |
subagent-stop | SubagentStop | subagentStop | SubagentStop | subagentStop | SubagentStop | A subagent’s final message and its name |
session-start | SessionStart | sessionStart | SessionStart | sessionStart | SessionStart | When an agent started, and from what |
session-end | SessionEnd | sessionEnd | SessionEnd | sessionEnd | SessionEnd | When it stopped, and why |
notification | Notification | notification | – | – | Notification | Permission prompts and elicitations |
permission-denied | PermissionDenied | – | – | – | – | What the agent’s own controls stopped |
pre-compact | PreCompact | preCompact | PreCompact | preCompact | PreCompact | Context discarded mid-session |
post-compact | PostCompact | – | – | – | PostCompact | After context compaction |
error | StopFailure | errorOccurred | – | – | – | Why a turn failed |
config-change | ConfigChange | – | – | – | – | Settings changed, which is a tamper signal |
instructions-loaded | InstructionsLoaded | – | – | – | – | A CLAUDE.md entering context |
task-created | TaskCreated | – | – | – | – | A task is being created |
task-completed | TaskCompleted | – | – | – | – | A task is marked complete |
teammate-idle | TeammateIdle | – | – | – | – | An agent-team teammate is about to go idle |
cwd-changed | CwdChanged | – | – | – | – | The working directory changed |
directory-added | DirectoryAdded | – | – | – | – | A directory added mid-session |
file-changed | FileChanged | – | – | – | – | A watched file changed on disk |
elicitation | Elicitation | – | – | – | – | An MCP server requested user input |
elicitation-result | ElicitationResult | – | – | – | – | The user responded to an MCP elicitation |
What Arcjet answers
Section titled “What Arcjet answers”A denial uses the vendor’s own hook-output shape. An allow is always {}.
Arcjet never answers permissionDecision: "allow" or Cursor’s
permission: "allow": those are grants that skip the agent’s own
permission flow. Codex can also rewrite the call when an allow carries
updatedInput.
| Event | Claude Code / Codex / Muse Code | Copilot | Cursor |
|---|---|---|---|
pre-tool-use | hookSpecificOutput.permissionDecision: "deny" | Flat permissionDecision and the nested object | { permission: "deny", user_message, agent_message } |
permission-request | hookSpecificOutput.decision: {behavior, message} | Flat behavior / message | No such hook |
user-prompt-submit | Top-level { decision: "block", reason } | Output dropped. Can’t enforce | { continue: false, user_message } |
user-prompt-expansion | Top-level { decision: "block", reason } (Claude Code) | No such hook | No such hook |
pre-model-switch | hookSpecificOutput.permissionDecision: "deny" (Claude Code). Codex, Cursor, and Muse Code do not fire this. | – | – |
| Event | Vendor | Denial |
|---|---|---|
pre-model-switch | Claude Code | hookSpecificOutput.hookEventName is PreModelSwitch, permissionDecision is deny, permissionDecisionReason is the rule id. Exit 0 with that JSON. |
pre-tool-use | Codex | hookSpecificOutput.hookEventName is PreToolUse, permissionDecision is deny, permissionDecisionReason is the rule id. The legacy {decision: "block", reason} shape is also accepted by Codex; send the permissionDecision shape only. |
permission-request | Codex | hookSpecificOutput.decision.behavior is deny, message is the rule id. |
user-prompt-submit | Codex | Top-level {decision: "block", reason}. reason is the rule id. |
pre-tool-use | Muse Code | hookSpecificOutput.hookEventName is PreToolUse, permissionDecision is deny, permissionDecisionReason is the rule id. |
permission-request | Muse Code | hookSpecificOutput.decision.behavior is deny, message is the rule id. |
user-prompt-submit | Muse Code | Top-level {decision: "block", reason}. reason is the rule id. |
pre-tool-use | Cursor | {permission: "deny", user_message, agent_message}. Both messages are the rule id. ask is not sent: Cursor accepts it on this event and does not enforce it. |
user-prompt-submit | Cursor | {continue: false, user_message}. user_message is the rule id. This is beforeSubmitPrompt. |
Policy inputs identify the agent as agent_vendor / agent_product:
anthropic / claude-code, github / copilot, openai / codex,
cursor / cursor, or meta / muse-code.
Enforcement limitations
Section titled “Enforcement limitations”- Claude Code and Copilot HTTP hooks fail open on timeout, network error,
and non-2xx, except
PreModelSwitch: a timeout there blocks the switch, and the default timeout is 30 seconds. The install setstimeout: 30on that entry. A slow response can block a legitimate switch. A 401 does not blockPreToolUse, but aPreModelSwitchtimeout does block the switch. Keep those fail-open facts for every Claude Code and Copilot hook other thanPreModelSwitch. - Codex, Cursor, and Muse Code are not fail-open HTTP. None of them has
an HTTP hook type. The wrapper is the fail-closed boundary: it POSTs the
raw stdin to the vendor’s URL and prints the response body; on any
transport failure, non-2xx, or non-JSON 2xx, it writes the vendor’s denial
shape to stdout and exits 2. Exit 2 is what Codex and Muse Code treat as a
deny on
PreToolUseandUserPromptSubmit. Cursor is configured withfailClosed: true, so a crash, timeout, or invalid JSON denies as well. A wrong key denies every Codex, Cursor, and Muse Code prompt and tool call. - Cursor has no
permission-requesthook. A tool-call policy still runs onpreToolUse. - Copilot can’t honor a prompt denial. It drops hook output on
userPromptSubmitted. Arcjet records the decision and marks it as not enforced. Claude Code, Codex, Cursor, and Muse Code honor a prompt denial. - Codex hosted tools skip
PreToolUse.WebSearchand other hosted tools don’t use Codex’s local function-tool hook path, so a tool-call policy never sees them. - A recorded event can never block. No supported agent offers a point where Arcjet could withhold a tool result. A poisoned web page or MCP response is the most common route for an injection into a coding agent, and the hook can’t catch it, because the tool has already run.
- A hook binds one client, not one person. A repository-level hook can be deleted, a third-party model provider skips the server-managed settings fetch, and a developer calling the API from another tool is outside all of it. Reconcile against OpenTelemetry or Compliance API ingest to find sessions with no hook decisions.
- Personal accounts don’t reach Arcjet. A personal Claude, ChatGPT, Copilot, or Cursor login on a corporate laptop uses the same product domains as the enterprise tier. Hooks and Compliance don’t see that session. Refuse it at the network or device with account restrictions.
Allowed models
Section titled “Allowed models”Allowed models is a normal Rego Guard policy. Write the list of model ids in Rego and choose when it runs: Tool call, Prompt, and Model switch. The hook file does not name the policy.
A rule over model does two different things, depending on which agent fired
the hook and which Execute on options you selected:
- On Claude Code it refuses the switch onto a model that is not in the list.
- On Codex and Cursor it refuses the prompt and the tool call while a model that is not in the list is selected.
Claude Code denies the switch only. Codex and Cursor deny the prompt and the tool call. Copilot and Muse Code are not covered.
| Agent | Blocks the switch | Blocks the next prompt | Blocks tool calls | How the hook is installed |
|---|---|---|---|---|
| Claude Code | Yes. PreModelSwitch. | No. Those payloads have no model. | No. Same reason. | HTTP entry, same as the other Claude hooks. |
| Codex | No. There is no switch hook. | Yes. UserPromptSubmit carries model and honours decision: "block". | Yes. PreToolUse and PermissionRequest carry model and honour a deny. | Command wrapper. Codex has command and MCP-tool hooks, not HTTP. |
| Cursor | No. There is no switch hook. | Yes, in the IDE. beforeSubmitPrompt carries model / model_id and honours continue: false. | Yes. preToolUse carries the same fields and honours permission: "deny". | Command wrapper. Cursor hooks are command-only. |
| Muse Code | No. There is no switch hook. | No. Official hook docs do not publish a model field. | No. Same reason. | Command wrapper. Muse Code hooks are command-only. |
| Copilot | No. | No. | No. | Not this policy. Copilot hook payloads have no model field. |
A session that opened on a disallowed model and never switches is stopped on Codex and Cursor (the next prompt and every tool call) and is not stopped on Claude Code or Muse Code. Claude Code still needs the vendor’s own org default or banned-model setting for that case.
Cloud agents:
- Claude Code cloud sessions run the same managed HTTP hooks, so the switch block applies there.
- Codex managed hooks from
requirements.toml, MDM, or cloud config are trusted and cannot be disabled. The wrapper applies wherever those hooks run. - Cursor cloud agents run
preToolUseand do not runbeforeSubmitPrompt. 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.jsondoes not.
Muse Code has no hosted cloud-agent surface. Managed hooks from
managed_hooks_path apply to the terminal and muse exec.
auto, default, inherit, and a blank model are not members of any
allowlist unless you listed that exact token. On Cursor they are real
selector states. On Claude Code, Copilot, and Muse Code the field is
absent, which is a different case: an optional input that is absent does
not make the binding incomplete. A Cursor session on Auto is denied unless you listed
auto. That is a deliberate edit, not a default.
List canonical model ids. Matching is exact, after lowercasing. Not a prefix
and not contains. contains "fable" misses claude-fable-5 and hits any
id that happens to include those letters.
See the install sections on Secure Claude Code, Secure OpenAI Codex, Secure Cursor, and Secure Muse Code, and the starter on Coding agent policies.
Where model is filled
Section titled “Where model is filled”| Execute on | Claude Code | Codex | Cursor | Muse Code | Copilot |
|---|---|---|---|---|---|
| Model switch | to_model | event does not fire | event does not fire | event does not fire | event does not fire |
| Prompt | absent | model on UserPromptSubmit | model_id, else model, on beforeSubmitPrompt | absent | absent |
| Tool call | absent | model on PreToolUse and PermissionRequest | model_id, else model, on preToolUse | absent | absent |
Absent means the translator does not set the input. It does not set it to
"". On Cursor a blank result is the token unknown. unknown is not a
member of any allowlist unless you listed that exact token. On Muse Code
the field is absent – the translator does not invent a model.