Codex Integration

July 11, 2026 · View on GitHub

Last updated: 2026-07-11

This document is the maintainer reference for dcg's Codex CLI hook path. It explains how dcg distinguishes Codex from Claude-compatible hook payloads, why Codex denials use a minimal stdout JSON contract, and how to debug a hook run that Codex reports as failed instead of blocked.

Protocol Detection

Codex CLI 0.125.0+ sends the same basic hook payload shape as Claude Code for shell commands: tool_name, tool_input.command, hook event metadata, and a tool-use identifier. dcg must therefore avoid treating every Bash hook as Codex. The discriminator is Codex's turn_id field.

The rule in src/hook.rs:detect_protocol is intentionally narrow:

  • A shell tool (Bash, bash, or launch-process) with a non-empty turn_id is treated as HookProtocol::Codex.
  • A shell tool with tool_use_id but no turn_id stays on the Claude-compatible JSON path.
  • Non-shell tools do not become Codex just because a turn_id field is present.
  • Copilot and Gemini envelope detection runs before the Codex check so their protocol-specific handling still wins.

The important regression is the Claude-shaped payload that includes tool_use_id but not turn_id. If that ever flips to the Codex path, Claude Code would stop receiving the structured JSON denial it expects.

Coverage lives in two layers:

  • src/hook.rs unit tests cover protocol detection and output dispatch.
  • tests/codex_hook_protocol.rs runs the compiled dcg binary against Codex-shaped hook payloads and verifies process exit codes, stdout, stderr, allowlists, allow-once codes, pack enablement, history writes, and heredoc behavior.

Deny Contract

Claude-compatible hooks receive a structured JSON denial on stdout. That JSON contains fields dcg users and agents rely on, including hookSpecificOutput, ruleId, packId, severity, confidence, allowOnceCode, and remediation.

Codex's hook output parser is stricter. The Codex deny parser can reject unknown fields, so sending dcg's extended Claude-compatible payload can turn a policy decision into a PreToolUse Failed event instead of a blocked command. dcg therefore emits only Codex's documented fields:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by dcg."
  }
}

The process exits 0. stderr still contains the human-readable warning for an operator, but Codex's blocking decision comes from the JSON on stdout. This is important on Codex 0.144.x for Windows: a verified report showed the legacy exit-2 path being classified as PreToolUse Failed and then failing open, whereas the minimal JSON denial blocks correctly (#183).

The implementation points are:

  • src/hook.rs:output_denial_for_protocol selects the minimal Codex JSON shape.
  • The deny branch in src/main.rs returns normally, so pending history writes flush through HistoryWriter::Drop.
  • src/hook.rs keeps each other agent's established protocol-specific output path unchanged.

The exit-code split is intentional:

Casestdoutstderrexit
Allow under any protocolemptyempty0
Claude-compatible denyJSON denialwarning text0
Codex denyminimal JSON denialwarning text0
Parse/config/runtime erroroptional error outputerror details1 or 2

For Codex hook integrations, parse the minimal stdout JSON. Empty stdout with exit 0 still means allow.

Manual Protocol Probe

Use a throwaway repository when testing real destructive commands through an agent. For a cheap protocol-shape probe, you can pipe a Codex-shaped hook payload directly into a dcg binary without asking Codex to run anything:

printf '%s\n' \
  '{"session_id":"s","turn_id":"turn-1","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"},"tool_use_id":"call-1"}' \
  | ./target/release/dcg >/tmp/dcg-codex-stdout.txt 2>/tmp/dcg-codex-stderr.txt
echo "exit=$?"
wc -c /tmp/dcg-codex-stdout.txt /tmp/dcg-codex-stderr.txt

Expected result:

  • exit code is 0;
  • stdout contains a three-field hookSpecificOutput denial;
  • stderr is non-empty and mentions the blocked command plus the matching rule.

For a Claude-compatible negative control, remove turn_id from the same payload. The denial should return exit code 0 with a JSON object on stdout.

Troubleshooting

Codex Reports PreToolUse Failed

