Skip to content

Codex

Codex is the harness's second first-class agent, and the one the agent contract was frozen against. Everything on this page was measured on codex-cli 0.154.0; the adapter is pinned to that version with no version switch, which is a deliberate limit rather than an oversight.

[profiles.cx]
agent = "codex"
config_dir = "~/.codex-cx"
roots = ["~/code/experiments"]

What the harness writes

Path Owned how
$CODEX_HOME/AGENTS.md Assembled from the profile's segments; carries a GENERATED by header
$CODEX_HOME/hooks.json Shared. Merged per hook group: the harness owns only the groups it can prove are its own builtins; foreign groups and event order are preserved
$CODEX_HOME/config.toml Shared. Only [mcp_servers.*] is merged, with tomlkit

config.toml being shared is the interesting one. It is Codex's file and the user's, so the merge carries [projects.*] trust entries and [hooks.state] approvals through untouched. MCP servers go here rather than into a dedicated document — mcp_config_file() returns "" for Codex, and the planner is the oracle for the behaviour, not that method.

Hook trust

Codex requires a deployed hooks.json to be trusted before it will run the hooks in it. lh deploy prints the re-trust instruction whenever a declaration changes, and lh doctor reports the state per Codex profile.

It reports two states where Codex's own config records three — untrusted, unknown, and orphaned entries — and never reports trusted. The reason is worth stating plainly: telling trusted from modified would mean recomputing Codex's own hash, which means reimplementing its TOML normalisation and going silently wrong the first time that normalisation changes upstream. Reading trusted_hash back only proves a hash was stored, not that it still matches the current content. So a stored hash reports as unknown.

Untrusted is the expected state immediately after a deploy, so this section never fails lh doctor. It is silent when no profile runs Codex.

One measurement stands on one data point: the hashes stored by 0.154.0 were honoured by 0.155.0 without re-approval. The roadmap item re-opens on the first upgrade that asks again for an unchanged hooks.json.

Two edit paths, and only one is gateable

Codex edits files through two paths, chosen non-deterministically:

  1. apply_patch — its own tool, carrying a *** Begin Patch … *** End Patch blob. The adapter parses the blob into FileEdits, maps the tool to modify_file, and — since ADR-046 — reports deletions in a separate deletes field rather than as a kind of edit. Security hooks evaluate every path in a call, which closed a hole where a multi-file patch left the second path ungated.
  2. Bash — a heredoc, under the same tool_input.command key. This path is structurally ungateable: the input is an arbitrary shell script with no path to extract.

So a fully-correct adapter gates roughly half of Codex's edits. ADR-044 records that as a measured consequence, not a TODO.

pre-tool-use-read-size is inert on Codex for a different reason: its tool map has no read tool at all, so the operation it reasons about never arrives. lh doctor's Hook operations section names it.

Matchers are not forked per agent

The deployed PreToolUse groups keep their Claude-shaped matcher literals — Bash|Read|Edit|Write|NotebookEdit — unchanged. Probes showed Codex matches them by regex with tool-name aliasing (apply_patch matches Edit|Write) and evaluates every group rather than stopping at the first match, so there is nothing to fork (ADR-056).

That ADR is also a good example of the repo's own failure discipline. An acceptance run reported every hook approved and still failed three assertions. The cause turned out not to be trust at all: the recursive-delete rule required recursion and force, and the model had issued a spelling with only recursion. The rule now denies on recursion alone.

Metering

Codex is metered through its TranscriptReader, the same Protocol Claude Code uses — there is no dialect-specific parser in the ingest pipeline any more (ADR-053).

  • Transcripts live at $CODEX_HOME/sessions/<yyyy>/<mm>/<dd>/rollout-<ts>-<uuid>.jsonl.
  • Messages and tool calls come from the response_item stream; token usage from token_usage_record. The event_msg/* stream is skipped entirely — reading both would duplicate every fact.
  • Only user and assistant roles are kept. developer is the instruction channel, re-sent every turn.
  • Dedup keys on response_id, which arrives through the same message_id field as Claude Code's message.id.
  • since filters on the file's st_mtime. The timestamp in the filename is deliberately ignored: it is local start time with no offset.

Cost. A Codex login on a ChatGPT plan is a subscription, not per-token billing. Set billing_model = "flat_rate" on the profile and its rows render — rather than a fabricated $0.00, and stay out of unknown_models. With billing_model = "per_token" and no published rate for the model id the reader reports, the row lands in unknown_models — which is the honest outcome, not a bug. See cost reporting.

Launching

lh run --profile cx
lh run --profile cx --bypass=activate      # → --approve-for-me
lh run --profile cx --bypass=no-sandbox    # → --dangerously-bypass-approvals-and-sandbox
lh run --profile cx --bypass=enable        # refused: Codex has no such level

--bypass=enable is refused rather than answered with activate, because "available but off" and "on" are different grants. Codex's --dangerously-bypass-hook-trust is never emitted by the harness: it is a different axis — trust in the harness's own hooks, not model approval.

lh exec does not support Codex today; HeadlessAgent is implemented only by the Claude Code adapter.

Credentials

credentials_file() returns None, so session-start preflight reports n/a for Codex auth — "this check does not apply to your agent", which is a different finding from "this check could not tell".

auth.json's last_refresh is deliberately not turned into a freshness verdict. No threshold at 7, 30 or 90 days was defensible, because nothing correlates that field with expiry (ADR-057).

Known limits

Limit Why it stands
Bash edits are ungateable The tool input is an arbitrary shell script
pre-tool-use-read-size is inert No read tool in the agent's tool map
stop-verify-guard is never deployed No rollout record marks an explicit goal
Hook trust never reports trusted Would require reimplementing Codex's TOML normalisation
Pinned to codex-cli 0.154.0 No version switch; a newer dialect is unmeasured
No lh exec HeadlessAgent not implemented