Coding agent policies
Arcjet enforces coding agent policies by observing the actions and prompts of coding agents through hooks. Internally they’re built on top of remote agent guard policies, which means you can use Arcjet’s built-in detectors like prompt injection detection and sensitive data protection.
Policies are written in Rego, the policy language used by Arcjet’s guard system. See the Rego documentation for more details.
Create policies in the Console
Section titled “Create policies in the Console”In the Arcjet Console, go to Policies. The create page asks whether the policy guards a coding agent or an action in your own application.
Choose when the policy runs: on a Tool call, on a Prompt, or on a Model switch. A model list is a normal Rego policy with those Execute on options, not a separate org-wide setting. The hook file does not name it.
| Execute on | Runs | Rules read |
|---|---|---|
| Tool call | Before the agent runs a command, reads or writes a file, calls an MCP server, or fetches a URL | tool_name, tool_kind, command, command_tokens, paths, domains, destinations, permission_mode, mcp_server, mcp_tool, model |
| Prompt | Before text reaches the model: what the developer typed, and what a slash command expanded into | prompt, model |
| Model switch | Before Claude Code changes which model the session is using. A denial keeps the current model. Codex, Cursor, and Muse Code do not fire this. | model |
Events that share an Execute on option share inputs. model is filled
only on the vendors in Where model is filled.
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 input contract
Section titled “The input contract”Arcjet builds every input from the hook payload. Read a value in Rego as
input.values.<name>.
| Input | Kind | What it holds |
|---|---|---|
contract | String | The contract version, always coding-agent/v1 |
agent_vendor | String | anthropic, github, openai, cursor, or meta |
agent_product | String | claude-code, copilot, codex, cursor, or muse-code |
agent_surface | String | The optional surface query parameter (cli, ide, cloud), or unknown when omitted |
event | String | The lifecycle event, such as pre-tool-use |
session | String | The agent’s session ID, which also correlates the recorded activity |
principal | String | The asserted developer identity from X-Arcjet-Principal. Untrusted |
cwd | String | The agent’s working directory |
permission_mode | String | The agent’s own permission mode, when it reports one. Claude Code sends default, plan, acceptEdits, auto, dontAsk, or bypassPermissions (Manual arrives as default). Codex sends default, plan, acceptEdits, dontAsk, or bypassPermissions (no auto). Empty for Copilot, Cursor, and Muse Code, which do not report a mode on the hook payload. |
tool_name | String | The tool about to be called, verbatim |
tool_kind | String | shell, file_read, file_write, web, mcp, agent, or other |
command | String | The shell command text, for a shell call |
command_tokens | String list | The command split on whitespace and the shell’s chaining operators |
paths | String list | File paths from the call’s structured arguments |
domains | String list | Hosts of the call’s URL arguments |
destinations | String list | Hosts a call would contact: URL-argument hosts plus hosts of absolute URLs in the shell command. Possibly empty |
mcp_server | String | The MCP server name, for an MCP call |
mcp_tool | String | The MCP tool name, for an MCP call |
prompt | String | The prompt text this event carries |
model | String | The canonical id being switched to, or the id selected for the prompt or tool call. Exact match, after lowercasing. Not a prefix and not contains. Required on pre-model-switch only. |
from_model, switch source, effort, and thinking level are metadata only.
They are not inputs.
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
"". An optional input that is absent does not make the binding incomplete.
A required input that is absent does. That is why model is required only on
pre-model-switch, where Claude Code always sends to_model. Declaring it
required on Tool call or Prompt would deny every Claude Code, Copilot,
and Muse Code call.
On Cursor, model_id if it is a non-blank string, otherwise model,
lowercased. The tokens auto, default, and inherit are kept as those
tokens. A blank result is the token unknown. On Codex, missing or blank is
treated as absent, not as unknown. On Claude Code, Copilot, and Muse
Code prompt and tool events, the field is absent. Official Muse hook
docs do not publish a model field. The translator does not invent one.
contains is not used. contains "fable" misses claude-fable-5 and hits
any id that happens to include those letters.
Compare tool_kind, not tool_name
Section titled “Compare tool_kind, not tool_name”The agents spell their tools differently so Arcjet normalizes them to a consistent tool_kind. Matching is case-insensitive. A name that isn’t in this table is tool_kind: other, so a rule over the kind never matches it.
tool_kind | Claude Code and Copilot | Copilot CLI | Codex | Cursor |
|---|---|---|---|---|
shell | Bash, BashOutput, KillShell | powershell | Bash, exec_command | Shell |
file_read | Read, Glob, Grep, NotebookRead | view, rg | Read, Glob, Grep, NotebookRead | Read, Grep |
file_write | Edit, Write, MultiEdit, NotebookEdit | create, str_replace_editor, apply_patch | apply_patch, Edit, Write, MultiEdit, NotebookEdit | Write, Delete |
web | WebFetch, WebSearch | web_fetch, web_search | WebFetch (hosted WebSearch skips PreToolUse) | WebFetch, WebSearch |
mcp | Any mcp__<server>__<tool> name | Any MCP tool | Any mcp__<server>__<tool> or @<server>/<tool> name | Any namespaced MCP name |
agent | Agent, Task | Agent, Task, spawn_agent | Task | |
other | Everything else, such as AskUserQuestion and TodoWrite | ask_user, update_todo | update_plan |
Starter policies
Section titled “Starter policies”Arcjet will run every policy attached to the specific event in one round trip so you can define multiple policies for the same event. The most restrictive decision will be applied.
| Label | What it denies |
|---|---|
coding-agent.destructive-command | rm, dd, mkfs, sudo and friends in a shell call |
coding-agent.credential-access | Tool calls and commands naming credential files |
coding-agent.piped-installer | A download piped into a shell |
coding-agent.rewrite-history | git push --force and git reset --hard |
coding-agent.mcp-allowlist | MCP servers outside a list written into the policy |
coding-agent.protected-paths | Writes to CI configuration and version-control internals |
coding-agent.egress-allowlist | Fetches to hosts outside a list written into the policy |
coding-agent.destination-threat | A destination whose IP threat risk is high or critical |
coding-agent.prompt-injection | Instructions the injection detector flags in a prompt |
coding-agent.sensitive-info | Card numbers and Social Security numbers in a prompt |
coding-agent.model-allowlist | A model switch, prompt, or tool call whose model is outside the list the author wrote in Rego. Claude Code denies the switch only; Codex and Cursor deny the prompt and the tool call; Copilot and Muse Code are not covered. |
coding-agent.tainted-session | Shell, web, MCP, and file writes while any session-taint flag is active |
coding-agent.no-auto-mode | Tool calls while permission_mode is auto, bypassPermissions, or dontAsk (Claude Code and Codex; Copilot, Cursor, and Muse Code do not report a mode) |
coding-agent.aws-access | The AWS CLI (including /…/aws), AWS API hosts in destinations, and AWS credential paths / env-name markers |
See Allowed models for the coverage table. For blocking cloud CLIs and requiring human approval, see Block AWS access and Require human approval.
Every example assumes the standard preamble:
package arcjet.guard
import rego.v1Destructive shell command
Section titled “Destructive shell command”A deny over the words in a command. command_tokens is what lets a rule match
a word without a regular expression.
destructive := {"rm", "rmdir", "shred", "dd", "mkfs", "sudo", "doas", "trash"}
deny contains "destructive-command" if { input.values.tool_kind == "shell" some token in input.values.command_tokens lower(token) in destructive}Credential access
Section titled “Credential access”Two rules over one marker list: one for the files a tool call names, one for
the text of a shell command. They’re different inputs because a shell command
is opaque text, so a rule that only read paths would miss cat ~/.ssh/id_rsa.
markers := {".ssh/", "id_rsa", "id_ed25519", ".aws/credentials", "/etc/shadow", ".netrc", ".npmrc", ".env"}
deny contains "credential-path" if { some path in input.values.paths some marker in markers contains(path, marker)}
deny contains "credential-command" if { input.values.tool_kind == "shell" some marker in markers contains(input.values.command, marker)}Both rules can fire on one call, such as cp .npmrc /tmp with .npmrc also
in paths.
A download piped into a shell
Section titled “A download piped into a shell”A plain download and a pipe into jq both pass.
deny contains "piped-installer" if { input.values.tool_kind == "shell" contains(input.values.command, "|") some fetcher in input.values.command_tokens lower(fetcher) in {"curl", "wget"} some shell in input.values.command_tokens lower(shell) in {"sh", "bash", "zsh", "dash"}}A rewrite of shared history
Section titled “A rewrite of shared history”A verb plus a flag.
deny contains "force-push" if { input.values.tool_kind == "shell" contains(input.values.command, "git push") some flag in input.values.command_tokens flag in {"--force", "-f"}}
deny contains "hard-reset" if { input.values.tool_kind == "shell" contains(input.values.command, "git reset") "--hard" in input.values.command_tokens}An MCP server allowlist
Section titled “An MCP server allowlist”The allowlist is written into the policy.
deny contains "unlisted-mcp-server" if { input.values.tool_kind == "mcp" not input.values.mcp_server in {"arcjet", "github", "sentry"}}A built-in tool isn’t an MCP call, so mcp_server is empty and the rule
doesn’t fire.
Writes to protected paths
Section titled “Writes to protected paths”A deny scoped to writes.
protected := {".github/workflows/", ".git/", "/etc/", "node_modules/"}
deny contains "protected-path-write" if { input.values.tool_kind == "file_write" some path in input.values.paths some marker in protected contains(path, marker)}An outbound domain allowlist
Section titled “An outbound domain allowlist”Write the allowlist into the policy. A list the agent supplied would be a list
the agent could widen. domains holds the hosts of URL arguments. The rule
scopes to tool_kind == "web", so a URL inside a shell command does not hit
it – use threat detection
for that path.
deny contains "unlisted-domain" if { input.values.tool_kind == "web" some host in input.values.domains not host in {"docs.arcjet.com", "github.com", "raw.githubusercontent.com"}}Threat detection
Section titled “Threat detection”Deny requests to malicious external destinations like APIs, MCP servers, and other web hosts, before the agent can interact with them.
Attach this to Tool call.
deny contains "malicious-destination" if { input.signals.ip_threat.dest.risk_level in {"high", "critical"}}Policies are executed in parallel so allowlists should be defined within the same policy:
allowed := {"docs.arcjet.com", "github.com", "raw.githubusercontent.com"}
deny contains "malicious-destination" if { some a in input.signals.ip_threat.dest.assessments a.risk_level in {"high", "critical"} not a.host in allowed}For signal fields, limits, and application Guard calls, see threat detection.
Prompt injection in a developer’s prompt
Section titled “Prompt injection in a developer’s prompt”Arcjet runs prompt injection detection over the prompt input, so a hook can
refuse a turn whose instructions came from somewhere other than the developer:
a README, an issue comment, or a web page the agent pasted in.
Declare a server-side prompt injection detector with the ID injection over prompt.
prompt_events := {"user-prompt-submit", "user-prompt-expansion", "user-prompt-transformed"}
deny contains "injected-prompt" if { input.values.event in prompt_events input.signals.prompt_injection.injection.detected}Sensitive information in a developer’s prompt
Section titled “Sensitive information in a developer’s prompt”Arcjet runs server-side sensitive information detection over the prompt
input, so a hook can refuse a turn that pastes a card number or Social
Security number into the prompt.
Declare a server-side sensitive information detector with the ID pii
over prompt, denying the CREDIT_CARD_NUMBER and SSN entity types.
prompt_events := {"user-prompt-submit", "user-prompt-expansion", "user-prompt-transformed"}
deny contains "prompt-has-sensitive-info" if { input.values.event in prompt_events input.signals.sensitive_info.pii.detected}Allowed models
Section titled “Allowed models”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 model switch, prompt, or tool call whose model is outside the list in the policy. Claude Code denies the switch only; Codex and Cursor deny the prompt and the tool call; Copilot and Muse Code are not covered.
Selecting all three Execute on options is how you get that coverage. A
policy that reads model and is attached only to Tool call refuses Codex
and Cursor tools and never sees a Claude Code switch. Clearing Model
switch leaves Claude Code unenforced. Clearing Tool call and Prompt
leaves Codex and Cursor unenforced. Muse Code has no switch hook and no
published model field, so this starter does not cover it.
The set in the example is a placeholder. The three ids are one per vendor
we enforce, so you can see that the list is not Claude-only. Replace it
with the ids you allow before the rule goes live. auto is not in the
list, so a Cursor session on Auto is denied unless you listed auto.
That is a deliberate edit, not a default.
deny contains "disallowed-model" if { input.values.model not input.values.model in {"claude-sonnet-4-6", "claude-opus-4-6", "gpt-5.4"}}input.values.model is false when the input is absent or "", so Claude
Code, Copilot, and Muse Code prompt and tool events do not match. Claude
Code pre-model-switch always has the input, so an id outside the list
matches. Codex and Cursor match whenever the translated id is outside
the list.
The denial reason sent to the agent is the rule id disallowed-model and
nothing else.
See Allowed models for the coverage table and the already-selected-model gap on Claude Code.
Require human approval
Section titled “Require human approval”Claude Code and Codex report permission_mode on hook events. Claude Code
sends default, plan, acceptEdits, auto, dontAsk, and
bypassPermissions (Manual arrives as default). Codex sends the same set
without auto. auto is Claude’s autonomous / YOLO mode;
bypassPermissions and dontAsk also skip the agent’s own permission
prompts.
Publish coding-agent.no-auto-mode on Tool call to refuse those modes
and allow tool calls when the mode is default, plan, or acceptEdits
(or when the vendor does not report a mode). Copilot, Cursor, and Muse
Code leave permission_mode empty on the hook payload Arcjet receives, so
this starter does not deny their calls from mode alone. Copilot’s Bypass
Approvals / Autopilot settings and Cursor’s Run Modes are separate product
controls; enforce tool and destination policies for those agents instead.
autonomous := {"auto", "bypassPermissions", "dontAsk"}
deny contains "autonomous-mode" if { input.values.permission_mode in autonomous}Block AWS access
Section titled “Block AWS access”Agents work around a single check: rename the CLI, curl the API, or read
~/.aws/credentials. Publish coding-agent.aws-access on Tool call for
three layers in one policy:
- CLI names — the token
aws, or a path that ends with/aws. - Destinations — hosts ending with
.amazonaws.comor.aws.amazon.com, plus the apex hostsamazonaws.comandaws.amazon.comby exact match (coversWebFetchand hosts inside shell commands such ascurl). - Credential paths —
.aws/credentials,.aws/config, and env-name markers inpathsorcommand.AWS_ACCESS_KEYandAWS_SECRET_ACCESSare intentional prefixes undercontains, so they also matchAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY.
cli_names := {"aws"}
aws_hosts_suffix := {".amazonaws.com", ".aws.amazon.com"}
aws_hosts_exact := {"amazonaws.com", "aws.amazon.com"}
credential_markers := {".aws/credentials", ".aws/config", "AWS_ACCESS_KEY", "AWS_SECRET_ACCESS"}
deny contains "aws-cli" if { input.values.tool_kind == "shell" some token in input.values.command_tokens lower(token) in cli_names}
deny contains "aws-cli" if { input.values.tool_kind == "shell" some token in input.values.command_tokens endswith(lower(token), "/aws")}
deny contains "aws-destination" if { some host in input.values.destinations some marker in aws_hosts_suffix endswith(lower(host), marker)}
deny contains "aws-destination" if { some host in input.values.destinations lower(host) in aws_hosts_exact}
deny contains "aws-credential" if { some path in input.values.paths some marker in credential_markers contains(path, marker)}
deny contains "aws-credential" if { input.values.tool_kind == "shell" some marker in credential_markers contains(input.values.command, marker)}Edit the token and host sets for other clouds the same way. A binary renamed
to something that never mentions aws and never names an AWS host is outside
the CLI layer — which is why the destination and credential rules sit beside
it. For broader credential files (SSH keys, .env, .npmrc), also publish
coding-agent.credential-access.
Why layered policies
Section titled “Why layered policies”A single rule is easy to work around. Layering means each path the agent can take has its own deny:
| Layer | What it stops | Starter |
|---|---|---|
| CLI name | aws s3 ls, /usr/local/bin/aws … | coding-agent.aws-access (aws-cli) |
| Destination / API | curl https://sts.amazonaws.com, WebFetch of an AWS host | coding-agent.aws-access (aws-destination) |
| Credential files | Reading ~/.aws/credentials or naming them in a command | coding-agent.aws-access (aws-credential), plus coding-agent.credential-access for other secrets |
| Session taint | Further shell, web, MCP, and writes after injection or PII | coding-agent.tainted-session |
Publish the layers you need. Arcjet runs every policy attached to the same
Execute on option in one round trip and applies the most restrictive
decision. A denied AWS call does not by itself mark the session tainted —
publish coding-agent.tainted-session when you want risky follow-ups locked
after injection or sensitive-information findings earlier in the session.
Write your own
Section titled “Write your own”-
In the Console, go to Policies, choose the coding agent kind, and give the policy a label such as
coding-agent.no-npm-publish. -
Set what it executes on: Tool call for anything about a command, a file, a URL, or an MCP server, Prompt for anything about what the developer typed, and Model switch for a Claude Code model change. Write the allowed model ids in Rego. The hook file does not name the policy. Choose the boxes that match when you want the rule to run.
-
Add rules in the visual builder, or start from the closest starter policy and edit its Rego.
-
Add stored tests. A test input is the contract’s
values, so a test for the force-push rule looks like this:{"values": {"tool_kind": "shell","command": "git push --force origin main","command_tokens": ["git", "push", "--force", "origin", "main"]}}Assert the allow as well as the denial.
-
Publish. Every new rule starts in dry run, so publishing can’t start denying tool calls before you’ve seen it evaluate.
Roll out safely
Section titled “Roll out safely”- Publish the policy with every rule in dry run.
- Let developers work, then open the site’s Activity in the Console and read the tool calls each dry-run rule would have denied.
- Set the rule live in a second edit.
- Watch the denial reasons developers see. A denial names the rule ID, so a rule ID a developer can understand is one they can work around properly.
Related
Section titled “Related”- Secure coding agents – the endpoint, the events, and where enforcement stops
- Secure Claude Code
- Secure GitHub Copilot
- Secure Muse Code
- Secure OpenAI Codex
- Secure Cursor
- Block personal coding agent accounts
- Threat detection – Arcjet threat intel signals, allowlist bypass, and fail-closed behavior
- Write policies in Rego – the language, the profile, and its exclusions
- Author and publish policies – the builder, plain English, tests, and publication
- Policy error codes