This usually means Codex could not interpret the hook result as a valid Codex block. Check these in order:

  1. Confirm the hook command in ~/.codex/hooks.json points to the intended dcg binary and that the binary exists.
  2. Confirm the binary is executable and runs from the same shell environment Codex uses.
  3. Confirm codex --version reports 0.125.0 or newer.
  4. Run the manual protocol probe above. The Codex payload must contain only hookEventName, permissionDecision, and permissionDecisionReason inside hookSpecificOutput; dcg-only metadata belongs only on tolerant protocols.
  5. If stderr is empty on a destructive command, inspect src/hook.rs output dispatch and src/main.rs deny handling before looking at installer code.

Codex Runs The Command After A Denial

Look for a failed-hook symptom first. A failed hook is not the same as a blocked hook. The common causes are an old dcg binary, stale hook configuration, or a hook output shape that no longer matches Codex's parser.

Also open /hooks once in Codex and trust the installed hook. Current Codex can silently skip an untrusted user hook, which is observationally similar to a failed-open hook.

The real-Codex harness checks the smoking-gun condition directly: after Codex is asked to run a destructive command, the test verifies the repository state is unchanged and the Codex log includes hook: PreToolUse Blocked.

Safe Commands Emit dcg Text

Allowed commands must be silent. Under Codex, git status and other safe commands should return exit code 0 with empty stdout and empty stderr from dcg. If Codex displays dcg text for an allowed command, inspect warning-mode routing and any environment variables that force diagnostic output.

Allow-Once Or Allowlists Do Not Apply

Codex uses the same evaluation, allowlist, pack, and allow-once logic as the Claude-compatible path. Only the final hook output contract changes. Check:

  • DCG_CONFIG, DCG_PACKS, and DCG_DISABLE are visible to the hook process;
  • the project/user/system allowlist file being edited is the one dcg loads;
  • the pending exception store is under the same home/project context that the hook process sees;
  • tests/codex_hook_protocol.rs still passes the allowlist and allow-once round-trip tests.

Installer And CI Surfaces

Installer support is split by platform:

  • install.sh:configure_codex merges a dcg PreToolUse Bash hook into ~/.codex/hooks.json when Codex is detected.
  • uninstall.sh:unconfigure_codex removes only dcg-owned Codex hooks and preserves unrelated user hooks.
  • install.ps1 and uninstall.ps1 provide the same ownership-preserving behavior for %USERPROFILE%\.codex\hooks.json on Windows.

CI covers Codex without making every pull request depend on a live Codex account:

  • The normal check job runs cargo nextest run, which includes tests/codex_hook_protocol.rs.
  • The coverage job enforces the project thresholds and keeps src/hook.rs coverage visible.
  • The push-only codex-e2e job builds dcg, installs Codex when CODEX_API_KEY is configured, authenticates, and runs scripts/e2e_codex.sh.
  • The real-Codex job exits cleanly with a clear skip when Codex is unavailable, unauthenticated, quota-limited, or temporarily unable to reach the API.

Do not make PR CI require live Codex network access. Subprocess protocol tests are the PR gate; the real-Codex harness is a push-to-main smoke layer.

Performance Notes

Codex does not get a separate matching engine. The hot path remains the same: parse, quick reject, normalize, safe patterns, destructive patterns, then output formatting. The Codex-specific work happens after the decision, where dcg chooses minimal JSON output for denials.

Performance-sensitive changes should keep these properties:

  • allowed commands stay silent and fast;
  • protocol detection stays O(1) over parsed hook metadata;
  • Codex serialization stays minimal and excludes dcg-only fields;
  • history writes flush through normal scope teardown rather than a special process-exit path.

The codex_deny benchmark exists to catch regressions in the Codex denial path.

Migration Notes

For existing users upgrading from older dcg versions:

  1. Upgrade the dcg binary first.
  2. Re-run the installer so ~/.codex/hooks.json points to the upgraded binary.
  3. Confirm Codex is 0.125.0 or newer.
  4. Run codex login status if you plan to use the real-Codex e2e harness.
  5. Run the manual protocol probe above before testing against a real repository.

If Codex has stale hooks that still point to an old binary, the safest fix is to run dcg's installer or uninstaller. They update only dcg-owned hook entries and preserve coexisting hooks.

