Skip to content

Secure OpenAI Codex

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

Codex 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 Codex needs curl.

Codex has two channels for organization policy. Managed requirements are admin-enforced and users can’t override them. Hooks also load from user, project, and plugin files. Managed hooks sit above every hook a developer can write.

MechanismWhere the files goReachesRemovable by a developer
System requirements.toml/etc/codex/requirements.toml (Linux, macOS) or %ProgramData%\OpenAI\Codex\requirements.toml (Windows), plus scripts under managed_dirThat device: CLI, app, and IDE extensionLocal administrator only
MDM managed preferencesmacOS com.openai.codex requirements_toml_base64The same surfaces, redeployable on a scheduleLocal administrator only
Cloud-managed requirementsChatGPT Business or Enterprise managed-config pageEvery session that signs in with an eligible ChatGPT credentialOnly by switching provider
Repository settings.codex/hooks.json or .codex/config.toml, committedSessions in that repository, after the project .codex/ layer is trustedYes

If your developers use Codex on the web, read Cloud sessions.

Save this script next to the hook configuration. In a repository, put it at .codex/hooks/arcjet-hook.sh. For a managed install, put it at the absolute path you set as managed_dir.

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
codex:pre-tool-use)
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"arcjet-hook"}}' ;;
codex:permission-request)
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"arcjet-hook"}}}' ;;
codex:user-prompt-submit)
printf '%s\n' '{"decision":"block","reason":"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. On Windows, save a sibling arcjet-hook.ps1 and point command_windows at it:

arcjet-hook.ps1
param([string]$Vendor, [string]$Event)
$principal = if ($env:USERNAME) { $env:USERNAME } else { $env:USER }
function Deny {
switch ("$Vendor`:$Event") {
"codex:pre-tool-use" { Write-Output '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"arcjet-hook"}}' }
"codex:permission-request" { Write-Output '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"arcjet-hook"}}}' }
"codex:user-prompt-submit" { Write-Output '{"decision":"block","reason":"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. Exit 2 is what Codex treats as a deny on PreToolUse and UserPromptSubmit. A wrong key denies every Codex prompt and tool call. The script does not log the body.

This template is a complete setup. It installs every enforcement point plus the recorded events worth keeping. It omits matcher, so Arcjet sees every tool Codex reports on PreToolUse and PermissionRequest. Replace the script path if you install the files somewhere else.

PermissionRequest is the other Tool call event. It doesn’t fire for calls that never ask for approval, so it isn’t the control to rely on. PreToolUse is.

Codex 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 .codex/hooks.json
.codex/hooks.json
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex pre-tool-use",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event pre-tool-use",
"timeout": 5
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex permission-request",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event permission-request",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex user-prompt-submit",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event user-prompt-submit",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex post-tool-use",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event post-tool-use",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex stop",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event stop",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex session-start",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event session-start",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex session-end",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event session-end",
"timeout": 3
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex subagent-start",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event subagent-start",
"timeout": 5
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex subagent-stop",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event subagent-stop",
"timeout": 5
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "sh .codex/hooks/arcjet-hook.sh codex pre-compact",
"commandWindows": "powershell -NoProfile -File .codex/hooks/arcjet-hook.ps1 -Vendor codex -Event pre-compact",
"timeout": 5
}
]
}
]
}
}

The repository template uses a path relative to the project directory Codex is running in. Don’t wrap it in git rev-parse --show-toplevel: outside a git working tree that resolves to / and the hook never runs.

For a user-level file at ~/.codex/hooks.json, change each command to sh ~/.codex/hooks/arcjet-hook.sh codex EVENT.

For a managed install, put the same events in requirements.toml with absolute script paths. Codex doesn’t distribute the scripts; your MDM or device-management tooling must install them under managed_dir. Managed hooks are trusted by policy and can’t be disabled from /hooks.

