Skip to content

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.

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 onRunsRules read
Tool callBefore the agent runs a command, reads or writes a file, calls an MCP server, or fetches a URLtool_name, tool_kind, command, command_tokens, paths, domains, destinations, permission_mode, mcp_server, mcp_tool, model
PromptBefore text reaches the model: what the developer typed, and what a slash command expanded intoprompt, model
Model switchBefore 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.

Arcjet builds every input from the hook payload. Read a value in Rego as input.values.<name>.

InputKindWhat it holds
contractStringThe contract version, always coding-agent/v1
agent_vendorStringanthropic, github, openai, cursor, or meta
agent_productStringclaude-code, copilot, codex, cursor, or muse-code
agent_surfaceStringThe optional surface query parameter (cli, ide, cloud), or unknown when omitted
eventStringThe lifecycle event, such as pre-tool-use
sessionStringThe agent’s session ID, which also correlates the recorded activity
principalStringThe asserted developer identity from X-Arcjet-Principal. Untrusted
cwdStringThe agent’s working directory
permission_modeStringThe 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_nameStringThe tool about to be called, verbatim
tool_kindStringshell, file_read, file_write, web, mcp, agent, or other
commandStringThe shell command text, for a shell call
command_tokensString listThe command split on whitespace and the shell’s chaining operators
pathsString listFile paths from the call’s structured arguments
domainsString listHosts of the call’s URL arguments
destinationsString listHosts a call would contact: URL-argument hosts plus hosts of absolute URLs in the shell command. Possibly empty
mcp_serverStringThe MCP server name, for an MCP call
mcp_toolStringThe MCP tool name, for an MCP call
promptStringThe prompt text this event carries
modelStringThe 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.

Execute onClaude CodeCodexCursorMuse CodeCopilot
Model switchto_modelevent does not fireevent does not fireevent does not fireevent does not fire
Promptabsentmodel on UserPromptSubmitmodel_id, else model, on beforeSubmitPromptabsentabsent
Tool callabsentmodel on PreToolUse and PermissionRequestmodel_id, else model, on preToolUseabsentabsent

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.

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_kindClaude Code and CopilotCopilot CLICodexCursor
shellBash, BashOutput, KillShellpowershellBash, exec_commandShell
file_readRead, Glob, Grep, NotebookReadview, rgRead, Glob, Grep, NotebookReadRead, Grep
file_writeEdit, Write, MultiEdit, NotebookEditcreate, str_replace_editor, apply_patchapply_patch, Edit, Write, MultiEdit, NotebookEditWrite, Delete
webWebFetch, WebSearchweb_fetch, web_searchWebFetch (hosted WebSearch skips PreToolUse)WebFetch, WebSearch
mcpAny mcp__<server>__<tool> nameAny MCP toolAny mcp__<server>__<tool> or @<server>/<tool> nameAny namespaced MCP name
agentAgent, TaskAgent, Task, spawn_agentTask
otherEverything else, such as AskUserQuestion and TodoWriteask_user, update_todoupdate_plan

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.

LabelWhat it denies
coding-agent.destructive-commandrm, dd, mkfs, sudo and friends in a shell call
coding-agent.credential-accessTool calls and commands naming credential files
coding-agent.piped-installerA download piped into a shell
coding-agent.rewrite-historygit push --force and git reset --hard
coding-agent.mcp-allowlistMCP servers outside a list written into the policy
coding-agent.protected-pathsWrites to CI configuration and version-control internals
coding-agent.egress-allowlistFetches to hosts outside a list written into the policy
coding-agent.destination-threatA destination whose IP threat risk is high or critical
coding-agent.prompt-injectionInstructions the injection detector flags in a prompt
coding-agent.sensitive-infoCard numbers and Social Security numbers in a prompt
coding-agent.model-allowlistA 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-sessionShell, web, MCP, and file writes while any session-taint flag is active
coding-agent.no-auto-modeTool 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-accessThe 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.v1

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
}

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

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.

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)
}

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"}
}

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
}

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.

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
}

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:

  1. CLI names — the token aws, or a path that ends with /aws.
  2. Destinations — hosts ending with .amazonaws.com or .aws.amazon.com, plus the apex hosts amazonaws.com and aws.amazon.com by exact match (covers WebFetch and hosts inside shell commands such as curl).
  3. Credential paths — .aws/credentials, .aws/config, and env-name markers in paths or command. AWS_ACCESS_KEY and AWS_SECRET_ACCESS are intentional prefixes under contains, so they also match AWS_ACCESS_KEY_ID and AWS_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.

A single rule is easy to work around. Layering means each path the agent can take has its own deny:

LayerWhat it stopsStarter
CLI nameaws s3 ls, /usr/local/bin/aws …coding-agent.aws-access (aws-cli)
Destination / APIcurl https://sts.amazonaws.com, WebFetch of an AWS hostcoding-agent.aws-access (aws-destination)
Credential filesReading ~/.aws/credentials or naming them in a commandcoding-agent.aws-access (aws-credential), plus coding-agent.credential-access for other secrets
Session taintFurther shell, web, MCP, and writes after injection or PIIcoding-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.

  1. In the Console, go to Policies, choose the coding agent kind, and give the policy a label such as coding-agent.no-npm-publish.

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

  3. Add rules in the visual builder, or start from the closest starter policy and edit its Rego.

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

  5. Publish. Every new rule starts in dry run, so publishing can’t start denying tool calls before you’ve seen it evaluate.

  1. Publish the policy with every rule in dry run.
  2. Let developers work, then open the site’s Activity in the Console and read the tool calls each dry-run rule would have denied.
  3. Set the rule live in a second edit.
  4. 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.