Known Limitation: Codex unified_exec Path (Windows Desktop / CLI)

Codex's PreToolUse hook dispatch does not intercept every shell call. Per OpenAI's hook docs: PreToolUse "doesn't intercept all shell calls yet, only the simple ones. The newer unified_exec mechanism allows richer streaming stdin/stdout handling of shell, but interception is incomplete." (https://developers.openai.com/codex/hooks)

This is the root cause behind the unresolved part of issue #125 (Windows Codex Desktop / codex exec). On that path Codex routes the command through unified_exec and may emit a command_execution event with a wrapped PowerShell invocation, e.g.:

{
  "type": "command_execution",
  "command": "\"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -Command 'git reset --hard HEAD~1'"
}

command_execution is not a tool-use event, so PreToolUse never fires for it — regardless of the matcher value. This was confirmed empirically: a reporter tested matcher: "Bash", matcher: "command_execution", and matcher: "*" (wildcard) and none fired for the command_execution path. The hook command is never invoked, so dcg never sees the payload.

Why the matcher is Bash and stays Bash

The matcher field is a regex applied to tool_name, and Codex's canonical shell tool_name is Bash — there is no shell_command alias. (Codex's docs: "Currently, the tool name is always \"Bash\" in Codex CLI"; matcher "is applied to tool_name".) When Codex does dispatch a shell call through PreToolUse (the "simple" path), the payload reports tool_name: "Bash", so the installer's matcher: "Bash" is correct. The Desktop runtime log line tool_name="shell_command" the reporter observed comes from the unified_exec/command_execution runtime internals — not from a PreToolUse payload that dcg would ever receive, and not a matcher dcg can usefully target (the hook isn't dispatched at all on that path).

Changing the installed matcher to shell_command would therefore be a regression: it would fail to match the canonical Bash payload on the path where hooks do fire, while still not helping the unified_exec path (where no hook fires under any matcher). The fix has to land upstream in Codex (extend PreToolUse dispatch to cover unified_exec/command_execution).

Upstream tracking:

dcg behavior under this gap is fail-open by construction: when Codex routes a command through unified_exec/command_execution, no PreToolUse hook fires, so dcg is simply never invoked — it cannot block what it never sees, and it neither crashes nor interferes. The simple per-tool shell path (Codex's Bash / PowerShell-named payload) is intercepted (it dispatches as HookProtocol::Codex, the minimal-JSON deny path). See also the Windows limitations summary in docs/windows.md.

dcg-side state (already correct)

The dcg engine and its installed hook config are correct for every path Codex does route through PreToolUse:

  • The PowerShell-wrapped command form (powershell.exe -Command '...', pwsh -c, quoted-full-path variants, cmd /c) is unwrapped and re-evaluated by the inline-script extractor (commit 57ec7ec), so a wrapped destructive command that reaches dcg is blocked (verified by direct payload).
  • ~/.codex/hooks.json is written as UTF-8 without a BOM on Windows (commits 17746e8, 5703a8a), so Codex's strict JSON parser accepts it.
  • The matcher is Bash (the canonical shell tool_name).

No further dcg-side change can make the unified_exec path block until Codex fires PreToolUse for it. Until then, treat Codex hooks as a guardrail that covers the simple-shell path, not a complete enforcement boundary on Windows Desktop / codex exec — consistent with the existing "the model can still write scripts to disk to bypass hook-based blocking" caveat.

Verifying It Works

Before closing Codex hook work, collect evidence for the relevant layer:

  • cargo test --test codex_hook_protocol passes.
  • cargo test --lib hook:: passes when protocol detection or output dispatch changes.
  • cargo check --all-targets passes.
  • cargo clippy --all-targets -- -D warnings passes.
  • The manual protocol probe returns exit code 0, minimal denial JSON on stdout, and non-empty stderr for a destructive Codex-shaped payload.
  • scripts/e2e_codex.sh --verbose --json --artifacts <dir> --dcg-binary <path> either passes against an authenticated Codex CLI or exits successfully with an explicit skip reason.
  • README's Codex CLI note links back to this document.
  • AGENTS.md states that a Codex deny uses the minimal JSON contract and that empty stdout with exit 0 remains the allow signal.