Show requirements.toml
requirements.toml
allow_managed_hooks_only = true
[features]
hooks = true
[hooks]
managed_dir = "/etc/codex/hooks"
windows_managed_dir = 'C:\ProgramData\OpenAI\Codex\hooks'
[[hooks.PreToolUse]]
[[hooks.PreToolUse.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex pre-tool-use"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event pre-tool-use'
timeout = 5
[[hooks.PermissionRequest]]
[[hooks.PermissionRequest.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex permission-request"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event permission-request'
timeout = 5
[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex user-prompt-submit"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event user-prompt-submit'
timeout = 5
[[hooks.PostToolUse]]
[[hooks.PostToolUse.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex post-tool-use"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event post-tool-use'
timeout = 5
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex stop"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event stop'
timeout = 5
[[hooks.SessionStart]]
[[hooks.SessionStart.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex session-start"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event session-start'
timeout = 5
[[hooks.SessionEnd]]
[[hooks.SessionEnd.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex session-end"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event session-end'
timeout = 3
[[hooks.SubagentStart]]
[[hooks.SubagentStart.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex subagent-start"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event subagent-start'
timeout = 5
[[hooks.SubagentStop]]
[[hooks.SubagentStop.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex subagent-stop"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event subagent-stop'
timeout = 5
[[hooks.PreCompact]]
[[hooks.PreCompact.hooks]]
type = "command"
command = "sh /etc/codex/hooks/arcjet-hook.sh codex pre-compact"
command_windows = 'powershell -NoProfile -File C:\ProgramData\OpenAI\Codex\hooks\arcjet-hook.ps1 -Vendor codex -Event pre-compact'
timeout = 5

Codex launches matching command hooks for an event concurrently, 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/codex with event as a query parameter. Policy inputs identify Codex as agent_vendor: openai and agent_product: codex.
  • PreToolUse and PermissionRequest run on Tool call. UserPromptSubmit runs on Prompt. Codex honors a prompt denial. Codex doesn’t fire prompt expansion.
  • Omit matcher. A matcher that lists only Bash or apply_patch leaves MCP tools and other local function tools outside the policy.
  • Codex reports apply_patch, exec_command, spawn_agent, and update_plan. Arcjet maps those to file_write, shell, agent, and other. It also matches the shared PascalCase names such as Bash, Read, and Write.
  • timeout on every entry. Codex’s default for most hooks is 600 seconds. SessionEnd supports at most 3 seconds.
  • 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 files reach the CLI, app, IDE extension, and cloud, so a hard-coded cli mislabels most of that traffic. Arcjet records unknown when you omit the parameter. Set it only when the hook file is specific to one surface.
  • Hosted tools such as WebSearch don’t use Codex’s local function-tool hook path, so PreToolUse never sees them.

Project-local hooks load only when the project .codex/ layer is trusted. Codex skips a non-managed command hook until someone reviews and trusts the exact definition in /hooks. Managed hooks from requirements.toml, MDM, or cloud requirements are trusted by policy and can’t be disabled from the user hook browser.

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

EventDenial bodyHonored
pre-tool-use{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "RULE_ID" } }Yes
permission-request{ "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": "RULE_ID" } } }Yes
user-prompt-submit{ "decision": "block", "reason": "RULE_ID" }Yes

An allow is {}. Codex treats permissionDecision: "allow" as a grant that skips its own permission flow, and can rewrite the call when that grant carries updatedInput. The denial reason names the rule IDs that fired and nothing else.

The script reads ARCJET_KEY from the environment. Export it where Codex runs, or inject it through your configuration-management tooling. Never commit a literal key.

X-Arcjet-Principal uses $USER or $USERNAME. You don’t need to list those names in a Codex allowlist; Codex command hooks inherit the process environment.

The last keys in the managed template are the lockdown:

allow_managed_hooks_only = true
[features]
hooks = true

allow_managed_hooks_only is valid only in requirements.toml. Putting it in config.toml doesn’t enable managed-hooks-only mode.

Without allow_managed_hooks_only, a developer’s own hooks run alongside yours. Pin [features].hooks = true so a local hooks = false can’t turn the managed entries off.

A cloud session on Codex on the web runs in an environment on a clone of the repository rather than on the developer’s machine.

A cloud session reads a committed .codex/hooks.json after the project layer is trusted, and your organization’s cloud-managed requirements.

Codex environments block agent internet access by default. Turn agent internet access On, add decide.arcjet.com to the domain allowlist, and allow POST. A methods allowlist that keeps only GET, HEAD, and OPTIONS blocks the hook.

If you pin sandboxed networking with experimental_network.managed_allowed_domains_only, add decide.arcjet.com to experimental_network.allowed_domains as well.

  1. Run /hooks in Codex. Confirm the Arcjet entries appear, and trust them if Codex marked a non-managed definition for review.

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